Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion packages/dashboard/e2e/tests/sales/orders.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,86 @@ test.describe('Orders', () => {
await expect(page.getByRole('button', { name: /Select address/i })).toHaveCount(2);
});

// #4838 — parity with the Angular admin-ui: a draft order should allow creating a
// new customer inline (not just selecting an existing one).
test('should create a new customer inline on a draft order', async ({ page }) => {
test.setTimeout(60_000);

// Create a draft order
const lp = listPage(page);
await lp.goto();
await lp.expectLoaded();
await lp.newButton.click();
await expect(page).toHaveURL(/\/orders\/draft\//, { timeout: 10_000 });

// Open the customer selector (a tabbed popover) and switch to "Create new customer"
await page.getByRole('button', { name: /Select customer/i }).click();
const customerPopover = page.locator('[data-slot="popover-content"]');
await expect(customerPopover).toBeVisible();
await customerPopover.getByRole('tab', { name: /Create new customer/i }).click();

const email = `inline.customer.${Date.now()}@test.com`;
await customerPopover.getByLabel('First name').fill('Inline');
await customerPopover.getByLabel('Last name').fill('Customer');
await customerPopover.getByLabel('Email address').fill(email);
await customerPopover.getByRole('button', { name: /Create customer/i }).click();

// The mutation runs and the customer becomes set on the order
await page.waitForResponse(resp => resp.url().includes('/admin-api') && resp.status() === 200);
await expect(page.getByRole('button', { name: /Inline Customer/i })).toBeVisible({
timeout: 10_000,
});
});

// #4838 — parity with the Angular admin-ui: a draft order should allow entering a
// new, ad-hoc address inline (not just selecting from the customer's saved addresses).
test('should enter a new shipping address inline on a draft order', async ({ page }) => {
test.setTimeout(60_000);

const client = new VendureAdminClient(page);
await client.login();

// Create a draft order with a customer already set
const lp = listPage(page);
await lp.goto();
await lp.expectLoaded();
await lp.newButton.click();
await expect(page).toHaveURL(/\/orders\/draft\//, { timeout: 10_000 });

await page.getByRole('button', { name: /Select customer/i }).click();
const customerPopover = page.locator('[data-slot="popover-content"]');
await customerPopover.getByPlaceholder('Search customers...').fill('hayden');
const haydenOption = page.getByRole('option').filter({ hasText: /hayden/i });
await expect(haydenOption.first()).toBeVisible({ timeout: 5_000 });
await haydenOption.first().click();
await page.waitForResponse(resp => resp.url().includes('/admin-api') && resp.status() === 200);

// Open the shipping address selector and switch to the "New address" tab
await page
.getByRole('button', { name: /Select address/i })
.first()
.click();
// Scope to the address popover (identified by its "New address" tab) to avoid
// matching the customer popover that may still be animating closed.
const popover = page
.locator('[data-slot="popover-content"]')
.filter({ has: page.getByRole('tab', { name: /New address/i }) });
await expect(popover).toBeVisible({ timeout: 5_000 });
await popover.getByRole('tab', { name: /New address/i }).click();

// Fill the inline address form
await popover.getByLabel('Street Address').fill('99 Inline Road');
await popover.getByLabel('City').fill('Inlineton');
// Country is a Select — open and pick the first available country
await popover.getByRole('combobox').click();
await page.getByRole('option').first().click();
await popover.getByRole('button', { name: /Save Address/i }).click();

// The new address is applied to the order
await page.waitForResponse(resp => resp.url().includes('/admin-api') && resp.status() === 200);
await expect(page.getByText('99 Inline Road')).toBeVisible({ timeout: 10_000 });
});

// #4393 — custom order history entry types should be displayed with key-value data
test('should display custom order history entry types', async ({ page }) => {
test.setTimeout(60_000);
Expand Down Expand Up @@ -348,7 +428,7 @@ test.describe('Orders', () => {

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

// #4393 — order modify page should show a "Recalculate shipping" checkbox
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,45 @@
import { AddressFormValues, CustomerAddressForm } from '@/vdb/components/shared/customer-address-form.js';
import { Button } from '@/vdb/components/ui/button.js';
import { Card } from '@/vdb/components/ui/card.js';
import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/vdb/components/ui/tabs.js';
import { api } from '@/vdb/graphql/api.js';
import { ResultOf } from '@/vdb/graphql/graphql.js';
import { Trans, useLingui } from '@lingui/react/macro';
import { cn } from '@/vdb/lib/utils.js';
import { Trans } from '@lingui/react/macro';
import { useQuery } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { VariablesOf } from 'gql.tada';
import { Link, Plus } from 'lucide-react';
import { useState } from 'react';
import { addressFragment } from '../../_customers/customers.graphql.js';
import { getCustomerAddressesDocument } from '../orders.graphql.js';
import { getCustomerAddressesDocument, setShippingAddressForDraftOrderDocument } from '../orders.graphql.js';

type CustomerAddressesQuery = ResultOf<typeof getCustomerAddressesDocument>;

export type CreateAddressInput = VariablesOf<typeof setShippingAddressForDraftOrderDocument>['input'];

interface CustomerAddressSelectorProps {
customerId: string | undefined;
onSelect: (address: ResultOf<typeof addressFragment>) => void;
/**
* @description
* Called when a new, ad-hoc address is entered via the "New address" tab. Receives a
* {@link CreateAddressInput} ready to be passed to the draft order address mutations.
*/
onSubmitNew: (input: CreateAddressInput) => void;
onCancel?: () => void;
defaultOpen?: boolean;
}

export function CustomerAddressSelector({
customerId,
onSelect,
onSubmitNew,
onCancel,
defaultOpen = false,
}: Readonly<CustomerAddressSelectorProps>) {
const [open, setOpen] = useState(defaultOpen);
const [activeTab, setActiveTab] = useState<string>('existing');

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

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

return (
<Popover
open={open}
onOpenChange={value => {
setOpen(value);
if (!value) {
setActiveTab('existing');
onCancel?.();
}
}}
>
<PopoverTrigger render={<div className="flex items-center gap-2" />}>
<Button variant="outline" size="sm" type="button" className="" disabled={!customerId}>
<Button variant="outline" size="sm" type="button">
<Plus className="h-4 w-4" />
<Trans>Select address</Trans>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<PopoverContent className="w-[520px] p-0" align="start">
<div className="p-4">
<h4 className="mb-4">
<Trans>Select an address</Trans>
</h4>
<div className="space-y-2">
{isLoading ? (
<div className="text-sm text-muted-foreground">
<Trans>Loading addresses...</Trans>
</div>
) : addresses.length === 0 ? (
<div className="text-sm text-muted-foreground">
<Trans>No addresses found</Trans>
<Tabs value={effectiveTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="existing" disabled={!canSelectExisting}>
<Link className="mr-2 h-4 w-4" />
<Trans>Existing address</Trans>
</TabsTrigger>
<TabsTrigger value="new">
<Plus className="mr-2 h-4 w-4" />
<Trans>New address</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="existing">
<div className="space-y-2 mt-2">
{isLoading ? (
<div className="text-sm text-muted-foreground">
<Trans>Loading addresses...</Trans>
</div>
) : addresses.length === 0 ? (
<div className="text-sm text-muted-foreground">
<Trans>No addresses found</Trans>
</div>
) : (
addresses.map(address => (
<Card
key={address.id}
className={cn(
'p-4 cursor-pointer hover:bg-accent transition-colors',
)}
onClick={() => {
onSelect(address);
setOpen(false);
}}
>
<div className="flex flex-col gap-1 text-sm">
<div className="font-semibold">{address.fullName}</div>
{address.company && <div>{address.company}</div>}
<div>{address.streetLine1}</div>
{address.streetLine2 && <div>{address.streetLine2}</div>}
<div>
{address.city}
{address.province && `, ${address.province}`}
</div>
<div>{address.postalCode}</div>
<div>{address.country.name}</div>
{address.phoneNumber && <div>{address.phoneNumber}</div>}
</div>
</Card>
))
)}
</div>
) : (
addresses.map(address => (
<Card
key={address.id}
className={cn('p-4 cursor-pointer hover:bg-accent transition-colors')}
onClick={() => {
onSelect(address);
</TabsContent>
<TabsContent value="new">
<div className="mt-2 max-h-[60vh] overflow-y-auto">
<CustomerAddressForm
hideDefaultAddressFlags
onSubmit={values => {
onSubmitNew(mapFormValuesToInput(values));
setOpen(false);
}}
>
<div className="flex flex-col gap-1 text-sm">
<div className="font-semibold">{address.fullName}</div>
{address.company && <div>{address.company}</div>}
<div>{address.streetLine1}</div>
{address.streetLine2 && <div>{address.streetLine2}</div>}
<div>
{address.city}
{address.province && `, ${address.province}`}
</div>
<div>{address.postalCode}</div>
<div>{address.country.name}</div>
{address.phoneNumber && <div>{address.phoneNumber}</div>}
</div>
</Card>
))
)}
</div>
/>
</div>
</TabsContent>
</Tabs>
</div>
</PopoverContent>
</Popover>
);
}

function mapFormValuesToInput(values: AddressFormValues): CreateAddressInput {
return {
fullName: values.fullName,
company: values.company,
streetLine1: values.streetLine1,
streetLine2: values.streetLine2,
city: values.city,
province: values.province,
postalCode: values.postalCode,
countryCode: values.countryCode,
phoneNumber: values.phoneNumber,
customFields: values.customFields,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { User } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { AddSurchargeForm } from './components/add-surcharge-form.js';
import { CustomerAddressSelector } from './components/customer-address-selector.js';
import { CreateAddressInput, CustomerAddressSelector } from './components/customer-address-selector.js';
import { EditOrderTable } from './components/edit-order-table.js';
import { OrderAddress } from './components/order-address.js';
import { OrderModificationPreviewDialog } from './components/order-modification-preview-dialog.js';
Expand Down Expand Up @@ -88,6 +88,8 @@ function ModifyOrderPage() {
removeCouponCode,
updateShippingAddress: updateShippingAddressInInput,
updateBillingAddress: updateBillingAddressInInput,
updateShippingAddressRaw: updateShippingAddressRawInInput,
updateBillingAddressRaw: updateBillingAddressRawInInput,
addSurcharge,
setNote,
setRecalculateShipping,
Expand All @@ -109,6 +111,16 @@ function ModifyOrderPage() {
setEditingBillingAddress(false);
}

function handleSubmitNewShippingAddress(input: CreateAddressInput) {
updateShippingAddressRawInInput(input);
setEditingShippingAddress(false);
}

function handleSubmitNewBillingAddress(input: CreateAddressInput) {
updateBillingAddressRawInInput(input);
setEditingBillingAddress(false);
}

const [previewOpen, setPreviewOpen] = useState(false);

if (!entity) {
Expand Down Expand Up @@ -250,6 +262,7 @@ function ModifyOrderPage() {
<CustomerAddressSelector
customerId={entity.customer?.id}
onSelect={handleSelectShippingAddress}
onSubmitNew={handleSubmitNewShippingAddress}
onCancel={() => setEditingShippingAddress(false)}
defaultOpen
/>
Expand Down Expand Up @@ -279,6 +292,7 @@ function ModifyOrderPage() {
<CustomerAddressSelector
customerId={entity.customer?.id}
onSelect={handleSelectBillingAddress}
onSubmitNew={handleSubmitNewBillingAddress}
onCancel={() => setEditingBillingAddress(false)}
defaultOpen
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -502,9 +502,13 @@ function DraftOrderPage() {
</Button>
) : null}
<CustomerSelector
allowCreateNew
onSelect={customer => {
setCustomerForDraftOrder({ orderId: entity.id, customerId: customer.id });
}}
onCreateNew={input => {
setCustomerForDraftOrder({ orderId: entity.id, input });
}}
/>
</PageBlock>
<PageBlock column="side" blockId="shipping-address" title={<Trans>Shipping address</Trans>}>
Expand All @@ -524,6 +528,12 @@ function DraftOrderPage() {
input: mapToAddressInput(address),
});
}}
onSubmitNew={input => {
setShippingAddressForDraftOrder({
orderId: entity.id,
input,
});
}}
/>
</div>
)}
Expand All @@ -546,6 +556,12 @@ function DraftOrderPage() {
input: mapToAddressInput(address),
});
}}
onSubmitNew={input => {
setBillingAddressForDraftOrder({
orderId: entity.id,
input,
});
}}
/>
</div>
)}
Expand Down
Loading
Loading