@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.
- package/package.json +6 -3
- package/public/mockServiceWorker.js +31 -8
- package/src/Dashboard/Dashboard.browser.test.tsx +107 -0
- package/src/Form/CollapsibleGroup.tsx +6 -33
- package/src/Form/Form.browser.test.tsx +553 -0
- package/src/Form/Form.stories.tsx +102 -1
- package/src/Form/Form.tsx +4 -3
- package/src/Form/Inputs/ExclusiveCheckboxGroup.browser.test.tsx +304 -0
- package/src/Form/Inputs/Grid.browser.test.tsx +186 -0
- package/src/Form/Inputs/Grid.tsx +59 -12
- package/src/Form/Inputs/PasswordInput.browser.test.tsx +80 -0
- package/src/Form/Inputs/SegmentedButtons.browser.test.tsx +197 -0
- package/src/Form/Inputs/SegmentedButtons.tsx +38 -29
- package/src/Form/Inputs/Text.tsx +13 -1
- package/src/Form/Inputs/index.tsx +10 -52
- package/src/Form/Inputs/models.ts +6 -0
- package/src/Form/Section/FormSection.browser.test.tsx +174 -0
- package/src/Form/Section/FormSection.tsx +20 -6
- package/src/Form/Section/navigateToSection.ts +26 -8
- package/src/Form/Section/useActiveSection.ts +213 -0
- package/src/InputField/Number/Number.browser.test.tsx +109 -0
- package/src/InputField/Select/Autocomplete/Connected/Multi/MultiConnectedAutocompleteField.browser.test.tsx +250 -0
- package/src/InputField/Select/Autocomplete/Connected/index.test.tsx +37 -0
- package/src/InputField/Select/Autocomplete/Connected/index.tsx +56 -45
- package/src/Listing/Cell/DataCell.tsx +0 -2
- package/src/Listing/Listing.browser.test.tsx +476 -0
- package/src/Module/Module.browser.test.tsx +145 -0
- package/src/Pagination/Pagination.browser.test.tsx +184 -0
- package/src/RichTextEditor/RichTextEditor.browser.test.tsx +567 -0
- package/src/RichTextEditor/plugins/FloatingLinkEditorPlugin.tsx +3 -1
- package/src/TimePeriods/ResolutionTimePeriod.browser.test.tsx +143 -0
- package/src/TimePeriods/TimePeriods.browser.test.tsx +468 -0
- package/src/Typography/FluidTypography/FluidTypography.browser.test.tsx +54 -0
- package/src/api/useMutationQuery/useMutationQuery.browser.test.tsx +154 -0
- package/src/components/CollapsibleItem/CollapsibleItem.browser.test.tsx +120 -0
- package/src/components/CopyCommand/CopyCommand.browser.test.tsx +287 -0
- package/src/components/CrudPage/CrudPage.browser.test.tsx +659 -0
- package/src/components/DataTable/DataTable.browser.test.tsx +257 -0
- package/src/components/Form/AccessRights/AccessRights.browser.test.tsx +527 -0
- package/src/components/Header/PageHeader/PageHeader.stories.tsx +9 -0
- package/src/components/Header/PageHeader/PageHeaderMain.tsx +4 -2
- package/src/components/Header/PageHeader/PageHeaderTitle.tsx +61 -18
- package/src/components/ItemComposition/ItemComposition.browser.test.tsx +172 -0
- package/src/components/Layout/PageLayout.browser.test.tsx +114 -0
- package/src/components/Modal/ConfirmationModal/ConfirmationModal.browser.test.tsx +237 -0
- package/src/components/SortableEntriesList/SortableEntriesList.browser.test.tsx +981 -0
- package/src/components/Tabs/Tabs.browser.test.tsx +115 -0
- package/src/components/Tabs/Tabs.tsx +7 -1
- package/src/utils/useFullscreen/Fullscreen.browser.test.tsx +176 -0
- package/src/utils/useLocale/useLocale.browser.test.tsx +49 -0
- package/src/utils/useLocaleTimezoneDate/{LocaleTimezoneDate.cypress.spec.tsx → LocaleTimezoneDate.browser.test.tsx} +24 -11
- package/src/Dashboard/Dashboard.cypress.spec.tsx +0 -71
- package/src/Form/Form.cypress.spec.tsx +0 -306
- package/src/Form/Inputs/ExclusiveCheckboxGroup.cypress.spec.tsx +0 -236
- package/src/Form/Inputs/SegmentedButtons.cypress.spec.tsx +0 -101
- package/src/InputField/Number/Number.cypress.spec.tsx +0 -89
- package/src/InputField/Select/Autocomplete/Connected/Multi/MultiConnectedAutocompleteField.cypress.spec.tsx +0 -182
- package/src/Listing/Listing.cypress.spec.tsx +0 -304
- package/src/Module/Module.cypress.spec.tsx +0 -127
- package/src/Pagination/Pagination.cypress.spec.tsx +0 -140
- package/src/RichTextEditor/RichTextEditor.cypress.spec.tsx +0 -408
- package/src/TimePeriods/ResolutionTimePeriod.cypress.spec.tsx +0 -74
- package/src/TimePeriods/TimePeriods.cypress.spec.tsx +0 -488
- package/src/Typography/FluidTypography/FluidTypography.cypress.spec.tsx +0 -27
- package/src/api/useMutationQuery/useMutationQuery.cypress.spec.tsx +0 -93
- package/src/components/CollapsibleItem/CollapsibleItem.cypress.spec.tsx +0 -76
- package/src/components/CopyCommand/CopyCommand.cypress.spec.tsx +0 -167
- package/src/components/CrudPage/CrudPage.cypress.spec.tsx +0 -561
- package/src/components/DataTable/DataTable.cypress.spec.tsx +0 -174
- package/src/components/Form/AccessRights/AccessRights.cypress.spec.tsx +0 -376
- package/src/components/ItemComposition/ItemComposition.cypress.spec.tsx +0 -112
- package/src/components/Layout/PageLayout.cypress.spec.tsx +0 -64
- package/src/components/Modal/ConfirmationModal/ConfirmationModal.cypress.spec.tsx +0 -171
- package/src/components/SortableEntriesList/SortableEntriesList.cypress.spec.tsx +0 -792
- package/src/components/Tabs/Tabs.cypress.spec.tsx +0 -69
- package/src/utils/useFullscreen/Fullscreen.cypress.spec.tsx +0 -130
- 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
|
+
});
|
package/src/Form/Inputs/Grid.tsx
CHANGED
|
@@ -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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
});
|