[ResponseOps][MW] Remove mw category selection from UI (#211793)

Fix: https://github.com/elastic/kibana/issues/197530
Fix: https://github.com/elastic/kibana/issues/212857

## Summary
I did from DOD:
- Remove the category selection from the UI when creating a MW.
- Show the section only if Filter alerts is ON.
- The terminology should change from category to Solution.
- Show a warning callout to users when editing a MW if they have
configured the categories and inform them that if upon saving the
category configuration will be removed

What'll be covered in follow up PR:
- Show only two solutions, O11y and Security. O11y will also include
Stack.


### Checklist

Check the PR satisfies following conditions. 

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
This commit is contained in:
Julia 2025-03-21 13:09:32 +01:00 committed by GitHub
parent f5cebe2c23
commit 8aa7d8b0a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 678 additions and 462 deletions

View file

@ -10655,13 +10655,6 @@
"xpack.alerting.maintenanceWindows.create.maintenanceWindow": "Créer la fenêtre de maintenance",
"xpack.alerting.maintenanceWindows.createForm.byweekdayFieldRequiredError": "Un jour ouvré est nécessaire.",
"xpack.alerting.maintenanceWindows.createForm.cancel": "Annuler",
"xpack.alerting.maintenanceWindows.createForm.categoriesSelection.description": "Seules les règles associées avec les catégories sélectionnées sont concernées par la fenêtre de maintenance.",
"xpack.alerting.maintenanceWindows.createForm.categoriesSelection.title": "Fenêtre de maintenance spécifique à la catégorie",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.observabilityRules": "Règles d'Observability",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.required": "Une catégorie est requise.",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.securityRules": "Règles de sécurité",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.stackRules": "Règles de la Suite Elastic",
"xpack.alerting.maintenanceWindows.createForm.categorySelection.checkboxGroupTitle": "Sélectionner les catégories qui devraient être concernées",
"xpack.alerting.maintenanceWindows.createForm.count.after": "Après",
"xpack.alerting.maintenanceWindows.createForm.count.occurrence": "occurrence",
"xpack.alerting.maintenanceWindows.createForm.countFieldRequiredError": "Un nombre est requis.",

View file

@ -10646,13 +10646,6 @@
"xpack.alerting.maintenanceWindows.create.maintenanceWindow": "保守時間枠を作成",
"xpack.alerting.maintenanceWindows.createForm.byweekdayFieldRequiredError": "曜日は必須です。",
"xpack.alerting.maintenanceWindows.createForm.cancel": "キャンセル",
"xpack.alerting.maintenanceWindows.createForm.categoriesSelection.description": "選択したカテゴリーに関連するルールのみがメンテナンスウィンドウの影響を受けます。",
"xpack.alerting.maintenanceWindows.createForm.categoriesSelection.title": "カテゴリー固有のメンテナンスウィンドウ",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.observabilityRules": "オブザーバビリティルール",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.required": "カテゴリーは必須です。",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.securityRules": "セキュリティルール",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.stackRules": "スタックルール",
"xpack.alerting.maintenanceWindows.createForm.categorySelection.checkboxGroupTitle": "これが影響するカテゴリーを選択",
"xpack.alerting.maintenanceWindows.createForm.count.after": "後",
"xpack.alerting.maintenanceWindows.createForm.count.occurrence": "発生",
"xpack.alerting.maintenanceWindows.createForm.countFieldRequiredError": "カウントが必要です。",

View file

@ -10663,13 +10663,6 @@
"xpack.alerting.maintenanceWindows.create.maintenanceWindow": "创建维护窗口",
"xpack.alerting.maintenanceWindows.createForm.byweekdayFieldRequiredError": "“工作日”必填。",
"xpack.alerting.maintenanceWindows.createForm.cancel": "取消",
"xpack.alerting.maintenanceWindows.createForm.categoriesSelection.description": "仅与选定类别关联的规则受维护窗口影响。",
"xpack.alerting.maintenanceWindows.createForm.categoriesSelection.title": "特定于类别的维护窗口",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.observabilityRules": "Observability 规则",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.required": "“类别”必填。",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.securityRules": "安全规则",
"xpack.alerting.maintenanceWindows.createForm.categoryIds.stackRules": "Stack 规则",
"xpack.alerting.maintenanceWindows.createForm.categorySelection.checkboxGroupTitle": "选择此项应影响的类别",
"xpack.alerting.maintenanceWindows.createForm.count.after": "之后",
"xpack.alerting.maintenanceWindows.createForm.count.occurrence": "发生",
"xpack.alerting.maintenanceWindows.createForm.countFieldRequiredError": "“计数”必填。",

View file

@ -31,6 +31,9 @@ jest.mock('../utils/kibana_react', () => {
jest.mock('../services/maintenance_windows_api/get', () => ({
getMaintenanceWindow: jest.fn(),
}));
jest.mock('../pages/maintenance_windows/helpers/convert_from_maintenance_window_to_form', () => ({
convertFromMaintenanceWindowToForm: jest.fn(),
}));
const { getMaintenanceWindow } = jest.requireMock('../services/maintenance_windows_api/get');
@ -52,4 +55,64 @@ describe('useGetMaintenanceWindow', () => {
await waitFor(() => expect(mockAddDanger).toBeCalledWith('Unable to get maintenance window.'));
});
it('should return an object where showMultipleSolutionsWarning is false when disabled scoped query filter', async () => {
getMaintenanceWindow.mockResolvedValue({
categoryIds: ['observability', 'management', 'securitySolution'],
scopedQuery: undefined,
});
const { result } = renderHook(() => useGetMaintenanceWindow('testId'), {
wrapper: appMockRenderer.AppWrapper,
});
await waitFor(() =>
expect(result.current).toEqual({
showMultipleSolutionsWarning: false,
isError: false,
isLoading: false,
maintenanceWindow: undefined,
})
);
});
it('should return object with a hasOldChosenSolutions is true when disabled scoped query filter', async () => {
getMaintenanceWindow.mockResolvedValue({
categoryIds: ['observability', 'management'],
scopedQuery: undefined,
});
const { result } = renderHook(() => useGetMaintenanceWindow('testId'), {
wrapper: appMockRenderer.AppWrapper,
});
await waitFor(() =>
expect(result.current).toEqual({
showMultipleSolutionsWarning: true,
isError: false,
isLoading: false,
maintenanceWindow: undefined,
})
);
});
it('should return an object where a hasOldChosenSolutions is false if scopedQuery is defined', async () => {
getMaintenanceWindow.mockResolvedValue({
categoryIds: ['observability'],
scopedQuery: { filter: 'filter' },
});
const { result } = renderHook(() => useGetMaintenanceWindow('testId'), {
wrapper: appMockRenderer.AppWrapper,
});
await waitFor(() =>
expect(result.current).toEqual({
showMultipleSolutionsWarning: false,
isError: false,
isLoading: false,
maintenanceWindow: undefined,
})
);
});
});

View file

@ -19,7 +19,17 @@ export const useGetMaintenanceWindow = (maintenanceWindowId: string) => {
const queryFn = async () => {
const maintenanceWindow = await getMaintenanceWindow({ http, maintenanceWindowId });
return convertFromMaintenanceWindowToForm(maintenanceWindow);
const hasScopedQuery = !!maintenanceWindow.scopedQuery;
const hasOldCategorySettings = maintenanceWindow.categoryIds
? maintenanceWindow.categoryIds.length > 0 && maintenanceWindow.categoryIds.length < 3
: false;
const showMultipleSolutionsWarning = !hasScopedQuery && hasOldCategorySettings;
return {
maintenanceWindow: convertFromMaintenanceWindowToForm(maintenanceWindow),
showMultipleSolutionsWarning,
};
};
const onErrorFn = () => {
@ -40,7 +50,8 @@ export const useGetMaintenanceWindow = (maintenanceWindowId: string) => {
});
return {
maintenanceWindow: data,
maintenanceWindow: data?.maintenanceWindow,
showMultipleSolutionsWarning: data?.showMultipleSolutionsWarning,
isLoading: isLoading || isInitialLoading,
isError,
};

View file

@ -6,7 +6,8 @@
*/
import React from 'react';
import { within, fireEvent, waitFor } from '@testing-library/react';
import { within, waitFor, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { AppMockRenderer } from '../../../lib/test_utils';
import { createAppMockRenderer } from '../../../lib/test_utils';
import type { CreateMaintenanceWindowFormProps } from './create_maintenance_windows_form';
@ -16,6 +17,10 @@ jest.mock('../../../utils/kibana_react');
jest.mock('../../../services/rule_api', () => ({
loadRuleTypes: jest.fn(),
}));
jest.mock('@kbn/alerts-ui-shared', () => ({
...jest.requireActual('@kbn/alerts-ui-shared'),
AlertsSearchBar: () => <div data-test-subj="mockAlertsSearchBar" />,
}));
const { loadRuleTypes } = jest.requireMock('../../../services/rule_api');
const { useKibana, useUiSetting } = jest.requireMock('../../../utils/kibana_react');
@ -25,6 +30,24 @@ const formProps: CreateMaintenanceWindowFormProps = {
onSuccess: jest.fn(),
};
const formPropsForEditMode: CreateMaintenanceWindowFormProps = {
onCancel: jest.fn(),
onSuccess: jest.fn(),
initialValue: {
title: 'test',
startDate: '2023-03-24',
endDate: '2023-03-26',
recurring: false,
solutionId: 'observability',
scopedQuery: {
kql: 'kibana.alert.job_errors_results.job_id : * ',
filters: [],
dsl: '{"bool":{"must":[],"filter":[{"bool":{"should":[{"exists":{"field":"kibana.alert.job_errors_results.job_id"}}],"minimum_should_match":1}}],"should":[],"must_not":[]}}',
},
},
maintenanceWindowId: 'fake_mw_id',
};
describe('CreateMaintenanceWindowForm', () => {
let appMockRenderer: AppMockRenderer;
@ -49,6 +72,13 @@ describe('CreateMaintenanceWindowForm', () => {
SearchBar: <div />,
},
},
data: {
dataViews: {
get: jest.fn(),
getIdsWithTitle: jest.fn().mockResolvedValue([]),
getDefaultDataView: jest.fn(),
},
},
},
});
@ -68,7 +98,6 @@ describe('CreateMaintenanceWindowForm', () => {
expect(result.getByTestId('title-field')).toBeInTheDocument();
expect(result.getByTestId('date-field')).toBeInTheDocument();
expect(result.getByTestId('recurring-field')).toBeInTheDocument();
expect(result.getByTestId('maintenanceWindowCategorySelection')).toBeInTheDocument();
expect(result.queryByTestId('recurring-form')).not.toBeInTheDocument();
expect(result.queryByTestId('timezone-field')).not.toBeInTheDocument();
});
@ -87,7 +116,6 @@ describe('CreateMaintenanceWindowForm', () => {
expect(result.getByTestId('title-field')).toBeInTheDocument();
expect(result.getByTestId('date-field')).toBeInTheDocument();
expect(result.getByTestId('recurring-field')).toBeInTheDocument();
expect(result.getByTestId('maintenanceWindowCategorySelection')).toBeInTheDocument();
expect(result.queryByTestId('recurring-form')).not.toBeInTheDocument();
expect(result.getByTestId('timezone-field')).toBeInTheDocument();
});
@ -128,7 +156,7 @@ describe('CreateMaintenanceWindowForm', () => {
endDate: '2023-03-26',
timezone: ['America/Los_Angeles'],
recurring: true,
categoryIds: [],
solutionId: undefined,
}}
/>
);
@ -147,25 +175,6 @@ describe('CreateMaintenanceWindowForm', () => {
'comboBoxSearchInput'
);
await waitFor(() => {
expect(
result.queryByTestId('maintenanceWindowCategorySelectionLoading')
).not.toBeInTheDocument();
});
const observabilityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-observability');
const securityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-securitySolution');
const managementInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-management');
expect(observabilityInput).toBeChecked();
expect(securityInput).toBeChecked();
expect(managementInput).toBeChecked();
expect(titleInput).toHaveValue('test');
expect(dateInputs[0]).toHaveValue('2023.03.23, 9:00:00');
expect(dateInputs[1]).toHaveValue('2023.03.25, 9:00:00');
@ -173,8 +182,8 @@ describe('CreateMaintenanceWindowForm', () => {
expect(timezoneInput).toHaveValue('America/Los_Angeles');
});
it('should initialize MWs without category ids properly', async () => {
const result = appMockRenderer.render(
it('should initialize MW with selected solution id properly', async () => {
appMockRenderer.render(
<CreateMaintenanceWindowForm
{...formProps}
initialValue={{
@ -183,102 +192,98 @@ describe('CreateMaintenanceWindowForm', () => {
endDate: '2023-03-26',
timezone: ['America/Los_Angeles'],
recurring: true,
solutionId: 'observability',
scopedQuery: {
filters: [],
kql: 'kibana.alert.job_errors_results.job_id : * ',
dsl: '{"bool":{"must":[],"filter":[{"bool":{"should":[{"exists":{"field":"kibana.alert.job_errors_results.job_id"}}],"minimum_should_match":1}}],"should":[],"must_not":[]}}',
},
}}
/>
);
await waitFor(() => {
expect(
result.queryByTestId('maintenanceWindowCategorySelectionLoading')
screen.queryByTestId('maintenanceWindowSolutionSelectionLoading')
).not.toBeInTheDocument();
});
const observabilityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-observability');
const securityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-securitySolution');
const managementInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-management');
expect(observabilityInput).toBeChecked();
expect(securityInput).toBeChecked();
expect(managementInput).toBeChecked();
});
it('should initialize MWs with selected category ids properly', async () => {
const result = appMockRenderer.render(
<CreateMaintenanceWindowForm
{...formProps}
initialValue={{
title: 'test',
startDate: '2023-03-24',
endDate: '2023-03-26',
timezone: ['America/Los_Angeles'],
recurring: true,
categoryIds: ['observability', 'management'],
}}
maintenanceWindowId="test"
/>
);
await waitFor(() => {
expect(
result.queryByTestId('maintenanceWindowCategorySelectionLoading')
).not.toBeInTheDocument();
expect(screen.getByTestId('maintenanceWindowSolutionSelection')).toBeInTheDocument();
});
const observabilityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-observability');
const securityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-securitySolution');
const managementInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-management');
const observabilityInput = screen.getByLabelText('Observability rules');
const securityInput = screen.getByLabelText('Security rules');
const managementInput = screen.getByLabelText('Stack rules');
expect(observabilityInput).toBeChecked();
expect(managementInput).toBeChecked();
expect(managementInput).not.toBeChecked();
expect(securityInput).not.toBeChecked();
});
it('can select category IDs', async () => {
it('can select one in the time solution id', async () => {
const user = userEvent.setup();
const result = appMockRenderer.render(<CreateMaintenanceWindowForm {...formProps} />);
await waitFor(() => {
expect(
result.queryByTestId('maintenanceWindowCategorySelectionLoading')
result.queryByTestId('maintenanceWindowSolutionSelectionLoading')
).not.toBeInTheDocument();
});
const observabilityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-observability');
const securityInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-securitySolution');
const managementInput = within(
result.getByTestId('maintenanceWindowCategorySelection')
).getByTestId('option-management');
await waitFor(() => {
expect(screen.queryByTestId('maintenanceWindowSolutionSelection')).not.toBeInTheDocument();
});
expect(observabilityInput).toBeChecked();
expect(securityInput).toBeChecked();
expect(managementInput).toBeChecked();
const switchContainer = screen.getByTestId('maintenanceWindowScopedQuerySwitch');
const scopedQueryToggle = within(switchContainer).getByRole('switch');
fireEvent.click(observabilityInput);
expect(scopedQueryToggle).not.toBeChecked();
await user.click(scopedQueryToggle);
expect(scopedQueryToggle).toBeChecked();
const observabilityInput = screen.getByLabelText('Observability rules');
const securityInput = screen.getByLabelText('Security rules');
const managementInput = screen.getByLabelText('Stack rules');
await user.click(securityInput);
expect(observabilityInput).not.toBeChecked();
expect(managementInput).not.toBeChecked();
expect(securityInput).toBeChecked();
expect(managementInput).toBeChecked();
fireEvent.click(securityInput);
fireEvent.click(observabilityInput);
await user.click(observabilityInput);
expect(observabilityInput).toBeChecked();
expect(managementInput).not.toBeChecked();
expect(securityInput).not.toBeChecked();
expect(managementInput).toBeChecked();
});
it('should hide "Filter alerts" toggle when do not have access to any solutions', async () => {
loadRuleTypes.mockResolvedValue([]);
const result = appMockRenderer.render(<CreateMaintenanceWindowForm {...formProps} />);
await waitFor(() => {
expect(
result.queryByTestId('maintenanceWindowSolutionSelectionLoading')
).not.toBeInTheDocument();
});
expect(screen.queryByTestId('maintenanceWindowScopedQuerySwitch')).not.toBeInTheDocument();
});
it('should show warning when edit if "Filter alerts" toggle on when do not have access to chosen solution', async () => {
loadRuleTypes.mockResolvedValue([]);
const result = appMockRenderer.render(
<CreateMaintenanceWindowForm {...formPropsForEditMode} />
);
await waitFor(() => {
expect(
result.queryByTestId('maintenanceWindowSolutionSelectionLoading')
).not.toBeInTheDocument();
});
expect(screen.getByTestId('maintenanceWindowNoAvailableSolutionsWarning')).toBeInTheDocument();
});
});

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useState, useRef, useEffect } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import moment from 'moment';
import type { FormSubmitHandler } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import {
@ -30,7 +30,6 @@ import {
EuiTextColor,
} from '@elastic/eui';
import { TIMEZONE_OPTIONS as UI_TIMEZONE_OPTIONS } from '@kbn/core-ui-settings-common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common';
import type { Filter } from '@kbn/es-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { KibanaServerError } from '@kbn/kibana-utils-plugin/public';
@ -47,18 +46,13 @@ import { useGetRuleTypes } from '../../../hooks/use_get_rule_types';
import { useUiSetting } from '../../../utils/kibana_react';
import { DatePickerRangeField } from './fields/date_picker_range_field';
import { useArchiveMaintenanceWindow } from '../../../hooks/use_archive_maintenance_window';
import { MaintenanceWindowCategorySelection } from './maintenance_window_category_selection';
import { MaintenanceWindowSolutionSelection } from './maintenance_window_category_selection';
import { MaintenanceWindowScopedQuerySwitch } from './maintenance_window_scoped_query_switch';
import { MaintenanceWindowScopedQuery } from './maintenance_window_scoped_query';
import { VALID_CATEGORIES } from '../constants';
const UseField = getUseField({ component: Field });
const VALID_CATEGORIES = [
DEFAULT_APP_CATEGORIES.observability.id,
DEFAULT_APP_CATEGORIES.security.id,
DEFAULT_APP_CATEGORIES.management.id,
];
export interface CreateMaintenanceWindowFormProps {
onCancel: () => void;
onSuccess: () => void;
@ -92,8 +86,6 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
(initialValue?.scopedQuery?.filters as Filter[]) || []
);
const [scopedQueryErrors, setScopedQueryErrors] = useState<string[]>([]);
const hasSetInitialCategories = useRef<boolean>(false);
const categoryIdsHistory = useRef<string[]>([]);
const isEditMode = initialValue !== undefined && maintenanceWindowId !== undefined;
@ -148,6 +140,17 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
};
}, [isScopedQueryEnabled, query, filters]);
const validRuleTypes = useMemo(() => {
if (!ruleTypes) {
return [];
}
return ruleTypes.filter((ruleType) => VALID_CATEGORIES.includes(ruleType.category));
}, [ruleTypes]);
const availableSolutions = useMemo(() => {
return [...new Set(validRuleTypes.map((ruleType) => ruleType.category))];
}, [validRuleTypes]);
const submitMaintenanceWindow = useCallback<FormSubmitHandler<FormProps>>(
async (formData, isValid) => {
if (!isValid || scopedQueryErrors.length !== 0) {
@ -169,8 +172,8 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
formData.timezone ? formData.timezone[0] : defaultTimezone,
formData.recurringSchedule
),
categoryIds: formData.categoryIds,
scopedQuery: scopedQueryPayload,
categoryIds: formData.solutionId ? [formData.solutionId] : null,
scopedQuery: availableSolutions.length !== 0 ? scopedQueryPayload : null,
};
if (isEditMode) {
@ -183,28 +186,29 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
}
},
[
isEditMode,
scopedQueryErrors.length,
isScopedQueryEnabled,
scopedQueryErrors,
maintenanceWindowId,
updateMaintenanceWindow,
createMaintenanceWindow,
onSuccess,
defaultTimezone,
scopedQueryPayload,
defaultTimezone,
availableSolutions.length,
isEditMode,
updateMaintenanceWindow,
maintenanceWindowId,
onSuccess,
createMaintenanceWindow,
]
);
const { form } = useForm<FormProps>({
defaultValue: initialValue,
options: { stripEmptyFields: false },
options: { stripEmptyFields: true },
schema,
onSubmit: submitMaintenanceWindow,
});
const [{ recurring, timezone, categoryIds }, _, mounted] = useFormData<FormProps>({
const [{ recurring, timezone, solutionId }, _, mounted] = useFormData<FormProps>({
form,
watch: ['recurring', 'timezone', 'categoryIds', 'scopedQuery'],
watch: ['recurring', 'timezone', 'solutionId', 'scopedQuery'],
});
const isRecurring = recurring || false;
@ -215,53 +219,42 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
const { setFieldValue } = form;
const validRuleTypes = useMemo(() => {
if (!ruleTypes) {
return [];
}
return ruleTypes.filter((ruleType) => VALID_CATEGORIES.includes(ruleType.category));
}, [ruleTypes]);
const availableCategories = useMemo(() => {
return [...new Set(validRuleTypes.map((ruleType) => ruleType.category))];
}, [validRuleTypes]);
const ruleTypeIds = useMemo(() => {
if (!Array.isArray(validRuleTypes) || !Array.isArray(categoryIds) || !mounted) {
if (!Array.isArray(validRuleTypes) || !mounted) {
return [];
}
const uniqueRuleTypeIds = new Set<string>();
validRuleTypes.forEach((ruleType) => {
if (categoryIds.includes(ruleType.category)) {
if (solutionId === ruleType.category) {
uniqueRuleTypeIds.add(ruleType.id);
}
});
return [...uniqueRuleTypeIds];
}, [validRuleTypes, categoryIds, mounted]);
}, [validRuleTypes, solutionId, mounted]);
const onCategoryIdsChange = useCallback(
(ids: string[]) => {
if (!categoryIds) {
const onSolutionIdChange = useCallback(
(id: string) => {
if (!solutionId) {
return;
}
setFieldValue('categoryIds', ids);
setFieldValue('solutionId', id);
},
[categoryIds, setFieldValue]
[solutionId, setFieldValue]
);
const onScopeQueryToggle = useCallback(
(isEnabled: boolean) => {
if (isEnabled) {
setFieldValue('categoryIds', [categoryIds?.sort()[0] || availableCategories.sort()[0]]);
setFieldValue('solutionId', availableSolutions.sort()[0]);
} else {
setFieldValue('categoryIds', categoryIdsHistory.current);
setFieldValue('solutionId', undefined);
}
setIsScopedQueryEnabled(isEnabled);
},
[categoryIds, availableCategories, setFieldValue]
[setFieldValue, availableSolutions]
);
const onQueryChange = useCallback(
@ -300,55 +293,23 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
return m;
}, [closeModal, archiveMaintenanceWindow, isModalVisible, maintenanceWindowId, onSuccess]);
// For create mode, we want to initialize options to the rule type category the
// user has access
useEffect(() => {
if (isEditMode) {
return;
}
if (!mounted) {
return;
}
if (hasSetInitialCategories.current) {
return;
}
if (!validRuleTypes.length) {
return;
}
setFieldValue('categoryIds', [...new Set(validRuleTypes.map((ruleType) => ruleType.category))]);
hasSetInitialCategories.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditMode, validRuleTypes, mounted]);
// For edit mode, if a maintenance window => category_ids is not an array, this means
// the maintenance window was created before the introduction of category filters.
// For backwards compat we will initialize all options for these.
useEffect(() => {
if (!isEditMode) {
return;
}
if (!mounted) {
return;
}
if (hasSetInitialCategories.current) {
return;
}
if (Array.isArray(categoryIds)) {
return;
}
setFieldValue('categoryIds', VALID_CATEGORIES);
hasSetInitialCategories.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditMode, categoryIds, mounted]);
useEffect(() => {
if (!isScopedQueryEnabled && Array.isArray(categoryIds)) {
categoryIdsHistory.current = categoryIds;
}
}, [categoryIds, isScopedQueryEnabled]);
const showNoAvailableSolutionsWarning =
availableSolutions.length === 0 && isScopedQueryEnabled && isEditMode;
return (
<Form form={form} data-test-subj="createMaintenanceWindowForm">
{showNoAvailableSolutionsWarning && (
<>
<EuiCallOut
data-test-subj="maintenanceWindowNoAvailableSolutionsWarning"
title={i18n.NO_AVAILABLE_SOLUTIONS_WARNING_TITLE}
color="warning"
>
<p>{i18n.NO_AVAILABLE_SOLUTIONS_WARNING_SUBTITLE}</p>
</EuiCallOut>
<EuiSpacer size="xl" />
</>
)}
<EuiFlexGroup direction="column" responsive={false}>
<EuiFlexItem>
<UseField
@ -450,48 +411,52 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
<RecurringSchedule data-test-subj="recurring-form" />
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiHorizontalRule margin="xl" />
<UseField path="scopedQuery">
{() => (
<MaintenanceWindowScopedQuerySwitch
checked={isScopedQueryEnabled}
onEnabledChange={onScopeQueryToggle}
/>
)}
</UseField>
</EuiFlexItem>
<EuiFlexItem>
<EuiHorizontalRule margin="xl" />
<UseField path="categoryIds">
{(field) => (
<MaintenanceWindowCategorySelection
isScopedQueryEnabled={isScopedQueryEnabled}
isLoading={isLoadingRuleTypes}
selectedCategories={categoryIds || []}
availableCategories={availableCategories}
errors={field.errors.map((error) => error.message)}
onChange={onCategoryIdsChange}
/>
)}
</UseField>
</EuiFlexItem>
<EuiFlexItem>
<UseField path="scopedQuery">
{() => (
<MaintenanceWindowScopedQuery
ruleTypeIds={ruleTypeIds}
query={query}
filters={filters}
isLoading={isLoadingRuleTypes}
isEnabled={isScopedQueryEnabled}
errors={scopedQueryErrors}
onQueryChange={onQueryChange}
onFiltersChange={setFilters}
/>
)}
</UseField>
</EuiFlexItem>
{availableSolutions.length > 0 && (
<>
<EuiFlexItem>
<EuiHorizontalRule margin="xl" />
<UseField path="scopedQuery">
{() => (
<MaintenanceWindowScopedQuerySwitch
checked={isScopedQueryEnabled}
onEnabledChange={onScopeQueryToggle}
/>
)}
</UseField>
</EuiFlexItem>
<EuiFlexItem>
<UseField path="solutionId">
{(field) => (
<MaintenanceWindowSolutionSelection
isScopedQueryEnabled={isScopedQueryEnabled}
isLoading={isLoadingRuleTypes}
selectedSolution={solutionId}
availableSolutions={availableSolutions}
errors={field.errors.map((error) => error.message)}
onChange={onSolutionIdChange}
/>
)}
</UseField>
</EuiFlexItem>
<EuiFlexItem>
<UseField path="scopedQuery">
{() => (
<MaintenanceWindowScopedQuery
ruleTypeIds={ruleTypeIds}
query={query}
filters={filters}
isLoading={isLoadingRuleTypes}
isEnabled={isScopedQueryEnabled}
errors={scopedQueryErrors}
onQueryChange={onQueryChange}
onFiltersChange={setFilters}
/>
)}
</UseField>
</EuiFlexItem>
</>
)}
<EuiHorizontalRule margin="xl" />
</EuiFlexGroup>
{isEditMode && (

View file

@ -6,8 +6,8 @@
*/
import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { MaintenanceWindowCategorySelection } from './maintenance_window_category_selection';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { MaintenanceWindowSolutionSelection } from './maintenance_window_category_selection';
import type { AppMockRenderer } from '../../../lib/test_utils';
import { createAppMockRenderer } from '../../../lib/test_utils';
@ -21,148 +21,121 @@ describe('maintenanceWindowCategorySelection', () => {
appMockRenderer = createAppMockRenderer();
});
it('renders correctly', async () => {
it('should not display radio group if scoped query is disabled', async () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
<MaintenanceWindowSolutionSelection
selectedSolution={''}
availableSolutions={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
isScopedQueryEnabled={false}
/>
);
expect(screen.getByTestId('option-observability')).not.toBeDisabled();
expect(screen.getByTestId('option-securitySolution')).not.toBeDisabled();
expect(screen.getByTestId('option-management')).not.toBeDisabled();
await waitFor(() => {
expect(
screen.queryByTestId('maintenanceWindowSolutionSelectionLoading')
).not.toBeInTheDocument();
});
expect(screen.queryByTestId('maintenanceWindowSolutionSelection')).not.toBeInTheDocument();
});
it('should disable options if option is not in the available categories array', () => {
it('should display radio group if scoped query is enabled', async () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
selectedCategories={[]}
availableCategories={[]}
<MaintenanceWindowSolutionSelection
selectedSolution={''}
availableSolutions={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
isScopedQueryEnabled={true}
/>
);
expect(screen.getByTestId('option-observability')).toBeDisabled();
expect(screen.getByTestId('option-securitySolution')).toBeDisabled();
expect(screen.getByTestId('option-management')).toBeDisabled();
await waitFor(() => {
expect(
screen.queryByTestId('maintenanceWindowSolutionSelectionLoading')
).not.toBeInTheDocument();
});
expect(await screen.findByTestId('maintenanceWindowSolutionSelection')).toBeInTheDocument();
expect(screen.getByTestId('maintenanceWindowSolutionSelectionRadioGroup')).toBeInTheDocument();
});
it('should disable options if option is not in the available solutions array', () => {
appMockRenderer.render(
<MaintenanceWindowSolutionSelection
selectedSolution={'management'}
availableSolutions={[]}
onChange={mockOnChange}
isScopedQueryEnabled={true}
/>
);
const observabilityInput = screen.getByLabelText('Observability rules');
const securityInput = screen.getByLabelText('Security rules');
const managementInput = screen.getByLabelText('Stack rules');
expect(observabilityInput).toBeDisabled();
expect(managementInput).toBeDisabled();
expect(securityInput).toBeDisabled();
});
it('can initialize checkboxes with initial values from props', async () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
selectedCategories={['securitySolution', 'management']}
availableCategories={['observability', 'management', 'securitySolution']}
<MaintenanceWindowSolutionSelection
selectedSolution={'securitySolution'}
availableSolutions={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
isScopedQueryEnabled={true}
/>
);
expect(screen.getByTestId('option-observability')).not.toBeChecked();
expect(screen.getByTestId('option-securitySolution')).toBeChecked();
expect(screen.getByTestId('option-management')).toBeChecked();
expect(screen.getByLabelText('Observability rules')).not.toBeChecked();
expect(screen.getByLabelText('Security rules')).toBeChecked();
expect(screen.getByLabelText('Stack rules')).not.toBeChecked();
});
it('can check checkboxes', async () => {
it('can choose solution', async () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
selectedCategories={['observability']}
availableCategories={['observability', 'management', 'securitySolution']}
<MaintenanceWindowSolutionSelection
selectedSolution={'observability'}
availableSolutions={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
isScopedQueryEnabled={true}
/>
);
const managementCheckbox = screen.getByTestId('option-management');
const securityCheckbox = screen.getByTestId('option-securitySolution');
fireEvent.click(screen.getByLabelText('Stack rules'));
fireEvent.click(managementCheckbox);
expect(mockOnChange).toHaveBeenLastCalledWith('management');
expect(mockOnChange).toHaveBeenLastCalledWith(['observability', 'management']);
fireEvent.click(screen.getByLabelText('Security rules'));
fireEvent.click(securityCheckbox);
expect(mockOnChange).toHaveBeenLastCalledWith(['observability', 'securitySolution']);
expect(mockOnChange).toHaveBeenLastCalledWith('securitySolution');
});
it('should display loading spinner if isLoading is true', () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
<MaintenanceWindowSolutionSelection
isLoading
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
selectedSolution={''}
availableSolutions={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
isScopedQueryEnabled={true}
/>
);
expect(screen.getByTestId('maintenanceWindowCategorySelectionLoading')).toBeInTheDocument();
expect(screen.getByTestId('maintenanceWindowSolutionSelectionLoading')).toBeInTheDocument();
});
it('should display error message if it exists', () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
<MaintenanceWindowSolutionSelection
selectedSolution={''}
availableSolutions={['observability', 'management', 'securitySolution']}
errors={['test error']}
onChange={mockOnChange}
isScopedQueryEnabled={true}
/>
);
expect(screen.getByText('test error')).toBeInTheDocument();
});
it('should display radio group if scoped query is enabled', () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
isScopedQueryEnabled={false}
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
expect(
screen.getByTestId('maintenanceWindowCategorySelectionCheckboxGroup')
).toBeInTheDocument();
appMockRenderer.render(
<MaintenanceWindowCategorySelection
isScopedQueryEnabled={true}
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
expect(screen.getByTestId('maintenanceWindowCategorySelectionRadioGroup')).toBeInTheDocument();
});
it('should set only 1 category at a time if scoped query is enabled', () => {
appMockRenderer.render(
<MaintenanceWindowCategorySelection
isScopedQueryEnabled={true}
selectedCategories={[]}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
let managementCheckbox = screen.getByLabelText('Stack rules');
fireEvent.click(managementCheckbox);
expect(mockOnChange).toHaveBeenLastCalledWith(['management']);
appMockRenderer.render(
<MaintenanceWindowCategorySelection
isScopedQueryEnabled={true}
selectedCategories={['observability']}
availableCategories={['observability', 'management', 'securitySolution']}
onChange={mockOnChange}
/>
);
managementCheckbox = screen.getByLabelText('Stack rules');
fireEvent.click(managementCheckbox);
expect(mockOnChange).toHaveBeenLastCalledWith(['management']);
});
});

View file

@ -13,117 +13,81 @@ import {
EuiFlexItem,
EuiFormRow,
EuiTextColor,
EuiCheckboxGroup,
EuiRadioGroup,
EuiLoadingSpinner,
} from '@elastic/eui';
import * as i18n from '../translations';
const CHECKBOX_OPTIONS = [
const RADIO_OPTIONS = [
{
id: DEFAULT_APP_CATEGORIES.observability.id,
label: i18n.CREATE_FORM_CATEGORY_OBSERVABILITY_RULES,
label: i18n.CREATE_FORM_SOLUTION_OBSERVABILITY_RULES,
['data-test-subj']: `option-${DEFAULT_APP_CATEGORIES.observability.id}`,
},
{
id: DEFAULT_APP_CATEGORIES.security.id,
label: i18n.CREATE_FORM_CATEGORY_SECURITY_RULES,
label: i18n.CREATE_FORM_SOLUTION_SECURITY_RULES,
['data-test-subj']: `option-${DEFAULT_APP_CATEGORIES.security.id}`,
},
{
id: DEFAULT_APP_CATEGORIES.management.id,
label: i18n.CREATE_FORM_CATEGORY_STACK_RULES,
label: i18n.CREATE_FORM_SOLUTION_STACK_RULES,
['data-test-subj']: `option-${DEFAULT_APP_CATEGORIES.management.id}`,
},
].sort((a, b) => a.id.localeCompare(b.id));
export interface MaintenanceWindowCategorySelectionProps {
selectedCategories: string[];
availableCategories: string[];
export interface MaintenanceWindowSolutionSelectionProps {
selectedSolution?: string;
availableSolutions: string[];
errors?: string[];
isLoading?: boolean;
isScopedQueryEnabled?: boolean;
onChange: (categories: string[]) => void;
onChange: (solution: string) => void;
}
export const MaintenanceWindowCategorySelection = (
props: MaintenanceWindowCategorySelectionProps
export const MaintenanceWindowSolutionSelection = (
props: MaintenanceWindowSolutionSelectionProps
) => {
const {
selectedCategories,
availableCategories,
selectedSolution,
availableSolutions,
errors = [],
isLoading = false,
isScopedQueryEnabled = false,
onChange,
} = props;
const selectedMap = useMemo(() => {
return selectedCategories.reduce<Record<string, boolean>>((result, category) => {
result[category] = true;
return result;
}, {});
}, [selectedCategories]);
const options = useMemo(() => {
return CHECKBOX_OPTIONS.map((option) => ({
return RADIO_OPTIONS.map((option) => ({
...option,
disabled: !availableCategories.includes(option.id),
disabled: !availableSolutions.includes(option.id),
})).sort((a, b) => a.id.localeCompare(b.id));
}, [availableCategories]);
const onCheckboxChange = useCallback(
(id: string) => {
if (selectedCategories.includes(id)) {
onChange(selectedCategories.filter((category) => category !== id));
} else {
onChange([...selectedCategories, id]);
}
},
[selectedCategories, onChange]
);
}, [availableSolutions]);
const onRadioChange = useCallback(
(id: string) => {
onChange([id]);
onChange(id);
},
[onChange]
);
const categorySelection = useMemo(() => {
if (isScopedQueryEnabled) {
return (
<EuiRadioGroup
data-test-subj="maintenanceWindowCategorySelectionRadioGroup"
options={options}
idSelected={selectedCategories[0]}
onChange={onRadioChange}
/>
);
}
const solutionSelection = useMemo(() => {
return (
<EuiCheckboxGroup
data-test-subj="maintenanceWindowCategorySelectionCheckboxGroup"
<EuiRadioGroup
data-test-subj="maintenanceWindowSolutionSelectionRadioGroup"
options={options}
idToSelectedMap={selectedMap}
onChange={onCheckboxChange}
idSelected={selectedSolution}
onChange={onRadioChange}
/>
);
}, [
isScopedQueryEnabled,
options,
selectedCategories,
selectedMap,
onCheckboxChange,
onRadioChange,
]);
}, [options, selectedSolution, onRadioChange]);
if (isLoading) {
return (
<EuiFlexGroup
justifyContent="spaceAround"
data-test-subj="maintenanceWindowCategorySelectionLoading"
data-test-subj="maintenanceWindowSolutionSelectionLoading"
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="l" />
@ -133,26 +97,28 @@ export const MaintenanceWindowCategorySelection = (
}
return (
<EuiFlexGroup data-test-subj="maintenanceWindowCategorySelection">
<EuiFlexItem>
<EuiText size="s">
<h4>{i18n.CREATE_FORM_CATEGORY_SELECTION_TITLE}</h4>
<p>
<EuiTextColor color="subdued">
{i18n.CREATE_FORM_CATEGORY_SELECTION_DESCRIPTION}
</EuiTextColor>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.CREATE_FORM_CATEGORIES_SELECTION_CHECKBOX_GROUP_TITLE}
isInvalid={!!errors.length}
error={errors[0]}
>
{categorySelection}
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
isScopedQueryEnabled && (
<EuiFlexGroup data-test-subj="maintenanceWindowSolutionSelection">
<EuiFlexItem>
<EuiText size="s">
<h4>{i18n.CREATE_FORM_SOLUTION_SELECTION_TITLE}</h4>
<p>
<EuiTextColor color="subdued">
{i18n.CREATE_FORM_SOLUTION_SELECTION_DESCRIPTION}
</EuiTextColor>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.CREATE_FORM_SOLUTION_SELECTION_CHECKBOX_GROUP_TITLE}
isInvalid={!!errors.length}
error={errors[0]}
>
{solutionSelection}
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
)
);
};

View file

@ -26,7 +26,6 @@ const initialValue: FormProps = {
frequency: 'CUSTOM',
ends: EndsOptions.NEVER,
},
categoryIds: [],
};
describe('CustomRecurringSchedule', () => {

View file

@ -22,7 +22,6 @@ const initialValue: FormProps = {
startDate: '2023-03-24',
endDate: '2023-03-26',
recurring: true,
categoryIds: [],
};
describe('RecurringSchedule', () => {

View file

@ -13,6 +13,7 @@ import * as i18n from '../translations';
import type { MaintenanceWindowFrequency } from '../constants';
import { EndsOptions } from '../constants';
import type { ScopedQueryAttributes } from '../../../../common';
import { VALID_CATEGORIES } from '../constants';
const { emptyField } = fieldValidators;
@ -23,7 +24,7 @@ export interface FormProps {
timezone?: string[];
recurring: boolean;
recurringSchedule?: RecurringScheduleFormProps;
categoryIds?: string[];
solutionId?: string;
scopedQuery?: ScopedQueryAttributes | null;
}
@ -48,12 +49,24 @@ export const schema: FormSchema<FormProps> = {
},
],
},
categoryIds: {
solutionId: {
type: FIELD_TYPES.SELECT,
validations: [
{
validator: emptyField(i18n.CREATE_FORM_CATEGORY_IDS_REQUIRED),
validator: ({ value }: { value: string }) => {
if (value === undefined) {
return;
}
if (!VALID_CATEGORIES.includes(value)) {
return {
message: `Value must be one of: ${VALID_CATEGORIES.join(', ')}`,
};
}
},
},
],
// The empty string appears by default because of how form libraries typically handle form inputs
deserializer: (value) => (value === '' ? undefined : value),
},
scopedQuery: {
defaultValue: {

View file

@ -7,9 +7,16 @@
import { invert, mapValues } from 'lodash';
import { Frequency } from '@kbn/rrule';
import moment from 'moment';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common';
import * as i18n from './translations';
import { ISO_WEEKDAYS, MaintenanceWindowStatus } from '../../../common';
export const VALID_CATEGORIES = [
DEFAULT_APP_CATEGORIES.observability.id,
DEFAULT_APP_CATEGORIES.security.id,
DEFAULT_APP_CATEGORIES.management.id,
];
export type MaintenanceWindowFrequency = Extract<
Frequency,
Frequency.YEARLY | Frequency.MONTHLY | Frequency.WEEKLY | Frequency.DAILY

View file

@ -36,7 +36,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
endDate: endDate.toISOString(),
timezone: ['UTC'],
recurring: false,
categoryIds: [],
solutionId: undefined,
});
});
@ -65,7 +65,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: Frequency.DAILY,
interval: 1,
},
categoryIds: [],
solutionId: undefined,
});
});
@ -98,7 +98,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: Frequency.DAILY,
interval: 1,
},
categoryIds: [],
solutionId: undefined,
});
});
@ -129,7 +129,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: Frequency.DAILY,
interval: 1,
},
categoryIds: [],
solutionId: undefined,
});
});
@ -158,7 +158,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
byweekday: { 1: false, 2: false, 3: true, 4: false, 5: false, 6: false, 7: false },
interval: 1,
},
categoryIds: [],
solutionId: undefined,
});
});
@ -187,7 +187,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
bymonth: 'weekday',
interval: 1,
},
categoryIds: [],
solutionId: undefined,
});
});
@ -216,7 +216,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: Frequency.YEARLY,
interval: 1,
},
categoryIds: [],
solutionId: undefined,
});
});
@ -244,7 +244,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: 'CUSTOM',
interval: 1,
},
categoryIds: [],
solutionId: undefined,
});
});
@ -274,7 +274,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: 'CUSTOM',
interval: 1,
},
categoryIds: [],
solutionId: undefined,
});
});
@ -304,7 +304,7 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: 'CUSTOM',
interval: 1,
},
categoryIds: [],
solutionId: undefined,
});
});
@ -334,7 +334,104 @@ describe('convertFromMaintenanceWindowToForm', () => {
frequency: 'CUSTOM',
interval: 3,
},
categoryIds: [],
solutionId: undefined,
});
});
test('should convert categoryIds into solutionId when scopedQuery is on', () => {
const maintenanceWindow = convertFromMaintenanceWindowToForm({
title,
duration,
rRule: {
dtstart: startDate.toISOString(),
tzid: 'UTC',
freq: Frequency.YEARLY,
interval: 3,
bymonth: [3],
bymonthday: [22],
},
categoryIds: ['observability'],
scopedQuery: null,
} as MaintenanceWindow);
expect(maintenanceWindow).toEqual({
title,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
timezone: ['UTC'],
recurring: true,
recurringSchedule: {
customFrequency: Frequency.YEARLY,
ends: 'never',
frequency: 'CUSTOM',
interval: 3,
},
solutionId: undefined,
scopedQuery: null,
});
});
test('should convert categoryIds into solutionId when scopedQuery is off', () => {
const maintenanceWindow = convertFromMaintenanceWindowToForm({
title,
duration,
rRule: {
dtstart: startDate.toISOString(),
tzid: 'UTC',
freq: Frequency.YEARLY,
interval: 3,
bymonth: [3],
bymonthday: [22],
},
categoryIds: ['observability'],
scopedQuery: { kql: '' },
} as MaintenanceWindow);
expect(maintenanceWindow).toEqual({
title,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
timezone: ['UTC'],
recurring: true,
recurringSchedule: {
customFrequency: Frequency.YEARLY,
ends: 'never',
frequency: 'CUSTOM',
interval: 3,
},
solutionId: 'observability',
scopedQuery: { kql: '' },
});
});
test('should convert old multivalued categoryIds into solutionId equal undefined', () => {
const maintenanceWindow = convertFromMaintenanceWindowToForm({
title,
duration,
rRule: {
dtstart: startDate.toISOString(),
tzid: 'UTC',
freq: Frequency.YEARLY,
interval: 3,
bymonth: [3],
bymonthday: [22],
},
categoryIds: ['observability', 'security'],
} as MaintenanceWindow);
expect(maintenanceWindow).toEqual({
title,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
timezone: ['UTC'],
recurring: true,
recurringSchedule: {
customFrequency: Frequency.YEARLY,
ends: 'never',
frequency: 'CUSTOM',
interval: 3,
},
solutionId: undefined,
});
});
});

