Skip to content

Commit cfa605f

Browse files
committed
feat(dashboard): Allow creating customer and address inline on draft orders
Brings the React dashboard draft order flow to parity with the Angular admin-ui by allowing an admin to create a new customer inline and enter a new/ad-hoc shipping or billing address inline, rather than only selecting existing records. - CustomerSelector gains an opt-in `allowCreateNew` mode rendering a tabbed popover (select existing / create new) that emits a CreateCustomerInput via `onCreateNew`. - CustomerAddressSelector gains a "New address" tab embedding the shared CustomerAddressForm, emitting a CreateAddressInput via `onSubmitNew`. - CustomerAddressForm gains a `hideDefaultAddressFlags` prop (the default shipping/billing flags are not applicable in the draft order context). - Wired into both the draft order page and the order modification page. No backend changes required: setCustomerForDraftOrder already accepts a CreateCustomerInput, and the draft order address mutations accept a CreateAddressInput. Relates to #4838
1 parent aa5859e commit cfa605f

34 files changed

Lines changed: 3488 additions & 2087 deletions

packages/dashboard/e2e/tests/sales/orders.spec.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,86 @@ test.describe('Orders', () => {
294294
await expect(page.getByRole('button', { name: /Select address/i })).toHaveCount(2);
295295
});
296296

