Skip to content

Commit 908c218

Browse files
committed
feat(payment): PAYMENTS-11577 make field rendering a reusable component for future use
1 parent 50f523a commit 908c218

5 files changed

Lines changed: 170 additions & 130 deletions

File tree

packages/offline-payment-integration/src/OfflinePaymentMethod.test.tsx

Lines changed: 7 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Formik } from 'formik';
44
import React from 'react';
55

66
import { type PaymentMethodProps } from '@bigcommerce/checkout/payment-integration-api';
7-
import { fireEvent, render, screen } from '@bigcommerce/checkout/test-utils';
7+
import { render, screen } from '@bigcommerce/checkout/test-utils';
88

99
import OfflinePaymentMethod from './OfflinePaymentMethod';
1010
import { getMethod } from './payment-method.mock';
@@ -83,22 +83,8 @@ describe('OfflinePaymentMethod', () => {
8383
type: 'number',
8484
fieldType: 'text',
8585
},
86-
{
87-
name: 'Reference',
88-
id: 'referenceText',
89-
required: false,
90-
type: 'text',
91-
fieldType: 'text',
92-
},
9386
];
9487

95-
const renderWithFormik = (props: PaymentMethodProps) =>
96-
render(
97-
<Formik initialValues={{}} onSubmit={jest.fn()}>
98-
<OfflinePaymentMethod {...props} />
99-
</Formik>,
100-
);
101-
10288
it('returns null when formFieldsData is not present', () => {
10389
const { container } = render(<OfflinePaymentMethod {...defaultProps} />);
10490

@@ -116,71 +102,19 @@ describe('OfflinePaymentMethod', () => {
116102
expect(container).toBeEmptyDOMElement();
117103
});
118104

119-
it('renders a field for each entry in formFieldsData', () => {
105+
it('renders fields from formFieldsData via PaymentFormFields', () => {
120106
const props = {
121107
...defaultProps,
122108
method: { ...defaultProps.method, initializationData: { formFieldsData } },
123109
};
124110

125-
renderWithFormik(props);
111+
render(
112+
<Formik initialValues={{}} onSubmit={jest.fn()}>
113+
<OfflinePaymentMethod {...props} />
114+
</Formik>,
115+
);
126116

127117
expect(screen.getByLabelText('Purchase Order')).toBeInTheDocument();
128-
expect(screen.getByLabelText('Reference')).toBeInTheDocument();
129-
});
130-
131-
it('strips non-digit characters from a number field on change', () => {
132-
const props = {
133-
...defaultProps,
134-
method: { ...defaultProps.method, initializationData: { formFieldsData } },
135-
};
136-
137-
renderWithFormik(props);
138-
139-
const input = screen.getByLabelText('Purchase Order');
140-
141-
fireEvent.change(input, { target: { value: '123abc' } });
142-
143-
expect((input as HTMLInputElement).value).toBe('123');
144-
});
145-
146-
it('shows a validation error for a required field left empty', async () => {
147-
const props = {
148-
...defaultProps,
149-
method: { ...defaultProps.method, initializationData: { formFieldsData } },
150-
};
151-
152-
renderWithFormik(props);
153-
154-
const input = screen.getByLabelText('Purchase Order');
155-
156-
fireEvent.blur(input);
157-
158-
expect(await screen.findByText('Purchase Order is required')).toBeInTheDocument();
159-
});
160-
161-
it('does not show a validation error for an optional field left empty', () => {
162-
const props = {
163-
...defaultProps,
164-
method: { ...defaultProps.method, initializationData: { formFieldsData } },
165-
};
166-
167-
renderWithFormik(props);
168-
169-
fireEvent.blur(screen.getByLabelText('Reference'));
170-
171-
expect(screen.queryByText('Reference is required')).not.toBeInTheDocument();
172-
});
173-
174-
it('uses numeric inputMode for number type fields', () => {
175-
const props = {
176-
...defaultProps,
177-
method: { ...defaultProps.method, initializationData: { formFieldsData } },
178-
};
179-
180-
renderWithFormik(props);
181-
182-
expect(screen.getByLabelText('Purchase Order')).toHaveAttribute('inputmode', 'numeric');
183-
expect(screen.getByLabelText('Reference')).not.toHaveAttribute('inputmode');
184118
});
185119
});
186120
});

packages/offline-payment-integration/src/OfflinePaymentMethod.tsx

