@centreon/ui 26.12.11 → 26.12.13
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 +1 -1
- package/src/Form/Inputs/IconSelector.browser.test.tsx +232 -0
- package/src/Form/Inputs/IconSelector.stories.tsx +82 -0
- package/src/Form/Inputs/IconSelector.tsx +78 -0
- package/src/Form/Inputs/LoadingSkeleton.tsx +4 -0
- package/src/Form/Inputs/index.tsx +5 -0
- package/src/Form/Inputs/models.ts +7 -1
- package/src/Listing/ActionBar/index.tsx +21 -1
- package/src/Listing/Listing.browser.test.tsx +96 -0
package/package.json
CHANGED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { Typography } from '@mui/material';
|
|
2
|
+
|
|
3
|
+
import { rs } from '@rstest/core';
|
|
4
|
+
import { interceptAPIRequest, Method, waitForRequest } from '@test-setup/api';
|
|
5
|
+
import { render } from '@test-setup/render';
|
|
6
|
+
import { screen, waitFor, within } from '@testing-library/react';
|
|
7
|
+
import userEvent from '@testing-library/user-event';
|
|
8
|
+
import { type FormikValues, useFormikContext } from 'formik';
|
|
9
|
+
import i18next from 'i18next';
|
|
10
|
+
import { path } from 'ramda';
|
|
11
|
+
import { initReactI18next } from 'react-i18next';
|
|
12
|
+
import { type AnyObjectSchema, object } from 'yup';
|
|
13
|
+
|
|
14
|
+
import buildListingEndpoint from '../../api/buildListingEndpoint';
|
|
15
|
+
import TestQueryProvider from '../../api/TestQueryProvider';
|
|
16
|
+
import type { GetEndpointParams } from '../../InputField/Select/Autocomplete/Connected';
|
|
17
|
+
import { Form } from '../Form';
|
|
18
|
+
import { InputType } from './models';
|
|
19
|
+
|
|
20
|
+
const label = 'Icon';
|
|
21
|
+
const labelRequired = 'Required';
|
|
22
|
+
const iconsEndpoint = '/configuration/icons';
|
|
23
|
+
|
|
24
|
+
const getIconUrl = (color: string): string =>
|
|
25
|
+
`data:image/svg+xml,${encodeURIComponent(
|
|
26
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" fill="${color}"/></svg>`
|
|
27
|
+
)}`;
|
|
28
|
+
|
|
29
|
+
const icons = [
|
|
30
|
+
{ id: 1, name: 'blue', url: getIconUrl('blue') },
|
|
31
|
+
{ id: 2, name: 'green', url: getIconUrl('green') }
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const getEndpoint = (parameters: GetEndpointParams): string =>
|
|
35
|
+
buildListingEndpoint({ baseEndpoint: iconsEndpoint, parameters });
|
|
36
|
+
|
|
37
|
+
interface InitializeProps {
|
|
38
|
+
dataTestId?: string;
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
fieldName?: string;
|
|
41
|
+
initialValues?: FormikValues;
|
|
42
|
+
validationSchema?: AnyObjectSchema;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ValueDisplay = ({ fieldName }: { fieldName: string }): JSX.Element => {
|
|
46
|
+
const { values } = useFormikContext<FormikValues>();
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Typography data-testid="value">
|
|
50
|
+
{JSON.stringify(path(fieldName.split('.'), values) ?? null)}
|
|
51
|
+
</Typography>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const initialize = ({
|
|
56
|
+
dataTestId,
|
|
57
|
+
disabled = false,
|
|
58
|
+
fieldName = 'icon',
|
|
59
|
+
initialValues = { icon: null },
|
|
60
|
+
validationSchema = object()
|
|
61
|
+
}: InitializeProps = {}): void => {
|
|
62
|
+
interceptAPIRequest({
|
|
63
|
+
alias: 'getIcons',
|
|
64
|
+
method: Method.GET,
|
|
65
|
+
path: `./api/latest${iconsEndpoint}**`,
|
|
66
|
+
response: { meta: { limit: 10, page: 1, total: 2 }, result: icons }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
render(
|
|
70
|
+
<TestQueryProvider>
|
|
71
|
+
<div className="w-100">
|
|
72
|
+
<Form
|
|
73
|
+
initialValues={initialValues}
|
|
74
|
+
inputs={[
|
|
75
|
+
{
|
|
76
|
+
dataTestId,
|
|
77
|
+
fieldName,
|
|
78
|
+
getDisabled: () => disabled,
|
|
79
|
+
group: '',
|
|
80
|
+
iconSelector: { getEndpoint },
|
|
81
|
+
label,
|
|
82
|
+
type: InputType.IconSelector
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
custom: {
|
|
86
|
+
Component: () => <ValueDisplay fieldName={fieldName} />
|
|
87
|
+
},
|
|
88
|
+
fieldName: 'value',
|
|
89
|
+
group: '',
|
|
90
|
+
label: 'value',
|
|
91
|
+
type: InputType.Custom
|
|
92
|
+
}
|
|
93
|
+
]}
|
|
94
|
+
submit={rs.fn()}
|
|
95
|
+
validationSchema={validationSchema}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
</TestQueryProvider>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const getPreview = (name: string): HTMLElement | null =>
|
|
103
|
+
document.querySelector(`img[alt="${name}"]:not([role="presentation"] img)`);
|
|
104
|
+
|
|
105
|
+
describe('Icon selector', () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
expect.hasAssertions();
|
|
108
|
+
|
|
109
|
+
i18next.use(initReactI18next).init({ lng: 'en', resources: {} });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('lists the images of the endpoint, each with its thumbnail', async () => {
|
|
113
|
+
initialize();
|
|
114
|
+
|
|
115
|
+
await userEvent.click(await screen.findByRole('combobox', { name: label }));
|
|
116
|
+
await waitForRequest('@getIcons');
|
|
117
|
+
|
|
118
|
+
const list = await screen.findByRole('listbox');
|
|
119
|
+
|
|
120
|
+
await waitFor(() => {
|
|
121
|
+
expect(within(list).getAllByRole('option')).toHaveLength(2);
|
|
122
|
+
});
|
|
123
|
+
icons.forEach(({ name, url }) => {
|
|
124
|
+
const thumbnail = within(list).getByAltText(name);
|
|
125
|
+
|
|
126
|
+
expect(thumbnail).toHaveAttribute('src', url);
|
|
127
|
+
expect(thumbnail).toHaveAttribute('width', '20');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('stores the picked image in the field and shows it beside the picker', async () => {
|
|
132
|
+
initialize();
|
|
133
|
+
|
|
134
|
+
await userEvent.click(await screen.findByRole('combobox', { name: label }));
|
|
135
|
+
await waitForRequest('@getIcons');
|
|
136
|
+
await userEvent.click(await screen.findByText('green'));
|
|
137
|
+
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
expect(screen.getByTestId('value')).toHaveTextContent(
|
|
140
|
+
JSON.stringify(icons[1])
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
expect(screen.getByRole('combobox', { name: label })).toHaveValue(
|
|
145
|
+
'green'
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(getPreview('green')).toHaveAttribute('src', icons[1].url);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('shows the initial image and removes it once the field is cleared', async () => {
|
|
154
|
+
initialize({ initialValues: { icon: icons[0] } });
|
|
155
|
+
|
|
156
|
+
expect(await screen.findByRole('combobox', { name: label })).toHaveValue(
|
|
157
|
+
'blue'
|
|
158
|
+
);
|
|
159
|
+
await waitFor(() => {
|
|
160
|
+
expect(getPreview('blue')).toBeInTheDocument();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await userEvent.hover(screen.getByRole('combobox', { name: label }));
|
|
164
|
+
await userEvent.click(await screen.findByTitle('Clear'));
|
|
165
|
+
|
|
166
|
+
await waitFor(() => {
|
|
167
|
+
expect(screen.getByTestId('value')).toHaveTextContent('null');
|
|
168
|
+
});
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(getPreview('blue')).not.toBeInTheDocument();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('shows the field error once the field is cleared', async () => {
|
|
175
|
+
initialize({
|
|
176
|
+
initialValues: { icon: icons[0] },
|
|
177
|
+
validationSchema: object({
|
|
178
|
+
icon: object().nullable().required(labelRequired)
|
|
179
|
+
})
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(await screen.findByRole('combobox', { name: label })).toHaveValue(
|
|
183
|
+
'blue'
|
|
184
|
+
);
|
|
185
|
+
expect(screen.queryByText(labelRequired)).not.toBeInTheDocument();
|
|
186
|
+
|
|
187
|
+
await userEvent.hover(screen.getByRole('combobox', { name: label }));
|
|
188
|
+
await userEvent.click(await screen.findByTitle('Clear'));
|
|
189
|
+
|
|
190
|
+
expect(await screen.findByText(labelRequired)).toBeVisible();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('reads and writes a nested field', async () => {
|
|
194
|
+
initialize({
|
|
195
|
+
fieldName: 'extendedInfos.icon',
|
|
196
|
+
initialValues: { extendedInfos: { icon: icons[0] } }
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(await screen.findByRole('combobox', { name: label })).toHaveValue(
|
|
200
|
+
'blue'
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
await userEvent.click(screen.getByRole('combobox', { name: label }));
|
|
204
|
+
await waitForRequest('@getIcons');
|
|
205
|
+
await userEvent.click(await screen.findByText('green'));
|
|
206
|
+
|
|
207
|
+
await waitFor(() => {
|
|
208
|
+
expect(screen.getByTestId('value')).toHaveTextContent(
|
|
209
|
+
JSON.stringify(icons[1])
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('uses the label as test id by default', async () => {
|
|
215
|
+
initialize();
|
|
216
|
+
|
|
217
|
+
expect(await screen.findByTestId(label)).toBeVisible();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('uses the given test id instead of the label', async () => {
|
|
221
|
+
initialize({ dataTestId: 'icon-selector' });
|
|
222
|
+
|
|
223
|
+
expect(await screen.findByTestId('icon-selector')).toBeVisible();
|
|
224
|
+
expect(screen.queryByTestId(label)).not.toBeInTheDocument();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('is disabled when getDisabled says so', async () => {
|
|
228
|
+
initialize({ disabled: true, initialValues: { icon: icons[0] } });
|
|
229
|
+
|
|
230
|
+
expect(await screen.findByRole('combobox', { name: label })).toBeDisabled();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { HttpResponse, http } from 'msw';
|
|
3
|
+
import { object } from 'yup';
|
|
4
|
+
|
|
5
|
+
import buildListingEndpoint from '../../api/buildListingEndpoint';
|
|
6
|
+
import type { GetEndpointParams } from '../../InputField/Select/Autocomplete/Connected';
|
|
7
|
+
import { Form } from '../Form';
|
|
8
|
+
import { InputType } from './models';
|
|
9
|
+
|
|
10
|
+
const getIconUrl = (color: string): string =>
|
|
11
|
+
`data:image/svg+xml,${encodeURIComponent(
|
|
12
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" fill="${color}"/></svg>`
|
|
13
|
+
)}`;
|
|
14
|
+
|
|
15
|
+
const icons = [
|
|
16
|
+
{ id: 1, name: 'blue', url: getIconUrl('#1976d2') },
|
|
17
|
+
{ id: 2, name: 'green', url: getIconUrl('#2e7d32') },
|
|
18
|
+
{ id: 3, name: 'red', url: getIconUrl('#d32f2f') }
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const iconsEndpoint = '/icons';
|
|
22
|
+
|
|
23
|
+
const getEndpoint = (parameters: GetEndpointParams): string =>
|
|
24
|
+
buildListingEndpoint({ baseEndpoint: iconsEndpoint, parameters });
|
|
25
|
+
|
|
26
|
+
interface Args {
|
|
27
|
+
disabled: boolean;
|
|
28
|
+
icon: (typeof icons)[number] | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const IconSelectorForm = ({ disabled, icon }: Args): JSX.Element => (
|
|
32
|
+
<div className="w-100">
|
|
33
|
+
<Form
|
|
34
|
+
initialValues={{ icon }}
|
|
35
|
+
inputs={[
|
|
36
|
+
{
|
|
37
|
+
fieldName: 'icon',
|
|
38
|
+
getDisabled: () => disabled,
|
|
39
|
+
group: '',
|
|
40
|
+
iconSelector: { getEndpoint },
|
|
41
|
+
label: 'Icon',
|
|
42
|
+
type: InputType.IconSelector
|
|
43
|
+
}
|
|
44
|
+
]}
|
|
45
|
+
submit={() => undefined}
|
|
46
|
+
validationSchema={object()}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const meta: Meta<typeof IconSelectorForm> = {
|
|
52
|
+
args: { disabled: false, icon: null },
|
|
53
|
+
component: IconSelectorForm,
|
|
54
|
+
parameters: {
|
|
55
|
+
docs: {
|
|
56
|
+
description: {
|
|
57
|
+
component:
|
|
58
|
+
'`InputType.IconSelector`: picks an image from the listing returned by `iconSelector.getEndpoint`, showing each option with its thumbnail and the picked image beside the field. The field (`fieldName`, a dotted path for a nested value) holds the picked `{ id, name, url }`, or null.'
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
msw: {
|
|
62
|
+
handlers: [
|
|
63
|
+
http.get(`**${iconsEndpoint}**`, () =>
|
|
64
|
+
HttpResponse.json({
|
|
65
|
+
meta: { limit: 10, page: 1, total: icons.length },
|
|
66
|
+
result: icons
|
|
67
|
+
})
|
|
68
|
+
)
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
title: 'Form/Inputs/IconSelector'
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export default meta;
|
|
76
|
+
type Story = StoryObj<typeof IconSelectorForm>;
|
|
77
|
+
|
|
78
|
+
export const Empty: Story = {};
|
|
79
|
+
|
|
80
|
+
export const WithIcon: Story = { args: { icon: icons[1] } };
|
|
81
|
+
|
|
82
|
+
export const Disabled: Story = { args: { disabled: true, icon: icons[2] } };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type FormikValues, useFormikContext } from 'formik';
|
|
2
|
+
import { isNil, path, split } from 'ramda';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
|
|
5
|
+
import Image, { ImageVariant } from '../../Image/Image';
|
|
6
|
+
import SingleConnectedAutocompleteField from '../../InputField/Select/Autocomplete/Connected/Single';
|
|
7
|
+
import LoadingSkeleton from '../../LoadingSkeleton';
|
|
8
|
+
import type { InputPropsWithoutGroup } from './models';
|
|
9
|
+
|
|
10
|
+
interface Icon {
|
|
11
|
+
id: number;
|
|
12
|
+
name: string;
|
|
13
|
+
url: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const previewSize = 25;
|
|
17
|
+
|
|
18
|
+
// A picker of the images listed by `iconSelector.getEndpoint`, each option
|
|
19
|
+
// with its thumbnail, and the picked image beside it. The field holds the
|
|
20
|
+
// picked `{ id, name, url }`, or null.
|
|
21
|
+
const IconSelector = ({
|
|
22
|
+
dataTestId,
|
|
23
|
+
fieldName,
|
|
24
|
+
getDisabled,
|
|
25
|
+
iconSelector,
|
|
26
|
+
label
|
|
27
|
+
}: InputPropsWithoutGroup): JSX.Element | null => {
|
|
28
|
+
const { t } = useTranslation();
|
|
29
|
+
|
|
30
|
+
const { errors, setFieldTouched, setFieldValue, touched, values } =
|
|
31
|
+
useFormikContext<FormikValues>();
|
|
32
|
+
|
|
33
|
+
const fieldPath = split('.', fieldName);
|
|
34
|
+
const icon = (path(fieldPath, values) as Icon | null | undefined) ?? null;
|
|
35
|
+
const error = path(fieldPath, touched)
|
|
36
|
+
? (path(fieldPath, errors) as string | undefined)
|
|
37
|
+
: undefined;
|
|
38
|
+
|
|
39
|
+
const changeIcon = (_: unknown, value: unknown): void => {
|
|
40
|
+
setFieldTouched(fieldName, true, false);
|
|
41
|
+
setFieldValue(fieldName, value);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (isNil(iconSelector)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="flex items-center gap-2">
|
|
50
|
+
<SingleConnectedAutocompleteField
|
|
51
|
+
dataTestId={dataTestId}
|
|
52
|
+
disableClearable={false}
|
|
53
|
+
disabled={getDisabled?.(values) ?? false}
|
|
54
|
+
displayOptionThumbnail
|
|
55
|
+
error={error}
|
|
56
|
+
field="name"
|
|
57
|
+
fullWidth
|
|
58
|
+
getEndpoint={iconSelector.getEndpoint}
|
|
59
|
+
id={fieldName}
|
|
60
|
+
label={t(label)}
|
|
61
|
+
onChange={changeIcon}
|
|
62
|
+
value={icon}
|
|
63
|
+
/>
|
|
64
|
+
{icon && (
|
|
65
|
+
<Image
|
|
66
|
+
alt={icon.name}
|
|
67
|
+
fallback={<LoadingSkeleton />}
|
|
68
|
+
height={previewSize}
|
|
69
|
+
imagePath={icon.url}
|
|
70
|
+
variant={ImageVariant.Contain}
|
|
71
|
+
width={previewSize}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export default IconSelector;
|
|
@@ -51,6 +51,10 @@ const getSkeleton = cond<Array<InputType>, JSX.Element>([
|
|
|
51
51
|
[
|
|
52
52
|
equals(InputType.Custom) as (b: InputType) => boolean,
|
|
53
53
|
always(<LoadingSkeleton height={52} />)
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
equals(InputType.IconSelector) as (b: InputType) => boolean,
|
|
57
|
+
always(<LoadingSkeleton height={52} />)
|
|
54
58
|
]
|
|
55
59
|
]);
|
|
56
60
|
|
|
@@ -38,6 +38,7 @@ import ExclusiveCheckboxGroup from './ExclusiveCheckboxGroup';
|
|
|
38
38
|
import FieldsTable from './FieldsTable/FieldsTable';
|
|
39
39
|
import File from './File';
|
|
40
40
|
import Grid from './Grid';
|
|
41
|
+
import IconSelector from './IconSelector';
|
|
41
42
|
import List from './List/List';
|
|
42
43
|
import LoadingSkeleton from './LoadingSkeleton';
|
|
43
44
|
import {
|
|
@@ -93,6 +94,10 @@ export const getInput = cond<
|
|
|
93
94
|
equals(InputType.SegmentedButtons) as (b: InputType) => boolean,
|
|
94
95
|
always(SegmentedButtons)
|
|
95
96
|
],
|
|
97
|
+
[
|
|
98
|
+
equals(InputType.IconSelector) as (b: InputType) => boolean,
|
|
99
|
+
always(IconSelector)
|
|
100
|
+
],
|
|
96
101
|
[equals(InputType.List) as (b: InputType) => boolean, always(List)],
|
|
97
102
|
[equals(InputType.File) as (b: InputType) => boolean, always(File)],
|
|
98
103
|
[equals(InputType.Text) as (b: InputType) => boolean, always(TextInput)],
|
|
@@ -5,6 +5,7 @@ import type { JsonDecoder } from 'ts.data.json';
|
|
|
5
5
|
|
|
6
6
|
import type { ConditionsSearchParameter } from '../../api/buildListingEndpoint/models';
|
|
7
7
|
import type { SelectEntry } from '../../InputField/Select';
|
|
8
|
+
import type { GetEndpointParams } from '../../InputField/Select/Autocomplete/Connected';
|
|
8
9
|
import type { QueryParameter } from '../../queryParameters/models';
|
|
9
10
|
|
|
10
11
|
export interface ChangeArgs {
|
|
@@ -52,7 +53,8 @@ export enum InputType {
|
|
|
52
53
|
File = 14,
|
|
53
54
|
Divider = 15,
|
|
54
55
|
ExclusiveCheckboxGroup = 16,
|
|
55
|
-
SegmentedButtons = 17
|
|
56
|
+
SegmentedButtons = 17,
|
|
57
|
+
IconSelector = 18
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
interface FieldsTableGetRequiredProps {
|
|
@@ -155,6 +157,10 @@ export interface InputProps {
|
|
|
155
157
|
};
|
|
156
158
|
group: string;
|
|
157
159
|
hideInput?: (values: FormikValues) => boolean;
|
|
160
|
+
// `InputType.IconSelector`: the listing of the images to pick from.
|
|
161
|
+
iconSelector?: {
|
|
162
|
+
getEndpoint: (parameters: GetEndpointParams) => string;
|
|
163
|
+
};
|
|
158
164
|
inputClassName?: string;
|
|
159
165
|
label: string;
|
|
160
166
|
list?: {
|
|
@@ -82,6 +82,26 @@ const useStyles = makeStyles<StyleProps>()(
|
|
|
82
82
|
})
|
|
83
83
|
);
|
|
84
84
|
|
|
85
|
+
const defaultRowsPerPageOptions = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
|
86
|
+
|
|
87
|
+
// The page size can be any number (the platform setting is free), so it is
|
|
88
|
+
// added to the options when missing, for the selector to show it. `Number`
|
|
89
|
+
// because `onLimitChange` is typed `string | number`, so a consumer may store a
|
|
90
|
+
// string limit.
|
|
91
|
+
const getRowsPerPageOptions = (limit: number | string): Array<number> => {
|
|
92
|
+
const rowsPerPage = Number(limit);
|
|
93
|
+
|
|
94
|
+
if (
|
|
95
|
+
!Number.isInteger(rowsPerPage) ||
|
|
96
|
+
rowsPerPage <= 0 ||
|
|
97
|
+
defaultRowsPerPageOptions.includes(rowsPerPage)
|
|
98
|
+
) {
|
|
99
|
+
return defaultRowsPerPageOptions;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return [...defaultRowsPerPageOptions, rowsPerPage].sort((a, b) => a - b);
|
|
103
|
+
};
|
|
104
|
+
|
|
85
105
|
type Props = Pick<
|
|
86
106
|
ListingProps<unknown>,
|
|
87
107
|
| 'actions'
|
|
@@ -287,7 +307,7 @@ const MemoListingActionBar = ({
|
|
|
287
307
|
onRowsPerPageChange={changeRowPerPage}
|
|
288
308
|
page={currentPage ?? 0}
|
|
289
309
|
rowsPerPage={limit ?? 10}
|
|
290
|
-
rowsPerPageOptions={
|
|
310
|
+
rowsPerPageOptions={getRowsPerPageOptions(limit ?? 10)}
|
|
291
311
|
SelectProps={{
|
|
292
312
|
id: labelRowsPerPage,
|
|
293
313
|
MenuProps: {
|
|
@@ -473,4 +473,100 @@ describe('Listing', () => {
|
|
|
473
473
|
});
|
|
474
474
|
expect(onRowClick).not.toHaveBeenCalled();
|
|
475
475
|
});
|
|
476
|
+
|
|
477
|
+
describe('Rows per page', () => {
|
|
478
|
+
const defaultOptions = [
|
|
479
|
+
'10',
|
|
480
|
+
'20',
|
|
481
|
+
'30',
|
|
482
|
+
'40',
|
|
483
|
+
'50',
|
|
484
|
+
'60',
|
|
485
|
+
'70',
|
|
486
|
+
'80',
|
|
487
|
+
'90',
|
|
488
|
+
'100'
|
|
489
|
+
];
|
|
490
|
+
|
|
491
|
+
const openRowsPerPage = async (limit: number): Promise<HTMLElement> => {
|
|
492
|
+
render(
|
|
493
|
+
<Listing
|
|
494
|
+
columns={defaultColumn}
|
|
495
|
+
currentPage={0}
|
|
496
|
+
limit={limit}
|
|
497
|
+
rows={listingWithSubItems3Rows}
|
|
498
|
+
totalRows={3}
|
|
499
|
+
/>
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
const rowsPerPage = within(
|
|
503
|
+
await screen.findByTestId('Listing Pagination')
|
|
504
|
+
).getByRole('combobox');
|
|
505
|
+
|
|
506
|
+
expect(rowsPerPage).toHaveTextContent(new RegExp(`^${limit}$`));
|
|
507
|
+
|
|
508
|
+
await userEvent.click(rowsPerPage);
|
|
509
|
+
|
|
510
|
+
return screen.findByRole('listbox');
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const getOptionLabels = (listbox: HTMLElement): Array<string | null> =>
|
|
514
|
+
within(listbox)
|
|
515
|
+
.getAllByRole('option')
|
|
516
|
+
.map((option) => option.textContent);
|
|
517
|
+
|
|
518
|
+
it.each([
|
|
519
|
+
{
|
|
520
|
+
limit: 25,
|
|
521
|
+
options: [
|
|
522
|
+
'10',
|
|
523
|
+
'20',
|
|
524
|
+
'25',
|
|
525
|
+
'30',
|
|
526
|
+
'40',
|
|
527
|
+
'50',
|
|
528
|
+
'60',
|
|
529
|
+
'70',
|
|
530
|
+
'80',
|
|
531
|
+
'90',
|
|
532
|
+
'100'
|
|
533
|
+
]
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
limit: 150,
|
|
537
|
+
options: [
|
|
538
|
+
'10',
|
|
539
|
+
'20',
|
|
540
|
+
'30',
|
|
541
|
+
'40',
|
|
542
|
+
'50',
|
|
543
|
+
'60',
|
|
544
|
+
'70',
|
|
545
|
+
'80',
|
|
546
|
+
'90',
|
|
547
|
+
'100',
|
|
548
|
+
'150'
|
|
549
|
+
]
|
|
550
|
+
}
|
|
551
|
+
])(
|
|
552
|
+
'adds the page size $limit to the options when it is not one of them',
|
|
553
|
+
async ({ limit, options }) => {
|
|
554
|
+
const listbox = await openRowsPerPage(limit);
|
|
555
|
+
|
|
556
|
+
expect(getOptionLabels(listbox)).toEqual(options);
|
|
557
|
+
expect(
|
|
558
|
+
within(listbox).getByRole('option', { name: `${limit}` })
|
|
559
|
+
).toHaveAttribute('aria-selected', 'true');
|
|
560
|
+
}
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
it('keeps the default options when the page size is one of them', async () => {
|
|
564
|
+
const listbox = await openRowsPerPage(30);
|
|
565
|
+
|
|
566
|
+
expect(getOptionLabels(listbox)).toEqual(defaultOptions);
|
|
567
|
+
expect(
|
|
568
|
+
within(listbox).getByRole('option', { name: '30' })
|
|
569
|
+
).toHaveAttribute('aria-selected', 'true');
|
|
570
|
+
});
|
|
571
|
+
});
|
|
476
572
|
});
|