@vendure/dashboard 3.3.2 → 3.3.3
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/dist/plugin/utils/config-loader.d.ts +12 -1
- package/dist/plugin/utils/config-loader.js +25 -7
- package/dist/plugin/vite-plugin-vendure-dashboard.d.ts +8 -0
- package/dist/plugin/vite-plugin-vendure-dashboard.js +5 -1
- package/package.json +4 -4
- package/src/app/routes/_authenticated/_administrators/administrators_.$id.tsx +1 -4
- package/src/app/routes/_authenticated/_channels/channels.tsx +18 -0
- package/src/app/routes/_authenticated/_channels/channels_.$id.tsx +1 -5
- package/src/app/routes/_authenticated/_collections/collections_.$id.tsx +1 -4
- package/src/app/routes/_authenticated/_countries/countries_.$id.tsx +1 -4
- package/src/app/routes/_authenticated/_facets/facets_.$id.tsx +1 -4
- package/src/app/routes/_authenticated/_global-settings/global-settings.tsx +1 -5
- package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +56 -74
- package/src/app/routes/_authenticated/_products/components/add-product-variant-dialog.tsx +369 -0
- package/src/app/routes/_authenticated/_products/components/create-product-options-dialog.tsx +435 -0
- package/src/app/routes/_authenticated/_products/components/product-option-select.tsx +117 -0
- package/src/app/routes/_authenticated/_products/components/product-variants-table.tsx +4 -2
- package/src/app/routes/_authenticated/_products/products_.$id.tsx +17 -3
- package/src/app/routes/_authenticated/_profile/profile.tsx +1 -4
- package/src/app/routes/_authenticated/_sellers/sellers_.$id.tsx +1 -4
- package/src/lib/components/data-table/data-table-view-options.tsx +12 -2
- package/src/lib/components/data-table/data-table.tsx +9 -0
- package/src/lib/components/layout/channel-switcher.tsx +1 -2
- package/src/lib/components/shared/assigned-facet-values.tsx +13 -14
- package/src/lib/components/shared/entity-assets.tsx +140 -70
- package/src/lib/components/shared/paginated-list-data-table.tsx +10 -0
- package/src/lib/components/ui/button.tsx +1 -1
- package/src/lib/framework/form-engine/use-generated-form.tsx +1 -0
- package/src/lib/framework/page/list-page.tsx +2 -2
- package/src/lib/framework/page/use-detail-page.ts +7 -0
- package/src/lib/graphql/api.ts +10 -1
- package/src/lib/hooks/use-permissions.ts +4 -4
- package/src/lib/providers/auth.tsx +9 -3
- package/src/lib/providers/channel-provider.tsx +64 -24
- package/src/lib/providers/server-config.tsx +2 -2
- package/vite/utils/config-loader.ts +48 -13
- package/vite/vite-plugin-vendure-dashboard.ts +14 -4
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { Button } from '@/components/ui/button.js';
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogFooter,
|
|
6
|
+
DialogHeader,
|
|
7
|
+
DialogTitle,
|
|
8
|
+
DialogTrigger,
|
|
9
|
+
} from '@/components/ui/dialog.js';
|
|
10
|
+
import { Form } from '@/components/ui/form.js';
|
|
11
|
+
import { Input } from '@/components/ui/input.js';
|
|
12
|
+
import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
|
|
13
|
+
import { api } from '@/graphql/api.js';
|
|
14
|
+
import { graphql } from '@/graphql/graphql.js';
|
|
15
|
+
import { Trans, useLingui } from '@/lib/trans.js';
|
|
16
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
17
|
+
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
18
|
+
import { Plus, Trash2 } from 'lucide-react';
|
|
19
|
+
import { useState } from 'react';
|
|
20
|
+
import { useForm } from 'react-hook-form';
|
|
21
|
+
import { toast } from 'sonner';
|
|
22
|
+
import * as z from 'zod';
|
|
23
|
+
|
|
24
|
+
const getProductDocument = graphql(`
|
|
25
|
+
query GetProduct($productId: ID!) {
|
|
26
|
+
product(id: $productId) {
|
|
27
|
+
id
|
|
28
|
+
name
|
|
29
|
+
variants {
|
|
30
|
+
id
|
|
31
|
+
name
|
|
32
|
+
sku
|
|
33
|
+
options {
|
|
34
|
+
id
|
|
35
|
+
code
|
|
36
|
+
name
|
|
37
|
+
groupId
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
optionGroups {
|
|
41
|
+
id
|
|
42
|
+
code
|
|
43
|
+
name
|
|
44
|
+
options {
|
|
45
|
+
id
|
|
46
|
+
code
|
|
47
|
+
name
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
`);
|
|
53
|
+
|
|
54
|
+
const createProductOptionGroupDocument = graphql(`
|
|
55
|
+
mutation CreateProductOptionGroup($input: CreateProductOptionGroupInput!) {
|
|
56
|
+
createProductOptionGroup(input: $input) {
|
|
57
|
+
id
|
|
58
|
+
code
|
|
59
|
+
name
|
|
60
|
+
options {
|
|
61
|
+
id
|
|
62
|
+
code
|
|
63
|
+
name
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
`);
|
|
68
|
+
|
|
69
|
+
const addOptionGroupToProductDocument = graphql(`
|
|
70
|
+
mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
|
|
71
|
+
addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
|
|
72
|
+
id
|
|
73
|
+
optionGroups {
|
|
74
|
+
id
|
|
75
|
+
code
|
|
76
|
+
name
|
|
77
|
+
options {
|
|
78
|
+
id
|
|
79
|
+
code
|
|
80
|
+
name
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
`);
|
|
86
|
+
|
|
87
|
+
const updateProductVariantDocument = graphql(`
|
|
88
|
+
mutation UpdateProductVariant($input: UpdateProductVariantInput!) {
|
|
89
|
+
updateProductVariant(input: $input) {
|
|
90
|
+
id
|
|
91
|
+
name
|
|
92
|
+
options {
|
|
93
|
+
id
|
|
94
|
+
code
|
|
95
|
+
name
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
`);
|
|
100
|
+
|
|
101
|
+
const formSchema = z.object({
|
|
102
|
+
optionGroups: z.array(z.object({
|
|
103
|
+
name: z.string().min(1, 'Option group name is required'),
|
|
104
|
+
options: z.array(z.string().min(1, 'Option name is required')).min(1, 'At least one option is required'),
|
|
105
|
+
})).min(1, 'At least one option group is required'),
|
|
106
|
+
existingVariantOptionIds: z.array(z.string()).min(1, 'Must select an option for the existing variant'),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
type FormValues = z.infer<typeof formSchema>;
|
|
110
|
+
|
|
111
|
+
export function CreateProductOptionsDialog({
|
|
112
|
+
productId,
|
|
113
|
+
onSuccess,
|
|
114
|
+
}: {
|
|
115
|
+
productId: string;
|
|
116
|
+
onSuccess?: () => void;
|
|
117
|
+
}) {
|
|
118
|
+
const [open, setOpen] = useState(false);
|
|
119
|
+
const { i18n } = useLingui();
|
|
120
|
+
|
|
121
|
+
const { data: productData } = useQuery({
|
|
122
|
+
queryKey: ['product', productId],
|
|
123
|
+
queryFn: () => api.query(getProductDocument, { productId }),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const form = useForm<FormValues>({
|
|
127
|
+
resolver: zodResolver(formSchema),
|
|
128
|
+
defaultValues: {
|
|
129
|
+
optionGroups: [{ name: '', options: [''] }],
|
|
130
|
+
existingVariantOptionIds: [],
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const createProductOptionGroupMutation = useMutation({
|
|
135
|
+
mutationFn: api.mutate(createProductOptionGroupDocument),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const addOptionGroupToProductMutation = useMutation({
|
|
139
|
+
mutationFn: api.mutate(addOptionGroupToProductDocument),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const updateProductVariantMutation = useMutation({
|
|
143
|
+
mutationFn: api.mutate(updateProductVariantDocument),
|
|
144
|
+
onSuccess: () => {
|
|
145
|
+
toast.success(i18n.t('Successfully created product options'));
|
|
146
|
+
setOpen(false);
|
|
147
|
+
onSuccess?.();
|
|
148
|
+
},
|
|
149
|
+
onError: (error) => {
|
|
150
|
+
toast.error(i18n.t('Failed to create product options'), {
|
|
151
|
+
description: error instanceof Error ? error.message : i18n.t('Unknown error'),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const onSubmit = async (values: FormValues) => {
|
|
157
|
+
if (!productData?.product) return;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
// Create all option groups and their options
|
|
161
|
+
const createdOptionGroups = await Promise.all(
|
|
162
|
+
values.optionGroups.map(async (group) => {
|
|
163
|
+
const result = await createProductOptionGroupMutation.mutateAsync({
|
|
164
|
+
input: {
|
|
165
|
+
code: group.name.toLowerCase().replace(/\s+/g, '-'),
|
|
166
|
+
translations: [
|
|
167
|
+
{
|
|
168
|
+
languageCode: "en",
|
|
169
|
+
name: group.name,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
options: group.options.map(option => ({
|
|
173
|
+
code: option.toLowerCase().replace(/\s+/g, '-'),
|
|
174
|
+
translations: [
|
|
175
|
+
{
|
|
176
|
+
languageCode: "en",
|
|
177
|
+
name: option,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
})),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Add the option group to the product
|
|
185
|
+
await addOptionGroupToProductMutation.mutateAsync({
|
|
186
|
+
productId,
|
|
187
|
+
optionGroupId: result.createProductOptionGroup.id,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return result.createProductOptionGroup;
|
|
191
|
+
})
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Combine existing and newly created option groups
|
|
195
|
+
const allOptionGroups = [
|
|
196
|
+
...(productData.product.optionGroups || []),
|
|
197
|
+
...createdOptionGroups,
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
// Map the selected option names to their IDs
|
|
201
|
+
const selectedOptionIds = values.existingVariantOptionIds.map((optionName, index) => {
|
|
202
|
+
const group = allOptionGroups[index];
|
|
203
|
+
const option = group.options.find(opt => opt.name === optionName);
|
|
204
|
+
if (!option) {
|
|
205
|
+
throw new Error(`Option "${optionName}" not found in group "${group.name}"`);
|
|
206
|
+
}
|
|
207
|
+
return option.id;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Update the existing variant with the selected options
|
|
211
|
+
if (productData.product.variants[0]) {
|
|
212
|
+
// Create a new name by appending the selected option names
|
|
213
|
+
const selectedOptionNames = values.existingVariantOptionIds
|
|
214
|
+
.map((optionName, index) => {
|
|
215
|
+
const group = allOptionGroups[index];
|
|
216
|
+
const option = group.options.find(opt => opt.name === optionName);
|
|
217
|
+
return option?.name;
|
|
218
|
+
})
|
|
219
|
+
.filter(Boolean)
|
|
220
|
+
.join(' ');
|
|
221
|
+
|
|
222
|
+
const newVariantName = `${productData.product.name} ${selectedOptionNames}`;
|
|
223
|
+
|
|
224
|
+
await updateProductVariantMutation.mutateAsync({
|
|
225
|
+
input: {
|
|
226
|
+
id: productData.product.variants[0].id,
|
|
227
|
+
optionIds: selectedOptionIds,
|
|
228
|
+
translations: [
|
|
229
|
+
{
|
|
230
|
+
languageCode: "en",
|
|
231
|
+
name: newVariantName,
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
toast.error(i18n.t('Failed to create product options'), {
|
|
239
|
+
description: error instanceof Error ? error.message : i18n.t('Unknown error'),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const addOptionGroup = () => {
|
|
245
|
+
const currentGroups = form.getValues('optionGroups');
|
|
246
|
+
form.setValue('optionGroups', [...currentGroups, { name: '', options: [''] }]);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const removeOptionGroup = (index: number) => {
|
|
250
|
+
const currentGroups = form.getValues('optionGroups');
|
|
251
|
+
form.setValue('optionGroups', currentGroups.filter((_, i) => i !== index));
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const addOption = (groupIndex: number) => {
|
|
255
|
+
const currentGroups = form.getValues('optionGroups');
|
|
256
|
+
const updatedGroups = [...currentGroups];
|
|
257
|
+
updatedGroups[groupIndex].options.push('');
|
|
258
|
+
form.setValue('optionGroups', updatedGroups);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const removeOption = (groupIndex: number, optionIndex: number) => {
|
|
262
|
+
const currentGroups = form.getValues('optionGroups');
|
|
263
|
+
const updatedGroups = [...currentGroups];
|
|
264
|
+
updatedGroups[groupIndex].options = updatedGroups[groupIndex].options.filter((_, i) => i !== optionIndex);
|
|
265
|
+
form.setValue('optionGroups', updatedGroups);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
270
|
+
<DialogTrigger asChild>
|
|
271
|
+
<Button variant="outline">
|
|
272
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
273
|
+
<Trans>Create options</Trans>
|
|
274
|
+
</Button>
|
|
275
|
+
</DialogTrigger>
|
|
276
|
+
<DialogContent className="max-w-2xl">
|
|
277
|
+
<DialogHeader>
|
|
278
|
+
<DialogTitle>
|
|
279
|
+
<Trans>Create product options</Trans>
|
|
280
|
+
</DialogTitle>
|
|
281
|
+
</DialogHeader>
|
|
282
|
+
<Form {...form}>
|
|
283
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
284
|
+
<div className="space-y-4">
|
|
285
|
+
{form.watch('optionGroups').map((group, groupIndex) => (
|
|
286
|
+
<div key={groupIndex} className="space-y-4 p-4 border rounded-lg">
|
|
287
|
+
<div className="flex justify-between items-center">
|
|
288
|
+
<FormFieldWrapper
|
|
289
|
+
control={form.control}
|
|
290
|
+
name={`optionGroups.${groupIndex}.name`}
|
|
291
|
+
label={<Trans>Option group name</Trans>}
|
|
292
|
+
render={({ field }) => (
|
|
293
|
+
<Input {...field} placeholder={i18n.t('e.g. Size')} />
|
|
294
|
+
)}
|
|
295
|
+
/>
|
|
296
|
+
{groupIndex > 0 && (
|
|
297
|
+
<Button
|
|
298
|
+
type="button"
|
|
299
|
+
variant="ghost"
|
|
300
|
+
onClick={() => removeOptionGroup(groupIndex)}
|
|
301
|
+
>
|
|
302
|
+
<Trans>Remove group</Trans>
|
|
303
|
+
</Button>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
<div className="space-y-2">
|
|
307
|
+
{group.options.map((_, optionIndex) => (
|
|
308
|
+
<div key={optionIndex} className="flex gap-2 items-end">
|
|
309
|
+
<FormFieldWrapper
|
|
310
|
+
control={form.control}
|
|
311
|
+
name={`optionGroups.${groupIndex}.options.${optionIndex}`}
|
|
312
|
+
label={<Trans>Option name</Trans>}
|
|
313
|
+
render={({ field }) => (
|
|
314
|
+
<Input {...field} placeholder={i18n.t('e.g. Small')} />
|
|
315
|
+
)}
|
|
316
|
+
/>
|
|
317
|
+
{optionIndex > 0 && (
|
|
318
|
+
<Button
|
|
319
|
+
type="button"
|
|
320
|
+
variant="ghost"
|
|
321
|
+
size="icon"
|
|
322
|
+
onClick={() => removeOption(groupIndex, optionIndex)}
|
|
323
|
+
>
|
|
324
|
+
<Trash2 className="h-4 w-4" />
|
|
325
|
+
</Button>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
))}
|
|
329
|
+
<Button
|
|
330
|
+
type="button"
|
|
331
|
+
variant="outline"
|
|
332
|
+
onClick={() => addOption(groupIndex)}
|
|
333
|
+
>
|
|
334
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
335
|
+
<Trans>Add option</Trans>
|
|
336
|
+
</Button>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
))}
|
|
340
|
+
<Button
|
|
341
|
+
type="button"
|
|
342
|
+
variant="outline"
|
|
343
|
+
size="sm"
|
|
344
|
+
onClick={addOptionGroup}
|
|
345
|
+
>
|
|
346
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
347
|
+
<Trans>Add another option group</Trans>
|
|
348
|
+
</Button>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{productData?.product?.variants[0] && (
|
|
352
|
+
<div className="space-y-4 p-4 border rounded-lg">
|
|
353
|
+
<h3 className="font-medium">
|
|
354
|
+
<Trans>Assign options to existing variant</Trans>
|
|
355
|
+
</h3>
|
|
356
|
+
<p className="text-sm text-muted-foreground">
|
|
357
|
+
<Trans>Select which options should apply to the existing variant "{productData.product.variants[0].name}"</Trans>
|
|
358
|
+
</p>
|
|
359
|
+
{/* Show existing option groups first */}
|
|
360
|
+
{productData.product.optionGroups?.map((group, groupIndex) => (
|
|
361
|
+
<FormFieldWrapper
|
|
362
|
+
key={group.id}
|
|
363
|
+
control={form.control}
|
|
364
|
+
name={`existingVariantOptionIds.${groupIndex}`}
|
|
365
|
+
label={group.name}
|
|
366
|
+
render={({ field }) => (
|
|
367
|
+
<select
|
|
368
|
+
className="w-full p-2 border rounded-md"
|
|
369
|
+
value={field.value}
|
|
370
|
+
onChange={(e) => {
|
|
371
|
+
const newValues = [...form.getValues('existingVariantOptionIds')];
|
|
372
|
+
newValues[groupIndex] = e.target.value;
|
|
373
|
+
form.setValue('existingVariantOptionIds', newValues);
|
|
374
|
+
}}
|
|
375
|
+
>
|
|
376
|
+
<option value="">Select an option</option>
|
|
377
|
+
{group.options.map((option) => (
|
|
378
|
+
<option key={option.id} value={option.name}>
|
|
379
|
+
{option.name}
|
|
380
|
+
</option>
|
|
381
|
+
))}
|
|
382
|
+
</select>
|
|
383
|
+
)}
|
|
384
|
+
/>
|
|
385
|
+
))}
|
|
386
|
+
{/* Then show new option groups */}
|
|
387
|
+
{form.watch('optionGroups').map((group, groupIndex) => (
|
|
388
|
+
<FormFieldWrapper
|
|
389
|
+
key={`new-${groupIndex}`}
|
|
390
|
+
control={form.control}
|
|
391
|
+
name={`existingVariantOptionIds.${(productData?.product?.optionGroups?.length || 0) + groupIndex}`}
|
|
392
|
+
label={group.name || <Trans>Option group {groupIndex + 1}</Trans>}
|
|
393
|
+
render={({ field }) => (
|
|
394
|
+
<select
|
|
395
|
+
className="w-full p-2 border rounded-md"
|
|
396
|
+
value={field.value}
|
|
397
|
+
onChange={(e) => {
|
|
398
|
+
const newValues = [...form.getValues('existingVariantOptionIds')];
|
|
399
|
+
newValues[(productData?.product?.optionGroups?.length || 0) + groupIndex] = e.target.value;
|
|
400
|
+
form.setValue('existingVariantOptionIds', newValues);
|
|
401
|
+
}}
|
|
402
|
+
>
|
|
403
|
+
<option value="">Select an option</option>
|
|
404
|
+
{group.options.map((option, optionIndex) => (
|
|
405
|
+
<option key={optionIndex} value={option}>
|
|
406
|
+
{option}
|
|
407
|
+
</option>
|
|
408
|
+
))}
|
|
409
|
+
</select>
|
|
410
|
+
)}
|
|
411
|
+
/>
|
|
412
|
+
))}
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
415
|
+
|
|
416
|
+
<DialogFooter>
|
|
417
|
+
<Button
|
|
418
|
+
type="submit"
|
|
419
|
+
disabled={
|
|
420
|
+
createProductOptionGroupMutation.isPending ||
|
|
421
|
+
addOptionGroupToProductMutation.isPending ||
|
|
422
|
+
updateProductVariantMutation.isPending ||
|
|
423
|
+
(productData?.product?.variants[0] &&
|
|
424
|
+
form.watch('existingVariantOptionIds').some(value => !value))
|
|
425
|
+
}
|
|
426
|
+
>
|
|
427
|
+
<Trans>Create options</Trans>
|
|
428
|
+
</Button>
|
|
429
|
+
</DialogFooter>
|
|
430
|
+
</form>
|
|
431
|
+
</Form>
|
|
432
|
+
</DialogContent>
|
|
433
|
+
</Dialog>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Plus, Check, ChevronsUpDown } from 'lucide-react';
|
|
3
|
+
import { Button } from '@/components/ui/button.js';
|
|
4
|
+
import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
|
|
5
|
+
import {
|
|
6
|
+
Command,
|
|
7
|
+
CommandEmpty,
|
|
8
|
+
CommandGroup,
|
|
9
|
+
CommandInput,
|
|
10
|
+
CommandItem,
|
|
11
|
+
} from '@/components/ui/command.js';
|
|
12
|
+
import {
|
|
13
|
+
Popover,
|
|
14
|
+
PopoverContent,
|
|
15
|
+
PopoverTrigger,
|
|
16
|
+
} from '@/components/ui/popover.js';
|
|
17
|
+
import { cn } from '@/lib/utils.js';
|
|
18
|
+
import { Trans, useLingui } from '@/lib/trans.js';
|
|
19
|
+
|
|
20
|
+
interface ProductOption {
|
|
21
|
+
id: string;
|
|
22
|
+
code: string;
|
|
23
|
+
name: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ProductOptionGroup {
|
|
27
|
+
id: string;
|
|
28
|
+
code: string;
|
|
29
|
+
name: string;
|
|
30
|
+
options: ProductOption[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ProductOptionSelectProps {
|
|
34
|
+
group: ProductOptionGroup;
|
|
35
|
+
value: string;
|
|
36
|
+
onChange: (value: string) => void;
|
|
37
|
+
onCreateOption: (name: string) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ProductOptionSelect({ group, value, onChange, onCreateOption }: ProductOptionSelectProps) {
|
|
41
|
+
const [open, setOpen] = useState(false);
|
|
42
|
+
const [newOptionInput, setNewOptionInput] = useState('');
|
|
43
|
+
const { i18n } = useLingui();
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<FormFieldWrapper
|
|
47
|
+
control={undefined}
|
|
48
|
+
name={`options.${group.id}`}
|
|
49
|
+
label={group.name}
|
|
50
|
+
render={() => (
|
|
51
|
+
<Popover
|
|
52
|
+
open={open}
|
|
53
|
+
onOpenChange={setOpen}
|
|
54
|
+
>
|
|
55
|
+
<PopoverTrigger asChild>
|
|
56
|
+
<Button
|
|
57
|
+
variant="outline"
|
|
58
|
+
role="combobox"
|
|
59
|
+
className="w-full justify-between"
|
|
60
|
+
>
|
|
61
|
+
{value
|
|
62
|
+
? group.options.find(
|
|
63
|
+
(option) => option.id === value,
|
|
64
|
+
)?.name
|
|
65
|
+
: <Trans>Select {group.name}</Trans>}
|
|
66
|
+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
67
|
+
</Button>
|
|
68
|
+
</PopoverTrigger>
|
|
69
|
+
<PopoverContent className="w-full p-0">
|
|
70
|
+
<Command>
|
|
71
|
+
<CommandInput
|
|
72
|
+
placeholder={i18n.t('Search {name}...').replace('{name}', group.name)}
|
|
73
|
+
onValueChange={setNewOptionInput}
|
|
74
|
+
/>
|
|
75
|
+
<CommandEmpty className='py-2'>
|
|
76
|
+
<div className="p-2">
|
|
77
|
+
<Button
|
|
78
|
+
variant="outline"
|
|
79
|
+
className="w-full justify-start"
|
|
80
|
+
onClick={() => {
|
|
81
|
+
if (newOptionInput) {
|
|
82
|
+
onCreateOption(newOptionInput);
|
|
83
|
+
}
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
87
|
+
<Trans>Add "{newOptionInput}"</Trans>
|
|
88
|
+
</Button>
|
|
89
|
+
</div>
|
|
90
|
+
</CommandEmpty>
|
|
91
|
+
<CommandGroup>
|
|
92
|
+
{group.options.map((option) => (
|
|
93
|
+
<CommandItem
|
|
94
|
+
key={option.id}
|
|
95
|
+
value={option.name}
|
|
96
|
+
onSelect={() => {
|
|
97
|
+
onChange(option.id);
|
|
98
|
+
setOpen(false);
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<Check
|
|
102
|
+
className={cn(
|
|
103
|
+
"mr-2 h-4 w-4",
|
|
104
|
+
value === option.id ? "opacity-100" : "opacity-0"
|
|
105
|
+
)}
|
|
106
|
+
/>
|
|
107
|
+
{option.name}
|
|
108
|
+
</CommandItem>
|
|
109
|
+
))}
|
|
110
|
+
</CommandGroup>
|
|
111
|
+
</Command>
|
|
112
|
+
</PopoverContent>
|
|
113
|
+
</Popover>
|
|
114
|
+
)}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PaginatedListDataTable } from "@/components/shared/paginated-list-data-table.js";
|
|
1
|
+
import { PaginatedListDataTable, PaginatedListRefresherRegisterFn } from "@/components/shared/paginated-list-data-table.js";
|
|
2
2
|
import { productVariantListDocument } from "../products.graphql.js";
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { ColumnFiltersState, SortingState } from "@tanstack/react-table";
|
|
@@ -9,9 +9,10 @@ import { Button } from "@/components/ui/button.js";
|
|
|
9
9
|
|
|
10
10
|
interface ProductVariantsTableProps {
|
|
11
11
|
productId: string;
|
|
12
|
+
registerRefresher?: PaginatedListRefresherRegisterFn;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export function ProductVariantsTable({ productId }: ProductVariantsTableProps) {
|
|
15
|
+
export function ProductVariantsTable({ productId, registerRefresher }: ProductVariantsTableProps) {
|
|
15
16
|
const { formatCurrencyName } = useLocalFormat();
|
|
16
17
|
const [page, setPage] = useState(1);
|
|
17
18
|
const [pageSize, setPageSize] = useState(10);
|
|
@@ -19,6 +20,7 @@ export function ProductVariantsTable({ productId }: ProductVariantsTableProps) {
|
|
|
19
20
|
const [filters, setFilters] = useState<ColumnFiltersState>([]);
|
|
20
21
|
|
|
21
22
|
return <PaginatedListDataTable
|
|
23
|
+
registerRefresher={registerRefresher}
|
|
22
24
|
listQuery={productVariantListDocument}
|
|
23
25
|
transformVariables={variables => ({
|
|
24
26
|
...variables,
|
|
@@ -27,7 +27,9 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
|
27
27
|
import { toast } from 'sonner';
|
|
28
28
|
import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
|
|
29
29
|
import { ProductVariantsTable } from './components/product-variants-table.js';
|
|
30
|
+
import { AddProductVariantDialog } from './components/add-product-variant-dialog.js';
|
|
30
31
|
import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
|
|
32
|
+
import { useRef } from 'react';
|
|
31
33
|
|
|
32
34
|
export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
|
|
33
35
|
component: ProductDetailPage,
|
|
@@ -48,6 +50,7 @@ function ProductDetailPage() {
|
|
|
48
50
|
const navigate = useNavigate();
|
|
49
51
|
const creatingNewEntity = params.id === NEW_ENTITY_PATH;
|
|
50
52
|
const { i18n } = useLingui();
|
|
53
|
+
const refreshRef = useRef<() => void>(() => {});
|
|
51
54
|
|
|
52
55
|
const { form, submitHandler, entity, isPending, refreshEntity, resetForm } = useDetailPage({
|
|
53
56
|
queryDocument: productDetailDocument,
|
|
@@ -68,7 +71,7 @@ function ProductDetailPage() {
|
|
|
68
71
|
description: translation.description,
|
|
69
72
|
customFields: (translation as any).customFields,
|
|
70
73
|
})),
|
|
71
|
-
customFields: entity.customFields,
|
|
74
|
+
customFields: entity.customFields as any,
|
|
72
75
|
};
|
|
73
76
|
},
|
|
74
77
|
params: { id: params.id },
|
|
@@ -85,7 +88,7 @@ function ProductDetailPage() {
|
|
|
85
88
|
});
|
|
86
89
|
},
|
|
87
90
|
});
|
|
88
|
-
|
|
91
|
+
|
|
89
92
|
return (
|
|
90
93
|
<Page pageId="product-detail" entity={entity} form={form} submitHandler={submitHandler}>
|
|
91
94
|
<PageTitle>{creatingNewEntity ? <Trans>New product</Trans> : (entity?.name ?? '')}</PageTitle>
|
|
@@ -139,7 +142,18 @@ function ProductDetailPage() {
|
|
|
139
142
|
<CustomFieldsPageBlock column="main" entityType="Product" control={form.control} />
|
|
140
143
|
{entity && entity.variantList.totalItems > 0 && (
|
|
141
144
|
<PageBlock column="main" blockId="product-variants-table">
|
|
142
|
-
<ProductVariantsTable productId={params.id}
|
|
145
|
+
<ProductVariantsTable productId={params.id} registerRefresher={refresher => {
|
|
146
|
+
refreshRef.current = refresher;
|
|
147
|
+
}} />
|
|
148
|
+
<div className="mt-4">
|
|
149
|
+
<AddProductVariantDialog
|
|
150
|
+
productId={params.id}
|
|
151
|
+
onSuccess={() => {
|
|
152
|
+
console.log('onSuccess');
|
|
153
|
+
refreshRef.current?.();
|
|
154
|
+
}}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
143
157
|
</PageBlock>
|
|
144
158
|
)}
|
|
145
159
|
{entity && entity.variantList.totalItems === 0 && (
|
|
@@ -58,14 +58,11 @@ function ProfilePage() {
|
|
|
58
58
|
},
|
|
59
59
|
params: { id: 'undefined' },
|
|
60
60
|
onSuccess: async data => {
|
|
61
|
-
toast(i18n.t('Successfully updated profile')
|
|
62
|
-
position: 'top-right',
|
|
63
|
-
});
|
|
61
|
+
toast(i18n.t('Successfully updated profile'));
|
|
64
62
|
form.reset(form.getValues());
|
|
65
63
|
},
|
|
66
64
|
onError: err => {
|
|
67
65
|
toast(i18n.t('Failed to update profile'), {
|
|
68
|
-
position: 'top-right',
|
|
69
66
|
description: err instanceof Error ? err.message : 'Unknown error',
|
|
70
67
|
});
|
|
71
68
|
},
|
|
@@ -51,9 +51,7 @@ function SellerDetailPage() {
|
|
|
51
51
|
},
|
|
52
52
|
params: { id: params.id },
|
|
53
53
|
onSuccess: async data => {
|
|
54
|
-
toast(i18n.t('Successfully updated seller')
|
|
55
|
-
position: 'top-right',
|
|
56
|
-
});
|
|
54
|
+
toast(i18n.t('Successfully updated seller'));
|
|
57
55
|
form.reset(form.getValues());
|
|
58
56
|
if (creatingNewEntity) {
|
|
59
57
|
await navigate({ to: `../${data?.id}`, from: Route.id });
|
|
@@ -61,7 +59,6 @@ function SellerDetailPage() {
|
|
|
61
59
|
},
|
|
62
60
|
onError: err => {
|
|
63
61
|
toast(i18n.t('Failed to update seller'), {
|
|
64
|
-
position: 'top-right',
|
|
65
62
|
description: err instanceof Error ? err.message : 'Unknown error',
|
|
66
63
|
});
|
|
67
64
|
},
|