@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,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
+ });
@@ -0,0 +1,186 @@
1
+ import { rs } from '@rstest/core';
2
+ import { render } from '@test-setup/render';
3
+ import { screen } from '@testing-library/react';
4
+ import { object } from 'yup';
5
+
6
+ import { Form } from '../Form';
7
+ import { type InputProps, InputType } from './models';
8
+
9
+ const getField = (
10
+ name: string,
11
+ extra: Partial<InputProps> = {}
12
+ ): Omit<InputProps, 'group'> => ({
13
+ fieldName: name,
14
+ label: name,
15
+ type: InputType.Text,
16
+ ...extra
17
+ });
18
+
19
+ interface InitializeProps {
20
+ columnSeparator?: boolean;
21
+ // The tracks of the grid: what makes the number of columns follow the width.
22
+ columns: string;
23
+ fields: Array<Omit<InputProps, 'group'>>;
24
+ width: number;
25
+ }
26
+
27
+ const initialize = ({
28
+ columns,
29
+ columnSeparator,
30
+ fields,
31
+ width
32
+ }: InitializeProps): void => {
33
+ render(
34
+ <div style={{ padding: 40, width }}>
35
+ <Form
36
+ initialValues={{}}
37
+ inputs={[
38
+ {
39
+ fieldName: 'layout',
40
+ grid: {
41
+ columnSeparator,
42
+ columns: fields,
43
+ gridTemplateColumns: columns
44
+ },
45
+ group: '',
46
+ label: 'layout',
47
+ type: InputType.Grid
48
+ }
49
+ ]}
50
+ submit={rs.fn()}
51
+ validationSchema={object()}
52
+ />
53
+ </div>
54
+ );
55
+ };
56
+
57
+ // The lines the grid draws, one for each of its items.
58
+ const getLines = (): Array<HTMLElement> =>
59
+ Array.from(document.querySelectorAll('[data-column-separator]'));
60
+
61
+ // A line is seen when what is painted at its place is the line itself: outside
62
+ // the grid, where it is clipped, there is something else.
63
+ const getVisibleLines = (): Array<boolean> =>
64
+ getLines().map((line) => {
65
+ const { left, top } = line.getBoundingClientRect();
66
+
67
+ return document.elementFromPoint(left + 0.5, top + 10) === line;
68
+ });
69
+
70
+ const threeFields = ['one', 'two', 'three'].map((name) => getField(name));
71
+
72
+ describe('Grid column separator', () => {
73
+ beforeEach(() => {
74
+ expect.hasAssertions();
75
+ });
76
+
77
+ it('draws nothing unless asked to', async () => {
78
+ initialize({
79
+ columns: 'repeat(2, 1fr)',
80
+ fields: threeFields,
81
+ width: 800
82
+ });
83
+
84
+ await screen.findByLabelText('one');
85
+
86
+ expect(document.querySelectorAll('[data-column-separator]')).toHaveLength(
87
+ 0
88
+ );
89
+ });
90
+
91
+ it('draws a dashed line between two columns, not left of the first', async () => {
92
+ initialize({
93
+ columnSeparator: true,
94
+ columns: 'repeat(2, 1fr)',
95
+ fields: [getField('one'), getField('two')],
96
+ width: 800
97
+ });
98
+
99
+ await screen.findByLabelText('one');
100
+
101
+ expect(getVisibleLines()).toEqual([false, true]);
102
+
103
+ const line = getComputedStyle(getLines()[1]);
104
+
105
+ expect(line.borderLeftStyle).toBe('dashed');
106
+ expect(line.borderLeftWidth).toBe('1px');
107
+ });
108
+
109
+ it('draws nothing when the layout has a single column', async () => {
110
+ initialize({
111
+ columnSeparator: true,
112
+ columns: 'repeat(auto-fill, minmax(500px, 1fr))',
113
+ fields: [getField('one'), getField('two')],
114
+ width: 400
115
+ });
116
+
117
+ await screen.findByLabelText('one');
118
+
119
+ expect(getVisibleLines()).toEqual([false, false]);
120
+ });
121
+
122
+ it('follows the number of columns the layout shows', async () => {
123
+ initialize({
124
+ columnSeparator: true,
125
+ columns: 'repeat(auto-fill, minmax(300px, 1fr))',
126
+ fields: threeFields,
127
+ width: 1100
128
+ });
129
+
130
+ await screen.findByLabelText('one');
131
+
132
+ expect(getVisibleLines()).toEqual([false, true, true]);
133
+ });
134
+
135
+ it('draws the lines of every row when the items wrap', async () => {
136
+ initialize({
137
+ columnSeparator: true,
138
+ columns: 'repeat(3, 1fr)',
139
+ fields: ['a', 'b', 'c', 'd', 'e'].map((name) => getField(name)),
140
+ width: 1100
141
+ });
142
+
143
+ await screen.findByLabelText('a');
144
+
145
+ expect(getVisibleLines()).toEqual([false, true, true, false, true]);
146
+ });
147
+
148
+ it('spans the height of the row, not of the item', async () => {
149
+ initialize({
150
+ columnSeparator: true,
151
+ columns: 'repeat(2, 1fr)',
152
+ fields: [
153
+ getField('short'),
154
+ getField('tall', { text: { multilineRows: 6 } })
155
+ ],
156
+ width: 800
157
+ });
158
+
159
+ await screen.findByLabelText('short');
160
+
161
+ const [short, tall] = getLines().map(
162
+ (item) => item.getBoundingClientRect().height
163
+ );
164
+
165
+ expect(tall).toBeGreaterThan(100);
166
+ expect(short).toBeCloseTo(tall, 0);
167
+ });
168
+
169
+ it('leaves no line for a hidden item and follows the items left', async () => {
170
+ initialize({
171
+ columnSeparator: true,
172
+ columns: 'repeat(2, 1fr)',
173
+ fields: [
174
+ getField('one'),
175
+ getField('hidden', { hideInput: () => true }),
176
+ getField('three')
177
+ ],
178
+ width: 800
179
+ });
180
+
181
+ await screen.findByLabelText('one');
182
+
183
+ expect(getLines()).toHaveLength(2);
184
+ expect(getVisibleLines()).toEqual([false, true]);
185
+ });
186
+ });
@@ -2,10 +2,36 @@ import { Box, Typography } from '@mui/material';
2
2
 
