@vendure/dashboard 3.3.5-master-202506260234 → 3.3.5
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/_products/components/assign-facet-values-dialog.tsx +177 -25
- package/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx +1 -1
- package/src/app/routes/_authenticated/_products/products.graphql.ts +21 -0
- package/src/app/routes/_authenticated/_products/products_.$id.tsx +1 -2
- package/src/app/styles.css +1 -1
- package/src/lib/components/data-table/data-table.tsx +29 -5
- package/src/lib/components/data-table/refresh-button.tsx +26 -14
- package/src/lib/components/shared/custom-fields-form.tsx +5 -6
- package/src/lib/components/shared/paginated-list-data-table.tsx +4 -2
- package/src/lib/components/shared/vendure-image.tsx +30 -1
- package/src/lib/framework/form-engine/use-generated-form.tsx +8 -2
- package/src/lib/framework/form-engine/utils.ts +30 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vendure/dashboard",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "3.3.5
|
|
4
|
+
"version": "3.3.5",
|
|
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": "
|
|
90
|
-
"@vendure/core": "
|
|
89
|
+
"@vendure/common": "3.3.5",
|
|
90
|
+
"@vendure/core": "3.3.5",
|
|
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": "71d36ec5164c833d9f05f6e8140db13c450f4d29"
|
|
134
134
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
1
2
|
import { useState } from 'react';
|
|
2
3
|
import { toast } from 'sonner';
|
|
3
|
-
import { useMutation } from '@tanstack/react-query';
|
|
4
4
|
|
|
5
|
+
import { FacetValueChip } from '@/components/shared/facet-value-chip.js';
|
|
6
|
+
import { FacetValue, FacetValueSelector } from '@/components/shared/facet-value-selector.js';
|
|
5
7
|
import { Button } from '@/components/ui/button.js';
|
|
6
8
|
import {
|
|
7
9
|
Dialog,
|
|
@@ -11,12 +13,31 @@ import {
|
|
|
11
13
|
DialogHeader,
|
|
12
14
|
DialogTitle,
|
|
13
15
|
} from '@/components/ui/dialog.js';
|
|
14
|
-
import { FacetValueSelector, FacetValue } from '@/components/shared/facet-value-selector.js';
|
|
15
16
|
import { api } from '@/graphql/api.js';
|
|
16
17
|
import { ResultOf } from '@/graphql/graphql.js';
|
|
17
18
|
import { Trans, useLingui } from '@/lib/trans.js';
|
|
18
19
|
|
|
19
|
-
import {
|
|
20
|
+
import { getDetailQueryOptions } from '@/framework/page/use-detail-page.js';
|
|
21
|
+
import {
|
|
22
|
+
getProductsWithFacetValuesByIdsDocument,
|
|
23
|
+
productDetailDocument,
|
|
24
|
+
updateProductsDocument,
|
|
25
|
+
} from '../products.graphql.js';
|
|
26
|
+
|
|
27
|
+
interface ProductWithFacetValues {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
facetValues: Array<{
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
code: string;
|
|
34
|
+
facet: {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
code: string;
|
|
38
|
+
};
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
20
41
|
|
|
21
42
|
interface AssignFacetValuesDialogProps {
|
|
22
43
|
open: boolean;
|
|
@@ -25,9 +46,24 @@ interface AssignFacetValuesDialogProps {
|
|
|
25
46
|
onSuccess?: () => void;
|
|
26
47
|
}
|
|
27
48
|
|
|
28
|
-
export function AssignFacetValuesDialog({
|
|
49
|
+
export function AssignFacetValuesDialog({
|
|
50
|
+
open,
|
|
51
|
+
onOpenChange,
|
|
52
|
+
productIds,
|
|
53
|
+
onSuccess,
|
|
54
|
+
}: AssignFacetValuesDialogProps) {
|
|
29
55
|
const { i18n } = useLingui();
|
|
30
|
-
const [
|
|
56
|
+
const [selectedValues, setSelectedValues] = useState<FacetValue[]>([]);
|
|
57
|
+
const [facetValuesRemoved, setFacetValuesRemoved] = useState(false);
|
|
58
|
+
const [removedFacetValues, setRemovedFacetValues] = useState<Set<string>>(new Set());
|
|
59
|
+
const queryClient = useQueryClient();
|
|
60
|
+
|
|
61
|
+
// Fetch existing facet values for the products
|
|
62
|
+
const { data: productsData, isLoading } = useQuery({
|
|
63
|
+
queryKey: ['productsWithFacetValues', productIds],
|
|
64
|
+
queryFn: () => api.query(getProductsWithFacetValuesByIdsDocument, { ids: productIds }),
|
|
65
|
+
enabled: open && productIds.length > 0,
|
|
66
|
+
});
|
|
31
67
|
|
|
32
68
|
const { mutate, isPending } = useMutation({
|
|
33
69
|
mutationFn: api.mutate(updateProductsDocument),
|
|
@@ -35,6 +71,14 @@ export function AssignFacetValuesDialog({ open, onOpenChange, productIds, onSucc
|
|
|
35
71
|
toast.success(i18n.t(`Successfully updated facet values for ${productIds.length} products`));
|
|
36
72
|
onSuccess?.();
|
|
37
73
|
onOpenChange(false);
|
|
74
|
+
// Reset state
|
|
75
|
+
setSelectedValues([]);
|
|
76
|
+
setFacetValuesRemoved(false);
|
|
77
|
+
setRemovedFacetValues(new Set());
|
|
78
|
+
productIds.forEach(id => {
|
|
79
|
+
const { queryKey } = getDetailQueryOptions(productDetailDocument, { id });
|
|
80
|
+
queryClient.removeQueries({ queryKey });
|
|
81
|
+
});
|
|
38
82
|
},
|
|
39
83
|
onError: () => {
|
|
40
84
|
toast.error(`Failed to update facet values for ${productIds.length} products`);
|
|
@@ -42,57 +86,165 @@ export function AssignFacetValuesDialog({ open, onOpenChange, productIds, onSucc
|
|
|
42
86
|
});
|
|
43
87
|
|
|
44
88
|
const handleAssign = () => {
|
|
45
|
-
if (
|
|
46
|
-
toast.error('Please select at least one facet value');
|
|
89
|
+
if (selectedValues.length === 0 && !facetValuesRemoved) {
|
|
90
|
+
toast.error('Please select at least one facet value or make changes to existing ones');
|
|
47
91
|
return;
|
|
48
92
|
}
|
|
49
93
|
|
|
94
|
+
if (!productsData?.products.items) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const selectedFacetValueIds = selectedValues.map(sv => sv.id);
|
|
99
|
+
|
|
50
100
|
mutate({
|
|
51
|
-
input:
|
|
52
|
-
id:
|
|
53
|
-
facetValueIds:
|
|
101
|
+
input: productsData.products.items.map(product => ({
|
|
102
|
+
id: product.id,
|
|
103
|
+
facetValueIds: [
|
|
104
|
+
...new Set([
|
|
105
|
+
...product.facetValues.filter(fv => !removedFacetValues.has(fv.id)).map(fv => fv.id),
|
|
106
|
+
...selectedFacetValueIds,
|
|
107
|
+
]),
|
|
108
|
+
],
|
|
54
109
|
})),
|
|
55
110
|
});
|
|
56
111
|
};
|
|
57
112
|
|
|
58
113
|
const handleFacetValueSelect = (facetValue: FacetValue) => {
|
|
59
|
-
|
|
114
|
+
setSelectedValues(prev => [...prev, facetValue]);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const removeFacetValue = (productId: string, facetValueId: string) => {
|
|
118
|
+
setRemovedFacetValues(prev => new Set([...prev, facetValueId]));
|
|
119
|
+
setFacetValuesRemoved(true);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleCancel = () => {
|
|
123
|
+
onOpenChange(false);
|
|
124
|
+
// Reset state
|
|
125
|
+
setSelectedValues([]);
|
|
126
|
+
setFacetValuesRemoved(false);
|
|
127
|
+
setRemovedFacetValues(new Set());
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Filter out removed facet values for display
|
|
131
|
+
const getDisplayFacetValues = (product: ProductWithFacetValues) => {
|
|
132
|
+
return product.facetValues.filter(fv => !removedFacetValues.has(fv.id));
|
|
60
133
|
};
|
|
61
134
|
|
|
62
135
|
return (
|
|
63
136
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
64
|
-
<DialogContent className="sm:max-w-[
|
|
137
|
+
<DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-hidden flex flex-col">
|
|
65
138
|
<DialogHeader>
|
|
66
|
-
<DialogTitle
|
|
139
|
+
<DialogTitle>
|
|
140
|
+
<Trans>Edit facet values</Trans>
|
|
141
|
+
</DialogTitle>
|
|
67
142
|
<DialogDescription>
|
|
68
|
-
<Trans>
|
|
143
|
+
<Trans>Add or remove facet values for {productIds.length} products</Trans>
|
|
69
144
|
</DialogDescription>
|
|
70
145
|
</DialogHeader>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
146
|
+
|
|
147
|
+
<div className="flex-1 overflow-hidden flex flex-col gap-4">
|
|
148
|
+
{/* Add new facet values section */}
|
|
149
|
+
<div className="flex items-center gap-2">
|
|
150
|
+
<div className="text-sm font-medium">
|
|
151
|
+
<Trans>Add facet value</Trans>
|
|
152
|
+
</div>
|
|
76
153
|
<FacetValueSelector
|
|
77
154
|
onValueSelect={handleFacetValueSelect}
|
|
78
155
|
placeholder="Search facet values..."
|
|
79
156
|
/>
|
|
80
157
|
</div>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
158
|
+
|
|
159
|
+
{/* Products table */}
|
|
160
|
+
<div className="flex-1 overflow-auto">
|
|
161
|
+
{isLoading ? (
|
|
162
|
+
<div className="flex items-center justify-center py-8">
|
|
163
|
+
<div className="text-sm text-muted-foreground">Loading...</div>
|
|
164
|
+
</div>
|
|
165
|
+
) : productsData?.products.items ? (
|
|
166
|
+
<div className="border rounded-md">
|
|
167
|
+
<table className="w-full">
|
|
168
|
+
<thead className="bg-muted/50">
|
|
169
|
+
<tr>
|
|
170
|
+
<th className="text-left p-3 text-sm font-medium">
|
|
171
|
+
<Trans>Product</Trans>
|
|
172
|
+
</th>
|
|
173
|
+
<th className="text-left p-3 text-sm font-medium">
|
|
174
|
+
<Trans>Current facet values</Trans>
|
|
175
|
+
</th>
|
|
176
|
+
</tr>
|
|
177
|
+
</thead>
|
|
178
|
+
<tbody>
|
|
179
|
+
{productsData.products.items.map(product => {
|
|
180
|
+
const displayFacetValues = getDisplayFacetValues(product);
|
|
181
|
+
return (
|
|
182
|
+
<tr key={product.id} className="border-t">
|
|
183
|
+
<td className="p-3 align-top">
|
|
184
|
+
<div className="font-medium">{product.name}</div>
|
|
185
|
+
</td>
|
|
186
|
+
<td className="p-3">
|
|
187
|
+
<div className="flex flex-wrap gap-2">
|
|
188
|
+
{displayFacetValues.map(facetValue => (
|
|
189
|
+
<FacetValueChip
|
|
190
|
+
key={facetValue.id}
|
|
191
|
+
facetValue={facetValue}
|
|
192
|
+
removable={true}
|
|
193
|
+
onRemove={() =>
|
|
194
|
+
removeFacetValue(
|
|
195
|
+
product.id,
|
|
196
|
+
facetValue.id,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
/>
|
|
200
|
+
))}
|
|
201
|
+
{displayFacetValues.length === 0 && (
|
|
202
|
+
<div className="text-sm text-muted-foreground">
|
|
203
|
+
<Trans>No facet values</Trans>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</td>
|
|
208
|
+
</tr>
|
|
209
|
+
);
|
|
210
|
+
})}
|
|
211
|
+
</tbody>
|
|
212
|
+
</table>
|
|
213
|
+
</div>
|
|
214
|
+
) : null}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{/* Selected values summary */}
|
|
218
|
+
{selectedValues.length > 0 && (
|
|
219
|
+
<div className="border-t pt-4">
|
|
220
|
+
<div className="text-sm font-medium mb-2">
|
|
221
|
+
<Trans>New facet values to add:</Trans>
|
|
222
|
+
</div>
|
|
223
|
+
<div className="flex flex-wrap gap-2">
|
|
224
|
+
{selectedValues.map(facetValue => (
|
|
225
|
+
<FacetValueChip
|
|
226
|
+
key={facetValue.id}
|
|
227
|
+
facetValue={facetValue}
|
|
228
|
+
removable={false}
|
|
229
|
+
/>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
84
232
|
</div>
|
|
85
233
|
)}
|
|
86
234
|
</div>
|
|
235
|
+
|
|
87
236
|
<DialogFooter>
|
|
88
|
-
<Button variant="outline" onClick={
|
|
237
|
+
<Button variant="outline" onClick={handleCancel}>
|
|
89
238
|
<Trans>Cancel</Trans>
|
|
90
239
|
</Button>
|
|
91
|
-
<Button
|
|
240
|
+
<Button
|
|
241
|
+
onClick={handleAssign}
|
|
242
|
+
disabled={(selectedValues.length === 0 && !facetValuesRemoved) || isPending}
|
|
243
|
+
>
|
|
92
244
|
<Trans>Update</Trans>
|
|
93
245
|
</Button>
|
|
94
246
|
</DialogFooter>
|
|
95
247
|
</DialogContent>
|
|
96
248
|
</Dialog>
|
|
97
249
|
);
|
|
98
|
-
}
|
|
250
|
+
}
|
|
@@ -61,7 +61,7 @@ export const DeleteProductsBulkAction: BulkActionComponent<any> = ({ selection,
|
|
|
61
61
|
|
|
62
62
|
export const AssignProductsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
|
|
63
63
|
const { refetchPaginatedList } = usePaginatedList();
|
|
64
|
-
const { channels
|
|
64
|
+
const { channels } = useChannel();
|
|
65
65
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
66
66
|
|
|
67
67
|
if (channels.length < 2) {
|
|
@@ -167,6 +167,27 @@ export const updateProductsDocument = graphql(`
|
|
|
167
167
|
}
|
|
168
168
|
`);
|
|
169
169
|
|
|
170
|
+
export const getProductsWithFacetValuesByIdsDocument = graphql(`
|
|
171
|
+
query GetProductsWithFacetValuesByIds($ids: [String!]!) {
|
|
172
|
+
products(options: { filter: { id: { in: $ids } } }) {
|
|
173
|
+
items {
|
|
174
|
+
id
|
|
175
|
+
name
|
|
176
|
+
facetValues {
|
|
177
|
+
id
|
|
178
|
+
name
|
|
179
|
+
code
|
|
180
|
+
facet {
|
|
181
|
+
id
|
|
182
|
+
name
|
|
183
|
+
code
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
`);
|
|
190
|
+
|
|
170
191
|
export const duplicateEntityDocument = graphql(`
|
|
171
192
|
mutation DuplicateEntity($input: DuplicateEntityInput!) {
|
|
172
193
|
duplicateEntity(input: $input) {
|
|
@@ -50,8 +50,7 @@ function ProductDetailPage() {
|
|
|
50
50
|
const navigate = useNavigate();
|
|
51
51
|
const creatingNewEntity = params.id === NEW_ENTITY_PATH;
|
|
52
52
|
const { i18n } = useLingui();
|
|
53
|
-
const refreshRef = useRef<() => void>(() => {
|
|
54
|
-
});
|
|
53
|
+
const refreshRef = useRef<() => void>(() => {});
|
|
55
54
|
|
|
56
55
|
const { form, submitHandler, entity, isPending, refreshEntity, resetForm } = useDetailPage({
|
|
57
56
|
entityName: 'Product',
|
package/src/app/styles.css
CHANGED
|
@@ -4,6 +4,7 @@ import { DataTablePagination } from '@/components/data-table/data-table-paginati
|
|
|
4
4
|
import { DataTableViewOptions } from '@/components/data-table/data-table-view-options.js';
|
|
5
5
|
import { RefreshButton } from '@/components/data-table/refresh-button.js';
|
|
6
6
|
import { Input } from '@/components/ui/input.js';
|
|
7
|
+
import { Skeleton } from '@/components/ui/skeleton.js';
|
|
7
8
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table.js';
|
|
8
9
|
import { BulkAction } from '@/framework/data-table/data-table-types.js';
|
|
9
10
|
import { useChannel } from '@/hooks/use-channel.js';
|
|
@@ -38,6 +39,7 @@ interface DataTableProps<TData> {
|
|
|
38
39
|
columns: ColumnDef<TData, any>[];
|
|
39
40
|
data: TData[];
|
|
40
41
|
totalItems: number;
|
|
42
|
+
isLoading?: boolean;
|
|
41
43
|
page?: number;
|
|
42
44
|
itemsPerPage?: number;
|
|
43
45
|
sorting?: SortingState;
|
|
@@ -63,6 +65,7 @@ export function DataTable<TData>({
|
|
|
63
65
|
columns,
|
|
64
66
|
data,
|
|
65
67
|
totalItems,
|
|
68
|
+
isLoading,
|
|
66
69
|
page,
|
|
67
70
|
itemsPerPage,
|
|
68
71
|
sorting: sortingInitialState,
|
|
@@ -149,6 +152,7 @@ export function DataTable<TData>({
|
|
|
149
152
|
onColumnVisibilityChange?.(table, columnVisibility);
|
|
150
153
|
}, [columnVisibility]);
|
|
151
154
|
|
|
155
|
+
const visibleColumnCount = Object.values(columnVisibility).filter(Boolean).length;
|
|
152
156
|
return (
|
|
153
157
|
<>
|
|
154
158
|
<div className="flex justify-between items-start">
|
|
@@ -200,7 +204,7 @@ export function DataTable<TData>({
|
|
|
200
204
|
</div>
|
|
201
205
|
<div className="flex items-center justify-start gap-2">
|
|
202
206
|
{!disableViewOptions && <DataTableViewOptions table={table} />}
|
|
203
|
-
{onRefresh && <RefreshButton onRefresh={onRefresh} />}
|
|
207
|
+
{onRefresh && <RefreshButton onRefresh={onRefresh} isLoading={isLoading ?? false} />}
|
|
204
208
|
</div>
|
|
205
209
|
</div>
|
|
206
210
|
<DataTableBulkActions bulkActions={bulkActions ?? []} table={table} />
|
|
@@ -225,18 +229,38 @@ export function DataTable<TData>({
|
|
|
225
229
|
))}
|
|
226
230
|
</TableHeader>
|
|
227
231
|
<TableBody>
|
|
228
|
-
{
|
|
232
|
+
{isLoading && !data?.length ? (
|
|
233
|
+
Array.from({ length: pagination.pageSize }).map((_, index) => (
|
|
234
|
+
<TableRow
|
|
235
|
+
key={`skeleton-${index}`}
|
|
236
|
+
className="animate-in fade-in duration-100"
|
|
237
|
+
>
|
|
238
|
+
{Array.from({ length: visibleColumnCount }).map((_, cellIndex) => (
|
|
239
|
+
<TableCell
|
|
240
|
+
key={`skeleton-cell-${index}-${cellIndex}`}
|
|
241
|
+
className="h-12"
|
|
242
|
+
>
|
|
243
|
+
<Skeleton className="h-4 my-2 w-full" />
|
|
244
|
+
</TableCell>
|
|
245
|
+
))}
|
|
246
|
+
</TableRow>
|
|
247
|
+
))
|
|
248
|
+
) : table.getRowModel().rows?.length ? (
|
|
229
249
|
table.getRowModel().rows.map(row => (
|
|
230
|
-
<TableRow
|
|
250
|
+
<TableRow
|
|
251
|
+
key={row.id}
|
|
252
|
+
data-state={row.getIsSelected() && 'selected'}
|
|
253
|
+
className="animate-in fade-in duration-100"
|
|
254
|
+
>
|
|
231
255
|
{row.getVisibleCells().map(cell => (
|
|
232
|
-
<TableCell key={cell.id}>
|
|
256
|
+
<TableCell key={cell.id} className="h-12">
|
|
233
257
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
234
258
|
</TableCell>
|
|
235
259
|
))}
|
|
236
260
|
</TableRow>
|
|
237
261
|
))
|
|
238
262
|
) : (
|
|
239
|
-
<TableRow>
|
|
263
|
+
<TableRow className="animate-in fade-in duration-100">
|
|
240
264
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
|
241
265
|
No results.
|
|
242
266
|
</TableCell>
|
|
@@ -1,25 +1,37 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
2
1
|
import { Button } from '@/components/ui/button.js';
|
|
3
2
|
import { RefreshCw } from 'lucide-react';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
const [
|
|
5
|
+
function useDelayedLoading(isLoading: boolean, delayMs: number = 100) {
|
|
6
|
+
const [delayedLoading, setDelayedLoading] = useState(isLoading);
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
if (
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (isLoading) {
|
|
10
|
+
// When loading starts, wait for the delay before showing loading state
|
|
11
|
+
const timer = setTimeout(() => {
|
|
12
|
+
setDelayedLoading(true);
|
|
13
|
+
}, delayMs);
|
|
14
|
+
|
|
15
|
+
return () => clearTimeout(timer);
|
|
16
|
+
} else {
|
|
17
|
+
// When loading stops, immediately hide loading state
|
|
18
|
+
setDelayedLoading(false);
|
|
12
19
|
}
|
|
20
|
+
}, [isLoading, delayMs]);
|
|
21
|
+
|
|
22
|
+
return delayedLoading;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function RefreshButton({ onRefresh, isLoading }: { onRefresh: () => void; isLoading: boolean }) {
|
|
26
|
+
const delayedLoading = useDelayedLoading(isLoading, 100);
|
|
27
|
+
|
|
28
|
+
const handleClick = () => {
|
|
29
|
+
onRefresh();
|
|
13
30
|
};
|
|
14
31
|
|
|
15
32
|
return (
|
|
16
|
-
<Button
|
|
17
|
-
|
|
18
|
-
size="sm"
|
|
19
|
-
onClick={handleClick}
|
|
20
|
-
>
|
|
21
|
-
<RefreshCw onAnimationEnd={() => setIsRotating(false)}
|
|
22
|
-
className={isRotating ? 'animate-rotate' : ''} />
|
|
33
|
+
<Button variant="ghost" size="sm" onClick={handleClick} disabled={delayedLoading}>
|
|
34
|
+
<RefreshCw className={delayedLoading ? 'animate-rotate' : ''} />
|
|
23
35
|
</Button>
|
|
24
36
|
);
|
|
25
37
|
}
|
|
@@ -40,10 +40,9 @@ export function CustomFieldsForm({ entityType, control, formPathPrefix }: Custom
|
|
|
40
40
|
|
|
41
41
|
const customFields = useCustomFieldConfig(entityType);
|
|
42
42
|
|
|
43
|
-
const getFieldName = (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
: `customFields.${fieldDefName}`;
|
|
43
|
+
const getFieldName = (fieldDef: CustomFieldConfig) => {
|
|
44
|
+
const name = fieldDef.type === 'relation' ? fieldDef.name + 'Id' : fieldDef.name;
|
|
45
|
+
return formPathPrefix ? `${formPathPrefix}.customFields.${name}` : `customFields.${name}`;
|
|
47
46
|
};
|
|
48
47
|
|
|
49
48
|
// Group custom fields by tabs
|
|
@@ -86,7 +85,7 @@ export function CustomFieldsForm({ entityType, control, formPathPrefix }: Custom
|
|
|
86
85
|
key={fieldDef.name}
|
|
87
86
|
fieldDef={fieldDef}
|
|
88
87
|
control={control}
|
|
89
|
-
fieldName={getFieldName(fieldDef
|
|
88
|
+
fieldName={getFieldName(fieldDef)}
|
|
90
89
|
getTranslation={getTranslation}
|
|
91
90
|
/>
|
|
92
91
|
))}
|
|
@@ -112,7 +111,7 @@ export function CustomFieldsForm({ entityType, control, formPathPrefix }: Custom
|
|
|
112
111
|
key={fieldDef.name}
|
|
113
112
|
fieldDef={fieldDef}
|
|
114
113
|
control={control}
|
|
115
|
-
fieldName={getFieldName(fieldDef
|
|
114
|
+
fieldName={getFieldName(fieldDef)}
|
|
116
115
|
getTranslation={getTranslation}
|
|
117
116
|
/>
|
|
118
117
|
))}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from '@/framework/document-introspection/get-document-structure.js';
|
|
8
8
|
import { useListQueryFields } from '@/framework/document-introspection/hooks.js';
|
|
9
9
|
import { api } from '@/graphql/api.js';
|
|
10
|
-
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
10
|
+
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
11
11
|
import { useDebounce } from '@uidotdev/usehooks';
|
|
12
12
|
|
|
13
13
|
import {
|
|
@@ -315,7 +315,7 @@ export function PaginatedListDataTable<
|
|
|
315
315
|
|
|
316
316
|
registerRefresher?.(refetchPaginatedList);
|
|
317
317
|
|
|
318
|
-
const { data } = useQuery({
|
|
318
|
+
const { data, isFetching } = useQuery({
|
|
319
319
|
queryFn: () => {
|
|
320
320
|
const searchFilter = onSearchTermChange ? onSearchTermChange(debouncedSearchTerm) : {};
|
|
321
321
|
const mergedFilter = { ...filter, ...searchFilter };
|
|
@@ -332,6 +332,7 @@ export function PaginatedListDataTable<
|
|
|
332
332
|
return api.query(listQuery, transformedVariables);
|
|
333
333
|
},
|
|
334
334
|
queryKey,
|
|
335
|
+
placeholderData: keepPreviousData,
|
|
335
336
|
});
|
|
336
337
|
|
|
337
338
|
const fields = useListQueryFields(listQuery);
|
|
@@ -484,6 +485,7 @@ export function PaginatedListDataTable<
|
|
|
484
485
|
<DataTable
|
|
485
486
|
columns={columns}
|
|
486
487
|
data={transformedData}
|
|
488
|
+
isLoading={isFetching}
|
|
487
489
|
page={page}
|
|
488
490
|
itemsPerPage={itemsPerPage}
|
|
489
491
|
sorting={sorting}
|
|
@@ -43,7 +43,11 @@ export function VendureImage({
|
|
|
43
43
|
...imgProps
|
|
44
44
|
}: VendureImageProps) {
|
|
45
45
|
if (!asset) {
|
|
46
|
-
return fallback ?
|
|
46
|
+
return fallback ? (
|
|
47
|
+
<>{fallback}</>
|
|
48
|
+
) : (
|
|
49
|
+
<PlaceholderImage preset={preset} width={width} height={height} className={className} />
|
|
50
|
+
);
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
// Build the URL with query parameters
|
|
@@ -75,11 +79,15 @@ export function VendureImage({
|
|
|
75
79
|
url.searchParams.set('fpy', asset.focalPoint.y.toString());
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
const minDimensions = getMinDimensions(preset, width, height);
|
|
83
|
+
|
|
78
84
|
return (
|
|
79
85
|
<img
|
|
80
86
|
src={url.toString()}
|
|
81
87
|
alt={alt || asset.name || ''}
|
|
82
88
|
className={cn(className, 'rounded-sm')}
|
|
89
|
+
width={minDimensions.width}
|
|
90
|
+
height={minDimensions.height}
|
|
83
91
|
style={style}
|
|
84
92
|
loading="lazy"
|
|
85
93
|
ref={ref}
|
|
@@ -88,6 +96,27 @@ export function VendureImage({
|
|
|
88
96
|
);
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
function getMinDimensions(preset?: ImagePreset, width?: number, height?: number) {
|
|
100
|
+
if (preset) {
|
|
101
|
+
switch (preset) {
|
|
102
|
+
case 'tiny':
|
|
103
|
+
return { width: 50, height: 50 };
|
|
104
|
+
case 'thumb':
|
|
105
|
+
return { width: 150, height: 150 };
|
|
106
|
+
case 'small':
|
|
107
|
+
return { width: 300, height: 300 };
|
|
108
|
+
case 'medium':
|
|
109
|
+
return { width: 500, height: 500 };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (width && height) {
|
|
114
|
+
return { width, height };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { width: 100, height: 100 };
|
|
118
|
+
}
|
|
119
|
+
|
|
91
120
|
export function PlaceholderImage({
|
|
92
121
|
width = 100,
|
|
93
122
|
height = 100,
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { getOperationVariablesFields } from '@/framework/document-introspection/get-document-structure.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createFormSchemaFromFields,
|
|
4
|
+
getDefaultValuesFromFields,
|
|
5
|
+
} from '@/framework/form-engine/form-schema-tools.js';
|
|
6
|
+
import { transformRelationFields } from '@/framework/form-engine/utils.js';
|
|
3
7
|
import { useChannel } from '@/hooks/use-channel.js';
|
|
4
8
|
import { useServerConfig } from '@/hooks/use-server-config.js';
|
|
5
9
|
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
|
|
@@ -54,7 +58,9 @@ export function useGeneratedForm<
|
|
|
54
58
|
},
|
|
55
59
|
mode: 'onChange',
|
|
56
60
|
defaultValues,
|
|
57
|
-
values: processedEntity
|
|
61
|
+
values: processedEntity
|
|
62
|
+
? transformRelationFields(updateFields, setValues(processedEntity))
|
|
63
|
+
: defaultValues,
|
|
58
64
|
});
|
|
59
65
|
let submitHandler = (event: FormEvent) => {
|
|
60
66
|
event.preventDefault();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { FieldInfo } from '../document-introspection/get-document-structure.js';
|
|
2
|
+
|
|
3
|
+
export function transformRelationFields<E extends Record<string, any>>(fields: FieldInfo[], entity: E) {
|
|
4
|
+
const processedEntity = { ...entity } as any;
|
|
5
|
+
|
|
6
|
+
for (const field of fields) {
|
|
7
|
+
if (field.name !== 'customFields' || !field.typeInfo) {
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (!entity.customFields || !processedEntity.customFields) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const customField of field.typeInfo) {
|
|
16
|
+
if (customField.type === 'ID') {
|
|
17
|
+
const relationField = customField.name;
|
|
18
|
+
const propertyAccessorKey = customField.name.replace(/Id$/, '');
|
|
19
|
+
const relationValue = entity.customFields[propertyAccessorKey];
|
|
20
|
+
const relationIdValue = relationValue?.id;
|
|
21
|
+
|
|
22
|
+
if (relationIdValue) {
|
|
23
|
+
processedEntity.customFields[relationField] = relationIdValue;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return processedEntity;
|
|
30
|
+
}
|