Skip to content

Commit

Permalink
feat(checkout): CHECKOUT-7538 Introduce extension messenger and comma…
Browse files Browse the repository at this point in the history
…nd handler (#2050)

* feat(checkout): CHECKOUT-7538 Introduce extension messenger

* feat(checkout): CHECKOUT-7538 Remove client side code

* feat(checkout): CHECKOUT-7538 Remove unnecessary actions

* feat(checkout): CHECKOUT-7538 Apply HostOriginEvent

* feat(checkout): CHECKOUT-7538 Rework extensionMessenger

* feat(checkout): CHECKOUT-7538 handleExtensionCommand

* feat(checkout): CHECKOUT-7538 Rework extensionMessenger.listen()

* feat(checkout): CHECKOUT-7538 Use ReadableCheckoutStore

* feat(checkout): CHECKOUT-7538 Update import path
  • Loading branch information
bc-peng authored Jul 16, 2023
1 parent f93eb11 commit b43adec
Show file tree
Hide file tree
Showing 21 changed files with 490 additions and 76 deletions.
29 changes: 28 additions & 1 deletion packages/core/src/checkout/checkout-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ import {
import CustomerStrategyRegistryV2 from '../customer/customer-strategy-registry-v2';
import {
ExtensionActionCreator,
ExtensionActionType,
ExtensionCommand,
ExtensionMessenger,
ExtensionRegion,
ExtensionRequestSender,
getExtensions,
} from '../extension';
import { ExtensionActionType } from '../extension/extension-actions';
import { FormFieldsActionCreator, FormFieldsRequestSender } from '../form';
import { getAddressFormFields, getFormFields } from '../form/form.mock';
import { CountryActionCreator, CountryRequestSender } from '../geography';
Expand Down Expand Up @@ -143,10 +145,13 @@ describe('CheckoutService', () => {
let spamProtectionActionCreator: SpamProtectionActionCreator;
let store: CheckoutStore;
let storeCreditRequestSender: StoreCreditRequestSender;
let extensionMessenger: ExtensionMessenger;

beforeEach(() => {
store = createCheckoutStore(getCheckoutStoreState());

extensionMessenger = new ExtensionMessenger(store, {}, {});

const locale = 'en';
const requestSender = createRequestSender();
const paymentClient = createPaymentClient(store);
Expand Down Expand Up @@ -369,6 +374,7 @@ describe('CheckoutService', () => {

checkoutService = new CheckoutService(
store,
extensionMessenger,
billingAddressActionCreator,
checkoutActionCreator,
configActionCreator,
Expand Down Expand Up @@ -1495,4 +1501,25 @@ describe('CheckoutService', () => {
expect(extensionActionCreator.renderExtension).toHaveBeenCalledWith(container, region);
});
});

describe('#listenExtensionCommand()', () => {
it('listens for extension commands', () => {
const extensions = getExtensions();
const handler = jest.fn();

jest.spyOn(extensionMessenger, 'listen');

checkoutService.listenExtensionCommand(
extensions[0].id,
ExtensionCommand.ReloadCheckout,
handler,
);

expect(extensionMessenger.listen).toHaveBeenCalledWith(
extensions[0].id,
ExtensionCommand.ReloadCheckout,
handler,
);
});
});
});
30 changes: 27 additions & 3 deletions packages/core/src/checkout/checkout-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ import {
ExecutePaymentMethodCheckoutOptions,
GuestCredentials,
} from '../customer';
import { ExtensionActionCreator, ExtensionRegion } from '../extension';
import {
ExtensionActionCreator,
ExtensionCommand,
ExtensionCommandHandler,
ExtensionMessenger,
ExtensionRegion,
} from '../extension';
import { FormFieldsActionCreator } from '../form';
import { CountryActionCreator } from '../geography';
import { OrderActionCreator, OrderRequestBody } from '../order';
Expand Down Expand Up @@ -77,6 +83,7 @@ export default class CheckoutService {
*/
constructor(
private _store: CheckoutStore,
private _extensionMessenger: ExtensionMessenger,
private _billingAddressActionCreator: BillingAddressActionCreator,
private _checkoutActionCreator: CheckoutActionCreator,
private _configActionCreator: ConfigActionCreator,
Expand Down Expand Up @@ -1389,8 +1396,8 @@ export default class CheckoutService {
* Currently, only one extension is allowed per region.
*
* @alpha
* @param container The ID of a container which the extension should be inserted.
* @param region The name of an area where the extension should be presented.
* @param container - The ID of a container which the extension should be inserted.
* @param region - The name of an area where the extension should be presented.
* @returns A promise that resolves to the current state.
*/
renderExtension(container: string, region: ExtensionRegion): Promise<CheckoutSelectors> {
Expand All @@ -1399,6 +1406,23 @@ export default class CheckoutService {
return this._dispatch(action, { queueId: 'extensions' });
}

/**
* Manages the command handler for an extension.
*
* @alpha
* @param extensionId - The ID of the extension sending the command.
* @param command - The command to be handled.
* @param handler - The handler function for the extension command.
* @returns A function that, when called, will deregister the command handler.
*/
listenExtensionCommand(
extensionId: string,
command: ExtensionCommand,
handler: ExtensionCommandHandler,
): () => void {
return this._extensionMessenger.listen(extensionId, command, handler);
}

/**
* Dispatches an action through the data store and returns the current state
* once the action is dispatched.
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/checkout/create-checkout-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
CustomerRequestSender,
CustomerStrategyActionCreator,
} from '../customer';
import { ExtensionActionCreator, ExtensionRequestSender } from '../extension';
import { ExtensionActionCreator, ExtensionMessenger, ExtensionRequestSender } from '../extension';
import { FormFieldsActionCreator, FormFieldsRequestSender } from '../form';
import * as defaultPaymentStrategyFactories from '../generated/payment-strategies';
import { CountryActionCreator, CountryRequestSender } from '../geography';
Expand Down Expand Up @@ -140,6 +140,7 @@ export default function createCheckoutService(options?: CheckoutServiceOptions):

return new CheckoutService(
store,
new ExtensionMessenger(store, {}, {}),
new BillingAddressActionCreator(
new BillingAddressRequestSender(requestSender),
subscriptionsActionCreator,
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/extension/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export { InvalidExtensionConfigError } from './invalid-extension-config-error';
export { ExtensionNotFoundError } from './extension-not-found-error';

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { StandardError } from '../../common/error/errors';

export class UnsupportedExtensionCommandError extends StandardError {
constructor(message?: string) {
super(message || 'Unable to proceed due to unsupported extension command.');

this.name = 'UnsupportedExtensionCommandError';
this.type = 'unsupported_extension_command_error';
}
}
28 changes: 22 additions & 6 deletions packages/core/src/extension/extension-action-creator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createRequestSender, Response } from '@bigcommerce/request-sender';
import { EventEmitter } from 'events';
import { from, of } from 'rxjs';
import { catchError, toArray } from 'rxjs/operators';

Expand All @@ -7,13 +8,14 @@ import { ErrorResponseBody } from '@bigcommerce/checkout-sdk/payment-integration
import { CheckoutStore, createCheckoutStore } from '../checkout';
import { getCheckout, getCheckoutStoreState } from '../checkout/checkouts.mock';
import { getErrorResponse, getResponse } from '../common/http-request/responses.mock';
import { EmbeddedCheckoutEventType } from '../embedded-checkout/embedded-checkout-events';
import { NotEmbeddableError } from '../embedded-checkout/errors';

import { ExtensionNotFoundError, InvalidExtensionConfigError } from './errors';
import { Extension, ExtensionRegion } from './extension';
import { ExtensionActionCreator } from './extension-action-creator';
import { ExtensionActionType } from './extension-actions';
import { ExtensionRequestSender } from './extension-request-sender';
import { getExtensions, getExtensionState } from './extension.mock';
import { getExtensionMessageEvent, getExtensions, getExtensionState } from './extension.mock';

describe('ExtensionActionCreator', () => {
let errorResponse: Response<ErrorResponseBody>;
Expand Down Expand Up @@ -91,7 +93,8 @@ describe('ExtensionActionCreator', () => {
});

const errorHandler = jest.fn((action) => of(action));
const actions = await from(

await from(
extensionActionCreator.renderExtension(
'foo',
ExtensionRegion.ShippingShippingAddressFormAfter,
Expand All @@ -101,18 +104,31 @@ describe('ExtensionActionCreator', () => {
.toPromise();

expect(errorHandler).toHaveBeenCalled();
expect(actions).toBeInstanceOf(ExtensionNotFoundError);
});

it('emits actions if able to render an extension', async () => {
const event = getExtensionMessageEvent();
const eventEmitter = new EventEmitter();
const mockElement = document.createElement('div');

jest.spyOn(document, 'getElementById').mockReturnValue(mockElement);
jest.spyOn(window, 'addEventListener').mockImplementation((type, listener) => {
return eventEmitter.addListener(type, listener);
});

setTimeout(() => {
eventEmitter.emit('message', {
...event,
data: {
type: EmbeddedCheckoutEventType.FrameLoaded,
},
});
});

const actions = await from(
extensionActionCreator.renderExtension(
'foo',
ExtensionRegion.ShippingShippingAddressFormAfter,
ExtensionRegion.ShippingShippingAddressFormBefore,
)(store),
)
.pipe(toArray())
Expand Down Expand Up @@ -141,7 +157,7 @@ describe('ExtensionActionCreator', () => {
{ type: ExtensionActionType.RenderExtensionRequested },
{
type: ExtensionActionType.RenderExtensionFailed,
payload: expect.any(InvalidExtensionConfigError),
payload: expect.any(NotEmbeddableError),
error: true,
},
]);
Expand Down
18 changes: 9 additions & 9 deletions packages/core/src/extension/extension-action-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,23 @@ export class ExtensionActionCreator {
region: ExtensionRegion,
): ThunkAction<ExtensionAction, InternalCheckoutSelectors> {
return (store) =>
Observable.create((observer: Observer<ExtensionAction>) => {
Observable.create(async (observer: Observer<ExtensionAction>) => {
const state = store.getState();
const { id: cartId } = state.cart.getCartOrThrow();
const extension = state.extensions.getExtensionByRegion(region);

if (!extension) {
throw new ExtensionNotFoundError(
`Unable to proceed due to no extension configured for ${region}.`,
);
}
try {
if (!extension) {
throw new ExtensionNotFoundError(
`Unable to proceed due to no extension configured for the region: ${region}.`,
);
}

observer.next(createAction(ExtensionActionType.RenderExtensionRequested));
observer.next(createAction(ExtensionActionType.RenderExtensionRequested));

try {
const iframe = new ExtensionIframe(container, extension, cartId);

iframe.attach();
await iframe.attach();

observer.next(createAction(ExtensionActionType.RenderExtensionSucceeded));
observer.complete();
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/extension/extension-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface RenderExtensionRequestedAction extends Action {
type: ExtensionActionType.RenderExtensionRequested;
}

export interface RenderExtensionSucceededAction extends Action<Extension> {
export interface RenderExtensionSucceededAction extends Action {
type: ExtensionActionType.RenderExtensionSucceeded;
}

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/extension/extension-command-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ExtensionOriginEvent } from './extension-origin-event';

export type ExtensionCommandHandler = (data: ExtensionOriginEvent) => void;
45 changes: 31 additions & 14 deletions packages/core/src/extension/extension-iframe.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { EventEmitter } from 'events';

import { Cart } from '@bigcommerce/checkout-sdk/payment-integration-api';
import { getCart } from '@bigcommerce/checkout-sdk/payment-integrations-test-utils';

import { InvalidExtensionConfigError } from './errors';
import { parseUrl } from '../common/url';
import { EmbeddedCheckoutEventType } from '../embedded-checkout/embedded-checkout-events';

import { Extension } from './extension';
import { ExtensionIframe } from './extension-iframe';
import { getExtensions } from './extension.mock';
Expand All @@ -11,39 +15,52 @@ describe('ExtensionIframe', () => {
let container: HTMLDivElement;
let extension: Extension;
let extensionIframe: ExtensionIframe;
let extensionOrigin: string;
let eventEmitter: EventEmitter;

beforeEach(() => {
container = document.createElement('div');
container.id = 'containerId';
document.getElementById = jest.fn().mockReturnValue(container);

cart = getCart();
extension = getExtensions()[0];
extensionIframe = new ExtensionIframe('containerId', extension, cart.id);
extensionOrigin = parseUrl(extension.url).origin;
eventEmitter = new EventEmitter();

document.getElementById = jest.fn().mockReturnValue(container);

setTimeout(() => {
eventEmitter.emit('message', {
origin: extensionOrigin,
data: { type: EmbeddedCheckoutEventType.FrameLoaded },
});
});

jest.spyOn(window, 'addEventListener').mockImplementation((type, listener) => {
return eventEmitter.addListener(type, listener);
});
});

afterEach(() => {
container.innerHTML = '';
});

it('attaches iframe to the container', () => {
const appendChild = jest.spyOn(container, 'appendChild');
it('attaches iframe to the container', async () => {
await extensionIframe.attach();

extensionIframe.attach();

expect(appendChild).toHaveBeenCalled();
});
const iframe = container.querySelector('iframe') || document.createElement('iframe');

it('throws InvalidExtensionConfigError when container ID is invalid', () => {
document.getElementById = jest.fn().mockReturnValue(null);
const url = new URL(iframe.src);

expect(() => extensionIframe.attach()).toThrow(InvalidExtensionConfigError);
expect(url.origin).toBe(extensionOrigin);
expect(url.searchParams.get('extensionId')).toBe(extension.id);
expect(url.searchParams.get('cartId')).toBe(cart.id);
});

it('detaches the iframe from its parent', () => {
it('detaches the iframe from its parent', async () => {
const removeChild = jest.spyOn(container, 'removeChild');

extensionIframe.attach();
await extensionIframe.attach();
extensionIframe.detach();

expect(removeChild).toHaveBeenCalled();
Expand Down
Loading

0 comments on commit b43adec

Please sign in to comment.