@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,527 @@
1
+ import { rs } from '@rstest/core';
2
+ import { interceptAPIRequest, Method, waitForRequest } from '@test-setup/api';
3
+ import { render } from '@test-setup/render';
4
+ import { screen, waitFor, within } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import { createStore, Provider } from 'jotai';
7
+
8
+ import { SnackbarProvider, TestQueryProvider } from '../../..';
9
+ import { AccessRights } from './AccessRights';
10
+ import type { AccessRightInitialValues } from './models';
11
+ import {
12
+ buildResult,
13
+ emptyAccessRights,
14
+ labels,
15
+ roles,
16
+ simpleAccessRights
17
+ } from './storiesData';
18
+
19
+ // The access rights live in a module-level Jotai store (`./Provider`), so
20
+ // they carry over from one test to the next: the tests below rely on that
21
+ // order.
22
+
23
+ interface InitializeProps {
24
+ initialValues?: Array<AccessRightInitialValues>;
25
+ loading?: boolean;
26
+ }
27
+
28
+ interface InitializeState {
29
+ cancel: ReturnType<typeof rs.fn>;
30
+ change: ReturnType<typeof rs.fn>;
31
+ save: ReturnType<typeof rs.fn>;
32
+ }
33
+
34
+ const initialize = ({
35
+ initialValues = simpleAccessRights,
36
+ loading = false
37
+ }: InitializeProps): InitializeState => {
38
+ const cancel = rs.fn();
39
+ const save = rs.fn();
40
+ const change = rs.fn();
41
+
42
+ interceptAPIRequest({
43
+ alias: 'getContacts',
44
+ method: Method.GET,
45
+ path: '**/contacts?**',
46
+ response: buildResult(false)
47
+ });
48
+ interceptAPIRequest({
49
+ alias: 'getContactGroups',
50
+ method: Method.GET,
51
+ path: '**/contact-groups?**',
52
+ response: buildResult(true)
53
+ });
54
+
55
+ render(
56
+ <SnackbarProvider>
57
+ <TestQueryProvider>
58
+ <Provider store={createStore()}>
59
+ <AccessRights
60
+ cancel={cancel}
61
+ endpoints={{
62
+ contact: '/contacts',
63
+ contactGroup: '/contact-groups'
64
+ }}
65
+ initialValues={initialValues}
66
+ labels={labels}
67
+ loading={loading}
68
+ onChange={change}
69
+ roles={roles}
70
+ submit={save}
71
+ />
72
+ </Provider>
73
+ </TestQueryProvider>
74
+ </SnackbarProvider>
75
+ );
76
+
77
+ return {
78
+ cancel,
79
+ change,
80
+ save
81
+ };
82
+ };
83
+
84
+ // The first element whose own text contains the given one.
85
+ const findFirstByText = async (text: string): Promise<HTMLElement> =>
86
+ (
87
+ await screen.findAllByText((_, element) =>
88
+ Array.from(element?.childNodes ?? []).some(
89
+ (node) =>
90
+ node.nodeType === Node.TEXT_NODE &&
91
+ (node.textContent ?? '').replace(/\s+/g, ' ').includes(text)
92
+ )
93
+ )
94
+ )[0];
95
+
96
+ const queryFirstByText = (text: string): HTMLElement | undefined =>
97
+ screen.queryAllByText((_, element) =>
98
+ Array.from(element?.childNodes ?? []).some(
99
+ (node) =>
100
+ node.nodeType === Node.TEXT_NODE &&
101
+ (node.textContent ?? '').replace(/\s+/g, ' ').includes(text)
102
+ )
103
+ )[0];
104
+
105
+ const getInput = (testId: string): HTMLInputElement =>
106
+ screen
107
+ .getAllByTestId(testId)
108
+ .find((element) => element.tagName === 'INPUT') as HTMLInputElement;
109
+
110
+ // Clicks the select's combobox: MUI opens the select on its mouse down.
111
+ const openSelect = async (testId: string): Promise<void> => {
112
+ const select = getInput(testId).parentElement as HTMLElement;
113
+
114
+ await userEvent.click(within(select).getByRole('combobox'));
115
+ };
116
+
117
+ const selectRole = async (testId: string, role: string): Promise<void> => {
118
+ await openSelect(testId);
119
+
120
+ const listbox = (await screen.findAllByRole('listbox')).at(-1) as HTMLElement;
121
+
122
+ await userEvent.click(
123
+ listbox.querySelector(`li[data-value="${role}"]`) as HTMLElement
124
+ );
125
+
126
+ await waitFor(() => {
127
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
128
+ });
129
+ };
130
+
131
+ const openAutocomplete = async (label: string): Promise<void> => {
132
+ await userEvent.click(screen.getByLabelText(label));
133
+ };
134
+
135
+ const selectOption = async (name: string): Promise<void> => {
136
+ const listbox = await screen.findByRole('listbox');
137
+
138
+ await userEvent.click(await within(listbox).findByText(name));
139
+ };
140
+
141
+ describe('Access rights', () => {
142
+ beforeEach(() => {
143
+ expect.hasAssertions();
144
+ });
145
+
146
+ it('displays the access rights in loading state', async () => {
147
+ initialize({ loading: true });
148
+
149
+ expect(await findFirstByText('Share dashboard with')).toBeVisible();
150
+ expect(await findFirstByText('Contact')).toBeVisible();
151
+ expect(await findFirstByText('Contact group')).toBeVisible();
152
+ expect(screen.getByLabelText('Add a contact')).toBeVisible();
153
+ expect(getInput('add_role')).toBeDisabled();
154
+ expect(screen.getByTestId('add')).toBeDisabled();
155
+ expect(screen.getByLabelText('Cancel')).toBeVisible();
156
+ expect(screen.getByLabelText('Save')).toBeVisible();
157
+
158
+ // The contact type is selected, the role defaults to viewer, the list is
159
+ // replaced by its skeleton and nothing can be saved.
160
+ expect(screen.getByRole('radio', { name: 'Contact' })).toBeChecked();
161
+ expect(getInput('add_role')).toHaveValue('viewer');
162
+ expect(
163
+ screen.queryByText(simpleAccessRights[0].name)
164
+ ).not.toBeInTheDocument();
165
+ expect(screen.getByLabelText('Save')).toBeDisabled();
166
+ });
167
+
168
+ it('displays the access rights with an empty list', async () => {
169
+ initialize({ initialValues: emptyAccessRights });
170
+
171
+ expect(await findFirstByText('The contact list is empty')).toBeVisible();
172
+
173
+ // The list title, no stats and nothing to save.
174
+ expect(screen.getByText('User rights')).toBeVisible();
175
+ expect(screen.queryByText(/added|updated|removed/)).not.toBeInTheDocument();
176
+ expect(screen.getByLabelText('Save')).toBeDisabled();
177
+ });
178
+
179
+ it('displays the access rights list', async () => {
180
+ initialize({});
181
+
182
+ for (const { name, email, isContactGroup, role } of simpleAccessRights) {
183
+ expect(await findFirstByText(name)).toBeVisible();
184
+ expect(await findFirstByText(email as string)).toBeVisible();
185
+
186
+ const groupChip = document.querySelector(`[data-groupChip="${name}"]`);
187
+
188
+ if (isContactGroup) {
189
+ expect(groupChip).toBeVisible();
190
+ } else {
191
+ expect(groupChip).not.toBeInTheDocument();
192
+ }
193
+
194
+ expect(getInput(`role-${name}`)).toHaveValue(role);
195
+ expect(screen.getByTestId(`remove-${name}`)).toBeVisible();
196
+ }
197
+
198
+ // No state chip, no stats and nothing to save.
199
+ expect(document.querySelector('[data-state]')).not.toBeInTheDocument();
200
+ expect(screen.queryByText(/added|updated|removed/)).not.toBeInTheDocument();
201
+ expect(screen.getByLabelText('Save')).toBeDisabled();
202
+ });
203
+
204
+ it('displays a removed chip when the corresponding icon is clicked', async () => {
205
+ initialize({});
206
+
207
+ expect(await screen.findByTestId('remove-Erin Monahan')).toHaveAttribute(
208
+ 'data-removed',
209
+ 'false'
210
+ );
211
+
212
+ await userEvent.click(screen.getByTestId('remove-Erin Monahan'));
213
+
214
+ await waitFor(() => {
215
+ expect(screen.getByTestId('remove-Erin Monahan')).toHaveAttribute(
216
+ 'data-removed',
217
+ 'true'
218
+ );
219
+ });
220
+ expect(await findFirstByText(labels.list.removed)).toBeVisible();
221
+
222
+ // The removed contact's role is disabled, the stats count it and the
223
+ // changes can be saved.
224
+ expect(getInput('role-Erin Monahan')).toBeDisabled();
225
+ expect(await screen.findByText('1 removed')).toBeVisible();
226
+ expect(screen.getByLabelText('Save')).toBeEnabled();
227
+ });
228
+
229
+ it('restores the contact when the contact is removed and the corresponding icon is clicked', async () => {
230
+ initialize({});
231
+
232
+ await userEvent.click(await screen.findByTestId('remove-Erin Monahan'));
233
+
234
+ await waitFor(() => {
235
+ expect(screen.getByTestId('remove-Erin Monahan')).toHaveAttribute(
236
+ 'data-removed',
237
+ 'false'
238
+ );
239
+ });
240
+ expect(queryFirstByText(labels.list.removed)).toBeUndefined();
241
+
242
+ // The role is enabled again, no stats and nothing to save.
243
+ expect(getInput('role-Erin Monahan')).toBeEnabled();
244
+ expect(screen.queryByText(/removed/)).not.toBeInTheDocument();
245
+ expect(screen.getByLabelText('Save')).toBeDisabled();
246
+ });
247
+
248
+ it('submits the new acces rights list without the removed contact', async () => {
249
+ const { save } = initialize({});
250
+
251
+ await userEvent.click(await screen.findByTestId('remove-Erin Monahan'));
252
+
253
+ await waitFor(() => {
254
+ expect(screen.getByTestId('remove-Erin Monahan')).toHaveAttribute(
255
+ 'data-removed',
256
+ 'true'
257
+ );
258
+ });
259
+ expect(await findFirstByText(labels.list.removed)).toBeVisible();
260
+ expect(await findFirstByText('1 removed')).toBeVisible();
261
+
262
+ await userEvent.click(await findFirstByText(labels.actions.save));
263
+
264
+ await waitFor(() => {
265
+ expect(save).toHaveBeenCalled();
266
+ });
267
+
268
+ // The saved list is the initial one without the removed contact (id 1).
269
+ expect(save).toHaveBeenCalledWith(
270
+ expect.not.arrayContaining([
271
+ expect.objectContaining({ name: 'Erin Monahan' })
272
+ ])
273
+ );
274
+ expect(
275
+ (save.mock.calls[0][0] as Array<{ id: number }>).map(({ id }) => id)
276
+ ).toEqual([0, 2, 3, 4, 5]);
277
+ });
278
+
279
+ it('submits the new acces rights list with the updated contact', async () => {
280
+ const { save } = initialize({});
281
+
282
+ await userEvent.click(await screen.findByTestId('remove-Erin Monahan'));
283
+ await selectRole('role-Erin Monahan', 'viewer');
284
+
285
+ expect(await findFirstByText(labels.list.updated)).toBeVisible();
286
+ expect(await findFirstByText('1 updated')).toBeVisible();
287
+
288
+ await userEvent.click(await findFirstByText(labels.actions.save));
289
+
290
+ expect(save).toHaveBeenCalled();
291
+
292
+ // The contact is back, with its new role.
293
+ expect(getInput('role-Erin Monahan')).toHaveValue('viewer');
294
+ expect(screen.getByTestId('remove-Erin Monahan')).toHaveAttribute(
295
+ 'data-removed',
296
+ 'false'
297
+ );
298
+ });
299
+
300
+ it('removes the updated chip when the contact role is updated and its initial role is assigned back', async () => {
301
+ initialize({});
302
+
303
+ await selectRole('role-Erin Monahan', 'viewer');
304
+ expect(await findFirstByText(labels.list.updated)).toBeVisible();
305
+
306
+ await selectRole('role-Erin Monahan', 'editor');
307
+ await waitFor(() => {
308
+ expect(queryFirstByText(labels.list.updated)).toBeUndefined();
309
+ });
310
+
311
+ // The initial role is back and nothing to save.
312
+ expect(getInput('role-Erin Monahan')).toHaveValue('editor');
313
+ expect(screen.getByLabelText('Save')).toBeDisabled();
314
+ });
315
+
316
+ it('retrieves contacts when the contact radio is selected', async () => {
317
+ initialize({});
318
+
319
+ await userEvent.click(await findFirstByText(labels.add.contact));
320
+ await openAutocomplete(labels.add.autocompleteContact);
321
+
322
+ await waitForRequest('@getContacts');
323
+
324
+ // The contacts are listed in the autocomplete.
325
+ const listbox = await screen.findByRole('listbox');
326
+
327
+ expect(await within(listbox).findByText('Entity 10')).toBeVisible();
328
+ expect(within(listbox).getAllByRole('option')).toHaveLength(10);
329
+ });
330
+
331
+ it('retrieves contact groups when the contact group radio is selected', async () => {
332
+ initialize({});
333
+
334
+ await userEvent.click(await findFirstByText(labels.add.contactGroup));
335
+ await openAutocomplete(labels.add.autocompleteContactGroup);
336
+
337
+ await waitForRequest('@getContactGroups');
338
+
339
+ // The contact group type is selected and the groups are listed in the
340
+ // autocomplete.
341
+ expect(screen.getByRole('radio', { name: 'Contact group' })).toBeChecked();
342
+
343
+ const listbox = await screen.findByRole('listbox');
344
+
345
+ expect(await within(listbox).findByText('Entity Group 10')).toBeVisible();
346
+ expect(within(listbox).getAllByRole('option')).toHaveLength(10);
347
+ });
348
+
349
+ it('adds the contact as viewer when a contact is selected and the corresponding button is clicked', async () => {
350
+ initialize({});
351
+
352
+ await userEvent.click(await findFirstByText(labels.add.contact));
353
+ await openAutocomplete(labels.add.autocompleteContact);
354
+
355
+ await waitForRequest('@getContacts');
356
+
357
+ await selectOption('Entity 10');
358
+
359
+ await userEvent.click(screen.getByTestId('add'));
360
+
361
+ expect(await findFirstByText('Entity 10')).toBeVisible();
362
+
363
+ expect(getInput('role-Entity 10')).toHaveValue('viewer');
364
+ expect(await findFirstByText(labels.list.added)).toBeVisible();
365
+
366
+ // The autocomplete is cleared and the stats count the new contact.
367
+ expect(screen.getByLabelText(labels.add.autocompleteContact)).toHaveValue(
368
+ ''
369
+ );
370
+ expect(await screen.findByText('1 added')).toBeVisible();
371
+ });
372
+
373
+ it('adds the contact group as editor when a contact group is selected and the corresponding button is clicked', async () => {
374
+ initialize({});
375
+
376
+ await userEvent.click(await findFirstByText(labels.add.contactGroup));
377
+ await openAutocomplete(labels.add.autocompleteContactGroup);
378
+
379
+ await waitForRequest('@getContactGroups');
380
+
381
+ await selectOption('Entity Group 10');
382
+ await selectRole('add_role', 'editor');
383
+ await userEvent.click(screen.getByTestId('add'));
384
+
385
+ expect(await findFirstByText('Entity Group 10')).toBeVisible();
386
+
387
+ expect(getInput('role-Entity Group 10')).toHaveValue('editor');
388
+ expect(await findFirstByText(labels.list.added)).toBeVisible();
389
+ expect(await findFirstByText('2 added')).toBeVisible();
390
+
391
+ expect(
392
+ document.querySelector('[data-groupChip="Entity Group 10"]')
393
+ ).toBeVisible();
394
+ });
395
+
396
+ it('displays statistics when a contact is added, a contact is updated and a contact is removed', async () => {
397
+ initialize({});
398
+
399
+ await userEvent.click(await findFirstByText(labels.add.contactGroup));
400
+ await openAutocomplete(labels.add.autocompleteContactGroup);
401
+
402
+ await waitForRequest('@getContactGroups');
403
+
404
+ await selectOption('Entity Group 11');
405
+ await selectRole('add_role', 'editor');
406
+ await userEvent.click(screen.getByTestId('add'));
407
+
408
+ expect(await findFirstByText('Entity Group 11')).toBeVisible();
409
+
410
+ await selectRole('role-Erin Monahan', 'viewer');
411
+
412
+ await userEvent.click(screen.getByTestId('remove-Maryann Ferry'));
413
+
414
+ expect(
415
+ await findFirstByText('3 added | 1 updated | 1 removed')
416
+ ).toBeVisible();
417
+
418
+ // The state chips of the changed contacts.
419
+ expect(document.querySelectorAll('[data-state="added"]')).toHaveLength(3);
420
+ expect(document.querySelectorAll('[data-state="updated"]')).toHaveLength(1);
421
+ expect(document.querySelectorAll('[data-state="removed"]')).toHaveLength(1);
422
+ expect(getInput('role-Entity Group 11')).toHaveValue('editor');
423
+ });
424
+
425
+ it('clears the autocomplete when a contact is selected and the contact type is changed', async () => {
426
+ initialize({});
427
+
428
+ await userEvent.click(await findFirstByText(labels.add.contact));
429
+ await openAutocomplete(labels.add.autocompleteContact);
430
+
431
+ await waitForRequest('@getContacts');
432
+
433
+ await selectOption('Entity 11');
434
+ await waitFor(() => {
435
+ expect(screen.getByLabelText(labels.add.autocompleteContact)).toHaveValue(
436
+ 'Entity 11'
437
+ );
438
+ });
439
+
440
+ await userEvent.click(await findFirstByText(labels.add.contactGroup));
441
+ await waitFor(() => {
442
+ expect(
443
+ screen.getByLabelText(labels.add.autocompleteContactGroup)
444
+ ).toHaveValue('');
445
+ });
446
+
447
+ // Nothing selected, so nothing can be added.
448
+ expect(screen.getByTestId('add')).toBeDisabled();
449
+ expect(getInput('add_role')).toBeDisabled();
450
+ });
451
+
452
+ it('removes the contact from the list when the contact has been to the list and the corresponding button is clicked', async () => {
453
+ initialize({});
454
+
455
+ await userEvent.click(await findFirstByText(labels.add.contact));
456
+ await openAutocomplete(labels.add.autocompleteContact);
457
+
458
+ await waitForRequest('@getContacts');
459
+
460
+ await selectOption('Entity 11');
461
+
462
+ await userEvent.click(screen.getByTestId('add'));
463
+
464
+ expect(await findFirstByText('Entity 11')).toBeVisible();
465
+
466
+ expect(await findFirstByText(labels.list.added)).toBeVisible();
467
+ expect(await findFirstByText('4 added')).toBeVisible();
468
+
469
+ await userEvent.click(screen.getByTestId('remove-Entity 11'));
470
+
471
+ await waitFor(() => {
472
+ expect(queryFirstByText('Entity 11')).toBeUndefined();
473
+ });
474
+ expect(queryFirstByText('4 added')).toBeUndefined();
475
+
476
+ // The stats are back to the previous changes.
477
+ expect(
478
+ await findFirstByText('3 added | 1 updated | 1 removed')
479
+ ).toBeVisible();
480
+ });
481
+
482
+ it('resets the role to "viewer" when the "editor" role is selected and a contact with "viewer" right is selected', async () => {
483
+ initialize({});
484
+
485
+ await openAutocomplete(labels.add.autocompleteContact);
486
+
487
+ await waitForRequest('@getContacts');
488
+
489
+ await selectOption('Entity 11');
490
+
491
+ await selectRole('add_role', 'editor');
492
+
493
+ await openAutocomplete(labels.add.autocompleteContact);
494
+
495
+ await waitForRequest('@getContacts');
496
+
497
+ await selectOption('Entity 12');
498
+
499
+ await waitFor(() => {
500
+ expect(getInput('add_role')).toHaveValue('viewer');
501
+ });
502
+
503
+ // The contact can be added.
504
+ expect(screen.getByTestId('add')).toBeEnabled();
505
+ });
506
+
507
+ it('calls the change function when the corresponding prop is set and the form is updated', async () => {
508
+ const { change } = initialize({});
509
+
510
+ await userEvent.click(await findFirstByText(labels.add.contact));
511
+ await openAutocomplete(labels.add.autocompleteContact);
512
+
513
+ await waitForRequest('@getContacts');
514
+
515
+ await selectOption('Entity 11');
516
+
517
+ await userEvent.click(screen.getByTestId('add'));
518
+
519
+ expect(await findFirstByText('Entity 11')).toBeVisible();
520
+
521
+ expect(getInput('role-Entity 11')).toHaveValue('viewer');
522
+ expect(await findFirstByText(labels.list.added)).toBeVisible();
523
+ await waitFor(() => {
524
+ expect(change).toHaveBeenCalledTimes(2);
525
+ });
526
+ });
527
+ });
@@ -51,6 +51,15 @@ export const WithDescription: Story = {
51
51
  render: Default.render
52
52
  };
