Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type CheckoutService,
createCheckoutService,
} from '@bigcommerce/checkout-sdk';
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import React, { type FunctionComponent } from 'react';

import {
Expand All @@ -23,6 +23,7 @@ import { type BillingFormValues } from '../../billing/billingFormConfig';
import { getCheckout } from '../../checkout/checkouts.mock';
import { getStoreConfig } from '../../config/config.mock';
import { getCustomer } from '../../customer/customers.mock';
import { getShippingAddress } from '../../shipping/shipping-addresses.mock';

import { PaymentBillingBlock } from './PaymentBillingBlock';
import { type PaymentBillingFormProps } from './PaymentBillingForm';
Expand Down Expand Up @@ -80,6 +81,7 @@ describe('PaymentBillingBlock', () => {
jest.spyOn(checkoutState.data, 'getBillingAddressFields').mockReturnValue(formFields);
jest.spyOn(checkoutState.data, 'getAddressExtraFields').mockReturnValue([]);
jest.spyOn(checkoutState.data, 'getBillingAddress').mockReturnValue(undefined);
jest.spyOn(checkoutState.data, 'getShippingAddress').mockReturnValue(getShippingAddress());

PaymentBillingBlockTest = ({ methodId }) => (
<CheckoutProvider checkoutService={checkoutService}>
Expand Down Expand Up @@ -124,6 +126,52 @@ describe('PaymentBillingBlock', () => {
expect(mockCapturedProps.methodId).toBe('amazonpay');
});

it('defaults the same-as-shipping toggle from checkout settings', async () => {
render(<PaymentBillingBlockTest />);

await screen.findByTestId('trigger-persist');

expect(mockCapturedProps.isBillingSameAsShipping).toBe(true);
});

it('copies the shipping address to billing when the toggle is checked', async () => {
render(<PaymentBillingBlockTest />);

await screen.findByTestId('trigger-persist');

mockCapturedProps.onBillingSameAsShippingChange(true);

await new Promise((resolve) => process.nextTick(resolve));

expect(checkoutService.updateBillingAddress).toHaveBeenCalledWith(getShippingAddress());
});

it('does not copy shipping to billing when the toggle is unchecked', async () => {
render(<PaymentBillingBlockTest />);

await screen.findByTestId('trigger-persist');

mockCapturedProps.onBillingSameAsShippingChange(false);

await new Promise((resolve) => process.nextTick(resolve));

expect(checkoutService.updateBillingAddress).not.toHaveBeenCalled();
});

it('remembers the shopper unchecking the toggle across re-renders', async () => {
render(<PaymentBillingBlockTest />);

await screen.findByTestId('trigger-persist');

expect(mockCapturedProps.isBillingSameAsShipping).toBe(true);

act(() => {
mockCapturedProps.onBillingSameAsShippingChange(false);
});

await waitFor(() => expect(mockCapturedProps.isBillingSameAsShipping).toBe(false));
});

it('shows a warning instead of the form when manual entry is restricted and there are no saved addresses', async () => {
jest.spyOn(checkoutState.data, 'getCustomer').mockReturnValue({
...getCustomer(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ export const PaymentBillingBlock: FunctionComponent<PaymentBillingBlockProps> =
getBillingAddressFields,
getAddressExtraFields,
},
// getBillingAddress guarantees the latest state inside async callbacks
// getBillingAddress / getShippingAddress guarantee the latest state inside
// async callbacks
checkoutState: {
data: { getBillingAddress },
data: { getBillingAddress, getShippingAddress },
},
checkoutService,
} = useCheckout(({ data, statuses }) => ({
Expand Down Expand Up @@ -84,6 +85,28 @@ export const PaymentBillingBlock: FunctionComponent<PaymentBillingBlockProps> =
const customerMessage = checkout.customerMessage;
const billingAddress = getBillingAddress();

const [isBillingSameAsShipping, setIsBillingSameAsShipping] = useState(
config.checkoutSettings.checkoutBillingSameAsShippingEnabled ?? true,
);

const handleBillingSameAsShippingChange = (checked: boolean) => {
setIsBillingSameAsShipping(checked);

if (!checked) {
return;
}

const shippingAddress = getShippingAddress();

if (shippingAddress && !isEqualAddress(shippingAddress, getBillingAddress())) {
checkoutService.updateBillingAddress(shippingAddress).catch((error) => {
if (error instanceof Error) {
onUnhandledError(error);
}
});
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
};
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

const getFields = useCallback(
(countryCode?: string) =>
getFieldsWithExtraFields(
Expand Down Expand Up @@ -163,8 +186,10 @@ export const PaymentBillingBlock: FunctionComponent<PaymentBillingBlockProps> =
billingAddress={billingAddress}
customerMessage={customerMessage}
getFields={getFields}
isBillingSameAsShipping={isBillingSameAsShipping}
isLoading={isInitializing}
methodId={methodId}
onBillingSameAsShippingChange={handleBillingSameAsShippingChange}
onPersist={handlePersist}
onUnhandledError={onUnhandledError}
/>
Expand Down
132 changes: 112 additions & 20 deletions packages/core/src/app/payment/billingForm/PaymentBillingForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
createCheckoutService,
} from '@bigcommerce/checkout-sdk';
import { noop } from 'lodash';
import React from 'react';
import React, { type FunctionComponent } from 'react';

import {
CapabilitiesContext,
Expand Down Expand Up @@ -35,26 +35,28 @@ describe('PaymentBillingForm', () => {
let setEnsureBillingAddressSaved: jest.Mock;
let defaultProps: PaymentBillingFormProps;

const PaymentBillingFormTest: FunctionComponent<PaymentBillingFormProps> = (props) => (
<CheckoutProvider checkoutService={checkoutService}>
<LocaleContext.Provider value={localeContext}>
<CapabilitiesContext.Provider value={defaultCapabilities}>
<PaymentContext.Provider
value={{
setEnsureBillingAddressSaved,
disableSubmit: jest.fn(),
setSubmit: jest.fn(),
setValidationSchema: jest.fn(),
hidePaymentSubmitButton: jest.fn(),
}}
>
<PaymentBillingForm {...props} />
</PaymentContext.Provider>
</CapabilitiesContext.Provider>
</LocaleContext.Provider>
</CheckoutProvider>
);

const renderForm = (props: PaymentBillingFormProps) =>
render(
<CheckoutProvider checkoutService={checkoutService}>
<LocaleContext.Provider value={localeContext}>
<CapabilitiesContext.Provider value={defaultCapabilities}>
<PaymentContext.Provider
value={{
setEnsureBillingAddressSaved,
disableSubmit: jest.fn(),
setSubmit: jest.fn(),
setValidationSchema: jest.fn(),
hidePaymentSubmitButton: jest.fn(),
}}
>
<PaymentBillingForm {...props} />
</PaymentContext.Provider>
</CapabilitiesContext.Provider>
</LocaleContext.Provider>
</CheckoutProvider>,
);
render(<PaymentBillingFormTest {...props} />);

beforeEach(() => {
checkoutService = createCheckoutService();
Expand All @@ -81,7 +83,9 @@ describe('PaymentBillingForm', () => {
billingAddress: getBillingAddress(),
customerMessage: '',
getFields: () => getFormFields(),
isBillingSameAsShipping: false,
isLoading: false,
onBillingSameAsShippingChange: jest.fn(),
onPersist,
onUnhandledError: noop,
};
Expand Down Expand Up @@ -168,6 +172,20 @@ describe('PaymentBillingForm', () => {
expect(onPersist).not.toHaveBeenCalled();
});

it('does not include the same-as-shipping flag in the persisted billing values', async () => {
renderForm(defaultProps);

await waitFor(() =>
expect(capturedEnsureBillingAddressSaved).toEqual(expect.any(Function)),
);

await capturedEnsureBillingAddressSaved?.();

expect(onPersist).toHaveBeenCalledWith(
expect.not.objectContaining({ billingSameAsShipping: expect.anything() }),
);
});

it('reports a persist failure via onUnhandledError and resolves false without throwing', async () => {
const error = new Error('failed to save billing address');
const onUnhandledError = jest.fn();
Expand All @@ -183,4 +201,78 @@ describe('PaymentBillingForm', () => {
await expect(capturedEnsureBillingAddressSaved?.()).resolves.toBe(false);
expect(onUnhandledError).toHaveBeenCalledWith(error);
});

describe('billing same as shipping toggle', () => {
it('renders the toggle with the payment-step label', () => {
renderForm(defaultProps);

expect(screen.getByTestId('billingSameAsShipping')).toBeInTheDocument();
expect(screen.getByText('Same as shipping address')).toBeInTheDocument();
});

it('collapses the billing address fields when checked', () => {
renderForm({ ...defaultProps, isBillingSameAsShipping: true });

expect(screen.getByTestId('billingSameAsShipping')).toBeInTheDocument();
expect(screen.queryByText('First Name')).not.toBeInTheDocument();
});

it('shows the billing address fields when unchecked', () => {
renderForm({ ...defaultProps, isBillingSameAsShipping: false });

expect(screen.getByText('First Name')).toBeInTheDocument();
});

it('resolves true without persisting when checked (billing mirrors shipping)', async () => {
renderForm({ ...defaultProps, isBillingSameAsShipping: true });

await waitFor(() =>
expect(capturedEnsureBillingAddressSaved).toEqual(expect.any(Function)),
);

await expect(capturedEnsureBillingAddressSaved?.()).resolves.toBe(true);
expect(onPersist).not.toHaveBeenCalled();
});

it('hides the toggle for static-address methods (e.g. Amazon Pay)', () => {
renderForm({ ...defaultProps, methodId: 'amazonpay' });

expect(screen.queryByTestId('billingSameAsShipping')).not.toBeInTheDocument();
});

it('stays unchecked and does not re-fire on a billing address reinitialize', async () => {
const onBillingSameAsShippingChange = jest.fn();
const props = {
...defaultProps,
isBillingSameAsShipping: false,
billingAddress: getBillingAddress(),
onBillingSameAsShippingChange,
};

const { rerender } = renderForm(props);

expect(screen.getByText('First Name')).toBeInTheDocument();

rerender(
<PaymentBillingFormTest {...props} billingAddress={getEmptyBillingAddress()} />,
);

expect(await screen.findByText('First Name')).toBeInTheDocument();
expect(onBillingSameAsShippingChange).not.toHaveBeenCalledWith(true);
});

it('notifies onBillingSameAsShippingChange when the shopper checks it', async () => {
const onBillingSameAsShippingChange = jest.fn();

renderForm({
...defaultProps,
isBillingSameAsShipping: false,
onBillingSameAsShippingChange,
});

fireEvent.click(screen.getByTestId('billingSameAsShipping'));

await waitFor(() => expect(onBillingSameAsShippingChange).toHaveBeenCalledWith(true));
});
});
});
Loading