@vendure/dashboard 3.7.2 → 3.7.3-master-202608050305

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 (108) hide show
  1. package/dist/bundle/chunks/{2SSKDMRQ-CMBxVbIh.js → 2SSKDMRQ-KZUCTCnr.js} +2 -2
  2. package/dist/bundle/chunks/{AP7HFJJL-PAkY4fBC.js → AP7HFJJL-kxZStlpP.js} +1 -1
  3. package/dist/bundle/chunks/{WDYDFRGG-xJgIWAuv.js → WDYDFRGG-BZRK2ITZ.js} +2 -2
  4. package/dist/bundle/chunks/ar-C8HyGurR.js +4 -0
  5. package/dist/bundle/chunks/bg--aJe6weI.js +4 -0
  6. package/dist/bundle/chunks/cs-DiORN27q.js +4 -0
  7. package/dist/bundle/chunks/de-BIbDqHi0.js +4 -0
  8. package/dist/bundle/chunks/en-BIXvudTL.js +4 -0
  9. package/dist/bundle/chunks/es-CtrB4rBI.js +4 -0
  10. package/dist/bundle/chunks/fa-psHxNGfe.js +4 -0
  11. package/dist/bundle/chunks/fr-C5nCJ4dT.js +4 -0
  12. package/dist/bundle/chunks/he-Bf9mrxS9.js +4 -0
  13. package/dist/bundle/chunks/hr-ukhxxcHj.js +4 -0
  14. package/dist/bundle/chunks/hu-C5Rw37rH.js +4 -0
  15. package/dist/bundle/chunks/it-9Wz02yYH.js +4 -0
  16. package/dist/bundle/chunks/ja-Dws9HYMD.js +4 -0
  17. package/dist/bundle/chunks/ko-BvE54r17.js +4 -0
  18. package/dist/bundle/chunks/{locale-BSCAi77j.js → locale-CJZpQnsu.js} +1 -1
  19. package/dist/bundle/chunks/{main-D8aGWlid.js → main-Df4f8P5A.js} +169 -40
  20. package/dist/bundle/chunks/nb-C5b8s5AZ.js +4 -0
  21. package/dist/bundle/chunks/ne-DidgSoes.js +4 -0
  22. package/dist/bundle/chunks/nl-Bs2EmoET.js +4 -0
  23. package/dist/bundle/chunks/pl-ByMSgi7e.js +4 -0
  24. package/dist/bundle/chunks/pt_BR-CTGGI34p.js +4 -0
  25. package/dist/bundle/chunks/pt_PT-BMabWU8-.js +4 -0
  26. package/dist/bundle/chunks/ro-DLijHEde.js +4 -0
  27. package/dist/bundle/chunks/ru-sL_M_iwR.js +4 -0
  28. package/dist/bundle/chunks/sv-C6w42jAh.js +4 -0
  29. package/dist/bundle/chunks/tr-Lr8P315q.js +4 -0
  30. package/dist/bundle/chunks/uk-5I_XIBoN.js +4 -0
  31. package/dist/bundle/chunks/{use-job-queue-polling-D8i6-8rR.js → use-job-queue-polling-BjEkkz66.js} +291 -70
  32. package/dist/bundle/chunks/uz-BjRS1co1.js +4 -0
  33. package/dist/bundle/chunks/zh_Hans-Bqmum3_n.js +4 -0
  34. package/dist/bundle/chunks/zh_Hant-pqbmvylJ.js +4 -0
  35. package/dist/bundle/dashboard.css +12 -0
  36. package/dist/bundle/lib.js +2 -2
  37. package/dist/bundle/main.js +2 -2
  38. package/package.json +3 -3
  39. package/src/app/routes/_authenticated/_orders/components/customer-address-selector.tsx +172 -41
  40. package/src/app/routes/_authenticated/_orders/orders_.$id_.modify.tsx +22 -0
  41. package/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx +61 -38
  42. package/src/app/routes/_authenticated/_orders/utils/use-modify-order.ts +26 -0
  43. package/src/i18n/locales/ar.po +69 -9
  44. package/src/i18n/locales/bg.po +69 -9
  45. package/src/i18n/locales/cs.po +69 -9
  46. package/src/i18n/locales/de.po +69 -9
  47. package/src/i18n/locales/en.po +69 -9
  48. package/src/i18n/locales/es.po +69 -9
  49. package/src/i18n/locales/fa.po +69 -9
  50. package/src/i18n/locales/fr.po +69 -9
  51. package/src/i18n/locales/he.po +69 -9
  52. package/src/i18n/locales/hr.po +69 -9
  53. package/src/i18n/locales/hu.po +69 -9
  54. package/src/i18n/locales/it.po +69 -9
  55. package/src/i18n/locales/ja.po +69 -9
  56. package/src/i18n/locales/ko.po +69 -9
  57. package/src/i18n/locales/nb.po +69 -9
  58. package/src/i18n/locales/ne.po +69 -9
  59. package/src/i18n/locales/nl.po +69 -9
  60. package/src/i18n/locales/pl.po +69 -9
  61. package/src/i18n/locales/pt_BR.po +69 -9
  62. package/src/i18n/locales/pt_PT.po +69 -9
  63. package/src/i18n/locales/ro.po +69 -9
  64. package/src/i18n/locales/ru.po +69 -9
  65. package/src/i18n/locales/sv.po +69 -9
  66. package/src/i18n/locales/tr.po +69 -9
  67. package/src/i18n/locales/uk.po +69 -9
  68. package/src/i18n/locales/uz.po +69 -9
  69. package/src/i18n/locales/zh_Hans.po +69 -9
  70. package/src/i18n/locales/zh_Hant.po +69 -9
  71. package/src/lib/components/shared/assign-to-channel-dialog.tsx +56 -31
  72. package/src/lib/components/shared/assign-to-channels.spec.ts +45 -0
  73. package/src/lib/components/shared/assign-to-channels.ts +31 -0
  74. package/src/lib/components/shared/customer-address-form.tsx +51 -36
  75. package/src/lib/components/shared/customer-selector.tsx +221 -39
  76. package/src/lib/framework/form-engine/form-schema-tools.spec.ts +48 -0
  77. package/src/lib/framework/form-engine/form-schema-tools.ts +1 -1
  78. package/src/lib/framework/page/list-page.spec.tsx +62 -0
  79. package/src/lib/framework/page/list-page.tsx +27 -0
  80. package/src/lib/graphql/common-operations.ts +33 -0
  81. package/dist/bundle/chunks/ar-DATX7H2x.js +0 -4
  82. package/dist/bundle/chunks/bg-DHS8PDKd.js +0 -4
  83. package/dist/bundle/chunks/cs-CGhaHD2Z.js +0 -4
  84. package/dist/bundle/chunks/de-BMtF5EY3.js +0 -4
  85. package/dist/bundle/chunks/en-DucAVNrv.js +0 -4
  86. package/dist/bundle/chunks/es-FBktM7AP.js +0 -4
  87. package/dist/bundle/chunks/fa-Dl5QN-Zp.js +0 -4
  88. package/dist/bundle/chunks/fr-TPBsPGD2.js +0 -4
  89. package/dist/bundle/chunks/he-CvNLNPHS.js +0 -4
  90. package/dist/bundle/chunks/hr-7QRa6pq0.js +0 -4
  91. package/dist/bundle/chunks/hu-BUCwangz.js +0 -4
  92. package/dist/bundle/chunks/it-DcVNes47.js +0 -4
  93. package/dist/bundle/chunks/ja-C-C3Z-2D.js +0 -4
  94. package/dist/bundle/chunks/ko-B41VP-2G.js +0 -4
  95. package/dist/bundle/chunks/nb-q5my_SIJ.js +0 -4
  96. package/dist/bundle/chunks/ne-DksEv0gd.js +0 -4
  97. package/dist/bundle/chunks/nl-BEWvhJPb.js +0 -4
  98. package/dist/bundle/chunks/pl-DRf5xQAy.js +0 -4
  99. package/dist/bundle/chunks/pt_BR-DEOQ9Vgg.js +0 -4
  100. package/dist/bundle/chunks/pt_PT-DvDBycjC.js +0 -4
  101. package/dist/bundle/chunks/ro-CNbK5V7l.js +0 -4
  102. package/dist/bundle/chunks/ru-BJ395nXO.js +0 -4
  103. package/dist/bundle/chunks/sv-Bxi6B55d.js +0 -4
  104. package/dist/bundle/chunks/tr-D2FNyrS9.js +0 -4
  105. package/dist/bundle/chunks/uk-C3iewRAE.js +0 -4
  106. package/dist/bundle/chunks/uz-DDVd9bpR.js +0 -4
  107. package/dist/bundle/chunks/zh_Hans-DFK1UiKv.js +0 -4
  108. package/dist/bundle/chunks/zh_Hant-BWs2-32i.js +0 -4
