@vendure/dashboard 3.3.8-master-202507290247 → 3.3.8-master-202507300243

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.
Files changed (35) hide show
  1. package/package.json +4 -4
  2. package/src/app/routes/_authenticated/_collections/components/collection-contents-preview-table.tsx +1 -1
  3. package/src/app/routes/_authenticated/_collections/components/collection-filters-selector.tsx +11 -78
  4. package/src/app/routes/_authenticated/_payment-methods/components/payment-eligibility-checker-selector.tsx +11 -81
  5. package/src/app/routes/_authenticated/_payment-methods/components/payment-handler-selector.tsx +10 -77
  6. package/src/app/routes/_authenticated/_promotions/components/promotion-actions-selector.tsx +12 -87
  7. package/src/app/routes/_authenticated/_promotions/components/promotion-conditions-selector.tsx +12 -87
  8. package/src/app/routes/_authenticated/_shipping-methods/components/shipping-calculator-selector.tsx +10 -80
  9. package/src/app/routes/_authenticated/_shipping-methods/components/shipping-eligibility-checker-selector.tsx +10 -79
  10. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx +8 -6
  11. package/src/lib/components/data-input/combination-mode-input.tsx +52 -0
  12. package/src/lib/components/data-input/configurable-operation-list-input.tsx +433 -0
  13. package/src/lib/components/data-input/custom-field-list-input.tsx +297 -0
  14. package/src/lib/components/data-input/datetime-input.tsx +5 -2
  15. package/src/lib/components/data-input/default-relation-input.tsx +599 -0
  16. package/src/lib/components/data-input/index.ts +6 -0
  17. package/src/lib/components/data-input/product-multi-selector.tsx +426 -0
  18. package/src/lib/components/data-input/relation-selector.tsx +7 -6
  19. package/src/lib/components/data-input/select-with-options.tsx +84 -0
  20. package/src/lib/components/data-input/struct-form-input.tsx +324 -0
  21. package/src/lib/components/shared/configurable-operation-arg-input.tsx +365 -21
  22. package/src/lib/components/shared/configurable-operation-input.tsx +81 -41
  23. package/src/lib/components/shared/configurable-operation-multi-selector.tsx +260 -0
  24. package/src/lib/components/shared/configurable-operation-selector.tsx +156 -0
  25. package/src/lib/components/shared/custom-fields-form.tsx +207 -36
  26. package/src/lib/components/shared/multi-select.tsx +1 -1
  27. package/src/lib/components/ui/form.tsx +4 -4
  28. package/src/lib/framework/extension-api/input-component-extensions.tsx +5 -1
  29. package/src/lib/framework/form-engine/form-schema-tools.spec.ts +472 -0
  30. package/src/lib/framework/form-engine/form-schema-tools.ts +340 -5
  31. package/src/lib/framework/form-engine/use-generated-form.tsx +24 -8
  32. package/src/lib/framework/form-engine/utils.ts +3 -9
  33. package/src/lib/framework/layout-engine/page-layout.tsx +11 -3
  34. package/src/lib/framework/page/use-detail-page.ts +3 -3
  35. package/src/lib/lib/utils.ts +26 -24
