@centreon/ui 26.10.7 → 26.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/package.json +6 -3
  2. package/public/mockServiceWorker.js +31 -8
  3. package/src/Dashboard/Dashboard.browser.test.tsx +107 -0
  4. package/src/Form/Form.browser.test.tsx +445 -0
  5. package/src/Form/Inputs/ConnectedAutocomplete.tsx +12 -4
  6. package/src/Form/Inputs/ExclusiveCheckboxGroup.browser.test.tsx +304 -0
  7. package/src/Form/Inputs/ExclusiveCheckboxGroup.tsx +60 -15
  8. package/src/Form/Inputs/LoadingSkeleton.tsx +4 -0
  9. package/src/Form/Inputs/SegmentedButtons.browser.test.tsx +113 -0
  10. package/src/Form/Inputs/SegmentedButtons.tsx +71 -0
  11. package/src/Form/Inputs/index.tsx +5 -0
  12. package/src/Form/Inputs/models.ts +16 -1
  13. package/src/InputField/Number/Number.browser.test.tsx +109 -0
  14. package/src/InputField/Select/Autocomplete/Connected/Multi/MultiConnectedAutocompleteField.browser.test.tsx +250 -0
  15. package/src/InputField/Select/Autocomplete/Connected/index.test.tsx +37 -0
  16. package/src/InputField/Select/Autocomplete/Connected/index.tsx +59 -45
  17. package/src/Listing/Cell/DataCell.tsx +0 -2
  18. package/src/Listing/Listing.browser.test.tsx +476 -0
  19. package/src/Module/Module.browser.test.tsx +145 -0
  20. package/src/Pagination/Pagination.browser.test.tsx +184 -0
  21. package/src/RichTextEditor/RichTextEditor.browser.test.tsx +567 -0
  22. package/src/RichTextEditor/plugins/FloatingLinkEditorPlugin.tsx +3 -1
  23. package/src/TimePeriods/ResolutionTimePeriod.browser.test.tsx +143 -0
  24. package/src/TimePeriods/TimePeriods.browser.test.tsx +468 -0
  25. package/src/Typography/FluidTypography/FluidTypography.browser.test.tsx +54 -0
  26. package/src/api/useMutationQuery/useMutationQuery.browser.test.tsx +154 -0
  27. package/src/components/CollapsibleItem/CollapsibleItem.browser.test.tsx +120 -0
  28. package/src/components/CopyCommand/CopyCommand.browser.test.tsx +287 -0
  29. package/src/components/CrudPage/CrudPage.browser.test.tsx +659 -0
  30. package/src/components/DataTable/DataTable.browser.test.tsx +257 -0
  31. package/src/components/Form/AccessRights/AccessRights.browser.test.tsx +527 -0
  32. package/src/components/ItemComposition/ItemComposition.browser.test.tsx +172 -0
  33. package/src/components/Layout/PageLayout.browser.test.tsx +114 -0
  34. package/src/components/Modal/ConfirmationModal/ConfirmationModal.browser.test.tsx +237 -0
  35. package/src/components/SortableEntriesList/SortableEntriesList.browser.test.tsx +981 -0
  36. package/src/components/Tabs/Tabs.browser.test.tsx +115 -0
  37. package/src/utils/useFullscreen/Fullscreen.browser.test.tsx +176 -0
  38. package/src/utils/useLocale/useLocale.browser.test.tsx +49 -0
  39. package/src/utils/useLocaleTimezoneDate/{LocaleTimezoneDate.cypress.spec.tsx → LocaleTimezoneDate.browser.test.tsx} +24 -11
  40. package/src/Dashboard/Dashboard.cypress.spec.tsx +0 -71
  41. package/src/Form/Form.cypress.spec.tsx +0 -306
  42. package/src/Form/Inputs/ExclusiveCheckboxGroup.cypress.spec.tsx +0 -174
  43. package/src/InputField/Number/Number.cypress.spec.tsx +0 -89
  44. package/src/InputField/Select/Autocomplete/Connected/Multi/MultiConnectedAutocompleteField.cypress.spec.tsx +0 -182
  45. package/src/Listing/Listing.cypress.spec.tsx +0 -304
  46. package/src/Module/Module.cypress.spec.tsx +0 -127
  47. package/src/Pagination/Pagination.cypress.spec.tsx +0 -140
  48. package/src/RichTextEditor/RichTextEditor.cypress.spec.tsx +0 -408
  49. package/src/TimePeriods/ResolutionTimePeriod.cypress.spec.tsx +0 -74
  50. package/src/TimePeriods/TimePeriods.cypress.spec.tsx +0 -488
  51. package/src/Typography/FluidTypography/FluidTypography.cypress.spec.tsx +0 -27
  52. package/src/api/useMutationQuery/useMutationQuery.cypress.spec.tsx +0 -93
  53. package/src/components/CollapsibleItem/CollapsibleItem.cypress.spec.tsx +0 -76
  54. package/src/components/CopyCommand/CopyCommand.cypress.spec.tsx +0 -167
  55. package/src/components/CrudPage/CrudPage.cypress.spec.tsx +0 -561
  56. package/src/components/DataTable/DataTable.cypress.spec.tsx +0 -174
  57. package/src/components/Form/AccessRights/AccessRights.cypress.spec.tsx +0 -376
  58. package/src/components/ItemComposition/ItemComposition.cypress.spec.tsx +0 -112
  59. package/src/components/Layout/PageLayout.cypress.spec.tsx +0 -64
  60. package/src/components/Modal/ConfirmationModal/ConfirmationModal.cypress.spec.tsx +0 -171
  61. package/src/components/SortableEntriesList/SortableEntriesList.cypress.spec.tsx +0 -792
  62. package/src/components/Tabs/Tabs.cypress.spec.tsx +0 -69
  63. package/src/utils/useFullscreen/Fullscreen.cypress.spec.tsx +0 -130
  64. package/src/utils/useLocale/useLocale.cypress.spec.tsx +0 -40