53
53
 
54
+ export const WithInlineDescription: Story = {
55
+ args: {
56
+ description: 'Configure the hosts monitored by your platform',
57
+ descriptionPosition: 'inline',
58
+ title: 'Hosts'
59
+ },
60
+ render: Default.render
61
+ };
62
+
54
63
  export const WithMenu: Story = {
55
64
  args: {
56
65
  ...WithDescription.args,
@@ -7,9 +7,11 @@ type PageHeaderMainProps = {
7
7
  };
8
8
 
9
9
  const PageHeaderMain = ({ children }: PageHeaderMainProps): ReactElement => {
10
- const { classes } = useStyles();
10
+ const { classes, cx } = useStyles();
11
11
 
12
- return <div className={classes.pageHeaderMain}>{children}</div>;
12
+ return (
13
+ <div className={cx(classes.pageHeaderMain, 'min-w-0')}>{children}</div>
14
+ );
13
15
  };
14
16
 
15
17
  export { PageHeaderMain };
@@ -8,37 +8,80 @@ import { useStyles } from './PageHeader.styles';
8
8
  type PageHeaderTitleProps = {
9
9
  actions?: ReactNode;
10
10
  description?: string;
11
+ descriptionPosition?: 'below' | 'inline';
11
12
  title: string;
12
13
  };
13
14
 
14
15
  const PageHeaderTitle = ({
15
16
  actions,
16
17
  title,
17
- description
18
+ description,
19
+ descriptionPosition = 'below'
18
20
  }: PageHeaderTitleProps): ReactElement => {
19
21
  const { classes } = useStyles();
20
22
 
23
+ const isInline = descriptionPosition === 'inline';
24
+
25
+ const descriptionElement = description && (
26
+ <TextOverflowTooltip label={description}>
27
+ <MuiTypography
28
+ aria-label="page header description"
29
+ className={classes.pageHeaderTitleDescription}
30
+ variant="body2"
31
+ >
32
+ {description}
33
+ </MuiTypography>
34
+ </TextOverflowTooltip>
35
+ );
36
+
37
+ // Inline, the title owns its typography: the `pageHeaderTitle` rules that
38
+ // style the `h1` of the stacked layout only apply to that layout.
39
+ const titleElement = isInline ? (
40
+ <MuiTypography
41
+ aria-label="page header title"
42
+ className="m-0 shrink-0 font-bold leading-none"
43
+ component="h1"
44
+ variant="h5"
45
+ >
46
+ {title}
47
+ </MuiTypography>
48
+ ) : (
49
+ <MuiTypography aria-label="page header title" variant="h1">
50
+ {title}
51
+ </MuiTypography>
52
+ );
53
+
54
+ const actionsElement = actions && (
55
+ <div className={classes.pageHeaderTitleActions}>{actions}</div>
56
+ );
57
+
58
+ if (isInline) {
59
+ return (
60
+ <div className="flex min-w-0 items-baseline gap-4 self-start">
61
+ {titleElement}
62
+ {description && (
63
+ <>
64
+ <span
65
+ aria-hidden
66
+ className="shrink-0 text-2xl font-light leading-none text-header-page-description"
67
+ >
68
+ |
69
+ </span>
70
+ <div className="min-w-0">{descriptionElement}</div>
71
+ </>
72
+ )}
73
+ {actionsElement}
74
+ </div>
75
+ );
76
+ }
77
+
21
78
  return (
22
79
  <div className={classes.pageHeaderTitle}>
23
80
  <span>
24
- <MuiTypography aria-label="page header title" variant="h1">
25
- {title}
26
- </MuiTypography>
27
- {actions && (
28
- <div className={classes.pageHeaderTitleActions}>{actions}</div>
29
- )}
81
+ {titleElement}
82
+ {actionsElement}
30
83
  </span>
31
- {description && (
32
- <TextOverflowTooltip label={description}>
33
- <MuiTypography
34
- aria-label="page header description"
35
- className={classes.pageHeaderTitleDescription}
36
- variant="body2"
37
- >
38
- {description}
39
- </MuiTypography>
40
- </TextOverflowTooltip>
41
- )}
84
+ {descriptionElement}
42
85
  </div>
43
86
  );
44
87
  };