@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 CHANGED
@@ -57,9 +57,8 @@ To start Storybook server, run:
57
57
 
58
58
  # Tests architecture
59
59
 
60
- There are two kinds of tests in Centreon UI.
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.9",
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",
@@ -27,9 +27,6 @@ const meta = {
27
27
 
28
28
  component: ButtonSave,
29
29
 
30
- parameters: {
31
- chromatic: { delay: 300 }
32
- },
33
30
  title: 'Button/Save'
34
31
  };
35
32
 
@@ -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={`${cx(classes.groupTitleContainer, containerClassName)} bg-background-listing-header`}
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={cx(classes.groupTitleContainer, containerClassName)}>
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: classes.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 { ComponentType } from 'react';
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
+ });
@@ -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 key={key as string} sx={{ width: '100%' }}>
46
- {field.additionalLabel && (
47
- <Typography
48
- className={field?.additionalLabelClassName}
49
- sx={{ color: 'primary.main', marginBottom: 0.5 }}
50
- variant="h6"
51
- >
52
- {field.additionalLabel}
53
- </Typography>
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
  })}