@@ -0,0 +1,304 @@
1
+ import { Typography } from '@mui/material';
2
+
3
+ import { rs } from '@rstest/core';
4
+ import { render } from '@test-setup/render';
5
+ import { fireEvent, screen, waitFor } from '@testing-library/react';
6
+ import userEvent from '@testing-library/user-event';
7
+ import { useFormikContext } from 'formik';
8
+ import { object } from 'yup';
9
+
10
+ import { Form } from '../Form';
11
+ import { InputType } from './models';
12
+
13
+ const options = ['Down', 'Unreachable', 'Recovery', 'None'];
14
+
15
+ const ValueDisplay = (): JSX.Element => {
16
+ const { values } = useFormikContext<{
17
+ notificationOptions: Array<string> | null;
18
+ }>();
19
+
20
+ return (
21
+ <Typography data-testid="value">
22
+ {JSON.stringify(values.notificationOptions)}
23
+ </Typography>
24
+ );
25
+ };
26
+
27
+ interface InitializeProps {
28
+ disabled?: boolean;
29
+ notificationOptions?: Array<string> | null;
30
+ variant?: 'checkboxes' | 'chips';
31
+ }
32
+
33
+ const initialize = ({
34
+ notificationOptions = [],
35
+ disabled = false,
36
+ variant
37
+ }: InitializeProps = {}): void => {
38
+ render(
39
+ <Form
40
+ initialValues={{ notificationOptions }}
41
+ inputs={[
42
+ {
43
+ dataTestId: 'notification-options',
44
+ exclusiveCheckboxGroup: {
45
+ direction: 'horizontal',
46
+ exclusiveLabel: 'No notifications',
47
+ exclusiveOption: 'None',
48
+ options,
49
+ variant
50
+ },
51
+ fieldName: 'notificationOptions',
52
+ getDisabled: () => disabled,
53
+ group: '',
54
+ label: 'Notification options',
55
+ type: InputType.ExclusiveCheckboxGroup
56
+ },
57
+ {
58
+ custom: { Component: ValueDisplay },
59
+ fieldName: 'value',
60
+ group: '',
61
+ label: 'value',
62
+ type: InputType.Custom
63
+ }
64
+ ]}
65
+ submit={rs.fn()}
66
+ validationSchema={object()}
67
+ />
68
+ );
69
+ };
70
+
71
+ const toggle = (): Promise<HTMLElement> =>
72
+ screen.findByLabelText('No notifications');
73
+
74
+ const checkbox = (option: string): HTMLElement =>
75
+ screen.getByRole('checkbox', { name: option });
76
+
77
+ const expectValue = async (value: Array<string> | null): Promise<void> => {
78
+ await waitFor(() => {
79
+ expect(screen.getByTestId('value')).toHaveTextContent(
80
+ new RegExp(`^${JSON.stringify(value).replace(/[[\]]/g, '\\$&')}$`)
81
+ );
82
+ });
83
+ };
84
+
85
+ describe('Exclusive checkbox group', () => {
86
+ beforeEach(() => {
87
+ expect.hasAssertions();
88
+ });
89
+
90
+ it('displays the toggle and every option except the exclusive one', async () => {
91
+ initialize();
92
+
93
+ expect(await toggle()).not.toBeChecked();
94
+ expect(screen.getByLabelText('Down')).toBeInTheDocument();
95
+ expect(screen.getByLabelText('Unreachable')).toBeInTheDocument();
96
+ expect(screen.getByLabelText('Recovery')).toBeInTheDocument();
97
+ expect(screen.queryByLabelText('None')).not.toBeInTheDocument();
98
+
99
+ // The three options in order, then the toggle,
100
+ // all unticked and usable, and the empty value.
101
+ expect(
102
+ screen
103
+ .getAllByRole('checkbox')
104
+ .map((element) => element.getAttribute('aria-label') ?? element.id)
105
+ ).toEqual(['Down', 'Unreachable', 'Recovery', 'No notifications']);
106
+ ['Down', 'Unreachable', 'Recovery'].forEach((option) => {
107
+ expect(checkbox(option)).not.toBeChecked();
108
+ expect(checkbox(option)).toBeEnabled();
109
+ });
110
+ expect(await toggle()).toBeEnabled();
111
+ expect(screen.getByText('No notifications')).toBeVisible();
112
+ await expectValue([]);
113
+ });
114
+
115
+ it('keeps the inherited state when nothing is selected', async () => {
116
+ initialize();
117
+
118
+ expect(await toggle()).not.toBeChecked();
119
+ expect(screen.getByLabelText('Down')).not.toBeChecked();
120
+ expect(screen.getByLabelText('Down')).toBeEnabled();
121
+ await expectValue([]);
122
+ });
123
+
124
+ it('treats a null value as the inherited state', async () => {
125
+ initialize({ notificationOptions: null });
126
+
127
+ expect(await toggle()).not.toBeChecked();
128
+ expect(screen.getByLabelText('Down')).not.toBeChecked();
129
+ expect(screen.getByLabelText('Down')).toBeEnabled();
130
+ });
131
+
132
+ it('displays the exclusive state from the initial value', async () => {
133
+ initialize({ notificationOptions: ['None'] });
134
+
135
+ expect(await toggle()).toBeChecked();
136
+ expect(screen.getByLabelText('Down')).not.toBeChecked();
137
+ expect(screen.getByLabelText('Down')).toBeDisabled();
138
+ await expectValue(['None']);
139
+
140
+ // Every option is unticked and greyed out.
141
+ ['Unreachable', 'Recovery'].forEach((option) => {
142
+ expect(checkbox(option)).not.toBeChecked();
143
+ expect(checkbox(option)).toBeDisabled();
144
+ });
145
+ expect(await toggle()).toBeEnabled();
146
+ });
147
+
148
+ it('displays a selection from the initial value', async () => {
149
+ initialize({ notificationOptions: ['Down', 'Recovery'] });
150
+
151
+ expect(await toggle()).not.toBeChecked();
152
+ expect(screen.getByLabelText('Down')).toBeChecked();
153
+ expect(screen.getByLabelText('Unreachable')).not.toBeChecked();
154
+ expect(screen.getByLabelText('Recovery')).toBeChecked();
155
+ await expectValue(['Down', 'Recovery']);
156
+ });
157
+
158
+ it('goes from the inherited state to a selection and back', async () => {
159
+ initialize();
160
+
161
+ await userEvent.click(await screen.findByLabelText('Down'));
162
+ await userEvent.click(screen.getByLabelText('Recovery'));
163
+ await expectValue(['Down', 'Recovery']);
164
+
165
+ await userEvent.click(screen.getByLabelText('Down'));
166
+ await userEvent.click(screen.getByLabelText('Recovery'));
167
+ await expectValue([]);
168
+ expect(await toggle()).not.toBeChecked();
169
+ });
170
+
171
+ it('goes from the inherited state to the exclusive state and back', async () => {
172
+ initialize();
173
+
174
+ await userEvent.click(await toggle());
175
+ await waitFor(async () => {
176
+ expect(await toggle()).toBeChecked();
177
+ });
178
+ await expectValue(['None']);
179
+
180
+ await userEvent.click(await toggle());
181
+ await waitFor(async () => {
182
+ expect(await toggle()).not.toBeChecked();
183
+ });
184
+ await expectValue([]);
185
+ });
186
+
187
+ it('clears the selection when the toggle is turned on and leaves it empty when turned off', async () => {
188
+ initialize({ notificationOptions: ['Down', 'Recovery'] });
189
+
190
+ await userEvent.click(await toggle());
191
+ await expectValue(['None']);
192
+ await waitFor(() => {
193
+ expect(screen.getByLabelText('Down')).not.toBeChecked();
194
+ });
195
+ expect(screen.getByLabelText('Down')).toBeDisabled();
196
+ expect(screen.getByLabelText('Recovery')).not.toBeChecked();
197
+ expect(screen.getByLabelText('Recovery')).toBeDisabled();
198
+
199
+ await userEvent.click(await toggle());
200
+ await expectValue([]);
201
+ await waitFor(() => {
202
+ expect(screen.getByLabelText('Down')).toBeEnabled();
203
+ });
204
+ expect(screen.getByLabelText('Down')).not.toBeChecked();
205
+
206
+ await userEvent.click(screen.getByLabelText('Unreachable'));
207
+ await expectValue(['Unreachable']);
208
+ });
209
+
210
+ it('prevents ticking a box while the toggle is on', async () => {
211
+ initialize({ notificationOptions: ['None'] });
212
+
213
+ // The checkbox is natively disabled, so the click never reaches its
214
+ // handler (`userEvent` would refuse it): it checks the box cannot be ticked.
215
+ fireEvent.click(await screen.findByLabelText('Down'));
216
+ expect(screen.getByLabelText('Down')).not.toBeChecked();
217
+ await expectValue(['None']);
218
+ });
219
+
220
+ it('disables both the toggle and the checkboxes when the input is disabled', async () => {
221
+ initialize({ disabled: true, notificationOptions: ['Down'] });
222
+
223
+ expect(await toggle()).toBeDisabled();
224
+ expect(screen.getByLabelText('Down')).toBeChecked();
225
+ expect(screen.getByLabelText('Down')).toBeDisabled();
226
+ await expectValue(['Down']);
227
+ });
228
+ });
229
+
230
+ describe('Exclusive checkbox group, as chips', () => {
231
+ beforeEach(() => {
232
+ expect.hasAssertions();
233
+ });
234
+
235
+ const chip = (option: string): Promise<HTMLElement> =>
236
+ screen.findByTestId(`notification-options-${option}`);
237
+
238
+ it('displays the options as chips and the exclusive one as the toggle', async () => {
239
+ initialize({ variant: 'chips' });
240
+
241
+ for (const option of ['Down', 'Unreachable', 'Recovery']) {
242
+ expect(await chip(option)).toHaveAttribute('aria-pressed', 'false');
243
+ }
244
+ expect(
245
+ screen.queryByTestId('notification-options-None')
246
+ ).not.toBeInTheDocument();
247
+ expect(await toggle()).not.toBeChecked();
248
+ expect(
249
+ screen.queryByRole('checkbox', { name: 'Down' })
250
+ ).not.toBeInTheDocument();
251
+ });
252
+
253
+ it('toggles a chip on and off', async () => {
254
+ initialize({ variant: 'chips' });
255
+
256
+ await userEvent.click(await chip('Down'));
257
+ await userEvent.click(await chip('Recovery'));
258
+
259
+ await expectValue(['Down', 'Recovery']);
260
+ await waitFor(async () => {
261
+ expect(await chip('Down')).toHaveAttribute('aria-pressed', 'true');
262
+ });
263
+
264
+ await userEvent.click(await chip('Down'));
265
+
266
+ await expectValue(['Recovery']);
267
+ });
268
+
269
+ it('clears and disables the chips while the toggle is on', async () => {
270
+ initialize({ notificationOptions: ['Down', 'Recovery'], variant: 'chips' });
271
+
272
+ await userEvent.click(await toggle());
273
+
274
+ await expectValue(['None']);
275
+ await waitFor(async () => {
276
+ expect(await chip('Down')).toHaveAttribute('aria-pressed', 'false');
277
+ });
278
+ expect(await chip('Down')).toHaveAttribute('aria-disabled', 'true');
279
+ });
280
+
281
+ it('leaves the chips empty and usable once the toggle is off', async () => {
282
+ initialize({ notificationOptions: ['None'], variant: 'chips' });
283
+
284
+ await userEvent.click(await toggle());
285
+
286
+ await expectValue([]);
287
+ await waitFor(async () => {
288
+ expect(await chip('Unreachable')).not.toHaveAttribute(
289
+ 'aria-disabled',
290
+ 'true'
291
+ );
292
+ });
293
+ await userEvent.click(await chip('Unreachable'));
294
+
295
+ await expectValue(['Unreachable']);
296
+ });
297
+
298
+ it('disables the chips and the toggle when the input is disabled', async () => {
299
+ initialize({ disabled: true, variant: 'chips' });
300
+
301
+ expect(await chip('Down')).toHaveAttribute('aria-disabled', 'true');
302
+ expect(await toggle()).toBeDisabled();
303
+ });
304
+ });
@@ -1,8 +1,9 @@
1
- import { Box } from '@mui/material';
1
+ import { Box, Chip } from '@mui/material';
2
2
 