3
3
  import { type FormikValues, useFormikContext } from 'formik';
4
4
  import { isNotEmpty, isNotNil } from 'ramda';
5
+ import type { CSSProperties } from 'react';
5
6
 
6
7
  import { getInput } from '.';
7
8
  import type { InputPropsWithoutGroup } from './models';
8
9
 
10
+ // The line is drawn left of every item, in the middle of the gap, and the grid
11
+ // clips what falls out of it: the items of the first column lose theirs, so it
12
+ // never shows in a single column nor left of the first one, whatever the number
13
+ // of columns, without knowing it. The clip keeps 4px around the grid so that
14
+ // nothing the items draw outside their box is cut.
15
+ const columnGap = 'calc(var(--spacing) * 8)';
16
+
17
+ const columnSeparatorGridStyle = {
18
+ '--form-grid-column-gap': columnGap,
19
+ columnGap: 'var(--form-grid-column-gap)',
20
+ overflowClipMargin: '4px',
21
+ overflowX: 'clip'
22
+ } as CSSProperties;
23
+
24
+ // The dashed line of an item: half a gap to its left, over the height of its
25
+ // content.
26
+ const ColumnSeparator = (): JSX.Element => (
27
+ <span
28
+ aria-hidden
29
+ className="absolute inset-y-0 border-l border-dashed border-divider"
30
+ data-column-separator
31
+ style={{ left: 'calc(var(--form-grid-column-gap) / -2)' }}
32
+ />
33
+ );
34
+
9
35
  const Grid = ({
10
36
  grid,
11
37
  hideInput
@@ -17,6 +43,7 @@ const Grid = ({
17
43
  }
18
44
 
19
45
  const className = grid?.className || '';
46
+ const hasColumnSeparator = grid?.columnSeparator ?? false;
20
47
 
21
48
  return (
22
49
  <div
@@ -26,7 +53,8 @@ const Grid = ({
26
53
  gridTemplateColumns: className
27
54
  ? grid?.gridTemplateColumns || undefined
28
55
  : grid?.gridTemplateColumns ||
29
- `repeat(${grid?.columns.length || 1}, 1fr)`
56
+ `repeat(${grid?.columns.length || 1}, 1fr)`,
57
+ ...(hasColumnSeparator && columnSeparatorGridStyle)
30
58
  }}
31
59
  >
32
60
  {grid?.columns.map((field) => {
@@ -41,18 +69,37 @@ const Grid = ({
41
69
  return null;
42
70
  }
43
71
 
72
+ const title = field.additionalLabel && (
73
+ <Typography
74
+ className={`mb-1 text-base font-bold text-text-primary ${field?.additionalLabelClassName || ''}`}
75
+ variant="h6"
76
+ >
77
+ {field.additionalLabel}
78
+ </Typography>
79
+ );
80
+
81
+ if (!hasColumnSeparator) {
82
+ return (
83
+ <Box key={key as string} sx={{ width: '100%' }}>
84
+ {title}
85
+ <Input {...field} />
86
+ </Box>
87
+ );
88
+ }
89
+
90
+ // The item stretches to the height of its row and its content fills
91
+ // what its title leaves, so the line spans the whole row.
44
92
  return (
45
- <Box key={key as string} sx={{ width: '100%' }}>
46
- {field.additionalLabel && (
47
- <Typography
48
- className={field?.additionalLabelClassName}
49
- sx={{ color: 'primary.main', marginBottom: 0.5 }}
50
- variant="h6"
51
- >
52
- {field.additionalLabel}
53
- </Typography>
54
- )}
55
- <Input {...field} />
93
+ <Box
94
+ className="flex flex-col self-stretch"
95
+ key={key as string}
96
+ sx={{ width: '100%' }}
97
+ >
98
+ {title}
99
+ <div className="relative flex-1">
100
+ <ColumnSeparator />
101
+ <Input {...field} />
102
+ </div>
56
103
  </Box>
57
104
  );
58
105
  })}
@@ -0,0 +1,80 @@
1
+ import { rs } from '@rstest/core';
2
+ import { render } from '@test-setup/render';
3
+ import { screen } 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 './models';
9
+
10
+ const initialize = (): void => {
11
+ render(
12
+ <Form
13
+ initialValues={{ community: '', note: '' }}
14
+ inputs={[
15
+ {
16
+ dataTestId: 'note',
17
+ fieldName: 'note',
18
+ group: '',
19
+ label: 'Note',
20
+ type: InputType.Text
21
+ },
22
+ {
23
+ dataTestId: 'community',
24
+ fieldName: 'community',
25
+ group: '',
26
+ label: 'SNMP community',
27
+ type: InputType.Password
28
+ }
29
+ ]}
30
+ submit={rs.fn()}
31
+ validationSchema={object()}
32
+ />
33
+ );
34
+ };
35
+
36
+ const getInput = async (testId: string): Promise<HTMLInputElement> =>
37
+ (await screen.findAllByTestId(testId)).find(
38
+ (element) => element.tagName === 'INPUT'
39
+ ) as HTMLInputElement;
40
+
41
+ describe('Password input', () => {
42
+ beforeEach(() => {
43
+ expect.hasAssertions();
44
+ });
45
+
46
+ it('asks the browser and the password managers not to fill it', async () => {
47
+ initialize();
48
+
49
+ const password = await getInput('community');
50
+
51
+ expect(password).toHaveAttribute('type', 'password');
52
+ expect(password).toHaveAttribute('autocomplete', 'new-password');
53
+ expect(password).toHaveAttribute('data-1p-ignore', 'true');
54
+ expect(password).toHaveAttribute('data-bwignore', 'true');
55
+ expect(password).toHaveAttribute('data-lpignore', 'true');
56
+ });
57
+
58
+ it('keeps those hints while the password is shown', async () => {
59
+ initialize();
60
+
61
+ await userEvent.click(
62
+ await screen.findByRole('button', { name: /password|show|visib/i })
63
+ );
64
+
65
+ const password = await getInput('community');
66
+
67
+ expect(password).toHaveAttribute('type', 'text');
68
+ expect(password).toHaveAttribute('autocomplete', 'new-password');
69
+ expect(password).toHaveAttribute('data-1p-ignore', 'true');
70
+ });
71
+
72
+ it('leaves the other text inputs as they were', async () => {
73
+ initialize();
74
+
75
+ const note = await getInput('note');
76
+
77
+ expect(note).toHaveAttribute('autocomplete', 'off');
78
+ expect(note).not.toHaveAttribute('data-1p-ignore');
79
+ });
80
+ });