@@ -1,8 +1,10 @@
1
- import { toast } from '@/vdb/components/ui/sonner.js';
1
+ import { useMutation } from '@tanstack/react-query';
2
2
  import { ReactNode, useEffect, useState } from 'react';
3
+ import { toast } from '@/vdb/components/ui/sonner.js';
3
4
 
4
5
  import { ChannelCodeLabel } from '@/vdb/components/shared/channel-code-label.js';
5
6
  import { MultiSelect } from '@/vdb/components/shared/multi-select.js';
7
+ import { assignToChannels } from '@/vdb/components/shared/assign-to-channels.js';
6
8
  import { Button } from '@/vdb/components/ui/button.js';
7
9
  import {
8
10
  Dialog,
@@ -14,6 +16,7 @@ import {
14
16
  } from '@/vdb/components/ui/dialog.js';
15
17
  import { Input } from '@/vdb/components/ui/input.js';
16
18
  import { ResultOf } from '@/vdb/graphql/graphql.js';
19
+ import { plural } from '@lingui/core/macro';
17
20
  import { Trans, useLingui } from '@lingui/react/macro';
18
21
 
19
22
  import { useChannel } from '@/vdb/hooks/use-channel.js';
@@ -55,7 +58,6 @@ export function AssignToChannelDialog({
55
58
  }: Readonly<AssignToChannelDialogProps>) {
56
59
  const { t } = useLingui();
57
60
  const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
58
- const [isAssigning, setIsAssigning] = useState(false);
59
61
  const { channels, activeChannel } = useChannel();
60
62
  const entityIdsLength = entityIds.length;
61
63
 
@@ -63,7 +65,7 @@ export function AssignToChannelDialog({
63
65
  if (!open) setSelectedChannelIds([]);
64
66
  }, [open]);
65
67
 
66
- // Filter out the currently selected channel from available options
68
+ // Filter out the currently active channel from available options
67
69
  const availableChannels = channels.filter(channel => channel.id !== activeChannel?.id);
68
70
 
69
71
  const selectItems = availableChannels.map(ch => ({
@@ -72,42 +74,64 @@ export function AssignToChannelDialog({
72
74
  display: <ChannelCodeLabel code={ch.code} />,
73
75
  }));
74
76
 
75
- const handleAssign = async () => {
76
- setIsAssigning(true);
77
- try {
78
- const results = await Promise.allSettled(
79
- selectedChannelIds.map(channelId =>
80
- mutationFn({ input: buildInput(channelId, additionalData) }),
81
- ),
82
- );
83
-
84
- const failedChannelIds = results.flatMap((r, i) =>
85
- r.status === 'rejected' ? [selectedChannelIds[i]] : [],
86
- );
87
- const succeededCount = results.length - failedChannelIds.length;
88
-
89
- if (succeededCount > 0) {
90
- onSuccess?.();
77
+ const { mutate, isPending } = useMutation({
78
+ mutationFn: () =>
79
+ assignToChannels(
80
+ selectedChannelIds,
81
+ channelId => buildInput(channelId, additionalData),
82
+ mutationFn,
83
+ ),
84
+ onSuccess: ({ succeeded, failed }) => {
85
+ if (failed.length) {
86
+ console.error('Failed to assign to channels', failed);
91
87
  }
92
88
 
93
- if (failedChannelIds.length === 0) {
89
+ const failedCodes = failed
90
+ .map(f => channels.find(c => c.id === f.channelId)?.code ?? f.channelId)
91
+ .join(', ');
92
+
93
+ if (failed.length === 0) {
94
94
  toast.success(
95
- t`Successfully assigned ${entityIdsLength} ${entityType} to ${selectedChannelIds.length} channels`,
95
+ plural(succeeded.length, {
96
+ one: `Assigned ${entityIdsLength} ${entityType} to ${succeeded.length} channel`,
97
+ other: `Assigned ${entityIdsLength} ${entityType} to ${succeeded.length} channels`,
98
+ }),
96
99
  );
100
+ setSelectedChannelIds([]);
101
+ onSuccess?.();
97
102
  onOpenChange(false);
98
- } else {
99
- const firstRejected = results.find(r => r.status === 'rejected');
100
- const firstReason = firstRejected?.status === 'rejected' ? firstRejected.reason : undefined;
101
- const description = firstReason instanceof Error ? firstReason.message : undefined;
102
- setSelectedChannelIds(failedChannelIds);
103
+ } else if (succeeded.length === 0) {
103
104
  toast.error(
104
- t`Failed to assign ${entityIdsLength} ${entityType} to ${failedChannelIds.length} of ${selectedChannelIds.length} channels`,
105
- { description },
105
+ plural(failed.length, {
106
+ one: `Failed to assign ${entityIdsLength} ${entityType} to ${failed.length} channel`,
107
+ other: `Failed to assign ${entityIdsLength} ${entityType} to ${failed.length} channels`,
108
+ }),
109
+ { description: failedCodes },
110
+ );
111
+ } else {
112
+ toast.warning(
113
+ plural(succeeded.length, {
114
+ one: `Assigned ${entityIdsLength} ${entityType} to ${succeeded.length} channel`,
115
+ other: `Assigned ${entityIdsLength} ${entityType} to ${succeeded.length} channels`,
116
+ }),
117
+ { description: t`Failed for: ${failedCodes}` },
106
118
  );
119
+ // Keep the dialog open and narrow the selection to the failures, so retrying
120
+ // is one click and doesn't re-assign the channels that already succeeded.
121
+ setSelectedChannelIds(failed.map(f => f.channelId));
122
+ onSuccess?.();
107
123
  }
108
- } finally {
109
- setIsAssigning(false);
124
+ },
125
+ onError: () => {
126
+ toast.error(t`Failed to assign ${entityIdsLength} ${entityType} to the selected channels`);
127
+ },
128
+ });
129
+
130
+ const handleAssign = () => {
131
+ if (selectedChannelIds.length === 0) {
132
+ return;
110
133
  }
134
+ mutate();
111
135
  };
112
136
 
113
137
  return (
@@ -135,6 +159,7 @@ export function AssignToChannelDialog({
135
159
  onChange={setSelectedChannelIds}
136
160
  placeholder={t`Select one or more channels`}
137
161
  searchPlaceholder={t`Search channels...`}
162
+ showSearch
138
163
  className="w-full"
139
164
  />
140
165
  </div>
@@ -144,7 +169,7 @@ export function AssignToChannelDialog({
144
169
  <Button variant="outline" onClick={() => onOpenChange(false)}>
145
170
  <Trans>Cancel</Trans>
146
171
  </Button>
147
- <Button onClick={handleAssign} disabled={selectedChannelIds.length === 0 || isAssigning}>
172
+ <Button onClick={handleAssign} disabled={selectedChannelIds.length === 0 || isPending}>
148
173
  <Trans>Assign</Trans>
149
174
  </Button>
150
175
  </DialogFooter>
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { assignToChannels } from './assign-to-channels.js';
4
+
5
+ const buildInput = (channelId: string) => ({ channelId });
6
+
7
+ describe('assignToChannels', () => {
8
+ it('reports all successes', async () => {
9
+ const result = await assignToChannels(['a', 'b'], buildInput, vi.fn().mockResolvedValue({}));
10
+ expect(result).toEqual({ succeeded: ['a', 'b'], failed: [] });
11
+ });
12
+
13
+ it('reports all failures with their reasons', async () => {
14
+ const error = new Error('nope');
15
+ const result = await assignToChannels(['a'], buildInput, vi.fn().mockRejectedValue(error));
16
+ expect(result.succeeded).toEqual([]);
17
+ expect(result.failed).toEqual([{ channelId: 'a', reason: error }]);
18
+ });
19
+
20
+ it('records which channels failed on partial failure', async () => {
21
+ const mutationFn = vi.fn(({ input }) =>
22
+ input.channelId === 'b' ? Promise.reject(new Error('boom')) : Promise.resolve({}),
23
+ );
24
+ const result = await assignToChannels(['a', 'b', 'c'], buildInput, mutationFn);
25
+ expect(result.succeeded).toEqual(['a', 'c']);
26
+ expect(result.failed.map(f => f.channelId)).toEqual(['b']);
27
+ });
28
+
29
+ it('continues with remaining channels after a failure', async () => {
30
+ const mutationFn = vi.fn().mockRejectedValue(new Error('x'));
31
+ await assignToChannels(['a', 'b', 'c'], buildInput, mutationFn);
32
+ expect(mutationFn).toHaveBeenCalledTimes(3);
33
+ });
34
+
35
+ it('captures synchronous buildInput errors as failures', async () => {
36
+ const throwing = () => {
37
+ throw new Error('bad input');
38
+ };
39
+ const result = await assignToChannels(['a'], throwing, vi.fn());
40
+ expect(result.succeeded).toEqual([]);
41
+ expect(result.failed.length).toBe(1);
42
+ expect(result.failed[0].channelId).toBe('a');
43
+ expect(result.failed[0].reason).toBeInstanceOf(Error);
44
+ });
45
+ });
@@ -0,0 +1,31 @@
1
+ export interface AssignToChannelsResult {
2
+ succeeded: string[];
3
+ failed: Array<{ channelId: string; reason: unknown }>;
4
+ }
5
+
6
+ /**
7
+ * Sequentially assigns entities to multiple channels via the provided mutation function.
8
+ *
9
+ * Sequential execution is intentional: each mutation writes channel relations for the same
10
+ * entity rows, so a parallel fan-out only buys lock contention and N overlapping reindex jobs.
11
+ *
12
+ * @param channelIds - Channel IDs to assign to
13
+ * @param buildInput - Builds the mutation input for a given channel ID
14
+ * @param mutationFn - The GraphQL mutation function to call per channel
15
+ */
16
+ export async function assignToChannels(
17
+ channelIds: string[],
18
+ buildInput: (channelId: string) => Record<string, any>,
19
+ mutationFn: (variables: any) => Promise<unknown>,
20
+ ): Promise<AssignToChannelsResult> {
21
+ const result: AssignToChannelsResult = { succeeded: [], failed: [] };
22
+ for (const channelId of channelIds) {
23
+ try {
24
+ await mutationFn({ input: buildInput(channelId) });
25
+ result.succeeded.push(channelId);
26
+ } catch (reason) {
27
+ result.failed.push({ channelId, reason });
28
+ }
29
+ }
30
+ return result;
31
+ }
@@ -49,6 +49,17 @@ interface CustomerAddressFormProps<T = any> {
49
49
  setValuesForUpdate?: (values: T) => AddressFormValues;
50
50
  onSubmit?: (values: AddressFormValues) => void;
51
51
  onCancel?: () => void;
52
+ /**
53
+ * @description
54
+ * Hides the "Default Shipping Address" / "Default Billing Address" checkboxes. Used in contexts
55
+ * such as draft order creation where the default-address flags are not applicable.
56
+ */
57
+ hideDefaultAddressFlags?: boolean;
58
+ /**
59
+ * @description
60
+ * Custom label for the submit button. Defaults to "Save Address".
61
+ */
62
+ submitLabel?: React.ReactNode;
52
63
  }
53
64
 
54
65
  export function CustomerAddressForm<T>({
@@ -56,6 +67,8 @@ export function CustomerAddressForm<T>({
56
67
  setValuesForUpdate,
57
68
  onSubmit,
58
69
  onCancel,
70
+ hideDefaultAddressFlags = false,
71
+ submitLabel,
59
72
  }: CustomerAddressFormProps<T>) {
60
73
  const { t } = useLingui();
61
74
 
@@ -162,7 +175,7 @@ export function CustomerAddressForm<T>({
162
175
  name="postalCode"
163
176
  label={<Trans>Postal Code</Trans>}
164
177
  render={({ field }) => (
165
- <Input placeholder="Postal Code" {...field} value={field.value || ''} />
178
+ <Input placeholder="Postal Code (optional)" {...field} value={field.value || ''} />
166
179
  )}
167
180
  />
168
181
 
@@ -208,43 +221,45 @@ export function CustomerAddressForm<T>({
208
221
  {/* Custom Fields */}
209
222
  <CustomFieldsForm entityType="Address" control={form.control} />
210
223
  {/* Default Address Checkboxes */}
211
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
212
- <Controller
213
- control={form.control}
214
- name="defaultShippingAddress"
215
- render={({ field }) => (
216
- <div className="flex flex-row items-start space-x-3">
217
- <Checkbox checked={field.value} onCheckedChange={field.onChange} />
218
- <div className="space-y-1 leading-none">
219
- <FieldLabel>
220
- <Trans>Default Shipping Address</Trans>
221
- </FieldLabel>
222
- <FieldDescription>
223
- <Trans>Use as the default shipping address</Trans>
224
- </FieldDescription>
224
+ {!hideDefaultAddressFlags && (
225
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
226
+ <Controller
227
+ control={form.control}
228
+ name="defaultShippingAddress"
229
+ render={({ field }) => (
230
+ <div className="flex flex-row items-start space-x-3">
231
+ <Checkbox checked={field.value} onCheckedChange={field.onChange} />
232
+ <div className="space-y-1 leading-none">
233
+ <FieldLabel>
234
+ <Trans>Default Shipping Address</Trans>
235
+ </FieldLabel>
236
+ <FieldDescription>
237
+ <Trans>Use as the default shipping address</Trans>
238
+ </FieldDescription>
239
+ </div>
225
240
  </div>
226
- </div>
227
- )}
228
- />
241
+ )}
242
+ />
229
243
 
230
- <Controller
231
- control={form.control}
232
- name="defaultBillingAddress"
233
- render={({ field }) => (
234
- <div className="flex flex-row items-start space-x-3">
235
- <Checkbox checked={field.value} onCheckedChange={field.onChange} />
236
- <div className="space-y-1 leading-none">
237
- <FieldLabel>
238
- <Trans>Default Billing Address</Trans>
239
- </FieldLabel>
240
- <FieldDescription>
241
- <Trans>Use as the default billing address</Trans>
242
- </FieldDescription>
244
+ <Controller
245
+ control={form.control}
246
+ name="defaultBillingAddress"
247
+ render={({ field }) => (
248
+ <div className="flex flex-row items-start space-x-3">
249
+ <Checkbox checked={field.value} onCheckedChange={field.onChange} />
250
+ <div className="space-y-1 leading-none">
251
+ <FieldLabel>
252
+ <Trans>Default Billing Address</Trans>
253
+ </FieldLabel>
254
+ <FieldDescription>
255
+ <Trans>Use as the default billing address</Trans>
256
+ </FieldDescription>
257
+ </div>
243
258
  </div>
244
- </div>
245
- )}
246
- />
247
- </div>
259
+ )}
260
+ />
261
+ </div>
262
+ )}
248
263
 
249
264
  {/* Form Actions */}
250
265
  <div className="flex justify-end gap-2 pt-4">
@@ -254,7 +269,7 @@ export function CustomerAddressForm<T>({
254
269
  </Button>
255
270
  )}
256
271
  <Button type="submit">
257
- <Trans>Save Address</Trans>
272
+ {submitLabel ?? <Trans>Save Address</Trans>}
258
273
  </Button>
259
274
  </div>
260
275
  </form>
@@ -6,14 +6,22 @@ import {
6
6
  CommandItem,
7
7
  CommandList,
8
8
  } from '@/vdb/components/ui/command.js';
9
+ import { Form } from '@/vdb/components/ui/form.js';
10
+ import { Input } from '@/vdb/components/ui/input.js';
9
11
  import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
12
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/vdb/components/ui/tabs.js';
10
13
  import { api } from '@/vdb/graphql/api.js';
14
+ import { type CreateCustomerInput } from '@/vdb/graphql/common-operations.js';
11
15
  import { graphql } from '@/vdb/graphql/graphql.js';
12
- import { Trans } from '@lingui/react/macro';
16
+ import { z, zodResolver } from '@/vdb/lib/zod.js';
17
+ import { Trans, useLingui } from '@lingui/react/macro';
13
18
  import { useQuery } from '@tanstack/react-query';
14
19
  import { useDebounce } from '@uidotdev/usehooks';
15
- import { Plus } from 'lucide-react';
20
+ import { Plus, Users } from 'lucide-react';
16
21
  import { useState } from 'react';
22
+ import { useForm } from 'react-hook-form';
23
+ import { CustomFieldsForm } from './custom-fields-form.js';
24
+ import { FormFieldWrapper } from './form-field-wrapper.js';
17
25
 
18
26
  const customersDocument = graphql(`
19
27
  query GetCustomers($options: CustomerListOptions) {
@@ -38,12 +46,31 @@ export interface Customer {
38
46
 
39
47
  export interface CustomerSelectorProps {
40
48
  onSelect: (value: Customer) => void;
49
+ /**
50
+ * @description
51
+ * When enabled, the selector renders a popover with tabs allowing the admin to either select an
52
+ * existing customer or create a new one inline. When a new customer is submitted, `onCreateNew`
53
+ * is called with the {@link CreateCustomerInput}.
54
+ */
55
+ allowCreateNew?: boolean;
56
+ onCreateNew?: (input: CreateCustomerInput) => void | Promise<void>;
41
57
  label?: string | React.ReactNode;
42
58
  readOnly?: boolean;
43
59
  }
44
60
 
45
- export function CustomerSelector(props: CustomerSelectorProps) {
46
- const [open, setOpen] = useState(false);
61
+ const createCustomerFormSchema = z.object({
62
+ title: z.string().optional(),
63
+ firstName: z.string().min(1, { message: 'First name is required' }),
64
+ lastName: z.string().min(1, { message: 'Last name is required' }),
65
+ emailAddress: z.string().min(1, { message: 'Email address is required' }).email(),
66
+ phoneNumber: z.string().optional(),
67
+ customFields: z.any().optional(),
68
+ });
69
+
70
+ type CreateCustomerFormValues = z.infer<typeof createCustomerFormSchema>;
71
+
72
+ function CustomerSearch({ onSelect }: Readonly<{ onSelect: (value: Customer) => void }>) {
73
+ const { t } = useLingui();
47
74
  const [searchTerm, setSearchTerm] = useState('');
48
75
  const debouncedSearchTerm = useDebounce(searchTerm, 300);
49
76
 
@@ -66,44 +93,199 @@ export function CustomerSelector(props: CustomerSelectorProps) {
66
93
  staleTime: 1000 * 60, // 1 minute
67
94
  });
68
95
 
69
- const handleSearch = (value: string) => {
70
- setSearchTerm(value);
71
- };
96
+ return (
97
+ <Command shouldFilter={false}>
98
+ <CommandInput
99
+ placeholder={t`Search customers...`}
100
+ onValueChange={setSearchTerm}
101
+ className="h-10 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
102
+ />
103
+ <CommandList>
104
+ <CommandEmpty>
105
+ {isLoading ? <Trans>Loading...</Trans> : <Trans>No customers found</Trans>}
106
+ </CommandEmpty>
107
+ {data?.customers.items.map(customer => (
108
+ <CommandItem
109
+ key={customer.id}
110
+ onSelect={() => onSelect(customer)}
111
+ className="flex flex-col items-start"
112
+ >
113
+ <div className="font-medium">
114
+ {customer.firstName} {customer.lastName}
115
+ </div>
116
+ <div className="text-sm text-muted-foreground">{customer.emailAddress}</div>
117
+ </CommandItem>
118
+ ))}
119
+ </CommandList>
120
+ </Command>
121
+ );
122
+ }
123
+
124
+ function CreateCustomerForm({ onSubmit, onCancel }: Readonly<{ onSubmit: (input: CreateCustomerInput) => void; onCancel?: () => void }>) {
125
+ const form = useForm<CreateCustomerFormValues>({
126
+ resolver: zodResolver(createCustomerFormSchema),
127
+ defaultValues: {
128
+ title: '',
129
+ firstName: '',
130
+ lastName: '',
131
+ emailAddress: '',
132
+ phoneNumber: '',
133
+ customFields: {},
134
+ },
135
+ mode: 'onChange',
136
+ });
72
137
 
73
138
  return (
74
- <Popover open={open} onOpenChange={setOpen}>
75
- <PopoverTrigger render={<Button variant="outline" size="sm" type="button" disabled={props.readOnly} className="gap-2" />}>
76
- <Plus className="h-4 w-4" />
77
- {props.label ?? <Trans>Select customer</Trans>}
78
- </PopoverTrigger>
79
- <PopoverContent className="p-0 w-[350px]" align="start">
80
- <Command shouldFilter={false}>
81
- <CommandInput
82
- placeholder="Search customers..."
83
- onValueChange={handleSearch}
84
- className="h-10 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
139
+ <Form {...form}>
140
+ <form
141
+ onSubmit={e => {
142
+ e.stopPropagation();
143
+ form.handleSubmit(onSubmit)(e).catch(() => undefined);
144
+ }}
145
+ className="space-y-4"
146
+ >
147
+ <div className="grid grid-cols-1 gap-4">
148
+ <FormFieldWrapper
149
+ control={form.control}
150
+ name="title"
151
+ label={<Trans>Title</Trans>}
152
+ render={({ field }) => <Input {...field} value={field.value || ''} />}
153
+ />
154
+ <FormFieldWrapper
155
+ control={form.control}
156
+ name="firstName"
157
+ label={<Trans>First name</Trans>}
158
+ render={({ field }) => <Input {...field} value={field.value || ''} />}
159
+ />
160
+ <FormFieldWrapper
161
+ control={form.control}
162
+ name="lastName"
163
+ label={<Trans>Last name</Trans>}
164
+ render={({ field }) => <Input {...field} value={field.value || ''} />}
165
+ />
166
+ <FormFieldWrapper
167
+ control={form.control}
168
+ name="emailAddress"
169
+ label={<Trans>Email address</Trans>}
170
+ render={({ field }) => <Input {...field} value={field.value || ''} />}
171
+ />
172
+ <FormFieldWrapper
173
+ control={form.control}
174
+ name="phoneNumber"
175
+ label={<Trans>Phone number</Trans>}
176
+ render={({ field }) => <Input {...field} value={field.value || ''} />}
85
177
  />
86
- <CommandList>
87
- <CommandEmpty>
88
- {isLoading ? <Trans>Loading...</Trans> : <Trans>No customers found</Trans>}
89
- </CommandEmpty>
90
- {data?.customers.items.map(customer => (
91
- <CommandItem
92
- key={customer.id}
93
- onSelect={() => {
94
- props.onSelect(customer);
95
- setOpen(false);
96
- }}
97
- className="flex flex-col items-start"
98
- >
99
- <div className="font-medium">
100
- {customer.firstName} {customer.lastName}
101
- </div>
102
- <div className="text-sm text-muted-foreground">{customer.emailAddress}</div>
103
- </CommandItem>
104
- ))}
105
- </CommandList>
106
- </Command>
178
+ </div>
179
+ <CustomFieldsForm entityType="Customer" control={form.control} />
180
+ <div className="flex justify-end gap-2 pt-2">
181
+ {onCancel && (
182
+ <Button type="button" variant="outline" onClick={onCancel}>
183
+ <Trans>Cancel</Trans>
184
+ </Button>
185
+ )}
186
+ <Button type="submit" disabled={!form.formState.isValid}>
187
+ <Trans>Create customer</Trans>
188
+ </Button>
189
+ </div>
190
+ </form>
191
+ </Form>
192
+ );
193
+ }
194
+
195
+ export function CustomerSelector(props: CustomerSelectorProps) {
196
+ const [open, setOpen] = useState(false);
197
+ const [activeTab, setActiveTab] = useState<string>('existing');
198
+
199
+ const trigger = (
200
+ <PopoverTrigger
201
+ render={
202
+ <Button
203
+ variant="outline"
204
+ size="sm"
205
+ type="button"
206
+ disabled={props.readOnly}
207
+ className="gap-2"
208
+ />
209
+ }
210
+ >
211
+ <Plus className="h-4 w-4" />
212
+ {props.label ?? <Trans>Select customer</Trans>}
213
+ </PopoverTrigger>
214
+ );
215
+
216
+ // When inline creation is not enabled, preserve the original lightweight popover behaviour.
217
+ if (!props.allowCreateNew) {
218
+ return (
219
+ <Popover open={open} onOpenChange={setOpen}>
220
+ {trigger}
221
+ <PopoverContent className="p-0 w-[350px]" align="start">
222
+ <CustomerSearch
223
+ onSelect={customer => {
224
+ props.onSelect(customer);
225
+ setOpen(false);
226
+ }}
227
+ />
228
+ </PopoverContent>
229
+ </Popover>
230
+ );
231
+ }
232
+
233
+ return (
234
+ <Popover
235
+ open={open}
236
+ onOpenChange={(isOpen, eventDetails) => {
237
+ // While the create tab is open, only our own submit/cancel may close the popover
238
+ if (!isOpen && activeTab === 'new') {
239
+ return eventDetails.cancel();
240
+ }
241
+ setOpen(isOpen);
242
+ if (!isOpen) {
243
+ setActiveTab('existing');
244
+ }
245
+ }}
246
+ >
247
+ {trigger}
248
+ <PopoverContent className="w-[420px] p-0" align="start">
249
+ <div className="p-4">
250
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
251
+ <TabsList className="grid w-full grid-cols-2">
252
+ <TabsTrigger value="existing">
253
+ <Users className="mr-2 h-4 w-4" />
254
+ <Trans>Existing customer</Trans>
255
+ </TabsTrigger>
256
+ <TabsTrigger value="new">
257
+ <Plus className="mr-2 h-4 w-4" />
258
+ <Trans>Create new customer</Trans>
259
+ </TabsTrigger>
260
+ </TabsList>
261
+ <TabsContent value="existing">
262
+ <div className="mt-2 rounded-md border">
263
+ <CustomerSearch
264
+ onSelect={customer => {
265
+ props.onSelect(customer);
266
+ setOpen(false);
267
+ setActiveTab('existing');
268
+ }}
269
+ />
270
+ </div>
271
+ </TabsContent>
272
+ <TabsContent value="new">
273
+ <div className="mt-2">
274
+ <CreateCustomerForm
275
+ onSubmit={async input => {
276
+ await props.onCreateNew?.(input);
277
+ setOpen(false);
278
+ setActiveTab('existing');
279
+ }}
280
+ onCancel={() => {
281
+ setOpen(false);
282
+ setActiveTab('existing');
283
+ }}
284
+ />
285
+ </div>
286
+ </TabsContent>
287
+ </Tabs>
288
+ </div>
107
289
  </PopoverContent>
108
290
  </Popover>
109
291
  );