@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,369 @@
|
|
|
1
|
+
import { MoneyInput } from '@/components/data-input/money-input.js';
|
|
2
|
+
import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
|
|
3
|
+
import { Button } from '@/components/ui/button.js';
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
DialogTrigger,
|
|
11
|
+
} from '@/components/ui/dialog.js';
|
|
12
|
+
import { Form } from '@/components/ui/form.js';
|
|
13
|
+
import { Input } from '@/components/ui/input.js';
|
|
14
|
+
import { api } from '@/graphql/api.js';
|
|
15
|
+
import { graphql, ResultOf, VariablesOf } from '@/graphql/graphql.js';
|
|
16
|
+
import { useChannel } from '@/hooks/use-channel.js';
|
|
17
|
+
import { Trans, useLingui } from '@/lib/trans.js';
|
|
18
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
19
|
+
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
20
|
+
import { Plus } from 'lucide-react';
|
|
21
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
22
|
+
import { useForm } from 'react-hook-form';
|
|
23
|
+
import { toast } from 'sonner';
|
|
24
|
+
import * as z from 'zod';
|
|
25
|
+
import { CreateProductOptionsDialog } from './create-product-options-dialog.js';
|
|
26
|
+
import { ProductOptionSelect } from './product-option-select.js';
|
|
27
|
+
|
|
28
|
+
const getProductOptionGroupsDocument = graphql(`
|
|
29
|
+
query GetProductOptionGroups($productId: ID!) {
|
|
30
|
+
product(id: $productId) {
|
|
31
|
+
id
|
|
32
|
+
name
|
|
33
|
+
optionGroups {
|
|
34
|
+
id
|
|
35
|
+
code
|
|
36
|
+
name
|
|
37
|
+
options {
|
|
38
|
+
id
|
|
39
|
+
code
|
|
40
|
+
name
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
variants {
|
|
44
|
+
id
|
|
45
|
+
name
|
|
46
|
+
sku
|
|
47
|
+
options {
|
|
48
|
+
id
|
|
49
|
+
code
|
|
50
|
+
name
|
|
51
|
+
groupId
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
`);
|
|
57
|
+
|
|
58
|
+
const createProductVariantDocument = graphql(`
|
|
59
|
+
mutation CreateProductVariant($input: CreateProductVariantInput!) {
|
|
60
|
+
createProductVariants(input: [$input]) {
|
|
61
|
+
id
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
`);
|
|
65
|
+
|
|
66
|
+
const createProductOptionDocument = graphql(`
|
|
67
|
+
mutation CreateProductOption($input: CreateProductOptionInput!) {
|
|
68
|
+
createProductOption(input: $input) {
|
|
69
|
+
id
|
|
70
|
+
code
|
|
71
|
+
name
|
|
72
|
+
groupId
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
`);
|
|
76
|
+
|
|
77
|
+
const createProductOptionGroupDocument = graphql(`
|
|
78
|
+
mutation CreateProductOptionGroup($input: CreateProductOptionGroupInput!) {
|
|
79
|
+
createProductOptionGroup(input: $input) {
|
|
80
|
+
id
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
`);
|
|
84
|
+
|
|
85
|
+
const addOptionGroupToProductDocument = graphql(`
|
|
86
|
+
mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
|
|
87
|
+
addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
|
|
88
|
+
id
|
|
89
|
+
optionGroups {
|
|
90
|
+
id
|
|
91
|
+
code
|
|
92
|
+
name
|
|
93
|
+
options {
|
|
94
|
+
id
|
|
95
|
+
code
|
|
96
|
+
name
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
`);
|
|
102
|
+
|
|
103
|
+
const formSchema = z.object({
|
|
104
|
+
name: z.string().min(1, 'Name is required'),
|
|
105
|
+
sku: z.string().min(1, 'SKU is required'),
|
|
106
|
+
price: z.string().min(1, 'Price is required'),
|
|
107
|
+
stockOnHand: z.string().min(1, 'Stock level is required'),
|
|
108
|
+
options: z.record(z.string(), z.string()),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
type FormValues = z.infer<typeof formSchema>;
|
|
112
|
+
|
|
113
|
+
export function AddProductVariantDialog({
|
|
114
|
+
productId,
|
|
115
|
+
onSuccess,
|
|
116
|
+
}: {
|
|
117
|
+
productId: string;
|
|
118
|
+
onSuccess?: () => void;
|
|
119
|
+
}) {
|
|
120
|
+
const [open, setOpen] = useState(false);
|
|
121
|
+
const { activeChannel } = useChannel();
|
|
122
|
+
const { i18n } = useLingui();
|
|
123
|
+
const [duplicateVariantError, setDuplicateVariantError] = useState<string | null>(null);
|
|
124
|
+
const [nameTouched, setNameTouched] = useState(false);
|
|
125
|
+
|
|
126
|
+
const { data: productData, refetch } = useQuery({
|
|
127
|
+
queryKey: ['productOptionGroups', productId],
|
|
128
|
+
queryFn: () => api.query(getProductOptionGroupsDocument, { productId }),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const form = useForm<FormValues>({
|
|
132
|
+
resolver: zodResolver(formSchema),
|
|
133
|
+
defaultValues: {
|
|
134
|
+
name: '',
|
|
135
|
+
sku: '',
|
|
136
|
+
price: '0',
|
|
137
|
+
stockOnHand: '0',
|
|
138
|
+
options: {},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const checkForDuplicateVariant = useCallback(
|
|
143
|
+
(values: FormValues) => {
|
|
144
|
+
if (!productData?.product) return;
|
|
145
|
+
|
|
146
|
+
const newOptionIds = Object.values(values.options).sort();
|
|
147
|
+
if (newOptionIds.length !== productData.product.optionGroups.length) {
|
|
148
|
+
setDuplicateVariantError(null);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const existingVariant = productData.product.variants.find(variant => {
|
|
153
|
+
const variantOptionIds = variant.options.map(opt => opt.id).sort();
|
|
154
|
+
return JSON.stringify(variantOptionIds) === JSON.stringify(newOptionIds);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (existingVariant) {
|
|
158
|
+
setDuplicateVariantError(
|
|
159
|
+
`A variant with these options already exists: ${existingVariant.name} (${existingVariant.sku})`,
|
|
160
|
+
);
|
|
161
|
+
} else {
|
|
162
|
+
setDuplicateVariantError(null);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
[productData?.product],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const generateNameFromOptions = useCallback(
|
|
169
|
+
(values: FormValues) => {
|
|
170
|
+
if (!productData?.product?.name || nameTouched) return;
|
|
171
|
+
|
|
172
|
+
const selectedOptions = Object.entries(values.options)
|
|
173
|
+
.map(([groupId, optionId]) => {
|
|
174
|
+
const group = productData.product?.optionGroups.find(g => g.id === groupId);
|
|
175
|
+
const option = group?.options.find(o => o.id === optionId);
|
|
176
|
+
return option?.name;
|
|
177
|
+
})
|
|
178
|
+
.filter(Boolean);
|
|
179
|
+
|
|
180
|
+
if (selectedOptions.length === productData.product.optionGroups.length) {
|
|
181
|
+
const newName = `${productData.product.name} ${selectedOptions.join(' ')}`;
|
|
182
|
+
form.setValue('name', newName, { shouldDirty: true });
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
[productData?.product, nameTouched, form],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// Watch for changes in options to check for duplicates and update name
|
|
189
|
+
const options = form.watch('options');
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
checkForDuplicateVariant(form.getValues());
|
|
192
|
+
generateNameFromOptions(form.getValues());
|
|
193
|
+
}, [JSON.stringify(options), checkForDuplicateVariant, generateNameFromOptions, form]);
|
|
194
|
+
|
|
195
|
+
// Also check when the dialog opens and product data is loaded
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (open && productData?.product) {
|
|
198
|
+
checkForDuplicateVariant(form.getValues());
|
|
199
|
+
}
|
|
200
|
+
}, [open, productData?.product, checkForDuplicateVariant, form]);
|
|
201
|
+
|
|
202
|
+
const createProductVariantMutation = useMutation({
|
|
203
|
+
mutationFn: api.mutate(createProductVariantDocument),
|
|
204
|
+
onSuccess: (result: ResultOf<typeof createProductVariantDocument>) => {
|
|
205
|
+
toast.success(i18n.t('Successfully created product variant'));
|
|
206
|
+
setOpen(false);
|
|
207
|
+
onSuccess?.();
|
|
208
|
+
},
|
|
209
|
+
onError: error => {
|
|
210
|
+
toast.error(i18n.t('Failed to create product variant'), {
|
|
211
|
+
description: error instanceof Error ? error.message : i18n.t('Unknown error'),
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const createProductOptionMutation = useMutation({
|
|
217
|
+
mutationFn: api.mutate(createProductOptionDocument),
|
|
218
|
+
onSuccess: (
|
|
219
|
+
result: ResultOf<typeof createProductOptionDocument>,
|
|
220
|
+
variables: VariablesOf<typeof createProductOptionDocument>,
|
|
221
|
+
) => {
|
|
222
|
+
if (result?.createProductOption) {
|
|
223
|
+
// Update the form with the new option
|
|
224
|
+
const currentOptions = form.getValues('options');
|
|
225
|
+
form.setValue('options', {
|
|
226
|
+
...currentOptions,
|
|
227
|
+
[variables.input.productOptionGroupId]: result.createProductOption.id,
|
|
228
|
+
});
|
|
229
|
+
// Refetch product data to get the new option
|
|
230
|
+
refetch();
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const onSubmit = useCallback(
|
|
236
|
+
(values: FormValues) => {
|
|
237
|
+
if (!productData?.product) return;
|
|
238
|
+
if (duplicateVariantError) return;
|
|
239
|
+
|
|
240
|
+
createProductVariantMutation.mutate({
|
|
241
|
+
input: {
|
|
242
|
+
productId,
|
|
243
|
+
sku: values.sku,
|
|
244
|
+
price: Number(values.price),
|
|
245
|
+
stockOnHand: Number(values.stockOnHand),
|
|
246
|
+
optionIds: Object.values(values.options),
|
|
247
|
+
translations: [
|
|
248
|
+
{
|
|
249
|
+
languageCode: 'en',
|
|
250
|
+
name: values.name,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
[createProductVariantMutation, productData?.product, duplicateVariantError, productId],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// If there are no option groups, show the create options dialog instead
|
|
260
|
+
if (productData?.product?.optionGroups.length === 0) {
|
|
261
|
+
return (
|
|
262
|
+
<CreateProductOptionsDialog
|
|
263
|
+
productId={productId}
|
|
264
|
+
onSuccess={() => {
|
|
265
|
+
refetch();
|
|
266
|
+
onSuccess?.();
|
|
267
|
+
}}
|
|
268
|
+
/>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
274
|
+
<DialogTrigger asChild>
|
|
275
|
+
<Button variant="outline">
|
|
276
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
277
|
+
<Trans>Add variant</Trans>
|
|
278
|
+
</Button>
|
|
279
|
+
</DialogTrigger>
|
|
280
|
+
<DialogContent>
|
|
281
|
+
<DialogHeader>
|
|
282
|
+
<DialogTitle>
|
|
283
|
+
<Trans>Add product variant</Trans>
|
|
284
|
+
</DialogTitle>
|
|
285
|
+
</DialogHeader>
|
|
286
|
+
<Form {...form}>
|
|
287
|
+
<form
|
|
288
|
+
onSubmit={e => {
|
|
289
|
+
e.stopPropagation();
|
|
290
|
+
form.handleSubmit(onSubmit)(e);
|
|
291
|
+
}}
|
|
292
|
+
className="space-y-4"
|
|
293
|
+
>
|
|
294
|
+
<div className="grid grid-cols-2 gap-4">
|
|
295
|
+
{productData?.product?.optionGroups.map(group => (
|
|
296
|
+
<ProductOptionSelect
|
|
297
|
+
key={group.id}
|
|
298
|
+
group={group}
|
|
299
|
+
value={form.watch(`options.${group.id}`)}
|
|
300
|
+
onChange={value => {
|
|
301
|
+
form.setValue(`options.${group.id}`, value, {
|
|
302
|
+
shouldDirty: true,
|
|
303
|
+
shouldValidate: true,
|
|
304
|
+
});
|
|
305
|
+
}}
|
|
306
|
+
onCreateOption={name => {
|
|
307
|
+
createProductOptionMutation.mutate({
|
|
308
|
+
input: {
|
|
309
|
+
productOptionGroupId: group.id,
|
|
310
|
+
code: name.toLowerCase().replace(/\s+/g, '-'),
|
|
311
|
+
translations: [
|
|
312
|
+
{
|
|
313
|
+
languageCode: 'en',
|
|
314
|
+
name,
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
}}
|
|
320
|
+
/>
|
|
321
|
+
))}
|
|
322
|
+
</div>
|
|
323
|
+
<FormFieldWrapper
|
|
324
|
+
control={form.control}
|
|
325
|
+
name="name"
|
|
326
|
+
label={<Trans>Name</Trans>}
|
|
327
|
+
render={({ field }) => <Input {...field} onFocus={() => setNameTouched(true)} />}
|
|
328
|
+
/>
|
|
329
|
+
<FormFieldWrapper
|
|
330
|
+
control={form.control}
|
|
331
|
+
name="sku"
|
|
332
|
+
label={<Trans>SKU</Trans>}
|
|
333
|
+
render={({ field }) => <Input {...field} />}
|
|
334
|
+
/>
|
|
335
|
+
<FormFieldWrapper
|
|
336
|
+
control={form.control}
|
|
337
|
+
name="price"
|
|
338
|
+
label={<Trans>Price</Trans>}
|
|
339
|
+
render={({ field }) => (
|
|
340
|
+
<MoneyInput
|
|
341
|
+
value={Number(field.value) || 0}
|
|
342
|
+
onChange={value => field.onChange(value.toString())}
|
|
343
|
+
currency={activeChannel?.defaultCurrencyCode ?? 'USD'}
|
|
344
|
+
/>
|
|
345
|
+
)}
|
|
346
|
+
/>
|
|
347
|
+
<FormFieldWrapper
|
|
348
|
+
control={form.control}
|
|
349
|
+
name="stockOnHand"
|
|
350
|
+
label={<Trans>Stock level</Trans>}
|
|
351
|
+
render={({ field }) => <Input type="number" {...field} />}
|
|
352
|
+
/>
|
|
353
|
+
<DialogFooter className="flex flex-col items-end gap-2">
|
|
354
|
+
{duplicateVariantError && (
|
|
355
|
+
<p className="text-sm text-destructive">{duplicateVariantError}</p>
|
|
356
|
+
)}
|
|
357
|
+
<Button
|
|
358
|
+
type="submit"
|
|
359
|
+
disabled={createProductVariantMutation.isPending || !!duplicateVariantError}
|
|
360
|
+
>
|
|
361
|
+
<Trans>Create variant</Trans>
|
|
362
|
+
</Button>
|
|
363
|
+
</DialogFooter>
|
|
364
|
+
</form>
|
|
365
|
+
</Form>
|
|
366
|
+
</DialogContent>
|
|
367
|
+
</Dialog>
|
|
368
|
+
);
|
|
369
|
+
}
|