@centreon/ui 26.10.6 → 26.10.8
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/ConnectedAutocomplete.tsx +12 -4
- package/src/Form/Inputs/ExclusiveCheckboxGroup.cypress.spec.tsx +64 -2
- package/src/Form/Inputs/ExclusiveCheckboxGroup.tsx +60 -15
- package/src/Form/Inputs/LoadingSkeleton.tsx +4 -0
- package/src/Form/Inputs/SegmentedButtons.cypress.spec.tsx +101 -0
- package/src/Form/Inputs/SegmentedButtons.tsx +71 -0
- package/src/Form/Inputs/index.tsx +5 -0
- package/src/Form/Inputs/models.ts +16 -1
- package/src/InputField/Select/Autocomplete/Connected/index.tsx +3 -0
- package/src/components/Layout/PageLayout/PageQuickAccess.tsx +28 -10
- package/src/components/Layout/PageLayout/translatedLabels.ts +1 -0
- package/src/utils/useInfiniteScrollListing.ts +11 -7
package/package.json
CHANGED
|
@@ -45,14 +45,20 @@ const ConnectedAutocomplete = ({
|
|
|
45
45
|
const isMultiple = equals(type, InputType.MultiConnectedAutocomplete);
|
|
46
46
|
|
|
47
47
|
const getEndpoint = (parameters: GetEndpointParams): string => {
|
|
48
|
+
// Once a value is selected the field prepends a `$ni` condition excluding
|
|
49
|
+
// it, so the typed text is not necessarily the first condition -- and
|
|
50
|
+
// clearing the text leaves no `$lk` condition at all.
|
|
51
|
+
const searchedValue = parameters?.search?.conditions?.find(
|
|
52
|
+
(condition) => condition?.values?.$lk
|
|
53
|
+
)?.values?.$lk as string | undefined;
|
|
54
|
+
|
|
48
55
|
const nameQueryParameters =
|
|
49
|
-
connectedAutocomplete?.useNewAPIFormat &&
|
|
56
|
+
connectedAutocomplete?.useNewAPIFormat && searchedValue
|
|
50
57
|
? [
|
|
51
58
|
{
|
|
52
59
|
name: 'name[lk]',
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
).slice(1, -1)
|
|
60
|
+
// The field wraps the typed text in `%`.
|
|
61
|
+
value: searchedValue.slice(1, -1)
|
|
56
62
|
}
|
|
57
63
|
]
|
|
58
64
|
: [];
|
|
@@ -166,6 +172,7 @@ const ConnectedAutocomplete = ({
|
|
|
166
172
|
return useMemoComponent({
|
|
167
173
|
Component: (
|
|
168
174
|
<TypedAutocompleteField
|
|
175
|
+
baseEndpoint={connectedAutocomplete?.baseEndpoint}
|
|
169
176
|
chipProps={chipProps}
|
|
170
177
|
dataTestId={dataTestId}
|
|
171
178
|
decoder={connectedAutocomplete?.decoder}
|
|
@@ -187,6 +194,7 @@ const ConnectedAutocomplete = ({
|
|
|
187
194
|
onBlur={blur}
|
|
188
195
|
onChange={changeAutocomplete}
|
|
189
196
|
optionProperty={connectedAutocomplete?.optionProperty}
|
|
197
|
+
queryKey={connectedAutocomplete?.queryKey}
|
|
190
198
|
required={isRequired}
|
|
191
199
|
searchConditions={connectedAutocomplete?.additionalConditionParameters}
|
|
192
200
|
value={value ?? null}
|
|
@@ -22,10 +22,12 @@ const ValueDisplay = (): JSX.Element => {
|
|
|
22
22
|
|
|
23
23
|
const initialize = ({
|
|
24
24
|
notificationOptions = [],
|
|
25
|
-
disabled = false
|
|
25
|
+
disabled = false,
|
|
26
|
+
variant
|
|
26
27
|
}: {
|
|
27
28
|
disabled?: boolean;
|
|
28
29
|
notificationOptions?: Array<string> | null;
|
|
30
|
+
variant?: 'checkboxes' | 'chips';
|
|
29
31
|
} = {}): void => {
|
|
30
32
|
cy.mount({
|
|
31
33
|
Component: (
|
|
@@ -38,7 +40,8 @@ const initialize = ({
|
|
|
38
40
|
direction: 'horizontal',
|
|
39
41
|
exclusiveLabel: 'No notifications',
|
|
40
42
|
exclusiveOption: 'None',
|
|
41
|
-
options
|
|
43
|
+
options,
|
|
44
|
+
variant
|
|
42
45
|
},
|
|
43
46
|
fieldName: 'notificationOptions',
|
|
44
47
|
getDisabled: () => disabled,
|
|
@@ -172,3 +175,62 @@ describe('Exclusive checkbox group', () => {
|
|
|
172
175
|
expectValue(['Down']);
|
|
173
176
|
});
|
|
174
177
|
});
|
|
178
|
+
|
|
179
|
+
describe('Exclusive checkbox group, as chips', () => {
|
|
180
|
+
const chip = (option: string): Cypress.Chainable =>
|
|
181
|
+
cy.findByTestId(`notification-options-${option}`);
|
|
182
|
+
|
|
183
|
+
it('displays the options as chips and the exclusive one as the toggle', () => {
|
|
184
|
+
initialize({ variant: 'chips' });
|
|
185
|
+
|
|
186
|
+
['Down', 'Unreachable', 'Recovery'].forEach((option) => {
|
|
187
|
+
chip(option).should('have.attr', 'aria-pressed', 'false');
|
|
188
|
+
});
|
|
189
|
+
chip('None').should('not.exist');
|
|
190
|
+
toggle().should('not.be.checked');
|
|
191
|
+
cy.findByRole('checkbox', { name: 'Down' }).should('not.exist');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('toggles a chip on and off', () => {
|
|
195
|
+
initialize({ variant: 'chips' });
|
|
196
|
+
|
|
197
|
+
chip('Down').click();
|
|
198
|
+
chip('Recovery').click();
|
|
199
|
+
|
|
200
|
+
expectValue(['Down', 'Recovery']);
|
|
201
|
+
chip('Down').should('have.attr', 'aria-pressed', 'true');
|
|
202
|
+
|
|
203
|
+
chip('Down').click();
|
|
204
|
+
|
|
205
|
+
expectValue(['Recovery']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('clears and disables the chips while the toggle is on', () => {
|
|
209
|
+
initialize({ notificationOptions: ['Down', 'Recovery'], variant: 'chips' });
|
|
210
|
+
|
|
211
|
+
toggle().click();
|
|
212
|
+
|
|
213
|
+
expectValue(['None']);
|
|
214
|
+
chip('Down')
|
|
215
|
+
.should('have.attr', 'aria-pressed', 'false')
|
|
216
|
+
.and('have.attr', 'aria-disabled', 'true');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('leaves the chips empty and usable once the toggle is off', () => {
|
|
220
|
+
initialize({ notificationOptions: ['None'], variant: 'chips' });
|
|
221
|
+
|
|
222
|
+
toggle().click();
|
|
223
|
+
|
|
224
|
+
expectValue([]);
|
|
225
|
+
chip('Unreachable').click();
|
|
226
|
+
|
|
227
|
+
expectValue(['Unreachable']);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('disables the chips and the toggle when the input is disabled', () => {
|
|
231
|
+
initialize({ disabled: true, variant: 'chips' });
|
|
232
|
+
|
|
233
|
+
chip('Down').should('have.attr', 'aria-disabled', 'true');
|
|
234
|
+
toggle().should('be.disabled');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { Box } from '@mui/material';
|
|
1
|
+
import { Box, Chip } from '@mui/material';
|
|
2
2
|
|
|
3
3
|
import { type FormikValues, useFormikContext } from 'formik';
|
|
4
4
|
import { equals, includes, path, reject, split } from 'ramda';
|
|
5
5
|
import type { ChangeEvent } from 'react';
|
|
6
|
+
import { useTranslation } from 'react-i18next';
|
|
6
7
|
|
|
7
8
|
import { useMemoComponent } from '../..';
|
|
8
9
|
import { CheckboxGroup as CheckboxGroupComponent } from '../../Checkbox';
|
|
@@ -19,6 +20,7 @@ const ExclusiveCheckboxGroup = ({
|
|
|
19
20
|
getDisabled,
|
|
20
21
|
dataTestId
|
|
21
22
|
}: InputPropsWithoutGroup): JSX.Element => {
|
|
23
|
+
const { t } = useTranslation();
|
|
22
24
|
const { values, setFieldValue } = useFormikContext<FormikValues>();
|
|
23
25
|
|
|
24
26
|
const fieldNamePath = split('.', fieldName);
|
|
@@ -53,10 +55,63 @@ const ExclusiveCheckboxGroup = ({
|
|
|
53
55
|
);
|
|
54
56
|
};
|
|
55
57
|
|
|
58
|
+
const toggleChip = (option: string) => (): void => {
|
|
59
|
+
setFieldValue(
|
|
60
|
+
fieldName,
|
|
61
|
+
includes(option, value)
|
|
62
|
+
? reject(equals(option), value)
|
|
63
|
+
: [...value, option]
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
56
67
|
const baseTestId = dataTestId || fieldName;
|
|
57
68
|
|
|
69
|
+
const isChips = equals(exclusiveCheckboxGroup?.variant, 'chips');
|
|
70
|
+
|
|
71
|
+
const exclusiveToggle = (
|
|
72
|
+
<Switch
|
|
73
|
+
change={changeToggle}
|
|
74
|
+
dataTestId={`${baseTestId}-exclusive`}
|
|
75
|
+
fieldName={fieldName}
|
|
76
|
+
getDisabled={() => disabled}
|
|
77
|
+
label={exclusiveCheckboxGroup?.exclusiveLabel as string}
|
|
78
|
+
switchInput={{
|
|
79
|
+
getChecked: (fieldValue) =>
|
|
80
|
+
includes(exclusiveOption, (fieldValue as Array<string>) ?? [])
|
|
81
|
+
}}
|
|
82
|
+
type={InputType.Switch}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
|
|
58
86
|
return useMemoComponent({
|
|
59
|
-
Component: (
|
|
87
|
+
Component: isChips ? (
|
|
88
|
+
<div
|
|
89
|
+
className="flex flex-wrap items-center gap-x-6 gap-y-3"
|
|
90
|
+
data-testid={baseTestId}
|
|
91
|
+
>
|
|
92
|
+
{options.map((option) => {
|
|
93
|
+
const isSelected = !isExclusive && includes(option, value);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Chip
|
|
97
|
+
aria-pressed={isSelected}
|
|
98
|
+
// The theme sizes chips for status badges, 12px or 20px tall.
|
|
99
|
+
className="h-6 rounded-3xl text-sm [&_.MuiChip-label]:px-3 [&_.MuiChip-label]:leading-[21px]"
|
|
100
|
+
clickable
|
|
101
|
+
color={isSelected ? 'primary' : 'default'}
|
|
102
|
+
data-testid={`${baseTestId}-${option}`}
|
|
103
|
+
disabled={disabled || isExclusive}
|
|
104
|
+
key={option}
|
|
105
|
+
label={t(option)}
|
|
106
|
+
onClick={toggleChip(option)}
|
|
107
|
+
size="small"
|
|
108
|
+
variant={isSelected ? 'filled' : 'outlined'}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
{exclusiveToggle}
|
|
113
|
+
</div>
|
|
114
|
+
) : (
|
|
60
115
|
<Box sx={{ alignItems: 'center', columnGap: 2, display: 'flex' }}>
|
|
61
116
|
<CheckboxGroupComponent
|
|
62
117
|
dataTestId={baseTestId}
|
|
@@ -67,18 +122,7 @@ const ExclusiveCheckboxGroup = ({
|
|
|
67
122
|
options={options}
|
|
68
123
|
values={isExclusive ? [] : value}
|
|
69
124
|
/>
|
|
70
|
-
|
|
71
|
-
change={changeToggle}
|
|
72
|
-
dataTestId={`${baseTestId}-exclusive`}
|
|
73
|
-
fieldName={fieldName}
|
|
74
|
-
getDisabled={() => disabled}
|
|
75
|
-
label={exclusiveCheckboxGroup?.exclusiveLabel as string}
|
|
76
|
-
switchInput={{
|
|
77
|
-
getChecked: (fieldValue) =>
|
|
78
|
-
includes(exclusiveOption, (fieldValue as Array<string>) ?? [])
|
|
79
|
-
}}
|
|
80
|
-
type={InputType.Switch}
|
|
81
|
-
/>
|
|
125
|
+
{exclusiveToggle}
|
|
82
126
|
</Box>
|
|
83
127
|
),
|
|
84
128
|
memoProps: [
|
|
@@ -88,7 +132,8 @@ const ExclusiveCheckboxGroup = ({
|
|
|
88
132
|
exclusiveOption,
|
|
89
133
|
exclusiveCheckboxGroup?.exclusiveLabel,
|
|
90
134
|
exclusiveCheckboxGroup?.direction,
|
|
91
|
-
exclusiveCheckboxGroup?.labelPlacement
|
|
135
|
+
exclusiveCheckboxGroup?.labelPlacement,
|
|
136
|
+
exclusiveCheckboxGroup?.variant
|
|
92
137
|
]
|
|
93
138
|
});
|
|
94
139
|
};
|
|
@@ -12,6 +12,10 @@ const getSkeleton = cond<Array<InputType>, JSX.Element>([
|
|
|
12
12
|
equals(InputType.Radio) as (b: InputType) => boolean,
|
|
13
13
|
always(<LoadingSkeleton height={104} />)
|
|
14
14
|
],
|
|
15
|
+
[
|
|
16
|
+
equals(InputType.SegmentedButtons) as (b: InputType) => boolean,
|
|
17
|
+
always(<LoadingSkeleton height={56} />)
|
|
18
|
+
],
|
|
15
19
|
[
|
|
16
20
|
equals(InputType.Text) as (b: InputType) => boolean,
|
|
17
21
|
always(<LoadingSkeleton height={52} />)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Typography } from '@mui/material';
|
|
2
|
+
|
|
3
|
+
import { useFormikContext } from 'formik';
|
|
4
|
+
import { object } from 'yup';
|
|
5
|
+
|
|
6
|
+
import { Form } from '../Form';
|
|
7
|
+
import { InputType } from './models';
|
|
8
|
+
|
|
9
|
+
const options = [
|
|
10
|
+
{ label: 'Yes', value: 'true' },
|
|
11
|
+
{ label: 'No', value: 'false' },
|
|
12
|
+
{ label: 'Default', value: 'use_default' }
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const ValueDisplay = (): JSX.Element => {
|
|
16
|
+
const { values } = useFormikContext<{ enabled: string }>();
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Typography data-testid="value">
|
|
20
|
+
{JSON.stringify(values.enabled)}
|
|
21
|
+
</Typography>
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const initialize = ({
|
|
26
|
+
enabled = 'use_default',
|
|
27
|
+
disabled = false
|
|
28
|
+
}: {
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
enabled?: string;
|
|
31
|
+
} = {}): void => {
|
|
32
|
+
cy.mount({
|
|
33
|
+
Component: (
|
|
34
|
+
<Form
|
|
35
|
+
initialValues={{ enabled }}
|
|
36
|
+
inputs={[
|
|
37
|
+
{
|
|
38
|
+
dataTestId: 'enabled',
|
|
39
|
+
fieldName: 'enabled',
|
|
40
|
+
getDisabled: () => disabled,
|
|
41
|
+
group: '',
|
|
42
|
+
label: 'Notification Enabled',
|
|
43
|
+
segmentedButtons: { options },
|
|
44
|
+
type: InputType.SegmentedButtons
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
custom: { Component: ValueDisplay },
|
|
48
|
+
fieldName: 'value',
|
|
49
|
+
group: '',
|
|
50
|
+
label: 'value',
|
|
51
|
+
type: InputType.Custom
|
|
52
|
+
}
|
|
53
|
+
]}
|
|
54
|
+
submit={cy.stub()}
|
|
55
|
+
validationSchema={object()}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const button = (label: string): Cypress.Chainable =>
|
|
62
|
+
cy.findByRole('button', { name: label });
|
|
63
|
+
|
|
64
|
+
describe('Segmented buttons', () => {
|
|
65
|
+
it('displays every option and marks the current one', () => {
|
|
66
|
+
initialize();
|
|
67
|
+
|
|
68
|
+
cy.contains('Notification Enabled').should('be.visible');
|
|
69
|
+
button('Yes').should('have.attr', 'aria-pressed', 'false');
|
|
70
|
+
button('No').should('have.attr', 'aria-pressed', 'false');
|
|
71
|
+
button('Default').should('have.attr', 'aria-pressed', 'true');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('stores the value of the clicked option as given', () => {
|
|
75
|
+
initialize();
|
|
76
|
+
|
|
77
|
+
button('Yes').click();
|
|
78
|
+
|
|
79
|
+
// A string, not the boolean the radio would store.
|
|
80
|
+
cy.findByTestId('value').should('have.text', '"true"');
|
|
81
|
+
button('Yes').should('have.attr', 'aria-pressed', 'true');
|
|
82
|
+
button('Default').should('have.attr', 'aria-pressed', 'false');
|
|
83
|
+
|
|
84
|
+
button('No').click();
|
|
85
|
+
|
|
86
|
+
cy.findByTestId('value').should('have.text', '"false"');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('starts on the initial value', () => {
|
|
90
|
+
initialize({ enabled: 'false' });
|
|
91
|
+
|
|
92
|
+
button('No').should('have.attr', 'aria-pressed', 'true');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not change when disabled', () => {
|
|
96
|
+
initialize({ disabled: true });
|
|
97
|
+
|
|
98
|
+
button('Yes').should('be.disabled');
|
|
99
|
+
cy.findByTestId('value').should('have.text', '"use_default"');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Button, ButtonGroup, FormGroup, FormLabel } from '@mui/material';
|
|
2
|
+
|
|
3
|
+
import { type FormikValues, useFormikContext } from 'formik';
|
|
4
|
+
import { equals, path, split } from 'ramda';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
|
|
7
|
+
import { useMemoComponent } from '../..';
|
|
8
|
+
import type { InputPropsWithoutGroup } from './models';
|
|
9
|
+
|
|
10
|
+
const SegmentedButtons = ({
|
|
11
|
+
dataTestId,
|
|
12
|
+
fieldName,
|
|
13
|
+
label,
|
|
14
|
+
segmentedButtons,
|
|
15
|
+
getDisabled,
|
|
16
|
+
additionalMemoProps
|
|
17
|
+
}: InputPropsWithoutGroup): JSX.Element => {
|
|
18
|
+
const { t } = useTranslation();
|
|
19
|
+
|
|
20
|
+
const { values, setFieldValue, setFieldTouched } =
|
|
21
|
+
useFormikContext<FormikValues>();
|
|
22
|
+
|
|
23
|
+
const value = path(split('.', fieldName), values);
|
|
24
|
+
|
|
25
|
+
const disabled = getDisabled?.(values) || false;
|
|
26
|
+
|
|
27
|
+
const select = (optionValue: string) => (): void => {
|
|
28
|
+
setFieldTouched(fieldName, true, false);
|
|
29
|
+
setFieldValue(fieldName, optionValue);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return useMemoComponent({
|
|
33
|
+
Component: (
|
|
34
|
+
<FormGroup>
|
|
35
|
+
<FormLabel>{t(label)}</FormLabel>
|
|
36
|
+
<ButtonGroup
|
|
37
|
+
aria-label={t(label)}
|
|
38
|
+
// As wide as its column, up to what three labels need.
|
|
39
|
+
className="max-w-[412px]"
|
|
40
|
+
color="primary"
|
|
41
|
+
data-testid={dataTestId}
|
|
42
|
+
disabled={disabled}
|
|
43
|
+
fullWidth
|
|
44
|
+
size="small"
|
|
45
|
+
>
|
|
46
|
+
{segmentedButtons?.options.map(
|
|
47
|
+
({ label: optionLabel, value: optionValue }) => {
|
|
48
|
+
const isSelected = equals(value, optionValue);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Button
|
|
52
|
+
aria-pressed={isSelected}
|
|
53
|
+
className="h-7 px-3 py-0 font-medium text-sm leading-[21px] normal-case"
|
|
54
|
+
data-testid={`${dataTestId}-${optionValue}`}
|
|
55
|
+
key={optionValue}
|
|
56
|
+
onClick={select(optionValue)}
|
|
57
|
+
variant={isSelected ? 'contained' : 'outlined'}
|
|
58
|
+
>
|
|
59
|
+
{t(optionLabel)}
|
|
60
|
+
</Button>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
)}
|
|
64
|
+
</ButtonGroup>
|
|
65
|
+
</FormGroup>
|
|
66
|
+
),
|
|
67
|
+
memoProps: [value, disabled, segmentedButtons, additionalMemoProps]
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export default SegmentedButtons;
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
InputType
|
|
49
49
|
} from './models';
|
|
50
50
|
import RadioInput from './Radio';
|
|
51
|
+
import SegmentedButtons from './SegmentedButtons';
|
|
51
52
|
import { SubgroupDivider } from './SubGroupDivider';
|
|
52
53
|
import SwitchInput from './Switch';
|
|
53
54
|
import TextInput from './Text';
|
|
@@ -89,6 +90,10 @@ export const getInput = cond<
|
|
|
89
90
|
equals(InputType.ExclusiveCheckboxGroup) as (b: InputType) => boolean,
|
|
90
91
|
always(ExclusiveCheckboxGroup)
|
|
91
92
|
],
|
|
93
|
+
[
|
|
94
|
+
equals(InputType.SegmentedButtons) as (b: InputType) => boolean,
|
|
95
|
+
always(SegmentedButtons)
|
|
96
|
+
],
|
|
92
97
|
[equals(InputType.List) as (b: InputType) => boolean, always(List)],
|
|
93
98
|
[equals(InputType.File) as (b: InputType) => boolean, always(File)],
|
|
94
99
|
[equals(InputType.Text) as (b: InputType) => boolean, always(TextInput)],
|
|
@@ -51,7 +51,8 @@ export enum InputType {
|
|
|
51
51
|
List = 13,
|
|
52
52
|
File = 14,
|
|
53
53
|
Divider = 15,
|
|
54
|
-
ExclusiveCheckboxGroup = 16
|
|
54
|
+
ExclusiveCheckboxGroup = 16,
|
|
55
|
+
SegmentedButtons = 17
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
interface FieldsTableGetRequiredProps {
|
|
@@ -88,6 +89,12 @@ export interface InputProps {
|
|
|
88
89
|
useNewAPIFormat?: boolean;
|
|
89
90
|
additionalConditionParameters: Array<ConditionsSearchParameter>;
|
|
90
91
|
customQueryParameters: Array<QueryParameter>;
|
|
92
|
+
// The API the options are read from. Left out, the request goes to the
|
|
93
|
+
// default `./api/latest` as it always has.
|
|
94
|
+
baseEndpoint?: string;
|
|
95
|
+
// What the options are cached under. Left out, the field's label is used,
|
|
96
|
+
// which two fields reading different endpoints can share.
|
|
97
|
+
queryKey?: string;
|
|
91
98
|
chipColor?: string;
|
|
92
99
|
endpoint?: string;
|
|
93
100
|
filterKey?: string;
|
|
@@ -105,6 +112,8 @@ export interface InputProps {
|
|
|
105
112
|
exclusiveOption: string;
|
|
106
113
|
labelPlacement?: LabelPlacement;
|
|
107
114
|
options: Array<string>;
|
|
115
|
+
// The options as chips instead of checkboxes; the exclusive one stays a switch.
|
|
116
|
+
variant?: 'checkboxes' | 'chips';
|
|
108
117
|
};
|
|
109
118
|
file?: {
|
|
110
119
|
multiple?: boolean;
|
|
@@ -161,6 +170,12 @@ export interface InputProps {
|
|
|
161
170
|
row?: boolean;
|
|
162
171
|
};
|
|
163
172
|
required?: boolean;
|
|
173
|
+
segmentedButtons?: {
|
|
174
|
+
options: Array<{
|
|
175
|
+
label: string;
|
|
176
|
+
value: string;
|
|
177
|
+
}>;
|
|
178
|
+
};
|
|
164
179
|
switchInput?: {
|
|
165
180
|
getChecked?: (value: unknown) => boolean;
|
|
166
181
|
};
|
|
@@ -126,8 +126,11 @@ const ConnectedAutocompleteField = (
|
|
|
126
126
|
search: searchParameter
|
|
127
127
|
});
|
|
128
128
|
},
|
|
129
|
+
// Two fields can carry the same label and read different APIs, and
|
|
130
|
+
// would otherwise share each other's options.
|
|
129
131
|
getQueryKey: () => [
|
|
130
132
|
`autocomplete-${queryKey || props.label}`,
|
|
133
|
+
baseEndpoint,
|
|
131
134
|
page,
|
|
132
135
|
searchParameter
|
|
133
136
|
],
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import AddIcon from '@mui/icons-material/Add';
|
|
2
2
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
3
|
+
import { CircularProgress } from '@mui/material';
|
|
3
4
|
|
|
4
5
|
import { useTranslation } from 'react-i18next';
|
|
5
6
|
|
|
6
7
|
import { Button, Menu } from '../..';
|
|
8
|
+
import { labelLoading } from './translatedLabels';
|
|
7
9
|
|
|
8
10
|
interface NamedEntity {
|
|
9
11
|
id: number | string;
|
|
@@ -16,10 +18,13 @@ type Props = {
|
|
|
16
18
|
goBack: () => void;
|
|
17
19
|
isActive: (id: number | string) => boolean;
|
|
18
20
|
isDisabled?: (id: number | string) => boolean;
|
|
21
|
+
isLoading?: boolean;
|
|
19
22
|
labels: {
|
|
20
23
|
create: string;
|
|
21
24
|
goBack: string;
|
|
22
25
|
};
|
|
26
|
+
// Observed at the bottom of the list to load the next page when it is reached
|
|
27
|
+
loadMoreRef?: (node: Element | null) => void;
|
|
23
28
|
navigateToElement: (id: number | string) => () => void;
|
|
24
29
|
};
|
|
25
30
|
|
|
@@ -27,6 +32,8 @@ export const PageQuickAccess = ({
|
|
|
27
32
|
elements,
|
|
28
33
|
isActive,
|
|
29
34
|
isDisabled,
|
|
35
|
+
isLoading,
|
|
36
|
+
loadMoreRef,
|
|
30
37
|
navigateToElement,
|
|
31
38
|
goBack,
|
|
32
39
|
create,
|
|
@@ -38,16 +45,27 @@ export const PageQuickAccess = ({
|
|
|
38
45
|
<Menu>
|
|
39
46
|
<Menu.Button data-testid="quickaccess" />
|
|
40
47
|
<Menu.Items>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
<div
|
|
49
|
+
className="max-h-[50vh] overflow-y-auto"
|
|
50
|
+
data-testid="quickaccess-elements"
|
|
51
|
+
>
|
|
52
|
+
{elements?.map((element) => (
|
|
53
|
+
<Menu.Item
|
|
54
|
+
isActive={isActive(element.id)}
|
|
55
|
+
isDisabled={isDisabled?.(element.id)}
|
|
56
|
+
key={`${element.id}`}
|
|
57
|
+
onClick={navigateToElement(element.id)}
|
|
58
|
+
>
|
|
59
|
+
{element.name}
|
|
60
|
+
</Menu.Item>
|
|
61
|
+
))}
|
|
62
|
+
{loadMoreRef && <div className="h-px" ref={loadMoreRef} />}
|
|
63
|
+
{isLoading && (
|
|
64
|
+
<div className="flex justify-center py-2">
|
|
65
|
+
<CircularProgress aria-label={t(labelLoading)} size={20} />
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
51
69
|
<Menu.Divider key="divider" />
|
|
52
70
|
<div className="px-2 pb-2 flex gap-4">
|
|
53
71
|
<Button
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const labelLoading = 'Loading...';
|
|
@@ -49,6 +49,15 @@ export const useInfiniteScrollListing = <T>({
|
|
|
49
49
|
|
|
50
50
|
const [page, setPage] = useAtom(pageAtom);
|
|
51
51
|
|
|
52
|
+
// Shared by the active query and the next page prefetch, so that the
|
|
53
|
+
// prefetched page is reused instead of being requested again
|
|
54
|
+
const getQueryKey = (queryPage: number): Array<string | number> => [
|
|
55
|
+
queryKeyName,
|
|
56
|
+
queryPage,
|
|
57
|
+
JSON.stringify(parameters),
|
|
58
|
+
JSON.stringify(customQueryParameters)
|
|
59
|
+
];
|
|
60
|
+
|
|
52
61
|
const { data, isLoading, prefetchNextPage, fetchStatus } = useFetchQuery<
|
|
53
62
|
Listing<T>
|
|
54
63
|
>({
|
|
@@ -59,12 +68,7 @@ export const useInfiniteScrollListing = <T>({
|
|
|
59
68
|
customQueryParameters,
|
|
60
69
|
parameters: { limit, page: params?.page || page, ...parameters }
|
|
61
70
|
}),
|
|
62
|
-
getQueryKey: () =>
|
|
63
|
-
queryKeyName,
|
|
64
|
-
page,
|
|
65
|
-
JSON.stringify(parameters),
|
|
66
|
-
JSON.stringify(customQueryParameters)
|
|
67
|
-
],
|
|
71
|
+
getQueryKey: () => getQueryKey(page),
|
|
68
72
|
isPaginated: true,
|
|
69
73
|
queryOptions: {
|
|
70
74
|
enabled,
|
|
@@ -110,7 +114,7 @@ export const useInfiniteScrollListing = <T>({
|
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
prefetchNextPage({
|
|
113
|
-
getPrefetchQueryKey:
|
|
117
|
+
getPrefetchQueryKey: getQueryKey,
|
|
114
118
|
page
|
|
115
119
|
});
|
|
116
120
|
}, [data, limit, page, prefetchNextPage]);
|