View file

@ -21,13 +21,17 @@ export const convertFromMaintenanceWindowToForm = (
const endDate = moment(startDate).add(maintenanceWindow.duration);
// maintenance window is considered recurring if interval is defined
const recurring = has(maintenanceWindow, 'rRule.interval');
const hasScopedQuery = !!maintenanceWindow.scopedQuery;
const form: FormProps = {
title: maintenanceWindow.title,
startDate,
endDate: endDate.toISOString(),
timezone: [maintenanceWindow.rRule.tzid],
recurring,
categoryIds: maintenanceWindow.categoryIds || [],
solutionId:
maintenanceWindow.categoryIds && maintenanceWindow.categoryIds.length === 1 && hasScopedQuery
? maintenanceWindow.categoryIds[0]
: undefined,
scopedQuery: maintenanceWindow.scopedQuery,
};
if (!recurring) return form;

View file

@ -7,7 +7,7 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { EuiPageSection, EuiSpacer } from '@elastic/eui';
import { EuiPageSection, EuiSpacer, EuiCallOut } from '@elastic/eui';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { useMaintenanceWindowsNavigation } from '../../hooks/use_navigation';
@ -24,7 +24,8 @@ export const MaintenanceWindowsEditPage = React.memo(() => {
useBreadcrumbs(MAINTENANCE_WINDOW_DEEP_LINK_IDS.maintenanceWindowsEdit);
const { maintenanceWindowId } = useParams<{ maintenanceWindowId: string }>();
const { maintenanceWindow, isLoading, isError } = useGetMaintenanceWindow(maintenanceWindowId);
const { maintenanceWindow, showMultipleSolutionsWarning, isLoading, isError } =
useGetMaintenanceWindow(maintenanceWindowId);
if (isError) {
navigateToMaintenanceWindows();
@ -38,6 +39,18 @@ export const MaintenanceWindowsEditPage = React.memo(() => {
<EuiPageSection restrictWidth={true}>
<PageHeader showBackButton={true} title={i18n.EDIT_MAINTENANCE_WINDOW} />
<EuiSpacer size="xl" />
{showMultipleSolutionsWarning && (
<>
<EuiCallOut
data-test-subj="maintenanceWindowMultipleSolutionsWarning"
title={i18n.SOLUTION_CONFIG_REMOVAL_WARNING_TITLE}
color="warning"
>
<p>{i18n.SOLUTION_CONFIG_REMOVAL_WARNING_SUBTITLE}</p>
</EuiCallOut>
<EuiSpacer size="xl" />
</>
)}
<CreateMaintenanceWindowForm
initialValue={maintenanceWindow}
maintenanceWindowId={maintenanceWindowId}

View file

@ -158,51 +158,51 @@ export const CREATE_FORM_TIMEFRAME_DESCRIPTION = i18n.translate(
}
);
export const CREATE_FORM_CATEGORY_IDS_REQUIRED = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoryIds.required',
export const CREATE_FORM_SOLUTION_IDS_REQUIRED = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.solutionIds.required',
{
defaultMessage: 'A category is required.',
defaultMessage: 'A solution is required.',
}
);
export const CREATE_FORM_CATEGORY_SELECTION_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoriesSelection.title',
export const CREATE_FORM_SOLUTION_SELECTION_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.solutionSelection.title',
{
defaultMessage: 'Category specific maintenance window',
defaultMessage: 'Solution specific maintenance window',
}
);
export const CREATE_FORM_CATEGORY_SELECTION_DESCRIPTION = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoriesSelection.description',
export const CREATE_FORM_SOLUTION_SELECTION_DESCRIPTION = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.solutionSelection.description',
{
defaultMessage:
'Only rules associated with the selected categories are affected by the maintenance window.',
'Only rules associated with the selected solution are affected by the maintenance window.',
}
);
export const CREATE_FORM_CATEGORIES_SELECTION_CHECKBOX_GROUP_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categorySelection.checkboxGroupTitle',
export const CREATE_FORM_SOLUTION_SELECTION_CHECKBOX_GROUP_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.solutionSelection.checkboxGroupTitle',
{
defaultMessage: 'Select the categories this should affect',
defaultMessage: 'Select the solution this should affect',
}
);
export const CREATE_FORM_CATEGORY_OBSERVABILITY_RULES = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoryIds.observabilityRules',
export const CREATE_FORM_SOLUTION_OBSERVABILITY_RULES = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.solutionIds.observabilityRules',
{
defaultMessage: 'Observability rules',
}
);
export const CREATE_FORM_CATEGORY_SECURITY_RULES = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoryIds.securityRules',
export const CREATE_FORM_SOLUTION_SECURITY_RULES = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.solutionIds.securityRules',
{
defaultMessage: 'Security rules',
}
);
export const CREATE_FORM_CATEGORY_STACK_RULES = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.categoryIds.stackRules',
export const CREATE_FORM_SOLUTION_STACK_RULES = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.solutionIds.stackRules',
{
defaultMessage: 'Stack rules',
}
@ -219,7 +219,7 @@ export const CREATE_FORM_SCOPED_QUERY_DESCRIPTION = i18n.translate(
'xpack.alerting.maintenanceWindows.createForm.scopedQuery.description',
{
defaultMessage:
'Add filters that refine the scope of the maintenance window. You can select only one category when filters are enabled.',
'Add filters that refine the scope of the maintenance window. You can select only one solution when filters are enabled.',
}
);
@ -667,6 +667,21 @@ export const ARCHIVE_SUBTITLE = i18n.translate(
}
);
export const SOLUTION_CONFIG_REMOVAL_WARNING_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.solutionConfigRemovalWarning.title',
{
defaultMessage: 'Support for multiple solution categories is removed.',
}
);
export const SOLUTION_CONFIG_REMOVAL_WARNING_SUBTITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.solutionConfigRemovalWarning.subtitle',
{
defaultMessage:
'When you save the changes, the maintenance window will affect rules in all solutions.',
}
);
export const TABLE_ACTION_UNARCHIVE = i18n.translate(
'xpack.alerting.maintenanceWindows.table.unarchive',
{
@ -758,3 +773,16 @@ export const SEARCH_PLACEHOLDER = i18n.translate(
'xpack.alerting.maintenanceWindows.searchPlaceholder',
{ defaultMessage: 'Search' }
);
export const NO_AVAILABLE_SOLUTIONS_WARNING_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.noAvailableSolutionsWarning.title',
{ defaultMessage: 'Limited permissions detected.' }
);
export const NO_AVAILABLE_SOLUTIONS_WARNING_SUBTITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.noAvailableSolutionsWarning.subtitle',
{
defaultMessage:
"You don't have access to all solution types required for alert filtering. If you save, your alert filter settings will be reset.",
}
);

