Skip to content

Commit b9a740d

Browse files
committed
feat(schedules): add retry policy fields to advanced create form
1 parent e2b424a commit b9a740d

8 files changed

Lines changed: 517 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { type Theme } from 'baseui';
2+
import { type CheckboxOverrides } from 'baseui/checkbox';
3+
import { type StyleObject } from 'styletron-react';
4+
5+
export const overrides = {
6+
toggle: {
7+
Root: {
8+
style: ({ $theme }: { $theme: Theme }): StyleObject => ({
9+
marginTop: 0,
10+
marginBottom: 0,
11+
marginLeft: 0,
12+
marginRight: 0,
13+
}),
14+
},
15+
} satisfies CheckboxOverrides,
16+
};
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
'use client';
2+
3+
import React from 'react';
4+
5+
import { Checkbox } from 'baseui/checkbox';
6+
import { Input } from 'baseui/input';
7+
import { Radio, RadioGroup } from 'baseui/radio';
8+
import { LabelXSmall } from 'baseui/typography';
9+
import { Controller, useWatch } from 'react-hook-form';
10+
11+
import DomainSchedulesHorizontalField from '@/views/domain-schedules/domain-schedules-horizontal-field/domain-schedules-horizontal-field';
12+
import getFieldErrorMessage from '@/views/workflow-actions/workflow-action-start-form/helpers/get-field-error-message';
13+
14+
import { overrides } from './create-schedule-retry-policy-fields.styles';
15+
import { type Props } from './create-schedule-retry-policy-fields.types';
16+
17+
export default function CreateScheduleRetryPolicyFields({
18+
control,
19+
clearErrors,
20+
fieldErrors,
21+
}: Props) {
22+
const enableRetryPolicy = useWatch({
23+
control,
24+
name: 'enableRetryPolicy',
25+
defaultValue: false,
26+
});
27+
const limitRetries = useWatch({
28+
control,
29+
name: 'limitRetries',
30+
defaultValue: 'ATTEMPTS',
31+
});
32+
33+
return (
34+
<>
35+
<DomainSchedulesHorizontalField
36+
label="Retry policy"
37+
description="Controls retry behavior for the started workflow."
38+
>
39+
<Controller
40+
name="enableRetryPolicy"
41+
control={control}
42+
defaultValue={false}
43+
render={({ field: { value, onChange, ref, ...field } }) => (
44+
<Checkbox
45+
{...field}
46+
// @ts-expect-error - inputRef expects ref object while ref is a callback. It should support both.
47+
inputRef={ref}
48+
aria-label="Enable Retry Policy"
49+
checked={value}
50+
checkmarkType="toggle"
51+
labelPlacement="right"
52+
overrides={overrides.toggle}
53+
onChange={(e) => {
54+
clearErrors('retryPolicy.initialIntervalSeconds');
55+
clearErrors('retryPolicy.backoffCoefficient');
56+
clearErrors('retryPolicy.maximumIntervalSeconds');
57+
clearErrors('retryPolicy.maximumAttempts');
58+
clearErrors('retryPolicy.expirationIntervalSeconds');
59+
onChange(e.currentTarget.checked);
60+
}}
61+
error={Boolean(
62+
getFieldErrorMessage(fieldErrors, 'enableRetryPolicy')
63+
)}
64+
>
65+
Enable retry policy
66+
</Checkbox>
67+
)}
68+
/>
69+
</DomainSchedulesHorizontalField>
70+
71+
{enableRetryPolicy && (
72+
<DomainSchedulesHorizontalField.GroupedFields>
73+
<DomainSchedulesHorizontalField
74+
label="Initial interval"
75+
description="How long to wait before first retry."
76+
htmlFor="create-schedule-form-retry-initial-interval"
77+
error={getFieldErrorMessage(fieldErrors, 'retryPolicy.initialIntervalSeconds')}
78+
>
79+
<Controller
80+
name="retryPolicy.initialIntervalSeconds"
81+
control={control}
82+
render={({ field: { ref, ...field } }) => (
83+
<Input
84+
{...field}
85+
id="create-schedule-form-retry-initial-interval"
86+
value={field.value ?? ''}
87+
// @ts-expect-error - inputRef expects ref object while ref is a callback. It should support both.
88+
inputRef={ref}
89+
aria-label="Initial interval"
90+
type="number"
91+
min={1}
92+
onChange={(e) =>
93+
field.onChange(
94+
e.target.value === ''
95+
? undefined
96+
: parseInt(e.target.value, 10)
97+
)
98+
}
99+
onBlur={field.onBlur}
100+
error={Boolean(
101+
getFieldErrorMessage(
102+
fieldErrors,
103+
'retryPolicy.initialIntervalSeconds'
104+
)
105+
)}
106+
size="compact"
107+
placeholder="Enter initial interval"
108+
endEnhancer={<LabelXSmall>Seconds</LabelXSmall>}
109+
/>
110+
)}
111+
/>
112+
</DomainSchedulesHorizontalField>
113+
114+
<DomainSchedulesHorizontalField
115+
label="Backoff coefficient"
116+
description="Multiplier applied between retries."
117+
htmlFor="create-schedule-form-retry-backoff-coefficient"
118+
error={getFieldErrorMessage(fieldErrors, 'retryPolicy.backoffCoefficient')}
119+
>
120+
<Controller
121+
name="retryPolicy.backoffCoefficient"
122+
control={control}
123+
render={({ field: { ref, ...field } }) => (
124+
<Input
125+
{...field}
126+
id="create-schedule-form-retry-backoff-coefficient"
127+
value={field.value ?? ''}
128+
// @ts-expect-error - inputRef expects ref object while ref is a callback. It should support both.
129+
inputRef={ref}
130+
aria-label="Backoff coefficient"
131+
type="number"
132+
step={0.1}
133+
min={1}
134+
onChange={(e) =>
135+
field.onChange(
136+
e.target.value === ''
137+
? undefined
138+
: parseFloat(e.target.value)
139+
)
140+
}
141+
onBlur={field.onBlur}
142+
error={Boolean(
143+
getFieldErrorMessage(
144+
fieldErrors,
145+
'retryPolicy.backoffCoefficient'
146+
)
147+
)}
148+
size="compact"
149+
placeholder="Enter backoff coefficient"
150+
/>
151+
)}
152+
/>
153+
</DomainSchedulesHorizontalField>
154+
155+
<DomainSchedulesHorizontalField
156+
label="Maximum interval (optional)"
157+
description="Upper bound for retry interval."
158+
htmlFor="create-schedule-form-retry-maximum-interval"
159+
error={getFieldErrorMessage(fieldErrors, 'retryPolicy.maximumIntervalSeconds')}
160+
>
161+
<Controller
162+
name="retryPolicy.maximumIntervalSeconds"
163+
control={control}
164+
render={({ field: { ref, ...field } }) => (
165+
<Input
166+
{...field}
167+
id="create-schedule-form-retry-maximum-interval"
168+
value={field.value ?? ''}
169+
// @ts-expect-error - inputRef expects ref object while ref is a callback. It should support both.
170+
inputRef={ref}
171+
aria-label="Maximum interval"
172+
type="number"
173+
min={1}
174+
onChange={(e) =>
175+
field.onChange(
176+
e.target.value === ''
177+
? undefined
178+
: parseInt(e.target.value, 10)
179+
)
180+
}
181+
onBlur={field.onBlur}
182+
error={Boolean(
183+
getFieldErrorMessage(
184+
fieldErrors,
185+
'retryPolicy.maximumIntervalSeconds'
186+
)
187+
)}
188+
size="compact"
189+
placeholder="Enter maximum interval"
190+
endEnhancer={<LabelXSmall>Seconds</LabelXSmall>}
191+
/>
192+
)}
193+
/>
194+
</DomainSchedulesHorizontalField>
195+
196+
<DomainSchedulesHorizontalField
197+
label="Limit retries"
198+
description="Choose whether retries are limited by attempts or duration."
199+
error={getFieldErrorMessage(fieldErrors, 'limitRetries')}
200+
>
201+
<Controller
202+
name="limitRetries"
203+
control={control}
204+
defaultValue="ATTEMPTS"
205+
render={({ field: { value, onChange, ref, ...field } }) => (
206+
<RadioGroup
207+
{...field}
208+
// @ts-expect-error - inputRef expects ref object while ref is a callback. It should support both.
209+
inputRef={ref}
210+
aria-label="Retries Limit"
211+
value={value}
212+
onChange={(e) => {
213+
clearErrors('retryPolicy.maximumAttempts');
214+
clearErrors('retryPolicy.expirationIntervalSeconds');
215+
onChange(e.currentTarget.value);
216+
}}
217+
error={Boolean(getFieldErrorMessage(fieldErrors, 'limitRetries'))}
218+
align="horizontal"
219+
>
220+
<Radio value="ATTEMPTS">Attempts</Radio>
221+
<Radio value="DURATION">Duration</Radio>
222+
</RadioGroup>
223+
)}
224+
/>
225+
</DomainSchedulesHorizontalField>
226+
227+
{limitRetries === 'ATTEMPTS' && (
228+
<DomainSchedulesHorizontalField
229+
label="Maximum attempts"
230+
description="Total number of retry attempts."
231+
htmlFor="create-schedule-form-retry-maximum-attempts"
232+
error={getFieldErrorMessage(fieldErrors, 'retryPolicy.maximumAttempts')}
233+
>
234+
<Controller
235+
name="retryPolicy.maximumAttempts"
236+
control={control}
237+
render={({ field: { ref, ...field } }) => (
238+
<Input
239+
{...field}
240+
id="create-schedule-form-retry-maximum-attempts"
241+
value={field.value ?? ''}
242+
// @ts-expect-error - inputRef expects ref object while ref is a callback. It should support both.
243+
inputRef={ref}
244+
aria-label="Maximum attempts"
245+
type="number"
246+
min={1}
247+
onChange={(e) =>
248+
field.onChange(
249+
e.target.value === ''
250+
? undefined
251+
: parseInt(e.target.value, 10)
252+
)
253+
}
254+
onBlur={field.onBlur}
255+
error={Boolean(
256+
getFieldErrorMessage(fieldErrors, 'retryPolicy.maximumAttempts')
257+
)}
258+
size="compact"
259+
placeholder="Enter maximum attempts"
260+
/>
261+
)}
262+
/>
263+
</DomainSchedulesHorizontalField>
264+
)}
265+
266+
{limitRetries === 'DURATION' && (
267+
<DomainSchedulesHorizontalField
268+
label="Expiration interval"
269+
description="Maximum total retry duration."
270+
htmlFor="create-schedule-form-retry-expiration-interval"
271+
error={getFieldErrorMessage(
272+
fieldErrors,
273+
'retryPolicy.expirationIntervalSeconds'
274+
)}
275+
>
276+
<Controller
277+
name="retryPolicy.expirationIntervalSeconds"
278+
control={control}
279+
render={({ field: { ref, ...field } }) => (
280+
<Input
281+
{...field}
282+
id="create-schedule-form-retry-expiration-interval"
283+
value={field.value ?? ''}
284+
// @ts-expect-error - inputRef expects ref object while ref is a callback. It should support both.
285+
inputRef={ref}
286+
aria-label="Expiration interval"
287+
type="number"
288+
min={1}
289+
onChange={(e) =>
290+
field.onChange(
291+
e.target.value === ''
292+
? undefined
293+
: parseInt(e.target.value, 10)
294+
)
295+
}
296+
onBlur={field.onBlur}
297+
error={Boolean(
298+
getFieldErrorMessage(
299+
fieldErrors,
300+
'retryPolicy.expirationIntervalSeconds'
301+
)
302+
)}
303+
size="compact"
304+
placeholder="Enter expiration interval"
305+
endEnhancer={<LabelXSmall>Seconds</LabelXSmall>}
306+
/>
307+
)}
308+
/>
309+
</DomainSchedulesHorizontalField>
310+
)}
311+
</DomainSchedulesHorizontalField.GroupedFields>
312+
)}
313+
</>
314+
);
315+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {
2+
type Control,
3+
type FieldErrors,
4+
type UseFormClearErrors,
5+
} from 'react-hook-form';
6+
7+
import { type DomainSchedulesCreateFormData } from '@/views/domain-schedules/domain-schedules-create-modal/domain-schedules-create-modal.types';
8+
9+
export type Props = {
10+
control: Control<DomainSchedulesCreateFormData>;
11+
clearErrors: UseFormClearErrors<DomainSchedulesCreateFormData>;
12+
fieldErrors: FieldErrors<DomainSchedulesCreateFormData>;
13+
};

src/views/domain-schedules/domain-schedules-create-advanced-form/__tests__/domain-schedules-create-advanced-form.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,34 @@ describe(DomainSchedulesCreateAdvancedForm.name, () => {
198198
await screen.findByText('Start date must be before end date')
199199
).toBeInTheDocument();
200200
});
201+
202+
it('shows retry sub-fields only after enabling retry policy', async () => {
203+
const { user } = setup();
204+
205+
await user.click(
206+
screen.getByRole('button', { name: /show advanced configurations/i })
207+
);
208+
209+
expect(screen.queryByLabelText('Initial interval')).not.toBeInTheDocument();
210+
211+
await user.click(screen.getByRole('checkbox', { name: /enable retry policy/i }));
212+
expect(screen.getByLabelText('Initial interval')).toBeInTheDocument();
213+
expect(screen.getByLabelText('Maximum attempts')).toBeInTheDocument();
214+
});
215+
216+
it('switches retry limit field between attempts and duration', async () => {
217+
const { user } = setup();
218+
219+
await user.click(
220+
screen.getByRole('button', { name: /show advanced configurations/i })
221+
);
222+
await user.click(screen.getByRole('checkbox', { name: /enable retry policy/i }));
223+
224+
expect(screen.getByLabelText('Maximum attempts')).toBeInTheDocument();
225+
await user.click(screen.getByRole('radio', { name: 'Duration' }));
226+
expect(screen.getByLabelText('Expiration interval')).toBeInTheDocument();
227+
expect(screen.queryByLabelText('Maximum attempts')).not.toBeInTheDocument();
228+
});
201229
});
202230

203231
function setup() {

0 commit comments

Comments
 (0)