@centreon/ui 26.12.0 → 26.12.2

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 (57) 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/ExclusiveCheckboxGroup.browser.test.tsx +304 -0
  6. package/src/Form/Inputs/SegmentedButtons.browser.test.tsx +113 -0
  7. package/src/InputField/Number/Number.browser.test.tsx +109 -0
  8. package/src/InputField/Select/Autocomplete/Connected/Multi/MultiConnectedAutocompleteField.browser.test.tsx +250 -0
  9. package/src/Listing/Listing.browser.test.tsx +476 -0
  10. package/src/Module/Module.browser.test.tsx +145 -0
  11. package/src/Pagination/Pagination.browser.test.tsx +184 -0
  12. package/src/Panel/Section/index.test.tsx +1 -1
  13. package/src/RichTextEditor/RichTextEditor.browser.test.tsx +567 -0
  14. package/src/RichTextEditor/plugins/FloatingLinkEditorPlugin.tsx +3 -1
  15. package/src/TimePeriods/ResolutionTimePeriod.browser.test.tsx +143 -0
  16. package/src/TimePeriods/TimePeriods.browser.test.tsx +468 -0
  17. package/src/Typography/FluidTypography/FluidTypography.browser.test.tsx +54 -0
  18. package/src/api/useMutationQuery/useMutationQuery.browser.test.tsx +154 -0
  19. package/src/components/CollapsibleItem/CollapsibleItem.browser.test.tsx +120 -0
  20. package/src/components/CopyCommand/CopyCommand.browser.test.tsx +287 -0
  21. package/src/components/CrudPage/CrudPage.browser.test.tsx +659 -0
  22. package/src/components/DataTable/DataTable.browser.test.tsx +257 -0
  23. package/src/components/Form/AccessRights/AccessRights.browser.test.tsx +527 -0
  24. package/src/components/ItemComposition/ItemComposition.browser.test.tsx +172 -0
  25. package/src/components/Layout/PageLayout.browser.test.tsx +114 -0
  26. package/src/components/Modal/ConfirmationModal/ConfirmationModal.browser.test.tsx +237 -0
  27. package/src/components/SortableEntriesList/SortableEntriesList.browser.test.tsx +981 -0
  28. package/src/components/Tabs/Tabs.browser.test.tsx +115 -0
  29. package/src/utils/useFullscreen/Fullscreen.browser.test.tsx +176 -0
  30. package/src/utils/useLocale/useLocale.browser.test.tsx +49 -0
  31. package/src/utils/useLocaleTimezoneDate/{LocaleTimezoneDate.cypress.spec.tsx → LocaleTimezoneDate.browser.test.tsx} +24 -11
  32. package/src/Dashboard/Dashboard.cypress.spec.tsx +0 -71
  33. package/src/Form/Form.cypress.spec.tsx +0 -306
  34. package/src/Form/Inputs/ExclusiveCheckboxGroup.cypress.spec.tsx +0 -236
  35. package/src/Form/Inputs/SegmentedButtons.cypress.spec.tsx +0 -101
  36. package/src/InputField/Number/Number.cypress.spec.tsx +0 -89
  37. package/src/InputField/Select/Autocomplete/Connected/Multi/MultiConnectedAutocompleteField.cypress.spec.tsx +0 -182
  38. package/src/Listing/Listing.cypress.spec.tsx +0 -349
  39. package/src/Module/Module.cypress.spec.tsx +0 -127
  40. package/src/Pagination/Pagination.cypress.spec.tsx +0 -140
  41. package/src/RichTextEditor/RichTextEditor.cypress.spec.tsx +0 -408
  42. package/src/TimePeriods/ResolutionTimePeriod.cypress.spec.tsx +0 -74
  43. package/src/TimePeriods/TimePeriods.cypress.spec.tsx +0 -488
  44. package/src/Typography/FluidTypography/FluidTypography.cypress.spec.tsx +0 -27
  45. package/src/api/useMutationQuery/useMutationQuery.cypress.spec.tsx +0 -93
  46. package/src/components/CollapsibleItem/CollapsibleItem.cypress.spec.tsx +0 -76
  47. package/src/components/CopyCommand/CopyCommand.cypress.spec.tsx +0 -167
  48. package/src/components/CrudPage/CrudPage.cypress.spec.tsx +0 -561
  49. package/src/components/DataTable/DataTable.cypress.spec.tsx +0 -174
  50. package/src/components/Form/AccessRights/AccessRights.cypress.spec.tsx +0 -376
  51. package/src/components/ItemComposition/ItemComposition.cypress.spec.tsx +0 -112
  52. package/src/components/Layout/PageLayout.cypress.spec.tsx +0 -64
  53. package/src/components/Modal/ConfirmationModal/ConfirmationModal.cypress.spec.tsx +0 -171
  54. package/src/components/SortableEntriesList/SortableEntriesList.cypress.spec.tsx +0 -792
  55. package/src/components/Tabs/Tabs.cypress.spec.tsx +0 -69
  56. package/src/utils/useFullscreen/Fullscreen.cypress.spec.tsx +0 -130
  57. package/src/utils/useLocale/useLocale.cypress.spec.tsx +0 -40
