@vendure/dashboard 3.3.8-master-202507260236 → 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.
- package/package.json +4 -4
- package/src/app/routes/_authenticated/_collections/components/collection-contents-preview-table.tsx +1 -1
- package/src/app/routes/_authenticated/_collections/components/collection-filters-selector.tsx +11 -78
- package/src/app/routes/_authenticated/_payment-methods/components/payment-eligibility-checker-selector.tsx +11 -81
- package/src/app/routes/_authenticated/_payment-methods/components/payment-handler-selector.tsx +10 -77
- package/src/app/routes/_authenticated/_promotions/components/promotion-actions-selector.tsx +12 -87
- package/src/app/routes/_authenticated/_promotions/components/promotion-conditions-selector.tsx +12 -87
- package/src/app/routes/_authenticated/_shipping-methods/components/shipping-calculator-selector.tsx +10 -80
- package/src/app/routes/_authenticated/_shipping-methods/components/shipping-eligibility-checker-selector.tsx +10 -79
- package/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx +8 -6
- package/src/lib/components/data-input/combination-mode-input.tsx +52 -0
- package/src/lib/components/data-input/configurable-operation-list-input.tsx +433 -0
- package/src/lib/components/data-input/custom-field-list-input.tsx +297 -0
- package/src/lib/components/data-input/datetime-input.tsx +5 -2
- package/src/lib/components/data-input/default-relation-input.tsx +599 -0
- package/src/lib/components/data-input/index.ts +6 -0
- package/src/lib/components/data-input/product-multi-selector.tsx +426 -0
- package/src/lib/components/data-input/relation-selector.tsx +7 -6
- package/src/lib/components/data-input/select-with-options.tsx +84 -0
- package/src/lib/components/data-input/struct-form-input.tsx +324 -0
- package/src/lib/components/shared/configurable-operation-arg-input.tsx +365 -21
- package/src/lib/components/shared/configurable-operation-input.tsx +81 -41
- package/src/lib/components/shared/configurable-operation-multi-selector.tsx +260 -0
- package/src/lib/components/shared/configurable-operation-selector.tsx +156 -0
- package/src/lib/components/shared/custom-fields-form.tsx +207 -36
- package/src/lib/components/shared/multi-select.tsx +1 -1
- package/src/lib/components/ui/form.tsx +4 -4
- package/src/lib/framework/extension-api/input-component-extensions.tsx +5 -1
- package/src/lib/framework/form-engine/form-schema-tools.spec.ts +472 -0
- package/src/lib/framework/form-engine/form-schema-tools.ts +340 -5
- package/src/lib/framework/form-engine/use-generated-form.tsx +24 -8
- package/src/lib/framework/form-engine/utils.ts +3 -9
- package/src/lib/framework/layout-engine/page-layout.tsx +11 -3
- package/src/lib/framework/page/use-detail-page.ts +3 -3
- 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?:
|
|
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
|
-
<
|
|
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
|
+
}
|