@vendure/dashboard 3.3.6-master-202507030234 → 3.3.6-master-202507030648

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vendure/dashboard",
3
3
  "private": false,
4
- "version": "3.3.6-master-202507030234",
4
+ "version": "3.3.6-master-202507030648",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -86,8 +86,8 @@
86
86
  "@types/react-dom": "^19.0.4",
87
87
  "@types/react-grid-layout": "^1.3.5",
88
88
  "@uidotdev/usehooks": "^2.4.1",
89
- "@vendure/common": "^3.3.6-master-202507030234",
90
- "@vendure/core": "^3.3.6-master-202507030234",
89
+ "@vendure/common": "^3.3.6-master-202507030648",
90
+ "@vendure/core": "^3.3.6-master-202507030648",
91
91
  "@vitejs/plugin-react": "^4.3.4",
92
92
  "awesome-graphql-client": "^2.1.0",
93
93
  "class-variance-authority": "^0.7.1",
@@ -130,5 +130,5 @@
130
130
  "lightningcss-linux-arm64-musl": "^1.29.3",
131
131
  "lightningcss-linux-x64-musl": "^1.29.1"
132
132
  },
133
- "gitHead": "ca527d7a89a9cff83d965e384e5f3d27c24efe82"
133
+ "gitHead": "04f25e9d0bd06d8031d2f5dbc4373ec0e322f535"
134
134
  }