@@ -0,0 +1,981 @@
1
+ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
2
+ import EditIcon from '@mui/icons-material/Edit';
3
+ import VisibilityIcon from '@mui/icons-material/Visibility';
4
+
5
+ import { rs } from '@rstest/core';
6
+ import { interceptAPIRequest, Method, waitForRequest } from '@test-setup/api';
7
+ import { render } from '@test-setup/render';
8
+ import { fireEvent, screen, waitFor } from '@testing-library/react';
9
+ import userEvent from '@testing-library/user-event';
10
+ import { useState } from 'react';
11
+
12
+ import { TestQueryProvider } from '../..';
13
+ import buildListingEndpoint from '../../api/buildListingEndpoint';
14
+ import type { SelectEntry } from '../../InputField/Select';
15
+ import SingleConnectedAutocompleteField from '../../InputField/Select/Autocomplete/Connected/Single';
16
+ import TextField from '../../InputField/Text';
17
+ import type { RenderRowParams, RowParams, SortableRowAction } from './models';
18
+ import { type Props, SortableEntriesList } from './SortableEntriesList';
19
+
20
+ type Spy = ReturnType<typeof rs.fn>;
21
+
22
+ const ControlledList = <T,>({
23
+ initialValues,
24
+ onChangeSpy,
25
+ ...props
26
+ }: Omit<Props<T>, 'values' | 'onChange'> & {
27
+ initialValues: Array<T>;
28
+ onChangeSpy: Spy;
29
+ }): JSX.Element => {
30
+ const [values, setValues] = useState(initialValues);
31
+
32
+ return (
33
+ <SortableEntriesList<T>
34
+ {...props}
35
+ onChange={(nextValues): void => {
36
+ onChangeSpy(nextValues);
37
+ setValues(nextValues);
38
+ }}
39
+ values={values}
40
+ />
41
+ );
42
+ };
43
+
44
+ const getLastValues = <T,>(onChange: Spy): Array<T> =>
45
+ onChange.mock.lastCall?.[0] as Array<T>;
46
+
47
+ const getRows = (): Array<HTMLElement> => screen.queryAllByRole('listitem');
48
+
49
+ const getFocused = (): HTMLElement => document.activeElement as HTMLElement;
50
+
51
+ const getRect = (element: Element): DOMRect => element.getBoundingClientRect();
52
+
53
+ const getInputs = (testId: string): Array<HTMLInputElement> =>
54
+ Array.from(document.querySelectorAll(`input[data-testid="${testId}"]`));
55
+
56
+ const getRowButtons = (rowIndex: number): Array<HTMLButtonElement> =>
57
+ Array.from(
58
+ document
59
+ .querySelectorAll('[data-row-fields]')
60
+ [rowIndex].nextElementSibling?.querySelectorAll('button') ?? []
61
+ );
62
+
63
+ interface MoveSortableElementProps {
64
+ direction: 'up' | 'down';
65
+ handle: HTMLElement;
66
+ times?: number;
67
+ }
68
+
69
+ // The rows other than the dragged one that make way for it: as many as the
70
+ // dragged row moved past.
71
+ const countShiftedRows = (row: HTMLElement): number =>
72
+ Array.from(row.parentElement?.children ?? []).filter(
73
+ (sibling) =>
74
+ sibling !== row &&
75
+ /translate3d\(0px, -?[1-9]/.test((sibling as HTMLElement).style.transform)
76
+ ).length;
77
+
78
+ // A keyboard drag: space to pick the row up, the arrows to move it,
79
+ // space to drop it. dnd-kit handles a key once the previous one took effect,
80
+ // so each step is awaited: the handle pressed once the row is picked up, one
81
+ // more row making way after each arrow, the handle released once dropped.
82
+ const moveSortableElement = async ({
83
+ handle,
84
+ direction,
85
+ times = 1
86
+ }: MoveSortableElementProps): Promise<void> => {
87
+ const row = handle.closest('li') as HTMLElement;
88
+ const key = direction === 'down' ? '{ArrowDown}' : '{ArrowUp}';
89
+
90
+ handle.focus();
91
+ await userEvent.keyboard(' ');
92
+ await waitFor(() => {
93
+ expect(handle).toHaveAttribute('aria-pressed', 'true');
94
+ });
95
+
96
+ for (let move = 1; move <= times; move += 1) {
97
+ await userEvent.keyboard(key);
98
+ await waitFor(() => {
99
+ expect(countShiftedRows(row)).toBe(move);
100
+ });
101
+ }
102
+
103
+ await userEvent.keyboard(' ');
104
+ await waitFor(() => {
105
+ expect(handle).not.toHaveAttribute('aria-pressed', 'true');
106
+ });
107
+ };
108
+
109
+ const endpoint = '/configuration/hosts/templates';
110
+
111
+ const options: Array<SelectEntry> = [
112
+ { id: 1, name: 'generic-active-host' },
113
+ { id: 2, name: 'generic-passive-host' },
114
+ { id: 3, name: 'linux-server-standard' }
115
+ ];
116
+
117
+ type TemplateRow = SelectEntry | null;
118
+
119
+ const renderTemplateRow = ({
120
+ setValue,
121
+ value,
122
+ values
123
+ }: RenderRowParams<TemplateRow>): JSX.Element => {
124
+ const selectedElsewhere = values
125
+ .map((template) => template?.id)
126
+ .filter((id) => id !== value?.id);
127
+
128
+ return (
129
+ <SingleConnectedAutocompleteField
130
+ baseEndpoint=""
131
+ field="name"
132
+ fullWidth
133
+ getEndpoint={(parameters): string =>
134
+ buildListingEndpoint({ baseEndpoint: endpoint, parameters })
135
+ }
136
+ getOptionDisabled={(option: SelectEntry): boolean =>
137
+ selectedElsewhere.includes(option.id)
138
+ }
139
+ label="Template"
140
+ onChange={(_event, template): void => setValue(template as TemplateRow)}
141
+ value={value}
142
+ />
143
+ );
144
+ };
145
+
146
+ const getTemplateInputs = (): Array<HTMLInputElement> => getInputs('Template');
147
+
148
+ interface InitializeTemplatesProps {
149
+ draggable?: boolean;
150
+ templates?: Array<TemplateRow>;
151
+ }
152
+
153
+ const initializeTemplates = ({
154
+ draggable = true,
155
+ templates = [options[0], options[1]]
156
+ }: InitializeTemplatesProps = {}): { onChange: Spy; onEdit: Spy } => {
157
+ const onChange = rs.fn();
158
+ const onEdit = rs.fn();
159
+
160
+ interceptAPIRequest({
161
+ alias: 'getTemplates',
162
+ method: Method.GET,
163
+ path: `**${endpoint}?**`,
164
+ response: {
165
+ meta: { limit: 10, page: 1, total: options.length },
166
+ result: options
167
+ }
168
+ });
169
+
170
+ render(
171
+ <TestQueryProvider>
172
+ <div className="p-4">
173
+ <ControlledList<TemplateRow>
174
+ actions={({
175
+ index,
176
+ value
177
+ }: RowParams<TemplateRow>): Array<SortableRowAction> => [
178
+ {
179
+ icon: <EditIcon />,
180
+ id: 'edit-template',
181
+ label: 'Edit template',
182
+ onClick: () => onEdit(value, index)
183
+ }
184
+ ]}
185
+ createValue={(): TemplateRow => null}
186
+ draggable={draggable}
187
+ initialValues={templates}
188
+ label="Templates"
189
+ onChangeSpy={onChange}
190
+ renderRow={renderTemplateRow}
191
+ />
192
+ </div>
193
+ </TestQueryProvider>
194
+ );
195
+
196
+ return { onChange, onEdit };
197
+ };
198
+
199
+ describe('SortableEntriesList - single input rows', () => {
200
+ beforeEach(() => {
201
+ expect.hasAssertions();
202
+ });
203
+
204
+ it('displays one row per value with its value, its actions, a delete button and a trailing drag handle', async () => {
205
+ initializeTemplates();
206
+
207
+ expect(
208
+ await screen.findByRole('list', { name: 'Templates' })
209
+ ).toBeInTheDocument();
210
+ expect(getRows()).toHaveLength(2);
211
+ await waitFor(() => {
212
+ expect(getTemplateInputs()[0]).toHaveValue(options[0].name);
213
+ });
214
+ expect(getTemplateInputs()[1]).toHaveValue(options[1].name);
215
+ expect(screen.getAllByTestId('edit-template')).toHaveLength(2);
216
+ expect(screen.getAllByTestId('delete-row')).toHaveLength(2);
217
+
218
+ const firstRowButtons = getRows()[0].querySelectorAll('button');
219
+
220
+ expect(firstRowButtons[firstRowButtons.length - 1]).toHaveAttribute(
221
+ 'data-testid',
222
+ 'drag-handle'
223
+ );
224
+
225
+ // The buttons of each row in order (edit, delete, drag handle), the
226
+ // delete button in red, and the add button below the rows.
227
+ getRows().forEach((_row, rowIndex) => {
228
+ expect(
229
+ getRowButtons(rowIndex).map((button) =>
230
+ button.getAttribute('data-testid')
231
+ )
232
+ ).toEqual(['edit-template', 'delete-row', 'drag-handle']);
233
+ });
234
+
235
+ const [red, green, blue] = (
236
+ getComputedStyle(screen.getAllByTestId('delete-row')[0]).color.match(
237
+ /\d+/g
238
+ ) ?? []
239
+ ).map(Number);
240
+
241
+ expect(red).toBeGreaterThan(green + 100);
242
+ expect(red).toBeGreaterThan(blue + 100);
243
+
244
+ const addButton = screen.getByTestId('Add');
245
+
246
+ expect(addButton).toBeVisible();
247
+ expect(getRect(addButton).top).toBeGreaterThanOrEqual(
248
+ getRect(getRows()[1]).bottom
249
+ );
250
+ });
251
+
252
+ it('reports the plain values, without any internal id, when an option is selected', async () => {
253
+ const { onChange } = initializeTemplates();
254
+
255
+ await userEvent.click(getTemplateInputs()[1]);
256
+ await waitForRequest('@getTemplates');
257
+ await userEvent.click(
258
+ await screen.findByRole('option', { name: options[2].name })
259
+ );
260
+
261
+ await waitFor(() => {
262
+ expect(getTemplateInputs()[1]).toHaveValue(options[2].name);
263
+ });
264
+ await waitFor(() => {
265
+ expect(getLastValues(onChange)).toEqual([options[0], options[2]]);
266
+ });
267
+ });
268
+
269
+ it('lets a row disable the options already selected in sibling rows', async () => {
270
+ initializeTemplates();
271
+
272
+ await userEvent.click(getTemplateInputs()[1]);
273
+ await waitForRequest('@getTemplates');
274
+
275
+ expect(
276
+ await screen.findByRole('option', { name: options[0].name })
277
+ ).toHaveAttribute('aria-disabled', 'true');
278
+ expect(
279
+ screen.getByRole('option', { name: options[2].name })
280
+ ).not.toHaveAttribute('aria-disabled', 'true');
281
+ });
282
+
283
+ it('appends a row built with createValue and focuses its first input', async () => {
284
+ const { onChange } = initializeTemplates();
285
+
286
+ await userEvent.click(await screen.findByTestId('Add'));
287
+
288
+ await waitFor(() => {
289
+ expect(getRows()).toHaveLength(3);
290
+ });
291
+ expect(getTemplateInputs()[2]).toHaveValue('');
292
+ await waitFor(() => {
293
+ expect(getFocused()).toHaveAttribute('data-testid', 'Template');
294
+ });
295
+
296
+ const focusedRow = getFocused().closest('li') as HTMLElement;
297
+
298
+ expect(
299
+ Array.from(focusedRow.parentElement?.children ?? []).indexOf(focusedRow)
300
+ ).toBe(2);
301
+ await waitFor(() => {
302
+ expect(getLastValues(onChange)).toEqual([options[0], options[1], null]);
303
+ });
304
+ });
305
+
306
+ it('removes a row and moves the focus to the first input of the row taking its place', async () => {
307
+ const { onChange } = initializeTemplates({
308
+ templates: [options[0], options[1], options[2]]
309
+ });
310
+
311
+ await userEvent.click((await screen.findAllByTestId('delete-row'))[0]);
312
+
313
+ await waitFor(() => {
314
+ expect(getRows()).toHaveLength(2);
315
+ });
316
+ await waitFor(() => {
317
+ expect(getLastValues(onChange)).toEqual([options[1], options[2]]);
318
+ });
319
+ await waitFor(() => {
320
+ expect(getFocused()).toHaveAttribute('data-testid', 'Template');
321
+ });
322
+ expect(getFocused()).toHaveValue(options[1].name);
323
+ });
324
+
325
+ it('moves the focus to the previous row when the last row is removed, then to the add button when the list becomes empty', async () => {
326
+ initializeTemplates();
327
+
328
+ await userEvent.click((await screen.findAllByTestId('delete-row'))[1]);
329
+ await waitFor(() => {
330
+ expect(
331
+ getFocused()
332
+ .closest('li')
333
+ ?.querySelector('input[data-testid="Template"]')
334
+ ).toHaveValue(options[0].name);
335
+ });
336
+
337
+ await userEvent.click(screen.getAllByTestId('delete-row')[0]);
338
+ await waitFor(() => {
339
+ expect(getRows()).toHaveLength(0);
340
+ });
341
+ await waitFor(() => {
342
+ expect(getFocused()).toHaveAttribute('data-testid', 'Add');
343
+ });
344
+ });
345
+
346
+ it('reorders the rows with the keyboard through the drag handle', async () => {
347
+ const { onChange } = initializeTemplates();
348
+
349
+ await moveSortableElement({
350
+ direction: 'down',
351
+ handle: (await screen.findAllByTestId('drag-handle'))[0]
352
+ });
353
+
354
+ await waitFor(() => {
355
+ expect(getTemplateInputs()[0]).toHaveValue(options[1].name);
356
+ });
357
+ expect(getTemplateInputs()[1]).toHaveValue(options[0].name);
358
+ await waitFor(() => {
359
+ expect(getLastValues(onChange)).toEqual([options[1], options[0]]);
360
+ });
361
+ });
362
+
363
+ it('passes the up-to-date row value and index to the actions after a reorder', async () => {
364
+ const { onEdit } = initializeTemplates();
365
+
366
+ await moveSortableElement({
367
+ direction: 'down',
368
+ handle: (await screen.findAllByTestId('drag-handle'))[0]
369
+ });
370
+ await waitFor(() => {
371
+ expect(getTemplateInputs()[0]).toHaveValue(options[1].name);
372
+ });
373
+
374
+ await userEvent.click(screen.getAllByTestId('edit-template')[1]);
375
+ await waitFor(() => {
376
+ expect(onEdit).toHaveBeenCalledWith(options[0], 1);
377
+ });
378
+ });
379
+
380
+ it('hides the drag handle when the list is not draggable', async () => {
381
+ initializeTemplates({ draggable: false });
382
+
383
+ expect(await screen.findAllByTestId('delete-row')).toHaveLength(2);
384
+ expect(screen.queryByTestId('drag-handle')).not.toBeInTheDocument();
385
+ });
386
+
387
+ it('lets the inputs take the remaining width and keeps the row buttons at a 32px pitch', async () => {
388
+ initializeTemplates();
389
+
390
+ await screen.findAllByTestId('drag-handle');
391
+
392
+ const buttons = getRowButtons(0);
393
+
394
+ expect(buttons).toHaveLength(3);
395
+
396
+ const lefts = buttons.map((button) => getRect(button).left);
397
+
398
+ lefts.slice(1).forEach((left, index) => {
399
+ expect(left - lefts[index]).toBe(32);
400
+ });
401
+
402
+ const rowFields = document.querySelectorAll('[data-row-fields]')[0];
403
+
404
+ expect(getRect(rowFields).width).toBeGreaterThan(500);
405
+ });
406
+ });
407
+
408
+ interface MacroRow {
409
+ isPassword: boolean;
410
+ name: string;
411
+ origin: 'direct' | 'fromTpl';
412
+ value: string;
413
+ }
414
+
415
+ const macros: Array<MacroRow> = [
416
+ { isPassword: false, name: 'MACRO_A', origin: 'direct', value: 'a' },
417
+ { isPassword: true, name: 'MACRO_B', origin: 'fromTpl', value: 'b' }
418
+ ];
419
+
420
+ const emptyMacro: MacroRow = {
421
+ isPassword: false,
422
+ name: '',
423
+ origin: 'direct',
424
+ value: ''
425
+ };
426
+
427
+ const renderMacroRow = ({
428
+ setField,
429
+ value: macro
430
+ }: RenderRowParams<MacroRow>): JSX.Element => (
431
+ <div className="grid grid-cols-2 gap-2 @max-[600px]:grid-cols-1">
432
+ <TextField
433
+ dataTestId="macro-name"
434
+ fullWidth
435
+ label="Name"
436
+ onChange={(event): void => setField('name', event.target.value)}
437
+ value={macro.name}
438
+ />
439
+ <TextField
440
+ dataTestId="macro-value"
441
+ fullWidth
442
+ label="Value"
443
+ onChange={(event): void => setField('value', event.target.value)}
444
+ type={macro.isPassword ? 'password' : 'text'}
445
+ value={macro.value}
446
+ />
447
+ </div>
448
+ );
449
+
450
+ interface MacrosListProps {
451
+ copyOnChange: boolean;
452
+ onChangeSpy: Spy;
453
+ }
454
+
455
+ const MacrosList = ({
456
+ copyOnChange,
457
+ onChangeSpy
458
+ }: MacrosListProps): JSX.Element => {
459
+ const [values, setValues] = useState(macros);
460
+
461
+ const change = (nextValues: Array<MacroRow>): void => {
462
+ onChangeSpy(nextValues);
463
+ // Simulates a parent that stores a copy (new array and objects), like a
464
+ // form library that clones its state.
465
+ setValues(
466
+ copyOnChange ? nextValues.map((macro) => ({ ...macro })) : nextValues
467
+ );
468
+ };
469
+
470
+ return (
471
+ <>
472
+ <button
473
+ data-testid="reset"
474
+ onClick={(): void => setValues([emptyMacro])}
475
+ type="button"
476
+ >
477
+ Reset
478
+ </button>
479
+ <SortableEntriesList<MacroRow>
480
+ actions={({ index, value }) => [
481
+ {
482
+ icon: <VisibilityIcon />,
483
+ id: 'toggle-password',
484
+ label: 'Toggle password',
485
+ onClick: () =>
486
+ change(
487
+ values.map((macro, macroIndex) =>
488
+ macroIndex === index
489
+ ? { ...macro, isPassword: !value.isPassword }
490
+ : macro
491
+ )
492
+ )
493
+ }
494
+ ]}
495
+ createValue={(): MacroRow => emptyMacro}
496
+ getRowClassName={({ value }) => `origin-${value.origin}`}
497
+ onChange={change}
498
+ renderRow={renderMacroRow}
499
+ values={values}
500
+ />
501
+ </>
502
+ );
503
+ };
504
+
505
+ const initializeMacros = ({
506
+ copyOnChange = false
507
+ }: {
508
+ copyOnChange?: boolean;
509
+ } = {}): { onChange: Spy } => {
510
+ const onChange = rs.fn();
511
+
512
+ render(<MacrosList copyOnChange={copyOnChange} onChangeSpy={onChange} />);
513
+
514
+ return { onChange };
515
+ };
516
+
517
+ describe('SortableEntriesList - multiple input rows', () => {
518
+ beforeEach(() => {
519
+ expect.hasAssertions();
520
+ });
521
+
522
+ it('renders every input of every row', async () => {
523
+ initializeMacros();
524
+
525
+ await waitFor(() => {
526
+ expect(getInputs('macro-name')).toHaveLength(2);
527
+ });
528
+ expect(getInputs('macro-value')).toHaveLength(2);
529
+ expect(getInputs('macro-value')[1]).toHaveAttribute('type', 'password');
530
+ });
531
+
532
+ it('keeps the focus in the edited input while typing and reports a structured value', async () => {
533
+ const { onChange } = initializeMacros();
534
+
535
+ await waitFor(() => {
536
+ expect(getInputs('macro-name')).toHaveLength(2);
537
+ });
538
+ await userEvent.clear(getInputs('macro-name')[0]);
539
+ await userEvent.type(getInputs('macro-name')[0], 'HOST_IP');
540
+ await waitFor(() => {
541
+ expect(getFocused()).toHaveValue('HOST_IP');
542
+ });
543
+
544
+ await userEvent.clear(getInputs('macro-value')[0]);
545
+ await userEvent.type(getInputs('macro-value')[0], '10.0.0.1');
546
+ await waitFor(() => {
547
+ expect(getFocused()).toHaveValue('10.0.0.1');
548
+ });
549
+
550
+ await waitFor(() => {
551
+ expect(getLastValues(onChange)).toEqual([
552
+ {
553
+ isPassword: false,
554
+ name: 'HOST_IP',
555
+ origin: 'direct',
556
+ value: '10.0.0.1'
557
+ },
558
+ macros[1]
559
+ ]);
560
+ });
561
+ });
562
+
563
+ it('keeps the focus when a new row is typed into right after being added', async () => {
564
+ const { onChange } = initializeMacros();
565
+
566
+ await userEvent.click(await screen.findByTestId('Add'));
567
+ await waitFor(() => {
568
+ expect(getFocused()).toHaveAttribute('data-testid', 'macro-name');
569
+ });
570
+ await userEvent.type(getFocused(), 'NEW');
571
+ await userEvent.type(getInputs('macro-value')[2], 'value');
572
+
573
+ await waitFor(() => {
574
+ expect(getLastValues<MacroRow>(onChange)[2]).toEqual({
575
+ ...emptyMacro,
576
+ name: 'NEW',
577
+ value: 'value'
578
+ });
579
+ });
580
+ });
581
+
582
+ it('keeps the rows and the focus stable when the parent stores a copy of the values', async () => {
583
+ const { onChange } = initializeMacros({ copyOnChange: true });
584
+
585
+ await waitFor(() => {
586
+ expect(getInputs('macro-name')).toHaveLength(2);
587
+ });
588
+ await userEvent.clear(getInputs('macro-name')[1]);
589
+ await userEvent.type(getInputs('macro-name')[1], 'RENAMED');
590
+ await waitFor(() => {
591
+ expect(getFocused()).toHaveValue('RENAMED');
592
+ });
593
+
594
+ await userEvent.click(screen.getByTestId('Add'));
595
+ await waitFor(() => {
596
+ expect(getFocused()).toHaveAttribute('data-testid', 'macro-name');
597
+ });
598
+ await userEvent.type(getFocused(), 'NEW');
599
+
600
+ await waitFor(() => {
601
+ expect(getLastValues<MacroRow>(onChange).map(({ name }) => name)).toEqual(
602
+ ['MACRO_A', 'RENAMED', 'NEW']
603
+ );
604
+ });
605
+ });
606
+
607
+ it('renders the new values when the parent replaces them', async () => {
608
+ initializeMacros();
609
+
610
+ await userEvent.click(await screen.findByTestId('reset'));
611
+
612
+ await waitFor(() => {
613
+ expect(getRows()).toHaveLength(1);
614
+ });
615
+ expect(getInputs('macro-name')[0]).toHaveValue('');
616
+ });
617
+
618
+ it('lets a row action change its own row', async () => {
619
+ initializeMacros();
620
+
621
+ await userEvent.click((await screen.findAllByTestId('toggle-password'))[0]);
622
+
623
+ await waitFor(() => {
624
+ expect(getInputs('macro-value')[0]).toHaveAttribute('type', 'password');
625
+ });
626
+ });
627
+
628
+ it('applies the class returned by getRowClassName to each row', async () => {
629
+ initializeMacros();
630
+
631
+ await screen.findAllByRole('listitem');
632
+
633
+ expect(getRows()[0]).toHaveClass('origin-direct');
634
+ expect(getRows()[1]).toHaveClass('origin-fromTpl');
635
+ });
636
+ });
637
+
638
+ const buildMacros = (count: number): Array<MacroRow> =>
639
+ Array.from({ length: count }, (_, index) => ({
640
+ ...emptyMacro,
641
+ name: `MACRO_${index + 1}`
642
+ }));
643
+
644
+ const panelWidths = [1400, 900, 720, 560];
645
+
646
+ interface ResizablePanelProps {
647
+ count: number;
648
+ maxVisibleRows?: number;
649
+ onChangeSpy: Spy;
650
+ }
651
+
652
+ // The panel width is state, so changing it resizes the mounted list at runtime.
653
+ const ResizablePanel = ({
654
+ count,
655
+ maxVisibleRows,
656
+ onChangeSpy
657
+ }: ResizablePanelProps): JSX.Element => {
658
+ const [width, setWidth] = useState(panelWidths[0]);
659
+
660
+ return (
661
+ <div className="p-4">
662
+ {panelWidths.map((panelWidth) => (
663
+ <button
664
+ data-testid={`width-${panelWidth}`}
665
+ key={panelWidth}
666
+ onClick={(): void => setWidth(panelWidth)}
667
+ type="button"
668
+ >
669
+ {panelWidth}
670
+ </button>
671
+ ))}
672
+ <div data-testid="panel" style={{ width }}>
673
+ <ControlledList<MacroRow>
674
+ actions={(): Array<SortableRowAction> => [
675
+ {
676
+ icon: <VisibilityIcon />,
677
+ id: 'toggle-password',
678
+ label: 'Toggle password',
679
+ onClick: () => undefined
680
+ },
681
+ {
682
+ icon: <DescriptionOutlinedIcon />,
683
+ id: 'macro-description',
684
+ label: 'Description',
685
+ onClick: () => undefined
686
+ }
687
+ ]}
688
+ createValue={(): MacroRow => emptyMacro}
689
+ initialValues={buildMacros(count)}
690
+ maxVisibleRows={maxVisibleRows}
691
+ onChangeSpy={onChangeSpy}
692
+ renderRow={renderMacroRow}
693
+ />
694
+ </div>
695
+ </div>
696
+ );
697
+ };
698
+
699
+ interface InitializePanelProps {
700
+ count: number;
701
+ maxVisibleRows?: number;
702
+ }
703
+
704
+ // The viewport is 1280x1000 and the panel widths are explicit, so the 1400px
705
+ // panel overflows the page: the list lays out against the panel, not the
706
+ // viewport.
707
+ const initializePanel = ({
708
+ count,
709
+ maxVisibleRows
710
+ }: InitializePanelProps): { onChange: Spy } => {
711
+ const onChange = rs.fn();
712
+
713
+ render(
714
+ <ResizablePanel
715
+ count={count}
716
+ maxVisibleRows={maxVisibleRows}
717
+ onChangeSpy={onChange}
718
+ />
719
+ );
720
+
721
+ return { onChange };
722
+ };
723
+
724
+ const getScrollArea = (): Promise<HTMLElement> =>
725
+ screen.findByTestId('sortable-entries-scroll');
726
+
727
+ const getLastNames = (onChange: Spy): Array<string> =>
728
+ getLastValues<MacroRow>(onChange).map(({ name }) => name);
729
+
730
+ const getMaskImage = (element: HTMLElement): string =>
731
+ getComputedStyle(element).maskImage;
732
+
733
+ // The list is capped from a ResizeObserver callback. A check that it is not
734
+ // capped first waits for an observer of its own, delivered after the list's,
735
+ // then a frame for the page to apply the cap.
736
+ const waitForResizeObservers = (element: HTMLElement): Promise<void> =>
737
+ new Promise((resolve) => {
738
+ const observer = new ResizeObserver(() => {
739
+ observer.disconnect();
740
+ requestAnimationFrame(() => setTimeout(resolve, 0));
741
+ });
742
+
743
+ observer.observe(element);
744
+ });
745
+
746
+ describe('SortableEntriesList - capped list', () => {
747
+ beforeEach(() => {
748
+ expect.hasAssertions();
749
+ });
750
+
751
+ it('shows maxVisibleRows rows plus half of the next one, and keeps the add button outside the scroll area', async () => {
752
+ initializePanel({ count: 15, maxVisibleRows: 10 });
753
+
754
+ const scrollArea = await getScrollArea();
755
+
756
+ await waitFor(() => {
757
+ const rows = scrollArea.querySelectorAll('li');
758
+ const visibleBottom = getRect(scrollArea).bottom;
759
+
760
+ expect(scrollArea.scrollHeight).toBeGreaterThan(scrollArea.clientHeight);
761
+ expect(getRect(rows[9]).bottom).toBeLessThanOrEqual(visibleBottom);
762
+ expect(
763
+ Math.abs(
764
+ visibleBottom - getRect(rows[10]).top - getRect(rows[10]).height / 2
765
+ )
766
+ ).toBeLessThanOrEqual(1);
767
+ });
768
+
769
+ const addButton = screen.getByTestId('Add');
770
+
771
+ expect(addButton).toBeVisible();
772
+ expect(
773
+ addButton.closest('[data-testid="sortable-entries-scroll"]')
774
+ ).not.toBeInTheDocument();
775
+ });
776
+
777
+ it('fades the cut-off row only while rows are hidden below', async () => {
778
+ initializePanel({ count: 15, maxVisibleRows: 10 });
779
+
780
+ const scrollArea = await getScrollArea();
781
+
782
+ await waitFor(() => {
783
+ expect(getMaskImage(scrollArea)).not.toBe('none');
784
+ });
785
+ scrollArea.scrollTo({ top: scrollArea.scrollHeight });
786
+ await waitFor(() => {
787
+ expect(getMaskImage(scrollArea)).toBe('none');
788
+ });
789
+ });
790
+
791
+ it('does not cap a list shorter than maxVisibleRows', async () => {
792
+ initializePanel({ count: 5, maxVisibleRows: 10 });
793
+
794
+ const scrollArea = await getScrollArea();
795
+
796
+ await waitForResizeObservers(scrollArea);
797
+
798
+ expect(getComputedStyle(scrollArea).maxHeight).toBe('none');
799
+ expect(scrollArea.scrollHeight).toBe(scrollArea.clientHeight);
800
+ });
801
+
802
+ it('focuses and scrolls to a row added at the end of a capped list', async () => {
803
+ initializePanel({ count: 15, maxVisibleRows: 10 });
804
+
805
+ const scrollArea = await getScrollArea();
806
+
807
+ await waitFor(() => {
808
+ expect(scrollArea.scrollHeight).toBeGreaterThan(scrollArea.clientHeight);
809
+ });
810
+ await userEvent.click(screen.getByTestId('Add'));
811
+
812
+ await waitFor(() => {
813
+ expect(getRows()).toHaveLength(16);
814
+ });
815
+ await waitFor(() => {
816
+ const input = getFocused();
817
+
818
+ expect(input).toHaveAttribute('data-testid', 'macro-name');
819
+
820
+ const scrollAreaRect = getRect(
821
+ input.closest('[data-testid="sortable-entries-scroll"]') as Element
822
+ );
823
+ const inputRect = getRect(input);
824
+
825
+ expect(inputRect.top).toBeGreaterThanOrEqual(scrollAreaRect.top);
826
+ expect(inputRect.bottom).toBeLessThanOrEqual(scrollAreaRect.bottom);
827
+ });
828
+ });
829
+
830
+ it('scrolls to the focused new row when adding it makes the list reach the cap', async () => {
831
+ initializePanel({ count: 10, maxVisibleRows: 10 });
832
+
833
+ const scrollArea = await getScrollArea();
834
+
835
+ await waitForResizeObservers(scrollArea);
836
+ expect(getComputedStyle(scrollArea).maxHeight).toBe('none');
837
+ await userEvent.click(screen.getByTestId('Add'));
838
+
839
+ await waitFor(() => {
840
+ expect(getRows()).toHaveLength(11);
841
+ });
842
+ await waitFor(() => {
843
+ expect(getComputedStyle(scrollArea).maxHeight).not.toBe('none');
844
+ });
845
+ await waitFor(() => {
846
+ expect(getFocused()).toHaveAttribute('data-testid', 'macro-name');
847
+
848
+ const row = getFocused().closest('li') as HTMLElement;
849
+ const scrollAreaRect = getRect(
850
+ row.closest('[data-testid="sortable-entries-scroll"]') as Element
851
+ );
852
+
853
+ // 1px tolerance for sub-pixel row heights.
854
+ expect(getRect(row).bottom).toBeLessThanOrEqual(
855
+ scrollAreaRect.bottom + 1
856
+ );
857
+ });
858
+ await waitFor(() => {
859
+ expect(getMaskImage(scrollArea)).toBe('none');
860
+ });
861
+ });
862
+
863
+ it('moves a row past the visible area with the keyboard, scrolling the list', async () => {
864
+ const { onChange } = initializePanel({ count: 15, maxVisibleRows: 10 });
865
+
866
+ await moveSortableElement({
867
+ direction: 'down',
868
+ handle: (await screen.findAllByTestId('drag-handle'))[0],
869
+ times: 12
870
+ });
871
+
872
+ await waitFor(() => {
873
+ expect(getLastNames(onChange).indexOf('MACRO_1')).toBe(12);
874
+ });
875
+ await waitFor(async () => {
876
+ expect((await getScrollArea()).scrollTop).toBeGreaterThan(0);
877
+ });
878
+ });
879
+
880
+ it('auto-scrolls while a row is dragged with the pointer near the bottom edge', async () => {
881
+ const { onChange } = initializePanel({ count: 15, maxVisibleRows: 10 });
882
+
883
+ const handle = (await screen.findAllByTestId('drag-handle'))[0];
884
+ const scrollArea = await getScrollArea();
885
+
886
+ // The list is capped once its rows are measured.
887
+ await waitFor(() => {
888
+ expect(scrollArea.scrollHeight).toBeGreaterThan(scrollArea.clientHeight);
889
+ });
890
+
891
+ const { left, top, width, height } = getRect(handle);
892
+ const pointer = {
893
+ button: 0,
894
+ clientX: left + width / 2,
895
+ isPrimary: true,
896
+ pointerId: 1
897
+ };
898
+
899
+ // A synthetic pointer drag: dnd-kit listens to the pointer events of the
900
+ // document once the drag started.
901
+ fireEvent.pointerDown(handle, { ...pointer, clientY: top + height / 2 });
902
+
903
+ const clientY = getRect(scrollArea).bottom - 5;
904
+
905
+ fireEvent.pointerMove(document, { ...pointer, clientY });
906
+ await waitFor(() => {
907
+ expect(scrollArea.scrollTop).toBeGreaterThan(100);
908
+ });
909
+ fireEvent.pointerMove(document, { ...pointer, clientY });
910
+ fireEvent.pointerUp(document, { ...pointer, clientY });
911
+
912
+ await waitFor(() => {
913
+ expect(getLastNames(onChange).indexOf('MACRO_1')).toBeGreaterThan(10);
914
+ });
915
+ });
916
+ });
917
+
918
+ describe('SortableEntriesList - container responsive rows', () => {
919
+ const getFieldRects = (): Array<DOMRect> =>
920
+ Array.from(getRows()[0].querySelectorAll('.MuiFormControl-root')).map(
921
+ getRect
922
+ );
923
+
924
+ beforeEach(() => {
925
+ expect.hasAssertions();
926
+ });
927
+
928
+ it('keeps the fields and the four buttons on one line at 1400, 900 and 720px, resized at runtime', async () => {
929
+ initializePanel({ count: 3 });
930
+
931
+ for (const width of [1400, 900, 720, 1400]) {
932
+ await userEvent.click(await screen.findByTestId(`width-${width}`));
933
+ await waitFor(() => {
934
+ expect(getComputedStyle(screen.getByTestId('panel')).width).toBe(
935
+ `${width}px`
936
+ );
937
+ });
938
+
939
+ await waitFor(() => {
940
+ const [name, value] = getFieldRects();
941
+
942
+ expect(value.top).toBe(name.top);
943
+ expect(name.width).toBeGreaterThanOrEqual(275);
944
+ expect(value.width).toBeGreaterThanOrEqual(275);
945
+ });
946
+
947
+ await waitFor(() => {
948
+ const buttons = getRowButtons(0);
949
+
950
+ expect(buttons).toHaveLength(4);
951
+
952
+ const rects = buttons.map(getRect);
953
+ const panelRight = getRect(screen.getByTestId('panel')).right;
954
+
955
+ rects.slice(1).forEach((rect, index) => {
956
+ expect(rect.top).toBe(rects[index].top);
957
+ expect(rect.left - rects[index].left).toBe(32);
958
+ });
959
+ expect(rects[3].right).toBeLessThanOrEqual(panelRight);
960
+ });
961
+ }
962
+ });
963
+
964
+ it('stacks the fields when the list gets narrower than 600px, whatever the viewport', async () => {
965
+ initializePanel({ count: 3 });
966
+
967
+ await userEvent.click(await screen.findByTestId('width-560'));
968
+ await waitFor(() => {
969
+ const [name, value] = getFieldRects();
970
+
971
+ expect(value.top).toBeGreaterThan(name.bottom);
972
+ });
973
+
974
+ await userEvent.click(screen.getByTestId('width-900'));
975
+ await waitFor(() => {
976
+ const [name, value] = getFieldRects();
977
+
978
+ expect(value.top).toBe(name.top);
979
+ });
980
+ });
981
+ });