Lines changed: 4 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,11 @@ import { createOfflinePaymentStrategy } from '@bigcommerce/checkout-sdk/integrat
22
import React, { type FunctionComponent, useEffect } from 'react';
33

44
import {
5+
getPaymentFormFields,
6+
PaymentFormFields,
57
type PaymentMethodProps,
68
toResolvableComponent,
79
} from '@bigcommerce/checkout/payment-integration-api';
8-
import { BasicFormField, TextInput } from '@bigcommerce/checkout/ui';
9-
10-
interface OfflineFormField {
11-
name: string;
12-
id: string;
13-
required: boolean;
14-
type: string;
15-
fieldType: string;
16-
}
17-
18-
const isOfflineFormFieldArray = (value: unknown): value is OfflineFormField[] =>
19-
Array.isArray(value);
20-
21-
const validateField = (value: string, field: OfflineFormField): string | undefined => {
22-
if (field.required && !value) {
23-
return `${field.name} is required`;
24-
}
25-
26-
if (field.type === 'number' && value && !/^\d+$/.test(value)) {
27-
return `${field.name} must be a positive whole number`;
28-
}
29-
};
3010

3111
const OfflinePaymentMethod: FunctionComponent<PaymentMethodProps> = ({
3212
method,
@@ -68,46 +48,13 @@ const OfflinePaymentMethod: FunctionComponent<PaymentMethodProps> = ({
6848
};
6949
}, [checkoutService, method.gateway, method.id, onUnhandledError]);
7050

71-
const rawFormFields: unknown = method.initializationData?.formFieldsData;
72-
const formFields = isOfflineFormFieldArray(rawFormFields) ? rawFormFields : [];
51+
const formFields = getPaymentFormFields(method.initializationData?.formFieldsData);
7352

7453
if (!formFields.length) {
7554
return null;
7655
}
7756

78-
return (
79-
<>
80-
{formFields.map((field) => (
81-
<BasicFormField
82-
key={field.id}
83-
name={field.id}
84-
render={({ field: fieldProps, meta }) => (
85-
<div style={{ paddingBottom: '1rem' }}>
86-
<label htmlFor={field.id}>{field.name}</label>
87-
<TextInput
88-
{...fieldProps}
89-
id={field.id}
90-
inputMode={field.type === 'number' ? 'numeric' : undefined}
91-
onChange={
92-
field.type === 'number'
93-
? (e) => {
94-
e.target.value = e.target.value.replace(/\D/g, '');
95-
fieldProps.onChange(e);
96-
}
97-
: fieldProps.onChange
98-
}
99-
type="text"
100-
/>
101-
{meta.touched && meta.error ? (
102-
<p className="form-field-error-message">{meta.error}</p>
103-
) : null}
104-
</div>
105-
)}
106-
validate={(value: string) => validateField(value, field)}
107-
/>
108-
))}
109-
</>
110-
);
57+
return <PaymentFormFields fields={formFields} />;
11158
};
11259

