Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
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
78 changes: 67 additions & 11 deletions src/components/FormFields/BillingFormFields.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon';
import { AddressElement, PaymentElement } from '@stripe/react-stripe-js';
import { StripeAddressElementOptions } from '@stripe/stripe-js';
import { StripeAddressElementChangeEvent, StripeAddressElementOptions } from '@stripe/stripe-js';
import { useCallback } from 'react';

import { FieldContainer } from '@/components/FieldContainer';
import { DataStoreKey } from '@/constants/checkout';
import { useCheckoutFormStore } from '@/hooks/useCheckoutFormStore';

import type { UseFormReturn } from 'react-hook-form';

interface BillingFormFieldsProps {
form: UseFormReturn<BillingDetailsData>;
}

const BillingAddressTitle = () => (
<h3 className="mb-3">
Expand Down Expand Up @@ -33,31 +43,77 @@ const BillingPaymentTitle = () => (
</>
);

const BillingFormFields = () => {
/**
* Stripe AddressElement configured for collecting the cardholder’s billing address.
*
* The `mode: "billing"` option ensures the address is tied to the payment method
* and used for fraud checks and payment authorization.
*
* Docs: https://docs.stripe.com/elements/address-element
*/
/**
* BillingFormFields component
*
* Renders the billing address form and payment element for the billing details page.
* Uses Stripe AddressElement and PaymentElement.
* AddressElement provides autocomplete/manual entry and returns
* normalized address fields (line1, line2, city, state, postal code).
*/
const BillingFormFields = ({ form }: BillingFormFieldsProps) => {
const billingDetailsData = useCheckoutFormStore(
(state) => state.formData[DataStoreKey.BillingDetails],
);
const setFormData = useCheckoutFormStore((state) => state.setFormData);

const onAddressChange = useCallback((event: StripeAddressElementChangeEvent) => {
const name = event.value?.name || '';
const country = event.value?.address?.country || '';
const line1 = event.value?.address?.line1 || '';
const line2 = event.value?.address?.line2 || '';
const city = event.value?.address?.city || '';
const state = event.value?.address?.state || '';
const zip = event.value?.address?.postal_code || '';

form.setValue('fullName', name, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('country', country, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('line1', line1, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('line2', line2, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('city', city, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('state', state, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('zip', zip, { shouldValidate: true, shouldDirty: true, shouldTouch: true });

setFormData(DataStoreKey.BillingDetails, {
...billingDetailsData,
fullName: name,
country,
line1,
line2,
city,
state,
zip,
});
}, [billingDetailsData, form, setFormData]);

const addressElementOptions: StripeAddressElementOptions = {
mode: 'billing',
validation: {
phone: {
required: 'never',
},
},
};

return (
<>
<FieldContainer>
<BillingAddressTitle />
<AddressElement
options={addressElementOptions}
onChange={onAddressChange}
/>
{form.formState.errors?.fullName?.message && (
<Form.Control.Feedback type="invalid" hasIcon={false} className="d-block mt-2">
{form.formState.errors.fullName.message}
</Form.Control.Feedback>
)}
Comment on lines +101 to +105
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only fullName validation feedback is rendered, but the schema now requires other address fields (country, line1, city, state, zip). If the AddressElement doesn’t populate these (or is incomplete), the user may be blocked from submitting with no visible error. Consider adding a generalized address error message when any of those related fields have validation errors (or integrating validation feedback with the AddressElement).

Copilot uses AI. Check for mistakes.
</FieldContainer>
<FieldContainer>
<BillingPaymentTitle />
<PaymentElement
options={{
layout: 'tabs', // or 'accordion'
layout: 'tabs',
}}
/>
</FieldContainer>
Expand Down
286 changes: 286 additions & 0 deletions src/components/FormFields/tests/BillingFormFields.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/extend-expect';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';

import { DataStoreKey } from '@/constants/checkout';
import { useCheckoutFormStore } from '@/hooks/useCheckoutFormStore';

import BillingFormFields from '../BillingFormFields';

const mockSetFormData = jest.fn();

const mockAddressChangeEvent = {
value: {
name: 'John Doe',
address: {
country: 'US',
line1: '123 Main St',
line2: 'Apt 4B',
city: 'Boston',
state: 'MA',
postal_code: '02109',
},
},
};

jest.mock('@/hooks/useCheckoutFormStore', () => ({
useCheckoutFormStore: jest.fn(),
}));

jest.mock('@stripe/react-stripe-js', () => ({
AddressElement: ({ onChange }: { onChange: (event: any) => void }) => (
<button type="button" data-testid="address-element" onClick={() => onChange(mockAddressChangeEvent)}>
AddressElement
</button>
),
PaymentElement: () => <div data-testid="payment-element">PaymentElement</div>,
}));

describe('BillingFormFields', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useCheckoutFormStore).mockImplementation((selector) => selector({
formData: {
[DataStoreKey.BillingDetails]: {},
},
setFormData: mockSetFormData,
checkoutSessionClientSecret: undefined,
checkoutSessionStatus: {
type: null,
paymentStatus: null,
},
setCheckoutSessionClientSecret: jest.fn(),
setCheckoutSessionStatus: jest.fn(),
}));
});

it('renders Stripe address and payment elements', () => {
const Wrapper = () => {
const form = useForm<BillingDetailsData>({
mode: 'onTouched',
defaultValues: {
fullName: '',
country: '',
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
});

return (
<IntlProvider locale="en">
<BillingFormFields form={form} />
</IntlProvider>
);
};

render(<Wrapper />);

expect(screen.getByTestId('address-element')).toBeInTheDocument();
expect(screen.getByTestId('payment-element')).toBeInTheDocument();
});

it('maps Stripe AddressElement fields into store on change', async () => {
const user = userEvent.setup();

const Wrapper = () => {
const form = useForm<BillingDetailsData>({
mode: 'onTouched',
defaultValues: {
fullName: '',
country: '',
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
});

return (
<IntlProvider locale="en">
<BillingFormFields form={form} />
</IntlProvider>
);
};

render(<Wrapper />);

await user.click(screen.getByTestId('address-element'));

expect(mockSetFormData).toHaveBeenCalledWith(
DataStoreKey.BillingDetails,
expect.objectContaining({
fullName: 'John Doe',
country: 'US',
line1: '123 Main St',
line2: 'Apt 4B',
city: 'Boston',
state: 'MA',
zip: '02109',
}),
);
});

it('renders fullName validation feedback when form error exists', () => {
const Wrapper = () => {
const form = useForm<BillingDetailsData>({
mode: 'onTouched',
defaultValues: {
fullName: '',
country: '',
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
});

useEffect(() => {
form.setError('fullName', {
type: 'manual',
message: 'Please provide your full name.',
});
}, [form]);

return (
<IntlProvider locale="en">
<BillingFormFields form={form} />
</IntlProvider>
);
};

render(<Wrapper />);

expect(screen.getByText('Please provide your full name.')).toBeInTheDocument();
});

it('handles address change event without errors', async () => {
const user = userEvent.setup();

const Wrapper = () => {
const form = useForm<BillingDetailsData>({
mode: 'onTouched',
defaultValues: {
fullName: '',
country: '',
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
});

return (
<IntlProvider locale="en">
<BillingFormFields form={form} />
</IntlProvider>
);
};

render(<Wrapper />);
await user.click(screen.getByTestId('address-element'));
expect(mockSetFormData).toHaveBeenCalled();
});

it('updates form setValue for all address fields when address changes', async () => {
const user = userEvent.setup();
const setValueSpy = jest.fn();

Comment thread
gshivajibiradar marked this conversation as resolved.
const Wrapper = () => {
const form = useForm<BillingDetailsData>({
mode: 'onTouched',
defaultValues: {
fullName: '',
country: '',
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
});

// Spy on the setValue method
const originalSetValue = form.setValue;
form.setValue = jest.fn(originalSetValue);
setValueSpy.mockImplementation(form.setValue);

return (
<IntlProvider locale="en">
<BillingFormFields form={form} />
</IntlProvider>
);
};

render(<Wrapper />);
await user.click(screen.getByTestId('address-element'));

expect(mockSetFormData).toHaveBeenCalled();
});
Comment on lines +193 to +227
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test "updates form setValue for all address fields" sets up setValueSpy, but never asserts that setValue was called for the individual address fields. As written, it only re-checks that setFormData was called (which is already covered by other tests), so it won’t fail if form.setValue updates regress. Add explicit expectations for form.setValue calls (or remove the unused spy/test if it’s redundant).

Copilot uses AI. Check for mistakes.

Comment on lines +193 to +228
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test case sets up setValueSpy and overrides form.setValue, but never asserts that setValue was called (and setValueSpy isn't used). As written, it only duplicates the earlier setFormData assertion and adds dead code; either assert the expected setValue calls or remove this test/setup.

Suggested change
it('updates form setValue for all address fields when address changes', async () => {
const user = userEvent.setup();
const setValueSpy = jest.fn();
const Wrapper = () => {
const form = useForm<BillingDetailsData>({
mode: 'onTouched',
defaultValues: {
fullName: '',
country: '',
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
});
// Spy on the setValue method
const originalSetValue = form.setValue;
form.setValue = jest.fn(originalSetValue);
setValueSpy.mockImplementation(form.setValue);
return (
<IntlProvider locale="en">
<BillingFormFields form={form} />
</IntlProvider>
);
};
render(<Wrapper />);
await user.click(screen.getByTestId('address-element'));
expect(mockSetFormData).toHaveBeenCalled();
});

Copilot uses AI. Check for mistakes.
it('renders billing section titles and descriptions', () => {
const Wrapper = () => {
const form = useForm<BillingDetailsData>({
mode: 'onTouched',
defaultValues: {
fullName: '',
country: '',
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
});

return (
<IntlProvider locale="en">
<BillingFormFields form={form} />
</IntlProvider>
);
};

render(<Wrapper />);

// Check that the billing section titles are rendered via translated messages
// Note: These will be translated via FormattedMessage, so we check for the elements
expect(screen.getByTestId('address-element')).toBeInTheDocument();
expect(screen.getByTestId('payment-element')).toBeInTheDocument();
});

it('does not render fullName error feedback when no error exists', () => {
const Wrapper = () => {
const form = useForm<BillingDetailsData>({
mode: 'onTouched',
defaultValues: {
fullName: 'Test User',
country: 'US',
line1: '123 Main',
line2: '',
city: 'Boston',
state: 'MA',
zip: '02109',
},
});

return (
<IntlProvider locale="en">
<BillingFormFields form={form} />
</IntlProvider>
);
};

render(<Wrapper />);

// Should not have error feedback displayed
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
Loading
Loading