@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centreon/ui",
3
- "version": "26.10.6",
3
+ "version": "26.10.8",
4
4
  "description": "Centreon UI Components",
5
5
  "scripts": {
6
6
  "update:deps": "pnpx npm-check-updates -i --format group",
@@ -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 && parameters?.search
56
+ connectedAutocomplete?.useNewAPIFormat && searchedValue
50
57
  ? [
51
58
  {
52
59
  name: 'name[lk]',
53
- value: (
54
- parameters.search.conditions?.[0].values?.$lk as string
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
- <Switch
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
- {elements?.map((element) => (
42
- <Menu.Item
43
- isActive={isActive(element.id)}
44
- isDisabled={isDisabled?.(element.id)}
45
- key={`${element.id}`}
46
- onClick={navigateToElement(element.id)}
47
- >
48
- {element.name}
49
- </Menu.Item>
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: (newPage: number) => ['dashboards', newPage],
117
+ getPrefetchQueryKey: getQueryKey,
114
118
  page
115
119
  });
116
120
  }, [data, limit, page, prefetchNextPage]);