View file

@ -149,6 +149,66 @@ describe('getMaintenanceWindows', () => {
).toEqual([mockMaintenanceWindows[0], mockMaintenanceWindows[2]]);
});
test('filters to rule type category and category IDs is null', async () => {
const mockMaintenanceWindows = [
{
...getMockMaintenanceWindow(),
eventStartTime: new Date().toISOString(),
eventEndTime: new Date().toISOString(),
status: MaintenanceWindowStatus.Running,
id: 'test-id1',
categoryIds: [maintenanceWindowCategoryIdTypes.OBSERVABILITY],
},
{
...getMockMaintenanceWindow(),
eventStartTime: new Date().toISOString(),
eventEndTime: new Date().toISOString(),
status: MaintenanceWindowStatus.Running,
id: 'test-id2',
categoryIds: [maintenanceWindowCategoryIdTypes.SECURITY_SOLUTION],
},
{
...getMockMaintenanceWindow(),
eventStartTime: new Date().toISOString(),
eventEndTime: new Date().toISOString(),
status: MaintenanceWindowStatus.Running,
id: 'test-id3',
categoryIds: null,
},
];
maintenanceWindowClient.getActiveMaintenanceWindows.mockResolvedValue(mockMaintenanceWindows);
expect(
await getMaintenanceWindows({
fakeRequest,
getMaintenanceWindowClientWithRequest: jest.fn().mockReturnValue(maintenanceWindowClient),
logger,
ruleTypeId,
ruleTypeCategory: 'management',
ruleId,
})
).toEqual([mockMaintenanceWindows[2]]);
expect(
await getMaintenanceWindows({
fakeRequest,
getMaintenanceWindowClientWithRequest: jest.fn().mockReturnValue(maintenanceWindowClient),
logger,
ruleTypeId,
ruleTypeCategory: 'observability',
ruleId,
})
).toEqual([mockMaintenanceWindows[0], mockMaintenanceWindows[2]]);
expect(
await getMaintenanceWindows({
fakeRequest,
getMaintenanceWindowClientWithRequest: jest.fn().mockReturnValue(maintenanceWindowClient),
logger,
ruleTypeId,
ruleTypeCategory: 'securitySolution',
ruleId,
})
).toEqual([mockMaintenanceWindows[1], mockMaintenanceWindows[2]]);
});
test('returns empty array if no active maintenance windows exist', async () => {
maintenanceWindowClient.getActiveMaintenanceWindows.mockResolvedValueOnce([]);
expect(

View file

@ -75,5 +75,39 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
expect(toastTitle).to.eql(`Updated maintenance window 'Test Maintenance Window updated'`);
});
});
it('should show callout when update a maintenance window with old chosen solutions', async () => {
const createdMaintenanceWindow = await createMaintenanceWindow({
name: 'Test Maintenance Window',
getService,
overwrite: {
r_rule: {
dtstart: new Date().toISOString(),
tzid: 'UTC',
freq: 3,
interval: 12,
count: 5,
},
category_ids: ['observability', 'securitySolution'],
},
});
objectRemover.add(createdMaintenanceWindow.id, 'rules/maintenance_window', 'alerting', true);
await browser.refresh();
await pageObjects.maintenanceWindows.searchMaintenanceWindows('Test Maintenance Window 2');
await testSubjects.click('table-actions-popover');
await testSubjects.click('table-actions-edit');
await retry.try(async () => {
await testSubjects.existOrFail('createMaintenanceWindowForm');
});
expect(
await testSubjects.getVisibleText('maintenanceWindowMultipleSolutionsWarning')
).to.contain('Support for multiple solution categories is removed.');
});
});
};

View file

@ -36,7 +36,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
'[data-test-subj="maintenanceWindowScopedQuerySwitch"] .euiSwitch__button'
);
await filterAlerts.click();
const radioGroup = await testSubjects.find('maintenanceWindowCategorySelectionRadioGroup');
const radioGroup = await testSubjects.find('maintenanceWindowSolutionSelectionRadioGroup');
const label = await radioGroup.findByCssSelector(`label[for="observability"]`);
await label.click();
await testSubjects.setValue('queryInput', 'kibana.alert.rule.name: custom-threshold-rule-1');