@centreon/ui 26.10.3 → 26.10.5

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.3",
3
+ "version": "26.10.5",
4
4
  "description": "Centreon UI Components",
5
5
  "scripts": {
6
6
  "update:deps": "pnpx npm-check-updates -i --format group",
@@ -1,4 +1,7 @@
1
+ import { object } from 'yup';
2
+
1
3
  import { Form, GroupDirection } from './Form';
4
+ import { InputType } from './Inputs/models';
2
5
  import {
3
6
  type BasicForm,
4
7
  basicFormGroups,
@@ -55,3 +58,55 @@ export const basicFormWithHorizontalGroups = (): JSX.Element => (
55
58
  groups={basicFormGroups.filter((group) => group.order !== 3)}
56
59
  />
57
60
  );
61
+
62
+ export const exclusiveCheckboxGroup = (): JSX.Element => (
63
+ <Form
64
+ initialValues={{
65
+ hostNotificationOptions: ['Down', 'Recovery'],
66
+ serviceNotificationOptions: ['None']
67
+ }}
68
+ inputs={[
69
+ {
70
+ exclusiveCheckboxGroup: {
71
+ direction: 'horizontal',
72
+ exclusiveLabel: 'No notifications',
73
+ exclusiveOption: 'None',
74
+ options: [
75
+ 'Down',
76
+ 'Unreachable',
77
+ 'Recovery',
78
+ 'Flapping',
79
+ 'Downtime Scheduled',
80
+ 'None'
81
+ ]
82
+ },
83
+ fieldName: 'hostNotificationOptions',
84
+ group: '',
85
+ label: 'Host notification options',
86
+ type: InputType.ExclusiveCheckboxGroup
87
+ },
88
+ {
89
+ exclusiveCheckboxGroup: {
90
+ direction: 'horizontal',
91
+ exclusiveLabel: 'No notifications',
92
+ exclusiveOption: 'None',
93
+ options: [
94
+ 'Warning',
95
+ 'Unknown',
96
+ 'Critical',
97
+ 'Recovery',
98
+ 'Flapping',
99
+ 'Downtime Scheduled',
100
+ 'None'
101
+ ]
102
+ },
103
+ fieldName: 'serviceNotificationOptions',
104
+ group: '',
105
+ label: 'Service notification options',
106
+ type: InputType.ExclusiveCheckboxGroup
107
+ }
108
+ ]}
109
+ submit={submit}
110
+ validationSchema={object()}
111
+ />
112
+ );
@@ -29,11 +29,7 @@ const CheckboxGroup = ({
29
29
  if (!disabled && !hideCheckbox) {
30
30
  return;
31
31
  }
32
- const resetedValue = (value ?? []).map((element) => ({
33
- ...(element as unknown as Record<string, unknown>),
34
- checked: false
35
- }));
36
- setFieldValue(fieldName, resetedValue);
32
+ setFieldValue(fieldName, []);
37
33
  }, [disabled, hideCheckbox, fieldName, setFieldValue]);
38
34
 
39
35
  const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
@@ -0,0 +1,174 @@
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 = ['Down', 'Unreachable', 'Recovery', 'None'];
10
+
11
+ const ValueDisplay = (): JSX.Element => {
12
+ const { values } = useFormikContext<{
13
+ notificationOptions: Array<string> | null;
14
+ }>();
15
+
16
+ return (
17
+ <Typography data-testid="value">
18
+ {JSON.stringify(values.notificationOptions)}
19
+ </Typography>
20
+ );
21
+ };
22
+
23
+ const initialize = ({
24
+ notificationOptions = [],
25
+ disabled = false
26
+ }: {
27
+ disabled?: boolean;
28
+ notificationOptions?: Array<string> | null;
29
+ } = {}): void => {
30
+ cy.mount({
31
+ Component: (
32
+ <Form
33
+ initialValues={{ notificationOptions }}
34
+ inputs={[
35
+ {
36
+ dataTestId: 'notification-options',
37
+ exclusiveCheckboxGroup: {
38
+ direction: 'horizontal',
39
+ exclusiveLabel: 'No notifications',
40
+ exclusiveOption: 'None',
41
+ options
42
+ },
43
+ fieldName: 'notificationOptions',
44
+ getDisabled: () => disabled,
45
+ group: '',
46
+ label: 'Notification options',
47
+ type: InputType.ExclusiveCheckboxGroup
48
+ },
49
+ {
50
+ custom: { Component: ValueDisplay },
51
+ fieldName: 'value',
52
+ group: '',
53
+ label: 'value',
54
+ type: InputType.Custom
55
+ }
56
+ ]}
57
+ submit={cy.stub()}
58
+ validationSchema={object()}
59
+ />
60
+ )
61
+ });
62
+ };
63
+
64
+ const toggle = (): Cypress.Chainable => cy.findByLabelText('No notifications');
65
+
66
+ const expectValue = (value: Array<string> | null): void => {
67
+ cy.findByTestId('value').should('have.text', JSON.stringify(value));
68
+ };
69
+
70
+ describe('Exclusive checkbox group', () => {
71
+ it('displays the toggle and every option except the exclusive one', () => {
72
+ initialize();
73
+
74
+ toggle().should('not.be.checked');
75
+ cy.findByLabelText('Down').should('exist');
76
+ cy.findByLabelText('Unreachable').should('exist');
77
+ cy.findByLabelText('Recovery').should('exist');
78
+ cy.findByLabelText('None').should('not.exist');
79
+
80
+ cy.makeSnapshot();
81
+ });
82
+
83
+ it('keeps the inherited state when nothing is selected', () => {
84
+ initialize();
85
+
86
+ toggle().should('not.be.checked');
87
+ cy.findByLabelText('Down').should('not.be.checked').and('be.enabled');
88
+ expectValue([]);
89
+ });
90
+
91
+ it('treats a null value as the inherited state', () => {
92
+ initialize({ notificationOptions: null });
93
+
94
+ toggle().should('not.be.checked');
95
+ cy.findByLabelText('Down').should('not.be.checked').and('be.enabled');
96
+ });
97
+
98
+ it('displays the exclusive state from the initial value', () => {
99
+ initialize({ notificationOptions: ['None'] });
100
+
101
+ toggle().should('be.checked');
102
+ cy.findByLabelText('Down').should('not.be.checked').and('be.disabled');
103
+ expectValue(['None']);
104
+
105
+ cy.makeSnapshot();
106
+ });
107
+
108
+ it('displays a selection from the initial value', () => {
109
+ initialize({ notificationOptions: ['Down', 'Recovery'] });
110
+
111
+ toggle().should('not.be.checked');
112
+ cy.findByLabelText('Down').should('be.checked');
113
+ cy.findByLabelText('Unreachable').should('not.be.checked');
114
+ cy.findByLabelText('Recovery').should('be.checked');
115
+ expectValue(['Down', 'Recovery']);
116
+ });
117
+
118
+ it('goes from the inherited state to a selection and back', () => {
119
+ initialize();
120
+
121
+ cy.findByLabelText('Down').click();
122
+ cy.findByLabelText('Recovery').click();
123
+ expectValue(['Down', 'Recovery']);
124
+
125
+ cy.findByLabelText('Down').click();
126
+ cy.findByLabelText('Recovery').click();
127
+ expectValue([]);
128
+ toggle().should('not.be.checked');
129
+ });
130
+
131
+ it('goes from the inherited state to the exclusive state and back', () => {
132
+ initialize();
133
+
134
+ toggle().click();
135
+ toggle().should('be.checked');
136
+ expectValue(['None']);
137
+
138
+ toggle().click();
139
+ toggle().should('not.be.checked');
140
+ expectValue([]);
141
+ });
142
+
143
+ it('clears the selection when the toggle is turned on and leaves it empty when turned off', () => {
144
+ initialize({ notificationOptions: ['Down', 'Recovery'] });
145
+
146
+ toggle().click();
147
+ expectValue(['None']);
148
+ cy.findByLabelText('Down').should('not.be.checked').and('be.disabled');
149
+ cy.findByLabelText('Recovery').should('not.be.checked').and('be.disabled');
150
+
151
+ toggle().click();
152
+ expectValue([]);
153
+ cy.findByLabelText('Down').should('not.be.checked').and('be.enabled');
154
+
155
+ cy.findByLabelText('Unreachable').click();
156
+ expectValue(['Unreachable']);
157
+ });
158
+
159
+ it('prevents ticking a box while the toggle is on', () => {
160
+ initialize({ notificationOptions: ['None'] });
161
+
162
+ cy.findByLabelText('Down').click({ force: true });
163
+ cy.findByLabelText('Down').should('not.be.checked');
164
+ expectValue(['None']);
165
+ });
166
+
167
+ it('disables both the toggle and the checkboxes when the input is disabled', () => {
168
+ initialize({ disabled: true, notificationOptions: ['Down'] });
169
+
170
+ toggle().should('be.disabled');
171
+ cy.findByLabelText('Down').should('be.checked').and('be.disabled');
172
+ expectValue(['Down']);
173
+ });
174
+ });
@@ -0,0 +1,96 @@
1
+ import { Box } from '@mui/material';
2
+
3
+ import { type FormikValues, useFormikContext } from 'formik';
4
+ import { equals, includes, path, reject, split } from 'ramda';
5
+ import type { ChangeEvent } from 'react';
6
+
7
+ import { useMemoComponent } from '../..';
8
+ import { CheckboxGroup as CheckboxGroupComponent } from '../../Checkbox';
9
+ import {
10
+ type ChangeArgs,
11
+ type InputPropsWithoutGroup,
12
+ InputType
13
+ } from './models';
14
+ import Switch from './Switch';
15
+
16
+ const ExclusiveCheckboxGroup = ({
17
+ exclusiveCheckboxGroup,
18
+ fieldName,
19
+ getDisabled,
20
+ dataTestId
21
+ }: InputPropsWithoutGroup): JSX.Element => {
22
+ const { values, setFieldValue } = useFormikContext<FormikValues>();
23
+
24
+ const fieldNamePath = split('.', fieldName);
25
+
26
+ const value = (path(fieldNamePath, values) as Array<string> | null) ?? [];
27
+
28
+ const exclusiveOption = exclusiveCheckboxGroup?.exclusiveOption as string;
29
+ const options = reject(
30
+ equals(exclusiveOption),
31
+ exclusiveCheckboxGroup?.options ?? []
32
+ );
33
+
34
+ const isExclusive = includes(exclusiveOption, value);
35
+ const disabled = getDisabled?.(values) || false;
36
+
37
+ const changeToggle = ({ value: checked }: ChangeArgs): void => {
38
+ setFieldValue(fieldName, checked ? [exclusiveOption] : []);
39
+ };
40
+
41
+ const changeCheckbox = (event: ChangeEvent<HTMLInputElement>): void => {
42
+ if (disabled || isExclusive) {
43
+ return;
44
+ }
45
+
46
+ const option = event.target.id;
47
+
48
+ setFieldValue(
49
+ fieldName,
50
+ includes(option, value)
51
+ ? reject(equals(option), value)
52
+ : [...value, option]
53
+ );
54
+ };
55
+
56
+ const baseTestId = dataTestId || fieldName;
57
+
58
+ return useMemoComponent({
59
+ Component: (
60
+ <Box sx={{ alignItems: 'center', columnGap: 2, display: 'flex' }}>
61
+ <CheckboxGroupComponent
62
+ dataTestId={baseTestId}
63
+ direction={exclusiveCheckboxGroup?.direction}
64
+ disabled={disabled || isExclusive}
65
+ labelPlacement={exclusiveCheckboxGroup?.labelPlacement || 'end'}
66
+ onChange={changeCheckbox}
67
+ options={options}
68
+ values={isExclusive ? [] : value}
69
+ />
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
+ />
82
+ </Box>
83
+ ),
84
+ memoProps: [
85
+ value,
86
+ disabled,
87
+ options,
88
+ exclusiveOption,
89
+ exclusiveCheckboxGroup?.exclusiveLabel,
90
+ exclusiveCheckboxGroup?.direction,
91
+ exclusiveCheckboxGroup?.labelPlacement
92
+ ]
93
+ });
94
+ };
95
+
96
+ export default ExclusiveCheckboxGroup;
@@ -35,6 +35,7 @@ import Checkbox from './Checkbox';
35
35
  import CheckboxGroup from './CheckboxGroup';
36
36
  import ConnectedAutocomplete from './ConnectedAutocomplete';
37
37
  import Custom from './Custom';
38
+ import ExclusiveCheckboxGroup from './ExclusiveCheckboxGroup';
38
39
  import FieldsTable from './FieldsTable/FieldsTable';
39
40
  import File from './File';
40
41
  import Grid from './Grid';
@@ -84,6 +85,10 @@ export const getInput = cond<
84
85
  equals(InputType.CheckboxGroup) as (b: InputType) => boolean,
85
86
  always(CheckboxGroup)
86
87
  ],
88
+ [
89
+ equals(InputType.ExclusiveCheckboxGroup) as (b: InputType) => boolean,
90
+ always(ExclusiveCheckboxGroup)
91
+ ],
87
92
  [equals(InputType.List) as (b: InputType) => boolean, always(List)],
88
93
  [equals(InputType.File) as (b: InputType) => boolean, always(File)],
89
94
  [equals(InputType.Text) as (b: InputType) => boolean, always(TextInput)],
@@ -50,7 +50,8 @@ export enum InputType {
50
50
  CheckboxGroup = 12,
51
51
  List = 13,
52
52
  File = 14,
53
- Divider = 15
53
+ Divider = 15,
54
+ ExclusiveCheckboxGroup = 16
54
55
  }
55
56
 
56
57
  interface FieldsTableGetRequiredProps {
@@ -98,6 +99,13 @@ export interface InputProps {
98
99
  limitTags?: number;
99
100
  decoder?: JsonDecoder.Decoder<unknown>;
100
101
  };
102
+ exclusiveCheckboxGroup?: {
103
+ direction?: 'horizontal' | 'vertical';
104
+ exclusiveLabel: string;
105
+ exclusiveOption: string;
106
+ labelPlacement?: LabelPlacement;
107
+ options: Array<string>;
108
+ };
101
109
  file?: {
102
110
  multiple?: boolean;
103
111
  accept?: string;
@@ -0,0 +1,117 @@
1
+ import DeleteIcon from '@mui/icons-material/Delete';
2
+ import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
3
+
4
+ import { useSortable } from '@dnd-kit/sortable';
5
+ import { CSS } from '@dnd-kit/utilities';
6
+ import type { ComponentPropsWithRef, ReactNode } from 'react';
7
+ import { useTranslation } from 'react-i18next';
8
+
9
+ import { IconButton } from '../Button';
10
+ import { Tooltip } from '../Tooltip';
11
+ import type { RenderRowParams, RowParams, SortableRowAction } from './models';
12
+ import { labelDelete, labelDragToReorder } from './translatedLabels';
13
+
14
+ type RowButtonProps = ComponentPropsWithRef<typeof IconButton> & {
15
+ label: string;
16
+ };
17
+
18
+ // 30px buttons at a 32px pitch. The icon size is set here because the Tooltip
19
+ // overrides IconButton's own small-size class, and consumer icons may be any size.
20
+ const RowButton = ({
21
+ className = '',
22
+ label,
23
+ ...buttonProps
24
+ }: RowButtonProps): JSX.Element => (
25
+ <Tooltip label={label}>
26
+ <IconButton
27
+ {...buttonProps}
28
+ aria-label={label}
29
+ className={`[&_.MuiSvgIcon-root]:text-xl ${className}`}
30
+ size="small"
31
+ variant="ghost"
32
+ />
33
+ </Tooltip>
34
+ );
35
+
36
+ interface Props<T> {
37
+ actions: Array<SortableRowAction>;
38
+ className?: string;
39
+ draggable: boolean;
40
+ id: string;
41
+ onDelete: (id: string) => void;
42
+ onUpdate: (id: string, update: (value: T) => T) => void;
43
+ renderRow: (params: RenderRowParams<T>) => ReactNode;
44
+ rowParams: RowParams<T>;
45
+ }
46
+
47
+ export const Row = <T,>({
48
+ actions,
49
+ className = '',
50
+ draggable,
51
+ id,
52
+ onDelete,
53
+ onUpdate,
54
+ renderRow,
55
+ rowParams
56
+ }: Props<T>): JSX.Element => {
57
+ const { t } = useTranslation();
58
+ const {
59
+ attributes,
60
+ isDragging,
61
+ listeners,
62
+ setActivatorNodeRef,
63
+ setNodeRef,
64
+ transform,
65
+ transition
66
+ } = useSortable({ disabled: !draggable, id });
67
+
68
+ const setValue = (value: T): void => onUpdate(id, () => value);
69
+ const setField = <K extends keyof T>(field: K, fieldValue: T[K]): void =>
70
+ onUpdate(
71
+ id,
72
+ (value) => ({ ...(value as object), [field]: fieldValue }) as T
73
+ );
74
+
75
+ return (
76
+ <li
77
+ className={`relative flex w-full items-center gap-2 ${isDragging ? 'z-1 opacity-70' : ''} ${className}`}
78
+ data-entry-id={id}
79
+ ref={setNodeRef}
80
+ style={{ transform: CSS.Translate.toString(transform), transition }}
81
+ >
82
+ <div className="min-w-0 flex-1" data-row-fields>
83
+ {renderRow({ ...rowParams, setField, setValue })}
84
+ </div>
85
+ <div className="flex flex-none items-center gap-0.5">
86
+ {actions.map(({ icon, id: actionId, isDisabled, label, onClick }) => (
87
+ <RowButton
88
+ data-testid={actionId}
89
+ disabled={isDisabled}
90
+ icon={icon}
91
+ key={actionId}
92
+ label={t(label)}
93
+ onClick={onClick}
94
+ />
95
+ ))}
96
+ <RowButton
97
+ className="text-error-main"
98
+ data-testid="delete-row"
99
+ icon={<DeleteIcon />}
100
+ label={t(labelDelete)}
101
+ onClick={(): void => onDelete(id)}
102
+ />
103
+ {draggable && (
104
+ <RowButton
105
+ {...attributes}
106
+ {...listeners}
107
+ className="cursor-grab touch-none active:cursor-grabbing"
108
+ data-testid="drag-handle"
109
+ icon={<DragIndicatorIcon />}
110
+ label={t(labelDragToReorder)}
111
+ ref={setActivatorNodeRef}
112
+ />
113
+ )}
114
+ </div>
115
+ </li>
116
+ );
117
+ };