[8.17] [Response Ops] Fix maintenance window custom schedule create and update error (#192649) (#208335)

# Backport

This will backport the following commits from `main` to `8.17`:
- [[Response Ops] Fix maintenance window custom schedule create and
update error (#192649)](https://github.com/elastic/kibana/pull/192649)

<!--- Backport version: 9.6.4 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Jiawei
Wu","email":"74562234+JiaweiWu@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-09-16T23:52:00Z","message":"[Response
Ops] Fix maintenance window custom schedule create and update error
(#192649)\n\n## Summary\r\nFixes a bug where the backend would throw an
error if we tried to create\r\nor update a maintenance window with a
custom schedule. This was due to\r\nthe `form-lib` converting everything
`frequency`, `interval`, and\r\n`customFrequency` field to a string and
our logic assumed it was a\r\nnumber so the `===` comparisons were
failing.\r\n\r\n### How to test:\r\n1. Navigate to the create
maintenance window form\r\n2. Attempt to create a maintenance window
with a custom schedule\r\n3. Assert the maintenance window was created
successfully\r\n4. Attempt to edit the maintenance window with a
different custom\r\nschedule\r\n5. Assert the maintenance window was
edited successfully \r\n\r\nFixes:
https://github.com/elastic/kibana/issues/192601\r\n\r\n---------\r\n\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"134b81572c4234e9c813aa5ed5dda286a99ffc32","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["bug","release_note:skip","backport:skip","Team:ResponseOps","v9.0.0","v8.16.0"],"title":"[Response
Ops] Fix maintenance window custom schedule create and update
error","number":192649,"url":"https://github.com/elastic/kibana/pull/192649","mergeCommit":{"message":"[Response
Ops] Fix maintenance window custom schedule create and update error
(#192649)\n\n## Summary\r\nFixes a bug where the backend would throw an
error if we tried to create\r\nor update a maintenance window with a
custom schedule. This was due to\r\nthe `form-lib` converting everything
`frequency`, `interval`, and\r\n`customFrequency` field to a string and
our logic assumed it was a\r\nnumber so the `===` comparisons were
failing.\r\n\r\n### How to test:\r\n1. Navigate to the create
maintenance window form\r\n2. Attempt to create a maintenance window
with a custom schedule\r\n3. Assert the maintenance window was created
successfully\r\n4. Attempt to edit the maintenance window with a
different custom\r\nschedule\r\n5. Assert the maintenance window was
edited successfully \r\n\r\nFixes:
https://github.com/elastic/kibana/issues/192601\r\n\r\n---------\r\n\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"134b81572c4234e9c813aa5ed5dda286a99ffc32"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/192649","number":192649,"mergeCommit":{"message":"[Response
Ops] Fix maintenance window custom schedule create and update error
(#192649)\n\n## Summary\r\nFixes a bug where the backend would throw an
error if we tried to create\r\nor update a maintenance window with a
custom schedule. This was due to\r\nthe `form-lib` converting everything
`frequency`, `interval`, and\r\n`customFrequency` field to a string and
our logic assumed it was a\r\nnumber so the `===` comparisons were
failing.\r\n\r\n### How to test:\r\n1. Navigate to the create
maintenance window form\r\n2. Attempt to create a maintenance window
with a custom schedule\r\n3. Assert the maintenance window was created
successfully\r\n4. Attempt to edit the maintenance window with a
different custom\r\nschedule\r\n5. Assert the maintenance window was
edited successfully \r\n\r\nFixes:
https://github.com/elastic/kibana/issues/192601\r\n\r\n---------\r\n\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"134b81572c4234e9c813aa5ed5dda286a99ffc32"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"},{"url":"https://github.com/elastic/kibana/pull/208334","number":208334,"branch":"8.16","state":"OPEN"}]}]
BACKPORT-->

Co-authored-by: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com>
This commit is contained in:
Antonio 2025-01-27 16:26:46 +01:00 committed by GitHub
parent 52fddd8394
commit d7fac03932
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 437 additions and 145 deletions

View file

@ -22,16 +22,22 @@ export const rRuleRequestSchema = schema.object({
interval: schema.maybe(
schema.number({
validate: (interval: number) => {
if (interval < 1) return 'rRule interval must be > 0';
if (!Number.isInteger(interval)) {
return 'rRule interval must be an integer greater than 0';
}
},
min: 1,
})
),
until: schema.maybe(schema.string({ validate: validateEndDateV1 })),
count: schema.maybe(
schema.number({
validate: (count: number) => {
if (count < 1) return 'rRule count must be > 0';
if (!Number.isInteger(count)) {
return 'rRule count must be an integer greater than 0';
}
},
min: 1,
})
),
byweekday: schema.maybe(

View file

@ -96,12 +96,16 @@ describe('CreateMaintenanceWindowForm', () => {
it('should initialize the form when no initialValue provided', () => {
const result = appMockRenderer.render(<CreateMaintenanceWindowForm {...formProps} />);
const titleInput = within(result.getByTestId('title-field')).getByTestId('input');
const titleInput = within(result.getByTestId('title-field')).getByTestId(
'createMaintenanceWindowFormNameInput'
);
const dateInputs = within(result.getByTestId('date-field')).getAllByLabelText(
// using the aria-label to query for the date-picker input
'Press the down key to open a popover containing a calendar.'
);
const recurringInput = within(result.getByTestId('recurring-field')).getByTestId('input');
const recurringInput = within(result.getByTestId('recurring-field')).getByTestId(
'createMaintenanceWindowRepeatSwitch'
);
expect(titleInput).toHaveValue('');
// except for the date field
@ -125,12 +129,16 @@ describe('CreateMaintenanceWindowForm', () => {
/>
);
const titleInput = within(result.getByTestId('title-field')).getByTestId('input');
const titleInput = within(result.getByTestId('title-field')).getByTestId(
'createMaintenanceWindowFormNameInput'
);
const dateInputs = within(result.getByTestId('date-field')).getAllByLabelText(
// using the aria-label to query for the date-picker input
'Press the down key to open a popover containing a calendar.'
);
const recurringInput = within(result.getByTestId('recurring-field')).getByTestId('input');
const recurringInput = within(result.getByTestId('recurring-field')).getByTestId(
'createMaintenanceWindowRepeatSwitch'
);
const timezoneInput = within(result.getByTestId('timezone-field')).getByTestId(
'comboBoxSearchInput'
);

View file

@ -344,7 +344,7 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
}, [categoryIds, isScopedQueryEnabled]);
return (
<Form form={form}>
<Form form={form} data-test-subj="createMaintenanceWindowForm">
<EuiFlexGroup direction="column" responsive={false}>
<EuiFlexItem>
<UseField
@ -352,6 +352,7 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
componentProps={{
'data-test-subj': 'title-field',
euiFieldProps: {
'data-test-subj': 'createMaintenanceWindowFormNameInput',
autoFocus: true,
},
}}
@ -434,6 +435,9 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
path="recurring"
componentProps={{
'data-test-subj': 'recurring-field',
euiFieldProps: {
'data-test-subj': 'createMaintenanceWindowRepeatSwitch',
},
}}
/>
</EuiFlexItem>

View file

@ -24,7 +24,12 @@ export const EmptyPrompt = React.memo<EmptyPromptProps>(
const renderActions = useMemo(() => {
if (showCreateButton) {
return [
<EuiButton key="create-action" fill onClick={onClickCreate}>
<EuiButton
data-test-subj="mw-create-button"
key="create-action"
fill
onClick={onClickCreate}
>
{i18n.EMPTY_PROMPT_BUTTON}
</EuiButton>,
<EuiButtonEmpty

View file

@ -67,9 +67,14 @@ describe('CustomRecurringSchedule', () => {
</MockHookWrapperComponent>
);
fireEvent.change(within(result.getByTestId('custom-frequency-field')).getByTestId('select'), {
target: { value: Frequency.WEEKLY },
});
fireEvent.change(
within(result.getByTestId('custom-frequency-field')).getByTestId(
'customRecurringScheduleFrequencySelect'
),
{
target: { value: Frequency.WEEKLY },
}
);
await waitFor(() => expect(result.getByTestId('byweekday-field')).toBeInTheDocument());
});
@ -97,9 +102,14 @@ describe('CustomRecurringSchedule', () => {
</MockHookWrapperComponent>
);
fireEvent.change(within(result.getByTestId('custom-frequency-field')).getByTestId('select'), {
target: { value: Frequency.MONTHLY },
});
fireEvent.change(
within(result.getByTestId('custom-frequency-field')).getByTestId(
'customRecurringScheduleFrequencySelect'
),
{
target: { value: Frequency.MONTHLY },
}
);
await waitFor(() => expect(result.getByTestId('bymonth-field')).toBeInTheDocument());
});
@ -111,9 +121,11 @@ describe('CustomRecurringSchedule', () => {
);
const frequencyInput = within(result.getByTestId('custom-frequency-field')).getByTestId(
'select'
'customRecurringScheduleFrequencySelect'
);
const intervalInput = within(result.getByTestId('interval-field')).getByTestId(
'customRecurringScheduleIntervalInput'
);
const intervalInput = within(result.getByTestId('interval-field')).getByTestId('input');
expect(frequencyInput).toHaveValue('2');
expect(intervalInput).toHaveValue(1);
@ -137,14 +149,16 @@ describe('CustomRecurringSchedule', () => {
);
const frequencyInput = within(result.getByTestId('custom-frequency-field')).getByTestId(
'select'
'customRecurringScheduleFrequencySelect'
);
const intervalInput = within(result.getByTestId('interval-field')).getByTestId(
'customRecurringScheduleIntervalInput'
);
const intervalInput = within(result.getByTestId('interval-field')).getByTestId('input');
const input3 = within(result.getByTestId('byweekday-field'))
.getByTestId('3')
.getByTestId('isoWeekdays3')
.getAttribute('aria-pressed');
const input4 = within(result.getByTestId('byweekday-field'))
.getByTestId('4')
.getByTestId('isoWeekdays4')
.getAttribute('aria-pressed');
expect(frequencyInput).toHaveValue('2');
expect(intervalInput).toHaveValue(3);

View file

@ -23,6 +23,7 @@ import * as i18n from '../../translations';
import { getInitialByWeekday } from '../../helpers/get_initial_by_weekday';
import { getWeekdayInfo } from '../../helpers/get_weekday_info';
import { FormProps } from '../schema';
import { parseSchedule } from '../../helpers/parse_schedule';
const UseField = getUseField({ component: Field });
@ -44,9 +45,13 @@ export const CustomRecurringSchedule: React.FC = React.memo(() => {
],
});
const parsedSchedule = useMemo(() => {
return parseSchedule(recurringSchedule);
}, [recurringSchedule]);
const frequencyOptions = useMemo(
() => CREATE_FORM_CUSTOM_FREQUENCY(recurringSchedule?.interval),
[recurringSchedule?.interval]
() => CREATE_FORM_CUSTOM_FREQUENCY(parsedSchedule?.interval),
[parsedSchedule?.interval]
);
const bymonthOptions = useMemo(() => {
@ -69,7 +74,7 @@ export const CustomRecurringSchedule: React.FC = React.memo(() => {
return (
<>
{recurringSchedule?.frequency !== Frequency.DAILY ? (
{parsedSchedule?.frequency !== Frequency.DAILY ? (
<>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s" alignItems="flexStart">
@ -81,6 +86,7 @@ export const CustomRecurringSchedule: React.FC = React.memo(() => {
'data-test-subj': 'interval-field',
id: 'interval',
euiFieldProps: {
'data-test-subj': 'customRecurringScheduleIntervalInput',
min: 1,
prepend: (
<EuiFormLabel htmlFor={'interval'}>
@ -97,6 +103,7 @@ export const CustomRecurringSchedule: React.FC = React.memo(() => {
componentProps={{
'data-test-subj': 'custom-frequency-field',
euiFieldProps: {
'data-test-subj': 'customRecurringScheduleFrequencySelect',
options: frequencyOptions,
},
}}
@ -106,8 +113,8 @@ export const CustomRecurringSchedule: React.FC = React.memo(() => {
<EuiSpacer size="s" />
</>
) : null}
{Number(recurringSchedule?.customFrequency) === Frequency.WEEKLY ||
recurringSchedule?.frequency === Frequency.DAILY ? (
{Number(parsedSchedule?.customFrequency) === Frequency.WEEKLY ||
parsedSchedule?.frequency === Frequency.DAILY ? (
<UseField
path="recurringSchedule.byweekday"
config={{
@ -131,6 +138,7 @@ export const CustomRecurringSchedule: React.FC = React.memo(() => {
componentProps={{
'data-test-subj': 'byweekday-field',
euiFieldProps: {
'data-test-subj': 'customRecurringScheduleByWeekdayButtonGroup',
legend: 'Repeat on weekday',
options: WEEKDAY_OPTIONS,
},
@ -138,7 +146,7 @@ export const CustomRecurringSchedule: React.FC = React.memo(() => {
/>
) : null}
{Number(recurringSchedule?.customFrequency) === Frequency.MONTHLY ? (
{Number(parsedSchedule?.customFrequency) === Frequency.MONTHLY ? (
<UseField
path="recurringSchedule.bymonth"
componentProps={{

View file

@ -64,7 +64,7 @@ describe('RecurringSchedule', () => {
</MockHookWrapperComponent>
);
const btn = within(result.getByTestId('ends-field')).getByTestId('ondate');
const btn = within(result.getByTestId('ends-field')).getByTestId('recurrenceEndOptionOnDate');
fireEvent.click(btn);
expect(result.getByTestId('until-field')).toBeInTheDocument();
@ -77,7 +77,7 @@ describe('RecurringSchedule', () => {
</MockHookWrapperComponent>
);
const btn = within(result.getByTestId('ends-field')).getByTestId('afterx');
const btn = within(result.getByTestId('ends-field')).getByTestId('recurrenceEndOptionAfterX');
fireEvent.click(btn);
expect(result.getByTestId('count-field')).toBeInTheDocument();
@ -90,8 +90,12 @@ describe('RecurringSchedule', () => {
</MockHookWrapperComponent>
);
const frequencyInput = within(result.getByTestId('frequency-field')).getByTestId('select');
const endsInput = within(result.getByTestId('ends-field')).getByTestId('never');
const frequencyInput = within(result.getByTestId('frequency-field')).getByTestId(
'recurringScheduleRepeatSelect'
);
const endsInput = within(result.getByTestId('ends-field')).getByTestId(
'recurrenceEndOptionNever'
);
expect(frequencyInput).toHaveValue('3');
expect(endsInput).toHaveAttribute('aria-pressed', 'true');
@ -112,8 +116,12 @@ describe('RecurringSchedule', () => {
</MockHookWrapperComponent>
);
const frequencyInput = within(result.getByTestId('frequency-field')).getByTestId('select');
const endsInput = within(result.getByTestId('ends-field')).getByTestId('ondate');
const frequencyInput = within(result.getByTestId('frequency-field')).getByTestId(
'recurringScheduleRepeatSelect'
);
const endsInput = within(result.getByTestId('ends-field')).getByTestId(
'recurrenceEndOptionOnDate'
);
const untilInput = within(result.getByTestId('until-field')).getByLabelText(
// using the aria-label to query for the date-picker input
'Press the down key to open a popover containing a calendar.'

View file

@ -34,6 +34,7 @@ import { CustomRecurringSchedule } from './custom_recurring_schedule';
import { recurringSummary } from '../../helpers/recurring_summary';
import { getPresets } from '../../helpers/get_presets';
import { FormProps } from '../schema';
import { parseSchedule } from '../../helpers/parse_schedule';
const UseField = getUseField({ component: Field });
@ -70,30 +71,39 @@ export const RecurringSchedule: React.FC = React.memo(() => {
{
text: i18n.CREATE_FORM_FREQUENCY_DAILY,
value: Frequency.DAILY,
'data-test-subj': 'recurringScheduleOptionDaily',
},
{
text: i18n.CREATE_FORM_FREQUENCY_WEEKLY_ON(dayOfWeek),
value: Frequency.WEEKLY,
'data-test-subj': 'recurringScheduleOptionWeekly',
},
{
text: i18n.CREATE_FORM_FREQUENCY_NTH_WEEKDAY(dayOfWeek)[
isLastOfMonth ? 0 : nthWeekdayOfMonth
],
value: Frequency.MONTHLY,
'data-test-subj': 'recurringScheduleOptionMonthly',
},
{
text: i18n.CREATE_FORM_FREQUENCY_YEARLY_ON(date),
value: Frequency.YEARLY,
'data-test-subj': 'recurringScheduleOptionYearly',
},
{
text: i18n.CREATE_FORM_FREQUENCY_CUSTOM,
value: 'CUSTOM',
'data-test-subj': 'recurringScheduleOptionCustom',
},
],
presets: getPresets(date),
};
}, [startDate]);
const parsedSchedule = useMemo(() => {
return parseSchedule(recurringSchedule);
}, [recurringSchedule]);
return (
<EuiSplitPanel.Outer hasShadow={false} hasBorder={true}>
<EuiSplitPanel.Inner color="subdued">
@ -102,14 +112,15 @@ export const RecurringSchedule: React.FC = React.memo(() => {
componentProps={{
'data-test-subj': 'frequency-field',
euiFieldProps: {
'data-test-subj': 'recurringScheduleRepeatSelect',
options,
},
}}
/>
{recurringSchedule?.frequency === Frequency.DAILY ||
recurringSchedule?.frequency === 'CUSTOM' ? (
{(parsedSchedule?.frequency === Frequency.DAILY ||
parsedSchedule?.frequency === 'CUSTOM') && (
<CustomRecurringSchedule data-test-subj="custom-recurring-form" />
) : null}
)}
<UseField
path="recurringSchedule.ends"
componentProps={{
@ -120,7 +131,7 @@ export const RecurringSchedule: React.FC = React.memo(() => {
},
}}
/>
{recurringSchedule?.ends === EndsOptions.ON_DATE ? (
{parsedSchedule?.ends === EndsOptions.ON_DATE ? (
<>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="flexEnd">
@ -164,13 +175,14 @@ export const RecurringSchedule: React.FC = React.memo(() => {
</EuiFlexGroup>
</>
) : null}
{recurringSchedule?.ends === EndsOptions.AFTER_X ? (
{parsedSchedule?.ends === EndsOptions.AFTER_X ? (
<UseField
path="recurringSchedule.count"
componentProps={{
'data-test-subj': 'count-field',
id: 'count',
euiFieldProps: {
'data-test-subj': 'recurringScheduleAfterXOccurenceInput',
type: 'number',
min: 1,
prepend: (
@ -187,7 +199,7 @@ export const RecurringSchedule: React.FC = React.memo(() => {
<EuiHorizontalRule margin="none" />
<EuiSplitPanel.Inner>
{i18n.CREATE_FORM_RECURRING_SUMMARY_PREFIX(
recurringSummary(moment(startDate), recurringSchedule, presets)
recurringSummary(moment(startDate), parsedSchedule, presets)
)}
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>

View file

@ -60,33 +60,50 @@ export enum EndsOptions {
}
export const RECURRENCE_END_OPTIONS = [
{ id: 'never', label: i18n.CREATE_FORM_ENDS_NEVER },
{ id: 'ondate', label: i18n.CREATE_FORM_ENDS_ON_DATE },
{ id: 'afterx', label: i18n.CREATE_FORM_ENDS_AFTER_X },
{
id: 'never',
label: i18n.CREATE_FORM_ENDS_NEVER,
'data-test-subj': 'recurrenceEndOptionNever',
},
{
id: 'ondate',
label: i18n.CREATE_FORM_ENDS_ON_DATE,
'data-test-subj': 'recurrenceEndOptionOnDate',
},
{
id: 'afterx',
label: i18n.CREATE_FORM_ENDS_AFTER_X,
'data-test-subj': 'recurrenceEndOptionAfterX',
},
];
export const CREATE_FORM_CUSTOM_FREQUENCY = (interval: number = 1) => [
{
text: i18n.CREATE_FORM_CUSTOM_FREQUENCY_DAILY(interval),
value: Frequency.DAILY,
'data-test-subj': 'customFrequencyDaily',
},
{
text: i18n.CREATE_FORM_CUSTOM_FREQUENCY_WEEKLY(interval),
value: Frequency.WEEKLY,
'data-test-subj': 'customFrequencyWeekly',
},
{
text: i18n.CREATE_FORM_CUSTOM_FREQUENCY_MONTHLY(interval),
value: Frequency.MONTHLY,
'data-test-subj': 'customFrequencyMonthly',
},
{
text: i18n.CREATE_FORM_CUSTOM_FREQUENCY_YEARLY(interval),
value: Frequency.YEARLY,
'data-test-subj': 'customFrequencyYearly',
},
];
export const WEEKDAY_OPTIONS = ISO_WEEKDAYS.map((n) => ({
id: String(n),
label: moment().isoWeekday(n).format('ddd'),
'data-test-subj': `isoWeekdays${n}`,
}));
export const ISO_WEEKDAYS_TO_RRULE: Record<number, string> = {

View file

@ -12,20 +12,23 @@ import { getNthByWeekday } from './get_nth_by_weekday';
import { RecurringScheduleFormProps } from '../components/schema';
import { getPresets } from './get_presets';
import { RRuleParams } from '../../../../common';
import { parseSchedule } from './parse_schedule';
export const convertToRRule = (
startDate: Moment,
timezone: string,
recurringForm?: RecurringScheduleFormProps
recurringSchedule?: RecurringScheduleFormProps
): RRuleParams => {
const presets = getPresets(startDate);
const parsedSchedule = parseSchedule(recurringSchedule);
const rRule: RRuleParams = {
dtstart: startDate.toISOString(),
tzid: timezone,
};
if (!recurringForm)
if (!parsedSchedule)
return {
...rRule,
// default to yearly and a count of 1
@ -34,9 +37,9 @@ export const convertToRRule = (
count: 1,
};
let form = recurringForm;
if (recurringForm.frequency !== 'CUSTOM') {
form = { ...recurringForm, ...presets[recurringForm.frequency] };
let form = parsedSchedule;
if (parsedSchedule.frequency !== 'CUSTOM') {
form = { ...parsedSchedule, ...presets[parsedSchedule.frequency] };
}
const frequency = form.customFrequency ?? (form.frequency as Frequency);

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RecurringScheduleFormProps } from '../components/schema';
export const parseSchedule = (
schedule: RecurringScheduleFormProps | undefined
): RecurringScheduleFormProps | undefined => {
if (!schedule) {
return schedule;
}
const { frequency, customFrequency, interval, count } = schedule;
// We must case them to unknown because form-lib is already turning them into strings
// despite what our types suggests
const parsedFrequency = parseInt(frequency as string, 10);
const parsedCustomFrequency = parseInt(customFrequency as unknown as string, 10);
const parsedInterval = parseInt(interval as unknown as string, 10);
const parsedCount = parseInt(count as unknown as string, 10);
const shallowCopy = { ...schedule };
if (!isNaN(parsedFrequency)) {
shallowCopy.frequency = parsedFrequency;
}
if (!isNaN(parsedCustomFrequency)) {
shallowCopy.customFrequency = parsedCustomFrequency;
}
if (!isNaN(parsedInterval)) {
shallowCopy.interval = parsedInterval;
}
if (!isNaN(parsedCount)) {
shallowCopy.count = parsedCount;
}
return shallowCopy;
};

View file

@ -24,80 +24,76 @@ export const recurringSummary = (
) => {
if (!recurringSchedule) return '';
if (recurringSchedule) {
let schedule = recurringSchedule;
if (recurringSchedule.frequency !== 'CUSTOM') {
schedule = { ...recurringSchedule, ...presets[recurringSchedule.frequency] };
}
const frequency =
schedule.customFrequency ?? (schedule.frequency as MaintenanceWindowFrequency);
const interval = schedule.interval || 1;
const frequencySummary = i18n.CREATE_FORM_FREQUENCY_SUMMARY(interval)[frequency];
// daily and weekly
let weeklySummary = null;
let dailyWeekdaySummary = null;
let dailyWithWeekdays = false;
const byweekday = schedule.byweekday;
if (byweekday) {
const weekdays = Object.keys(byweekday)
.filter((k) => byweekday[k] === true)
.map((n) => ISO_WEEKDAYS_TO_RRULE[Number(n)]);
const formattedWeekdays = weekdays.map((weekday) => toWeekdayName(weekday)).join(', ');
weeklySummary = i18n.CREATE_FORM_WEEKLY_SUMMARY(formattedWeekdays);
dailyWeekdaySummary = formattedWeekdays;
dailyWithWeekdays = frequency === Frequency.DAILY;
}
// monthly
let monthlySummary = null;
const bymonth = schedule.bymonth;
if (bymonth) {
if (bymonth === 'weekday') {
const nthWeekday = getNthByWeekday(startDate);
const nth = nthWeekday.startsWith('-1') ? 0 : Number(nthWeekday[1]);
monthlySummary = i18n.CREATE_FORM_WEEKDAY_SHORT(toWeekdayName(nthWeekday))[nth];
monthlySummary = monthlySummary[0].toLocaleLowerCase() + monthlySummary.slice(1);
} else if (bymonth === 'day') {
monthlySummary = i18n.CREATE_FORM_MONTHLY_BY_DAY_SUMMARY(startDate.date());
}
}
// yearly
const yearlyByMonthSummary = i18n.CREATE_FORM_YEARLY_BY_MONTH_SUMMARY(
monthDayDate(moment().month(startDate.month()).date(startDate.date()))
);
const onSummary = dailyWithWeekdays
? dailyWeekdaySummary
: frequency === Frequency.WEEKLY
? weeklySummary
: frequency === Frequency.MONTHLY
? monthlySummary
: frequency === Frequency.YEARLY
? yearlyByMonthSummary
: null;
const untilSummary = schedule.until
? i18n.CREATE_FORM_UNTIL_DATE_SUMMARY(moment(schedule.until).format('LL'))
: schedule.count
? i18n.CREATE_FORM_OCURRENCES_SUMMARY(schedule.count)
: null;
const every = i18n
.CREATE_FORM_RECURRING_SUMMARY(
!dailyWithWeekdays ? frequencySummary : null,
onSummary,
untilSummary
)
.trim();
return every;
let schedule = recurringSchedule;
if (recurringSchedule.frequency !== 'CUSTOM') {
schedule = { ...recurringSchedule, ...presets[recurringSchedule.frequency] };
}
return '';
const frequency = schedule.customFrequency ?? (schedule.frequency as MaintenanceWindowFrequency);
const interval = schedule.interval || 1;
const frequencySummary = i18n.CREATE_FORM_FREQUENCY_SUMMARY(interval)[frequency];
// daily and weekly
let weeklySummary = null;
let dailyWeekdaySummary = null;
let dailyWithWeekdays = false;
const byweekday = schedule.byweekday;
if (byweekday) {
const weekdays = Object.keys(byweekday)
.filter((k) => byweekday[k] === true)
.map((n) => ISO_WEEKDAYS_TO_RRULE[Number(n)]);
const formattedWeekdays = weekdays.map((weekday) => toWeekdayName(weekday)).join(', ');
weeklySummary = i18n.CREATE_FORM_WEEKLY_SUMMARY(formattedWeekdays);
dailyWeekdaySummary = formattedWeekdays;
dailyWithWeekdays = frequency === Frequency.DAILY;
}
// monthly
let monthlySummary = null;
const bymonth = schedule.bymonth;
if (bymonth) {
if (bymonth === 'weekday') {
const nthWeekday = getNthByWeekday(startDate);
const nth = nthWeekday.startsWith('-1') ? 0 : Number(nthWeekday[1]);
monthlySummary = i18n.CREATE_FORM_WEEKDAY_SHORT(toWeekdayName(nthWeekday))[nth];
monthlySummary = monthlySummary[0].toLocaleLowerCase() + monthlySummary.slice(1);
} else if (bymonth === 'day') {
monthlySummary = i18n.CREATE_FORM_MONTHLY_BY_DAY_SUMMARY(startDate.date());
}
}
// yearly
const yearlyByMonthSummary = i18n.CREATE_FORM_YEARLY_BY_MONTH_SUMMARY(
monthDayDate(moment().month(startDate.month()).date(startDate.date()))
);
const onSummary = dailyWithWeekdays
? dailyWeekdaySummary
: frequency === Frequency.WEEKLY
? weeklySummary
: frequency === Frequency.MONTHLY
? monthlySummary
: frequency === Frequency.YEARLY
? yearlyByMonthSummary
: null;
const untilSummary = schedule.until
? i18n.CREATE_FORM_UNTIL_DATE_SUMMARY(moment(schedule.until).format('LL'))
: schedule.count
? i18n.CREATE_FORM_OCURRENCES_SUMMARY(schedule.count)
: null;
const every = i18n
.CREATE_FORM_RECURRING_SUMMARY(
!dailyWithWeekdays ? frequencySummary : null,
onSummary,
untilSummary
)
.trim();
return every;
};
export const toWeekdayName = (weekday: string) =>

View file

@ -13,25 +13,27 @@ export const rRuleSchema = schema.object({
dtstart: schema.string({ validate: validateSnoozeStartDate }),
tzid: schema.string(),
freq: schema.maybe(
schema.number({
validate: (freq: number) => {
if (freq < 0 || freq > 3) return 'rRule freq must be 0, 1, 2, or 3';
},
})
schema.oneOf([schema.literal(0), schema.literal(1), schema.literal(2), schema.literal(3)])
),
interval: schema.maybe(
schema.number({
validate: (interval: number) => {
if (interval < 1) return 'rRule interval must be > 0';
if (!Number.isInteger(interval)) {
return 'rRule interval must be an integer greater than 0';
}
},
min: 1,
})
),
until: schema.maybe(schema.string({ validate: validateSnoozeEndDate })),
count: schema.maybe(
schema.number({
validate: (count: number) => {
if (count < 1) return 'rRule count must be > 0';
if (!Number.isInteger(count)) {
return 'rRule count must be an integer greater than 0';
}
},
min: 1,
})
),
byweekday: schema.maybe(

View file

@ -10,5 +10,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context';
export default ({ loadTestFile }: FtrProviderContext) => {
describe('Maintenance Windows', function () {
loadTestFile(require.resolve('./maintenance_windows_table'));
loadTestFile(require.resolve('./maintenance_window_create_form'));
loadTestFile(require.resolve('./maintenance_window_update_form'));
});
};

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { ObjectRemover } from '../../../lib/object_remover';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const testSubjects = getService('testSubjects');
const supertest = getService('supertest');
const pageObjects = getPageObjects(['common', 'header']);
const retry = getService('retry');
const toasts = getService('toasts');
const objectRemover = new ObjectRemover(supertest);
describe('Maintenance window create form', () => {
beforeEach(async () => {
await pageObjects.common.navigateToApp('maintenanceWindows');
});
after(async () => {
const { body } = await supertest.get('/internal/alerting/rules/maintenance_window/_find');
body?.data?.forEach((mw: { id: string }) => {
objectRemover.add(mw.id, 'rules/maintenance_window', 'alerting', true);
});
await objectRemover.removeAll();
});
it('should create a maintenance window', async () => {
await pageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.click('mw-create-button');
await retry.try(async () => {
await testSubjects.existOrFail('createMaintenanceWindowForm');
});
const nameInput = await testSubjects.find('createMaintenanceWindowFormNameInput');
await nameInput.click();
await nameInput.type('Test Maintenance Window');
// Turn on repeat
await (await testSubjects.find('createMaintenanceWindowRepeatSwitch')).click();
await retry.try(async () => {
await testSubjects.existOrFail('recurringScheduleRepeatSelect');
});
// Open the repeat dropdown select
await (await testSubjects.find('recurringScheduleRepeatSelect')).click();
// Select custom
await (await testSubjects.find('recurringScheduleOptionCustom')).click();
await retry.try(async () => {
await testSubjects.existOrFail('customRecurringScheduleFrequencySelect');
});
// Change interval to 2
const intervalInput = await testSubjects.find('customRecurringScheduleIntervalInput');
await intervalInput.click();
await intervalInput.type('2');
// Open "every" frequency dropdown
await (await testSubjects.find('customRecurringScheduleFrequencySelect')).click();
// Select daily
await (await testSubjects.find('customFrequencyDaily')).click();
// Click on "End -> after {X}"
await (await testSubjects.find('recurrenceEndOptionAfterX')).click();
await retry.try(async () => {
await testSubjects.existOrFail('count-field');
});
const afterXOccurenceInput = await testSubjects.find('recurringScheduleAfterXOccurenceInput');
await afterXOccurenceInput.click();
await afterXOccurenceInput.clearValue();
await afterXOccurenceInput.type('5');
await (await testSubjects.find('create-submit')).click();
await retry.try(async () => {
const toastTitle = await toasts.getTitleAndDismiss();
expect(toastTitle).to.eql(`Created maintenance window 'Test Maintenance Window'`);
});
});
});
};

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { ObjectRemover } from '../../../lib/object_remover';
import { createMaintenanceWindow } from './utils';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const testSubjects = getService('testSubjects');
const supertest = getService('supertest');
const pageObjects = getPageObjects(['common', 'maintenanceWindows', 'header']);
const retry = getService('retry');
const toasts = getService('toasts');
const objectRemover = new ObjectRemover(supertest);
const browser = getService('browser');
describe('Maintenance window update form', () => {
beforeEach(async () => {
await pageObjects.common.navigateToApp('maintenanceWindows');
});
after(async () => {
await objectRemover.removeAll();
});
it('should update a maintenance window', 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: ['management', 'observability', 'securitySolution'],
},
});
objectRemover.add(createdMaintenanceWindow.id, 'rules/maintenance_window', 'alerting', true);
await browser.refresh();
await pageObjects.maintenanceWindows.searchMaintenanceWindows('Test Maintenance Window');
await testSubjects.click('table-actions-popover');
await testSubjects.click('table-actions-edit');
await retry.try(async () => {
await testSubjects.existOrFail('createMaintenanceWindowForm');
});
const nameInput = await testSubjects.find('createMaintenanceWindowFormNameInput');
await nameInput.click();
await nameInput.clearValue();
await nameInput.type('Test Maintenance Window updated');
// Open the repeat dropdown select
await (await testSubjects.find('recurringScheduleRepeatSelect')).click();
// Select daily
await (await testSubjects.find('recurringScheduleOptionDaily')).click();
await (await testSubjects.find('create-submit')).click();
await retry.try(async () => {
const toastTitle = await toasts.getTitleAndDismiss();
expect(toastTitle).to.eql(`Updated maintenance window 'Test Maintenance Window updated'`);
});
});
});
};

View file

@ -9,22 +9,18 @@ import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { ObjectRemover } from '../../../lib/object_remover';
import { generateUniqueKey } from '../../../lib/get_test_data';
import { createMaintenanceWindow, createObjectRemover } from './utils';
import { createMaintenanceWindow } from './utils';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const testSubjects = getService('testSubjects');
const supertest = getService('supertest');
const pageObjects = getPageObjects(['common', 'maintenanceWindows', 'header']);
const retry = getService('retry');
const toasts = getService('toasts');
let objectRemover: ObjectRemover;
const objectRemover = new ObjectRemover(supertest);
const browser = getService('browser');
describe('Maintenance windows table', function () {
before(async () => {
objectRemover = await createObjectRemover({ getService });
});
beforeEach(async () => {
await pageObjects.common.navigateToApp('maintenanceWindows');
});

View file

@ -5,30 +5,20 @@
* 2.0.
*/
import { ObjectRemover } from '../../../lib/object_remover';
import { FtrProviderContext } from '../../../ftr_provider_context';
export const createObjectRemover = async ({
getService,
}: {
getService: FtrProviderContext['getService'];
}) => {
const supertest = getService('supertest');
const objectRemover = new ObjectRemover(supertest);
return objectRemover;
};
export const createMaintenanceWindow = async ({
name,
startDate,
notRecurring,
getService,
overwrite,
}: {
name: string;
startDate?: Date;
notRecurring?: boolean;
getService: FtrProviderContext['getService'];
overwrite?: Record<string, any>;
}) => {
const supertest = getService('supertest');
const dtstart = startDate ? startDate : new Date();
@ -40,6 +30,7 @@ export const createMaintenanceWindow = async ({
tzid: 'UTC',
...(notRecurring ? { freq: 1, count: 1 } : { freq: 2 }),
},
...overwrite,
};
const { body } = await supertest