@centreon/ui 26.10.4 → 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 +1 -1
- package/src/components/SortableEntriesList/Row.tsx +117 -0
- package/src/components/SortableEntriesList/SortableEntriesList.cypress.spec.tsx +506 -0
- package/src/components/SortableEntriesList/SortableEntriesList.stories.tsx +252 -0
- package/src/components/SortableEntriesList/SortableEntriesList.tsx +164 -0
- package/src/components/SortableEntriesList/index.ts +3 -0
- package/src/components/SortableEntriesList/models.ts +20 -0
- package/src/components/SortableEntriesList/translatedLabels.ts +12 -0
- package/src/components/SortableEntriesList/useSortableEntries.ts +140 -0
- package/src/components/index.ts +1 -0
package/package.json
CHANGED
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import EditIcon from '@mui/icons-material/Edit';
|
|
2
|
+
import VisibilityIcon from '@mui/icons-material/Visibility';
|
|
3
|
+
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { TestQueryProvider } from '../..';
|
|
7
|
+
import buildListingEndpoint from '../../api/buildListingEndpoint';
|
|
8
|
+
import type { SelectEntry } from '../../InputField/Select';
|
|
9
|
+
import SingleConnectedAutocompleteField from '../../InputField/Select/Autocomplete/Connected/Single';
|
|
10
|
+
import TextField from '../../InputField/Text';
|
|
11
|
+
import type { RenderRowParams, RowParams, SortableRowAction } from './models';
|
|
12
|
+
import { type Props, SortableEntriesList } from './SortableEntriesList';
|
|
13
|
+
|
|
14
|
+
type Stub = ReturnType<typeof cy.stub>;
|
|
15
|
+
|
|
16
|
+
const ControlledList = <T,>({
|
|
17
|
+
initialValues,
|
|
18
|
+
onChangeSpy,
|
|
19
|
+
...props
|
|
20
|
+
}: Omit<Props<T>, 'values' | 'onChange'> & {
|
|
21
|
+
initialValues: Array<T>;
|
|
22
|
+
onChangeSpy: Stub;
|
|
23
|
+
}): JSX.Element => {
|
|
24
|
+
const [values, setValues] = useState(initialValues);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<SortableEntriesList<T>
|
|
28
|
+
{...props}
|
|
29
|
+
onChange={(nextValues): void => {
|
|
30
|
+
onChangeSpy(nextValues);
|
|
31
|
+
setValues(nextValues);
|
|
32
|
+
}}
|
|
33
|
+
values={values}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const getLastValues = <T,>(onChange: Stub): Array<T> =>
|
|
39
|
+
onChange.lastCall.args[0];
|
|
40
|
+
|
|
41
|
+
const getRows = (): Cypress.Chainable => cy.findAllByRole('listitem');
|
|
42
|
+
|
|
43
|
+
const endpoint = '/configuration/hosts/templates';
|
|
44
|
+
|
|
45
|
+
const options: Array<SelectEntry> = [
|
|
46
|
+
{ id: 1, name: 'generic-active-host' },
|
|
47
|
+
{ id: 2, name: 'generic-passive-host' },
|
|
48
|
+
{ id: 3, name: 'linux-server-standard' }
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
type TemplateRow = SelectEntry | null;
|
|
52
|
+
|
|
53
|
+
const renderTemplateRow = ({
|
|
54
|
+
setValue,
|
|
55
|
+
value,
|
|
56
|
+
values
|
|
57
|
+
}: RenderRowParams<TemplateRow>): JSX.Element => {
|
|
58
|
+
const selectedElsewhere = values
|
|
59
|
+
.map((template) => template?.id)
|
|
60
|
+
.filter((id) => id !== value?.id);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<SingleConnectedAutocompleteField
|
|
64
|
+
baseEndpoint=""
|
|
65
|
+
field="name"
|
|
66
|
+
fullWidth
|
|
67
|
+
getEndpoint={(parameters): string =>
|
|
68
|
+
buildListingEndpoint({ baseEndpoint: endpoint, parameters })
|
|
69
|
+
}
|
|
70
|
+
getOptionDisabled={(option: SelectEntry): boolean =>
|
|
71
|
+
selectedElsewhere.includes(option.id)
|
|
72
|
+
}
|
|
73
|
+
label="Template"
|
|
74
|
+
onChange={(_event, template): void => setValue(template as TemplateRow)}
|
|
75
|
+
value={value}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const initializeTemplates = ({
|
|
81
|
+
draggable = true,
|
|
82
|
+
templates = [options[0], options[1]]
|
|
83
|
+
}: {
|
|
84
|
+
draggable?: boolean;
|
|
85
|
+
templates?: Array<TemplateRow>;
|
|
86
|
+
} = {}): { onChange: Stub; onEdit: Stub } => {
|
|
87
|
+
const onChange = cy.stub();
|
|
88
|
+
const onEdit = cy.stub();
|
|
89
|
+
|
|
90
|
+
cy.intercept('GET', `**${endpoint}**`, {
|
|
91
|
+
body: {
|
|
92
|
+
meta: { limit: 10, page: 1, total: options.length },
|
|
93
|
+
result: options
|
|
94
|
+
},
|
|
95
|
+
statusCode: 200
|
|
96
|
+
}).as('getTemplates');
|
|
97
|
+
|
|
98
|
+
cy.mount({
|
|
99
|
+
Component: (
|
|
100
|
+
<TestQueryProvider>
|
|
101
|
+
<div className="p-4">
|
|
102
|
+
<ControlledList<TemplateRow>
|
|
103
|
+
actions={({
|
|
104
|
+
index,
|
|
105
|
+
value
|
|
106
|
+
}: RowParams<TemplateRow>): Array<SortableRowAction> => [
|
|
107
|
+
{
|
|
108
|
+
icon: <EditIcon />,
|
|
109
|
+
id: 'edit-template',
|
|
110
|
+
label: 'Edit template',
|
|
111
|
+
onClick: () => onEdit(value, index)
|
|
112
|
+
}
|
|
113
|
+
]}
|
|
114
|
+
createValue={(): TemplateRow => null}
|
|
115
|
+
draggable={draggable}
|
|
116
|
+
initialValues={templates}
|
|
117
|
+
label="Templates"
|
|
118
|
+
onChangeSpy={onChange}
|
|
119
|
+
renderRow={renderTemplateRow}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
</TestQueryProvider>
|
|
123
|
+
)
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return { onChange, onEdit };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
describe('SortableEntriesList - single input rows', () => {
|
|
130
|
+
it('displays one row per value with its value, its actions, a delete button and a trailing drag handle', () => {
|
|
131
|
+
initializeTemplates();
|
|
132
|
+
|
|
133
|
+
cy.findByRole('list', { name: 'Templates' }).should('exist');
|
|
134
|
+
getRows().should('have.length', 2);
|
|
135
|
+
cy.get('[data-testid="Template"]')
|
|
136
|
+
.eq(0)
|
|
137
|
+
.should('have.value', options[0].name);
|
|
138
|
+
cy.get('[data-testid="Template"]')
|
|
139
|
+
.eq(1)
|
|
140
|
+
.should('have.value', options[1].name);
|
|
141
|
+
cy.findAllByTestId('edit-template').should('have.length', 2);
|
|
142
|
+
cy.findAllByTestId('delete-row').should('have.length', 2);
|
|
143
|
+
|
|
144
|
+
getRows()
|
|
145
|
+
.eq(0)
|
|
146
|
+
.find('button')
|
|
147
|
+
.last()
|
|
148
|
+
.should('have.attr', 'data-testid', 'drag-handle');
|
|
149
|
+
|
|
150
|
+
cy.makeSnapshot();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('reports the plain values, without any internal id, when an option is selected', () => {
|
|
154
|
+
const { onChange } = initializeTemplates();
|
|
155
|
+
|
|
156
|
+
cy.get('[data-testid="Template"]').eq(1).click();
|
|
157
|
+
cy.wait('@getTemplates');
|
|
158
|
+
cy.findByRole('option', { name: options[2].name }).click();
|
|
159
|
+
|
|
160
|
+
cy.get('[data-testid="Template"]')
|
|
161
|
+
.eq(1)
|
|
162
|
+
.should('have.value', options[2].name);
|
|
163
|
+
cy.wrap(onChange).should(() => {
|
|
164
|
+
expect(getLastValues(onChange)).to.deep.equal([options[0], options[2]]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('lets a row disable the options already selected in sibling rows', () => {
|
|
169
|
+
initializeTemplates();
|
|
170
|
+
|
|
171
|
+
cy.get('[data-testid="Template"]').eq(1).click();
|
|
172
|
+
cy.wait('@getTemplates');
|
|
173
|
+
|
|
174
|
+
cy.findByRole('option', { name: options[0].name }).should(
|
|
175
|
+
'have.attr',
|
|
176
|
+
'aria-disabled',
|
|
177
|
+
'true'
|
|
178
|
+
);
|
|
179
|
+
cy.findByRole('option', { name: options[2].name }).should(
|
|
180
|
+
'not.have.attr',
|
|
181
|
+
'aria-disabled',
|
|
182
|
+
'true'
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('appends a row built with createValue and focuses its first input', () => {
|
|
187
|
+
const { onChange } = initializeTemplates();
|
|
188
|
+
|
|
189
|
+
cy.findByTestId('Add').click();
|
|
190
|
+
|
|
191
|
+
getRows().should('have.length', 3);
|
|
192
|
+
cy.get('[data-testid="Template"]').eq(2).should('have.value', '');
|
|
193
|
+
cy.focused().should('have.attr', 'data-testid', 'Template');
|
|
194
|
+
cy.focused()
|
|
195
|
+
.closest('li')
|
|
196
|
+
.should(($row) => {
|
|
197
|
+
expect($row.index()).to.equal(2);
|
|
198
|
+
});
|
|
199
|
+
cy.wrap(onChange).should(() => {
|
|
200
|
+
expect(getLastValues(onChange)).to.deep.equal([
|
|
201
|
+
options[0],
|
|
202
|
+
options[1],
|
|
203
|
+
null
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('removes a row and moves the focus to the delete button of the row taking its place', () => {
|
|
209
|
+
const { onChange } = initializeTemplates({
|
|
210
|
+
templates: [options[0], options[1], options[2]]
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
cy.findAllByTestId('delete-row').eq(0).click();
|
|
214
|
+
|
|
215
|
+
getRows().should('have.length', 2);
|
|
216
|
+
cy.wrap(onChange).should(() => {
|
|
217
|
+
expect(getLastValues(onChange)).to.deep.equal([options[1], options[2]]);
|
|
218
|
+
});
|
|
219
|
+
cy.focused()
|
|
220
|
+
.should('have.attr', 'data-testid', 'delete-row')
|
|
221
|
+
.closest('li')
|
|
222
|
+
.find('[data-testid="Template"]')
|
|
223
|
+
.should('have.value', options[1].name);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('moves the focus to the previous row when the last row is removed, then to the add button when the list becomes empty', () => {
|
|
227
|
+
initializeTemplates();
|
|
228
|
+
|
|
229
|
+
cy.findAllByTestId('delete-row').eq(1).click();
|
|
230
|
+
cy.focused()
|
|
231
|
+
.closest('li')
|
|
232
|
+
.find('[data-testid="Template"]')
|
|
233
|
+
.should('have.value', options[0].name);
|
|
234
|
+
|
|
235
|
+
cy.findAllByTestId('delete-row').eq(0).click();
|
|
236
|
+
getRows().should('not.exist');
|
|
237
|
+
cy.focused().should('have.attr', 'data-testid', 'Add');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('reorders the rows with the keyboard through the drag handle', () => {
|
|
241
|
+
const { onChange } = initializeTemplates();
|
|
242
|
+
|
|
243
|
+
cy.moveSortableElement({
|
|
244
|
+
direction: 'down',
|
|
245
|
+
element: cy.findAllByTestId('drag-handle').eq(0)
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
cy.get('[data-testid="Template"]')
|
|
249
|
+
.eq(0)
|
|
250
|
+
.should('have.value', options[1].name);
|
|
251
|
+
cy.get('[data-testid="Template"]')
|
|
252
|
+
.eq(1)
|
|
253
|
+
.should('have.value', options[0].name);
|
|
254
|
+
cy.wrap(onChange).should(() => {
|
|
255
|
+
expect(getLastValues(onChange)).to.deep.equal([options[1], options[0]]);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('passes the up-to-date row value and index to the actions after a reorder', () => {
|
|
260
|
+
const { onEdit } = initializeTemplates();
|
|
261
|
+
|
|
262
|
+
cy.moveSortableElement({
|
|
263
|
+
direction: 'down',
|
|
264
|
+
element: cy.findAllByTestId('drag-handle').eq(0)
|
|
265
|
+
});
|
|
266
|
+
cy.get('[data-testid="Template"]')
|
|
267
|
+
.eq(0)
|
|
268
|
+
.should('have.value', options[1].name);
|
|
269
|
+
|
|
270
|
+
cy.findAllByTestId('edit-template').eq(1).click();
|
|
271
|
+
cy.wrap(onEdit).should('have.been.calledWith', options[0], 1);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('hides the drag handle when the list is not draggable', () => {
|
|
275
|
+
initializeTemplates({ draggable: false });
|
|
276
|
+
|
|
277
|
+
cy.findAllByTestId('delete-row').should('have.length', 2);
|
|
278
|
+
cy.findByTestId('drag-handle').should('not.exist');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('lets the inputs take the remaining width and keeps the row buttons at a 32px pitch', () => {
|
|
282
|
+
initializeTemplates();
|
|
283
|
+
|
|
284
|
+
cy.get('[data-row-fields]')
|
|
285
|
+
.eq(0)
|
|
286
|
+
.next()
|
|
287
|
+
.find('button')
|
|
288
|
+
.should('have.length', 3)
|
|
289
|
+
.then(($buttons) => {
|
|
290
|
+
const lefts = $buttons
|
|
291
|
+
.toArray()
|
|
292
|
+
.map((button) => button.getBoundingClientRect().left);
|
|
293
|
+
|
|
294
|
+
lefts.slice(1).forEach((left, index) => {
|
|
295
|
+
expect(left - lefts[index]).to.equal(32);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
cy.get('[data-row-fields]')
|
|
300
|
+
.eq(0)
|
|
301
|
+
.invoke('outerWidth')
|
|
302
|
+
.should('be.greaterThan', 500);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
interface MacroRow {
|
|
307
|
+
isPassword: boolean;
|
|
308
|
+
name: string;
|
|
309
|
+
origin: 'direct' | 'fromTpl';
|
|
310
|
+
value: string;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const macros: Array<MacroRow> = [
|
|
314
|
+
{ isPassword: false, name: 'MACRO_A', origin: 'direct', value: 'a' },
|
|
315
|
+
{ isPassword: true, name: 'MACRO_B', origin: 'fromTpl', value: 'b' }
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
const emptyMacro: MacroRow = {
|
|
319
|
+
isPassword: false,
|
|
320
|
+
name: '',
|
|
321
|
+
origin: 'direct',
|
|
322
|
+
value: ''
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const renderMacroRow = ({
|
|
326
|
+
setField,
|
|
327
|
+
value: macro
|
|
328
|
+
}: RenderRowParams<MacroRow>): JSX.Element => (
|
|
329
|
+
<div className="flex gap-2">
|
|
330
|
+
<TextField
|
|
331
|
+
dataTestId="macro-name"
|
|
332
|
+
label="Name"
|
|
333
|
+
onChange={(event): void => setField('name', event.target.value)}
|
|
334
|
+
value={macro.name}
|
|
335
|
+
/>
|
|
336
|
+
<TextField
|
|
337
|
+
dataTestId="macro-value"
|
|
338
|
+
label="Value"
|
|
339
|
+
onChange={(event): void => setField('value', event.target.value)}
|
|
340
|
+
type={macro.isPassword ? 'password' : 'text'}
|
|
341
|
+
value={macro.value}
|
|
342
|
+
/>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const MacrosList = ({
|
|
347
|
+
copyOnChange,
|
|
348
|
+
onChangeSpy
|
|
349
|
+
}: {
|
|
350
|
+
copyOnChange: boolean;
|
|
351
|
+
onChangeSpy: Stub;
|
|
352
|
+
}): JSX.Element => {
|
|
353
|
+
const [values, setValues] = useState(macros);
|
|
354
|
+
|
|
355
|
+
const change = (nextValues: Array<MacroRow>): void => {
|
|
356
|
+
onChangeSpy(nextValues);
|
|
357
|
+
// Simulates a parent that stores a copy (new array and objects), like a
|
|
358
|
+
// form library that clones its state.
|
|
359
|
+
setValues(
|
|
360
|
+
copyOnChange ? nextValues.map((macro) => ({ ...macro })) : nextValues
|
|
361
|
+
);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<>
|
|
366
|
+
<button
|
|
367
|
+
data-testid="reset"
|
|
368
|
+
onClick={(): void => setValues([emptyMacro])}
|
|
369
|
+
type="button"
|
|
370
|
+
>
|
|
371
|
+
Reset
|
|
372
|
+
</button>
|
|
373
|
+
<SortableEntriesList<MacroRow>
|
|
374
|
+
actions={({ index, value }) => [
|
|
375
|
+
{
|
|
376
|
+
icon: <VisibilityIcon />,
|
|
377
|
+
id: 'toggle-password',
|
|
378
|
+
label: 'Toggle password',
|
|
379
|
+
onClick: () =>
|
|
380
|
+
change(
|
|
381
|
+
values.map((macro, macroIndex) =>
|
|
382
|
+
macroIndex === index
|
|
383
|
+
? { ...macro, isPassword: !value.isPassword }
|
|
384
|
+
: macro
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
]}
|
|
389
|
+
createValue={(): MacroRow => emptyMacro}
|
|
390
|
+
getRowClassName={({ value }) => `origin-${value.origin}`}
|
|
391
|
+
onChange={change}
|
|
392
|
+
renderRow={renderMacroRow}
|
|
393
|
+
values={values}
|
|
394
|
+
/>
|
|
395
|
+
</>
|
|
396
|
+
);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const initializeMacros = ({
|
|
400
|
+
copyOnChange = false
|
|
401
|
+
}: {
|
|
402
|
+
copyOnChange?: boolean;
|
|
403
|
+
} = {}): { onChange: Stub } => {
|
|
404
|
+
const onChange = cy.stub();
|
|
405
|
+
|
|
406
|
+
cy.mount({
|
|
407
|
+
Component: <MacrosList copyOnChange={copyOnChange} onChangeSpy={onChange} />
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
return { onChange };
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
describe('SortableEntriesList - multiple input rows', () => {
|
|
414
|
+
it('renders every input of every row', () => {
|
|
415
|
+
initializeMacros();
|
|
416
|
+
|
|
417
|
+
cy.get('input[data-testid="macro-name"]').should('have.length', 2);
|
|
418
|
+
cy.get('input[data-testid="macro-value"]').should('have.length', 2);
|
|
419
|
+
cy.get('input[data-testid="macro-value"]')
|
|
420
|
+
.eq(1)
|
|
421
|
+
.should('have.attr', 'type', 'password');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('keeps the focus in the edited input while typing and reports a structured value', () => {
|
|
425
|
+
const { onChange } = initializeMacros();
|
|
426
|
+
|
|
427
|
+
cy.get('input[data-testid="macro-name"]').eq(0).clear();
|
|
428
|
+
cy.get('input[data-testid="macro-name"]').eq(0).type('HOST_IP');
|
|
429
|
+
cy.focused().should('have.value', 'HOST_IP');
|
|
430
|
+
|
|
431
|
+
cy.get('input[data-testid="macro-value"]').eq(0).clear();
|
|
432
|
+
cy.get('input[data-testid="macro-value"]').eq(0).type('10.0.0.1');
|
|
433
|
+
cy.focused().should('have.value', '10.0.0.1');
|
|
434
|
+
|
|
435
|
+
cy.wrap(onChange).should(() => {
|
|
436
|
+
expect(getLastValues(onChange)).to.deep.equal([
|
|
437
|
+
{
|
|
438
|
+
isPassword: false,
|
|
439
|
+
name: 'HOST_IP',
|
|
440
|
+
origin: 'direct',
|
|
441
|
+
value: '10.0.0.1'
|
|
442
|
+
},
|
|
443
|
+
macros[1]
|
|
444
|
+
]);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('keeps the focus when a new row is typed into right after being added', () => {
|
|
449
|
+
const { onChange } = initializeMacros();
|
|
450
|
+
|
|
451
|
+
cy.findByTestId('Add').click();
|
|
452
|
+
cy.focused().should('have.attr', 'data-testid', 'macro-name').type('NEW');
|
|
453
|
+
cy.get('input[data-testid="macro-value"]').eq(2).type('value');
|
|
454
|
+
|
|
455
|
+
cy.wrap(onChange).should(() => {
|
|
456
|
+
expect(getLastValues<MacroRow>(onChange)[2]).to.deep.equal({
|
|
457
|
+
...emptyMacro,
|
|
458
|
+
name: 'NEW',
|
|
459
|
+
value: 'value'
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('keeps the rows and the focus stable when the parent stores a copy of the values', () => {
|
|
465
|
+
const { onChange } = initializeMacros({ copyOnChange: true });
|
|
466
|
+
|
|
467
|
+
cy.get('input[data-testid="macro-name"]').eq(1).clear();
|
|
468
|
+
cy.get('input[data-testid="macro-name"]').eq(1).type('RENAMED');
|
|
469
|
+
cy.focused().should('have.value', 'RENAMED');
|
|
470
|
+
|
|
471
|
+
cy.findByTestId('Add').click();
|
|
472
|
+
cy.focused().should('have.attr', 'data-testid', 'macro-name').type('NEW');
|
|
473
|
+
|
|
474
|
+
cy.wrap(onChange).should(() => {
|
|
475
|
+
expect(
|
|
476
|
+
getLastValues<MacroRow>(onChange).map(({ name }) => name)
|
|
477
|
+
).to.deep.equal(['MACRO_A', 'RENAMED', 'NEW']);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('renders the new values when the parent replaces them', () => {
|
|
482
|
+
initializeMacros();
|
|
483
|
+
|
|
484
|
+
cy.findByTestId('reset').click();
|
|
485
|
+
|
|
486
|
+
getRows().should('have.length', 1);
|
|
487
|
+
cy.get('input[data-testid="macro-name"]').should('have.value', '');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('lets a row action change its own row', () => {
|
|
491
|
+
initializeMacros();
|
|
492
|
+
|
|
493
|
+
cy.findAllByTestId('toggle-password').eq(0).click();
|
|
494
|
+
|
|
495
|
+
cy.get('input[data-testid="macro-value"]')
|
|
496
|
+
.eq(0)
|
|
497
|
+
.should('have.attr', 'type', 'password');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('applies the class returned by getRowClassName to each row', () => {
|
|
501
|
+
initializeMacros();
|
|
502
|
+
|
|
503
|
+
getRows().eq(0).should('have.class', 'origin-direct');
|
|
504
|
+
getRows().eq(1).should('have.class', 'origin-fromTpl');
|
|
505
|
+
});
|
|
506
|
+
});
|
|
@@ -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,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
|
+
};
|
package/src/components/index.ts
CHANGED