11360
export default toResolvableComponent(OfflinePaymentMethod, [
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Formik } from 'formik';
2+
import React from 'react';
3+
4+
import { fireEvent, render, screen } from '@bigcommerce/checkout/test-utils';
5+
6+
import PaymentFormFields, { getPaymentFormFields, type PaymentFormField } from './PaymentFormFields';
7+
8+
const fields: PaymentFormField[] = [
9+
{
10+
name: 'Purchase Order',
11+
id: 'purchaseOrderNumber',
12+
required: true,
13+
type: 'number',
14+
fieldType: 'text',
15+
},
16+
{
17+
name: 'Reference',
18+
id: 'referenceText',
19+
required: false,
20+
type: 'text',
21+
fieldType: 'text',
22+
},
23+
];
24+
25+
const renderWithFormik = (ui: React.ReactElement) =>
26+
render(
27+
<Formik initialValues={{}} onSubmit={jest.fn()}>
28+
{ui}
29+
</Formik>,
30+
);
31+
32+
describe('getPaymentFormFields', () => {
33+
it('returns empty array when value is undefined', () => {
34+
expect(getPaymentFormFields(undefined)).toEqual([]);
35+
});
36+
37+
it('returns empty array when value is null', () => {
38+
expect(getPaymentFormFields(null)).toEqual([]);
39+
});
40+
41+
it('returns empty array when value is not an array', () => {
42+
expect(getPaymentFormFields({ formFieldsData: [] })).toEqual([]);
43+
});
44+
45+
it('returns the array when value is a valid array', () => {
46+
expect(getPaymentFormFields(fields)).toEqual(fields);
47+
});
48+
});
49+
50+
describe('PaymentFormFields', () => {
51+
it('renders a label and input for each field', () => {
52+
renderWithFormik(<PaymentFormFields fields={fields} />);
53+
54+
expect(screen.getByLabelText('Purchase Order')).toBeInTheDocument();
55+
expect(screen.getByLabelText('Reference')).toBeInTheDocument();
56+
});
57+
58+
it('uses numeric inputMode for number type fields', () => {
59+
renderWithFormik(<PaymentFormFields fields={fields} />);
60+
61+
expect(screen.getByLabelText('Purchase Order')).toHaveAttribute('inputmode', 'numeric');
62+
expect(screen.getByLabelText('Reference')).not.toHaveAttribute('inputmode');
63+
});
64+
65+
it('strips non-digit characters from a number field on change', () => {
66+
renderWithFormik(<PaymentFormFields fields={fields} />);
67+
68+
const input = screen.getByLabelText('Purchase Order');
69+
70+
fireEvent.change(input, { target: { value: '123abc' } });
71+
72+
expect((input as HTMLInputElement).value).toBe('123');
73+
});
74+
75+
it('shows a validation error for a required field left empty', async () => {
76+
renderWithFormik(<PaymentFormFields fields={fields} />);
77+
78+
fireEvent.blur(screen.getByLabelText('Purchase Order'));
79+
80+
expect(await screen.findByText('Purchase Order is required')).toBeInTheDocument();
81+
});
82+
83+
it('does not show a validation error for an optional field left empty', () => {
84+
renderWithFormik(<PaymentFormFields fields={fields} />);
85+
86+
fireEvent.blur(screen.getByLabelText('Reference'));
87+
88+
expect(screen.queryByText('Reference is required')).not.toBeInTheDocument();
89+
});
90+
91+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React, { type FunctionComponent } from 'react';
2+
3+
import { BasicFormField, TextInput } from '@bigcommerce/checkout/ui';
4+
5+
export interface PaymentFormField {
6+
name: string;
7+
id: string;
8+
required: boolean;
9+
type: string;
10+
fieldType: string;
11+
}
12+
13+
const isPaymentFormFieldArray = (value: unknown): value is PaymentFormField[] =>
14+
Array.isArray(value);
15+
16+
export const getPaymentFormFields = (formFieldsData: unknown): PaymentFormField[] =>
17+
isPaymentFormFieldArray(formFieldsData) ? formFieldsData : [];
18+
19+
const validateField = (value: string, field: PaymentFormField): string | undefined => {
20+
if (field.required && !value) {
21+
return `${field.name} is required`;
22+
}
23+
24+
if (field.type === 'number' && value && !/^\d+$/.test(value)) {
25+
return `${field.name} must be a positive whole number`;
26+
}
27+
};
28+
29+
const PaymentFormFields: FunctionComponent<{ fields: PaymentFormField[] }> = ({ fields }) => (
30+
<>
31+
{fields.map((field) => (
32+
<BasicFormField
33+
key={field.id}
34+
name={field.id}
35+
render={({ field: fieldProps, meta }) => (
36+
<div style={{ paddingBottom: '1rem' }}>
37+
<label htmlFor={field.id}>{field.name}</label>
38+
<TextInput
39+
{...fieldProps}
40+
id={field.id}
41+
inputMode={field.type === 'number' ? 'numeric' : undefined}
42+
onChange={
43+
field.type === 'number'
44+
? (e) => {
45+
e.target.value = e.target.value.replace(/\D/g, '');
46+
fieldProps.onChange(e);
47+
}
48+
: fieldProps.onChange
49+
}
50+
type="text"
51+
/>
52+
{meta.touched && meta.error ? (
53+
<p className="form-field-error-message">{meta.error}</p>
54+
) : null}
55+
</div>
56+
)}
57+
validate={(value: string) => validateField(value, field)}
58+
/>
59+
))}
60+
</>
61+
);
62+
63+
export default PaymentFormFields;

packages/payment-integration-api/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export {
2222
export { SpecificError } from './errors';
2323
export { getCountryData } from './CountryData';
2424
export { CaptureMessageComponent } from './CaptureMessageComponent';
25+
export {
26+
default as PaymentFormFields,
27+
getPaymentFormFields,
28+
type PaymentFormField,
29+
} from './PaymentFormFields';
2530

2631
// export types separately
2732
export type { CountryData } from './CountryData';

0 commit comments

Comments
 (0)