@@ -0,0 +1,11 @@
1
+ // Existing data input components
2
+ export * from './affixed-input.js';
3
+ export * from './customer-group-input.js';
4
+ export * from './datetime-input.js';
5
+ export * from './facet-value-input.js';
6
+ export * from './money-input.js';
7
+ export * from './rich-text-input.js';
8
+
9
+ // Relation selector components
10
+ export * from './relation-input.js';
11
+ export * from './relation-selector.js';
@@ -0,0 +1,156 @@
1
+ import { graphql } from '@/vdb/graphql/graphql.js';
2
+ import { createRelationSelectorConfig, RelationSelector } from './relation-selector.js';
3
+
4
+ // Re-export for convenience
5
+ export { createRelationSelectorConfig };
6
+
7
+ /**
8
+ * Single relation input component
9
+ */
10
+ export interface SingleRelationInputProps<T = any> {
11
+ value: string;
12
+ onChange: (value: string) => void;
13
+ config: Parameters<typeof createRelationSelectorConfig<T>>[0];
14
+ disabled?: boolean;
15
+ className?: string;
16
+ }
17
+
18
+ export function SingleRelationInput<T>({
19
+ value,
20
+ onChange,
21
+ config,
22
+ disabled,
23
+ className,
24
+ }: Readonly<SingleRelationInputProps<T>>) {
25
+ const singleConfig = createRelationSelectorConfig<T>({
26
+ ...config,
27
+ multiple: false,
28
+ });
29
+
30
+ return (
31
+ <RelationSelector
32
+ config={singleConfig}
33
+ value={value}
34
+ onChange={newValue => onChange(newValue as string)}
35
+ disabled={disabled}
36
+ className={className}
37
+ />
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Multi relation input component
43
+ */
44
+ export interface MultiRelationInputProps<T = any> {
45
+ value: string[];
46
+ onChange: (value: string[]) => void;
47
+ config: Parameters<typeof createRelationSelectorConfig<T>>[0];
48
+ disabled?: boolean;
49
+ className?: string;
50
+ }
51
+
52
+ export function MultiRelationInput<T>({
53
+ value,
54
+ onChange,
55
+ config,
56
+ disabled,
57
+ className,
58
+ }: Readonly<MultiRelationInputProps<T>>) {
59
+ const multiConfig = createRelationSelectorConfig<T>({
60
+ ...config,
61
+ multiple: true,
62
+ });
63
+
64
+ return (
65
+ <RelationSelector
66
+ config={multiConfig}
67
+ value={value}
68
+ onChange={newValue => onChange(newValue as string[])}
69
+ disabled={disabled}
70
+ className={className}
71
+ />
72
+ );
73
+ }
74
+
75
+ // Example configurations for common entities
76
+
77
+ /**
78
+ * Product relation selector configuration
79
+ */
80
+ export const productRelationConfig = createRelationSelectorConfig({
81
+ listQuery: graphql(`
82
+ query GetProductsForRelationSelector($options: ProductListOptions) {
83
+ products(options: $options) {
84
+ items {
85
+ id
86
+ name
87
+ slug
88
+ featuredAsset {
89
+ id
90
+ preview
91
+ }
92
+ }
93
+ totalItems
94
+ }
95
+ }
96
+ `),
97
+ idKey: 'id' as const,
98
+ labelKey: 'name' as const,
99
+ placeholder: 'Search products...',
100
+ buildSearchFilter: (term: string) => ({
101
+ name: { contains: term },
102
+ }),
103
+ });
104
+
105
+ /**
106
+ * Customer relation selector configuration
107
+ */
108
+ export const customerRelationConfig = createRelationSelectorConfig({
109
+ listQuery: graphql(`
110
+ query GetCustomersForRelationSelector($options: CustomerListOptions) {
111
+ customers(options: $options) {
112
+ items {
113
+ id
114
+ firstName
115
+ lastName
116
+ emailAddress
117
+ }
118
+ totalItems
119
+ }
120
+ }
121
+ `),
122
+ idKey: 'id' as const,
123
+ labelKey: 'emailAddress' as const,
124
+ placeholder: 'Search customers...',
125
+ buildSearchFilter: (term: string) => ({
126
+ emailAddress: { contains: term },
127
+ }),
128
+ });
129
+
130
+ /**
131
+ * Collection relation selector configuration
132
+ */
133
+ export const collectionRelationConfig = createRelationSelectorConfig({
134
+ listQuery: graphql(`
135
+ query GetCollectionsForRelationSelector($options: CollectionListOptions) {
136
+ collections(options: $options) {
137
+ items {
138
+ id
139
+ name
140
+ slug
141
+ featuredAsset {
142
+ id
143
+ preview
144
+ }
145
+ }
146
+ totalItems
147
+ }
148
+ }
149
+ `),
150
+ idKey: 'id' as const,
151
+ labelKey: 'name' as const,
152
+ placeholder: 'Search collections...',
153
+ buildSearchFilter: (term: string) => ({
154
+ name: { contains: term },
155
+ }),
156
+ });
@@ -0,0 +1,350 @@
1
+ import { Button } from '@/vdb/components/ui/button.js';
2
+ import { Checkbox } from '@/vdb/components/ui/checkbox.js';
3
+ import {
4
+ Command,
5
+ CommandEmpty,
6
+ CommandInput,
7
+ CommandItem,
8
+ CommandList,
9
+ } from '@/vdb/components/ui/command.js';
10
+ import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
11
+ import { getQueryName } from '@/vdb/framework/document-introspection/get-document-structure.js';
12
+ import { api } from '@/vdb/graphql/api.js';
13
+ import { Trans } from '@/vdb/lib/trans.js';
14
+ import { useInfiniteQuery } from '@tanstack/react-query';
15
+ import { useDebounce } from '@uidotdev/usehooks';
16
+ import type { DocumentNode } from 'graphql';
17
+ import { CheckIcon, Loader2, Plus, X } from 'lucide-react';
18
+ import React, { useState } from 'react';
19
+
20
+ export interface RelationSelectorConfig<T = any> {
21
+ /** The GraphQL query document for fetching items */
22
+ listQuery: DocumentNode;
23
+ /** The property key for the entity ID */
24
+ idKey: keyof T;
25
+ /** The property key for the display label */
26
+ labelKey: keyof T;
27
+ /** Number of items to load per page */
28
+ pageSize?: number;
29
+ /** Placeholder text for the search input */
30
+ placeholder?: string;
31
+ /** Whether to enable multi-select mode */
32
+ multiple?: boolean;
33
+ /** Custom filter function for search */
34
+ buildSearchFilter?: (searchTerm: string) => any;
35
+ /** Custom label renderer function for rich display */
36
+ label?: (item: T) => React.ReactNode;
37
+ }
38
+
39
+ export interface RelationSelectorProps<T = any> {
40
+ config: RelationSelectorConfig<T>;
41
+ value?: string | string[];
42
+ onChange: (value: string | string[]) => void;
43
+ disabled?: boolean;
44
+ className?: string;
45
+ }
46
+
47
+ export interface RelationSelectorItemProps<T = any> {
48
+ item: T;
49
+ config: RelationSelectorConfig<T>;
50
+ isSelected: boolean;
51
+ onSelect: () => void;
52
+ showCheckbox?: boolean;
53
+ }
54
+
55
+ /**
56
+ * Abstract relation selector item component
57
+ */
58
+ export function RelationSelectorItem<T>({
59
+ item,
60
+ config,
61
+ isSelected,
62
+ onSelect,
63
+ showCheckbox = false,
64
+ }: Readonly<RelationSelectorItemProps<T>>) {
65
+ const id = String(item[config.idKey]);
66
+ const label = config.label ? config.label(item) : String(item[config.labelKey]);
67
+
68
+ return (
69
+ <CommandItem key={id} value={id} onSelect={onSelect} className="flex items-center gap-2">
70
+ {showCheckbox && (
71
+ <Checkbox
72
+ checked={isSelected}
73
+ onChange={onSelect}
74
+ onClick={(e: React.MouseEvent) => e.stopPropagation()}
75
+ />
76
+ )}
77
+ {isSelected && !showCheckbox && <CheckIcon className="h-4 w-4" />}
78
+ <span className="flex-1">{label}</span>
79
+ </CommandItem>
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Hook for managing relation selector state and queries
85
+ */
86
+ export function useRelationSelector<T>(config: RelationSelectorConfig<T>) {
87
+ const [searchTerm, setSearchTerm] = useState('');
88
+ const debouncedSearch = useDebounce(searchTerm, 300);
89
+
90
+ const pageSize = config.pageSize ?? 25;
91
+
92
+ // Build the default search filter if none provided
93
+ const buildFilter =
94
+ config.buildSearchFilter ??
95
+ ((term: string) => ({
96
+ [config.labelKey]: { contains: term },
97
+ }));
98
+
99
+ const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, error } = useInfiniteQuery({
100
+ queryKey: ['relationSelector', getQueryName(config.listQuery), debouncedSearch],
101
+ queryFn: async ({ pageParam = 0 }) => {
102
+ const variables: any = {
103
+ options: {
104
+ skip: pageParam * pageSize,
105
+ take: pageSize,
106
+ sort: { [config.labelKey]: 'ASC' },
107
+ },
108
+ };
109
+
110
+ // Add search filter if there's a search term
111
+ if (debouncedSearch.trim().length > 0) {
112
+ variables.options.filter = buildFilter(debouncedSearch.trim());
113
+ }
114
+
115
+ const response = (await api.query(config.listQuery, variables)) as any;
116
+ return response[getQueryName(config.listQuery)];
117
+ },
118
+ getNextPageParam: (lastPage, allPages) => {
119
+ if (!lastPage) return undefined;
120
+ const totalFetched = allPages.length * pageSize;
121
+ return totalFetched < lastPage.totalItems ? allPages.length : undefined;
122
+ },
123
+ initialPageParam: 0,
124
+ });
125
+
126
+ const items = data?.pages.flatMap(page => page?.items ?? []) ?? [];
127
+
128
+ return {
129
+ items,
130
+ isLoading,
131
+ fetchNextPage,
132
+ hasNextPage,
133
+ isFetchingNextPage,
134
+ error,
135
+ searchTerm,
136
+ setSearchTerm,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Abstract relation selector component
142
+ */
143
+ export function RelationSelector<T>({
144
+ config,
145
+ value,
146
+ onChange,
147
+ disabled,
148
+ className,
149
+ }: Readonly<RelationSelectorProps<T>>) {
150
+ const [open, setOpen] = useState(false);
151
+ const [selectedItemsCache, setSelectedItemsCache] = useState<T[]>([]);
152
+ const isMultiple = config.multiple ?? false;
153
+
154
+ const { items, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, searchTerm, setSearchTerm } =
155
+ useRelationSelector(config);
156
+
157
+ // Normalize value to always be an array for easier handling
158
+ const selectedIds = React.useMemo(() => {
159
+ if (isMultiple) {
160
+ return Array.isArray(value) ? value : value ? [value] : [];
161
+ }
162
+ return value ? [String(value)] : [];
163
+ }, [value, isMultiple]);
164
+
165
+ const handleSelect = (item: T) => {
166
+ const itemId = String(item[config.idKey]);
167
+
168
+ if (isMultiple) {
169
+ const isCurrentlySelected = selectedIds.includes(itemId);
170
+ const newSelectedIds = isCurrentlySelected
171
+ ? selectedIds.filter(id => id !== itemId)
172
+ : [...selectedIds, itemId];
173
+
174
+ // Update cache: add item if selecting, remove if deselecting
175
+ setSelectedItemsCache(prev => {
176
+ if (isCurrentlySelected) {
177
+ return prev.filter(prevItem => String(prevItem[config.idKey]) !== itemId);
178
+ } else {
179
+ // Only add if not already in cache
180
+ const alreadyInCache = prev.some(prevItem => String(prevItem[config.idKey]) === itemId);
181
+ return alreadyInCache ? prev : [...prev, item];
182
+ }
183
+ });
184
+
185
+ onChange(newSelectedIds);
186
+ } else {
187
+ // For single select, update cache with the new item
188
+ setSelectedItemsCache([item]);
189
+ onChange(itemId);
190
+ setOpen(false);
191
+ setSearchTerm('');
192
+ }
193
+ };
194
+
195
+ const handleRemove = (itemId: string) => {
196
+ if (isMultiple) {
197
+ const newSelectedIds = selectedIds.filter(id => id !== itemId);
198
+ // Remove from cache as well
199
+ setSelectedItemsCache(prev => prev.filter(prevItem => String(prevItem[config.idKey]) !== itemId));
200
+ onChange(newSelectedIds);
201
+ } else {
202
+ // Clear cache for single select
203
+ setSelectedItemsCache([]);
204
+ onChange('');
205
+ }
206
+ };
207
+
208
+ const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
209
+ const target = e.currentTarget;
210
+ const scrolledToBottom = Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) < 1;
211
+
212
+ if (scrolledToBottom && hasNextPage && !isFetchingNextPage) {
213
+ fetchNextPage();
214
+ }
215
+ };
216
+
217
+ // Clean up cache when selectedIds change externally (e.g., form reset)
218
+ React.useEffect(() => {
219
+ setSelectedItemsCache(prev => prev.filter(item => selectedIds.includes(String(item[config.idKey]))));
220
+ }, [selectedIds, config.idKey]);
221
+
222
+ // Populate cache with items from search results that are selected but not yet cached
223
+ React.useEffect(() => {
224
+ const itemsToAdd = items.filter(item => {
225
+ const itemId = String(item[config.idKey]);
226
+ const isSelected = selectedIds.includes(itemId);
227
+ const isAlreadyCached = selectedItemsCache.some(
228
+ cachedItem => String(cachedItem[config.idKey]) === itemId,
229
+ );
230
+ return isSelected && !isAlreadyCached;
231
+ });
232
+
233
+ if (itemsToAdd.length > 0) {
234
+ setSelectedItemsCache(prev => [...prev, ...itemsToAdd]);
235
+ }
236
+ }, [items, selectedIds, selectedItemsCache, config.idKey]);
237
+
238
+ // Get selected items for display from cache, filtered by current selection
239
+ const selectedItems = React.useMemo(() => {
240
+ return selectedItemsCache.filter(item => selectedIds.includes(String(item[config.idKey])));
241
+ }, [selectedItemsCache, selectedIds, config.idKey]);
242
+
243
+ return (
244
+ <div className={className}>
245
+ {/* Display selected items */}
246
+ {selectedItems.length > 0 && (
247
+ <div className="flex flex-wrap gap-2 mb-2">
248
+ {selectedItems.map(item => {
249
+ const itemId = String(item[config.idKey]);
250
+ const label = config.label ? config.label(item) : String(item[config.labelKey]);
251
+ return (
252
+ <div
253
+ key={itemId}
254
+ className="inline-flex items-center gap-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
255
+ >
256
+ <span>{label}</span>
257
+ {!disabled && (
258
+ <button
259
+ type="button"
260
+ onClick={() => handleRemove(itemId)}
261
+ className="text-secondary-foreground/70 hover:text-secondary-foreground"
262
+ >
263
+ <X className="h-3 w-3" />
264
+ </button>
265
+ )}
266
+ </div>
267
+ );
268
+ })}
269
+ </div>
270
+ )}
271
+
272
+ {/* Selector trigger */}
273
+ <Popover open={open} onOpenChange={setOpen}>
274
+ <PopoverTrigger asChild>
275
+ <Button variant="outline" size="sm" type="button" disabled={disabled} className="gap-2">
276
+ <Plus className="h-4 w-4" />
277
+ <Trans>
278
+ {isMultiple
279
+ ? selectedItems.length > 0
280
+ ? `Add more (${selectedItems.length} selected)`
281
+ : 'Select items'
282
+ : selectedItems.length > 0
283
+ ? 'Change selection'
284
+ : 'Select item'}
285
+ </Trans>
286
+ </Button>
287
+ </PopoverTrigger>
288
+ <PopoverContent className="p-0 w-[400px]" align="start">
289
+ <Command shouldFilter={false}>
290
+ <CommandInput
291
+ placeholder={config.placeholder ?? 'Search...'}
292
+ value={searchTerm}
293
+ onValueChange={setSearchTerm}
294
+ disabled={disabled}
295
+ />
296
+ <CommandList className="h-[300px] overflow-y-auto" onScroll={handleScroll}>
297
+ <CommandEmpty>
298
+ {isLoading ? (
299
+ <div className="flex items-center justify-center py-6">
300
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
301
+ <Trans>Loading...</Trans>
302
+ </div>
303
+ ) : (
304
+ <Trans>No results found</Trans>
305
+ )}
306
+ </CommandEmpty>
307
+
308
+ {items.map(item => {
309
+ const itemId = String(item[config.idKey]);
310
+ const isSelected = selectedIds.includes(itemId);
311
+
312
+ return (
313
+ <RelationSelectorItem
314
+ key={itemId}
315
+ item={item}
316
+ config={config}
317
+ isSelected={isSelected}
318
+ onSelect={() => handleSelect(item)}
319
+ showCheckbox={isMultiple}
320
+ />
321
+ );
322
+ })}
323
+
324
+ {(isFetchingNextPage || isLoading) && (
325
+ <div className="flex items-center justify-center py-2">
326
+ <Loader2 className="h-4 w-4 animate-spin" />
327
+ </div>
328
+ )}
329
+
330
+ {!hasNextPage && items.length > 0 && (
331
+ <div className="text-center py-2 text-sm text-muted-foreground">
332
+ <Trans>No more items</Trans>
333
+ </div>
334
+ )}
335
+ </CommandList>
336
+ </Command>
337
+ </PopoverContent>
338
+ </Popover>
339
+ </div>
340
+ );
341
+ }
342
+
343
+ /**
344
+ * Utility function to create a relation selector configuration
345
+ */
346
+ export function createRelationSelectorConfig<T>(
347
+ config: Readonly<RelationSelectorConfig<T>>,
348
+ ): RelationSelectorConfig<T> {
349
+ return config;
350
+ }
package/src/lib/index.ts CHANGED
@@ -8,7 +8,10 @@ export * from './components/data-input/affixed-input.js';
8
8
  export * from './components/data-input/customer-group-input.js';
9
9
  export * from './components/data-input/datetime-input.js';
10
10
  export * from './components/data-input/facet-value-input.js';
11
+ export * from './components/data-input/index.js';
11
12
  export * from './components/data-input/money-input.js';
13
+ export * from './components/data-input/relation-input.js';
14
+ export * from './components/data-input/relation-selector.js';
12
15
  export * from './components/data-input/richt-text-input.js';
13
16
  export * from './components/data-table/add-filter-menu.js';
14
17
  export * from './components/data-table/data-table-bulk-action-item.js';
@@ -184,10 +187,6 @@ export * from './framework/page/use-detail-page.js';
184
187
  export * from './framework/page/use-extended-router.js';
185
188
  export * from './framework/registry/global-registry.js';
186
189
  export * from './framework/registry/registry-types.js';
187
- export * from './graphql/api.js';
188
- export * from './graphql/common-operations.js';
189
- export * from './graphql/fragments.js';
190
- export * from './graphql/graphql.js';
191
190
  export * from './hooks/use-auth.js';
192
191
  export * from './hooks/use-channel.js';
193
192
  export * from './hooks/use-custom-field-config.js';
@@ -204,3 +203,7 @@ export * from './hooks/use-theme.js';
204
203
  export * from './hooks/use-user-settings.js';
205
204
  export * from './lib/trans.js';
206
205
  export * from './lib/utils.js';
206
+ export * from './graphql/api.js';
207
+ export * from './graphql/common-operations.js';
208
+ export * from './graphql/fragments.js';
209
+ export * from './graphql/graphql.js';