@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.
Files changed (37) hide show
  1. package/dist/plugin/utils/config-loader.d.ts +12 -1
  2. package/dist/plugin/utils/config-loader.js +25 -7
  3. package/dist/plugin/vite-plugin-vendure-dashboard.d.ts +8 -0
  4. package/dist/plugin/vite-plugin-vendure-dashboard.js +5 -1
  5. package/package.json +4 -4
  6. package/src/app/routes/_authenticated/_administrators/administrators_.$id.tsx +1 -4
  7. package/src/app/routes/_authenticated/_channels/channels.tsx +18 -0
  8. package/src/app/routes/_authenticated/_channels/channels_.$id.tsx +1 -5
  9. package/src/app/routes/_authenticated/_collections/collections_.$id.tsx +1 -4
  10. package/src/app/routes/_authenticated/_countries/countries_.$id.tsx +1 -4
  11. package/src/app/routes/_authenticated/_facets/facets_.$id.tsx +1 -4
  12. package/src/app/routes/_authenticated/_global-settings/global-settings.tsx +1 -5
  13. package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +56 -74
  14. package/src/app/routes/_authenticated/_products/components/add-product-variant-dialog.tsx +369 -0
  15. package/src/app/routes/_authenticated/_products/components/create-product-options-dialog.tsx +435 -0
  16. package/src/app/routes/_authenticated/_products/components/product-option-select.tsx +117 -0
  17. package/src/app/routes/_authenticated/_products/components/product-variants-table.tsx +4 -2
  18. package/src/app/routes/_authenticated/_products/products_.$id.tsx +17 -3
  19. package/src/app/routes/_authenticated/_profile/profile.tsx +1 -4
  20. package/src/app/routes/_authenticated/_sellers/sellers_.$id.tsx +1 -4
  21. package/src/lib/components/data-table/data-table-view-options.tsx +12 -2
  22. package/src/lib/components/data-table/data-table.tsx +9 -0
  23. package/src/lib/components/layout/channel-switcher.tsx +1 -2
  24. package/src/lib/components/shared/assigned-facet-values.tsx +13 -14
  25. package/src/lib/components/shared/entity-assets.tsx +140 -70
  26. package/src/lib/components/shared/paginated-list-data-table.tsx +10 -0
  27. package/src/lib/components/ui/button.tsx +1 -1
  28. package/src/lib/framework/form-engine/use-generated-form.tsx +1 -0
  29. package/src/lib/framework/page/list-page.tsx +2 -2
  30. package/src/lib/framework/page/use-detail-page.ts +7 -0
  31. package/src/lib/graphql/api.ts +10 -1
  32. package/src/lib/hooks/use-permissions.ts +4 -4
  33. package/src/lib/providers/auth.tsx +9 -3
  34. package/src/lib/providers/channel-provider.tsx +64 -24
  35. package/src/lib/providers/server-config.tsx +2 -2
  36. package/vite/utils/config-loader.ts +48 -13
  37. 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
+ }