Skip to content

Commit

Permalink
Merge pull request #429 from paypal/feature/add-card-fields-form
Browse files Browse the repository at this point in the history
Feature/add card fields form
  • Loading branch information
sebastianfdz authored Apr 18, 2024
2 parents ce7c713 + 7eb2b06 commit 8b6e7fe
Show file tree
Hide file tree
Showing 8 changed files with 327 additions and 3 deletions.
69 changes: 68 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,73 @@ export default function App() {

The JS SDK card-fields component provides payment form functionality that you can customize. Read more about this integration in the [PayPal Advanced Card Payments documentation](https://developer.paypal.com/docs/business/checkout/advanced-card-payments/).

There are 3 parts to the card-fields integration:
#### Using Card Fields Form (recommeneded)

There are 3 parts to the this card-fields integration:

1. The `<PayPalCardFieldsProvider />` provider component wraps the form field elements and accepts props like `createOrder()`.
2. The `<PayPalCardFieldsForm />` component renders a form with all 4 fields included out of the box. This is an alternative for merchants who don't want to render each field individually in their react app.
3. The `usePayPalCardFields` hook exposes the `cardFieldsForm` instance that includes methods suchs as the `cardFieldsForm.submit()` function for submitting the payment with your own custom button. It also exposes the references to each of the individual components for more granular control, eg: `fields.CVVField.focus()` to programatically manipulate the element in the DOM.

```jsx
import {
PayPalScriptProvider,
PayPalCardFieldsProvider,
PayPalCardFieldsForm
usePayPalCardFields,
} from "@paypal/react-paypal-js";

const SubmitPayment = () => {
const { cardFields, fields } = usePayPalCardFields();

function submitHandler() {
if (typeof cardFields.submit !== "function") return; // validate that `submit()` exists before using it

cardFields
.submit()
.then(() => {
// submit successful
})
.catch(() => {
// submission error
});
}
return <button onClick={submitHandler}>Pay</button>;
};

export default function App() {
function createOrder() {
// merchant code
}
function onApprove() {
// merchant code
}
function onError() {
// merchant code
}
return (
<PayPalScriptProvider
options={{
clientId: "your-client-id",
components: "card-fields",
}}
>
<PayPalCardFieldsProvider
createOrder={createOrder}
onApprove={onApprove}
onError={onError}
>
<PayPalCardFieldsForm />
<SubmitPayment />
</PayPalCardFieldsProvider>
</PayPalScriptProvider>
);
}
```

#### Using Card Fields Individually

There are 3 parts to the this card-fields integration:

1. The `<PayPalCardFieldsProvider />` provider component wraps the form field elements and accepts props like `createOrder()`.
2. The individual CardFields:
Expand Down Expand Up @@ -403,6 +469,7 @@ const SubmitPayment = () => {
return <button onClick={submitHandler}>Pay</button>;
};

// Example using individual card fields
export default function App() {
function createOrder() {
// merchant code
Expand Down
210 changes: 210 additions & 0 deletions src/components/cardFields/PayPalCardFieldsForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import React from "react";
import { render, waitFor } from "@testing-library/react";
import { ErrorBoundary } from "react-error-boundary";
import { loadScript } from "@paypal/paypal-js";
import { mock } from "jest-mock-extended";

import { PayPalScriptProvider } from "../PayPalScriptProvider";
import { PayPalCardFieldsProvider } from "./PayPalCardFieldsProvider";
import { CARD_FIELDS_CONTEXT_ERROR } from "../../constants";
import { PayPalCardFieldsForm } from "./PayPalCardFieldsForm";

import type { PayPalCardFieldsComponent } from "../../types";
import type { ReactNode } from "react";

const onError = jest.fn();
const CVVField = jest.fn();
const ExpiryField = jest.fn();
const NumberField = jest.fn();
const NameField = jest.fn();
const CardFields = jest.fn(
() =>
({
isEligible: jest.fn().mockReturnValue(true),
CVVField: CVVField.mockReturnValue({
render: jest.fn(() => Promise.resolve()),
close: jest.fn(() => Promise.resolve()),
}),
ExpiryField: ExpiryField.mockReturnValue({
render: jest.fn(() => Promise.resolve()),
close: jest.fn(() => Promise.resolve()),
}),
NumberField: NumberField.mockReturnValue({
render: jest.fn(() => Promise.resolve()),
close: jest.fn(() => Promise.resolve()),
}),
NameField: NameField.mockReturnValue({
render: jest.fn(() => Promise.resolve()),
close: jest.fn(() => Promise.resolve()),
}),
} as unknown as PayPalCardFieldsComponent)
);
const wrapper = ({ children }: { children: ReactNode }) => (
<ErrorBoundary fallback={<div>Error</div>} onError={onError}>
{children}
</ErrorBoundary>
);

jest.mock("@paypal/paypal-js", () => ({
loadScript: jest.fn(),
}));
const mockCreateOrder = mock<() => Promise<string>>();
const mockOnApprove = mock<() => Promise<string>>();
const mockOnError = mock<() => void>();
const mockOnChange = mock<() => void>();
const mockOnBlur = mock<() => void>();
const mockOnFocus = mock<() => void>();
const mockOnInputSubmitRequest = mock<() => void>();

describe("PayPalCardFieldsForm", () => {
beforeEach(() => {
document.body.innerHTML = "";

window.paypal = {
CardFields,
version: "",
};

(loadScript as jest.Mock).mockResolvedValue(window.paypal);
});
test("should render each component with the global style passed", async () => {
const spyConsoleError = jest
.spyOn(console, "error")
.mockImplementation();

render(
<PayPalScriptProvider
options={{
clientId: "test-client",
currency: "USD",
intent: "authorize",
components: "card-fields",
}}
>
<PayPalCardFieldsProvider
onApprove={mockOnApprove}
createOrder={mockCreateOrder}
onError={mockOnError}
>
<PayPalCardFieldsForm
style={{ input: { color: "black" } }}
/>
</PayPalCardFieldsProvider>
</PayPalScriptProvider>,
{ wrapper }
);
await waitFor(() => expect(onError).toHaveBeenCalledTimes(0));

[CVVField, ExpiryField, NameField, NumberField].forEach((field) => {
expect(field).toHaveBeenCalledWith(
expect.objectContaining({
style: { input: { color: "black" } },
})
);
});

spyConsoleError.mockRestore();
});

test("should render component with specific input event callbacks", async () => {
const spyConsoleError = jest
.spyOn(console, "error")
.mockImplementation();

render(
<PayPalScriptProvider
options={{
clientId: "test-client",
currency: "USD",
intent: "authorize",
components: "card-fields",
}}
>
<PayPalCardFieldsProvider
onApprove={mockOnApprove}
createOrder={mockCreateOrder}
onError={mockOnError}
>
<PayPalCardFieldsForm
inputEvents={{
onChange: mockOnChange,
onFocus: mockOnFocus,
onBlur: mockOnBlur,
onInputSubmitRequest: mockOnInputSubmitRequest,
}}
/>
</PayPalCardFieldsProvider>
</PayPalScriptProvider>,
{ wrapper }
);
await waitFor(() => expect(onError).toHaveBeenCalledTimes(0));

[CVVField, ExpiryField, NameField, NumberField].forEach((field) => {
expect(field).toHaveBeenCalledWith(
expect.objectContaining({
inputEvents: {
onChange: mockOnChange,
onFocus: mockOnFocus,
onBlur: mockOnBlur,
onInputSubmitRequest: mockOnInputSubmitRequest,
},
})
);
});

spyConsoleError.mockRestore();
});

test("should render component with specific container classes", async () => {
const spyConsoleError = jest
.spyOn(console, "error")
.mockImplementation();

const { container } = render(
<PayPalScriptProvider
options={{
clientId: "test-client",
currency: "USD",
intent: "authorize",
components: "card-fields",
dataClientToken: "test-data-client-token",
}}
>
<PayPalCardFieldsProvider
onApprove={mockOnApprove}
createOrder={mockCreateOrder}
onError={mockOnError}
>
<PayPalCardFieldsForm className="class1 class2 class3" />
</PayPalCardFieldsProvider>
</PayPalScriptProvider>,
{ wrapper }
);
await waitFor(() => expect(onError).toHaveBeenCalledTimes(0));

const renderedElement = container.querySelector(".class1");
expect(renderedElement?.classList.contains("class2")).toBeTruthy();
expect(renderedElement?.classList.contains("class3")).toBeTruthy();

spyConsoleError.mockRestore();
});

test("should fail rendering the component when context is invalid", async () => {
const spyConsoleError = jest
.spyOn(console, "error")
.mockImplementation();

render(
<PayPalScriptProvider options={{ clientId: "" }}>
<PayPalCardFieldsForm />
</PayPalScriptProvider>,
{ wrapper }
);

await waitFor(() => expect(onError).toBeCalledTimes(4)); // 4 times, 1 for each field in the form.
expect(onError.mock.calls[0][0].message).toBe(
CARD_FIELDS_CONTEXT_ERROR
);
spyConsoleError.mockRestore();
});
});
26 changes: 26 additions & 0 deletions src/components/cardFields/PayPalCardFieldsForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from "react";

import { PayPalCardFieldsFormOptions } from "../../types";
import { PayPalCardField } from "./PayPalCardField";
import { FlexContainer } from "../ui/FlexContainer";
import { FullWidthContainer } from "../ui/FullWidthContainer";

export const PayPalCardFieldsForm: React.FC<PayPalCardFieldsFormOptions> = ({
className,
...options
}) => {
return (
<div className={className}>
<PayPalCardField fieldName="NameField" {...options} />
<PayPalCardField fieldName="NumberField" {...options} />
<FlexContainer>
<FullWidthContainer>
<PayPalCardField fieldName="ExpiryField" {...options} />
</FullWidthContainer>
<FullWidthContainer>
<PayPalCardField fieldName="CVVField" {...options} />
</FullWidthContainer>
</FlexContainer>
</div>
);
};
5 changes: 3 additions & 2 deletions src/components/cardFields/PayPalCardFieldsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SDK_SETTINGS } from "../../constants";
import { generateMissingCardFieldsError } from "./utils";
import { PayPalCardFieldsContext } from "./context";
import { usePayPalCardFieldsRegistry } from "./hooks";
import { FullWidthContainer } from "../ui/FullWidthContainer";

import type {
PayPalCardFieldsComponentOptions,
Expand Down Expand Up @@ -81,7 +82,7 @@ export const PayPalCardFieldsProvider = ({
}

return (
<div style={{ width: "100%" }}>
<FullWidthContainer>
<PayPalCardFieldsContext.Provider
value={{
cardFieldsForm,
Expand All @@ -92,6 +93,6 @@ export const PayPalCardFieldsProvider = ({
>
{children}
</PayPalCardFieldsContext.Provider>
</div>
</FullWidthContainer>
);
};
7 changes: 7 additions & 0 deletions src/components/ui/FlexContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React, { ReactNode } from "react";

export const FlexContainer: React.FC<{ children: ReactNode }> = ({
children,
}) => {
return <div style={{ display: "flex", width: "100%" }}>{children}</div>;
};
7 changes: 7 additions & 0 deletions src/components/ui/FullWidthContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React, { ReactNode } from "react";

export const FullWidthContainer: React.FC<{ children: ReactNode }> = ({
children,
}) => {
return <div style={{ width: "100%" }}>{children}</div>;
};
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export * from "./components/cardFields/PayPalNameField";
export * from "./components/cardFields/PayPalNumberField";
export * from "./components/cardFields/PayPalExpiryField";
export * from "./components/cardFields/PayPalCVVField";
export * from "./components/cardFields/PayPalCardFieldsForm";
export * from "./components/cardFields/context";
export { usePayPalCardFields } from "./components/cardFields/hooks";

Expand Down
5 changes: 5 additions & 0 deletions src/types/payPalCardFieldsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export type PayPalCardFieldsIndividualFieldOptions = FieldOptions & {
className?: string;
};

export type PayPalCardFieldsFormOptions = Omit<
PayPalCardFieldsIndividualFieldOptions,
"placeholder"
>;

export type PayPalCardFieldsNamespace = {
components: string | string[] | undefined;
} & { [DATA_NAMESPACE: string]: string | undefined };
Expand Down

0 comments on commit 8b6e7fe

Please sign in to comment.