@centreon/ui 26.11.5 → 26.11.7

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.
@@ -0,0 +1,252 @@
1
+ import EditIcon from '@mui/icons-material/Edit';
2
+ import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
3
+ import VisibilityIcon from '@mui/icons-material/Visibility';
4
+ import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
5
+
6
+ import type { Meta, StoryObj } from '@storybook/react';
7
+ import { useState } from 'react';
8
+
9
+ import buildListingEndpoint from '../../api/buildListingEndpoint';
10
+ import type { Listing } from '../../api/models';
11
+ import type { SelectEntry } from '../../InputField/Select';
12
+ import type { GetEndpointParams } from '../../InputField/Select/Autocomplete/Connected';
13
+ import SingleConnectedAutocompleteField from '../../InputField/Select/Autocomplete/Connected/Single';
14
+ import TextField from '../../InputField/Text';
15
+ import type { RenderRowParams } from './models';
16
+ import {
17
+ SortableEntriesList,
18
+ type Props as SortableEntriesListProps
19
+ } from './SortableEntriesList';
20
+
21
+ const meta: Meta<typeof SortableEntriesList> = {
22
+ component: SortableEntriesList
23
+ };
24
+
25
+ export default meta;
26
+ type Story = StoryObj<typeof SortableEntriesList>;
27
+
28
+ const ValuesPreview = ({ values }: { values: Array<unknown> }): JSX.Element => (
29
+ <pre className="mt-4 rounded bg-background-default p-2 text-text-primary text-xs">
30
+ {JSON.stringify(values, null, 2)}
31
+ </pre>
32
+ );
33
+
34
+ // Templates: one connected autocomplete per row, order matters (inheritance)
35
+
36
+ type TemplateRow = SelectEntry | null;
37
+
38
+ const hostTemplateOptions: Array<SelectEntry> = [
39
+ { id: 1, name: 'generic-active-host' },
40
+ { id: 2, name: 'generic-passive-host' },
41
+ { id: 3, name: 'generic-host-custom' },
42
+ { id: 4, name: 'linux-server-standard' },
43
+ { id: 5, name: 'windows-server-standard' }
44
+ ];
45
+
46
+ const templatesEndpoint = '/configuration/hosts/templates';
47
+
48
+ const getTemplatesEndpoint = (parameters: GetEndpointParams): string =>
49
+ buildListingEndpoint({ baseEndpoint: templatesEndpoint, parameters });
50
+
51
+ const templatesMockData = [
52
+ {
53
+ delay: 300,
54
+ method: 'GET',
55
+ response: (): Listing<SelectEntry> => ({
56
+ meta: { limit: 10, page: 1, total: hostTemplateOptions.length },
57
+ result: hostTemplateOptions
58
+ }),
59
+ status: 200,
60
+ url: `${templatesEndpoint}?page=`
61
+ }
62
+ ];
63
+
64
+ const createTemplateRow = (): TemplateRow => null;
65
+
66
+ const renderTemplateRow = ({
67
+ setValue,
68
+ value,
69
+ values
70
+ }: RenderRowParams<TemplateRow>): JSX.Element => {
71
+ // A template already chosen in another row cannot be chosen twice.
72
+ const selectedElsewhere = values
73
+ .map((template) => template?.id)
74
+ .filter((id) => id !== value?.id);
75
+
76
+ return (
77
+ <SingleConnectedAutocompleteField
78
+ baseEndpoint=""
79
+ field="name"
80
+ fullWidth
81
+ getEndpoint={getTemplatesEndpoint}
82
+ getOptionDisabled={(option: SelectEntry): boolean =>
83
+ selectedElsewhere.includes(option.id)
84
+ }
85
+ label="Select a template"
86
+ onChange={(_event, template): void => setValue(template as TemplateRow)}
87
+ value={value}
88
+ />
89
+ );
90
+ };
91
+
92
+ type TemplatesListArgs = Partial<
93
+ Pick<SortableEntriesListProps<TemplateRow>, 'addLabel' | 'draggable'>
94
+ >;
95
+
96
+ const TemplatesList = (args: TemplatesListArgs): JSX.Element => {
97
+ const [templates, setTemplates] = useState<Array<TemplateRow>>([
98
+ hostTemplateOptions[0],
99
+ hostTemplateOptions[3]
100
+ ]);
101
+
102
+ return (
103
+ <div className="w-[640px]">
104
+ <SortableEntriesList<TemplateRow>
105
+ actions={({ value }) => [
106
+ {
107
+ icon: <EditIcon />,
108
+ id: 'edit-template',
109
+ isDisabled: !value,
110
+ label: 'Edit template',
111
+ onClick: () => undefined
112
+ },
113
+ {
114
+ icon: <InfoOutlinedIcon />,
115
+ id: 'template-info',
116
+ isDisabled: !value,
117
+ label: 'Template information',
118
+ onClick: () => undefined
119
+ }
120
+ ]}
121
+ addLabel="Add new entry"
122
+ createValue={createTemplateRow}
123
+ label="Templates"
124
+ onChange={setTemplates}
125
+ renderRow={renderTemplateRow}
126
+ values={templates}
127
+ {...args}
128
+ />
129
+ <ValuesPreview values={templates} />
130
+ </div>
131
+ );
132
+ };
133
+
134
+ export const Templates: Story = {
135
+ args: { draggable: true },
136
+ argTypes: {
137
+ addLabel: { control: 'text' },
138
+ draggable: { control: 'boolean' }
139
+ },
140
+ parameters: { mockData: templatesMockData },
141
+ render: (args) => <TemplatesList {...(args as TemplatesListArgs)} />
142
+ };
143
+
144
+ // Custom macros: several inputs per row, row styled from its own value
145
+
146
+ interface MacroRow {
147
+ isPassword: boolean;
148
+ name: string;
149
+ origin: 'direct' | 'fromCommand' | 'fromTpl';
150
+ value: string;
151
+ }
152
+
153
+ const createMacroRow = (): MacroRow => ({
154
+ isPassword: false,
155
+ name: '',
156
+ origin: 'direct',
157
+ value: ''
158
+ });
159
+
160
+ const macroRowClassName: Record<MacroRow['origin'], string> = {
161
+ direct: 'rounded p-1',
162
+ fromCommand: 'rounded bg-teal-50 p-1 dark:bg-teal-950',
163
+ fromTpl: 'rounded bg-purple-50 p-1 dark:bg-purple-950'
164
+ };
165
+
166
+ const renderMacroRow = ({
167
+ setField,
168
+ value: macro
169
+ }: RenderRowParams<MacroRow>): JSX.Element => (
170
+ <div className="grid grid-cols-2 gap-2">
171
+ <TextField
172
+ dataTestId="macro-name"
173
+ fullWidth
174
+ label="Name"
175
+ onChange={(event): void => setField('name', event.target.value)}
176
+ value={macro.name}
177
+ />
178
+ <TextField
179
+ dataTestId="macro-value"
180
+ fullWidth
181
+ label="Value"
182
+ onChange={(event): void => setField('value', event.target.value)}
183
+ type={macro.isPassword ? 'password' : 'text'}
184
+ value={macro.value}
185
+ />
186
+ </div>
187
+ );
188
+
189
+ const initialMacros: Array<MacroRow> = [
190
+ { isPassword: false, name: 'HOST_IP', origin: 'direct', value: '10.0.0.1' },
191
+ {
192
+ isPassword: true,
193
+ name: 'API_TOKEN',
194
+ origin: 'fromTpl',
195
+ value: 'secret-token'
196
+ },
197
+ { isPassword: false, name: 'PORT', origin: 'fromCommand', value: '443' }
198
+ ];
199
+
200
+ const MacrosList = ({
201
+ draggable,
202
+ initialValues = initialMacros
203
+ }: {
204
+ draggable: boolean;
205
+ initialValues?: Array<MacroRow>;
206
+ }): JSX.Element => {
207
+ const [macros, setMacros] = useState(initialValues);
208
+
209
+ const toggleIsPassword = (index: number): void =>
210
+ setMacros((current) =>
211
+ current.map((macro, macroIndex) =>
212
+ macroIndex === index
213
+ ? { ...macro, isPassword: !macro.isPassword }
214
+ : macro
215
+ )
216
+ );
217
+
218
+ return (
219
+ <div className="w-[640px]">
220
+ <SortableEntriesList<MacroRow>
221
+ actions={({ index, value }) => [
222
+ {
223
+ icon: value.isPassword ? <VisibilityOffIcon /> : <VisibilityIcon />,
224
+ id: 'toggle-password',
225
+ label: value.isPassword ? 'Show value' : 'Hide value',
226
+ onClick: () => toggleIsPassword(index)
227
+ }
228
+ ]}
229
+ createValue={createMacroRow}
230
+ draggable={draggable}
231
+ getRowClassName={({ value }) => macroRowClassName[value.origin]}
232
+ label="Custom macros"
233
+ onChange={setMacros}
234
+ renderRow={renderMacroRow}
235
+ values={macros}
236
+ />
237
+ <ValuesPreview values={macros} />
238
+ </div>
239
+ );
240
+ };
241
+
242
+ export const CustomMacros: Story = {
243
+ render: () => <MacrosList draggable />
244
+ };
245
+
246
+ export const NonDraggable: Story = {
247
+ render: () => <MacrosList draggable={false} />
248
+ };
249
+
250
+ export const Empty: Story = {
251
+ render: () => <MacrosList draggable initialValues={[]} />
252
+ };
@@ -0,0 +1,164 @@
1
+ import {
2
+ type Announcements,
3
+ closestCenter,
4
+ DndContext,
5
+ type DragEndEvent,
6
+ KeyboardSensor,
7
+ type Modifier,
8
+ PointerSensor,
9
+ type UniqueIdentifier,
10
+ useSensor,
11
+ useSensors
12
+ } from '@dnd-kit/core';
13
+ import {
14
+ SortableContext,
15
+ sortableKeyboardCoordinates,
16
+ verticalListSortingStrategy
17
+ } from '@dnd-kit/sortable';
18
+ import type { ReactNode } from 'react';
19
+ import { useTranslation } from 'react-i18next';
20
+
21
+ import { ItemComposition } from '../ItemComposition';
22
+ import type { RenderRowParams, RowParams, SortableRowAction } from './models';
23
+ import { Row } from './Row';
24
+ import {
25
+ labelAdd,
26
+ labelRowDragCancelled,
27
+ labelRowDragInstructions,
28
+ labelRowDropped,
29
+ labelRowMovedOver,
30
+ labelRowPickedUp
31
+ } from './translatedLabels';
32
+ import { useSortableEntries } from './useSortableEntries';
33
+
34
+ export interface Props<T> {
35
+ /** Extra per-row buttons, rendered before the delete button. */
36
+ actions?: (params: RowParams<T>) => Array<SortableRowAction>;
37
+ addLabel?: string;
38
+ /** Initial value of a row added with the add button. */
39
+ createValue: () => T;
40
+ /** Shows the drag handle (last in the row) and enables reordering. */
41
+ draggable?: boolean;
42
+ getRowClassName?: (params: RowParams<T>) => string | undefined;
43
+ /** Accessible name of the list. */
44
+ label?: string;
45
+ onChange: (values: Array<T>) => void;
46
+ /** Renders the inputs of one row. `values` gives access to sibling rows. */
47
+ renderRow: (params: RenderRowParams<T>) => ReactNode;
48
+ values: Array<T>;
49
+ }
50
+
51
+ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
52
+ ...transform,
53
+ x: 0
54
+ });
55
+
56
+ export const SortableEntriesList = <T,>({
57
+ actions,
58
+ addLabel,
59
+ createValue,
60
+ draggable = true,
61
+ getRowClassName,
62
+ label,
63
+ onChange,
64
+ renderRow,
65
+ values
66
+ }: Props<T>): JSX.Element => {
67
+ const { t } = useTranslation();
68
+ const translatedAddLabel = addLabel || t(labelAdd);
69
+
70
+ const {
71
+ addEntry,
72
+ containerRef,
73
+ entries,
74
+ removeEntry,
75
+ reorderEntries,
76
+ updateEntry
77
+ } = useSortableEntries({
78
+ addLabel: translatedAddLabel,
79
+ createValue,
80
+ onChange,
81
+ values
82
+ });
83
+
84
+ const sensors = useSensors(
85
+ useSensor(PointerSensor),
86
+ useSensor(KeyboardSensor, {
87
+ coordinateGetter: sortableKeyboardCoordinates
88
+ })
89
+ );
90
+
91
+ const entryIds = entries.map(({ id }) => id);
92
+
93
+ const getPosition = (id: UniqueIdentifier): number =>
94
+ entryIds.indexOf(id as string) + 1;
95
+
96
+ const announceMove =
97
+ (moveLabel: string) =>
98
+ ({ active, over }: Pick<DragEndEvent, 'active' | 'over'>) =>
99
+ over
100
+ ? t(moveLabel, {
101
+ newPosition: getPosition(over.id),
102
+ position: getPosition(active.id)
103
+ })
104
+ : undefined;
105
+
106
+ const announcements: Announcements = {
107
+ onDragCancel: ({ active }) =>
108
+ t(labelRowDragCancelled, { position: getPosition(active.id) }),
109
+ onDragEnd: announceMove(labelRowDropped),
110
+ onDragOver: announceMove(labelRowMovedOver),
111
+ onDragStart: ({ active }) =>
112
+ t(labelRowPickedUp, { position: getPosition(active.id) })
113
+ };
114
+
115
+ return (
116
+ <div ref={containerRef}>
117
+ <ItemComposition labelAdd={translatedAddLabel} onAddItem={addEntry}>
118
+ {[
119
+ <DndContext
120
+ accessibility={{
121
+ announcements,
122
+ screenReaderInstructions: {
123
+ draggable: t(labelRowDragInstructions)
124
+ }
125
+ }}
126
+ collisionDetection={closestCenter}
127
+ key="sortable-entries"
128
+ modifiers={[restrictToVerticalAxis]}
129
+ onDragEnd={reorderEntries}
130
+ sensors={sensors}
131
+ >
132
+ <SortableContext
133
+ items={entryIds}
134
+ strategy={verticalListSortingStrategy}
135
+ >
136
+ <ul
137
+ aria-label={label}
138
+ className="m-0 flex w-full list-none flex-col gap-2 p-0"
139
+ >
140
+ {entries.map(({ id, value }, index) => {
141
+ const rowParams = { index, value, values };
142
+
143
+ return (
144
+ <Row<T>
145
+ actions={actions?.(rowParams) ?? []}
146
+ className={getRowClassName?.(rowParams)}
147
+ draggable={draggable}
148
+ id={id}
149
+ key={id}
150
+ onDelete={removeEntry}
151
+ onUpdate={updateEntry}
152
+ renderRow={renderRow}
153
+ rowParams={rowParams}
154
+ />
155
+ );
156
+ })}
157
+ </ul>
158
+ </SortableContext>
159
+ </DndContext>
160
+ ]}
161
+ </ItemComposition>
162
+ </div>
163
+ );
164
+ };
@@ -0,0 +1,3 @@
1
+ export type { RenderRowParams, RowParams, SortableRowAction } from './models';
2
+ export type { Props as SortableEntriesListProps } from './SortableEntriesList';
3
+ export { SortableEntriesList } from './SortableEntriesList';
@@ -0,0 +1,20 @@
1
+ import type { ReactElement } from 'react';
2
+
3
+ export interface SortableRowAction {
4
+ icon: ReactElement;
5
+ id: string;
6
+ isDisabled?: boolean;
7
+ label: string;
8
+ onClick: () => void;
9
+ }
10
+
11
+ export interface RowParams<T> {
12
+ index: number;
13
+ value: T;
14
+ values: ReadonlyArray<T>;
15
+ }
16
+
17
+ export interface RenderRowParams<T> extends RowParams<T> {
18
+ setField: <K extends keyof T>(field: K, fieldValue: T[K]) => void;
19
+ setValue: (value: T) => void;
20
+ }
@@ -0,0 +1,12 @@
1
+ export const labelAdd = 'Add';
2
+ export const labelDelete = 'Delete';
3
+ export const labelDragToReorder = 'Drag to reorder';
4
+ export const labelRowDragInstructions =
5
+ 'To pick up a row, press space or enter. Use the arrow keys to move it, then press space or enter to drop it, or escape to cancel.';
6
+ export const labelRowPickedUp = 'Picked up row {{position}}';
7
+ export const labelRowMovedOver =
8
+ 'Row {{position}} moved to position {{newPosition}}';
9
+ export const labelRowDropped =
10
+ 'Row {{position}} dropped at position {{newPosition}}';
11
+ export const labelRowDragCancelled =
12
+ 'Reordering cancelled, row {{position}} returned to its original position';
@@ -0,0 +1,140 @@
1
+ import type { DragEndEvent } from '@dnd-kit/core';
2
+ import { arrayMove } from '@dnd-kit/sortable';
3
+ import {
4
+ type RefObject,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef
9
+ } from 'react';
10
+
11
+ // A value paired with the stable key used for React keys, drag & drop and focus
12
+ // management. Consumers only ever see plain values.
13
+ interface Entry<T> {
14
+ id: string;
15
+ value: T;
16
+ }
17
+
18
+ const generateEntryId = (): string =>
19
+ `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
20
+
21
+ const focusableSelector =
22
+ 'input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';
23
+
24
+ const getRowSelector = (id: string): string =>
25
+ `[data-entry-id="${CSS.escape(id)}"]`;
26
+
27
+ interface Params<T> {
28
+ addLabel: string;
29
+ createValue: () => T;
30
+ onChange: (values: Array<T>) => void;
31
+ values: Array<T>;
32
+ }
33
+
34
+ interface Result<T> {
35
+ addEntry: () => void;
36
+ containerRef: RefObject<HTMLDivElement | null>;
37
+ entries: Array<Entry<T>>;
38
+ removeEntry: (id: string) => void;
39
+ reorderEntries: (event: DragEndEvent) => void;
40
+ updateEntry: (id: string, update: (value: T) => T) => void;
41
+ }
42
+
43
+ export const useSortableEntries = <T>({
44
+ addLabel,
45
+ createValue,
46
+ onChange,
47
+ values
48
+ }: Params<T>): Result<T> => {
49
+ const containerRef = useRef<HTMLDivElement>(null);
50
+ // Latest entries, updated as soon as a change is emitted so that several
51
+ // changes fired before the parent re-renders compose instead of overwriting
52
+ // each other.
53
+ const latestEntriesRef = useRef<Array<Entry<T>>>([]);
54
+ const pendingFocusSelectorRef = useRef<string | null>(null);
55
+
56
+ // Ids are kept by position: after a change emitted here, the latest entries
57
+ // are exactly the emitted ones, so every row keeps its id (and its DOM node)
58
+ // even when the parent stores a copy of the values.
59
+ const entries = useMemo(
60
+ () =>
61
+ values.map((value, index) => ({
62
+ id: latestEntriesRef.current[index]?.id ?? generateEntryId(),
63
+ value
64
+ })),
65
+ [values]
66
+ );
67
+
68
+ useLayoutEffect(() => {
69
+ latestEntriesRef.current = entries;
70
+ }, [entries]);
71
+
72
+ useEffect(() => {
73
+ const selector = pendingFocusSelectorRef.current;
74
+
75
+ if (!selector) {
76
+ return;
77
+ }
78
+
79
+ pendingFocusSelectorRef.current = null;
80
+ containerRef.current?.querySelector<HTMLElement>(selector)?.focus();
81
+ }, [entries]);
82
+
83
+ const commit = (nextEntries: Array<Entry<T>>): void => {
84
+ latestEntriesRef.current = nextEntries;
85
+ onChange(nextEntries.map(({ value }) => value));
86
+ };
87
+
88
+ const updateEntry = (id: string, update: (value: T) => T): void =>
89
+ commit(
90
+ latestEntriesRef.current.map((entry) =>
91
+ entry.id === id ? { ...entry, value: update(entry.value) } : entry
92
+ )
93
+ );
94
+
95
+ const addEntry = (): void => {
96
+ const entry = { id: generateEntryId(), value: createValue() };
97
+
98
+ pendingFocusSelectorRef.current = `${getRowSelector(entry.id)} [data-row-fields] :is(${focusableSelector})`;
99
+ commit([...latestEntriesRef.current, entry]);
100
+ };
101
+
102
+ const removeEntry = (id: string): void => {
103
+ const currentEntries = latestEntriesRef.current;
104
+ const index = currentEntries.findIndex((entry) => entry.id === id);
105
+ // Keep keyboard users in place: focus the row that takes the removed one's
106
+ // position, else the previous row, else the add button.
107
+ const nextFocusedEntry =
108
+ currentEntries[index + 1] ?? currentEntries[index - 1];
109
+
110
+ pendingFocusSelectorRef.current = nextFocusedEntry
111
+ ? `${getRowSelector(nextFocusedEntry.id)} [data-testid="delete-row"]`
112
+ : `button[aria-label="${CSS.escape(addLabel)}"]`;
113
+ commit(currentEntries.filter((entry) => entry.id !== id));
114
+ };
115
+
116
+ const reorderEntries = ({ active, over }: DragEndEvent): void => {
117
+ if (!over || active.id === over.id) {
118
+ return;
119
+ }
120
+
121
+ const currentEntries = latestEntriesRef.current;
122
+
123
+ commit(
124
+ arrayMove(
125
+ currentEntries,
126
+ currentEntries.findIndex(({ id }) => id === active.id),
127
+ currentEntries.findIndex(({ id }) => id === over.id)
128
+ )
129
+ );
130
+ };
131
+
132
+ return {
133
+ addEntry,
134
+ containerRef,
135
+ entries,
136
+ removeEntry,
137
+ reorderEntries,
138
+ updateEntry
139
+ };
140
+ };
@@ -11,6 +11,7 @@ export * from './Layout';
11
11
  export * from './List';
12
12
  export * from './Menu';
13
13
  export * from './Modal';
14
+ export * from './SortableEntriesList';
14
15
  export * from './Tabs';
15
16
  export * from './Tooltip';
16
17
  export type { ZoomState } from './Zoom/models';