@@ -0,0 +1,426 @@
1
+ import { VendureImage } from '@/vdb/components/shared/vendure-image.js';
2
+ import { Badge } from '@/vdb/components/ui/badge.js';
3
+ import { Button } from '@/vdb/components/ui/button.js';
4
+ import { Checkbox } from '@/vdb/components/ui/checkbox.js';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '@/vdb/components/ui/dialog.js';
12
+ import { Input } from '@/vdb/components/ui/input.js';
13
+ import { DataInputComponent } from '@/vdb/framework/component-registry/component-registry.js';
14
+ import { api } from '@/vdb/graphql/api.js';
15
+ import { graphql } from '@/vdb/graphql/graphql.js';
16
+ import { Trans } from '@/vdb/lib/trans.js';
17
+ import { useQuery } from '@tanstack/react-query';
18
+ import { useDebounce } from '@uidotdev/usehooks';
19
+ import { Plus, X } from 'lucide-react';
20
+ import { useCallback, useEffect, useMemo, useState } from 'react';
21
+
22
+ // GraphQL queries
23
+ const searchProductsDocument = graphql(`
24
+ query SearchProducts($input: SearchInput!) {
25
+ search(input: $input) {
26
+ totalItems
27
+ items {
28
+ enabled
29
+ productId
30
+ productName
31
+ slug
32
+ productAsset {
33
+ id
34
+ preview
35
+ }
36
+ productVariantId
37
+ productVariantName
38
+ productVariantAsset {
39
+ id
40
+ preview
41
+ }
42
+ sku
43
+ }
44
+ facetValues {
45
+ count
46
+ facetValue {
47
+ id
48
+ name
49
+ facet {
50
+ id
51
+ name
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ `);
58
+
59
+ type SearchItem = {
60
+ enabled: boolean;
61
+ productId: string;
62
+ productName: string;
63
+ slug: string;
64
+ productAsset?: { id: string; preview: string } | null;
65
+ productVariantId: string;
66
+ productVariantName: string;
67
+ productVariantAsset?: { id: string; preview: string } | null;
68
+ sku: string;
69
+ };
70
+
71
+ interface ProductMultiSelectorProps {
72
+ mode: 'product' | 'variant';
73
+ initialSelectionIds?: string[];
74
+ onSelectionChange: (selectedIds: string[]) => void;
75
+ open: boolean;
76
+ onOpenChange: (open: boolean) => void;
77
+ }
78
+
79
+ function LoadingState() {
80
+ return (
81
+ <div className="text-center text-muted-foreground">
82
+ <Trans>Loading...</Trans>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ function EmptyState() {
88
+ return (
89
+ <div className="text-center text-muted-foreground">
90
+ <Trans>No items found</Trans>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ function ProductList({
96
+ items,
97
+ mode,
98
+ selectedIds,
99
+ getItemId,
100
+ getItemName,
101
+ toggleSelection,
102
+ }: Readonly<{
103
+ items: SearchItem[];
104
+ mode: 'product' | 'variant';
105
+ selectedIds: Set<string>;
106
+ getItemId: (item: SearchItem) => string;
107
+ getItemName: (item: SearchItem) => string;
108
+ toggleSelection: (item: SearchItem) => void;
109
+ }>) {
110
+ return (
111
+ <>
112
+ {items.map(item => {
113
+ const itemId = getItemId(item);
114
+ const isSelected = selectedIds.has(itemId);
115
+ const asset =
116
+ mode === 'product' ? item.productAsset : item.productVariantAsset || item.productAsset;
117
+
118
+ return (
119
+ <div
120
+ key={itemId}
121
+ role="checkbox"
122
+ tabIndex={0}
123
+ aria-checked={isSelected}
124
+ className={`border rounded-lg p-3 cursor-pointer transition-colors ${
125
+ isSelected
126
+ ? 'border-primary bg-primary/5'
127
+ : 'border-border hover:border-primary/50'
128
+ }`}
129
+ onClick={() => toggleSelection(item)}
130
+ onKeyDown={e => {
131
+ if (e.key === 'Enter' || e.key === ' ') {
132
+ e.preventDefault();
133
+ toggleSelection(item);
134
+ }
135
+ }}
136
+ >
137
+ <div className="flex items-start gap-3">
138
+ <div className="flex-shrink-0">
139
+ <VendureImage
140
+ asset={asset}
141
+ preset="tiny"
142
+ className="w-16 h-16 rounded object-contain bg-secondary/10"
143
+ fallback={<div className="w-16 h-16 rounded bg-secondary/10" />}
144
+ />
145
+ </div>
146
+ <div className="flex-1 min-w-0">
147
+ <div className="font-medium text-sm">{getItemName(item)}</div>
148
+ {mode === 'product' ? (
149
+ <div className="text-xs text-muted-foreground">{item.slug}</div>
150
+ ) : (
151
+ <div className="text-xs text-muted-foreground">{item.sku}</div>
152
+ )}
153
+ </div>
154
+ <div className="flex-shrink-0">
155
+ <Checkbox checked={isSelected} />
156
+ </div>
157
+ </div>
158
+ </div>
159
+ );
160
+ })}
161
+ </>
162
+ );
163
+ }
164
+
165
+ function ProductMultiSelectorDialog({
166
+ mode,
167
+ initialSelectionIds = [],
168
+ onSelectionChange,
169
+ open,
170
+ onOpenChange,
171
+ }: Readonly<ProductMultiSelectorProps>) {
172
+ const [searchTerm, setSearchTerm] = useState('');
173
+ const [selectedItems, setSelectedItems] = useState<SearchItem[]>([]);
174
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
175
+
176
+ // Add debounced search term
177
+ const debouncedSearchTerm = useDebounce(searchTerm, 300);
178
+
179
+ // Search input configuration
180
+ const searchInput = useMemo(
181
+ () => ({
182
+ term: debouncedSearchTerm,
183
+ groupByProduct: mode === 'product',
184
+ take: 50,
185
+ skip: 0,
186
+ }),
187
+ [debouncedSearchTerm, mode],
188
+ );
189
+
190
+ // Query search results
191
+ const { data: searchData, isLoading } = useQuery({
192
+ queryKey: ['searchProducts', searchInput],
193
+ queryFn: () => api.query(searchProductsDocument, { input: searchInput }),
194
+ enabled: open,
195
+ });
196
+
197
+ const items = searchData?.search.items || [];
198
+
199
+ // Get the appropriate ID for an item based on mode
200
+ const getItemId = useCallback(
201
+ (item: SearchItem): string => {
202
+ return mode === 'product' ? item.productId : item.productVariantId;
203
+ },
204
+ [mode],
205
+ );
206
+
207
+ // Get the appropriate name for an item based on mode
208
+ const getItemName = useCallback(
209
+ (item: SearchItem): string => {
210
+ return mode === 'product' ? item.productName : item.productVariantName;
211
+ },
212
+ [mode],
213
+ );
214
+
215
+ // Toggle item selection
216
+ const toggleSelection = useCallback(
217
+ (item: SearchItem) => {
218
+ const itemId = getItemId(item);
219
+ const newSelectedIds = new Set(selectedIds);
220
+ const newSelectedItems = [...selectedItems];
221
+
222
+ if (selectedIds.has(itemId)) {
223
+ newSelectedIds.delete(itemId);
224
+ const index = selectedItems.findIndex(selected => getItemId(selected) === itemId);
225
+ if (index >= 0) {
226
+ newSelectedItems.splice(index, 1);
227
+ }
228
+ } else {
229
+ newSelectedIds.add(itemId);
230
+ newSelectedItems.push(item);
231
+ }
232
+
233
+ setSelectedIds(newSelectedIds);
234
+ setSelectedItems(newSelectedItems);
235
+ },
236
+ [selectedIds, selectedItems, getItemId],
237
+ );
238
+
239
+ // Clear all selections
240
+ const clearSelection = useCallback(() => {
241
+ setSelectedIds(new Set());
242
+ setSelectedItems([]);
243
+ }, []);
244
+
245
+ // Handle selection confirmation
246
+ const handleSelect = useCallback(() => {
247
+ onSelectionChange(Array.from(selectedIds));
248
+ onOpenChange(false);
249
+ }, [selectedIds, onSelectionChange, onOpenChange]);
250
+
251
+ // Initialize selected items when dialog opens
252
+ useEffect(() => {
253
+ if (open) {
254
+ setSelectedIds(new Set(initialSelectionIds));
255
+ // We'll update the selectedItems once we have search results that match the IDs
256
+ }
257
+ }, [open, initialSelectionIds]);
258
+
259
+ // Update selectedItems when we have search results that match our selected IDs
260
+ useEffect(() => {
261
+ if (items.length > 0 && selectedIds.size > 0) {
262
+ const newSelectedItems = items.filter(item => selectedIds.has(getItemId(item)));
263
+ if (newSelectedItems.length > 0) {
264
+ setSelectedItems(prevItems => {
265
+ const existingIds = new Set(prevItems.map(getItemId));
266
+ const uniqueNewItems = newSelectedItems.filter(item => !existingIds.has(getItemId(item)));
267
+ return [...prevItems, ...uniqueNewItems];
268
+ });
269
+ }
270
+ }
271
+ }, [items, selectedIds, getItemId]);
272
+
273
+ return (
274
+ <Dialog open={open} onOpenChange={onOpenChange}>
275
+ <DialogContent className="max-w-[95vw] md:max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
276
+ <DialogHeader>
277
+ <DialogTitle>
278
+ <Trans>{mode === 'product' ? 'Select Products' : 'Select Variants'}</Trans>
279
+ </DialogTitle>
280
+ </DialogHeader>
281
+
282
+ <div className="flex-1 min-h-0 flex flex-col">
283
+ {/* Search Input */}
284
+ <div className="flex-shrink-0 mb-4">
285
+ <Input
286
+ id="search"
287
+ placeholder="Search products..."
288
+ value={searchTerm}
289
+ onChange={e => setSearchTerm(e.target.value)}
290
+ />
291
+ </div>
292
+
293
+ <div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-3 gap-6">
294
+ {/* Items Grid */}
295
+ <div className="lg:col-span-2 overflow-auto flex flex-col">
296
+ <div className="space-y-2 p-2">
297
+ {isLoading && <LoadingState />}
298
+ {!isLoading && items.length === 0 && <EmptyState />}
299
+ {!isLoading && items.length > 0 && (
300
+ <ProductList
301
+ items={items}
302
+ mode={mode}
303
+ selectedIds={selectedIds}
304
+ getItemId={getItemId}
305
+ getItemName={getItemName}
306
+ toggleSelection={toggleSelection}
307
+ />
308
+ )}
309
+ </div>
310
+ </div>
311
+
312
+ {/* Selected Items Panel */}
313
+ <div className="border rounded-lg p-4 overflow-auto flex flex-col">
314
+ <div className="flex items-center justify-between mb-4 flex-shrink-0">
315
+ <div className="text-sm font-medium">
316
+ <Trans>Selected Items</Trans>
317
+ <Badge variant="secondary" className="ml-2">
318
+ {selectedItems.length}
319
+ </Badge>
320
+ </div>
321
+ {selectedItems.length > 0 && (
322
+ <Button variant="outline" size="sm" onClick={clearSelection}>
323
+ <Trans>Clear</Trans>
324
+ </Button>
325
+ )}
326
+ </div>
327
+
328
+ <div className="space-y-2">
329
+ {selectedItems.length === 0 ? (
330
+ <div className="text-center text-muted-foreground text-sm">
331
+ <Trans>No items selected</Trans>
332
+ </div>
333
+ ) : (
334
+ selectedItems.map(item => (
335
+ <div
336
+ key={getItemId(item)}
337
+ className="flex items-center justify-between p-2 border rounded"
338
+ >
339
+ <div className="flex-1 min-w-0">
340
+ <div className="text-sm font-medium truncate">
341
+ {getItemName(item)}
342
+ </div>
343
+ <div className="text-xs text-muted-foreground">
344
+ {mode === 'product' ? item.slug : item.sku}
345
+ </div>
346
+ </div>
347
+ <Button
348
+ variant="ghost"
349
+ size="icon"
350
+ onClick={() => toggleSelection(item)}
351
+ >
352
+ <X className="h-4 w-4" />
353
+ </Button>
354
+ </div>
355
+ ))
356
+ )}
357
+ </div>
358
+ </div>
359
+ </div>
360
+ </div>
361
+
362
+ <DialogFooter className="mt-4">
363
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
364
+ <Trans>Cancel</Trans>
365
+ </Button>
366
+ <Button onClick={handleSelect} disabled={selectedItems.length === 0}>
367
+ <Trans>Select {selectedItems.length} Items</Trans>
368
+ </Button>
369
+ </DialogFooter>
370
+ </DialogContent>
371
+ </Dialog>
372
+ );
373
+ }
374
+
375
+ export const ProductMultiInput: DataInputComponent = ({ value, onChange, ...props }) => {
376
+ const [open, setOpen] = useState(false);
377
+
378
+ // Parse the configuration from the field definition
379
+ const mode = (props as any)?.selectionMode === 'variant' ? 'variant' : 'product';
380
+
381
+ // Parse the current value (JSON array of IDs)
382
+ const selectedIds = useMemo(() => {
383
+ if (!value || typeof value !== 'string') return [];
384
+ try {
385
+ return JSON.parse(value);
386
+ } catch {
387
+ return [];
388
+ }
389
+ }, [value]);
390
+
391
+ const handleSelectionChange = useCallback(
392
+ (newSelectedIds: string[]) => {
393
+ onChange(JSON.stringify(newSelectedIds));
394
+ },
395
+ [onChange],
396
+ );
397
+
398
+ const itemType = mode === 'product' ? 'products' : 'variants';
399
+ const buttonText =
400
+ selectedIds.length > 0 ? `Selected ${selectedIds.length} ${itemType}` : `Select ${itemType}`;
401
+
402
+ return (
403
+ <>
404
+ <div className="space-y-2">
405
+ <Button variant="outline" onClick={() => setOpen(true)}>
406
+ <Plus className="h-4 w-4 mr-2" />
407
+ <Trans>{buttonText}</Trans>
408
+ </Button>
409
+
410
+ {selectedIds.length > 0 && (
411
+ <div className="text-sm text-muted-foreground">
412
+ <Trans>{selectedIds.length} items selected</Trans>
413
+ </div>
414
+ )}
415
+ </div>
416
+
417
+ <ProductMultiSelectorDialog
418
+ mode={mode}
419
+ initialSelectionIds={selectedIds}
420
+ onSelectionChange={handleSelectionChange}
421
+ open={open}
422
+ onOpenChange={setOpen}
423
+ />
424
+ </>
425
+ );
426
+ };
@@ -11,6 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/pop
11
11
  import { getQueryName } from '@/vdb/framework/document-introspection/get-document-structure.js';
12
12
  import { api } from '@/vdb/graphql/api.js';
13
13
  import { Trans } from '@/vdb/lib/trans.js';
14
+ import { cn } from '@/vdb/lib/utils.js';
14
15
  import { useInfiniteQuery } from '@tanstack/react-query';
15
16
  import { useDebounce } from '@uidotdev/usehooks';
16
17
  import type { DocumentNode } from 'graphql';
@@ -27,7 +28,7 @@ export interface RelationSelectorConfig<T = any> {
27
28
  /** Number of items to load per page */
28
29
  pageSize?: number;
29
30
  /** Placeholder text for the search input */
30
- placeholder?: string;
31
+ placeholder?: React.ReactNode;
31
32
  /** Whether to enable multi-select mode */
32
33
  multiple?: boolean;
33
34
  /** Custom filter function for search */
@@ -366,7 +367,7 @@ export function RelationSelector<T>({
366
367
  }, [selectedItemsCache, selectedIds, config.idKey, isMultiple]);
367
368
 
368
369
  return (
369
- <div className={className}>
370
+ <div className={cn('overflow-auto', className)}>
370
371
  {/* Display selected items */}
371
372
  {selectedItems.length > 0 && (
372
373
  <div className="flex flex-wrap gap-2 mb-2">
@@ -376,9 +377,9 @@ export function RelationSelector<T>({
376
377
  return (
377
378
  <div
378
379
  key={itemId}
379
- className="inline-flex items-center gap-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
380
+ className="inline-flex items-center gap-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm max-w-full min-w-0"
380
381
  >
381
- <span>{label}</span>
382
+ <div className="min-w-0 flex-1">{label}</div>
382
383
  {!disabled && (
383
384
  <button
384
385
  type="button"
@@ -403,10 +404,10 @@ export function RelationSelector<T>({
403
404
  {isMultiple
404
405
  ? selectedItems.length > 0
405
406
  ? `Add more (${selectedItems.length} selected)`
406
- : selectorLabel ?? <Trans>Select items</Trans>
407
+ : (selectorLabel ?? <Trans>Select items</Trans>)
407
408
  : selectedItems.length > 0
408
409
  ? 'Change selection'
409
- : selectorLabel ?? <Trans>Select item</Trans>}
410
+ : (selectorLabel ?? <Trans>Select item</Trans>)}
410
411
  </Trans>
411
412
  </Button>
412
413
  </PopoverTrigger>
@@ -0,0 +1,84 @@
1
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
2
+ import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
3
+ import { Trans } from '@/vdb/lib/trans.js';
4
+ import { StringFieldOption } from '@vendure/common/lib/generated-types';
5
+ import React from 'react';
6
+ import { ControllerRenderProps } from 'react-hook-form';
7
+ import { MultiSelect } from '../shared/multi-select.js';
8
+
9
+ export interface SelectWithOptionsProps {
10
+ field: ControllerRenderProps<any, any>;
11
+ options: StringFieldOption[];
12
+ disabled?: boolean;
13
+ placeholder?: React.ReactNode;
14
+ isListField?: boolean;
15
+ }
16
+
17
+ /**
18
+ * @description
19
+ * A select component that renders options from custom field configuration.
20
+ * It automatically handles localization of option labels based on user settings.
21
+ *
22
+ * @since 3.3.0
23
+ */
24
+ export function SelectWithOptions({
25
+ field,
26
+ options,
27
+ disabled,
28
+ placeholder,
29
+ isListField = false,
30
+ }: Readonly<SelectWithOptionsProps>) {
31
+ const {
32
+ settings: { displayLanguage },
33
+ } = useUserSettings();
34
+
35
+ const getTranslation = (label: Array<{ languageCode: string; value: string }> | null) => {
36
+ if (!label) return '';
37
+ const translation = label.find(t => t.languageCode === displayLanguage);
38
+ return translation?.value ?? label[0]?.value ?? '';
39
+ };
40
+
41
+ // Convert options to MultiSelect format
42
+ const multiSelectItems = options.map(option => ({
43
+ value: option.value,
44
+ label: option.label ? getTranslation(option.label) : option.value,
45
+ }));
46
+
47
+ // For list fields, use MultiSelect component
48
+ if (isListField) {
49
+ return (
50
+ <MultiSelect
51
+ multiple={true}
52
+ value={field.value || []}
53
+ onChange={field.onChange}
54
+ items={multiSelectItems}
55
+ placeholder={placeholder ? String(placeholder) : 'Select options'}
56
+ className={disabled ? 'opacity-50 pointer-events-none' : ''}
57
+ />
58
+ );
59
+ }
60
+
61
+ // For single fields, use regular Select
62
+ const currentValue = field.value ?? '';
63
+
64
+ const handleValueChange = (value: string) => {
65
+ if (value) {
66
+ field.onChange(value);
67
+ }
68
+ };
69
+
70
+ return (
71
+ <Select value={currentValue ?? undefined} onValueChange={handleValueChange} disabled={disabled}>
72
+ <SelectTrigger>
73
+ <SelectValue placeholder={placeholder || <Trans>Select an option</Trans>} />
74
+ </SelectTrigger>
75
+ <SelectContent>
76
+ {options.map(option => (
77
+ <SelectItem key={option.value} value={option.value}>
78
+ {option.label ? getTranslation(option.label) : option.value}
79
+ </SelectItem>
80
+ ))}
81
+ </SelectContent>
82
+ </Select>
83
+ );
84
+ }