Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add useCartElement and useCartElementState hooks #335

Merged
merged 4 commits into from
Oct 25, 2022
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
66 changes: 65 additions & 1 deletion src/components/Elements.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@ import React, {StrictMode} from 'react';
import {render, act} from '@testing-library/react';
import {renderHook} from '@testing-library/react-hooks';

import {Elements, useElements, useStripe, ElementsConsumer} from './Elements';
import {
Elements,
useElements,
useStripe,
ElementsConsumer,
useCartElement,
useCartElementState,
} from './Elements';
import createElementComponent from './createElementComponent';
import {CartElementComponent} from '../types';
import * as mocks from '../../test/mocks';

describe('Elements', () => {
let mockStripe: any;
let mockStripePromise: any;
let mockElements: any;
let mockCartElement: any;
let consoleError: any;
let consoleWarn: any;

Expand All @@ -18,6 +28,8 @@ describe('Elements', () => {
mockElements = mocks.mockElements();
mockStripe.elements.mockReturnValue(mockElements);

mockCartElement = mocks.mockElement();

jest.spyOn(console, 'error');
jest.spyOn(console, 'warn');
consoleError = console.error;
Expand Down Expand Up @@ -63,6 +75,42 @@ describe('Elements', () => {
expect(result.current).toBe(mockStripe);
});

test('injects cartElement with the useCartElement hook', () => {
const CartElement: CartElementComponent = createElementComponent(
'cart',
false
);

const wrapper = ({children}: any) => (
<Elements stripe={mockStripe}>
<CartElement options={{clientSecret: ''}} />
{children}
</Elements>
);

const {result} = renderHook(() => useCartElement(), {wrapper});

expect(result.current).toBe(mockCartElement);
});

test('returns cartElement state with the useCartElement hook', () => {
const CartElement: CartElementComponent = createElementComponent(
'cart',
false
);

const wrapper = ({children}: any) => (
<Elements stripe={mockStripe}>
<CartElement options={{clientSecret: ''}} />
{children}
</Elements>
);

const {result} = renderHook(() => useCartElementState(), {wrapper});

expect(result.current).toHaveProperty('lineItems.count');
});

test('provides elements and stripe with the ElementsConsumer component', () => {
expect.assertions(2);

Expand Down Expand Up @@ -293,6 +341,22 @@ describe('Elements', () => {
);
});

test('throws when trying to call useCartElement outside of Elements context', () => {
const {result} = renderHook(() => useCartElement());

expect(result.error && result.error.message).toBe(
'Could not find Elements context; You need to wrap the part of your app that calls useCartElement() in an <Elements> provider.'
);
});

test('throws when trying to call useCartElementState outside of Elements context', () => {
const {result} = renderHook(() => useCartElementState());

expect(result.error && result.error.message).toBe(
'Could not find Elements context; You need to wrap the part of your app that calls useCartElementState() in an <Elements> provider.'
);
});

describe('React.StrictMode', () => {
test('creates elements twice in StrictMode', () => {
const TestComponent = () => {
Expand Down
68 changes: 67 additions & 1 deletion src/components/Elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,33 @@ export const parseElementsContext = (
return ctx;
};

interface CartElementContextValue {
cart: stripeJs.StripeCartElement | null;
cartState: stripeJs.StripeCartElementPayloadEvent | null;
setCart: (cart: stripeJs.StripeCartElement | null) => void;
setCartState: (
cartState: stripeJs.StripeCartElementPayloadEvent | null
) => void;
}

export const CartElementContext = React.createContext<CartElementContextValue | null>(
null
);
CartElementContext.displayName = 'CartElementContext';

export const parseCartElementContext = (
ctx: CartElementContextValue | null,
useCase: string
): CartElementContextValue => {
if (!ctx) {
throw new Error(
`Could not find Elements context; You need to wrap the part of your app that ${useCase} in an <Elements> provider.`
);
}

return ctx;
};

interface ElementsProps {
/**
* A [Stripe object](https://stripe.com/docs/js/initializing) or a `Promise` resolving to a `Stripe` object.
Expand Down Expand Up @@ -116,6 +143,14 @@ export const Elements: FunctionComponent<PropsWithChildren<ElementsProps>> = (({
rawStripeProp,
]);

const [cart, setCart] = React.useState<stripeJs.StripeCartElement | null>(
null
);
const [
cartState,
setCartState,
] = React.useState<stripeJs.StripeCartElementPayloadEvent | null>(null);

// For a sync stripe instance, initialize into context
const [ctx, setContext] = React.useState<ElementsContextValue>(() => ({
stripe: parsed.tag === 'sync' ? parsed.stripe : null,
Expand Down Expand Up @@ -205,7 +240,13 @@ export const Elements: FunctionComponent<PropsWithChildren<ElementsProps>> = (({
}, [ctx.stripe]);

return (
<ElementsContext.Provider value={ctx}>{children}</ElementsContext.Provider>
<ElementsContext.Provider value={ctx}>
<CartElementContext.Provider
value={{cart, setCart, cartState, setCartState}}
>
{children}
</CartElementContext.Provider>
</ElementsContext.Provider>
);
}) as FunctionComponent<PropsWithChildren<ElementsProps>>;

Expand All @@ -221,6 +262,13 @@ export const useElementsContextWithUseCase = (
return parseElementsContext(ctx, useCaseMessage);
};

export const useCartElementContextWithUseCase = (
useCaseMessage: string
): CartElementContextValue => {
const ctx = React.useContext(CartElementContext);
return parseCartElementContext(ctx, useCaseMessage);
};

/**
* @docs https://stripe.com/docs/stripe-js/react#useelements-hook
*/
Expand All @@ -237,6 +285,24 @@ export const useStripe = (): stripeJs.Stripe | null => {
return stripe;
};

/**
* @docs https://stripe.com/docs/payments/checkout/cart-element
*/
export const useCartElement = (): stripeJs.StripeCartElement | null => {
const {cart} = useCartElementContextWithUseCase('calls useCartElement()');
return cart;
};

/**
* @docs https://stripe.com/docs/payments/checkout/cart-element
*/
export const useCartElementState = (): stripeJs.StripeCartElementPayloadEvent | null => {
const {cartState} = useCartElementContextWithUseCase(
'calls useCartElementState()'
);
return cartState;
};

interface ElementsConsumerProps {
children: (props: ElementsContextValue) => ReactNode;
}
Expand Down
54 changes: 54 additions & 0 deletions src/components/createElementComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
CardElementComponent,
PaymentElementComponent,
PaymentRequestButtonElementComponent,
CartElementComponent,
} from '../types';

describe('createElementComponent', () => {
Expand All @@ -23,6 +24,8 @@ describe('createElementComponent', () => {
let simulateLoadError: any;
let simulateLoaderStart: any;
let simulateNetworksChange: any;
let simulateCheckout: any;
let simulateLineItemClick: any;

beforeEach(() => {
mockStripe = mocks.mockStripe();
Expand Down Expand Up @@ -60,6 +63,12 @@ describe('createElementComponent', () => {
case 'networkschange':
simulateNetworksChange = fn;
break;
case 'checkout':
simulateCheckout = fn;
break;
case 'lineitemclick':
simulateLineItemClick = fn;
break;
default:
throw new Error('TestSetupError: Unexpected event registration.');
}
Expand Down Expand Up @@ -138,6 +147,11 @@ describe('createElementComponent', () => {
false
);

const CartElement: CartElementComponent = createElementComponent(
'cart',
false
);

it('Can remove and add CardElement at the same time', () => {
let cardMounted = false;
mockElement.mount.mockImplementation(() => {
Expand Down Expand Up @@ -420,6 +434,46 @@ describe('createElementComponent', () => {
expect(mockHandler).not.toHaveBeenCalled();
});

it('propagates the Element`s checkout event to the current onCheckout prop', () => {
const mockHandler = jest.fn();
const mockHandler2 = jest.fn();
const {rerender} = render(
<Elements stripe={mockStripe}>
<CartElement onCheckout={mockHandler} />
</Elements>
);
rerender(
<Elements stripe={mockStripe}>
<CartElement onCheckout={mockHandler2} />
</Elements>
);

const checkoutEventMock = Symbol('checkout');
simulateCheckout(checkoutEventMock);
expect(mockHandler2).toHaveBeenCalledWith(checkoutEventMock);
expect(mockHandler).not.toHaveBeenCalled();
});

it('propagates the Element`s lineitemclick event to the current onLineItemClick prop', () => {
const mockHandler = jest.fn();
const mockHandler2 = jest.fn();
const {rerender} = render(
<Elements stripe={mockStripe}>
<CartElement onLineItemClick={mockHandler} />
</Elements>
);
rerender(
<Elements stripe={mockStripe}>
<CartElement onLineItemClick={mockHandler2} />
</Elements>
);

const lineItemClickEventMock = Symbol('lineitemclick');
simulateLineItemClick(lineItemClickEventMock);
expect(mockHandler2).toHaveBeenCalledWith(lineItemClickEventMock);
expect(mockHandler).not.toHaveBeenCalled();
});

it('updates the Element when options change', () => {
const {rerender} = render(
<Elements stripe={mockStripe}>
Expand Down
Loading