Skip to content

Commit

Permalink
feat(braintree): add optional prop for braintree namespace (#238)
Browse files Browse the repository at this point in the history
  • Loading branch information
borodovisin committed Jan 20, 2022
1 parent 8fc371b commit 8deab76
Show file tree
Hide file tree
Showing 4 changed files with 183 additions and 71 deletions.
128 changes: 59 additions & 69 deletions src/components/braintree/BraintreePayPalButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import React, { FC, useState, useEffect } from "react";
import { loadCustomScript } from "@paypal/paypal-js";

import {
SDK_SETTINGS,
BRAINTREE_SOURCE,
BRAINTREE_PAYPAL_CHECKOUT_SOURCE,
LOAD_SCRIPT_ERROR,
} from "../../constants";
import { SDK_SETTINGS, LOAD_SCRIPT_ERROR } from "../../constants";
import { PayPalButtons } from "../PayPalButtons";
import { useScriptProviderContext } from "../../hooks/scriptProviderHooks";
import { getBraintreeWindowNamespace } from "../../utils";
import { decorateActions } from "./utils";
import { decorateActions, getBraintreeNamespace } from "./utils";
import { DISPATCH_ACTION } from "../../types";
import type {
BraintreePayPalButtonsComponentProps,
Expand All @@ -26,69 +19,66 @@ Note: You are able to make your integration using the client token or using the
- To use the client token integration set the key `data-client-token` in the `PayPayScriptProvider` component's options.
- To use the tokenization key integration set the key `data-user-id-token` in the `PayPayScriptProvider` component's options.
*/
export const BraintreePayPalButtons: FC<BraintreePayPalButtonsComponentProps> =
({
className = "",
disabled = false,
children,
forceReRender = [],
...buttonProps
}: BraintreePayPalButtonsComponentProps) => {
const [, setErrorState] = useState(null);
const [providerContext, dispatch] = useScriptProviderContext();
export const BraintreePayPalButtons: FC<
BraintreePayPalButtonsComponentProps
> = ({
className = "",
disabled = false,
children,
forceReRender = [],
braintreeNamespace,
...buttonProps
}: BraintreePayPalButtonsComponentProps) => {
const [, setErrorState] = useState(null);
const [providerContext, dispatch] = useScriptProviderContext();

useEffect(() => {
Promise.all([
loadCustomScript({ url: BRAINTREE_SOURCE }),
loadCustomScript({ url: BRAINTREE_PAYPAL_CHECKOUT_SOURCE }),
])
.then(() => {
const clientTokenizationKey: string =
providerContext.options[
SDK_SETTINGS.DATA_USER_ID_TOKEN
];
const clientToken: string =
providerContext.options[SDK_SETTINGS.DATA_CLIENT_TOKEN];
const braintreeNamespace = getBraintreeWindowNamespace();
useEffect(() => {
getBraintreeNamespace(braintreeNamespace)
.then((braintree) => {
const clientTokenizationKey: string =
providerContext.options[SDK_SETTINGS.DATA_USER_ID_TOKEN];
const clientToken: string =
providerContext.options[SDK_SETTINGS.DATA_CLIENT_TOKEN];

return braintreeNamespace.client
.create({
authorization: clientTokenizationKey || clientToken,
})
.then((clientInstance) => {
return braintreeNamespace.paypalCheckout.create({
client: clientInstance,
});
})
.then((paypalCheckoutInstance) => {
dispatch({
type: DISPATCH_ACTION.SET_BRAINTREE_INSTANCE,
value: paypalCheckoutInstance,
});
return braintree.client
.create({
authorization: clientTokenizationKey || clientToken,
})
.then((clientInstance) => {
return braintree.paypalCheckout.create({
client: clientInstance,
});
})
.then((paypalCheckoutInstance) => {
dispatch({
type: DISPATCH_ACTION.SET_BRAINTREE_INSTANCE,
value: paypalCheckoutInstance,
});
})
.catch((err) => {
setErrorState(() => {
throw new Error(`${LOAD_SCRIPT_ERROR} ${err}`);
});
})
.catch((err) => {
setErrorState(() => {
throw new Error(`${LOAD_SCRIPT_ERROR} ${err}`);
});
}, [providerContext.options, dispatch]);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providerContext.options]);

return (
<>
{providerContext.braintreePayPalCheckoutInstance && (
<PayPalButtons
className={className}
disabled={disabled}
forceReRender={forceReRender}
{...(decorateActions(
buttonProps,
providerContext.braintreePayPalCheckoutInstance
) as PayPalButtonsComponentProps)}
>
{children}
</PayPalButtons>
)}
</>
);
};
return (
<>
{providerContext.braintreePayPalCheckoutInstance && (
<PayPalButtons
className={className}
disabled={disabled}
forceReRender={forceReRender}
{...(decorateActions(
buttonProps,
providerContext.braintreePayPalCheckoutInstance
) as PayPalButtonsComponentProps)}
>
{children}
</PayPalButtons>
)}
</>
);
};
60 changes: 58 additions & 2 deletions src/components/braintree/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { decorateActions } from "./utils";

import { mock } from "jest-mock-extended";

import { decorateActions, getBraintreeNamespace } from "./utils";
import { BraintreePayPalCheckout } from "../../types/braintree/paypalCheckout";
import { CreateBillingAgreementActions } from "../..";
import { getBraintreeWindowNamespace } from "../../utils";

import type { BraintreeNamespace } from "./../../types/braintreePayPalButtonTypes";
import type {
CreateOrderBraintreeActions,
OnApproveBraintreeActions,
OnApproveBraintreeData,
} from "../../types/braintreePayPalButtonTypes";

jest.mock("@paypal/paypal-js", () => ({
loadCustomScript: jest.fn(),
}));
jest.mock("../../utils");

describe("decorateActions", () => {
test("shouldn't modify the button props", () => {
const buttonProps = {
Expand Down Expand Up @@ -139,3 +147,51 @@ describe("decorateActions", () => {
).toBeTruthy();
});
});

describe("getBraintreeNamespace", () => {
const braintreeNamespace = {
client: {
create: jest.fn(),
authorization: "",
VERSION: "",
getConfiguration: jest.fn(),
request: jest.fn(),
teardown: jest.fn(),
},
paypalCheckout: {
create: jest.fn(),
loadPayPalSDK: jest.fn(),
VERSION: "",
createPayment: jest.fn(),
tokenizePayment: jest.fn(),
getClientId: jest.fn(),
startVaultInitiatedCheckout: jest.fn(),
teardown: jest.fn(),
},
};
(getBraintreeWindowNamespace as jest.Mock).mockReturnValue(
braintreeNamespace
);

test("should return Braintree namespace from argument", async () => {
const result = await getBraintreeNamespace(braintreeNamespace);

expect(result).toMatchObject(braintreeNamespace);
});

test("should return Braintree namespace from the CDN", async () => {
const result = await getBraintreeNamespace();

expect(result).toMatchObject(braintreeNamespace);
});

test("should throw an Error when the braintreeeNamespace is an invalid property", () => {
try {
getBraintreeNamespace(mock<BraintreeNamespace>());
} catch (err) {
expect((err as Error).message).toEqual(
"The braintreeNamespace property is not a valid BraintreeNamespace type."
);
}
});
});
58 changes: 58 additions & 0 deletions src/components/braintree/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import { loadCustomScript } from "@paypal/paypal-js";

import { getBraintreeWindowNamespace } from "../../utils";
import {
BRAINTREE_SOURCE,
BRAINTREE_PAYPAL_CHECKOUT_SOURCE,
} from "../../constants";

import type { BraintreeNamespace } from "./../../types/braintreePayPalButtonTypes";
import type { BraintreePayPalCheckout } from "../../types/braintree/paypalCheckout";
import type { BraintreePayPalButtonsComponentProps } from "../../types";

/**
* Simple check to determine if the Braintree is a valid namespace.
*
* @since 7.5.1
* @param braintreeSource the source {@link BraintreeNamespace}
* @returns a boolean representing if the namespace is valid.
*/
const isValidBraintreeNamespace = (braintreeSource?: BraintreeNamespace) => {
if (
typeof braintreeSource?.client?.create !== "function" &&
typeof braintreeSource?.paypalCheckout?.create !== "function"
) {
throw new Error(
"The braintreeNamespace property is not a valid BraintreeNamespace type."
);
}
return true;
};

/**
* Use `actions.braintree` to provide an interface for the paypalCheckoutInstance
* through the createOrder, createBillingAgreement and onApprove callbacks
Expand Down Expand Up @@ -42,3 +70,33 @@ export const decorateActions = (

return { ...buttonProps };
};
/**
* Get the Braintree namespace from the component props.
* If the prop `braintreeNamespace` is undefined will try to load it from the CDN.
* This function allows users to set the braintree manually on the `BraintreePayPalButtons` component.
*
* Use case can be for example legacy sites using AMD/UMD modules,
* trying to integrate the `BraintreePayPalButtons` component.
* If we attempt to load the Braintree from the CDN won't define the braintree namespace.
* This happens because the braintree script is an UMD module.
* After detecting the AMD on the global scope will create an anonymous module using `define`
* and the `BraintreePayPalButtons` won't be able to get access to the `window.braintree` namespace
* from the global context.
*
*
* @since 7.5.1
* @param braintreeSource the source {@link BraintreeNamespace}
* @returns the {@link BraintreeNamespace}
*/
export const getBraintreeNamespace = (
braintreeSource?: BraintreeNamespace
): Promise<BraintreeNamespace> => {
if (braintreeSource && isValidBraintreeNamespace(braintreeSource)) {
return Promise.resolve(braintreeSource);
}

return Promise.all([
loadCustomScript({ url: BRAINTREE_SOURCE }),
loadCustomScript({ url: BRAINTREE_PAYPAL_CHECKOUT_SOURCE }),
]).then(() => getBraintreeWindowNamespace());
};
8 changes: 8 additions & 0 deletions src/types/braintreePayPalButtonTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ export interface BraintreePayPalButtonsComponentProps
data: OnApproveBraintreeData,
actions: OnApproveBraintreeActions
) => Promise<void>;
/**
* An optional Braintree namespace.
* Useful to provide your own implementation of the Braintree namespace loader
* and avoid the default behavior of loading it from the official CDN.
*
* @since 7.5.1
*/
braintreeNamespace?: BraintreeNamespace;
}

export type BraintreeNamespace = {
Expand Down

0 comments on commit 8deab76

Please sign in to comment.