@centreon/ui 26.10.9 → 26.10.11
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/README.md +1 -2
- package/package.json +1 -2
- package/src/Button/Save/index.stories.tsx +0 -3
- package/src/Form/CollapsibleGroup.tsx +6 -33
- package/src/Form/Form.browser.test.tsx +108 -0
- package/src/Form/Form.stories.tsx +102 -1
- package/src/Form/Form.tsx +4 -3
- 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 +109 -25
- 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/Graph/BarStack/BarStack.stories.tsx +1 -6
- package/src/Graph/Chart/Chart.stories.tsx +0 -3
- package/src/Graph/PieChart/PieChart.stories.tsx +1 -6
- package/src/Graph/Timeline/Timeline.stories.tsx +0 -5
- package/src/Graph/Tree/Tree.stories.tsx +0 -3
- package/src/TimePeriods/index.stories.tsx +0 -3
- 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/Tabs/Tabs.tsx +7 -1
package/README.md
CHANGED
|
@@ -57,9 +57,8 @@ To start Storybook server, run:
|
|
|
57
57
|
|
|
58
58
|
# Tests architecture
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Centreon UI tests are component tests:
|
|
61
61
|
- Jest + RTL and Cypress: Component testing. We want to migrate from Jest to Cypress
|
|
62
|
-
- Chromatic: Chromatic is a tool that snapshots our stories and better handle snapshots changes using a review process
|
|
63
62
|
|
|
64
63
|
|
|
65
64
|
### Run Jest tests
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@centreon/ui",
|
|
3
|
-
"version": "26.10.
|
|
3
|
+
"version": "26.10.11",
|
|
4
4
|
"description": "Centreon UI Components",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"update:deps": "pnpx npm-check-updates -i --format group",
|
|
@@ -153,7 +153,6 @@
|
|
|
153
153
|
"@visx/visx": "3.12.0",
|
|
154
154
|
"@visx/zoom": "^3.12.0",
|
|
155
155
|
"anylogger": "^1.0.11",
|
|
156
|
-
"chromatic": "^13.3.3",
|
|
157
156
|
"d3-array": "3.2.4",
|
|
158
157
|
"dayjs": "^1.11.13",
|
|
159
158
|
"highlight.js": "^11.11.1",
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
|
|
11
11
|
import { useCallback, useState } from 'react';
|
|
12
12
|
import { useTranslation } from 'react-i18next';
|
|
13
|
-
import { makeStyles } from 'tss-react/mui';
|
|
14
13
|
|
|
15
14
|
import type { Group } from './Inputs/models';
|
|
16
15
|
|
|
@@ -23,22 +22,6 @@ interface Props {
|
|
|
23
22
|
isCollapsible: boolean;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
const useStyles = makeStyles()((theme) => ({
|
|
27
|
-
expandCollapseIcon: {
|
|
28
|
-
justifySelf: 'flex-end'
|
|
29
|
-
},
|
|
30
|
-
groupTitleContainer: {
|
|
31
|
-
alignItems: 'center',
|
|
32
|
-
columnGap: theme.spacing(1),
|
|
33
|
-
display: 'flex',
|
|
34
|
-
flexDirection: 'row'
|
|
35
|
-
},
|
|
36
|
-
title: {},
|
|
37
|
-
tooltip: {
|
|
38
|
-
maxWidth: theme.spacing(60)
|
|
39
|
-
}
|
|
40
|
-
}));
|
|
41
|
-
|
|
42
25
|
const CollapsibleGroup = ({
|
|
43
26
|
children,
|
|
44
27
|
isCollapsible,
|
|
@@ -47,7 +30,6 @@ const CollapsibleGroup = ({
|
|
|
47
30
|
defaultIsOpen = false,
|
|
48
31
|
className
|
|
49
32
|
}: Props): JSX.Element => {
|
|
50
|
-
const { classes, cx } = useStyles();
|
|
51
33
|
const { t } = useTranslation();
|
|
52
34
|
|
|
53
35
|
const [isOpen, setIsOpen] = useState(isCollapsible && defaultIsOpen);
|
|
@@ -56,7 +38,7 @@ const CollapsibleGroup = ({
|
|
|
56
38
|
setIsOpen((currentIsOpen) => !currentIsOpen);
|
|
57
39
|
};
|
|
58
40
|
|
|
59
|
-
const containerClassName = className || ''
|
|
41
|
+
const containerClassName = `flex items-center gap-x-2 ${className || ''}`;
|
|
60
42
|
|
|
61
43
|
const CollapseIcon = isOpen ? ExpandLess : ExpandMore;
|
|
62
44
|
const ContainerComponent = useCallback(
|
|
@@ -66,7 +48,7 @@ const CollapsibleGroup = ({
|
|
|
66
48
|
isCollapsible ? (
|
|
67
49
|
<ListItemButton
|
|
68
50
|
aria-label={group?.name}
|
|
69
|
-
className={`${
|
|
51
|
+
className={`${containerClassName} bg-background-listing-header rounded-lg`}
|
|
70
52
|
dense
|
|
71
53
|
disableGutters
|
|
72
54
|
disableRipple
|
|
@@ -75,18 +57,9 @@ const CollapsibleGroup = ({
|
|
|
75
57
|
{containerComponentChildren}
|
|
76
58
|
</ListItemButton>
|
|
77
59
|
) : (
|
|
78
|
-
<Box className={
|
|
79
|
-
{containerComponentChildren}
|
|
80
|
-
</Box>
|
|
60
|
+
<Box className={containerClassName}>{containerComponentChildren}</Box>
|
|
81
61
|
),
|
|
82
|
-
[
|
|
83
|
-
isCollapsible,
|
|
84
|
-
classes.groupTitleContainer,
|
|
85
|
-
containerClassName,
|
|
86
|
-
cx,
|
|
87
|
-
group?.name,
|
|
88
|
-
toggle
|
|
89
|
-
]
|
|
62
|
+
[isCollapsible, containerClassName, group?.name, toggle]
|
|
90
63
|
);
|
|
91
64
|
|
|
92
65
|
return (
|
|
@@ -100,7 +73,7 @@ const CollapsibleGroup = ({
|
|
|
100
73
|
data-testid={`${group?.name}-header`}
|
|
101
74
|
>
|
|
102
75
|
<Typography
|
|
103
|
-
className="groupText scroll-m-12 snap-start"
|
|
76
|
+
className="groupText scroll-m-12 snap-start text-base font-medium"
|
|
104
77
|
variant="h6"
|
|
105
78
|
{...group?.titleAttributes}
|
|
106
79
|
data-section-group-form-id={group?.name}
|
|
@@ -110,7 +83,7 @@ const CollapsibleGroup = ({
|
|
|
110
83
|
{group?.EndIcon && (
|
|
111
84
|
<Tooltip
|
|
112
85
|
classes={{
|
|
113
|
-
tooltip:
|
|
86
|
+
tooltip: 'max-w-120'
|
|
114
87
|
}}
|
|
115
88
|
placement="top"
|
|
116
89
|
title={group?.TooltipContent ? <group.TooltipContent /> : ''}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { Typography } from '@mui/material';
|
|
2
2
|
|
|
3
|
+
import { ThemeMode, userAtom } from '@centreon/ui-context';
|
|
4
|
+
|
|
3
5
|
import { faker } from '@faker-js/faker';
|
|
4
6
|
import { rs } from '@rstest/core';
|
|
5
7
|
import { render } from '@test-setup/render';
|
|
6
8
|
import { screen, waitFor, within } from '@testing-library/react';
|
|
7
9
|
import userEvent from '@testing-library/user-event';
|
|
8
10
|
import { useFormikContext } from 'formik';
|
|
11
|
+
import { createStore, Provider } from 'jotai';
|
|
9
12
|
import { object } from 'yup';
|
|
10
13
|
|
|
11
14
|
import type { SelectEntry } from '..';
|
|
12
15
|
import { Button } from '../components';
|
|
16
|
+
import ThemeProvider from '../ThemeProvider';
|
|
13
17
|
import { Form } from './Form';
|
|
14
18
|
import { type InputProps, InputType } from './Inputs/models';
|
|
15
19
|
|
|
@@ -443,3 +447,107 @@ describe('Form with sections', () => {
|
|
|
443
447
|
);
|
|
444
448
|
});
|
|
445
449
|
});
|
|
450
|
+
|
|
451
|
+
// The theme mode comes from the user atom, and the Tailwind tokens from the
|
|
452
|
+
// `dark` class on the document.
|
|
453
|
+
const initializeFormTitles = (themeMode: ThemeMode): void => {
|
|
454
|
+
const store = createStore();
|
|
455
|
+
store.set(userAtom, { ...store.get(userAtom), themeMode });
|
|
456
|
+
|
|
457
|
+
document.documentElement.classList.toggle(
|
|
458
|
+
'dark',
|
|
459
|
+
themeMode === ThemeMode.dark
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
render(
|
|
463
|
+
<Provider store={store}>
|
|
464
|
+
<ThemeProvider>
|
|
465
|
+
<Form
|
|
466
|
+
areGroupsOpen
|
|
467
|
+
groups={[{ name: 'Titles section', order: 1 }]}
|
|
468
|
+
initialValues={{ gridField: '', topLevelField: '' }}
|
|
469
|
+
inputs={[
|
|
470
|
+
{
|
|
471
|
+
additionalLabel: 'Top level block title',
|
|
472
|
+
fieldName: 'topLevelField',
|
|
473
|
+
group: 'Titles section',
|
|
474
|
+
label: 'Top level field',
|
|
475
|
+
type: InputType.Text
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
fieldName: '',
|
|
479
|
+
grid: {
|
|
480
|
+
columns: [
|
|
481
|
+
{
|
|
482
|
+
additionalLabel: 'Grid block title',
|
|
483
|
+
fieldName: 'gridField',
|
|
484
|
+
label: 'Grid field',
|
|
485
|
+
type: InputType.Text
|
|
486
|
+
}
|
|
487
|
+
]
|
|
488
|
+
},
|
|
489
|
+
group: 'Titles section',
|
|
490
|
+
label: 'Grid',
|
|
491
|
+
type: InputType.Grid
|
|
492
|
+
}
|
|
493
|
+
]}
|
|
494
|
+
isCollapsible
|
|
495
|
+
submit={rs.fn()}
|
|
496
|
+
validationSchema={object()}
|
|
497
|
+
/>
|
|
498
|
+
</ThemeProvider>
|
|
499
|
+
</Provider>
|
|
500
|
+
);
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const textPrimaryColor = {
|
|
504
|
+
[ThemeMode.dark]: 'rgb(255, 255, 255)',
|
|
505
|
+
[ThemeMode.light]: 'rgb(0, 0, 0)'
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
[ThemeMode.light, ThemeMode.dark].forEach((themeMode) => {
|
|
509
|
+
describe(`Form titles in ${themeMode} mode`, () => {
|
|
510
|
+
beforeEach(() => {
|
|
511
|
+
expect.hasAssertions();
|
|
512
|
+
});
|
|
513
|
+
beforeEach(() => initializeFormTitles(themeMode));
|
|
514
|
+
|
|
515
|
+
afterEach(() => {
|
|
516
|
+
document.documentElement.classList.remove('dark');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('displays the section title in 16px medium on a 24px line', async () => {
|
|
520
|
+
const title = await screen.findByText('Titles section');
|
|
521
|
+
|
|
522
|
+
expect(title).toHaveStyle({
|
|
523
|
+
fontSize: '16px',
|
|
524
|
+
fontWeight: '500',
|
|
525
|
+
lineHeight: '24px'
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('rounds the collapsible section header with an 8px radius', async () => {
|
|
530
|
+
expect(await screen.findByLabelText('Titles section')).toHaveStyle({
|
|
531
|
+
borderRadius: '8px'
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('displays the grid block title in 16px bold with the text colour', async () => {
|
|
536
|
+
expect(await screen.findByText('Grid block title')).toHaveStyle({
|
|
537
|
+
color: textPrimaryColor[themeMode],
|
|
538
|
+
fontSize: '16px',
|
|
539
|
+
fontWeight: '700',
|
|
540
|
+
lineHeight: '24px'
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('displays the top level block title like the grid block title', async () => {
|
|
545
|
+
expect(await screen.findByText('Top level block title')).toHaveStyle({
|
|
546
|
+
color: textPrimaryColor[themeMode],
|
|
547
|
+
fontSize: '16px',
|
|
548
|
+
fontWeight: '700',
|
|
549
|
+
lineHeight: '24px'
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { object } from 'yup';
|
|
2
2
|
|
|
3
3
|
import { Form, GroupDirection } from './Form';
|
|
4
|
-
import { InputType } from './Inputs/models';
|
|
4
|
+
import { type InputProps, InputType } from './Inputs/models';
|
|
5
5
|
import {
|
|
6
6
|
type BasicForm,
|
|
7
7
|
basicFormGroups,
|
|
@@ -110,3 +110,104 @@ export const exclusiveCheckboxGroup = (): JSX.Element => (
|
|
|
110
110
|
validationSchema={object()}
|
|
111
111
|
/>
|
|
112
112
|
);
|
|
113
|
+
|
|
114
|
+
export const segmentedButtons = (): JSX.Element => (
|
|
115
|
+
<Form
|
|
116
|
+
initialValues={{
|
|
117
|
+
activeChecks: 'use_default',
|
|
118
|
+
updateMode: 'incremental'
|
|
119
|
+
}}
|
|
120
|
+
inputs={[
|
|
121
|
+
{
|
|
122
|
+
fieldName: 'activeChecks',
|
|
123
|
+
group: '',
|
|
124
|
+
label: 'Active checks enabled',
|
|
125
|
+
segmentedButtons: {
|
|
126
|
+
options: [
|
|
127
|
+
{ label: 'Yes', value: 'true' },
|
|
128
|
+
{ label: 'No', value: 'false' },
|
|
129
|
+
{ label: 'Default', value: 'use_default' }
|
|
130
|
+
]
|
|
131
|
+
},
|
|
132
|
+
type: InputType.SegmentedButtons
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
fieldName: 'updateMode',
|
|
136
|
+
group: '',
|
|
137
|
+
label: 'Update mode (long labels)',
|
|
138
|
+
segmentedButtons: {
|
|
139
|
+
options: [
|
|
140
|
+
{ label: 'Incremental', value: 'incremental' },
|
|
141
|
+
{ label: 'Replacement', value: 'replacement' },
|
|
142
|
+
{ label: 'Inherit from the host template', value: 'inherit' }
|
|
143
|
+
]
|
|
144
|
+
},
|
|
145
|
+
type: InputType.SegmentedButtons
|
|
146
|
+
}
|
|
147
|
+
]}
|
|
148
|
+
submit={submit}
|
|
149
|
+
validationSchema={object()}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const getSeparatedFields = (count: number): Array<InputProps> =>
|
|
154
|
+
Array.from({ length: count }, (_, index) => ({
|
|
155
|
+
fieldName: `field${index + 1}`,
|
|
156
|
+
group: '',
|
|
157
|
+
label: `Field ${index + 1}`,
|
|
158
|
+
type: InputType.Text
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
const getSeparatedGrid = ({
|
|
162
|
+
className,
|
|
163
|
+
count,
|
|
164
|
+
name
|
|
165
|
+
}: {
|
|
166
|
+
className: string;
|
|
167
|
+
count: number;
|
|
168
|
+
name: string;
|
|
169
|
+
}): InputProps => ({
|
|
170
|
+
fieldName: name,
|
|
171
|
+
grid: {
|
|
172
|
+
className,
|
|
173
|
+
columnSeparator: true,
|
|
174
|
+
columns: getSeparatedFields(count).map(({ group, ...field }) => ({
|
|
175
|
+
...field,
|
|
176
|
+
fieldName: `${name}-${field.fieldName}`
|
|
177
|
+
}))
|
|
178
|
+
},
|
|
179
|
+
group: '',
|
|
180
|
+
label: name,
|
|
181
|
+
type: InputType.Grid
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// The grids of this story show 1, 2 or 3 columns as the panel is resized (drag
|
|
185
|
+
// its bottom right corner): the lines only come between columns, on every row.
|
|
186
|
+
export const columnSeparators = (): JSX.Element => (
|
|
187
|
+
<div className="@container resize-x overflow-auto border border-dashed p-4">
|
|
188
|
+
<Form
|
|
189
|
+
initialValues={{}}
|
|
190
|
+
inputs={[
|
|
191
|
+
getSeparatedGrid({
|
|
192
|
+
className: 'grid-cols-1 gap-x-8 @[500px]:grid-cols-2',
|
|
193
|
+
count: 2,
|
|
194
|
+
name: 'two-columns'
|
|
195
|
+
}),
|
|
196
|
+
getSeparatedGrid({
|
|
197
|
+
className:
|
|
198
|
+
'grid-cols-1 gap-x-8 @[500px]:grid-cols-2 @[900px]:grid-cols-3',
|
|
199
|
+
count: 3,
|
|
200
|
+
name: 'three-columns'
|
|
201
|
+
}),
|
|
202
|
+
getSeparatedGrid({
|
|
203
|
+
className:
|
|
204
|
+
'grid-cols-1 gap-x-8 @[500px]:grid-cols-2 @[900px]:grid-cols-3',
|
|
205
|
+
count: 5,
|
|
206
|
+
name: 'wrapping-rows'
|
|
207
|
+
})
|
|
208
|
+
]}
|
|
209
|
+
submit={submit}
|
|
210
|
+
validationSchema={object()}
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
package/src/Form/Form.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type FormikSharedConfig,
|
|
5
5
|
type FormikValues
|
|
6
6
|
} from 'formik';
|
|
7
|
-
import type
|
|
7
|
+
import { type ComponentType, useRef } from 'react';
|
|
8
8
|
import type { Schema } from 'yup';
|
|
9
9
|
|
|
10
10
|
import { useStyles } from './Form.styles';
|
|
@@ -55,6 +55,7 @@ const Form = <T extends object>({
|
|
|
55
55
|
...formikSharedConfig
|
|
56
56
|
}: FormProps<T>): JSX.Element => {
|
|
57
57
|
const { cx, classes } = useStyles();
|
|
58
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
58
59
|
|
|
59
60
|
if (isLoading) {
|
|
60
61
|
return (
|
|
@@ -79,8 +80,8 @@ const Form = <T extends object>({
|
|
|
79
80
|
validationSchema={validationSchema}
|
|
80
81
|
{...formikSharedConfig}
|
|
81
82
|
>
|
|
82
|
-
<div>
|
|
83
|
-
<FormSection groups={groups} />
|
|
83
|
+
<div ref={containerRef}>
|
|
84
|
+
<FormSection containerRef={containerRef} groups={groups} />
|
|
84
85
|
{children}
|
|
85
86
|
<div className={cx(className, classes.form)}>
|
|
86
87
|
<Inputs
|
|
@@ -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
|
})}
|