Skip to content

Commit c97aedb

Browse files
[8.19] [ResponseOps][MaintenanceWindows] Move recurring schedule form to dedicated package (#220535) (#223203)
# Backport This will backport the following commits from `main` to `8.19`: - [[ResponseOps][MaintenanceWindows] Move recurring schedule form to dedicated package (#220535)](#220535) <!--- Backport version: 10.0.0 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Umberto Pepato","email":"[email protected]"},"sourceCommit":{"committedDate":"2025-06-10T08:50:22Z","message":"[ResponseOps][MaintenanceWindows] Move recurring schedule form to dedicated package (#220535)\n\n## Summary\n\nMoves the recurring schedule (rrule) form from the Maintenance Windows\nform to a dedicated package.\n\n## Implementation details\n\n~~Because of the tight time constraints we have for this epic, I tried\nto change the recurring schedule form as little as possible, keeping the\nexisting form-lib-based implementation and adding an intermediate layer\nto embed the editing UI in larger forms (i.e. MW form). While this is\nnot particularly elegant, it allows us to build on top of an existing,\ntested codebase and to leverage form-lib's validation and state\nmanagement capabilities.~~\n\n~~In the context of the MW form, the recurring schedule editor is\ntreated as a single field. Value and error updates are synced through\nform-lib's field hook API:~~\n\n\nhttps://github.com/elastic/kibana/blob/acd6a4823e5b9c53ae7cbed59a246972ab32cfe0/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/recurring_schedule_field.tsx#L21-L34\n\n~~Errors are handled internally by the schedule editor, but must still\nbe surfaced up to the parent form in order to have a consistent validity\nstate (otherwise submits are not prevented even if errors are shown in\nthe UI).~~\n\nThe recurring schedule form schema and form fields are exported to be\nreused in larger form-lib forms.\n\n## References\n\nCloses #219454\n\n### Checklist\n\n- [x] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n\n---------\n\nCo-authored-by: kibanamachine <[email protected]>","sha":"664a435e6c20444217c9215bfa280c3f8fe9320e","branchLabelMapping":{"^v9.1.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:ResponseOps","backport:version","v9.1.0","v8.19.0"],"title":"[ResponseOps][MaintenanceWindows] Move recurring schedule form to dedicated package","number":220535,"url":"https://github.com/elastic/kibana/pull/220535","mergeCommit":{"message":"[ResponseOps][MaintenanceWindows] Move recurring schedule form to dedicated package (#220535)\n\n## Summary\n\nMoves the recurring schedule (rrule) form from the Maintenance Windows\nform to a dedicated package.\n\n## Implementation details\n\n~~Because of the tight time constraints we have for this epic, I tried\nto change the recurring schedule form as little as possible, keeping the\nexisting form-lib-based implementation and adding an intermediate layer\nto embed the editing UI in larger forms (i.e. MW form). While this is\nnot particularly elegant, it allows us to build on top of an existing,\ntested codebase and to leverage form-lib's validation and state\nmanagement capabilities.~~\n\n~~In the context of the MW form, the recurring schedule editor is\ntreated as a single field. Value and error updates are synced through\nform-lib's field hook API:~~\n\n\nhttps://github.com/elastic/kibana/blob/acd6a4823e5b9c53ae7cbed59a246972ab32cfe0/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/recurring_schedule_field.tsx#L21-L34\n\n~~Errors are handled internally by the schedule editor, but must still\nbe surfaced up to the parent form in order to have a consistent validity\nstate (otherwise submits are not prevented even if errors are shown in\nthe UI).~~\n\nThe recurring schedule form schema and form fields are exported to be\nreused in larger form-lib forms.\n\n## References\n\nCloses #219454\n\n### Checklist\n\n- [x] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n\n---------\n\nCo-authored-by: kibanamachine <[email protected]>","sha":"664a435e6c20444217c9215bfa280c3f8fe9320e"}},"sourceBranch":"main","suggestedTargetBranches":["8.19"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/220535","number":220535,"mergeCommit":{"message":"[ResponseOps][MaintenanceWindows] Move recurring schedule form to dedicated package (#220535)\n\n## Summary\n\nMoves the recurring schedule (rrule) form from the Maintenance Windows\nform to a dedicated package.\n\n## Implementation details\n\n~~Because of the tight time constraints we have for this epic, I tried\nto change the recurring schedule form as little as possible, keeping the\nexisting form-lib-based implementation and adding an intermediate layer\nto embed the editing UI in larger forms (i.e. MW form). While this is\nnot particularly elegant, it allows us to build on top of an existing,\ntested codebase and to leverage form-lib's validation and state\nmanagement capabilities.~~\n\n~~In the context of the MW form, the recurring schedule editor is\ntreated as a single field. Value and error updates are synced through\nform-lib's field hook API:~~\n\n\nhttps://github.com/elastic/kibana/blob/acd6a4823e5b9c53ae7cbed59a246972ab32cfe0/x-pack/platform/plugins/shared/alerting/public/pages/maintenance_windows/components/recurring_schedule_field.tsx#L21-L34\n\n~~Errors are handled internally by the schedule editor, but must still\nbe surfaced up to the parent form in order to have a consistent validity\nstate (otherwise submits are not prevented even if errors are shown in\nthe UI).~~\n\nThe recurring schedule form schema and form fields are exported to be\nreused in larger form-lib forms.\n\n## References\n\nCloses #219454\n\n### Checklist\n\n- [x] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n\n---------\n\nCo-authored-by: kibanamachine <[email protected]>","sha":"664a435e6c20444217c9215bfa280c3f8fe9320e"}},{"branch":"8.19","label":"v8.19.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> --------- Co-authored-by: kibanamachine <[email protected]>
1 parent 7fea362 commit c97aedb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1548
-1287
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ src/platform/packages/shared/response-ops/alerts-apis @elastic/response-ops
778778
src/platform/packages/shared/response-ops/alerts-fields-browser @elastic/response-ops
779779
src/platform/packages/shared/response-ops/alerts-filters-form @elastic/response-ops
780780
src/platform/packages/shared/response-ops/alerts-table @elastic/response-ops
781+
src/platform/packages/shared/response-ops/recurring-schedule-form @elastic/response-ops
781782
src/platform/packages/shared/response-ops/rule_form @elastic/response-ops
782783
src/platform/packages/shared/response-ops/rule_params @elastic/response-ops
783784
src/platform/packages/shared/response-ops/rules-apis @elastic/response-ops

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,7 @@
789789
"@kbn/response-ops-alerts-fields-browser": "link:src/platform/packages/shared/response-ops/alerts-fields-browser",
790790
"@kbn/response-ops-alerts-filters-form": "link:src/platform/packages/shared/response-ops/alerts-filters-form",
791791
"@kbn/response-ops-alerts-table": "link:src/platform/packages/shared/response-ops/alerts-table",
792+
"@kbn/response-ops-recurring-schedule-form": "link:src/platform/packages/shared/response-ops/recurring-schedule-form",
792793
"@kbn/response-ops-rule-form": "link:src/platform/packages/shared/response-ops/rule_form",
793794
"@kbn/response-ops-rule-params": "link:src/platform/packages/shared/response-ops/rule_params",
794795
"@kbn/response-ops-rules-apis": "link:src/platform/packages/shared/response-ops/rules-apis",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# @kbn/response-ops-recurring-schedule-form
2+
3+
[Form lib](https://docs.elastic.dev/form-lib/welcome) fields to create RRule recurring schedules.
4+
5+
## Usage
6+
7+
In your form schema, add a `recurringSchedule` field:
8+
9+
```tsx
10+
const form = useForm({
11+
schema: {
12+
recurringSchedule: getRecurringScheduleFormSchema(),
13+
}
14+
});
15+
```
16+
17+
And render `RecurringScheduleFormFields` in your form:
18+
19+
```tsx
20+
<RecurringScheduleFormFields
21+
startDate={startDate}
22+
endDate={endDate}
23+
timezone={timezone}
24+
/>
25+
```
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import React, { PropsWithChildren } from 'react';
11+
import { fireEvent, render, within, screen } from '@testing-library/react';
12+
import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
13+
import { Frequency } from '@kbn/rrule';
14+
import type { RecurringSchedule } from '../types';
15+
import { getRecurringScheduleFormSchema } from '../schemas/recurring_schedule_form_schema';
16+
import { CustomRecurringSchedule } from './custom_recurring_schedule';
17+
import { RecurrenceEnd } from '../constants';
18+
19+
interface FormValue {
20+
recurringSchedule: RecurringSchedule;
21+
}
22+
23+
const initialValue: FormValue = {
24+
recurringSchedule: {
25+
frequency: 'CUSTOM',
26+
ends: RecurrenceEnd.NEVER,
27+
},
28+
};
29+
30+
const TestWrapper = ({ children, iv = initialValue }: PropsWithChildren<{ iv?: FormValue }>) => {
31+
const { form } = useForm<FormValue>({
32+
defaultValue: iv,
33+
options: { stripEmptyFields: false },
34+
schema: { recurringSchedule: getRecurringScheduleFormSchema() },
35+
});
36+
37+
return <Form form={form}>{children}</Form>;
38+
};
39+
40+
describe('CustomRecurringSchedule', () => {
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
});
44+
45+
it('renders all form fields', async () => {
46+
render(
47+
<TestWrapper>
48+
<CustomRecurringSchedule />
49+
</TestWrapper>
50+
);
51+
52+
expect(screen.getByTestId('interval-field')).toBeInTheDocument();
53+
expect(screen.getByTestId('custom-frequency-field')).toBeInTheDocument();
54+
expect(screen.getByTestId('byweekday-field')).toBeInTheDocument();
55+
expect(screen.queryByTestId('bymonth-field')).not.toBeInTheDocument();
56+
});
57+
58+
it('renders byweekday field if custom frequency = weekly', async () => {
59+
render(
60+
<TestWrapper>
61+
<CustomRecurringSchedule />
62+
</TestWrapper>
63+
);
64+
65+
fireEvent.change(
66+
within(screen.getByTestId('custom-frequency-field')).getByTestId(
67+
'customRecurringScheduleFrequencySelect'
68+
),
69+
{
70+
target: { value: Frequency.WEEKLY },
71+
}
72+
);
73+
expect(await screen.findByTestId('byweekday-field')).toBeInTheDocument();
74+
});
75+
76+
it('renders byweekday field if frequency = daily', async () => {
77+
const iv: FormValue = {
78+
recurringSchedule: {
79+
...initialValue,
80+
frequency: Frequency.DAILY,
81+
ends: RecurrenceEnd.NEVER,
82+
},
83+
};
84+
render(
85+
<TestWrapper iv={iv}>
86+
<CustomRecurringSchedule />
87+
</TestWrapper>
88+
);
89+
90+
expect(screen.getByTestId('byweekday-field')).toBeInTheDocument();
91+
});
92+
93+
it('renders bymonth field if custom frequency = monthly', async () => {
94+
render(
95+
<TestWrapper>
96+
<CustomRecurringSchedule />
97+
</TestWrapper>
98+
);
99+
100+
fireEvent.change(
101+
within(screen.getByTestId('custom-frequency-field')).getByTestId(
102+
'customRecurringScheduleFrequencySelect'
103+
),
104+
{
105+
target: { value: Frequency.MONTHLY },
106+
}
107+
);
108+
expect(await screen.findByTestId('bymonth-field')).toBeInTheDocument();
109+
});
110+
111+
it('should initialize the form when no initialValue provided', () => {
112+
render(
113+
<TestWrapper>
114+
<CustomRecurringSchedule />
115+
</TestWrapper>
116+
);
117+
118+
const frequencyInput = within(screen.getByTestId('custom-frequency-field')).getByTestId(
119+
'customRecurringScheduleFrequencySelect'
120+
);
121+
const intervalInput = within(screen.getByTestId('interval-field')).getByTestId(
122+
'customRecurringScheduleIntervalInput'
123+
);
124+
125+
expect(frequencyInput).toHaveValue('2');
126+
expect(intervalInput).toHaveValue(1);
127+
});
128+
129+
it('should prefill the form when provided with initialValue', () => {
130+
const iv: FormValue = {
131+
recurringSchedule: {
132+
...initialValue,
133+
frequency: 'CUSTOM',
134+
ends: RecurrenceEnd.NEVER,
135+
customFrequency: Frequency.WEEKLY,
136+
interval: 3,
137+
byweekday: { 1: false, 2: false, 3: true, 4: true, 5: false, 6: false, 7: false },
138+
},
139+
};
140+
render(
141+
<TestWrapper iv={iv}>
142+
<CustomRecurringSchedule />
143+
</TestWrapper>
144+
);
145+
146+
const frequencyInput = within(screen.getByTestId('custom-frequency-field')).getByTestId(
147+
'customRecurringScheduleFrequencySelect'
148+
);
149+
const intervalInput = within(screen.getByTestId('interval-field')).getByTestId(
150+
'customRecurringScheduleIntervalInput'
151+
);
152+
const input3 = within(screen.getByTestId('byweekday-field'))
153+
.getByTestId('isoWeekdays3')
154+
.getAttribute('aria-pressed');
155+
const input4 = within(screen.getByTestId('byweekday-field'))
156+
.getByTestId('isoWeekdays4')
157+
.getAttribute('aria-pressed');
158+
expect(frequencyInput).toHaveValue('2');
159+
expect(intervalInput).toHaveValue(3);
160+
expect(input3).toBe('true');
161+
expect(input4).toBe('true');
162+
});
163+
});
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import React, { useMemo } from 'react';
11+
import { Frequency } from '@kbn/rrule';
12+
import moment from 'moment';
13+
import { css } from '@emotion/react';
14+
import {
15+
FIELD_TYPES,
16+
getUseField,
17+
useFormData,
18+
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
19+
import type { MultiButtonGroupFieldValue } from '@kbn/es-ui-shared-plugin/static/forms/components';
20+
import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components';
21+
import { EuiFlexGroup, EuiFlexItem, EuiFormLabel, EuiSpacer } from '@elastic/eui';
22+
import { RECURRING_SCHEDULE_FORM_CUSTOM_FREQUENCY, WEEKDAY_OPTIONS } from '../constants';
23+
import { getInitialByWeekday } from '../utils/get_initial_by_weekday';
24+
import { parseSchedule } from '../utils/parse_schedule';
25+
import { getWeekdayInfo } from '../utils/get_weekday_info';
26+
import { RecurringSchedule } from '../types';
27+
import {
28+
RECURRING_SCHEDULE_FORM_CUSTOM_REPEAT_MONTHLY_ON_DAY,
29+
RECURRING_SCHEDULE_FORM_WEEKDAY_SHORT,
30+
RECURRING_SCHEDULE_FORM_INTERVAL_EVERY,
31+
RECURRING_SCHEDULE_FORM_BYWEEKDAY_REQUIRED,
32+
} from '../translations';
33+
34+
const UseField = getUseField({ component: Field });
35+
36+
const styles = {
37+
flexField: css`
38+
.euiFormRow__labelWrapper {
39+
margin-bottom: unset;
40+
}
41+
`,
42+
};
43+
44+
export interface CustomRecurringScheduleProps {
45+
startDate?: string;
46+
}
47+
48+
export const CustomRecurringSchedule: React.FC = React.memo(
49+
({ startDate }: CustomRecurringScheduleProps) => {
50+
const [{ recurringSchedule }] = useFormData<{ recurringSchedule: RecurringSchedule }>({
51+
watch: [
52+
'recurringSchedule.frequency',
53+
'recurringSchedule.interval',
54+
'recurringSchedule.customFrequency',
55+
],
56+
});
57+
58+
const parsedSchedule = useMemo(() => {
59+
return parseSchedule(recurringSchedule);
60+
}, [recurringSchedule]);
61+
62+
const frequencyOptions = useMemo(
63+
() => RECURRING_SCHEDULE_FORM_CUSTOM_FREQUENCY(parsedSchedule?.interval),
64+
[parsedSchedule?.interval]
65+
);
66+
67+
const bymonthOptions = useMemo(() => {
68+
if (!startDate) return [];
69+
const date = moment(startDate);
70+
const { dayOfWeek, nthWeekdayOfMonth, isLastOfMonth } = getWeekdayInfo(date, 'ddd');
71+
return [
72+
{
73+
id: 'day',
74+
label: RECURRING_SCHEDULE_FORM_CUSTOM_REPEAT_MONTHLY_ON_DAY(date),
75+
},
76+
{
77+
id: 'weekday',
78+
label:
79+
RECURRING_SCHEDULE_FORM_WEEKDAY_SHORT(dayOfWeek)[isLastOfMonth ? 0 : nthWeekdayOfMonth],
80+
},
81+
];
82+
}, [startDate]);
83+
84+
const defaultByWeekday = useMemo(() => getInitialByWeekday([], moment(startDate)), [startDate]);
85+
86+
return (
87+
<>
88+
{parsedSchedule?.frequency !== Frequency.DAILY ? (
89+
<>
90+
<EuiSpacer size="s" />
91+
<EuiFlexGroup gutterSize="s" alignItems="flexStart">
92+
<EuiFlexItem>
93+
<UseField
94+
path="recurringSchedule.interval"
95+
css={styles.flexField}
96+
componentProps={{
97+
'data-test-subj': 'interval-field',
98+
id: 'interval',
99+
euiFieldProps: {
100+
'data-test-subj': 'customRecurringScheduleIntervalInput',
101+
min: 1,
102+
prepend: (
103+
<EuiFormLabel htmlFor={'interval'}>
104+
{RECURRING_SCHEDULE_FORM_INTERVAL_EVERY}
105+
</EuiFormLabel>
106+
),
107+
},
108+
}}
109+
/>
110+
</EuiFlexItem>
111+
<EuiFlexItem>
112+
<UseField
113+
path="recurringSchedule.customFrequency"
114+
componentProps={{
115+
'data-test-subj': 'custom-frequency-field',
116+
euiFieldProps: {
117+
'data-test-subj': 'customRecurringScheduleFrequencySelect',
118+
options: frequencyOptions,
119+
},
120+
}}
121+
/>
122+
</EuiFlexItem>
123+
</EuiFlexGroup>
124+
<EuiSpacer size="s" />
125+
</>
126+
) : null}
127+
{Number(parsedSchedule?.customFrequency) === Frequency.WEEKLY ||
128+
parsedSchedule?.frequency === Frequency.DAILY ? (
129+
<UseField
130+
path="recurringSchedule.byweekday"
131+
config={{
132+
type: FIELD_TYPES.MULTI_BUTTON_GROUP,
133+
label: '',
134+
validations: [
135+
{
136+
validator: ({ value }) => {
137+
if (
138+
Object.values(value as MultiButtonGroupFieldValue).every((v) => v === false)
139+
) {
140+
return {
141+
message: RECURRING_SCHEDULE_FORM_BYWEEKDAY_REQUIRED,
142+
};
143+
}
144+
},
145+
},
146+
],
147+
defaultValue: defaultByWeekday,
148+
}}
149+
componentProps={{
150+
'data-test-subj': 'byweekday-field',
151+
euiFieldProps: {
152+
'data-test-subj': 'customRecurringScheduleByWeekdayButtonGroup',
153+
legend: 'Repeat on weekday',
154+
options: WEEKDAY_OPTIONS,
155+
},
156+
}}
157+
/>
158+
) : null}
159+
160+
{Number(parsedSchedule?.customFrequency) === Frequency.MONTHLY ? (
161+
<UseField
162+
path="recurringSchedule.bymonth"
163+
componentProps={{
164+
'data-test-subj': 'bymonth-field',
165+
euiFieldProps: {
166+
legend: 'Repeat on weekday or month day',
167+
options: bymonthOptions,
168+
},
169+
}}
170+
/>
171+
) : null}
172+
</>
173+
);
174+
}
175+
);
176+
177+
CustomRecurringSchedule.displayName = 'CustomRecurringSchedule';

0 commit comments

Comments
 (0)