@vendure/dashboard 3.5.2-master-202512170238 → 3.5.2-master-202512180239

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.
@@ -151,7 +151,7 @@ let DashboardPlugin = DashboardPlugin_1 = class DashboardPlugin {
151
151
  createStaticServer(dashboardPath) {
152
152
  const limiter = (0, express_rate_limit_1.rateLimit)({
153
153
  windowMs: 60 * 1000,
154
- limit: process.env.NODE_ENV === 'production' ? 500 : 10000,
154
+ limit: process.env.NODE_ENV === 'production' ? 500 : 1000000,
155
155
  standardHeaders: true,
156
156
  legacyHeaders: false,
157
157
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vendure/dashboard",
3
3
  "private": false,
4
- "version": "3.5.2-master-202512170238",
4
+ "version": "3.5.2-master-202512180239",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -156,8 +156,8 @@
156
156
  "@storybook/addon-vitest": "^10.0.0-beta.9",
157
157
  "@storybook/react-vite": "^10.0.0-beta.9",
158
158
  "@types/node": "^22.13.4",
159
- "@vendure/common": "^3.5.2-master-202512170238",
160
- "@vendure/core": "^3.5.2-master-202512170238",
159
+ "@vendure/common": "^3.5.2-master-202512180239",
160
+ "@vendure/core": "^3.5.2-master-202512180239",
161
161
  "@vitest/browser": "^3.2.4",
162
162
  "@vitest/coverage-v8": "^3.2.4",
163
163
  "eslint": "^9.19.0",
@@ -163,6 +163,7 @@ export const moveCollectionDocument = graphql(`
163
163
  mutation MoveCollection($input: MoveCollectionInput!) {
164
164
  moveCollection(input: $input) {
165
165
  id
166
+ position
166
167
  }
167
168
  }
168
169
  `);
@@ -4,18 +4,25 @@ import { Button } from '@/vdb/components/ui/button.js';
4
4
  import { PageActionBarRight } from '@/vdb/framework/layout-engine/page-layout.js';
5
5
  import { ListPage } from '@/vdb/framework/page/list-page.js';
6
6
  import { api } from '@/vdb/graphql/api.js';
7
- import { Trans } from '@lingui/react/macro';
8
- import { FetchQueryOptions, useQueries } from '@tanstack/react-query';
7
+ import { Trans, useLingui } from '@lingui/react/macro';
8
+ import { FetchQueryOptions, useQueries, useQueryClient } from '@tanstack/react-query';
9
9
  import { createFileRoute, Link } from '@tanstack/react-router';
10
10
  import { ExpandedState, getExpandedRowModel } from '@tanstack/react-table';
11
11
  import { TableOptions } from '@tanstack/table-core';
12
12
  import { ResultOf } from 'gql.tada';
13
13
  import { Folder, FolderOpen, PlusIcon } from 'lucide-react';
14
14
  import { useState } from 'react';
15
+ import { toast } from 'sonner';
15
16
 
16
17
  import { RichTextDescriptionCell } from '@/vdb/components/shared/table-cell/order-table-cell-components.js';
17
18
  import { Badge } from '@/vdb/components/ui/badge.js';
18
- import { collectionListDocument } from './collections.graphql.js';
19
+ import {
20
+ calculateDragTargetPosition,
21
+ calculateSiblingIndex,
22
+ getItemParentId,
23
+ isCircularReference,
24
+ } from '@/vdb/components/data-table/data-table-utils.js';
25
+ import { collectionListDocument, moveCollectionDocument } from './collections.graphql.js';
19
26
  import {
20
27
  AssignCollectionsToChannelBulkAction,
21
28
  DeleteCollectionsBulkAction,
@@ -25,15 +32,21 @@ import {
25
32
  } from './components/collection-bulk-actions.js';
26
33
  import { CollectionContentsSheet } from './components/collection-contents-sheet.js';
27
34
 
35
+
28
36
  export const Route = createFileRoute('/_authenticated/_collections/collections')({
29
37
  component: CollectionListPage,
30
38
  loader: () => ({ breadcrumb: () => <Trans>Collections</Trans> }),
31
39
  });
32
40
 
41
+
33
42
  type Collection = ResultOf<typeof collectionListDocument>['collections']['items'][number];
34
43
 
35
44
  function CollectionListPage() {
45
+ const { t } = useLingui();
46
+ const queryClient = useQueryClient();
36
47
  const [expanded, setExpanded] = useState<ExpandedState>({});
48
+ const [searchTerm, setSearchTerm] = useState<string>('');
49
+
37
50
  const childrenQueries = useQueries({
38
51
  queries: Object.entries(expanded).map(([collectionId, isExpanded]) => {
39
52
  return {
@@ -50,6 +63,7 @@ function CollectionListPage() {
50
63
  } satisfies FetchQueryOptions;
51
64
  }),
52
65
  });
66
+
53
67
  const childCollectionsByParentId = childrenQueries.reduce(
54
68
  (acc, query, index) => {
55
69
  const collectionId = Object.keys(expanded)[index];
@@ -79,177 +93,245 @@ function CollectionListPage() {
79
93
  return allRows;
80
94
  };
81
95
 
96
+ const handleReorder = async (oldIndex: number, newIndex: number, item: Collection, allItems?: Collection[]) => {
97
+ try {
98
+ const items = allItems || [];
99
+ const sourceParentId = getItemParentId(item);
100
+
101
+ if (!sourceParentId) {
102
+ throw new Error('Unable to determine parent collection ID');
103
+ }
104
+
105
+ // Calculate target position (parent and index)
106
+ const { targetParentId, adjustedIndex: initialIndex } = calculateDragTargetPosition({
107
+ item,
108
+ oldIndex,
109
+ newIndex,
110
+ items,
111
+ sourceParentId,
112
+ expanded,
113
+ });
114
+
115
+ // Validate no circular references when moving to different parent
116
+ if (targetParentId !== sourceParentId && isCircularReference(item, targetParentId, items)) {
117
+ toast.error(t`Cannot move a collection into its own descendant`);
118
+ throw new Error('Circular reference detected');
119
+ }
120
+
121
+ // Calculate final index (adjust for same-parent moves)
122
+ const adjustedIndex = targetParentId === sourceParentId
123
+ ? calculateSiblingIndex({ item, oldIndex, newIndex, items, parentId: sourceParentId })
124
+ : initialIndex;
125
+
126
+ // Perform the move
127
+ await api.mutate(moveCollectionDocument, {
128
+ input: {
129
+ collectionId: item.id,
130
+ parentId: targetParentId,
131
+ index: adjustedIndex,
132
+ },
133
+ });
134
+
135
+ // Invalidate queries and show success message
136
+ const queriesToInvalidate = [
137
+ queryClient.invalidateQueries({ queryKey: ['childCollections', sourceParentId] }),
138
+ queryClient.invalidateQueries({ queryKey: ['PaginatedListDataTable'] }),
139
+ ];
140
+
141
+ if (targetParentId === sourceParentId) {
142
+ await Promise.all(queriesToInvalidate);
143
+ toast.success(t`Collection position updated`);
144
+ } else {
145
+ queriesToInvalidate.push(
146
+ queryClient.invalidateQueries({ queryKey: ['childCollections', targetParentId] })
147
+ );
148
+ await Promise.all(queriesToInvalidate);
149
+ toast.success(t`Collection moved to new parent`);
150
+ }
151
+ } catch (error) {
152
+ console.error('Failed to reorder collection:', error);
153
+ if (error instanceof Error && error.message !== 'Circular reference detected') {
154
+ toast.error(t`Failed to update collection position`);
155
+ }
156
+ throw error;
157
+ }
158
+ };
159
+
82
160
  return (
83
- <>
84
- <ListPage
85
- pageId="collection-list"
86
- title={<Trans>Collections</Trans>}
87
- listQuery={collectionListDocument}
88
- transformVariables={input => {
89
- const filterTerm = input.options?.filter?.name?.contains;
90
- const isFiltering = !!filterTerm;
91
- return {
92
- options: {
93
- ...input.options,
94
- topLevelOnly: !isFiltering,
95
- },
96
- };
97
- }}
98
- customizeColumns={{
99
- name: {
100
- meta: {
101
- // This column needs the following fields to always be available
102
- // in order to correctly render.
103
- dependencies: ['children', 'breadcrumbs'],
104
- },
105
- cell: ({ row }) => {
106
- const isExpanded = row.getIsExpanded();
107
- const hasChildren = !!row.original.children?.length;
108
- return (
109
- <div
110
- style={{ marginLeft: (row.original.breadcrumbs?.length - 2) * 20 + 'px' }}
111
- className="flex gap-2 items-center"
112
- >
113
- <Button
114
- size="icon"
115
- variant="secondary"
116
- onClick={row.getToggleExpandedHandler()}
117
- disabled={!hasChildren}
118
- className={!hasChildren ? 'opacity-20' : ''}
119
- >
120
- {isExpanded ? <FolderOpen /> : <Folder />}
121
- </Button>
122
- <DetailPageButton id={row.original.id} label={row.original.name} />
123
- </div>
124
- );
125
- },
161
+ <ListPage
162
+ pageId="collection-list"
163
+ title={<Trans>Collections</Trans>}
164
+ listQuery={collectionListDocument}
165
+ transformVariables={input => {
166
+ const filterTerm = input.options?.filter?.name?.contains;
167
+ const isFiltering = !!filterTerm;
168
+ return {
169
+ options: {
170
+ ...input.options,
171
+ topLevelOnly: !isFiltering,
126
172
  },
127
- description: {
128
- cell: RichTextDescriptionCell,
173
+ };
174
+ }}
175
+ customizeColumns={{
176
+ name: {
177
+ meta: {
178
+ dependencies: ['children', 'breadcrumbs'],
129
179
  },
130
- breadcrumbs: {
131
- cell: ({ cell }) => {
132
- const value = cell.getValue();
133
- if (!Array.isArray(value)) {
134
- return null;
135
- }
136
- return (
137
- <div>
138
- {value
139
- .slice(1)
140
- .map(breadcrumb => breadcrumb.name)
141
- .join(' / ')}
142
- </div>
143
- );
144
- },
145
- },
146
- productVariants: {
147
- header: () => <Trans>Contents</Trans>,
148
- cell: ({ row }) => {
149
- return (
150
- <CollectionContentsSheet
151
- collectionId={row.original.id}
152
- collectionName={row.original.name}
180
+ cell: ({ row }) => {
181
+ const isExpanded = row.getIsExpanded();
182
+ const hasChildren = !!row.original.children?.length;
183
+ return (
184
+ <div
185
+ style={{ marginLeft: (row.original.breadcrumbs?.length - 2) * 20 + 'px' }}
186
+ className="flex gap-2 items-center"
187
+ >
188
+ <Button
189
+ size="icon"
190
+ variant="secondary"
191
+ onClick={row.getToggleExpandedHandler()}
192
+ disabled={!hasChildren}
193
+ className={!hasChildren ? 'opacity-20' : ''}
153
194
  >
154
- <Trans>{row.original.productVariants?.totalItems} variants</Trans>
155
- </CollectionContentsSheet>
156
- );
157
- },
158
- },
159
- children: {
160
- cell: ({ row }) => {
161
- const children = row.original.children ?? [];
162
- const count = children.length;
163
- const maxDisplay = 5;
164
- const leftOver = Math.max(count - maxDisplay, 0);
165
- return (
166
- <div className="flex flex-wrap gap-2">
167
- {children.slice(0, maxDisplay).map(child => (
168
- <Badge variant="outline">{child.name}</Badge>
169
- ))}
170
- {leftOver > 0 ? (
171
- <Badge variant="outline">
172
- <Trans>+ {leftOver} more</Trans>
173
- </Badge>
174
- ) : null}
175
- </div>
176
- );
177
- },
195
+ {isExpanded ? <FolderOpen /> : <Folder />}
196
+ </Button>
197
+ <DetailPageButton id={row.original.id} label={row.original.name} />
198
+ </div>
199
+ );
178
200
  },
179
- }}
180
- defaultColumnOrder={[
181
- 'featuredAsset',
182
- 'children',
183
- 'name',
184
- 'slug',
185
- 'breadcrumbs',
186
- 'productVariants',
187
- ]}
188
- transformData={data => {
189
- return addSubCollections(data);
190
- }}
191
- setTableOptions={(options: TableOptions<any>) => {
192
- options.state = {
193
- ...options.state,
194
- expanded: expanded,
195
- };
196
- options.onExpandedChange = setExpanded;
197
- options.getExpandedRowModel = getExpandedRowModel();
198
- options.getRowCanExpand = () => true;
199
- options.getRowId = row => {
200
- return row.id;
201
- };
202
- return options;
203
- }}
204
- defaultVisibility={{
205
- id: false,
206
- createdAt: false,
207
- updatedAt: false,
208
- position: false,
209
- parentId: false,
210
- children: false,
211
- description: false,
212
- }}
213
- onSearchTermChange={searchTerm => {
214
- return {
215
- name: { contains: searchTerm },
216
- };
217
- }}
218
- route={Route}
219
- bulkActions={[
220
- {
221
- component: AssignCollectionsToChannelBulkAction,
222
- order: 100,
201
+ },
202
+ description: {
203
+ cell: RichTextDescriptionCell,
204
+ },
205
+ breadcrumbs: {
206
+ cell: ({ cell }) => {
207
+ const value = cell.getValue();
208
+ if (!Array.isArray(value)) {
209
+ return null;
210
+ }
211
+ return (
212
+ <div>
213
+ {value
214
+ .slice(1)
215
+ .map(breadcrumb => breadcrumb.name)
216
+ .join(' / ')}
217
+ </div>
218
+ );
223
219
  },
224
- {
225
- component: RemoveCollectionsFromChannelBulkAction,
226
- order: 200,
220
+ },
221
+ productVariants: {
222
+ header: () => <Trans>Contents</Trans>,
223
+ cell: ({ row }) => {
224
+ return (
225
+ <CollectionContentsSheet
226
+ collectionId={row.original.id}
227
+ collectionName={row.original.name}
228
+ >
229
+ <Trans>{row.original.productVariants?.totalItems} variants</Trans>
230
+ </CollectionContentsSheet>
231
+ );
227
232
  },
228
- {
229
- component: DuplicateCollectionsBulkAction,
230
- order: 300,
233
+ },
234
+ children: {
235
+ cell: ({ row }) => {
236
+ const children = row.original.children ?? [];
237
+ const count = children.length;
238
+ const maxDisplay = 5;
239
+ const leftOver = Math.max(count - maxDisplay, 0);
240
+ return (
241
+ <div className="flex flex-wrap gap-2">
242
+ {children.slice(0, maxDisplay).map(child => (
243
+ <Badge key={child.id} variant="outline">{child.name}</Badge>
244
+ ))}
245
+ {leftOver > 0 ? (
246
+ <Badge variant="outline">
247
+ <Trans>+ {leftOver} more</Trans>
248
+ </Badge>
249
+ ) : null}
250
+ </div>
251
+ );
231
252
  },
232
- {
233
- component: MoveCollectionsBulkAction,
234
- order: 400,
235
- },
236
- {
237
- component: DeleteCollectionsBulkAction,
238
- order: 500,
239
- },
240
- ]}
241
- >
242
- <PageActionBarRight>
243
- <PermissionGuard requires={['CreateCollection', 'CreateCatalog']}>
244
- <Button asChild>
245
- <Link to="./new">
246
- <PlusIcon className="mr-2 h-4 w-4" />
247
- <Trans>New Collection</Trans>
248
- </Link>
249
- </Button>
250
- </PermissionGuard>
251
- </PageActionBarRight>
252
- </ListPage>
253
- </>
253
+ },
254
+ }}
255
+ defaultColumnOrder={[
256
+ 'featuredAsset',
257
+ 'name',
258
+ 'slug',
259
+ 'breadcrumbs',
260
+ 'productVariants',
261
+ ]}
262
+ transformData={data => {
263
+ return addSubCollections(data);
264
+ }}
265
+ setTableOptions={(options: TableOptions<any>) => {
266
+ options.state = {
267
+ ...options.state,
268
+ expanded: expanded,
269
+ };
270
+ options.onExpandedChange = setExpanded;
271
+ options.getExpandedRowModel = getExpandedRowModel();
272
+ options.getRowCanExpand = () => true;
273
+ options.getRowId = row => {
274
+ return row.id;
275
+ };
276
+ options.meta = {
277
+ ...options.meta,
278
+ resetExpanded: () => setExpanded({}),
279
+ };
280
+ return options;
281
+ }}
282
+ defaultVisibility={{
283
+ id: false,
284
+ createdAt: false,
285
+ updatedAt: false,
286
+ position: false,
287
+ parentId: false,
288
+ children: false,
289
+ description: false,
290
+ isPrivate: false,
291
+ }}
292
+ onSearchTermChange={searchTerm => {
293
+ setSearchTerm(searchTerm);
294
+ return {
295
+ name: { contains: searchTerm },
296
+ };
297
+ }}
298
+ route={Route}
299
+ bulkActions={[
300
+ {
301
+ component: AssignCollectionsToChannelBulkAction,
302
+ order: 100,
303
+ },
304
+ {
305
+ component: RemoveCollectionsFromChannelBulkAction,
306
+ order: 200,
307
+ },
308
+ {
309
+ component: DuplicateCollectionsBulkAction,
310
+ order: 300,
311
+ },
312
+ {
313
+ component: MoveCollectionsBulkAction,
314
+ order: 400,
315
+ },
316
+ {
317
+ component: DeleteCollectionsBulkAction,
318
+ order: 500,
319
+ },
320
+ ]}
321
+ onReorder={handleReorder}
322
+ disableDragAndDrop={!!searchTerm} // Disable dragging while searching
323
+ >
324
+ <PageActionBarRight>
325
+ <PermissionGuard requires={['CreateCollection', 'CreateCatalog']}>
326
+ <Button asChild>
327
+ <Link to="./new">
328
+ <PlusIcon className="mr-2 h-4 w-4" />
329
+ <Trans>New Collection</Trans>
330
+ </Link>
331
+ </Button>
332
+ </PermissionGuard>
333
+ </PageActionBarRight>
334
+ </ListPage>
254
335
  );
255
336
  }
337
+
@@ -103,6 +103,13 @@ export const MoveCollectionsBulkAction: BulkActionComponent<any> = ({ selection,
103
103
  table.resetRowSelection();
104
104
  };
105
105
 
106
+ const handleResetExpanded = () => {
107
+ const resetExpanded = (table.options.meta as { resetExpanded: () => void })?.resetExpanded;
108
+ if (resetExpanded) {
109
+ resetExpanded();
110
+ }
111
+ };
112
+
106
113
  return (
107
114
  <>
108
115
  <DataTableBulkActionItem
@@ -116,6 +123,7 @@ export const MoveCollectionsBulkAction: BulkActionComponent<any> = ({ selection,
116
123
  onOpenChange={setDialogOpen}
117
124
  collectionsToMove={selection}
118
125
  onSuccess={handleSuccess}
126
+ onResetExpanded={handleResetExpanded}
119
127
  />
120
128
  </>
121
129
  );
@@ -34,6 +34,7 @@ interface MoveCollectionsDialogProps {
34
34
  onOpenChange: (open: boolean) => void;
35
35
  collectionsToMove: Collection[];
36
36
  onSuccess?: () => void;
37
+ onResetExpanded?: () => void;
37
38
  }
38
39
 
39
40
  interface CollectionTreeNodeProps {
@@ -209,6 +210,7 @@ export function MoveCollectionsDialog({
209
210
  onOpenChange,
210
211
  collectionsToMove,
211
212
  onSuccess,
213
+ onResetExpanded,
212
214
  }: Readonly<MoveCollectionsDialogProps>) {
213
215
  const [expanded, setExpanded] = useState<Record<string, boolean>>({});
214
216
  const [selectedCollectionId, setSelectedCollectionId] = useState<string>();
@@ -282,6 +284,8 @@ export function MoveCollectionsDialog({
282
284
  toast.success(t`Collections moved successfully`);
283
285
  queryClient.invalidateQueries({ queryKey: collectionForMoveKey });
284
286
  queryClient.invalidateQueries({ queryKey: childCollectionsForMoveKey() });
287
+ queryClient.invalidateQueries({ queryKey: ['PaginatedListDataTable'] });
288
+ onResetExpanded?.();
285
289
  onSuccess?.();
286
290
  onOpenChange(false);
287
291
  },
@@ -1,4 +1,4 @@
1
- import { AffixedInput } from '@/vdb/components/data-input/affixed-input.js';
1
+ import { NumberInput } from '@/vdb/components/data-input/number-input.js';
2
2
  import { ErrorPage } from '@/vdb/components/shared/error-page.js';
3
3
  import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
4
4
  import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
@@ -121,14 +121,7 @@ function TaxRateDetailPage() {
121
121
  name="value"
122
122
  label={<Trans>Rate</Trans>}
123
123
  render={({ field }) => (
124
- <AffixedInput
125
- {...field}
126
- type="number"
127
- suffix="%"
128
- min={0}
129
- value={field.value}
130
- onChange={e => field.onChange(e.target.valueAsNumber)}
131
- />
124
+ <NumberInput {...field} value={field.value} min={0} step={0.01} suffix="%" />
132
125
  )}
133
126
  />
134
127
  <FormFieldWrapper
@@ -3,11 +3,14 @@ import { Input } from '@/vdb/components/ui/input.js';
3
3
 
4
4
  import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
5
5
  import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
6
+ import { ReactNode } from 'react';
6
7
 
7
8
  export type NumberInputProps = DashboardFormComponentProps & {
8
9
  min?: number;
9
10
  max?: number;
10
11
  step?: number;
12
+ prefix?: ReactNode;
13
+ suffix?: ReactNode;
11
14
  };
12
15
 
13
16
  /**
@@ -17,28 +20,43 @@ export type NumberInputProps = DashboardFormComponentProps & {
17
20
  * @docsCategory form-components
18
21
  * @docsPage NumberInput
19
22
  */
20
- export function NumberInput({ fieldDef, onChange, ...fieldProps }: Readonly<NumberInputProps>) {
23
+ export function NumberInput({
24
+ fieldDef,
25
+ onChange,
26
+ prefix: overridePrefix,
27
+ suffix: overrideSuffix,
28
+ ...fieldProps
29
+ }: Readonly<NumberInputProps>) {
21
30
  const readOnly = fieldProps.disabled || isReadonlyField(fieldDef);
22
31
  const isFloat = fieldDef ? fieldDef.type === 'float' : false;
23
32
  const min = fieldProps.min ?? fieldDef?.ui?.min;
24
33
  const max = fieldProps.max ?? fieldDef?.ui?.max;
25
34
  const step = fieldProps.step ?? (fieldDef?.ui?.step || (isFloat ? 0.01 : 1));
26
- const prefix = fieldDef?.ui?.prefix;
27
- const suffix = fieldDef?.ui?.suffix;
35
+ const prefix = overridePrefix ?? fieldDef?.ui?.prefix;
36
+ const suffix = overrideSuffix ?? fieldDef?.ui?.suffix;
28
37
  const shouldUseAffixedInput = prefix || suffix;
38
+ const value = fieldProps.value ?? '';
29
39
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
30
40
  if (readOnly) return;
31
- const numValue = e.target.valueAsNumber;
41
+
42
+ let numValue = e.target.valueAsNumber;
43
+
44
+ if (Number.isNaN(numValue) && e.target.value) {
45
+ const normalized = e.target.value.replace(',', '.');
46
+ numValue = Number(normalized);
47
+ }
48
+
32
49
  if (Number.isNaN(numValue)) {
33
50
  onChange(null);
34
51
  } else {
35
- onChange(e.target.valueAsNumber);
52
+ onChange(numValue);
36
53
  }
37
54
  };
38
55
  if (shouldUseAffixedInput) {
39
56
  return (
40
57
  <AffixedInput
41
58
  {...fieldProps}
59
+ value={value}
42
60
  type="number"
43
61
  onChange={handleChange}
44
62
  min={min}
@@ -57,6 +75,7 @@ export function NumberInput({ fieldDef, onChange, ...fieldProps }: Readonly<Numb
57
75
  type="number"
58
76
  onChange={handleChange}
59
77
  {...fieldProps}
78
+ value={value}
60
79
  min={min}
61
80
  max={max}
62
81
  step={step}