297+
// #4838 — parity with the Angular admin-ui: a draft order should allow creating a
298+
// new customer inline (not just selecting an existing one).
299+
test('should create a new customer inline on a draft order', async ({ page }) => {
300+
test.setTimeout(60_000);
301+
302+
// Create a draft order
303+
const lp = listPage(page);
304+
await lp.goto();
305+
await lp.expectLoaded();
306+
await lp.newButton.click();
307+
await expect(page).toHaveURL(/\/orders\/draft\//, { timeout: 10_000 });
308+
309+
// Open the customer selector (a tabbed popover) and switch to "Create new customer"
310+
await page.getByRole('button', { name: /Select customer/i }).click();
311+
const customerPopover = page.locator('[data-slot="popover-content"]');
312+
await expect(customerPopover).toBeVisible();
313+
await customerPopover.getByRole('tab', { name: /Create new customer/i }).click();
314+
315+
const email = `inline.customer.${Date.now()}@test.com`;
316+
await customerPopover.getByLabel('First name').fill('Inline');
317+
await customerPopover.getByLabel('Last name').fill('Customer');
318+
await customerPopover.getByLabel('Email address').fill(email);
319+
await customerPopover.getByRole('button', { name: /Create customer/i }).click();
320+
321+
// The mutation runs and the customer becomes set on the order
322+
await page.waitForResponse(resp => resp.url().includes('/admin-api') && resp.status() === 200);
323+
await expect(page.getByRole('button', { name: /Inline Customer/i })).toBeVisible({
324+
timeout: 10_000,
325+
});
326+
});
327+
328+
// #4838 — parity with the Angular admin-ui: a draft order should allow entering a
329+
// new, ad-hoc address inline (not just selecting from the customer's saved addresses).
330+
test('should enter a new shipping address inline on a draft order', async ({ page }) => {
331+
test.setTimeout(60_000);
332+
333+
const client = new VendureAdminClient(page);
334+
await client.login();
335+
336+
// Create a draft order with a customer already set
337+
const lp = listPage(page);
338+
await lp.goto();
339+
await lp.expectLoaded();
340+
await lp.newButton.click();
341+
await expect(page).toHaveURL(/\/orders\/draft\//, { timeout: 10_000 });
342+
343+
await page.getByRole('button', { name: /Select customer/i }).click();
344+
const customerPopover = page.locator('[data-slot="popover-content"]');
345+
await customerPopover.getByPlaceholder('Search customers...').fill('hayden');
346+
const haydenOption = page.getByRole('option').filter({ hasText: /hayden/i });
347+
await expect(haydenOption.first()).toBeVisible({ timeout: 5_000 });
348+
await haydenOption.first().click();
349+
await page.waitForResponse(resp => resp.url().includes('/admin-api') && resp.status() === 200);
350+
351+
// Open the shipping address selector and switch to the "New address" tab
352+
await page
353+
.getByRole('button', { name: /Select address/i })
354+
.first()
355+
.click();
356+
// Scope to the address popover (identified by its "New address" tab) to avoid
357+
// matching the customer popover that may still be animating closed.
358+
const popover = page
359+
.locator('[data-slot="popover-content"]')
360+
.filter({ has: page.getByRole('tab', { name: /New address/i }) });
361+
await expect(popover).toBeVisible({ timeout: 5_000 });
362+
await popover.getByRole('tab', { name: /New address/i }).click();
363+
364+
// Fill the inline address form
365+
await popover.getByLabel('Street Address').fill('99 Inline Road');
366+
await popover.getByLabel('City').fill('Inlineton');
367+
// Country is a Select — open and pick the first available country
368+
await popover.getByRole('combobox').click();
369+
await page.getByRole('option').first().click();
370+
await popover.getByRole('button', { name: /Save Address/i }).click();
371+
372+
// The new address is applied to the order
373+
await page.waitForResponse(resp => resp.url().includes('/admin-api') && resp.status() === 200);
374+
await expect(page.getByText('99 Inline Road')).toBeVisible({ timeout: 10_000 });
375+
});
376+
297377
// #4393 — custom order history entry types should be displayed with key-value data
298378
test('should display custom order history entry types', async ({ page }) => {
299379
test.setTimeout(60_000);
@@ -348,7 +428,7 @@ test.describe('Orders', () => {
348428

349429
// The address selector popover should auto-open
350430
await expect(page.locator('[data-slot="popover-content"]')).toBeVisible({ timeout: 5_000 });
351-
await expect(page.getByText('Select an address')).toBeVisible();
431+
await expect(page.getByRole('tab', { name: 'Existing address' })).toBeVisible();
352432
});
353433

354434
// #4393 — order modify page should show a "Recalculate shipping" checkbox
Lines changed: 98 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,45 @@
1+
import { AddressFormValues, CustomerAddressForm } from '@/vdb/components/shared/customer-address-form.js';
12
import { Button } from '@/vdb/components/ui/button.js';
23
import { Card } from '@/vdb/components/ui/card.js';
34
import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
5+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/vdb/components/ui/tabs.js';
46
import { api } from '@/vdb/graphql/api.js';
57
import { ResultOf } from '@/vdb/graphql/graphql.js';
6-
import { Trans, useLingui } from '@lingui/react/macro';
78
import { cn } from '@/vdb/lib/utils.js';
9+
import { Trans } from '@lingui/react/macro';
810
import { useQuery } from '@tanstack/react-query';
9-
import { Plus } from 'lucide-react';
11+
import { VariablesOf } from 'gql.tada';
12+
import { Link, Plus } from 'lucide-react';
1013
import { useState } from 'react';
1114
import { addressFragment } from '../../_customers/customers.graphql.js';
12-
import { getCustomerAddressesDocument } from '../orders.graphql.js';
15+
import { getCustomerAddressesDocument, setShippingAddressForDraftOrderDocument } from '../orders.graphql.js';
1316

1417
type CustomerAddressesQuery = ResultOf<typeof getCustomerAddressesDocument>;
1518

19+
export type CreateAddressInput = VariablesOf<typeof setShippingAddressForDraftOrderDocument>['input'];
20+
1621
interface CustomerAddressSelectorProps {
1722
customerId: string | undefined;
1823
onSelect: (address: ResultOf<typeof addressFragment>) => void;
24+
/**
25+
* @description
26+
* Called when a new, ad-hoc address is entered via the "New address" tab. Receives a
27+
* {@link CreateAddressInput} ready to be passed to the draft order address mutations.
28+
*/
29+
onSubmitNew: (input: CreateAddressInput) => void;
1930
onCancel?: () => void;
2031
defaultOpen?: boolean;
2132
}
2233

2334
export function CustomerAddressSelector({
2435
customerId,
2536
onSelect,
37+
onSubmitNew,
2638
onCancel,
2739
defaultOpen = false,
2840
}: Readonly<CustomerAddressSelectorProps>) {
2941
const [open, setOpen] = useState(defaultOpen);
42+
const [activeTab, setActiveTab] = useState<string>('existing');
3043

3144
const { data, isLoading } = useQuery<CustomerAddressesQuery>({
3245
queryKey: ['customerAddresses', customerId],
@@ -35,66 +48,110 @@ export function CustomerAddressSelector({
3548
});
3649

3750
const addresses: ResultOf<typeof addressFragment>[] = data?.customer?.addresses || [];
51+
// Existing addresses are only selectable when a customer with saved addresses is present.
52+
// Otherwise, the admin can only enter a new ad-hoc address (matching the Angular admin-ui).
53+
const canSelectExisting = !!customerId && addresses.length > 0;
54+
const effectiveTab = canSelectExisting ? activeTab : 'new';
3855

3956
return (
4057
<Popover
4158
open={open}
4259
onOpenChange={value => {
4360
setOpen(value);
4461
if (!value) {
62+
setActiveTab('existing');
4563
onCancel?.();
4664
}
4765
}}
4866
>
4967
<PopoverTrigger render={<div className="flex items-center gap-2" />}>
50-
<Button variant="outline" size="sm" type="button" className="" disabled={!customerId}>
68+
<Button variant="outline" size="sm" type="button">
5169
<Plus className="h-4 w-4" />
5270
<Trans>Select address</Trans>
5371
</Button>
5472
</PopoverTrigger>
55-
<PopoverContent className="w-[400px] p-0" align="start">
73+
<PopoverContent className="w-[520px] p-0" align="start">
5674
<div className="p-4">
57-
<h4 className="mb-4">
58-
<Trans>Select an address</Trans>
59-
</h4>
60-
<div className="space-y-2">
61-
{isLoading ? (
62-
<div className="text-sm text-muted-foreground">
63-
<Trans>Loading addresses...</Trans>
64-
</div>
65-
) : addresses.length === 0 ? (
66-
<div className="text-sm text-muted-foreground">
67-
<Trans>No addresses found</Trans>
75+
<Tabs value={effectiveTab} onValueChange={setActiveTab}>
76+
<TabsList className="grid w-full grid-cols-2">
77+
<TabsTrigger value="existing" disabled={!canSelectExisting}>
78+
<Link className="mr-2 h-4 w-4" />
79+
<Trans>Existing address</Trans>
80+
</TabsTrigger>
81+
<TabsTrigger value="new">
82+
<Plus className="mr-2 h-4 w-4" />
83+
<Trans>New address</Trans>
84+
</TabsTrigger>
85+
</TabsList>
86+
<TabsContent value="existing">
87+
<div className="space-y-2 mt-2">
88+
{isLoading ? (
89+
<div className="text-sm text-muted-foreground">
90+
<Trans>Loading addresses...</Trans>
91+
</div>
92+
) : addresses.length === 0 ? (
93+
<div className="text-sm text-muted-foreground">
94+
<Trans>No addresses found</Trans>
95+
</div>
96+
) : (
97+
addresses.map(address => (
98+
<Card
99+
key={address.id}
100+
className={cn(
101+
'p-4 cursor-pointer hover:bg-accent transition-colors',
102+
)}
103+
onClick={() => {
104+
onSelect(address);
105+
setOpen(false);
106+
}}
107+
>
108+
<div className="flex flex-col gap-1 text-sm">
109+
<div className="font-semibold">{address.fullName}</div>
110+
{address.company && <div>{address.company}</div>}
111+
<div>{address.streetLine1}</div>
112+
{address.streetLine2 && <div>{address.streetLine2}</div>}
113+
<div>
114+
{address.city}
115+
{address.province && `, ${address.province}`}
116+
</div>
117+
<div>{address.postalCode}</div>
118+
<div>{address.country.name}</div>
119+
{address.phoneNumber && <div>{address.phoneNumber}</div>}
120+
</div>
121+
</Card>
122+
))
123+
)}
68124
</div>
69-
) : (
70-
addresses.map(address => (
71-
<Card
72-
key={address.id}
73-
className={cn('p-4 cursor-pointer hover:bg-accent transition-colors')}
74-
onClick={() => {
75-
onSelect(address);
125+
</TabsContent>
126+
<TabsContent value="new">
127+
<div className="mt-2 max-h-[60vh] overflow-y-auto">
128+
<CustomerAddressForm
129+
hideDefaultAddressFlags
130+
onSubmit={values => {
131+
onSubmitNew(mapFormValuesToInput(values));
76132
setOpen(false);
77133
}}
78-
>
79-
<div className="flex flex-col gap-1 text-sm">
80-
<div className="font-semibold">{address.fullName}</div>
81-
{address.company && <div>{address.company}</div>}
82-
<div>{address.streetLine1}</div>
83-
{address.streetLine2 && <div>{address.streetLine2}</div>}
84-
<div>
85-
{address.city}
86-
{address.province && `, ${address.province}`}
87-
</div>
88-
<div>{address.postalCode}</div>
89-
<div>{address.country.name}</div>
90-
{address.phoneNumber && <div>{address.phoneNumber}</div>}
91-
</div>
92-
</Card>
93-
))
94-
)}
95-
</div>
134+
/>
135+
</div>
136+
</TabsContent>
137+
</Tabs>
96138
</div>
97139
</PopoverContent>
98140
</Popover>
99141
);
100142
}
143+
144+
function mapFormValuesToInput(values: AddressFormValues): CreateAddressInput {
145+
return {
146+
fullName: values.fullName,
147+
company: values.company,
148+
streetLine1: values.streetLine1,
149+
streetLine2: values.streetLine2,
150+
city: values.city,
151+
province: values.province,
152+
postalCode: values.postalCode,
153+
countryCode: values.countryCode,
154+
phoneNumber: values.phoneNumber,
155+
customFields: values.customFields,
156+
};
157+
}

packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id_.modify.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { User } from 'lucide-react';
1818
import { useState } from 'react';
1919
import { toast } from 'sonner';
2020
import { AddSurchargeForm } from './components/add-surcharge-form.js';
21-
import { CustomerAddressSelector } from './components/customer-address-selector.js';
21+
import { CreateAddressInput, CustomerAddressSelector } from './components/customer-address-selector.js';
2222
import { EditOrderTable } from './components/edit-order-table.js';
2323
import { OrderAddress } from './components/order-address.js';
2424
import { OrderModificationPreviewDialog } from './components/order-modification-preview-dialog.js';
@@ -88,6 +88,8 @@ function ModifyOrderPage() {
8888
removeCouponCode,
8989
updateShippingAddress: updateShippingAddressInInput,
9090
updateBillingAddress: updateBillingAddressInInput,
91+
updateShippingAddressRaw: updateShippingAddressRawInInput,
92+
updateBillingAddressRaw: updateBillingAddressRawInInput,
9193
addSurcharge,
9294
setNote,
9395
setRecalculateShipping,
@@ -109,6 +111,16 @@ function ModifyOrderPage() {
109111
setEditingBillingAddress(false);
110112
}
111113

114+
function handleSubmitNewShippingAddress(input: CreateAddressInput) {
115+
updateShippingAddressRawInInput(input);
116+
setEditingShippingAddress(false);
117+
}
118+
119+
function handleSubmitNewBillingAddress(input: CreateAddressInput) {
120+
updateBillingAddressRawInInput(input);
121+
setEditingBillingAddress(false);
122+
}
123+
112124
const [previewOpen, setPreviewOpen] = useState(false);
113125

114126
if (!entity) {
@@ -250,6 +262,7 @@ function ModifyOrderPage() {
250262
<CustomerAddressSelector
251263
customerId={entity.customer?.id}
252264
onSelect={handleSelectShippingAddress}
265+
onSubmitNew={handleSubmitNewShippingAddress}
253266
onCancel={() => setEditingShippingAddress(false)}
254267
defaultOpen
255268
/>
@@ -279,6 +292,7 @@ function ModifyOrderPage() {
279292
<CustomerAddressSelector
280293
customerId={entity.customer?.id}
281294
onSelect={handleSelectBillingAddress}
295+
onSubmitNew={handleSubmitNewBillingAddress}
282296
onCancel={() => setEditingBillingAddress(false)}
283297
defaultOpen
284298
/>

packages/dashboard/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,9 +502,13 @@ function DraftOrderPage() {
502502
</Button>
503503
) : null}
504504
<CustomerSelector
505+
allowCreateNew
505506
onSelect={customer => {
506507
setCustomerForDraftOrder({ orderId: entity.id, customerId: customer.id });
507508
}}
509+
onCreateNew={input => {
510+
setCustomerForDraftOrder({ orderId: entity.id, input });
511+
}}
508512
/>
509513
</PageBlock>
510514
<PageBlock column="side" blockId="shipping-address" title={<Trans>Shipping address</Trans>}>
@@ -524,6 +528,12 @@ function DraftOrderPage() {
524528
input: mapToAddressInput(address),
525529
});
526530
}}
531+
onSubmitNew={input => {
532+
setShippingAddressForDraftOrder({
533+
orderId: entity.id,
534+
input,
535+
});
536+
}}
527537
/>
528538
</div>
529539
)}
@@ -546,6 +556,12 @@ function DraftOrderPage() {
546556
input: mapToAddressInput(address),
547557
});
548558
}}
559+
onSubmitNew={input => {
560+
setBillingAddressForDraftOrder({
561+
orderId: entity.id,
562+
input,
563+
});
564+
}}
549565
/>
550566
</div>
551567
)}

0 commit comments

Comments
 (0)