@centreon/ui 26.10.3 → 26.10.5

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.
@@ -0,0 +1,506 @@
1
+ import EditIcon from '@mui/icons-material/Edit';
2
+ import VisibilityIcon from '@mui/icons-material/Visibility';
3
+
4
+ import { useState } from 'react';
5
+
6
+ import { TestQueryProvider } from '../..';
7
+ import buildListingEndpoint from '../../api/buildListingEndpoint';
8
+ import type { SelectEntry } from '../../InputField/Select';
9
+ import SingleConnectedAutocompleteField from '../../InputField/Select/Autocomplete/Connected/Single';
10
+ import TextField from '../../InputField/Text';
11
+ import type { RenderRowParams, RowParams, SortableRowAction } from './models';
12
+ import { type Props, SortableEntriesList } from './SortableEntriesList';
13
+
14
+ type Stub = ReturnType<typeof cy.stub>;
15
+
16
+ const ControlledList = <T,>({
17
+ initialValues,
18
+ onChangeSpy,
19
+ ...props
20
+ }: Omit<Props<T>, 'values' | 'onChange'> & {
21
+ initialValues: Array<T>;
22
+ onChangeSpy: Stub;
23
+ }): JSX.Element => {
24
+ const [values, setValues] = useState(initialValues);
25
+
26
+ return (
27
+ <SortableEntriesList<T>
28
+ {...props}
29
+ onChange={(nextValues): void => {
30
+ onChangeSpy(nextValues);
31
+ setValues(nextValues);
32
+ }}
33
+ values={values}
34
+ />
35
+ );
36
+ };
37
+
38
+ const getLastValues = <T,>(onChange: Stub): Array<T> =>
39
+ onChange.lastCall.args[0];
40
+
41
+ const getRows = (): Cypress.Chainable => cy.findAllByRole('listitem');
42
+
43
+ const endpoint = '/configuration/hosts/templates';
44
+
45
+ const options: Array<SelectEntry> = [
46
+ { id: 1, name: 'generic-active-host' },
47
+ { id: 2, name: 'generic-passive-host' },
48
+ { id: 3, name: 'linux-server-standard' }
49
+ ];
50
+
51
+ type TemplateRow = SelectEntry | null;
52
+
53
+ const renderTemplateRow = ({
54
+ setValue,
55
+ value,
56
+ values
57
+ }: RenderRowParams<TemplateRow>): JSX.Element => {
58
+ const selectedElsewhere = values
59
+ .map((template) => template?.id)
60
+ .filter((id) => id !== value?.id);
61
+
62
+ return (
63
+ <SingleConnectedAutocompleteField
64
+ baseEndpoint=""
65
+ field="name"
66
+ fullWidth
67
+ getEndpoint={(parameters): string =>
68
+ buildListingEndpoint({ baseEndpoint: endpoint, parameters })
69
+ }
70
+ getOptionDisabled={(option: SelectEntry): boolean =>
71
+ selectedElsewhere.includes(option.id)
72
+ }
73
+ label="Template"
74
+ onChange={(_event, template): void => setValue(template as TemplateRow)}
75
+ value={value}
76
+ />
77
+ );
78
+ };
79
+
80
+ const initializeTemplates = ({
81
+ draggable = true,
82
+ templates = [options[0], options[1]]
83
+ }: {
84
+ draggable?: boolean;
85
+ templates?: Array<TemplateRow>;
86
+ } = {}): { onChange: Stub; onEdit: Stub } => {
87
+ const onChange = cy.stub();
88
+ const onEdit = cy.stub();
89
+
90
+ cy.intercept('GET', `**${endpoint}**`, {
91
+ body: {
92
+ meta: { limit: 10, page: 1, total: options.length },
93
+ result: options
94
+ },
95
+ statusCode: 200
96
+ }).as('getTemplates');
97
+
98
+ cy.mount({
99
+ Component: (
100
+ <TestQueryProvider>
101
+ <div className="p-4">
102
+ <ControlledList<TemplateRow>
103
+ actions={({
104
+ index,
105
+ value
106
+ }: RowParams<TemplateRow>): Array<SortableRowAction> => [
107
+ {
108
+ icon: <EditIcon />,
109
+ id: 'edit-template',
110
+ label: 'Edit template',
111
+ onClick: () => onEdit(value, index)
112
+ }
113
+ ]}
114
+ createValue={(): TemplateRow => null}
115
+ draggable={draggable}
116
+ initialValues={templates}
117
+ label="Templates"
118
+ onChangeSpy={onChange}
119
+ renderRow={renderTemplateRow}
120
+ />
121
+ </div>
122
+ </TestQueryProvider>
123
+ )
124
+ });
125
+
126
+ return { onChange, onEdit };
127
+ };
128
+
129
+ describe('SortableEntriesList - single input rows', () => {
130
+ it('displays one row per value with its value, its actions, a delete button and a trailing drag handle', () => {
131
+ initializeTemplates();
132
+
133
+ cy.findByRole('list', { name: 'Templates' }).should('exist');
134
+ getRows().should('have.length', 2);
135
+ cy.get('[data-testid="Template"]')
136
+ .eq(0)
137
+ .should('have.value', options[0].name);
138
+ cy.get('[data-testid="Template"]')
139
+ .eq(1)
140
+ .should('have.value', options[1].name);
141
+ cy.findAllByTestId('edit-template').should('have.length', 2);
142
+ cy.findAllByTestId('delete-row').should('have.length', 2);
143
+
144
+ getRows()
145
+ .eq(0)
146
+ .find('button')
147
+ .last()
148
+ .should('have.attr', 'data-testid', 'drag-handle');
149
+
150
+ cy.makeSnapshot();
151
+ });
152
+
153
+ it('reports the plain values, without any internal id, when an option is selected', () => {
154
+ const { onChange } = initializeTemplates();
155
+
156
+ cy.get('[data-testid="Template"]').eq(1).click();
157
+ cy.wait('@getTemplates');
158
+ cy.findByRole('option', { name: options[2].name }).click();
159
+
160
+ cy.get('[data-testid="Template"]')
161
+ .eq(1)
162
+ .should('have.value', options[2].name);
163
+ cy.wrap(onChange).should(() => {
164
+ expect(getLastValues(onChange)).to.deep.equal([options[0], options[2]]);
165
+ });
166
+ });
167
+
168
+ it('lets a row disable the options already selected in sibling rows', () => {
169
+ initializeTemplates();
170
+
171
+ cy.get('[data-testid="Template"]').eq(1).click();
172
+ cy.wait('@getTemplates');
173
+
174
+ cy.findByRole('option', { name: options[0].name }).should(
175
+ 'have.attr',
176
+ 'aria-disabled',
177
+ 'true'
178
+ );
179
+ cy.findByRole('option', { name: options[2].name }).should(
180
+ 'not.have.attr',
181
+ 'aria-disabled',
182
+ 'true'
183
+ );
184
+ });
185
+
186
+ it('appends a row built with createValue and focuses its first input', () => {
187
+ const { onChange } = initializeTemplates();
188
+
189
+ cy.findByTestId('Add').click();
190
+
191
+ getRows().should('have.length', 3);
192
+ cy.get('[data-testid="Template"]').eq(2).should('have.value', '');
193
+ cy.focused().should('have.attr', 'data-testid', 'Template');
194
+ cy.focused()
195
+ .closest('li')
196
+ .should(($row) => {
197
+ expect($row.index()).to.equal(2);
198
+ });
199
+ cy.wrap(onChange).should(() => {
200
+ expect(getLastValues(onChange)).to.deep.equal([
201
+ options[0],
202
+ options[1],
203
+ null
204
+ ]);
205
+ });
206
+ });
207
+
208
+ it('removes a row and moves the focus to the delete button of the row taking its place', () => {
209
+ const { onChange } = initializeTemplates({
210
+ templates: [options[0], options[1], options[2]]
211
+ });
212
+
213
+ cy.findAllByTestId('delete-row').eq(0).click();
214
+
215
+ getRows().should('have.length', 2);
216
+ cy.wrap(onChange).should(() => {
217
+ expect(getLastValues(onChange)).to.deep.equal([options[1], options[2]]);
218
+ });
219
+ cy.focused()
220
+ .should('have.attr', 'data-testid', 'delete-row')
221
+ .closest('li')
222
+ .find('[data-testid="Template"]')
223
+ .should('have.value', options[1].name);
224
+ });
225
+
226
+ it('moves the focus to the previous row when the last row is removed, then to the add button when the list becomes empty', () => {
227
+ initializeTemplates();
228
+
229
+ cy.findAllByTestId('delete-row').eq(1).click();
230
+ cy.focused()
231
+ .closest('li')
232
+ .find('[data-testid="Template"]')
233
+ .should('have.value', options[0].name);
234
+
235
+ cy.findAllByTestId('delete-row').eq(0).click();
236
+ getRows().should('not.exist');
237
+ cy.focused().should('have.attr', 'data-testid', 'Add');
238
+ });
239
+
240
+ it('reorders the rows with the keyboard through the drag handle', () => {
241
+ const { onChange } = initializeTemplates();
242
+
243
+ cy.moveSortableElement({
244
+ direction: 'down',
245
+ element: cy.findAllByTestId('drag-handle').eq(0)
246
+ });
247
+
248
+ cy.get('[data-testid="Template"]')
249
+ .eq(0)
250
+ .should('have.value', options[1].name);
251
+ cy.get('[data-testid="Template"]')
252
+ .eq(1)
253
+ .should('have.value', options[0].name);
254
+ cy.wrap(onChange).should(() => {
255
+ expect(getLastValues(onChange)).to.deep.equal([options[1], options[0]]);
256
+ });
257
+ });
258
+
259
+ it('passes the up-to-date row value and index to the actions after a reorder', () => {
260
+ const { onEdit } = initializeTemplates();
261
+
262
+ cy.moveSortableElement({
263
+ direction: 'down',
264
+ element: cy.findAllByTestId('drag-handle').eq(0)
265
+ });
266
+ cy.get('[data-testid="Template"]')
267
+ .eq(0)
268
+ .should('have.value', options[1].name);
269
+
270
+ cy.findAllByTestId('edit-template').eq(1).click();
271
+ cy.wrap(onEdit).should('have.been.calledWith', options[0], 1);
272
+ });
273
+
274
+ it('hides the drag handle when the list is not draggable', () => {
275
+ initializeTemplates({ draggable: false });
276
+
277
+ cy.findAllByTestId('delete-row').should('have.length', 2);
278
+ cy.findByTestId('drag-handle').should('not.exist');
279
+ });
280
+
281
+ it('lets the inputs take the remaining width and keeps the row buttons at a 32px pitch', () => {
282
+ initializeTemplates();
283
+
284
+ cy.get('[data-row-fields]')
285
+ .eq(0)
286
+ .next()
287
+ .find('button')
288
+ .should('have.length', 3)
289
+ .then(($buttons) => {
290
+ const lefts = $buttons
291
+ .toArray()
292
+ .map((button) => button.getBoundingClientRect().left);
293
+
294
+ lefts.slice(1).forEach((left, index) => {
295
+ expect(left - lefts[index]).to.equal(32);
296
+ });
297
+ });
298
+
299
+ cy.get('[data-row-fields]')
300
+ .eq(0)
301
+ .invoke('outerWidth')
302
+ .should('be.greaterThan', 500);
303
+ });
304
+ });
305
+
306
+ interface MacroRow {
307
+ isPassword: boolean;
308
+ name: string;
309
+ origin: 'direct' | 'fromTpl';
310
+ value: string;
311
+ }
312
+
313
+ const macros: Array<MacroRow> = [
314
+ { isPassword: false, name: 'MACRO_A', origin: 'direct', value: 'a' },
315
+ { isPassword: true, name: 'MACRO_B', origin: 'fromTpl', value: 'b' }
316
+ ];
317
+
318
+ const emptyMacro: MacroRow = {
319
+ isPassword: false,
320
+ name: '',
321
+ origin: 'direct',
322
+ value: ''
323
+ };
324
+
325
+ const renderMacroRow = ({
326
+ setField,
327
+ value: macro
328
+ }: RenderRowParams<MacroRow>): JSX.Element => (
329
+ <div className="flex gap-2">
330
+ <TextField
331
+ dataTestId="macro-name"
332
+ label="Name"
333
+ onChange={(event): void => setField('name', event.target.value)}
334
+ value={macro.name}
335
+ />
336
+ <TextField
337
+ dataTestId="macro-value"
338
+ label="Value"
339
+ onChange={(event): void => setField('value', event.target.value)}
340
+ type={macro.isPassword ? 'password' : 'text'}
341
+ value={macro.value}
342
+ />
343
+ </div>
344
+ );
345
+
346
+ const MacrosList = ({
347
+ copyOnChange,
348
+ onChangeSpy
349
+ }: {
350
+ copyOnChange: boolean;
351
+ onChangeSpy: Stub;
352
+ }): JSX.Element => {
353
+ const [values, setValues] = useState(macros);
354
+
355
+ const change = (nextValues: Array<MacroRow>): void => {
356
+ onChangeSpy(nextValues);
357
+ // Simulates a parent that stores a copy (new array and objects), like a
358
+ // form library that clones its state.
359
+ setValues(
360
+ copyOnChange ? nextValues.map((macro) => ({ ...macro })) : nextValues
361
+ );
362
+ };
363
+
364
+ return (
365
+ <>
366
+ <button
367
+ data-testid="reset"
368
+ onClick={(): void => setValues([emptyMacro])}
369
+ type="button"
370
+ >
371
+ Reset
372
+ </button>
373
+ <SortableEntriesList<MacroRow>
374
+ actions={({ index, value }) => [
375
+ {
376
+ icon: <VisibilityIcon />,
377
+ id: 'toggle-password',
378
+ label: 'Toggle password',
379
+ onClick: () =>
380
+ change(
381
+ values.map((macro, macroIndex) =>
382
+ macroIndex === index
383
+ ? { ...macro, isPassword: !value.isPassword }
384
+ : macro
385
+ )
386
+ )
387
+ }
388
+ ]}
389
+ createValue={(): MacroRow => emptyMacro}
390
+ getRowClassName={({ value }) => `origin-${value.origin}`}
391
+ onChange={change}
392
+ renderRow={renderMacroRow}
393
+ values={values}
394
+ />
395
+ </>
396
+ );
397
+ };
398
+
399
+ const initializeMacros = ({
400
+ copyOnChange = false
401
+ }: {
402
+ copyOnChange?: boolean;
403
+ } = {}): { onChange: Stub } => {
404
+ const onChange = cy.stub();
405
+
406
+ cy.mount({
407
+ Component: <MacrosList copyOnChange={copyOnChange} onChangeSpy={onChange} />
408
+ });
409
+
410
+ return { onChange };
411
+ };
412
+
413
+ describe('SortableEntriesList - multiple input rows', () => {
414
+ it('renders every input of every row', () => {
415
+ initializeMacros();
416
+
417
+ cy.get('input[data-testid="macro-name"]').should('have.length', 2);
418
+ cy.get('input[data-testid="macro-value"]').should('have.length', 2);
419
+ cy.get('input[data-testid="macro-value"]')
420
+ .eq(1)
421
+ .should('have.attr', 'type', 'password');
422
+ });
423
+
424
+ it('keeps the focus in the edited input while typing and reports a structured value', () => {
425
+ const { onChange } = initializeMacros();
426
+
427
+ cy.get('input[data-testid="macro-name"]').eq(0).clear();
428
+ cy.get('input[data-testid="macro-name"]').eq(0).type('HOST_IP');
429
+ cy.focused().should('have.value', 'HOST_IP');
430
+
431
+ cy.get('input[data-testid="macro-value"]').eq(0).clear();
432
+ cy.get('input[data-testid="macro-value"]').eq(0).type('10.0.0.1');
433
+ cy.focused().should('have.value', '10.0.0.1');
434
+
435
+ cy.wrap(onChange).should(() => {
436
+ expect(getLastValues(onChange)).to.deep.equal([
437
+ {
438
+ isPassword: false,
439
+ name: 'HOST_IP',
440
+ origin: 'direct',
441
+ value: '10.0.0.1'
442
+ },
443
+ macros[1]
444
+ ]);
445
+ });
446
+ });
447
+
448
+ it('keeps the focus when a new row is typed into right after being added', () => {
449
+ const { onChange } = initializeMacros();
450
+
451
+ cy.findByTestId('Add').click();
452
+ cy.focused().should('have.attr', 'data-testid', 'macro-name').type('NEW');
453
+ cy.get('input[data-testid="macro-value"]').eq(2).type('value');
454
+
455
+ cy.wrap(onChange).should(() => {
456
+ expect(getLastValues<MacroRow>(onChange)[2]).to.deep.equal({
457
+ ...emptyMacro,
458
+ name: 'NEW',
459
+ value: 'value'
460
+ });
461
+ });
462
+ });
463
+
464
+ it('keeps the rows and the focus stable when the parent stores a copy of the values', () => {
465
+ const { onChange } = initializeMacros({ copyOnChange: true });
466
+
467
+ cy.get('input[data-testid="macro-name"]').eq(1).clear();
468
+ cy.get('input[data-testid="macro-name"]').eq(1).type('RENAMED');
469
+ cy.focused().should('have.value', 'RENAMED');
470
+
471
+ cy.findByTestId('Add').click();
472
+ cy.focused().should('have.attr', 'data-testid', 'macro-name').type('NEW');
473
+
474
+ cy.wrap(onChange).should(() => {
475
+ expect(
476
+ getLastValues<MacroRow>(onChange).map(({ name }) => name)
477
+ ).to.deep.equal(['MACRO_A', 'RENAMED', 'NEW']);
478
+ });
479
+ });
480
+
481
+ it('renders the new values when the parent replaces them', () => {
482
+ initializeMacros();
483
+
484
+ cy.findByTestId('reset').click();
485
+
486
+ getRows().should('have.length', 1);
487
+ cy.get('input[data-testid="macro-name"]').should('have.value', '');
488
+ });
489
+
490
+ it('lets a row action change its own row', () => {
491
+ initializeMacros();
492
+
493
+ cy.findAllByTestId('toggle-password').eq(0).click();
494
+
495
+ cy.get('input[data-testid="macro-value"]')
496
+ .eq(0)
497
+ .should('have.attr', 'type', 'password');
498
+ });
499
+
500
+ it('applies the class returned by getRowClassName to each row', () => {
501
+ initializeMacros();
502
+
503
+ getRows().eq(0).should('have.class', 'origin-direct');
504
+ getRows().eq(1).should('have.class', 'origin-fromTpl');
505
+ });
506
+ });