@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 +4 -4
- package/src/lib/components/data-input/index.ts +11 -0
- package/src/lib/components/data-input/relation-input.tsx +156 -0
- package/src/lib/components/data-input/relation-selector.tsx +350 -0
- package/src/lib/index.ts +7 -4
- /package/src/lib/components/data-input/{richt-text-input.tsx → rich-text-input.tsx} +0 -0
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-
|
|
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-
|
|
90
|
-
"@vendure/core": "^3.3.6-master-
|
|
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": "
|
|
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';
|
|
File without changes
|