3
3
  import { type FormikValues, useFormikContext } from 'formik';
4
4
  import { equals, includes, path, reject, split } from 'ramda';
5
5
  import type { ChangeEvent } from 'react';
6
+ import { useTranslation } from 'react-i18next';
6
7
 
7
8
  import { useMemoComponent } from '../..';
8
9
  import { CheckboxGroup as CheckboxGroupComponent } from '../../Checkbox';
@@ -19,6 +20,7 @@ const ExclusiveCheckboxGroup = ({
19
20
  getDisabled,
20
21
  dataTestId
21
22
  }: InputPropsWithoutGroup): JSX.Element => {
23
+ const { t } = useTranslation();
22
24
  const { values, setFieldValue } = useFormikContext<FormikValues>();
23
25
 
24
26
  const fieldNamePath = split('.', fieldName);
@@ -53,10 +55,63 @@ const ExclusiveCheckboxGroup = ({
53
55
  );
54
56
  };
55
57
 
58
+ const toggleChip = (option: string) => (): void => {
59
+ setFieldValue(
60
+ fieldName,
61
+ includes(option, value)
62
+ ? reject(equals(option), value)
63
+ : [...value, option]
64
+ );
65
+ };
66
+
56
67
  const baseTestId = dataTestId || fieldName;
57
68
 
69
+ const isChips = equals(exclusiveCheckboxGroup?.variant, 'chips');
70
+
71
+ const exclusiveToggle = (
72
+ <Switch
73
+ change={changeToggle}
74
+ dataTestId={`${baseTestId}-exclusive`}
75
+ fieldName={fieldName}
76
+ getDisabled={() => disabled}
77
+ label={exclusiveCheckboxGroup?.exclusiveLabel as string}
78
+ switchInput={{
79
+ getChecked: (fieldValue) =>
80
+ includes(exclusiveOption, (fieldValue as Array<string>) ?? [])
81
+ }}
82
+ type={InputType.Switch}
83
+ />
84
+ );
85
+
58
86
  return useMemoComponent({
59
- Component: (
87
+ Component: isChips ? (
88
+ <div
89
+ className="flex flex-wrap items-center gap-x-6 gap-y-3"
90
+ data-testid={baseTestId}
91
+ >
92
+ {options.map((option) => {
93
+ const isSelected = !isExclusive && includes(option, value);
94
+
95
+ return (
96
+ <Chip
97
+ aria-pressed={isSelected}
98
+ // The theme sizes chips for status badges, 12px or 20px tall.
99
+ className="h-6 rounded-3xl text-sm [&_.MuiChip-label]:px-3 [&_.MuiChip-label]:leading-[21px]"
100
+ clickable
101
+ color={isSelected ? 'primary' : 'default'}
102
+ data-testid={`${baseTestId}-${option}`}
103
+ disabled={disabled || isExclusive}
104
+ key={option}
105
+ label={t(option)}
106
+ onClick={toggleChip(option)}
107
+ size="small"
108
+ variant={isSelected ? 'filled' : 'outlined'}
109
+ />
110
+ );
111
+ })}
112
+ {exclusiveToggle}
113
+ </div>
114
+ ) : (
60
115
  <Box sx={{ alignItems: 'center', columnGap: 2, display: 'flex' }}>
61
116
  <CheckboxGroupComponent
62
117
  dataTestId={baseTestId}
@@ -67,18 +122,7 @@ const ExclusiveCheckboxGroup = ({
67
122
  options={options}
68
123
  values={isExclusive ? [] : value}
69
124
  />
70
- <Switch
71
- change={changeToggle}
72
- dataTestId={`${baseTestId}-exclusive`}
73
- fieldName={fieldName}
74
- getDisabled={() => disabled}
75
- label={exclusiveCheckboxGroup?.exclusiveLabel as string}
76
- switchInput={{
77
- getChecked: (fieldValue) =>
78
- includes(exclusiveOption, (fieldValue as Array<string>) ?? [])
79
- }}
80
- type={InputType.Switch}
81
- />
125
+ {exclusiveToggle}
82
126
  </Box>
83
127
  ),
84
128
  memoProps: [
@@ -88,7 +132,8 @@ const ExclusiveCheckboxGroup = ({
88
132
  exclusiveOption,
89
133
  exclusiveCheckboxGroup?.exclusiveLabel,
90
134
  exclusiveCheckboxGroup?.direction,
91
- exclusiveCheckboxGroup?.labelPlacement
135
+ exclusiveCheckboxGroup?.labelPlacement,
136
+ exclusiveCheckboxGroup?.variant
92
137
  ]
93
138
  });
94
139
  };
@@ -12,6 +12,10 @@ const getSkeleton = cond<Array<InputType>, JSX.Element>([
12
12
  equals(InputType.Radio) as (b: InputType) => boolean,
13
13
  always(<LoadingSkeleton height={104} />)
14
14
  ],
15
+ [
16
+ equals(InputType.SegmentedButtons) as (b: InputType) => boolean,
17
+ always(<LoadingSkeleton height={56} />)
18
+ ],
15
19
  [
16
20
  equals(InputType.Text) as (b: InputType) => boolean,
17
21
  always(<LoadingSkeleton height={52} />)
@@ -0,0 +1,113 @@
1
+ import { Typography } from '@mui/material';
2
+
3
+ import { rs } from '@rstest/core';
4
+ import { render } from '@test-setup/render';
5
+ import { screen, waitFor } from '@testing-library/react';
6
+ import userEvent from '@testing-library/user-event';
7
+ import { useFormikContext } from 'formik';
8
+ import { object } from 'yup';
9
+
10
+ import { Form } from '../Form';
11
+ import { InputType } from './models';
12
+
13
+ const options = [
14
+ { label: 'Yes', value: 'true' },
15
+ { label: 'No', value: 'false' },
16
+ { label: 'Default', value: 'use_default' }
17
+ ];
18
+
19
+ const ValueDisplay = (): JSX.Element => {
20
+ const { values } = useFormikContext<{ enabled: string }>();
21
+
22
+ return (
23
+ <Typography data-testid="value">
24
+ {JSON.stringify(values.enabled)}
25
+ </Typography>
26
+ );
27
+ };
28
+
29
+ interface InitializeProps {
30
+ disabled?: boolean;
31
+ enabled?: string;
32
+ }
33
+
34
+ const initialize = ({
35
+ enabled = 'use_default',
36
+ disabled = false
37
+ }: InitializeProps = {}): void => {
38
+ render(
39
+ <Form
40
+ initialValues={{ enabled }}
41
+ inputs={[
42
+ {
43
+ dataTestId: 'enabled',
44
+ fieldName: 'enabled',
45
+ getDisabled: () => disabled,
46
+ group: '',
47
+ label: 'Notification Enabled',
48
+ segmentedButtons: { options },
49
+ type: InputType.SegmentedButtons
50
+ },
51
+ {
52
+ custom: { Component: ValueDisplay },
53
+ fieldName: 'value',
54
+ group: '',
55
+ label: 'value',
56
+ type: InputType.Custom
57
+ }
58
+ ]}
59
+ submit={rs.fn()}
60
+ validationSchema={object()}
61
+ />
62
+ );
63
+ };
64
+
65
+ const button = (label: string): Promise<HTMLElement> =>
66
+ screen.findByRole('button', { name: label });
67
+
68
+ describe('Segmented buttons', () => {
69
+ beforeEach(() => {
70
+ expect.hasAssertions();
71
+ });
72
+
73
+ it('displays every option and marks the current one', async () => {
74
+ initialize();
75
+
76
+ expect(await screen.findByText('Notification Enabled')).toBeVisible();
77
+ expect(await button('Yes')).toHaveAttribute('aria-pressed', 'false');
78
+ expect(await button('No')).toHaveAttribute('aria-pressed', 'false');
79
+ expect(await button('Default')).toHaveAttribute('aria-pressed', 'true');
80
+ });
81
+
82
+ it('stores the value of the clicked option as given', async () => {
83
+ initialize();
84
+
85
+ await userEvent.click(await button('Yes'));
86
+
87
+ // A string, not the boolean the radio would store.
88
+ await waitFor(() => {
89
+ expect(screen.getByTestId('value')).toHaveTextContent(/^"true"$/);
90
+ });
91
+ expect(await button('Yes')).toHaveAttribute('aria-pressed', 'true');
92
+ expect(await button('Default')).toHaveAttribute('aria-pressed', 'false');
93
+
94
+ await userEvent.click(await button('No'));
95
+
96
+ await waitFor(() => {
97
+ expect(screen.getByTestId('value')).toHaveTextContent(/^"false"$/);
98
+ });
99
+ });
100
+
101
+ it('starts on the initial value', async () => {
102
+ initialize({ enabled: 'false' });
103
+
104
+ expect(await button('No')).toHaveAttribute('aria-pressed', 'true');
105
+ });
106
+
107
+ it('does not change when disabled', async () => {
108
+ initialize({ disabled: true });
109
+
110
+ expect(await button('Yes')).toBeDisabled();
111
+ expect(screen.getByTestId('value')).toHaveTextContent(/^"use_default"$/);
112
+ });
113
+ });
@@ -0,0 +1,71 @@
1
+ import { Button, ButtonGroup, FormGroup, FormLabel } from '@mui/material';
2
+
3
+ import { type FormikValues, useFormikContext } from 'formik';
4
+ import { equals, path, split } from 'ramda';
5
+ import { useTranslation } from 'react-i18next';
6
+
7
+ import { useMemoComponent } from '../..';
8
+ import type { InputPropsWithoutGroup } from './models';
9
+
10
+ const SegmentedButtons = ({
11
+ dataTestId,
12
+ fieldName,
13
+ label,
14
+ segmentedButtons,
15
+ getDisabled,
16
+ additionalMemoProps
17
+ }: InputPropsWithoutGroup): JSX.Element => {
18
+ const { t } = useTranslation();
19
+
20
+ const { values, setFieldValue, setFieldTouched } =
21
+ useFormikContext<FormikValues>();
22
+
23
+ const value = path(split('.', fieldName), values);
24
+
25
+ const disabled = getDisabled?.(values) || false;
26
+
27
+ const select = (optionValue: string) => (): void => {
28
+ setFieldTouched(fieldName, true, false);
29
+ setFieldValue(fieldName, optionValue);
30
+ };
31
+
32
+ return useMemoComponent({
33
+ Component: (
34
+ <FormGroup>
35
+ <FormLabel>{t(label)}</FormLabel>
36
+ <ButtonGroup
37
+ aria-label={t(label)}
38
+ // As wide as its column, up to what three labels need.
39
+ className="max-w-[412px]"
40
+ color="primary"
41
+ data-testid={dataTestId}
42
+ disabled={disabled}
43
+ fullWidth
44
+ size="small"
45
+ >
46
+ {segmentedButtons?.options.map(
47
+ ({ label: optionLabel, value: optionValue }) => {
48
+ const isSelected = equals(value, optionValue);
49
+
50
+ return (
51
+ <Button
52
+ aria-pressed={isSelected}
53
+ className="h-7 px-3 py-0 font-medium text-sm leading-[21px] normal-case"
54
+ data-testid={`${dataTestId}-${optionValue}`}
55
+ key={optionValue}
56
+ onClick={select(optionValue)}
57
+ variant={isSelected ? 'contained' : 'outlined'}
58
+ >
59
+ {t(optionLabel)}
60
+ </Button>
61
+ );
62
+ }
63
+ )}
64
+ </ButtonGroup>
65
+ </FormGroup>
66
+ ),
67
+ memoProps: [value, disabled, segmentedButtons, additionalMemoProps]
68
+ });
69
+ };
70
+
71
+ export default SegmentedButtons;
@@ -48,6 +48,7 @@ import {
48
48
  InputType
49
49
  } from './models';
50
50
  import RadioInput from './Radio';
51
+ import SegmentedButtons from './SegmentedButtons';
51
52
  import { SubgroupDivider } from './SubGroupDivider';
52
53
  import SwitchInput from './Switch';
53
54
  import TextInput from './Text';
@@ -89,6 +90,10 @@ export const getInput = cond<
89
90
  equals(InputType.ExclusiveCheckboxGroup) as (b: InputType) => boolean,
90
91
  always(ExclusiveCheckboxGroup)
91
92
  ],
93
+ [
94
+ equals(InputType.SegmentedButtons) as (b: InputType) => boolean,
95
+ always(SegmentedButtons)
96
+ ],
92
97
  [equals(InputType.List) as (b: InputType) => boolean, always(List)],
93
98
  [equals(InputType.File) as (b: InputType) => boolean, always(File)],
94
99
  [equals(InputType.Text) as (b: InputType) => boolean, always(TextInput)],
@@ -51,7 +51,8 @@ export enum InputType {
51
51
  List = 13,
52
52
  File = 14,
53
53
  Divider = 15,
54
- ExclusiveCheckboxGroup = 16
54
+ ExclusiveCheckboxGroup = 16,
55
+ SegmentedButtons = 17
55
56
  }
56
57
 
57
58
  interface FieldsTableGetRequiredProps {
@@ -88,6 +89,12 @@ export interface InputProps {
88
89
  useNewAPIFormat?: boolean;
89
90
  additionalConditionParameters: Array<ConditionsSearchParameter>;
90
91
  customQueryParameters: Array<QueryParameter>;
92
+ // The API the options are read from. Left out, the request goes to the
93
+ // default `./api/latest` as it always has.
94
+ baseEndpoint?: string;
95
+ // What the options are cached under. Left out, the field's label is used,
96
+ // which two fields reading different endpoints can share.
97
+ queryKey?: string;
91
98
  chipColor?: string;
92
99
  endpoint?: string;
93
100
  filterKey?: string;
@@ -105,6 +112,8 @@ export interface InputProps {
105
112
  exclusiveOption: string;
106
113
  labelPlacement?: LabelPlacement;
107
114
  options: Array<string>;
115
+ // The options as chips instead of checkboxes; the exclusive one stays a switch.
116
+ variant?: 'checkboxes' | 'chips';
108
117
  };
109
118
  file?: {
110
119
  multiple?: boolean;
@@ -161,6 +170,12 @@ export interface InputProps {
161
170
  row?: boolean;
162
171
  };
163
172
  required?: boolean;
173
+ segmentedButtons?: {
174
+ options: Array<{
175
+ label: string;
176
+ value: string;
177
+ }>;
178
+ };
164
179
  switchInput?: {
165
180
  getChecked?: (value: unknown) => boolean;
166
181
  };