@centreon/ui 26.10.8 → 26.10.10

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 (77) 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/CollapsibleGroup.tsx +6 -33
  5. package/src/Form/Form.browser.test.tsx +553 -0
  6. package/src/Form/Form.stories.tsx +102 -1
  7. package/src/Form/Form.tsx +4 -3
  8. package/src/Form/Inputs/ExclusiveCheckboxGroup.browser.test.tsx +304 -0
  9. package/src/Form/Inputs/Grid.browser.test.tsx +186 -0
  10. package/src/Form/Inputs/Grid.tsx +59 -12
  11. package/src/Form/Inputs/PasswordInput.browser.test.tsx +80 -0
  12. package/src/Form/Inputs/SegmentedButtons.browser.test.tsx +197 -0
  13. package/src/Form/Inputs/SegmentedButtons.tsx +38 -29
  14. package/src/Form/Inputs/Text.tsx +13 -1
  15. package/src/Form/Inputs/index.tsx +10 -52
  16. package/src/Form/Inputs/models.ts +6 -0
  17. package/src/Form/Section/FormSection.browser.test.tsx +174 -0
  18. package/src/Form/Section/FormSection.tsx +20 -6
  19. package/src/Form/Section/navigateToSection.ts +26 -8
  20. package/src/Form/Section/useActiveSection.ts +213 -0
  21. package/src/InputField/Number/Number.browser.test.tsx +109 -0
  22. package/src/InputField/Select/Autocomplete/Connected/Multi/MultiConnectedAutocompleteField.browser.test.tsx +250 -0
  23. package/src/InputField/Select/Autocomplete/Connected/index.test.tsx +37 -0
  24. package/src/InputField/Select/Autocomplete/Connected/index.tsx +56 -45
  25. package/src/Listing/Cell/DataCell.tsx +0 -2
  26. package/src/Listing/Listing.browser.test.tsx +476 -0
  27. package/src/Module/Module.browser.test.tsx +145 -0
  28. package/src/Pagination/Pagination.browser.test.tsx +184 -0
  29. package/src/RichTextEditor/RichTextEditor.browser.test.tsx +567 -0
  30. package/src/RichTextEditor/plugins/FloatingLinkEditorPlugin.tsx +3 -1
  31. package/src/TimePeriods/ResolutionTimePeriod.browser.test.tsx +143 -0
  32. package/src/TimePeriods/TimePeriods.browser.test.tsx +468 -0
  33. package/src/Typography/FluidTypography/FluidTypography.browser.test.tsx +54 -0
  34. package/src/api/useMutationQuery/useMutationQuery.browser.test.tsx +154 -0
  35. package/src/components/CollapsibleItem/CollapsibleItem.browser.test.tsx +120 -0
  36. package/src/components/CopyCommand/CopyCommand.browser.test.tsx +287 -0
  37. package/src/components/CrudPage/CrudPage.browser.test.tsx +659 -0
  38. package/src/components/DataTable/DataTable.browser.test.tsx +257 -0
  39. package/src/components/Form/AccessRights/AccessRights.browser.test.tsx +527 -0
  40. package/src/components/Header/PageHeader/PageHeader.stories.tsx +9 -0
  41. package/src/components/Header/PageHeader/PageHeaderMain.tsx +4 -2
  42. package/src/components/Header/PageHeader/PageHeaderTitle.tsx +61 -18
  43. package/src/components/ItemComposition/ItemComposition.browser.test.tsx +172 -0
  44. package/src/components/Layout/PageLayout.browser.test.tsx +114 -0
  45. package/src/components/Modal/ConfirmationModal/ConfirmationModal.browser.test.tsx +237 -0
  46. package/src/components/SortableEntriesList/SortableEntriesList.browser.test.tsx +981 -0
  47. package/src/components/Tabs/Tabs.browser.test.tsx +115 -0
  48. package/src/components/Tabs/Tabs.tsx +7 -1
  49. package/src/utils/useFullscreen/Fullscreen.browser.test.tsx +176 -0
  50. package/src/utils/useLocale/useLocale.browser.test.tsx +49 -0
  51. package/src/utils/useLocaleTimezoneDate/{LocaleTimezoneDate.cypress.spec.tsx → LocaleTimezoneDate.browser.test.tsx} +24 -11
  52. package/src/Dashboard/Dashboard.cypress.spec.tsx +0 -71
  53. package/src/Form/Form.cypress.spec.tsx +0 -306
  54. package/src/Form/Inputs/ExclusiveCheckboxGroup.cypress.spec.tsx +0 -236
  55. package/src/Form/Inputs/SegmentedButtons.cypress.spec.tsx +0 -101
  56. package/src/InputField/Number/Number.cypress.spec.tsx +0 -89
  57. package/src/InputField/Select/Autocomplete/Connected/Multi/MultiConnectedAutocompleteField.cypress.spec.tsx +0 -182
  58. package/src/Listing/Listing.cypress.spec.tsx +0 -304
  59. package/src/Module/Module.cypress.spec.tsx +0 -127
  60. package/src/Pagination/Pagination.cypress.spec.tsx +0 -140
  61. package/src/RichTextEditor/RichTextEditor.cypress.spec.tsx +0 -408
  62. package/src/TimePeriods/ResolutionTimePeriod.cypress.spec.tsx +0 -74
  63. package/src/TimePeriods/TimePeriods.cypress.spec.tsx +0 -488
  64. package/src/Typography/FluidTypography/FluidTypography.cypress.spec.tsx +0 -27
  65. package/src/api/useMutationQuery/useMutationQuery.cypress.spec.tsx +0 -93
  66. package/src/components/CollapsibleItem/CollapsibleItem.cypress.spec.tsx +0 -76
  67. package/src/components/CopyCommand/CopyCommand.cypress.spec.tsx +0 -167
  68. package/src/components/CrudPage/CrudPage.cypress.spec.tsx +0 -561
  69. package/src/components/DataTable/DataTable.cypress.spec.tsx +0 -174
  70. package/src/components/Form/AccessRights/AccessRights.cypress.spec.tsx +0 -376
  71. package/src/components/ItemComposition/ItemComposition.cypress.spec.tsx +0 -112
  72. package/src/components/Layout/PageLayout.cypress.spec.tsx +0 -64
  73. package/src/components/Modal/ConfirmationModal/ConfirmationModal.cypress.spec.tsx +0 -171
  74. package/src/components/SortableEntriesList/SortableEntriesList.cypress.spec.tsx +0 -792
  75. package/src/components/Tabs/Tabs.cypress.spec.tsx +0 -69
  76. package/src/utils/useFullscreen/Fullscreen.cypress.spec.tsx +0 -130
  77. package/src/utils/useLocale/useLocale.cypress.spec.tsx +0 -40
