@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.
- package/dist/bundle/chunks/{2SSKDMRQ-CMBxVbIh.js → 2SSKDMRQ-KZUCTCnr.js} +2 -2
- package/dist/bundle/chunks/{AP7HFJJL-PAkY4fBC.js → AP7HFJJL-kxZStlpP.js} +1 -1
- package/dist/bundle/chunks/{WDYDFRGG-xJgIWAuv.js → WDYDFRGG-BZRK2ITZ.js} +2 -2
- package/dist/bundle/chunks/ar-C8HyGurR.js +4 -0
- package/dist/bundle/chunks/bg--aJe6weI.js +4 -0
- package/dist/bundle/chunks/cs-DiORN27q.js +4 -0
- package/dist/bundle/chunks/de-BIbDqHi0.js +4 -0
- package/dist/bundle/chunks/en-BIXvudTL.js +4 -0
- package/dist/bundle/chunks/es-CtrB4rBI.js +4 -0
- package/dist/bundle/chunks/fa-psHxNGfe.js +4 -0
- package/dist/bundle/chunks/fr-C5nCJ4dT.js +4 -0
- package/dist/bundle/chunks/he-Bf9mrxS9.js +4 -0
- package/dist/bundle/chunks/hr-ukhxxcHj.js +4 -0
- package/dist/bundle/chunks/hu-C5Rw37rH.js +4 -0
- package/dist/bundle/chunks/it-9Wz02yYH.js +4 -0
- package/dist/bundle/chunks/ja-Dws9HYMD.js +4 -0
- package/dist/bundle/chunks/ko-BvE54r17.js +4 -0
- package/dist/bundle/chunks/{locale-BSCAi77j.js → locale-CJZpQnsu.js} +1 -1
- package/dist/bundle/chunks/{main-D8aGWlid.js → main-Df4f8P5A.js} +169 -40
- package/dist/bundle/chunks/nb-C5b8s5AZ.js +4 -0
- package/dist/bundle/chunks/ne-DidgSoes.js +4 -0
- package/dist/bundle/chunks/nl-Bs2EmoET.js +4 -0
- package/dist/bundle/chunks/pl-ByMSgi7e.js +4 -0
- package/dist/bundle/chunks/pt_BR-CTGGI34p.js +4 -0
- package/dist/bundle/chunks/pt_PT-BMabWU8-.js +4 -0
- package/dist/bundle/chunks/ro-DLijHEde.js +4 -0
- package/dist/bundle/chunks/ru-sL_M_iwR.js +4 -0
- package/dist/bundle/chunks/sv-C6w42jAh.js +4 -0
- package/dist/bundle/chunks/tr-Lr8P315q.js +4 -0
- package/dist/bundle/chunks/uk-5I_XIBoN.js +4 -0
- package/dist/bundle/chunks/{use-job-queue-polling-D8i6-8rR.js → use-job-queue-polling-BjEkkz66.js} +291 -70
- package/dist/bundle/chunks/uz-BjRS1co1.js +4 -0
- package/dist/bundle/chunks/zh_Hans-Bqmum3_n.js +4 -0
- package/dist/bundle/chunks/zh_Hant-pqbmvylJ.js +4 -0
- package/dist/bundle/dashboard.css +12 -0
- package/dist/bundle/lib.js +2 -2
- package/dist/bundle/main.js +2 -2
- package/package.json +3 -3
- package/src/app/routes/_authenticated/_orders/components/customer-address-selector.tsx +172 -41
- package/src/app/routes/_authenticated/_orders/orders_.$id_.modify.tsx +22 -0
- package/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx +61 -38
- package/src/app/routes/_authenticated/_orders/utils/use-modify-order.ts +26 -0
- package/src/i18n/locales/ar.po +69 -9
- package/src/i18n/locales/bg.po +69 -9
- package/src/i18n/locales/cs.po +69 -9
- package/src/i18n/locales/de.po +69 -9
- package/src/i18n/locales/en.po +69 -9
- package/src/i18n/locales/es.po +69 -9
- package/src/i18n/locales/fa.po +69 -9
- package/src/i18n/locales/fr.po +69 -9
- package/src/i18n/locales/he.po +69 -9
- package/src/i18n/locales/hr.po +69 -9
- package/src/i18n/locales/hu.po +69 -9
- package/src/i18n/locales/it.po +69 -9
- package/src/i18n/locales/ja.po +69 -9
- package/src/i18n/locales/ko.po +69 -9
- package/src/i18n/locales/nb.po +69 -9
- package/src/i18n/locales/ne.po +69 -9
- package/src/i18n/locales/nl.po +69 -9
- package/src/i18n/locales/pl.po +69 -9
- package/src/i18n/locales/pt_BR.po +69 -9
- package/src/i18n/locales/pt_PT.po +69 -9
- package/src/i18n/locales/ro.po +69 -9
- package/src/i18n/locales/ru.po +69 -9
- package/src/i18n/locales/sv.po +69 -9
- package/src/i18n/locales/tr.po +69 -9
- package/src/i18n/locales/uk.po +69 -9
- package/src/i18n/locales/uz.po +69 -9
- package/src/i18n/locales/zh_Hans.po +69 -9
- package/src/i18n/locales/zh_Hant.po +69 -9
- package/src/lib/components/shared/assign-to-channel-dialog.tsx +56 -31
- package/src/lib/components/shared/assign-to-channels.spec.ts +45 -0
- package/src/lib/components/shared/assign-to-channels.ts +31 -0
- package/src/lib/components/shared/customer-address-form.tsx +51 -36
- package/src/lib/components/shared/customer-selector.tsx +221 -39
- package/src/lib/framework/form-engine/form-schema-tools.spec.ts +48 -0
- package/src/lib/framework/form-engine/form-schema-tools.ts +1 -1
- package/src/lib/framework/page/list-page.spec.tsx +62 -0
- package/src/lib/framework/page/list-page.tsx +27 -0
- package/src/lib/graphql/common-operations.ts +33 -0
- package/dist/bundle/chunks/ar-DATX7H2x.js +0 -4
- package/dist/bundle/chunks/bg-DHS8PDKd.js +0 -4
- package/dist/bundle/chunks/cs-CGhaHD2Z.js +0 -4
- package/dist/bundle/chunks/de-BMtF5EY3.js +0 -4
- package/dist/bundle/chunks/en-DucAVNrv.js +0 -4
- package/dist/bundle/chunks/es-FBktM7AP.js +0 -4
- package/dist/bundle/chunks/fa-Dl5QN-Zp.js +0 -4
- package/dist/bundle/chunks/fr-TPBsPGD2.js +0 -4
- package/dist/bundle/chunks/he-CvNLNPHS.js +0 -4
- package/dist/bundle/chunks/hr-7QRa6pq0.js +0 -4
- package/dist/bundle/chunks/hu-BUCwangz.js +0 -4
- package/dist/bundle/chunks/it-DcVNes47.js +0 -4
- package/dist/bundle/chunks/ja-C-C3Z-2D.js +0 -4
- package/dist/bundle/chunks/ko-B41VP-2G.js +0 -4
- package/dist/bundle/chunks/nb-q5my_SIJ.js +0 -4
- package/dist/bundle/chunks/ne-DksEv0gd.js +0 -4
- package/dist/bundle/chunks/nl-BEWvhJPb.js +0 -4
- package/dist/bundle/chunks/pl-DRf5xQAy.js +0 -4
- package/dist/bundle/chunks/pt_BR-DEOQ9Vgg.js +0 -4
- package/dist/bundle/chunks/pt_PT-DvDBycjC.js +0 -4
- package/dist/bundle/chunks/ro-CNbK5V7l.js +0 -4
- package/dist/bundle/chunks/ru-BJ395nXO.js +0 -4
- package/dist/bundle/chunks/sv-Bxi6B55d.js +0 -4
- package/dist/bundle/chunks/tr-D2FNyrS9.js +0 -4
- package/dist/bundle/chunks/uk-C3iewRAE.js +0 -4
- package/dist/bundle/chunks/uz-DDVd9bpR.js +0 -4
- package/dist/bundle/chunks/zh_Hans-DFK1UiKv.js +0 -4
- package/dist/bundle/chunks/zh_Hant-BWs2-32i.js +0 -4
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
}
|
|
109
|
-
|
|
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 ||
|
|
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
|
-
|
|
212
|
-
<
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
<
|
|
218
|
-
|
|
219
|
-
<
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
<
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
/>
|
|
241
|
+
)}
|
|
242
|
+
/>
|
|
229
243
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 {
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
<
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
);
|