@@ -0,0 +1,197 @@
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
+ columnWidth?: number;
31
+ disabled?: boolean;
32
+ enabled?: string;
33
+ segmentedOptions?: Array<{ label: string; value: string }>;
34
+ }
35
+
36
+ const initialize = ({
37
+ enabled = 'use_default',
38
+ disabled = false,
39
+ columnWidth = 800,
40
+ segmentedOptions = options
41
+ }: InitializeProps = {}): void => {
42
+ render(
43
+ <div style={{ width: columnWidth }}>
44
+ <Form
45
+ initialValues={{ enabled }}
46
+ inputs={[
47
+ {
48
+ dataTestId: 'enabled',
49
+ fieldName: 'enabled',
50
+ getDisabled: () => disabled,
51
+ group: '',
52
+ label: 'Notification Enabled',
53
+ segmentedButtons: { options: segmentedOptions },
54
+ type: InputType.SegmentedButtons
55
+ },
56
+ {
57
+ custom: { Component: ValueDisplay },
58
+ fieldName: 'value',
59
+ group: '',
60
+ label: 'value',
61
+ type: InputType.Custom
62
+ }
63
+ ]}
64
+ submit={rs.fn()}
65
+ validationSchema={object()}
66
+ />
67
+ </div>
68
+ );
69
+ };
70
+
71
+ const button = (label: string): Promise<HTMLElement> =>
72
+ screen.findByRole('button', { name: label });
73
+
74
+ describe('Segmented buttons', () => {
75
+ beforeEach(() => {
76
+ expect.hasAssertions();
77
+ });
78
+
79
+ it('displays every option and marks the current one', async () => {
80
+ initialize();
81
+
82
+ const label = await screen.findByText('Notification Enabled');
83
+
84
+ expect(label).toBeVisible();
85
+ // Bold, as the design shows the label above the buttons.
86
+ expect(getComputedStyle(label).fontWeight).toBe('700');
87
+ expect(await button('Yes')).toHaveAttribute('aria-pressed', 'false');
88
+ expect(await button('No')).toHaveAttribute('aria-pressed', 'false');
89
+ expect(await button('Default')).toHaveAttribute('aria-pressed', 'true');
90
+ });
91
+
92
+ it('stores the value of the clicked option as given', async () => {
93
+ initialize();
94
+
95
+ await userEvent.click(await button('Yes'));
96
+
97
+ // A string, not the boolean the radio would store.
98
+ await waitFor(() => {
99
+ expect(screen.getByTestId('value')).toHaveTextContent(/^"true"$/);
100
+ });
101
+ expect(await button('Yes')).toHaveAttribute('aria-pressed', 'true');
102
+ expect(await button('Default')).toHaveAttribute('aria-pressed', 'false');
103
+
104
+ await userEvent.click(await button('No'));
105
+
106
+ await waitFor(() => {
107
+ expect(screen.getByTestId('value')).toHaveTextContent(/^"false"$/);
108
+ });
109
+ });
110
+
111
+ it('starts on the initial value', async () => {
112
+ initialize({ enabled: 'false' });
113
+
114
+ expect(await button('No')).toHaveAttribute('aria-pressed', 'true');
115
+ });
116
+
117
+ it('sizes each segment to its label, at least 104px, whatever the column', async () => {
118
+ initialize({
119
+ columnWidth: 800,
120
+ segmentedOptions: [
121
+ { label: 'Yes', value: 'true' },
122
+ { label: 'No', value: 'false' },
123
+ { label: 'Inherit from template', value: 'use_default' }
124
+ ]
125
+ });
126
+
127
+ const group = await screen.findByTestId('enabled');
128
+ const [yes, no, inherit] = (
129
+ await Promise.all(['Yes', 'No', 'Inherit from template'].map(button))
130
+ ).map((element) => element.getBoundingClientRect().width);
131
+
132
+ // As in the design: Yes and No keep 104px, the longer label grows alone
133
+ // (the group overlaps the borders of its buttons, a pixel at most).
134
+ expect(yes).toBeGreaterThanOrEqual(104);
135
+ expect(Math.abs(yes - no)).toBeLessThanOrEqual(1);
136
+ expect(yes).toBeLessThan(110);
137
+ expect(inherit).toBeGreaterThan(yes + 20);
138
+ expect(group.getBoundingClientRect().width).toBeLessThan(500);
139
+ expect(group.getBoundingClientRect().left).toBe(
140
+ (await screen.findByText('Notification Enabled')).getBoundingClientRect()
141
+ .left
142
+ );
143
+ });
144
+
145
+ it('keeps three segments of 104px when the labels are short', async () => {
146
+ initialize({ columnWidth: 800 });
147
+
148
+ const widths = (
149
+ await Promise.all(['Yes', 'No', 'Default'].map(button))
150
+ ).map((element) => element.getBoundingClientRect().width);
151
+
152
+ expect(Math.min(...widths)).toBeGreaterThanOrEqual(104);
153
+ expect(Math.max(...widths) - Math.min(...widths)).toBeLessThanOrEqual(1);
154
+ });
155
+
156
+ it('cuts a label wider than the room left with an ellipsis', async () => {
157
+ const longLabel =
158
+ 'Vorlage erben und alle Werte der Vorlage ohne Ausnahme übernehmen';
159
+
160
+ initialize({
161
+ columnWidth: 420,
162
+ segmentedOptions: [
163
+ { label: 'Yes', value: 'true' },
164
+ { label: 'No', value: 'false' },
165
+ { label: longLabel, value: 'use_default' }
166
+ ]
167
+ });
168
+
169
+ const long = await button(longLabel);
170
+ const text = long.firstElementChild as HTMLElement;
171
+
172
+ expect(text.scrollWidth).toBeGreaterThan(text.clientWidth);
173
+ expect(getComputedStyle(text).textOverflow).toBe('ellipsis');
174
+ expect(long).toHaveAttribute('title', longLabel);
175
+ expect(long.getBoundingClientRect().height).toBe(28);
176
+ expect(
177
+ (await screen.findByTestId('enabled')).getBoundingClientRect().right
178
+ ).toBeLessThanOrEqual(420 + 1);
179
+ });
180
+
181
+ it('stays inside a column narrower than its segments', async () => {
182
+ initialize({ columnWidth: 200 });
183
+
184
+ const group = await screen.findByTestId('enabled');
185
+ const wrapper = group.parentElement as HTMLElement;
186
+
187
+ expect(wrapper.getBoundingClientRect().width).toBeLessThanOrEqual(200);
188
+ expect(wrapper.scrollWidth).toBeGreaterThan(wrapper.clientWidth);
189
+ });
190
+
191
+ it('does not change when disabled', async () => {
192
+ initialize({ disabled: true });
193
+
194
+ expect(await button('Yes')).toBeDisabled();
195
+ expect(screen.getByTestId('value')).toHaveTextContent(/^"use_default"$/);
196
+ });
197
+ });
@@ -32,36 +32,45 @@ const SegmentedButtons = ({
32
32
  return useMemoComponent({
33
33
  Component: (
34
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);
35
+ {!segmentedButtons?.hideLabel && (
36
+ <FormLabel className="font-bold">{t(label)}</FormLabel>
37
+ )}
38
+ {/* In a column narrower than the three minimum widths, the group scrolls
39
+ inside it rather than overflowing into what is next to it. */}
40
+ <div className="max-w-full self-start overflow-x-auto">
41
+ <ButtonGroup
42
+ aria-label={t(label)}
43
+ className="max-w-full"
44
+ color="primary"
45
+ data-testid={dataTestId}
46
+ disabled={disabled}
47
+ size="small"
48
+ >
49
+ {segmentedButtons?.options.map(
50
+ ({ label: optionLabel, value: optionValue }) => {
51
+ const isSelected = equals(value, optionValue);
49
52
 
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>
53
+ // As in the design: every segment is 26 spacing units (104px)
54
+ // wide at least, and grows with its own label. A label wider
55
+ // than the room left is cut with an ellipsis, its full text
56
+ // in the tooltip.
57
+ return (
58
+ <Button
59
+ aria-pressed={isSelected}
60
+ className="h-7 min-w-26 px-3 py-0 font-medium text-sm leading-[21px] normal-case"
61
+ data-testid={`${dataTestId}-${optionValue}`}
62
+ key={optionValue}
63
+ onClick={select(optionValue)}
64
+ title={t(optionLabel)}
65
+ variant={isSelected ? 'contained' : 'outlined'}
66
+ >
67
+ <span className="min-w-0 truncate">{t(optionLabel)}</span>
68
+ </Button>
69
+ );
70
+ }
71
+ )}
72
+ </ButtonGroup>
73
+ </div>
65
74
  </FormGroup>
66
75
  ),
67
76
  memoProps: [value, disabled, segmentedButtons, additionalMemoProps]
@@ -17,6 +17,17 @@ import { TextField, useMemoComponent } from '../..';
17
17
  import { type InputPropsWithoutGroup, InputType } from './models';
18
18
  import PasswordEndAdornment from './PasswordEndAdornment';
19
19
 
20
+ // Keeps password managers from taking the text field before a password field
21
+ // for a user name and filling it in, and from filling the password itself:
22
+ // `new-password` for the browsers, the opt-out attributes for the extensions
23
+ // (1Password, Bitwarden, LastPass).
24
+ const passwordManagerOptOut = {
25
+ autoComplete: 'new-password',
26
+ 'data-1p-ignore': true,
27
+ 'data-bwignore': true,
28
+ 'data-lpignore': true
29
+ };
30
+
20
31
  const Text = ({
21
32
  dataTestId,
22
33
  label,
@@ -140,7 +151,8 @@ const Text = ({
140
151
  htmlInput: {
141
152
  'aria-label': label,
142
153
  'data-testid': dataTestId || label,
143
- min: text?.min
154
+ min: text?.min,
155
+ ...(equals(type, InputType.Password) && passwordManagerOptOut)
144
156
  }
145
157
  }
146
158
  }}
@@ -26,7 +26,6 @@ import {
26
26
  toPairs
27
27
  } from 'ramda';
28
28
  import { Fragment, useMemo } from 'react';
29
- import { makeStyles } from 'tss-react/mui';
30
29
 
31
30
  import { GroupDirection } from '..';
32
31
  import CollapsibleGroup from '../CollapsibleGroup';
@@ -104,42 +103,6 @@ export const getInput = cond<
104
103
  [T, always(TextInput)]
105
104
  ]);
106
105
 
107
- interface StylesProps {
108
- groupDirection?: GroupDirection;
109
- }
110
-
111
- const useStyles = makeStyles<StylesProps>()((theme, { groupDirection }) => ({
112
- additionalLabel: {
113
- marginBottom: theme.spacing(0.5)
114
- },
115
- buttons: {
116
- columnGap: theme.spacing(2),
117
- display: 'flex',
118
- flexDirection: 'row',
119
- justifyContent: 'flex-end'
120
- },
121
- divider: {
122
- margin: equals(groupDirection, GroupDirection.Horizontal)
123
- ? theme.spacing(0, 2)
124
- : theme.spacing(2, 0)
125
- },
126
- groups: {
127
- display: 'flex',
128
- flexDirection: equals(groupDirection, GroupDirection.Horizontal)
129
- ? 'row'
130
- : 'column'
131
- },
132
- inputs: {
133
- display: 'flex',
134
- flexDirection: 'column',
135
- margin: theme.spacing(2, 0),
136
- rowGap: theme.spacing(2)
137
- },
138
- inputWrapper: {
139
- width: '100%'
140
- }
141
- }));
142
-
143
106
  interface Props {
144
107
  areGroupsOpen?: boolean;
145
108
  groupDirection?: GroupDirection;
@@ -161,7 +124,7 @@ const Inputs = ({
161
124
  areGroupsOpen,
162
125
  inputsClassName
163
126
  }: Props): JSX.Element => {
164
- const { classes, cx } = useStyles({ groupDirection });
127
+ const isHorizontal = equals(groupDirection, GroupDirection.Horizontal);
165
128
  const formikContext = useFormikContext<FormikValues>();
166
129
 
167
130
  const groupsName = pluck('name', groups);
@@ -217,7 +180,7 @@ const Inputs = ({
217
180
  ) as Array<[string | null, Array<InputProps>]>;
218
181
 
219
182
  return (
220
- <div className={classes.groups}>
183
+ <div className={`flex ${isHorizontal ? 'flex-row' : 'flex-col'}`}>
221
184
  {normalizedInputsByGroup.map(([groupName, groupedInputs], index) => {
222
185
  const hasGroupTitle = not(isNil(groupName));
223
186
 
@@ -238,7 +201,9 @@ const Inputs = ({
238
201
  hasGroupTitle={hasGroupTitle}
239
202
  isCollapsible={isCollapsible}
240
203
  >
241
- <div className={cx(classes.inputs, inputsClassName)}>
204
+ <div
205
+ className={`flex flex-col my-4 gap-y-4 ${inputsClassName || ''}`}
206
+ >
242
207
  {groupedInputs.map((inputProps) => {
243
208
  const key =
244
209
  isNotNil(inputProps.label) || isNotEmpty(inputProps.label)
@@ -257,14 +222,11 @@ const Inputs = ({
257
222
  const Input = getInput(inputProps.type);
258
223
 
259
224
  return (
260
- <div className={classes.inputWrapper} key={key as string}>
225
+ <div className="w-full" key={key as string}>
261
226
  {inputProps.additionalLabel && (
262
227
  <Typography
263
- className={cx(
264
- classes.additionalLabel,
265
- inputProps?.additionalLabelClassName
266
- )}
267
- variant="body1"
228
+ className={`mb-1 text-base font-bold text-text-primary ${inputProps?.additionalLabelClassName || ''}`}
229
+ variant="h6"
268
230
  >
269
231
  {inputProps.additionalLabel}
270
232
  </Typography>
@@ -282,13 +244,9 @@ const Inputs = ({
282
244
  hasGroupTitle &&
283
245
  not(equals(lastGroup, groupName as string)) && (
284
246
  <Divider
285
- className={classes.divider}
247
+ className={isHorizontal ? 'my-0 mx-4' : 'my-4 mx-0'}
286
248
  flexItem
287
- orientation={
288
- equals(groupDirection, GroupDirection.Horizontal)
289
- ? 'vertical'
290
- : 'horizontal'
291
- }
249
+ orientation={isHorizontal ? 'vertical' : 'horizontal'}
292
250
  />
293
251
  )}
294
252
  </Fragment>
@@ -146,6 +146,10 @@ export interface InputProps {
146
146
  grid?: {
147
147
  alignItems?: string;
148
148
  className?: string;
149
+ // A dashed vertical line in the gap between two columns, never left of
150
+ // the first one, whatever the number of columns the layout shows. To set
151
+ // on the grids that place groups of fields side by side.
152
+ columnSeparator?: boolean;
149
153
  columns: Array<Omit<InputProps, 'group'>>;
150
154
  gridTemplateColumns?: string;
151
155
  };
@@ -171,6 +175,8 @@ export interface InputProps {
171
175
  };
172
176
  required?: boolean;
173
177
  segmentedButtons?: {
178
+ // Leaves the label out of the display; it stays the group's accessible name.
179
+ hideLabel?: boolean;
174
180
  options: Array<{
175
181
  label: string;
176
182
  value: string;
@@ -0,0 +1,174 @@
1
+ import { rs } from '@rstest/core';
2
+ import { render } from '@test-setup/render';
3
+ import { screen, waitFor, within } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { object } from 'yup';
6
+
7
+ import { Form } from '../Form';
8
+ import { InputType } from '../Inputs/models';
9
+
10
+ const sectionNames = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'];
11
+
12
+ const SectionContent = (): JSX.Element => <div style={{ height: 400 }} />;
13
+
14
+ const groups = sectionNames.map((name, index) => ({
15
+ name,
16
+ order: index + 1
17
+ }));
18
+
19
+ const inputs = sectionNames.map((name) => ({
20
+ custom: { Component: SectionContent },
21
+ fieldName: name,
22
+ group: name,
23
+ label: name,
24
+ type: InputType.Custom
25
+ }));
26
+
27
+ const FormInScrollArea = ({ testId }: { testId: string }): JSX.Element => (
28
+ <div
29
+ data-testid={testId}
30
+ style={{ height: 300, overflowY: 'auto', position: 'relative' }}
31
+ >
32
+ <Form
33
+ groups={groups}
34
+ initialValues={{}}
35
+ inputs={inputs}
36
+ submit={rs.fn()}
37
+ validationSchema={object()}
38
+ />
39
+ </div>
40
+ );
41
+
42
+ const getScrollArea = (testId = 'scroll-area'): HTMLElement =>
43
+ screen.getByTestId(testId);
44
+
45
+ const getTab = (name: string, testId = 'scroll-area'): HTMLElement =>
46
+ within(getScrollArea(testId)).getByRole('tab', { name });
47
+
48
+ const getSelectedTabs = (testId = 'scroll-area'): Array<string> =>
49
+ within(getScrollArea(testId))
50
+ .getAllByRole('tab')
51
+ .filter((tab) => tab.getAttribute('aria-selected') === 'true')
52
+ .map((tab) => tab.getAttribute('aria-label') as string);
53
+
54
+ const scrollToSection = (name: string, testId = 'scroll-area'): void => {
55
+ const scrollArea = getScrollArea(testId);
56
+ const anchor = within(scrollArea).getByTestId(`${name}-header`);
57
+
58
+ scrollArea.scrollTop +=
59
+ anchor.getBoundingClientRect().top -
60
+ scrollArea.getBoundingClientRect().top -
61
+ 48;
62
+ };
63
+
64
+ describe('Form section navigation', () => {
65
+ beforeEach(() => {
66
+ expect.hasAssertions();
67
+ });
68
+
69
+ it('activates the first tab when the form is displayed', async () => {
70
+ render(<FormInScrollArea testId="scroll-area" />);
71
+
72
+ expect(await screen.findByRole('tab', { name: 'Alpha' })).toBeVisible();
73
+ expect(getSelectedTabs()).toStrictEqual(['Alpha']);
74
+ });
75
+
76
+ it('activates the tab of the section in view when the form is scrolled down then up', async () => {
77
+ render(<FormInScrollArea testId="scroll-area" />);
78
+ await screen.findByRole('tab', { name: 'Alpha' });
79
+
80
+ scrollToSection('Charlie');
81
+
82
+ await waitFor(() => {
83
+ expect(getSelectedTabs()).toStrictEqual(['Charlie']);
84
+ });
85
+
86
+ scrollToSection('Bravo');
87
+
88
+ await waitFor(() => {
89
+ expect(getSelectedTabs()).toStrictEqual(['Bravo']);
90
+ });
91
+ });
92
+
93
+ it('activates the last tab when the form is scrolled to the bottom', async () => {
94
+ render(<FormInScrollArea testId="scroll-area" />);
95
+ await screen.findByRole('tab', { name: 'Alpha' });
96
+
97
+ const scrollArea = getScrollArea();
98
+ scrollArea.scrollTop = scrollArea.scrollHeight;
99
+
100
+ await waitFor(() => {
101
+ expect(getSelectedTabs()).toStrictEqual(['Echo']);
102
+ });
103
+ });
104
+
105
+ it('scrolls to the section and keeps its tab active all along when a tab is clicked', async () => {
106
+ render(<FormInScrollArea testId="scroll-area" />);
107
+ await screen.findByRole('tab', { name: 'Alpha' });
108
+
109
+ const tablist = within(getScrollArea()).getByRole('tablist');
110
+ const selectedWhileScrolling = new Set<string>();
111
+ const observer = new MutationObserver(() => {
112
+ for (const tab of getSelectedTabs()) {
113
+ selectedWhileScrolling.add(tab);
114
+ }
115
+ });
116
+ observer.observe(tablist, {
117
+ attributeFilter: ['aria-selected'],
118
+ attributes: true,
119
+ subtree: true
120
+ });
121
+
122
+ await userEvent.click(getTab('Delta'));
123
+
124
+ await waitFor(() => {
125
+ expect(getScrollArea().scrollTop).toBeGreaterThan(0);
126
+ });
127
+ await waitFor(() => {
128
+ expect(
129
+ within(getScrollArea())
130
+ .getByTestId('Delta-header')
131
+ .getBoundingClientRect().top -
132
+ getScrollArea().getBoundingClientRect().top
133
+ ).toBeLessThan(100);
134
+ });
135
+
136
+ // Reaching the section is not the end of the smooth scroll: keep observing
137
+ // until the scroll position stops moving.
138
+ let previousScrollTop = -1;
139
+ await waitFor(() => {
140
+ const { scrollTop } = getScrollArea();
141
+ const hasStopped = scrollTop === previousScrollTop;
142
+ previousScrollTop = scrollTop;
143
+
144
+ expect(hasStopped).toBe(true);
145
+ });
146
+ observer.disconnect();
147
+
148
+ await waitFor(() => {
149
+ expect(getSelectedTabs()).toStrictEqual(['Delta']);
150
+ });
151
+ expect([...selectedWhileScrolling]).toStrictEqual(['Delta']);
152
+ });
153
+
154
+ it('only scrolls its own form when two forms share the same group names', async () => {
155
+ render(
156
+ <>
157
+ <FormInScrollArea testId="first-form" />
158
+ <FormInScrollArea testId="second-form" />
159
+ </>
160
+ );
161
+ await waitFor(() => {
162
+ expect(screen.getAllByRole('tab', { name: 'Alpha' })).toHaveLength(2);
163
+ });
164
+
165
+ await userEvent.click(getTab('Charlie', 'second-form'));
166
+
167
+ await waitFor(() => {
168
+ expect(getScrollArea('second-form').scrollTop).toBeGreaterThan(0);
169
+ });
170
+
171
+ expect(getScrollArea('first-form').scrollTop).toBe(0);
172
+ expect(getSelectedTabs('first-form')).toStrictEqual(['Alpha']);
173
+ });
174
+ });
@@ -1,22 +1,35 @@
1
1
  import { Box, type TabsProps } from '@mui/material';
2
2
 
3
3
  import { isNil } from 'ramda';
4
- import { useMemo } from 'react';
4
+ import { type RefObject, useMemo } from 'react';
5
5
 
6
6
  import { Tabs } from '../../components/Tabs';
7
7
  import type { Group } from '../Inputs/models';
8
- import { useNavigateToSection } from './navigateToSection';
9
8
  import { groupToTab } from './PanelTabs';
9
+ import { useActiveSection } from './useActiveSection';
10
10
 
11
11
  export interface FormSectionProps extends TabsProps {
12
+ containerRef: RefObject<HTMLElement | null>;
12
13
  groups?: Array<Group>;
13
14
  }
14
15
 
15
- const FormSection = ({ groups }: FormSectionProps) => {
16
- const navigateToSection = useNavigateToSection();
16
+ const FormSection = ({ groups, containerRef }: FormSectionProps) => {
17
17
  const tabMemo = useMemo(() => groupToTab(groups), [groups]);
18
18
 
19
- if (isNil(groups) || groups.length < 4) {
19
+ const hasTabs = !isNil(groups) && groups.length >= 4;
20
+
21
+ const sectionNames = useMemo(
22
+ () => tabMemo.map(({ value }) => value),
23
+ [tabMemo]
24
+ );
25
+
26
+ const { activeSection, goToSection } = useActiveSection({
27
+ containerRef,
28
+ isEnabled: hasTabs,
29
+ sectionNames
30
+ });
31
+
32
+ if (!hasTabs) {
20
33
  return null;
21
34
  }
22
35
 
@@ -24,8 +37,9 @@ const FormSection = ({ groups }: FormSectionProps) => {
24
37
  <Box className="sticky top-0 bg-background-paper z-100">
25
38
  <Tabs
26
39
  defaultTab={tabMemo[0].value}
27
- onChange={navigateToSection}
40
+ onChange={goToSection}
28
41
  scrollButtons={false}
42
+ selectedTab={activeSection}
29
43
  tabs={tabMemo}
30
44
  variant="scrollable"
31
45
  />