From 30de59d08a1592222ae81d884948029d7ab08749 Mon Sep 17 00:00:00 2001 From: Gianni Chiappetta Date: Fri, 11 Jan 2019 12:37:45 -0500 Subject: [PATCH] feat: initial commit --- .gitignore | 10 + README.md | 12 + api-extractor.json | 10 + package.json | 33 + src/MessageTransport.ts | 112 +++ src/actions/Button/README.md | 103 ++ src/actions/Button/actions.ts | 113 +++ src/actions/Button/index.ts | 2 + src/actions/Button/tests/actions.test.ts | 107 +++ src/actions/Button/types.ts | 40 + src/actions/ButtonGroup/README.md | 123 +++ src/actions/ButtonGroup/actions.ts | 158 ++++ src/actions/ButtonGroup/index.ts | 2 + src/actions/ButtonGroup/tests/actions.test.ts | 172 ++++ src/actions/ButtonGroup/types.ts | 26 + src/actions/Camera/README.md | 39 + src/actions/Camera/actions.ts | 50 + src/actions/Camera/index.ts | 2 + src/actions/Camera/types.ts | 39 + src/actions/Cart/README.md | 277 ++++++ src/actions/Cart/actions.ts | 134 +++ src/actions/Cart/index.ts | 2 + src/actions/Cart/tests/actions.test.ts | 356 +++++++ src/actions/Cart/types.ts | 183 ++++ src/actions/Error/README.md | 111 +++ src/actions/Error/actions.ts | 155 +++ src/actions/Error/index.ts | 2 + src/actions/Error/tests/actions.test.ts | 270 ++++++ src/actions/Error/types.ts | 35 + src/actions/Features/README.md | 59 ++ src/actions/Features/actions.ts | 27 + src/actions/Features/index.ts | 2 + src/actions/Features/tests/actions.test.ts | 33 + src/actions/Features/types.ts | 11 + src/actions/Flash/README.md | 3 + src/actions/Flash/actions.ts | 22 + src/actions/Flash/index.ts | 2 + src/actions/Loading/README.md | 60 ++ src/actions/Loading/actions.ts | 54 ++ src/actions/Loading/index.ts | 2 + src/actions/Loading/tests/actions.test.ts | 57 ++ src/actions/Loading/types.ts | 17 + src/actions/Modal/README.md | 264 ++++++ src/actions/Modal/actions.ts | 330 +++++++ src/actions/Modal/index.ts | 2 + src/actions/Modal/tests/actions.test.ts | 357 +++++++ src/actions/Modal/types.ts | 68 ++ src/actions/Navigation/History/README.md | 66 ++ src/actions/Navigation/History/actions.ts | 56 ++ src/actions/Navigation/History/index.ts | 2 + .../Navigation/History/tests/actions.test.ts | 73 ++ src/actions/Navigation/History/types.ts | 18 + src/actions/Navigation/Redirect/README.md | 156 +++ src/actions/Navigation/Redirect/actions.ts | 181 ++++ src/actions/Navigation/Redirect/index.ts | 2 + .../Navigation/Redirect/tests/actions.test.ts | 300 ++++++ src/actions/Navigation/Redirect/types.ts | 62 ++ src/actions/Print/README.md | 22 + src/actions/Print/actions.ts | 16 + src/actions/Print/index.ts | 2 + src/actions/Print/tests/actions.test.ts | 16 + src/actions/Print/types.ts | 11 + src/actions/README.md | 333 +++++++ src/actions/ResourcePicker/README.md | 168 ++++ src/actions/ResourcePicker/actions.ts | 200 ++++ src/actions/ResourcePicker/index.ts | 2 + .../ResourcePicker/tests/actions.test.ts | 105 +++ src/actions/ResourcePicker/types.ts | 185 ++++ src/actions/TitleBar/README.md | 244 +++++ src/actions/TitleBar/actions.ts | 241 +++++ src/actions/TitleBar/index.ts | 2 + src/actions/TitleBar/tests/actions.test.ts | 375 ++++++++ src/actions/TitleBar/types.ts | 46 + src/actions/Toast/README.md | 89 ++ src/actions/Toast/actions.ts | 92 ++ src/actions/Toast/index.ts | 2 + src/actions/Toast/tests/actions.test.ts | 91 ++ src/actions/Toast/types.ts | 28 + src/actions/buttonGroupHelper.ts | 25 + src/actions/buttonHelper.ts | 18 + src/actions/constants.ts | 1 + src/actions/helper.ts | 402 ++++++++ src/actions/index.ts | 40 + src/actions/merge.ts | 51 + src/actions/tests/helper.test.ts | 892 ++++++++++++++++++ src/actions/tests/merge.test.ts | 215 +++++ src/actions/tests/validator.test.ts | 38 + src/actions/types.ts | 199 ++++ src/actions/uuid.ts | 60 ++ src/actions/validator.ts | 40 + src/client/Client.ts | 298 ++++++ src/client/Hooks.ts | 39 + src/client/index.ts | 6 + src/client/print.ts | 50 + src/client/redirect.ts | 28 + src/client/tests/Client.test.ts | 567 +++++++++++ src/client/tests/Hooks.test.ts | 199 ++++ src/client/tests/print.test.ts | 76 ++ src/client/tests/redirect.test.ts | 20 + src/client/types.ts | 174 ++++ src/index.ts | 7 + src/package.json | 1 + src/tests/MessageTransport.test.ts | 236 +++++ src/util/collection.ts | 35 + src/util/env.ts | 5 + src/util/tests/collection.test.ts | 54 ++ tsconfig.base.json | 35 + tsconfig.json | 11 + yarn.lock | 211 +++++ 109 files changed, 11267 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api-extractor.json create mode 100644 package.json create mode 100644 src/MessageTransport.ts create mode 100644 src/actions/Button/README.md create mode 100644 src/actions/Button/actions.ts create mode 100644 src/actions/Button/index.ts create mode 100644 src/actions/Button/tests/actions.test.ts create mode 100644 src/actions/Button/types.ts create mode 100644 src/actions/ButtonGroup/README.md create mode 100644 src/actions/ButtonGroup/actions.ts create mode 100644 src/actions/ButtonGroup/index.ts create mode 100644 src/actions/ButtonGroup/tests/actions.test.ts create mode 100644 src/actions/ButtonGroup/types.ts create mode 100644 src/actions/Camera/README.md create mode 100644 src/actions/Camera/actions.ts create mode 100644 src/actions/Camera/index.ts create mode 100644 src/actions/Camera/types.ts create mode 100644 src/actions/Cart/README.md create mode 100644 src/actions/Cart/actions.ts create mode 100644 src/actions/Cart/index.ts create mode 100644 src/actions/Cart/tests/actions.test.ts create mode 100644 src/actions/Cart/types.ts create mode 100644 src/actions/Error/README.md create mode 100644 src/actions/Error/actions.ts create mode 100644 src/actions/Error/index.ts create mode 100644 src/actions/Error/tests/actions.test.ts create mode 100644 src/actions/Error/types.ts create mode 100644 src/actions/Features/README.md create mode 100644 src/actions/Features/actions.ts create mode 100644 src/actions/Features/index.ts create mode 100644 src/actions/Features/tests/actions.test.ts create mode 100644 src/actions/Features/types.ts create mode 100644 src/actions/Flash/README.md create mode 100644 src/actions/Flash/actions.ts create mode 100644 src/actions/Flash/index.ts create mode 100644 src/actions/Loading/README.md create mode 100644 src/actions/Loading/actions.ts create mode 100644 src/actions/Loading/index.ts create mode 100644 src/actions/Loading/tests/actions.test.ts create mode 100644 src/actions/Loading/types.ts create mode 100644 src/actions/Modal/README.md create mode 100644 src/actions/Modal/actions.ts create mode 100644 src/actions/Modal/index.ts create mode 100644 src/actions/Modal/tests/actions.test.ts create mode 100644 src/actions/Modal/types.ts create mode 100644 src/actions/Navigation/History/README.md create mode 100644 src/actions/Navigation/History/actions.ts create mode 100644 src/actions/Navigation/History/index.ts create mode 100644 src/actions/Navigation/History/tests/actions.test.ts create mode 100644 src/actions/Navigation/History/types.ts create mode 100644 src/actions/Navigation/Redirect/README.md create mode 100644 src/actions/Navigation/Redirect/actions.ts create mode 100644 src/actions/Navigation/Redirect/index.ts create mode 100644 src/actions/Navigation/Redirect/tests/actions.test.ts create mode 100644 src/actions/Navigation/Redirect/types.ts create mode 100644 src/actions/Print/README.md create mode 100644 src/actions/Print/actions.ts create mode 100644 src/actions/Print/index.ts create mode 100644 src/actions/Print/tests/actions.test.ts create mode 100644 src/actions/Print/types.ts create mode 100644 src/actions/README.md create mode 100644 src/actions/ResourcePicker/README.md create mode 100644 src/actions/ResourcePicker/actions.ts create mode 100644 src/actions/ResourcePicker/index.ts create mode 100644 src/actions/ResourcePicker/tests/actions.test.ts create mode 100644 src/actions/ResourcePicker/types.ts create mode 100644 src/actions/TitleBar/README.md create mode 100644 src/actions/TitleBar/actions.ts create mode 100644 src/actions/TitleBar/index.ts create mode 100644 src/actions/TitleBar/tests/actions.test.ts create mode 100644 src/actions/TitleBar/types.ts create mode 100644 src/actions/Toast/README.md create mode 100644 src/actions/Toast/actions.ts create mode 100644 src/actions/Toast/index.ts create mode 100644 src/actions/Toast/tests/actions.test.ts create mode 100644 src/actions/Toast/types.ts create mode 100644 src/actions/buttonGroupHelper.ts create mode 100644 src/actions/buttonHelper.ts create mode 100644 src/actions/constants.ts create mode 100644 src/actions/helper.ts create mode 100644 src/actions/index.ts create mode 100644 src/actions/merge.ts create mode 100644 src/actions/tests/helper.test.ts create mode 100644 src/actions/tests/merge.test.ts create mode 100644 src/actions/tests/validator.test.ts create mode 100644 src/actions/types.ts create mode 100644 src/actions/uuid.ts create mode 100644 src/actions/validator.ts create mode 100644 src/client/Client.ts create mode 100644 src/client/Hooks.ts create mode 100644 src/client/index.ts create mode 100644 src/client/print.ts create mode 100644 src/client/redirect.ts create mode 100644 src/client/tests/Client.test.ts create mode 100644 src/client/tests/Hooks.test.ts create mode 100644 src/client/tests/print.test.ts create mode 100644 src/client/tests/redirect.test.ts create mode 100644 src/client/types.ts create mode 100644 src/index.ts create mode 120000 src/package.json create mode 100644 src/tests/MessageTransport.test.ts create mode 100644 src/util/collection.ts create mode 100644 src/util/env.ts create mode 100644 src/util/tests/collection.test.ts create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ec0fdf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +umd +*.js +*.js.map +*.d.ts + +/node_modules/ +/actions/ +/client/ +/umd/ +/util/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f12d402 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# API Extractor Issue + +API Extractor is unable to parse out API for the `actions` portion of this package. See: + +* [`src/index.ts`](src/index.ts) +* [`src/actions/index.ts`](src/actions/index.ts) + +## To build + +1. `yarn` +2. `yarn run build` +3. `yarn run api` diff --git a/api-extractor.json b/api-extractor.json new file mode 100644 index 0000000..0a7062b --- /dev/null +++ b/api-extractor.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/api-extractor.schema.json", + "compiler": { + "configType": "tsconfig", + "rootFolder": "." + }, + "project": { + "entryPointSourceFile": "index.d.ts" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ef6eb23 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "api-extractor-issue", + "version": "1.0.0", + "types": "index.d.ts", + "main": "index.js", + "files": [ + "MessageTransport.d.ts", + "MessageTransport.js", + "MessageTransport.js.map", + "actions/", + "client/", + "index.d.ts", + "index.js", + "index.js.map", + "util/" + ], + "private": true, + "repository": "git@github.com:gf3/api-extractor-issue.git", + "author": "Gianni Chiappetta ", + "license": "MIT", + "scripts": { + "api": "api-extractor run", + "docs": "api-documenter markdown", + "build": "NODE_ENV=production tsc" + }, + "sideEffects": false, + "devDependencies": { + "@microsoft/api-documenter": "^7.0.13", + "@microsoft/api-extractor": "^7.0.9", + "@types/node": "^10.12.5", + "typescript": "3.2.1" + } +} diff --git a/src/MessageTransport.ts b/src/MessageTransport.ts new file mode 100644 index 0000000..2daab82 --- /dev/null +++ b/src/MessageTransport.ts @@ -0,0 +1,112 @@ +import {AnyAction} from './actions/types'; +import {fromAction, AppActionType} from './actions/Error'; +import {isAppBridgeAction, isAppMessage} from './actions/validator'; +import {TransportDispatch} from './client'; +import {addAndRemoveFromCollection} from './util/collection'; + +/** + * @internal + */ +export type HandlerData = {data: AnyAction}; + +/** + * @internal + */ +export type Handler = (event: HandlerData) => void; + +/** + * @internal + */ +export interface MessageTransport { + dispatch(message: any): void; + hostFrame: Window; + localOrigin: string; + subscribe(handler: Handler): () => void; +} + +/** + * Create a MessageTransport from an IFrame. + * @remarks + * Used on the host-side to create a postMessage MessageTransport. + * @beta + */ +export function fromFrame(frame: HTMLIFrameElement, localOrigin: string): MessageTransport { + const handlers: Handler[] = []; + + if (typeof frame === 'undefined' || !frame.ownerDocument || !frame.ownerDocument.defaultView) { + throw fromAction('App frame is undefined', AppActionType.WINDOW_UNDEFINED); + } + + const parent = frame.ownerDocument.defaultView; + + parent.addEventListener('message', event => { + if (event.origin !== localOrigin || !isAppMessage(event)) { + return; + } + + for (const handler of handlers) { + handler(event); + } + }); + + return { + localOrigin, + + hostFrame: parent, + + dispatch(message) { + const contentWindow = frame.contentWindow; + if (contentWindow) { + contentWindow.postMessage(message, '*'); + } + }, + + subscribe(handler) { + return addAndRemoveFromCollection(handlers, handler); + }, + }; +} + +/** + * Create a MessageTransport from a parent window. + * @remarks + * Used on the client-side to create a postMessage MessageTransport. + * @beta + */ +export function fromWindow(contentWindow: Window, localOrigin: string): MessageTransport { + const handlers: Handler[] = []; + if (typeof window !== undefined && contentWindow !== window) { + window.addEventListener('message', event => { + if ( + event.source !== contentWindow || + !(isAppBridgeAction(event.data.payload) || isAppMessage(event)) + ) { + return; + } + + for (const handler of handlers) { + handler(event); + } + }); + } + + return { + localOrigin, + + hostFrame: contentWindow, + + dispatch(message: TransportDispatch) { + if (!message.source || !message.source.shopOrigin) { + return; + } + + const messageOrigin = `https://${message.source.shopOrigin}`; + + contentWindow.postMessage(message, messageOrigin); + }, + + subscribe(handler) { + return addAndRemoveFromCollection(handlers, handler); + }, + }; +} diff --git a/src/actions/Button/README.md b/src/actions/Button/README.md new file mode 100644 index 0000000..9bab4b0 --- /dev/null +++ b/src/actions/Button/README.md @@ -0,0 +1,103 @@ +# Button + +## Setup + +Create an app and import the `Button` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {Button} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); +``` + +## Create a button + +Generate a primary button with the label `Save`: + +```js +const myButton = Button.create(app, {label: 'Save'}); +``` + +## Subscribe to click action + +You can subscribe to button actions by calling `subscribe`. This returns a method that you can call to unsubscribe from the action: + +```js +const myButton = Button.create(app, {label: 'Save'}); +const clickUnsubscribe = myButton.subscribe(Button.Action.CLICK, data => { + // Do something with the click event +}); + +// Unsubscribe to click actions +clickUnsubscribe(); +``` + +## Dispatch click action + +```js +const myButton = Button.create(app, {label: 'Save'}); +myButton.dispatch(Button.Action.CLICK); +``` + +## Dispatch click action with a payload + +```js +const myButton = Button.create(app, {label: 'Save'}); +// Trigger the action with a payload +myButton.dispatch(Button.Action.CLICK, {message: 'Saved'}); + +// Subscribe to the action and read the payload +myButton.subscribe(Button.Action.CLICK, data => { + // data = { payload: { message: 'Saved'} } + console.log(`Received ${data.payload.message} message`); +}); +``` + +## Attach buttons to a modal + +You can attach buttons to other actions such as modals. To learn more about modals, see [Modal](../Modal). + +```js +const okButton = Button.create(app, {label: 'Ok'}); +const cancelButton = Button.create(app, {label: 'Cancel'}); +const modalOptions = { + title: 'My Modal', + message: 'Hello world!', + footer: {primary: okButton, secondary: [cancelButton]}, +}; + +const myModal = Modal.create(app, modalOptions); +``` + +## Button Style + +You can change the style of the button by passing the `style` property. Buttons support a single alternate style, the `Danger` style: + +```js +const myButton = Button.create(app, {label: 'Delete', style: Button.Style.Danger}); +``` + +## Update options + +You can call the `set` method with partial button options to update the options of an existing button. This automatically triggers the `update` action on the button and merges the new given options with existing options: + +```js +const myButton = Button.create(app, {label: 'Save'}); +myButton.set({disabled: true}); +``` + +## Unsubscribe + +You call `unsubscribe` to remove all current subscriptions on the button: + +```js +const myButton = Button.create(app, {label: 'Save'}); +myButton.subscribe(Button.Action.CLICK, data => { + // Do something with the click event +}); + +myButton.unsubscribe(); +``` diff --git a/src/actions/Button/actions.ts b/src/actions/Button/actions.ts new file mode 100644 index 0000000..2ecdaa6 --- /dev/null +++ b/src/actions/Button/actions.ts @@ -0,0 +1,113 @@ +/** + * @module Button + */ + +import {ClientApplication} from '../../client'; +import {actionWrapper, getEventNameSpace, getMergedProps, ActionSet} from '../helper'; +import {ActionSetProps, ClickAction, Component, ComponentType, Group, MetaAction} from '../types'; +import {Action, ClickPayload, Icon, Options, Payload, Style} from './types'; + +export interface ButtonUpdateAction extends MetaAction { + readonly group: string; + payload: Payload; +} + +export type ButtonAction = ButtonUpdateAction | ClickAction | MetaAction; + +export function clickButton( + group: string, + component: Component, + payload?: ClickPayload, +): ClickAction { + const {id} = component; + const action = getEventNameSpace(group, Action.CLICK, component); + const buttonPayload: ClickPayload = { + id, + payload, + }; + + return actionWrapper({type: action, group, payload: buttonPayload}); +} + +export function update(group: string, component: Component, props: Payload): ButtonUpdateAction { + const {id} = component; + const {label} = props; + const action = getEventNameSpace(group, Action.UPDATE, component); + const buttonPayload: Payload = { + id, + label, + ...props, + }; + + return actionWrapper({type: action, group, payload: buttonPayload}); +} + +export function isValidButtonProps(button: Payload) { + return typeof button.id === 'string' && typeof button.label === 'string'; +} + +export class Button extends ActionSet implements ActionSetProps { + label!: string; + disabled = false; + icon?: Icon; + style?: Style; + + constructor(app: ClientApplication, options: Options) { + super(app, ComponentType.Button, Group.Button); + this.set(options, false); + } + + get options(): Options { + return { + disabled: this.disabled, + icon: this.icon, + label: this.label, + style: this.style, + }; + } + + get payload(): Payload { + return { + id: this.id, + ...this.options, + }; + } + + set(options: Partial, shouldUpdate = true) { + const mergedOptions = getMergedProps(this.options, options); + const {label, disabled, icon, style} = mergedOptions; + + this.label = label; + this.disabled = !!disabled; + this.icon = icon; + this.style = style; + + if (shouldUpdate) { + this.dispatch(Action.UPDATE); + } + + return this; + } + + dispatch(action: Action.UPDATE): ActionSet; + + dispatch(action: Action.CLICK, payload?: any): ActionSet; + + dispatch(action: Action, payload?: any) { + switch (action) { + case Action.CLICK: + this.app.dispatch(clickButton(this.group, this.component, payload)); + break; + case Action.UPDATE: + const updateAction = update(this.group, this.component, this.payload); + this.app.dispatch(updateAction); + break; + } + + return this; + } +} + +export function create(app: ClientApplication, options: Options) { + return new Button(app, options); +} diff --git a/src/actions/Button/index.ts b/src/actions/Button/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/Button/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/Button/tests/actions.test.ts b/src/actions/Button/tests/actions.test.ts new file mode 100644 index 0000000..092f054 --- /dev/null +++ b/src/actions/Button/tests/actions.test.ts @@ -0,0 +1,107 @@ +import {createMockApp} from 'test/helper'; +import {create, Button} from '../actions'; +import {Action} from '../types'; + +jest.mock('../../uuid', (fakeId = 'fakeId') => jest.fn().mockReturnValue(fakeId)); + +describe('Button', () => { + let app; + const defaultOptions = {label: 'Save'}; + const expectedDefaultProps = { + ...defaultOptions, + disabled: false, + }; + + beforeEach(() => { + app = createMockApp(); + }); + + it('sets expected properties', () => { + const button = new Button(app, defaultOptions); + const expectedProps = { + group: 'Button', + type: 'Button', + ...expectedDefaultProps, + }; + expect(button).toMatchObject(expectedProps); + }); + + it('get options returns expected properties', () => { + const button = new Button(app, defaultOptions); + expect(button.options).toEqual(expectedDefaultProps); + }); + + it('dispatches generic click action on click', () => { + const fakeButtonPayload = { + message: 'Hi', + }; + const button = new Button(app, defaultOptions); + const expectedAction = { + payload: { + id: button.id, + payload: fakeButtonPayload, + }, + type: 'APP::BUTTON::CLICK', + }; + button.dispatch(Action.CLICK, fakeButtonPayload); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatches click action with groups and subgroups on click', () => { + const fakeButtonPayload = { + message: 'Hi', + }; + const button = new Button(app, defaultOptions); + + button.group = 'Header'; + button.subgroups = ['Title', 'NavBar']; + + const expectedAction = { + group: 'Header', + payload: { + id: button.id, + payload: fakeButtonPayload, + }, + type: 'APP::HEADER::TITLE::NAVBAR::BUTTON::CLICK', + }; + button.dispatch(Action.CLICK, fakeButtonPayload); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('create generates a new Button instance when given button options', () => { + const obj = create(app, defaultOptions); + expect(obj instanceof Button).toBe(true); + expect(obj.options).toEqual(expectedDefaultProps); + }); + + it('set updates options, payload and dispatch update action', () => { + const button = new Button(app, defaultOptions); + const newOptions = {label: 'New label'}; + const expectedOptions = {...expectedDefaultProps, ...newOptions}; + const expectedPayload = { + id: button.id, + ...expectedOptions, + }; + const expectedAction = { + payload: { + id: button.id, + ...expectedPayload, + }, + type: 'APP::BUTTON::UPDATE', + }; + button.set(newOptions); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(button.options).toMatchObject(expectedOptions); + expect(button.payload).toEqual(expectedPayload); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('set does not dispatch update action if shouldUpdate = false', () => { + const button = new Button(app, defaultOptions); + const newOptions = {label: 'New label'}; + button.set(newOptions, false); + expect(app.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/Button/types.ts b/src/actions/Button/types.ts new file mode 100644 index 0000000..d781179 --- /dev/null +++ b/src/actions/Button/types.ts @@ -0,0 +1,40 @@ +/** + * @module Button + */ + +export enum Action { + CLICK = 'CLICK', + UPDATE = 'UPDATE', +} + +export enum ActionType { + CLICK = 'APP::BUTTON::CLICK', + UPDATE = 'APP::BUTTON::UPDATE', +} + +export enum Icon { + Print = 'print', +} + +export enum Style { + Danger = 'danger', +} + +export interface Presentation { + icon?: Icon; + style?: Style; +} + +export interface Options extends Presentation { + label: string; + disabled?: boolean; +} + +export interface Payload extends Options { + readonly id: string; +} + +export interface ClickPayload { + readonly id: string; + payload?: any; +} diff --git a/src/actions/ButtonGroup/README.md b/src/actions/ButtonGroup/README.md new file mode 100644 index 0000000..b2d5720 --- /dev/null +++ b/src/actions/ButtonGroup/README.md @@ -0,0 +1,123 @@ +# ButtonGroup + +## Setup + +Create an app and import the `Button` and `ButtonGroup` modules from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {Button, ButtonGroup} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); +``` + +## Create a button group + +Generate a primary button with the label `More actions` and two buttons with the label `Settings` and `Help`: + +```js +const button1 = Button.create(app, {label: 'Settings'}); +const button2 = Button.create(app, {label: 'Help'}); +const myGroupButton = ButtonGroup.create(app, {label: 'More actions', buttons: [button1, button2]}); +``` + +## Subscribe to updates + +You can subscribe to the button group update action by calling `subscribe`. This returns a method that you can call to unsubscribe from the action: + +```js +// Using the same button group as above +const updateUnsubscribe = myGroupButton.subscribe(ButtonGroup.Action.UPDATE, data => { + // Do something when the button group is updated + // The data is in the following shape: {id: string, label: string, buttons: [{id: string, label: string, disabled: boolean,} ...]} +}); + +// Unsubscribe +updateUnsubscribe(); +``` + +## Unsubscribe + +You call `unsubscribe` to remove all subscriptions on the button group and its children: + +```js +const button1 = Button.create(app, {label: 'Settings'}); +const button2 = Button.create(app, {label: 'Help'}); +const myGroupButton = ButtonGroup.create(app, {label: 'More actions', buttons: [button1, button2]}); +// Using the same button group as above +myGroupButton.subscribe(ButtonGroup.Action.UPDATE, data => { + // Do something when the button group is updated + // The data is in the following format: {id: string, label: string, buttons: [{id: string, label: string, disabled: boolean} ...]} +}); + +button1.subscribe(Button.Action.CLICK, () => { + //Do something with the click action +}); + +button2.subscribe(Button.Action.CLICK, () => { + //Do something with the click action +}); + +// Unsubscribe from the button group update action +// Unsubscribe from button1 and button2 click actions +myGroupButton.unsubscribe(); +``` + +## Unsubscribe from button group actions only + +You call `unsubscribe` with `false` to remove only button group subscriptions while leaving child subscriptions intact. For example, you might want to unsubscribe from the button group but keep button listeners so that the buttons can be reused in a different actions (such as a modal). + +```js +const button1 = Button.create(app, {label: 'Settings'}); +const button2 = Button.create(app, {label: 'Help'}); +const myGroupButton = ButtonGroup.create(app, {label: 'More actions', buttons: [button1, button2]}); +// Using the same button group as above +myGroupButton.subscribe(ButtonGroup.Action.UPDATE, data => { + // Do something when the button group is updated + // The data is in the following format: {id: string, label: string, buttons: [{id: string, label: string, disabled: boolean} ...]} +}); + +button1.subscribe(Button.Action.CLICK, () => { + //Do something with the click action +}); + +button2.subscribe(Button.Action.CLICK, () => { + //Do something with the click action +}); + +// Unsubscribe from only the button group update action +myGroupButton.unsubscribe(false); + +// Reuse button1 and button2 in a modal +const modalOptions = { + title: 'My Modal', + message: 'Hello world!', + footer: {secondary: [button1, button2]}, +}; + +const myModal = Modal.create(app, modalOptions); +``` + +## Update options + +You can call the `set` method with partial button group options to update the options of an existing button group. This automatically triggers the `update` action on the button group and merges the new given options with existing options. + +```js +const button1 = Button.create(app, {label: 'Settings'}); +const button2 = Button.create(app, {label: 'Help'}); +const myGroupButton = ButtonGroup.create(app, {label: 'More actions', buttons: [button1, button2]}); +myGroupButton.set({disabled: true}); +``` + +## Update buttons + +You can update buttons attached to a button group. Any updates made to the button group's children automatically trigger an `update` action on the button group. + +```js +const button1 = Button.create(app, {label: 'Settings'}); +const button2 = Button.create(app, {label: 'Help'}); +const myGroupButton = ButtonGroup.create(app, {label: 'More actions', buttons: [button1, button2]}); +button1.set({disabled: true}); +``` diff --git a/src/actions/ButtonGroup/actions.ts b/src/actions/ButtonGroup/actions.ts new file mode 100644 index 0000000..a10b480 --- /dev/null +++ b/src/actions/ButtonGroup/actions.ts @@ -0,0 +1,158 @@ +/** + * @module ButtonGroup + */ + +import {ClientApplication} from '../../client'; +import {getSingleButton} from '../buttonHelper'; +import { + actionWrapper, + getEventNameSpace, + getMergedProps, + updateActionFromPayload, + ActionSetWithChildren, +} from '../helper'; +import {ActionSetProps, Component, ComponentType, Group, MetaAction} from '../types'; +import {Button, Payload as ButtonPayload} from '../Button'; +import {Action, Options, Payload} from './types'; + +export interface ButtonGroupUpdateAction extends MetaAction { + readonly group: string; + payload: Payload; +} + +export type ButtonGroupAction = ButtonGroupUpdateAction | MetaAction; + +export function update( + group: string, + component: Component, + props: Payload, +): ButtonGroupUpdateAction { + return buttonActionWrapper(group, component, Action.UPDATE, props); +} + +export function isGroupedButton(options: ButtonGroup | object): options is ButtonGroup { + const castOptions = options as ButtonGroup; + + return castOptions.buttons && castOptions.buttons.length > 0 && castOptions.label !== undefined; +} + +export function isGroupedButtonPayload(payload: Payload | object): payload is Payload { + const castOptions = payload as Payload; + + return ( + Array.isArray(castOptions.buttons) && + typeof castOptions.id === 'string' && + typeof castOptions.label === 'string' + ); +} + +export class ButtonGroup extends ActionSetWithChildren implements ActionSetProps { + label!: string; + disabled = false; + buttonsOptions: Button[] = []; + buttons: ButtonPayload[] = []; + constructor(app: ClientApplication, options: Options) { + super(app, ComponentType.ButtonGroup, Group.ButtonGroup); + this.set(options, false); + } + + get options(): Options { + return { + buttons: this.buttonsOptions, + disabled: this.disabled, + label: this.label, + }; + } + + get payload(): Payload { + return { + ...this.options, + buttons: this.buttons, + id: this.id, + }; + } + + set(options: Partial, shouldUpdate = true) { + const mergedOptions = getMergedProps(this.options, options); + const {label, disabled, buttons} = mergedOptions; + + this.label = label; + this.disabled = !!disabled; + this.buttons = this.getButtons(buttons); + if (shouldUpdate) { + this.dispatch(Action.UPDATE); + } + + return this; + } + + dispatch(action: Action) { + switch (action) { + case Action.UPDATE: + const updateAction = update(this.group, this.component, this.payload); + this.app.dispatch(updateAction); + break; + } + + return this; + } + + updateButtons(newPayload: ButtonPayload) { + if (!this.buttons || this.buttons.length === 0) { + return; + } + let updated; + for (const action of this.buttons) { + updated = updateActionFromPayload(action, newPayload); + if (updated) { + break; + } + } + if (updated) { + this.dispatch(Action.UPDATE); + } + } + + protected getSingleButton(button: Button): ButtonPayload { + return getSingleButton(this, button, this.subgroups, this.updateButtons); + } + + protected getButtons(buttonOptions?: Button[]): ButtonPayload[] { + const buttons: ButtonPayload[] = []; + if (!buttonOptions) { + return []; + } + + buttonOptions.forEach((button: Button) => { + const singleButton = getSingleButton(this, button, this.subgroups, this.updateButtons); + buttons.push(singleButton); + }); + this.buttonsOptions = buttonOptions; + + return buttons; + } +} + +export function create(app: ClientApplication, options: Options) { + return new ButtonGroup(app, options); +} + +function buttonActionWrapper( + group: string, + component: Component, + eventName: string, + props: Payload, + payload?: any, +): any { + const {id} = component; + const {label} = props; + const action = getEventNameSpace(group, eventName, component); + const buttonPayload = { + id, + label, + ...props, + payload, + }; + + return actionWrapper({type: action, group, payload: buttonPayload}); +} diff --git a/src/actions/ButtonGroup/index.ts b/src/actions/ButtonGroup/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/ButtonGroup/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/ButtonGroup/tests/actions.test.ts b/src/actions/ButtonGroup/tests/actions.test.ts new file mode 100644 index 0000000..53d6a24 --- /dev/null +++ b/src/actions/ButtonGroup/tests/actions.test.ts @@ -0,0 +1,172 @@ +import {createMockApp} from 'test/helper'; +import * as ButtonHelper from '../../buttonHelper'; +import * as Helper from '../../helper'; +import {Button} from '../../Button'; +import {create, ButtonGroup} from '../actions'; +import {Action} from '../types'; + +describe('ButtonGroup', () => { + let app; + let buttonA; + let buttonB; + let defaultOptions; + let defaultExpectedPayload; + + beforeEach(() => { + app = createMockApp(); + buttonA = new Button(app, {label: 'Button A'}); + buttonB = new Button(app, {label: 'Button B'}); + defaultOptions = {label: 'More options', buttons: [buttonA, buttonB]}; + defaultExpectedPayload = { + buttons: [buttonA.payload, buttonB.payload], + disabled: false, + label: defaultOptions.label, + }; + + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('sets expected properties', () => { + const buttonGroup = new ButtonGroup(app, defaultOptions); + const expectedProps = {group: 'ButtonGroup', type: 'ButtonGroup', label: defaultOptions.label}; + expect(buttonGroup).toMatchObject(expectedProps); + }); + + it('sets buttons as expected', () => { + const buttonGroup = new ButtonGroup(app, defaultOptions); + expect(buttonGroup.buttons).toEqual(defaultExpectedPayload.buttons); + }); + + it('sets buttonOptions as expected', () => { + const buttonGroup = new ButtonGroup(app, defaultOptions); + expect(buttonGroup.buttonsOptions).toEqual([buttonA, buttonB]); + }); + + it('get options returns expected properties', () => { + const buttonGroup = new ButtonGroup(app, defaultOptions); + const expectedOptions = { + ...defaultOptions, + disabled: false, + }; + expect(buttonGroup.options).toEqual(expectedOptions); + }); + + it('get payload returns expected properties', () => { + const buttonGroup = new ButtonGroup(app, defaultOptions); + const expectedPayload = { + id: buttonGroup.id, + ...defaultExpectedPayload, + }; + expect(buttonGroup.payload).toEqual(expectedPayload); + }); + + it('calls getSingleButton for each button with expected args', () => { + const getSingleButtonSpy = jest.spyOn(ButtonHelper, 'getSingleButton'); + const buttonGroup = new ButtonGroup(app, defaultOptions); + + expect(getSingleButtonSpy).toHaveBeenCalledTimes(2); + expect(getSingleButtonSpy).toHaveBeenCalledWith( + buttonGroup, + buttonA, + buttonGroup.subgroups, + buttonGroup.updateButtons, + ); + expect(getSingleButtonSpy).toHaveBeenCalledWith( + buttonGroup, + buttonB, + buttonGroup.subgroups, + buttonGroup.updateButtons, + ); + }); + + it('subscribes to children update actions', () => { + const subscribeSpy = jest.spyOn(ButtonGroup.prototype, 'subscribe'); + + const buttonGroup = new ButtonGroup(app, defaultOptions); + + expect(subscribeSpy).toHaveBeenCalledTimes(2); + + expect(subscribeSpy).toHaveBeenCalledWith( + Action.UPDATE, + expect.any(Function), + buttonA.component, + ); + expect(subscribeSpy).toHaveBeenCalledWith( + Action.UPDATE, + expect.any(Function), + buttonB.component, + ); + }); + + it('updateButtons calls updateActionFromPayload with expected args and dispatch update action', () => { + const updateActionFromPayloadSpy = jest + .spyOn(Helper, 'updateActionFromPayload') + .mockImplementation(jest.fn(_ => true)); + + const updateSpy = jest.spyOn(ButtonGroup.prototype, 'dispatch'); + const buttonGroup = new ButtonGroup(app, defaultOptions); + const newButtonPayload = { + id: buttonA.id, + label: 'Hello', + }; + buttonGroup.updateButtons(newButtonPayload); + expect(updateActionFromPayloadSpy).toHaveBeenCalledWith(buttonA.payload, newButtonPayload); + expect(updateSpy).toHaveBeenCalledWith(Action.UPDATE); + }); + + it('dispatches expected update action on update', () => { + const buttonGroup = new ButtonGroup(app, defaultOptions); + buttonGroup.group = 'Header'; + buttonGroup.subgroups = ['Title', 'NavBar']; + const expectedAction = { + group: 'Header', + payload: { + id: buttonGroup.id, + ...defaultExpectedPayload, + }, + type: 'APP::HEADER::TITLE::NAVBAR::BUTTONGROUP::UPDATE', + }; + buttonGroup.dispatch(Action.UPDATE); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('set updates options, payload and dispatch update action', () => { + const buttonGroup = new ButtonGroup(app, defaultOptions); + const newOptions = {label: 'New label'}; + const expectedOptions = {...defaultExpectedPayload, ...newOptions}; + const expectedPayload = { + id: buttonGroup.id, + ...expectedOptions, + }; + const expectedAction = { + payload: { + id: buttonGroup.id, + ...expectedPayload, + }, + type: 'APP::BUTTONGROUP::UPDATE', + }; + buttonGroup.set(newOptions); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(buttonGroup.options).toMatchObject(expectedOptions); + expect(buttonGroup.payload).toEqual(expectedPayload); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('set does not dispatch update action if shouldUpdate = false', () => { + const buttonGroup = new ButtonGroup(app, defaultOptions); + const newOptions = {label: 'New label'}; + buttonGroup.set(newOptions, false); + expect(app.dispatch).not.toHaveBeenCalled(); + }); + + it('create generates a new ButtonGroup instance when given ButtonGroupOptions', () => { + const obj = create(app, defaultOptions); + expect(obj instanceof ButtonGroup).toBe(true); + expect(obj.options).toMatchObject(defaultOptions); + }); +}); diff --git a/src/actions/ButtonGroup/types.ts b/src/actions/ButtonGroup/types.ts new file mode 100644 index 0000000..616eba5 --- /dev/null +++ b/src/actions/ButtonGroup/types.ts @@ -0,0 +1,26 @@ +/** + * @module ButtonGroup + */ + +import {Button, Payload as ButtonPayload} from '../Button'; + +export enum Action { + UPDATE = 'UPDATE', +} + +export enum ActionType { + UPDATE = 'APP::BUTTONGROUP::UPDATE', +} + +export interface Options { + label: string; + disabled?: boolean; + buttons: Button[]; +} + +export interface Payload { + readonly id: string; + label: string; + disabled?: boolean; + buttons: ButtonPayload[]; +} diff --git a/src/actions/Camera/README.md b/src/actions/Camera/README.md new file mode 100644 index 0000000..dee60dc --- /dev/null +++ b/src/actions/Camera/README.md @@ -0,0 +1,39 @@ +# Camera + +> This is a Pre-Release feature that only works on Point of Sale. To use this feature you must use [Point of Sale](https://github.com/Shopify/ios/tree/available-features) and enable `App Bridge` in `Store -> Settings` in the Point of Sale app. In Services Internal you must also enable the Feature Flags: `easdkv1` and `new-admin-pos`. + +## Setup + +Create an app and import the `Camera` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {Camera} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); + +const camera = Camera.create(app); +``` + +## Camera action + + Open the camera component. This will trigger a dialog to ask for Camera based permissions: + + ```js + camera.dispatch(Camera.Action.OPEN, { + data: { + type: OpenType.Scan, + } + }); + ``` + + Subscribe to Camera Capture: + + ```js + camera.subscribe(Camera.Action.CAPTURE, payload => { + // The payload will contain `scanData` or `imageData` depending on the type passed into the + // `Camera.Action.OPEN` action. + }); + ``` \ No newline at end of file diff --git a/src/actions/Camera/actions.ts b/src/actions/Camera/actions.ts new file mode 100644 index 0000000..c4e9cf0 --- /dev/null +++ b/src/actions/Camera/actions.ts @@ -0,0 +1,50 @@ +/** + * @module Camera + */ + +import {ClientApplication} from '../../client'; +import {actionWrapper, ActionSet} from '../helper'; +import {Group} from '../types'; +import {Action, ActionType, Options, OpenPayload, Payload} from './types'; + +/** + * Camera + */ + +export class Camera extends ActionSet { + constructor(app: ClientApplication, options?: Options) { + super(app, Group.Camera, Group.Camera, options ? options.id : undefined); + } + + dispatch(action: Action.OPEN, payload: OpenPayload): Camera; + dispatch(action: Action.CAPTURE, payload: Payload): Camera; + dispatch(action: Action, payload?: any) { + switch (action) { + case Action.CAPTURE: + this.dispatchCameraAction(ActionType.CAPTURE, payload); + break; + case Action.OPEN: + this.dispatchCameraAction(ActionType.OPEN, payload); + break; + } + + return this; + } + + private dispatchCameraAction(type: ActionType, payload?: any) { + this.app.dispatch( + actionWrapper({ + type, + group: Group.Camera, + payload: { + ...(payload || {}), + id: this.id, + }, + }), + ); + } +} + +export function create(app: ClientApplication, options?: Options) { + return new Camera(app, options); +} diff --git a/src/actions/Camera/index.ts b/src/actions/Camera/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/Camera/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/Camera/types.ts b/src/actions/Camera/types.ts new file mode 100644 index 0000000..5e6aa6e --- /dev/null +++ b/src/actions/Camera/types.ts @@ -0,0 +1,39 @@ +/** + * @module Camera + */ + +export enum ActionType { + OPEN = 'APP::CAMERA::OPEN', + CAPTURE = 'APP::CAMERA::CAPTURE', +} + +export enum Action { + OPEN = 'OPEN', + CAPTURE = 'CAPTURE', +} + +export interface Data { + imageData?: string; + scanData?: string; +} + +export interface Payload { + readonly data: Data; +} + +export interface Options { + readonly id?: string; +} + +export enum OpenType { + Image = 'Image', + Scan = 'Scan', +} + +export interface OpenData { + type: OpenType; +} + +export interface OpenPayload { + readonly data: OpenData; +} diff --git a/src/actions/Cart/README.md b/src/actions/Cart/README.md new file mode 100644 index 0000000..b443bb6 --- /dev/null +++ b/src/actions/Cart/README.md @@ -0,0 +1,277 @@ +# Cart + +## Setup + +Create an app and import the `Cart` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {Cart} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); +``` + +## Create a cart + +Create a cart and subscribe to cart updates: + +```js +const cart = Cart.create(app); +cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] cart update', payload); +}); +``` + +## Handling error + +```js +app.error((data: Error.ErrorAction) => { + console.info('[client] Error received: ', data); +}); +``` + +## Fetch cart + +A cart needs to call fetch before receiving data in `Cart.Action.UPDATE`: + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] fetchCart', payload); + unsubscriber(); +}); +cart.dispatch(Cart.fetch()); +``` + +## Set customer + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] setCustomer', payload); + unsubscriber(); +}); +cart.dispatch( + Cart.setCustomer({ + email: 'dev@shopify.com', + firstName: 'dev', + id: 123, + lastName: 'shopify', + note: 'some note', + }), +); +``` + +## Add customer address + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] addCustomerAddress', payload); + unsubscriber(); +}); +cart.dispatch( + Cart.addCustomerAddress({ + address1: 'any address1', + address2: 'any address2', + city: 'any city', + company: 'any company', + country: 'any country', + countryCode: 'any countryCode', + firstName: 'any firstName', + lastName: 'any lastName', + name: 'any name', + phone: 'any phone', + provice: 'any provice', + proviceCode: 'any proviceCode', + zip: 'any zip', + }), +); +``` + +## Update customer address + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] updateCustomerAddress', payload); + unsubscriber(); +}); +cart.dispatch( + Cart.updateCustomerAddress(0, { + address1: 'any address1', + address2: 'any address2', + city: 'any city', + company: 'any company', + country: 'any country', + countryCode: 'any countryCode', + firstName: 'any firstName', + lastName: 'any lastName', + name: 'any name', + phone: 'any phone', + provice: 'any provice', + proviceCode: 'any proviceCode', + zip: 'any zip', + }), +); +``` + +## Remove customer + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] removeCustomer', payload); + unsubscriber(); +}); +cart.dispatch(Cart.removeCustomer()); +``` + +## Set discount + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] setDiscount', payload); + unsubscriber(); +}); +cart.dispatch( + Cart.setDiscount({ + amount: '1', + discountDescription: new Date().toISOString(), + type: 'flat', + }), +); +``` + +## Remove discount + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] removeDiscount', payload); + unsubscriber(); +}); +cart.dispatch(Cart.removeDiscount()); +``` + +## Set cart properties + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] setProperties', payload); + unsubscriber(); +}); +cart.dispatch( + Cart.setProperties({ + foo: 'bar', + hello: 'moto', + }), +); +``` + +## Remove cart properties + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] removeProperties', payload); + unsubscriber(); +}); +cart.dispatch(Cart.removeProperties(['foo', 'hello'])); +``` + +## Clear cart + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] clear', payload); + unsubscriber(); +}); +cart.dispatch(Cart.clear()); +``` + +## Add line item + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] addLineItem', payload); + unsubscriber(); +}); +cart.dispatch( + Cart.addLineItem({ + price: '20', + quantity: 1, + title: 'Some CartLineItem title', + }), +); +``` + +## Update line item + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] updateLineItem', payload); + unsubscriber(); +}); +cart.dispatch( + Cart.updateLineItem(0, { + quantity: 100, + }), +); +``` + +## Remove line item + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] removeLineItem', payload); + unsubscriber(); +}); +cart.dispatch(Cart.removeLineItem(0)); +``` + +## Set line item discount + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] setLineItemDiscount', payload); + unsubscriber(); +}); +cart.dispatch( + Cart.setLineItemDiscount(0, { + amount: '1', + discountDescription: 'some description', + type: 'flat', + }), +); +``` + +## Remove line item discount + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] removeLineItemDiscount', payload); + unsubscriber(); +}); +cart.dispatch(Cart.removeLineItemDiscount(0)); +``` + +## Set line item properties + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] setLineItemProperties', payload); + unsubscriber(); +}); +cart.dispatch( + Cart.setLineItemProperties(0, { + foo: 'bar', + hello: 'moto', + }), +); +``` + +## Remove line item properties + +```js +const unsubscriber = cart.subscribe(Cart.Action.UPDATE, (payload: Cart.Payload) => { + console.log('[Client] removeLineItemProperties', payload); + unsubscriber(); +}); +cart.dispatch(Cart.removeLineItemProperties(0, ['foo', 'hello'])); +``` diff --git a/src/actions/Cart/actions.ts b/src/actions/Cart/actions.ts new file mode 100644 index 0000000..e0e33f7 --- /dev/null +++ b/src/actions/Cart/actions.ts @@ -0,0 +1,134 @@ +/** + * @module Cart + */ + +import {ClientApplication} from '../../client'; +import {actionWrapper, ActionSet} from '../helper'; +import {Group} from '../types'; +import { + Action, + ActionType, + AddCustomerAddressPayload, + AddLineItemPayload, + Options, + Payload, + RemoveLineItemDiscountPayload, + RemoveLineItemPayload, + RemoveLineItemPropertiesPayload, + RemovePropertiesPayload, + SetCustomerPayload, + SetDiscountPayload, + SetLineItemDiscountPayload, + SetLineItemPropertiesPayload, + SetPropertiesPayload, + UpdateCustomerAddressPayload, + UpdateLineItemPayload, +} from './types'; + +/** + * Cart + */ + +export class Cart extends ActionSet { + constructor(app: ClientApplication, options?: Options) { + super(app, Group.Cart, Group.Cart, options ? options.id : undefined); + } + + dispatch( + action: Action.FETCH | Action.REMOVE_CUSTOMER | Action.REMOVE_DISCOUNT | Action.CLEAR, + ): Cart; + dispatch(action: Action.UPDATE, payload: Payload): Cart; + dispatch(action: Action.SET_CUSTOMER, payload: SetCustomerPayload): Cart; + dispatch(action: Action.ADD_CUSTOMER_ADDRESS, payload: AddCustomerAddressPayload): Cart; + dispatch(action: Action.UPDATE_CUSTOMER_ADDRESS, payload: UpdateCustomerAddressPayload): Cart; + dispatch(action: Action.SET_DISCOUNT, payload: SetDiscountPayload): Cart; + dispatch(action: Action.SET_PROPERTIES, payload: SetPropertiesPayload): Cart; + dispatch(action: Action.REMOVE_PROPERTIES, payload: RemovePropertiesPayload): Cart; + dispatch(action: Action.ADD_LINE_ITEM, payload: AddLineItemPayload): Cart; + dispatch(action: Action.UPDATE_LINE_ITEM, payload: UpdateLineItemPayload): Cart; + dispatch(action: Action.REMOVE_LINE_ITEM, payload: RemoveLineItemPayload): Cart; + dispatch(action: Action.SET_LINE_ITEM_DISCOUNT, payload: SetLineItemDiscountPayload): Cart; + dispatch(action: Action.REMOVE_LINE_ITEM_DISCOUNT, payload: RemoveLineItemDiscountPayload): Cart; + dispatch(action: Action.SET_LINE_ITEM_PROPERTIES, payload: SetLineItemPropertiesPayload): Cart; + dispatch( + action: Action.REMOVE_LINE_ITEM_PROPERTIES, + payload: RemoveLineItemPropertiesPayload, + ): Cart; + dispatch(action: Action, payload?: any) { + switch (action) { + case Action.FETCH: + this.dispatchCartAction(ActionType.FETCH); + break; + case Action.UPDATE: + this.dispatchCartAction(ActionType.UPDATE, payload); + break; + case Action.SET_CUSTOMER: + this.dispatchCartAction(ActionType.SET_CUSTOMER, payload); + break; + case Action.REMOVE_CUSTOMER: + this.dispatchCartAction(ActionType.REMOVE_CUSTOMER, payload); + break; + case Action.ADD_CUSTOMER_ADDRESS: + this.dispatchCartAction(ActionType.ADD_CUSTOMER_ADDRESS, payload); + break; + case Action.UPDATE_CUSTOMER_ADDRESS: + this.dispatchCartAction(ActionType.UPDATE_CUSTOMER_ADDRESS, payload); + break; + case Action.SET_DISCOUNT: + this.dispatchCartAction(ActionType.SET_DISCOUNT, payload); + break; + case Action.REMOVE_DISCOUNT: + this.dispatchCartAction(ActionType.REMOVE_DISCOUNT, payload); + break; + case Action.SET_PROPERTIES: + this.dispatchCartAction(ActionType.SET_PROPERTIES, payload); + break; + case Action.REMOVE_PROPERTIES: + this.dispatchCartAction(ActionType.REMOVE_PROPERTIES, payload); + break; + case Action.CLEAR: + this.dispatchCartAction(ActionType.CLEAR, payload); + break; + case Action.ADD_LINE_ITEM: + this.dispatchCartAction(ActionType.ADD_LINE_ITEM, payload); + break; + case Action.UPDATE_LINE_ITEM: + this.dispatchCartAction(ActionType.UPDATE_LINE_ITEM, payload); + break; + case Action.REMOVE_LINE_ITEM: + this.dispatchCartAction(ActionType.REMOVE_LINE_ITEM, payload); + break; + case Action.SET_LINE_ITEM_DISCOUNT: + this.dispatchCartAction(ActionType.SET_LINE_ITEM_DISCOUNT, payload); + break; + case Action.REMOVE_LINE_ITEM_DISCOUNT: + this.dispatchCartAction(ActionType.REMOVE_LINE_ITEM_DISCOUNT, payload); + break; + case Action.SET_LINE_ITEM_PROPERTIES: + this.dispatchCartAction(ActionType.SET_LINE_ITEM_PROPERTIES, payload); + break; + case Action.REMOVE_LINE_ITEM_PROPERTIES: + this.dispatchCartAction(ActionType.REMOVE_LINE_ITEM_PROPERTIES, payload); + break; + } + + return this; + } + + private dispatchCartAction(type: ActionType, payload?: any) { + this.app.dispatch( + actionWrapper({ + type, + group: Group.Cart, + payload: { + ...(payload || {}), + id: this.id, + }, + }), + ); + } +} + +export function create(app: ClientApplication, options?: Options) { + return new Cart(app, options); +} diff --git a/src/actions/Cart/index.ts b/src/actions/Cart/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/Cart/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/Cart/tests/actions.test.ts b/src/actions/Cart/tests/actions.test.ts new file mode 100644 index 0000000..9a7333c --- /dev/null +++ b/src/actions/Cart/tests/actions.test.ts @@ -0,0 +1,356 @@ +import {createMockApp} from 'test/helper'; +import * as Helper from '../../helper'; +import * as Actions from '../actions'; +import * as Types from '../types'; +const Cart = Actions.Cart; +const {Action, ActionType} = Types; + +jest.mock('../../uuid', (fakeId = 'fakeId') => jest.fn().mockReturnValue(fakeId)); + +describe('Cart', () => { + let app; + + beforeEach(() => { + app = createMockApp(); + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('dispatch fetch', () => { + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + id: cart.id, + }, + type: ActionType.FETCH, + }; + cart.dispatch(Action.FETCH); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch setCustomer', () => { + const data = { + email: 'any email', + firstName: 'any firstName', + id: 123, + lastName: 'any lastName', + note: 'any note', + }; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + }, + type: ActionType.SET_CUSTOMER, + }; + cart.dispatch(Action.SET_CUSTOMER, {data}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch removeCustomer', () => { + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + id: cart.id, + }, + type: ActionType.REMOVE_CUSTOMER, + }; + cart.dispatch(Action.REMOVE_CUSTOMER); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch addCustomerAddress', () => { + const data = { + address1: 'any address1', + address2: 'any address2', + city: 'any city', + company: 'any company', + country: 'any country', + countryCode: 'any countryCode', + firstName: 'any firstName', + lastName: 'any lastName', + name: 'any name', + phone: 'any phone', + provice: 'any provice', + proviceCode: 'any proviceCode', + zip: 'any zip', + }; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + }, + type: ActionType.ADD_CUSTOMER_ADDRESS, + }; + cart.dispatch(Action.ADD_CUSTOMER_ADDRESS, {data}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch updateCustomerAddress', () => { + const data = { + address1: 'any address1', + address2: 'any address2', + city: 'any city', + company: 'any company', + country: 'any country', + countryCode: 'any countryCode', + firstName: 'any firstName', + lastName: 'any lastName', + name: 'any name', + phone: 'any phone', + provice: 'any provice', + proviceCode: 'any proviceCode', + zip: 'any zip', + }; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + index: 0, + }, + type: ActionType.UPDATE_CUSTOMER_ADDRESS, + }; + cart.dispatch(Action.UPDATE_CUSTOMER_ADDRESS, {data, index: 0}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch setDiscount with amount', () => { + const data = { + amount: 10, + discountDescription: 'any discountDescription', + type: 'any type', + }; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + }, + type: ActionType.SET_DISCOUNT, + }; + cart.dispatch(Action.SET_DISCOUNT, {data}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch setDiscount with code', () => { + const data = { + discountCode: 'TEST', + }; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + }, + type: ActionType.SET_DISCOUNT, + }; + cart.dispatch(Action.SET_DISCOUNT, {data}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch removeDiscount', () => { + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + id: cart.id, + }, + type: ActionType.REMOVE_DISCOUNT, + }; + cart.dispatch(Action.REMOVE_DISCOUNT); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch setProperties', () => { + const data = { + foo: 'bar', + hello: 'moto', + }; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + }, + type: ActionType.SET_PROPERTIES, + }; + cart.dispatch(Action.SET_PROPERTIES, {data}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch removeProperties', () => { + const data = ['foo', 'hello']; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + }, + type: ActionType.REMOVE_PROPERTIES, + }; + cart.dispatch(Action.REMOVE_PROPERTIES, {data}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch clear cart', () => { + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + id: cart.id, + }, + type: ActionType.CLEAR, + }; + cart.dispatch(Action.CLEAR); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch addLineItem', () => { + const data = { + price: 10, + quantity: 1, + title: 'addLineItem', + }; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + }, + type: ActionType.ADD_LINE_ITEM, + }; + cart.dispatch(Action.ADD_LINE_ITEM, {data}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch updateLineItem', () => { + const data = { + quantity: 1, + }; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + index: 1, + }, + type: ActionType.UPDATE_LINE_ITEM, + }; + cart.dispatch(Action.UPDATE_LINE_ITEM, {data, index: 1}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch removeLineItem', () => { + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + id: cart.id, + index: 1, + }, + type: ActionType.REMOVE_LINE_ITEM, + }; + cart.dispatch(Action.REMOVE_LINE_ITEM, {index: 1}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch setLineItemDiscount', () => { + const data = { + amount: 10, + discountDescription: 'any discountDescription', + type: 'any type', + }; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + index: 1, + }, + type: Types.ActionType.SET_LINE_ITEM_DISCOUNT, + }; + cart.dispatch(Action.SET_LINE_ITEM_DISCOUNT, {data, index: 1}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch removeLineItemDiscount', () => { + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + id: cart.id, + index: 1, + }, + type: ActionType.REMOVE_LINE_ITEM_DISCOUNT, + }; + cart.dispatch(Action.REMOVE_LINE_ITEM_DISCOUNT, {index: 1}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch setLineItemProperties', () => { + const data = { + foo: 'bar', + hello: 'moto', + }; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + index: 1, + }, + type: ActionType.SET_LINE_ITEM_PROPERTIES, + }; + cart.dispatch(Action.SET_LINE_ITEM_PROPERTIES, {data, index: 1}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatch removeLineItemProperties', () => { + const data = ['foo', 'bar']; + const cart = new Cart(app); + const expectedAction = { + group: 'Cart', + payload: { + data, + id: cart.id, + index: 1, + }, + type: ActionType.REMOVE_LINE_ITEM_PROPERTIES, + }; + cart.dispatch(Action.REMOVE_LINE_ITEM_PROPERTIES, {data, index: 1}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); +}); diff --git a/src/actions/Cart/types.ts b/src/actions/Cart/types.ts new file mode 100644 index 0000000..66193b7 --- /dev/null +++ b/src/actions/Cart/types.ts @@ -0,0 +1,183 @@ +/** + * @module Cart + */ + +export enum ActionType { + FETCH = 'APP::CART::FETCH', + UPDATE = 'APP::CART::UPDATE', + SET_CUSTOMER = 'APP::CART::SET_CUSTOMER', + REMOVE_CUSTOMER = 'APP::CART::REMOVE_CUSTOMER', + ADD_CUSTOMER_ADDRESS = 'APP::CART::ADD_CUSTOMER_ADDRESS', + UPDATE_CUSTOMER_ADDRESS = 'APP::CART::UPDATE_CUSTOMER_ADDRESS', + SET_DISCOUNT = 'APP::CART::SET_DISCOUNT', + REMOVE_DISCOUNT = 'APP::CART::REMOVE_DISCOUNT', + SET_PROPERTIES = 'APP::CART::SET_PROPERTIES', + REMOVE_PROPERTIES = 'APP::CART::REMOVE_PROPERTIES', + CLEAR = 'APP::CART::CLEAR', + ADD_LINE_ITEM = 'APP::CART::ADD_LINE_ITEM', + UPDATE_LINE_ITEM = 'APP::CART::UPDATE_LINE_ITEM', + REMOVE_LINE_ITEM = 'APP::CART::REMOVE_LINE_ITEM', + SET_LINE_ITEM_DISCOUNT = 'APP::CART::SET_LINE_ITEM_DISCOUNT', + REMOVE_LINE_ITEM_DISCOUNT = 'APP::CART::REMOVE_LINE_ITEM_DISCOUNT', + SET_LINE_ITEM_PROPERTIES = 'APP::CART::SET_LINE_ITEM_PROPERTIES', + REMOVE_LINE_ITEM_PROPERTIES = 'APP::CART::REMOVE_LINE_ITEM_PROPERTIES', +} + +export enum Action { + FETCH = 'FETCH', + UPDATE = 'UPDATE', + SET_CUSTOMER = 'SET_CUSTOMER', + REMOVE_CUSTOMER = 'REMOVE_CUSTOMER', + ADD_CUSTOMER_ADDRESS = 'ADD_CUSTOMER_ADDRESS', + UPDATE_CUSTOMER_ADDRESS = 'UPDATE_CUSTOMER_ADDRESS', + SET_DISCOUNT = 'SET_DISCOUNT', + REMOVE_DISCOUNT = 'REMOVE_DISCOUNT', + SET_PROPERTIES = 'SET_PROPERTIES', + REMOVE_PROPERTIES = 'REMOVE_PROPERTIES', + CLEAR = 'CLEAR', + ADD_LINE_ITEM = 'ADD_LINE_ITEM', + UPDATE_LINE_ITEM = 'UPDATE_LINE_ITEM', + REMOVE_LINE_ITEM = 'REMOVE_LINE_ITEM', + SET_LINE_ITEM_DISCOUNT = 'SET_LINE_ITEM_DISCOUNT', + REMOVE_LINE_ITEM_DISCOUNT = 'REMOVE_LINE_ITEM_DISCOUNT', + SET_LINE_ITEM_PROPERTIES = 'SET_LINE_ITEM_PROPERTIES', + REMOVE_LINE_ITEM_PROPERTIES = 'REMOVE_LINE_ITEM_PROPERTIES', +} + +export interface Data { + cartDiscount?: Discount; + customer?: CustomerWithAddresses; + grandTotal?: string; + lineItems?: LineItem[]; + noteAttributes?: NoteAttributes; + subTotal?: string; + taxTotal?: string; +} + +export interface Payload { + readonly data: Data; +} + +export interface Options { + readonly id?: string; +} + +export interface AddCustomerAddressPayload { + readonly data: Address; +} + +export interface AddLineItemPayload { + readonly data: LineItem; +} + +export interface SetCustomerPayload { + readonly data: Customer; +} + +export interface UpdateCustomerAddressPayload { + readonly data: Address; + readonly index: number; +} + +export interface SetDiscountPayload { + readonly data: DiscountAmount | DiscountCode; +} + +export interface SetPropertiesPayload { + readonly data: Properties; +} + +export interface RemovePropertiesPayload { + readonly data: string[]; +} + +export interface UpdateLineItemData { + quantity: number; +} +export interface UpdateLineItemPayload { + readonly data: UpdateLineItemData; + readonly index: number; +} + +export interface RemoveLineItemPayload { + readonly index: number; +} + +export interface SetLineItemDiscountPayload { + readonly data: DiscountAmount; + readonly index: number; +} + +export interface RemoveLineItemDiscountPayload { + readonly index: number; +} + +export interface SetLineItemPropertiesPayload { + readonly data: Properties; + readonly index: number; +} + +export interface RemoveLineItemPropertiesPayload { + readonly data: string[]; + readonly index: number; +} + +/** + * Cart types + */ + +export interface Customer { + id?: number; + email?: string; + firstName?: string; + lastName?: string; + note?: string; +} + +export interface CustomerWithAddresses extends Customer { + addresses?: Address[]; +} + +export interface Address { + address1?: string; + address2?: string; + city?: string; + company?: string; + firstName?: string; + lastName?: string; + phone?: string; + provice?: string; + country?: string; + zip?: string; + name?: string; + proviceCode?: string; + countryCode?: string; +} + +export interface DiscountAmount { + amount: number; + discountDescription?: string; + type?: string; +} + +export interface DiscountCode { + discountCode: string; +} + +export interface Discount extends DiscountAmount, DiscountCode {} + +export interface LineItem { + price?: number; + quantity: number; + title?: string; + variantId?: number; +} + +export type NoteAttributes = Array<{ + name: string; + value: string; +}>; + +export interface Properties { + [index: string]: string; +} diff --git a/src/actions/Error/README.md b/src/actions/Error/README.md new file mode 100644 index 0000000..8c3ea16 --- /dev/null +++ b/src/actions/Error/README.md @@ -0,0 +1,111 @@ +# Error + +You can subscribe to runtime errors similar to other action types. It's important to subscribe to error actions since they might occur asynchronously when actions are dispatched through the app store. Errors will be thrown in the console if there isn't an error handler defined. + +## Setup + +Create an app and import the `Error` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {Error} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); +``` + +## Subscribe to all errors through the app + +Call the `app.error` method to subscribe to all errors including those that are caused by actions. Calling `app.error` returns a method that you can call to unsubscribe from all errors: + +```ts +const unsubscribe = app.error((data: Error.ErrorAction) => { + // type will be the error type + // action will contain the original action including its id + // message will contain additional hints on how to fix the error + const {type, action, message} = data; + // Handle all errors here + switch(type) { + case Error.ActionType.INVALID_PAYLOAD: + //Do something with the error + break; + } + } +}); + +// Unsubscribe from all errors +unsubscribe(); +``` + +## Subscribe to specific errors + +You can call `app.subscribe` with a specific error type to subscribe only to that error type: + +```ts +const unsubscribe = app.subscribe(Error.ActionType.INVALID_ACTION, (data: Error.ErrorAction) => { + // Do something with the error +}); + +// Unsubscribe from the error +unsubscribe(); +``` + +## Subscribe to all errors for an action set + +Call the `error` method on any action set to subscribe to all errors that are related to that action set: + +```ts +import {Modal} from '@shopify/app-bridge/actions'; + +const modalOptions = { + message: 'Hello World', +}; +const modal = Modal.create(app, modalOptions); + +const unsubscribe = modal.error((data: Error.ErrorAction) => { + // type will be the error type + // action will contain the original action including its id + // message will contain additional hints on how to fix the error + const {type, action, message} = data; + // Handle all errors here + switch(type) { + case Error.ActionType.UNEXPECTED_ACTION: + //Do something with the error + break; + } + } +}); + +// Trigger an UNEXPECTED_ACTION error by updating a modal that is not opened +modal.set({title: 'Greeting'}); + +// Unsubscribe from all errors related to this flash +unsubscribe(); +``` + +## Subscribe to a specific error for an action set + +```ts +import {Modal} from '@shopify/app-bridge/actions'; + +const modalOptions = { + message: 'Hello World', +}; +const modal = Modal.create(app, modalOptions); + +const unsubscribe = modal.subscribe(Error.ActionType.UNEXPECTED_ACTION, (data: Error.ErrorAction) => { + // type will be the error type + // action will contain the original action including its id + // message will contain additional hints on how to fix the error + const {type, action, message} = data; + // Handle the error here + } +}); + +// Trigger an UNEXPECTED_ACTION error by updating a modal that is not opened +modal.set({title: 'Greeting'}); + +// Unsubscribe from UNEXPECTED_ACTION errors related to this flash +unsubscribe(); +``` diff --git a/src/actions/Error/actions.ts b/src/actions/Error/actions.ts new file mode 100644 index 0000000..e81f070 --- /dev/null +++ b/src/actions/Error/actions.ts @@ -0,0 +1,155 @@ +import {actionWrapper, findMatchInEnum} from '../helper'; +import {Group, MetaAction} from '../types'; +import {Action, ActionType as Type, Payload} from './types'; + +function errorActionWrapperWithId(type: Type, action: A, message?: string) { + const castPayload = (action as MetaAction).payload; + + return actionWrapper({ + type, + group: Group.Error, + payload: { + action, + message, + type, + id: castPayload && castPayload.id ? castPayload.id : undefined, + }, + }); +} + +export enum Message { + MISSING_PAYLOAD = 'Missing payload', + INVALID_PAYLOAD_ID = 'Id in payload is missing or invalid', +} + +export interface ErrorAction extends MetaAction { + payload: { + type: typeof Type; + action: Payload; + }; +} + +export function invalidPayload(action: A, message?: string): ErrorAction { + return errorActionWrapperWithId( + Type.INVALID_PAYLOAD, + action, + message || "The action's payload is missing required properties or has invalid properties", + ); +} + +export function invalidActionType(action: A, message?: string): ErrorAction { + return actionWrapper({ + group: Group.Error, + payload: { + action, + message: message || 'The action type is invalid or unsupported', + type: Type.INVALID_ACTION_TYPE, + }, + type: Type.INVALID_ACTION_TYPE, + }); +} + +export function invalidAction(action: A, message?: string): ErrorAction { + return actionWrapper({ + group: Group.Error, + payload: { + action, + message: + message || "The action's has missing/invalid values for `group`, `type` or `version`", + type: Type.INVALID_ACTION, + }, + type: Type.INVALID_ACTION, + }); +} + +export function unexpectedAction(action: A, message?: string): ErrorAction { + return actionWrapper({ + group: Group.Error, + payload: { + action, + message: message || 'Action cannot be called at this time', + type: Type.UNEXPECTED_ACTION, + }, + type: Type.UNEXPECTED_ACTION, + }); +} + +export function unsupportedOperationAction( + action: A, + message?: string, +): ErrorAction { + return errorActionWrapperWithId( + Type.UNSUPPORTED_OPERATION, + action, + message || 'The action type is unsupported', + ); +} + +export function persistenceAction(action: A, message?: string): ErrorAction { + return errorActionWrapperWithId( + Type.PERSISTENCE, + action, + message || 'Action cannot be persisted on server', + ); +} + +export function networkAction(action: A, message?: string): ErrorAction { + return errorActionWrapperWithId(Type.NETWORK, action, message || 'Network error'); +} + +export function permissionAction(action: A, message?: string): ErrorAction { + return errorActionWrapperWithId(Type.PERMISSION, action, message || 'Action is not permitted'); +} + +export function isErrorEventName(eventName: string) { + const match = findMatchInEnum(Action, eventName); + + return typeof match === 'string'; +} + +export class AppBridgeError { + message: string; + name: string; + stack!: any; + action?: Payload; + type?: string; + + constructor(message: string) { + this.name = 'AppBridgeError'; + this.message = message; + + if (typeof (Error as any).captureStackTrace === 'function') { + (Error as any).captureStackTrace(this, this.constructor); + } else { + this.stack = new Error(this.message).stack; + } + } +} + +AppBridgeError.prototype = Object.create(Error.prototype); + +export function fromAction(message: string, type: string, action?: Payload) { + const errorMessage = message ? `${type}: ${message}` : type; + const error = new AppBridgeError(errorMessage); + + error.action = action; + error.type = type; + + return error; +} + +export function throwError(type: Type | string, action: Payload, message?: string): void; +export function throwError(type: Type | string, message: string): void; +export function throwError() { + const type = arguments[0]; + let message; + let action; + + if (typeof arguments[1] === 'string') { + message = arguments[1]; + } else { + action = arguments[1]; + message = arguments[2] || ''; + } + throw fromAction(message, type, action); +} diff --git a/src/actions/Error/index.ts b/src/actions/Error/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/Error/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/Error/tests/actions.test.ts b/src/actions/Error/tests/actions.test.ts new file mode 100644 index 0000000..23e5b60 --- /dev/null +++ b/src/actions/Error/tests/actions.test.ts @@ -0,0 +1,270 @@ +import * as Helper from '../../helper'; +import {AppBridgeError} from '../../Error'; +import { + fromAction, + invalidAction, + invalidActionType, + invalidPayload, + isErrorEventName, + networkAction, + permissionAction, + persistenceAction, + throwError, + unexpectedAction, + unsupportedOperationAction, +} from '../actions'; +import {ActionType} from '../types'; + +describe('Error', () => { + beforeEach(() => { + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('dispatches invalidPayload', () => { + const errorAction = { + group: 'some-group', + payload: { + id: 'some-id', + }, + type: 'APP::SOMEACTION', + version: 'some-version', + }; + const expectedAction = { + group: 'Error', + payload: { + action: errorAction, + id: 'some-id', + message: expect.any(String), + type: 'APP::ERROR::INVALID_PAYLOAD', + }, + type: 'APP::ERROR::INVALID_PAYLOAD', + }; + expect(invalidPayload(errorAction)).toEqual(expectedAction); + }); + + it('dispatches invalidAction', () => { + const errorAction = { + group: 'some-group', + payload: 'something', + type: 'APP::SOMEACTION', + version: 'some-version', + }; + const expectedAction = { + group: 'Error', + payload: { + action: errorAction, + message: expect.any(String), + type: 'APP::ERROR::INVALID_ACTION', + }, + type: 'APP::ERROR::INVALID_ACTION', + }; + expect(invalidAction(errorAction)).toEqual(expectedAction); + }); + + it('dispatches invalidActionType', () => { + const errorAction = { + group: 'some-group', + payload: 'something', + type: 'APP::SOMEACTION', + version: 'some-version', + }; + const expectedAction = { + group: 'Error', + payload: { + action: errorAction, + message: expect.any(String), + type: 'APP::ERROR::INVALID_ACTION_TYPE', + }, + type: 'APP::ERROR::INVALID_ACTION_TYPE', + }; + expect(invalidActionType(errorAction)).toEqual(expectedAction); + }); + + it('dispatches unexpectedAction', () => { + const errorAction = { + group: 'some-group', + payload: 'something', + type: 'APP::SOMEACTION', + version: 'some-version', + }; + const expectedAction = { + group: 'Error', + payload: { + action: errorAction, + message: expect.any(String), + type: 'APP::ERROR::UNEXPECTED_ACTION', + }, + type: 'APP::ERROR::UNEXPECTED_ACTION', + }; + expect(unexpectedAction(errorAction)).toEqual(expectedAction); + }); + + it('dispatches unsupportedOperationAction', () => { + const errorAction = { + group: 'some-group', + id: 'some-id', + payload: { + id: 'some-id', + }, + type: 'APP::SOMEACTION', + version: 'some-version', + }; + const expectedAction = { + group: 'Error', + payload: { + action: errorAction, + id: 'some-id', + message: expect.any(String), + type: 'APP::ERROR::UNSUPPORTED_OPERATION', + }, + type: 'APP::ERROR::UNSUPPORTED_OPERATION', + }; + expect(unsupportedOperationAction(errorAction)).toEqual(expectedAction); + }); + + it('dispatches persistenceAction', () => { + const errorAction = { + group: 'some-group', + id: 'some-id', + payload: { + id: 'some-id', + }, + type: 'APP::SOMEACTION', + version: 'some-version', + }; + const expectedAction = { + group: 'Error', + payload: { + action: errorAction, + id: 'some-id', + message: expect.any(String), + type: 'APP::ERROR::PERSISTENCE', + }, + type: 'APP::ERROR::PERSISTENCE', + }; + expect(persistenceAction(errorAction)).toEqual(expectedAction); + }); + + it('dispatches networkAction', () => { + const errorAction = { + group: 'some-group', + id: 'some-id', + payload: { + id: 'some-id', + }, + type: 'APP::SOMEACTION', + version: 'some-version', + }; + const expectedAction = { + group: 'Error', + payload: { + action: errorAction, + id: 'some-id', + message: expect.any(String), + type: 'APP::ERROR::NETWORK', + }, + type: 'APP::ERROR::NETWORK', + }; + expect(networkAction(errorAction)).toEqual(expectedAction); + }); + + it('dispatches permissionAction', () => { + const errorAction = { + group: 'some-group', + id: 'some-id', + payload: { + id: 'some-id', + }, + type: 'APP::SOMEACTION', + version: 'some-version', + }; + const expectedAction = { + group: 'Error', + payload: { + action: errorAction, + id: 'some-id', + message: expect.any(String), + type: 'APP::ERROR::PERMISSION', + }, + type: 'APP::ERROR::PERMISSION', + }; + expect(permissionAction(errorAction)).toEqual(expectedAction); + }); + + it('isErrorEventName returns true if findMatchInEnum returns match', () => { + const spy = jest.spyOn(Helper, 'findMatchInEnum').mockImplementation(() => 'SOME_ERROR'); + expect(isErrorEventName('error')).toBe(true); + }); + + it('isErrorEventName returns true if findMatchInEnum returns undefined', () => { + const spy = jest.spyOn(Helper, 'findMatchInEnum').mockImplementation(() => undefined); + expect(isErrorEventName('error')).toBe(false); + }); + + it('throwError throws expected error from an action with message', () => { + const orgAction = Helper.actionWrapper({type: 'SOME_ACTION', payload: 'something'}); + const message = 'Helpful message'; + const error = new AppBridgeError(`${ActionType.INVALID_ACTION}: ${message}`); + error.action = orgAction; + error.type = ActionType.INVALID_ACTION; + + // Note that jest's toThrowError assert does not work with custom errors + // We need to catch the error to do the assertion + let thrownError; + try { + throwError(ActionType.INVALID_ACTION, orgAction, message); + } catch (error) { + thrownError = error; + } + expect(thrownError).toEqual(error); + }); + + it('throwError throws expected error from an action without message', () => { + const orgAction = Helper.actionWrapper({type: 'SOME_ACTION', payload: 'something'}); + const error = new AppBridgeError(`${ActionType.INVALID_ACTION}`); + error.action = orgAction; + error.type = ActionType.INVALID_ACTION; + + // Note that jest's toThrowError assert does not work with custom errors + // We need to catch the error to do the assertion + let thrownError; + try { + throwError(ActionType.INVALID_ACTION, orgAction); + } catch (error) { + thrownError = error; + } + expect(thrownError).toEqual(error); + }); + + it('throwError throws expected error without action', () => { + const orgAction = Helper.actionWrapper({type: 'SOME_ACTION', payload: 'something'}); + const message = 'Helpful message'; + const error = new AppBridgeError(`${ActionType.INVALID_ACTION}: ${message}`); + error.type = ActionType.INVALID_ACTION; + + // Note that jest's toThrowError assert does not work with custom errors + // We need to catch the error to do the assertion + let thrownError; + try { + throwError(ActionType.INVALID_ACTION, orgAction, message); + } catch (error) { + thrownError = error; + } + expect(thrownError).toEqual(error); + }); + + it('fromAction creates custom Error object', () => { + const message = 'Error hint'; + const orgAction = Helper.actionWrapper({type: 'SOME_ACTION', payload: 'something'}); + const error = fromAction(message, ActionType.INVALID_ACTION, orgAction); + + expect(error instanceof AppBridgeError).toBe(true); + expect(Error.prototype.isPrototypeOf(error)).toBe(true); + expect(error).toMatchObject({ + action: orgAction, + message: `${ActionType.INVALID_ACTION}: ${message}`, + name: 'AppBridgeError', + type: ActionType.INVALID_ACTION, + }); + }); +}); diff --git a/src/actions/Error/types.ts b/src/actions/Error/types.ts new file mode 100644 index 0000000..cbef64d --- /dev/null +++ b/src/actions/Error/types.ts @@ -0,0 +1,35 @@ +import {AnyAction, MetaAction} from '../types'; + +export enum Action { + INVALID_ACTION = 'INVALID_ACTION', + INVALID_ACTION_TYPE = 'INVALID_ACTION_TYPE', + INVALID_OPTIONS = 'INVALID_OPTIONS', + INVALID_PAYLOAD = 'INVALID_PAYLOAD', + UNEXPECTED_ACTION = 'UNEXPECTED_ACTION', + PERSISTENCE = 'PERSISTENCE', + UNSUPPORTED_OPERATION = 'UNSUPPORTED_OPERATION', + NETWORK = 'NETWORK', + PERMISSION = 'PERMISSION', +} + +export enum ActionType { + INVALID_ACTION = 'APP::ERROR::INVALID_ACTION', + INVALID_ACTION_TYPE = 'APP::ERROR::INVALID_ACTION_TYPE', + INVALID_PAYLOAD = 'APP::ERROR::INVALID_PAYLOAD', + INVALID_OPTIONS = 'APP::ERROR::INVALID_OPTIONS', + UNEXPECTED_ACTION = 'APP::ERROR::UNEXPECTED_ACTION', + PERSISTENCE = 'APP::ERROR::PERSISTENCE', + UNSUPPORTED_OPERATION = 'APP::ERROR::UNSUPPORTED_OPERATION', + NETWORK = 'APP::ERROR::NETWORK', + PERMISSION = 'APP::ERROR::PERMISSION', +} + +export enum AppActionType { + INVALID_CONFIG = 'APP::ERROR::INVALID_CONFIG', + MISSING_CONFIG = 'APP::APP_ERROR::MISSING_CONFIG', + MISSING_APP_BRIDGE_MIDDLEWARE = 'APP::APP_ERROR::MISSING_APP_BRIDGE_MIDDLEWARE', + WINDOW_UNDEFINED = 'APP::APP_ERROR::WINDOW_UNDEFINED', + MISSING_LOCAL_ORIGIN = 'APP::APP_ERROR::MISSING_LOCAL_ORIGIN', +} + +export type Payload = MetaAction | AnyAction; diff --git a/src/actions/Features/README.md b/src/actions/Features/README.md new file mode 100644 index 0000000..ef2dba2 --- /dev/null +++ b/src/actions/Features/README.md @@ -0,0 +1,59 @@ +# Features + +## Setup + +Create an app and import the `Features` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {Features} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', + shopOrigin: getShopOrigin(), +}); +``` + +## Features app + +### Subscribe to Feature availablility updates: + +```js +app.subscribe(Features.ActionType.UPDATE, (state: FeaturesAvailable) { + // state will be a delta of what has changed in the store. + // Either check the state for what has changed + // or call `app.featuresAvailable()` to get all available features +}); +``` + +### App.featuresAvailable() + +Calling app.featuresAvailable() returns a promise that evaluates to the entire list of available features for the app. This can be used when you want to check if a specific feature or group of features is available at any time, possibly for optional UI displaying. + +```js +app.featuresAvailable().then(state => { + // state will contain all `Group`s and `Action`s for the app. + /* ie. The print.app action is available: + { + Print: { + APP: true + } + } */ +}); +``` + +If you want to limit your resulting state to a subset of Groups then pass in a parameter, an array of `Group`s. + +```js +app.featuresAvailable([Group.Cart]).then(state => { + // state will contain only Cart actions + /* + { + Cart: { + FETCH: false, + REMOVE_LINE_ITEM_PROPERTIES: false, + ... + } + } */ +}); +``` \ No newline at end of file diff --git a/src/actions/Features/actions.ts b/src/actions/Features/actions.ts new file mode 100644 index 0000000..a7ed3eb --- /dev/null +++ b/src/actions/Features/actions.ts @@ -0,0 +1,27 @@ +/** + * @module Features + */ + +import {actionWrapper} from '../helper'; +import {Group, MetaAction} from '../types'; +import {invalidActionType, ErrorAction} from '../Error'; +import {ActionType} from './types'; +import {AnyAction} from '../../client'; + +export type FeaturesAction = MetaAction | AnyAction; + +export function update(): FeaturesAction { + return actionWrapper({ + group: Group.Features, + type: ActionType.UPDATE, + }); +} + +export function validationError(action: MetaAction): undefined | ErrorAction { + switch (action.type) { + case ActionType.UPDATE: + return undefined; + default: + return invalidActionType(action); + } +} diff --git a/src/actions/Features/index.ts b/src/actions/Features/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/Features/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/Features/tests/actions.test.ts b/src/actions/Features/tests/actions.test.ts new file mode 100644 index 0000000..96b2f2b --- /dev/null +++ b/src/actions/Features/tests/actions.test.ts @@ -0,0 +1,33 @@ +import * as Helper from '../../helper'; +import {ActionType as ErrorActionType} from '../../Error'; +import {update, validationError} from '../actions'; +import {ActionType} from '../types'; + +describe('Features', () => { + beforeEach(() => { + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('dispatches app', () => { + const expectedAction = { + group: 'Features', + type: 'APP::FEATURES::UPDATE', + }; + expect(update()).toEqual(expectedAction); + }); + + it('returns undefined for ActionType.UPDATE action', () => { + expect( + validationError({ + type: ActionType.UPDATE, + }), + ).toEqual(undefined); + }); + + it('returns INVALID_ACTION_TYPE error for unknown actions', () => { + const invalidAction = {type: 'Unknown', payload: {}}; + expect(validationError(invalidAction)).toMatchObject({ + type: ErrorActionType.INVALID_ACTION_TYPE, + }); + }); +}); diff --git a/src/actions/Features/types.ts b/src/actions/Features/types.ts new file mode 100644 index 0000000..39623b9 --- /dev/null +++ b/src/actions/Features/types.ts @@ -0,0 +1,11 @@ +/** + * @module Features + */ + +export enum ActionType { + UPDATE = 'APP::FEATURES::UPDATE', +} + +export enum Action { + UPDATE = 'UPDATE', +} diff --git a/src/actions/Flash/README.md b/src/actions/Flash/README.md new file mode 100644 index 0000000..384c5e6 --- /dev/null +++ b/src/actions/Flash/README.md @@ -0,0 +1,3 @@ +# Flash + +### The Flash action has been deprecated. Use [Toast](../Toast/README.md) instead. diff --git a/src/actions/Flash/actions.ts b/src/actions/Flash/actions.ts new file mode 100644 index 0000000..01408b2 --- /dev/null +++ b/src/actions/Flash/actions.ts @@ -0,0 +1,22 @@ +/** + * @module Flash + */ + +import {ClientApplication} from '../../client'; +import {Toast} from '../Toast'; +import {Options} from '../Toast/types'; + +export { + ActionBase, + clear, + ClearAction, + show, + ShowAction, + ToastAction as FlashAction, +} from '../Toast'; + +export class Flash extends Toast {} + +export function create(app: ClientApplication, options: Options) { + return new Flash(app, options); +} diff --git a/src/actions/Flash/index.ts b/src/actions/Flash/index.ts new file mode 100644 index 0000000..5e5e4a6 --- /dev/null +++ b/src/actions/Flash/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from '../Toast/types'; diff --git a/src/actions/Loading/README.md b/src/actions/Loading/README.md new file mode 100644 index 0000000..ab92e6d --- /dev/null +++ b/src/actions/Loading/README.md @@ -0,0 +1,60 @@ +# Loading + +## Setup + +Create an app and import the `Loading` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {Loading} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); + +const loading = Loading.create(app); +``` + +## Start loading + +Call the `START` loading action when loading new pages or completing asynchronous requests that might be more than a few seconds: + +```js +loading.dispatch(Loading.Action.START); +``` + +## Stop loading + +After the loading action is complete, you can dispatch the `STOP` loading action: + +```js +loading.dispatch(Loading.Action.STOP); +``` + +## Subscribe to actions + +You can subscribe to actions that are dispatched through the loading action set: + +```js +loading.subscribe(Loading.Action.START, () => { + // Do something when loading starts +}); + +loading.subscribe(Loading.Action.STOP, () => { + // Do something when loading stops +}); +``` + +## Subscribe to all actions + +You can subscribe to all loading actions, regardless of which action sets trigger the actions: + +```js +app.subscribe(Loading.Action.START, () => { + // Do something when loading starts +}); + +app.subscribe(Loading.Action.STOP, () => { + // Do something when loading stops +}); +``` diff --git a/src/actions/Loading/actions.ts b/src/actions/Loading/actions.ts new file mode 100644 index 0000000..1d6b2d5 --- /dev/null +++ b/src/actions/Loading/actions.ts @@ -0,0 +1,54 @@ +/** + * @module Loading + */ + +import {ClientApplication} from '../../client'; +import {actionWrapper, ActionSet} from '../helper'; +import {ActionSetPayload, Group, MetaAction} from '../types'; +import {Action, ActionType, Payload} from './types'; + +export type LoadingAction = MetaAction; + +export function start(payload?: Payload): LoadingAction { + return actionWrapper({ + payload, + group: Group.Loading, + type: ActionType.START, + }); +} + +export function stop(payload?: Payload): LoadingAction { + return actionWrapper({ + payload, + group: Group.Loading, + type: ActionType.STOP, + }); +} + +export class Loading extends ActionSet implements ActionSetPayload { + constructor(app: ClientApplication) { + super(app, Group.Loading, Group.Loading); + } + + get payload() { + return {id: this.id}; + } + + dispatch(action: Action) { + switch (action) { + case Action.START: + this.app.dispatch(start(this.payload)); + break; + + case Action.STOP: + this.app.dispatch(stop(this.payload)); + break; + } + + return this; + } +} + +export function create(app: ClientApplication) { + return new Loading(app); +} diff --git a/src/actions/Loading/index.ts b/src/actions/Loading/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/Loading/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/Loading/tests/actions.test.ts b/src/actions/Loading/tests/actions.test.ts new file mode 100644 index 0000000..feaf230 --- /dev/null +++ b/src/actions/Loading/tests/actions.test.ts @@ -0,0 +1,57 @@ +import {createMockApp} from 'test/helper'; +import {create, start, stop, Loading} from '../actions'; +import {Action} from '../types'; + +describe('Loading Actions', () => { + it('dispatches start', () => { + const expectedAction = { + group: 'Loading', + type: 'APP::LOADING::START', + }; + expect(start()).toEqual(expect.objectContaining(expectedAction)); + }); + + it('dispatches stop', () => { + const expectedAction = { + group: 'Loading', + type: 'APP::LOADING::STOP', + }; + expect(stop()).toEqual(expect.objectContaining(expectedAction)); + }); +}); + +describe('Loading', () => { + let app; + beforeEach(() => { + app = createMockApp(); + }); + + it('dispatches start action on start', () => { + const loading = new Loading(app); + const expectedAction = { + group: 'Loading', + payload: {id: loading.id}, + type: 'APP::LOADING::START', + }; + loading.dispatch(Action.START); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatches stop action on stop', () => { + const loading = new Loading(app); + const expectedAction = { + group: 'Loading', + payload: {id: loading.id}, + type: 'APP::LOADING::STOP', + }; + loading.dispatch(Action.STOP); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('create generates a new Loading instance', () => { + const obj = create(app); + expect(obj instanceof Loading).toBe(true); + }); +}); diff --git a/src/actions/Loading/types.ts b/src/actions/Loading/types.ts new file mode 100644 index 0000000..1b34029 --- /dev/null +++ b/src/actions/Loading/types.ts @@ -0,0 +1,17 @@ +/** + * @module Loading + */ + +export enum ActionType { + START = 'APP::LOADING::START', + STOP = 'APP::LOADING::STOP', +} + +export enum Action { + START = 'START', + STOP = 'STOP', +} + +export interface Payload { + readonly id?: string; +} diff --git a/src/actions/Modal/README.md b/src/actions/Modal/README.md new file mode 100644 index 0000000..3e4d7a2 --- /dev/null +++ b/src/actions/Modal/README.md @@ -0,0 +1,264 @@ +# Modal + +## Setup + +Create an app and import the `Modal` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {Modal} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); +``` + +## Create a message modal + +```js +const modalOptions = { + title: 'My Modal', + message: 'Hello world!', +}; + +const myModal = Modal.create(app, modalOptions); +``` + +## Create an iframe modal with an absolute url + +```js +const modalOptions = { + title: 'My Modal', + url: 'http://example.com', +}; + +const myModal = Modal.create(app, modalOptions); +``` + +## Create an iframe modal a relative path + +The iframe URL will be set to a path that's relative to your app root: + +```js +const modalOptions = { + title: 'My Modal', + path: '/setting', +}; + +const myModal = Modal.create(app, modalOptions); +``` + +## Create a modal with footer buttons + +You can attach buttons to the modal footer. To learn more about buttons, see [Button](../Button). + +```js +const okButton = Button.create(app, {label: 'Ok'}); +const cancelButton = Button.create(app, {label: 'Cancel'}); +const modalOptions = { + title: 'My Modal', + message: 'Hello world!', + footer: { + buttons: { + primary: okButton, + secondary: [cancelButton], + }, + }, +}; + +const myModal = Modal.create(app, modalOptions); +``` + +## Subscribe to actions + +You can subscribe to modal actions by calling `subscribe`. This returns a method that you can call to unsubscribe from the action: + +```js +const modalOptions = { + title: 'My Modal', + url: 'http://example.com', +}; + +const myModal = Modal.create(app, modalOptions); + +const openUnsubscribe = myModal.subscribe(Modal.Action.OPEN, () => { + // Do something with the open event +}); + +const closeUnsubscribe = myModal.subscribe(Modal.Action.CLOSE, () => { + // Do something with the close event +}); + +// Unsubscribe to actions +openUnsubscribe(); +closeUnsubscribe(); +``` + +## Unsubscribe + +You can call `unsubscribe` to remove all subscriptions on the modal and its children (including buttons): + +```js +const okButton = Button.create(app, {label: 'Ok'}); +okButton.subscribe(Button.Action.CLICK, () => { + // Do something with the click action +}); +const cancelButton = Button.create(app, {label: 'Cancel'}); +cancelButton.subscribe(Button.Action.CLICK, () => { + // Do something with the click action +}); +const modalOptions = { + title: 'My Modal', + url: 'http://example.com', + footer: { + buttons: { + primary: okButton, + secondary: [cancelButton], + }, + }, +}; + +const myModal = Modal.create(app, modalOptions); + +myModal.subscribe(Modal.Action.OPEN, () => { + // Do something with the open event +}); + +myModal.subscribe(Modal.Action.CLOSE, () => { + // Do something with the close event +}); + +// Unsubscribe from modal open and close actions +// Unsubscribe from okButton and cancelButton click actions +myModal.unsubscribe(); +``` + +## Unsubscribe from modal actions only + +You can call `unsubscribe` with `false` to remove only modal subscriptions while leaving child subscriptions intact. For example, you might want to unsubscribe from the modal but keep button listeners so that the buttons can be reused in a different modal. + +```js +const okButton = Button.create(app, {label: 'Ok'}); +okButton.subscribe(Button.Action.CLICK, () => { + // Do something with the click action +}); +const cancelButton = Button.create(app, {label: 'Cancel'}); +cancelButton.subscribe(Button.Action.CLICK, () => { + // Do something with the click action +}); + +const modalOptions = { + title: 'My Modal', + message: 'Hello world!', + footer: { + buttons: { + primary: okButton, + secondary: [cancelButton], + }, + }, +}; + +const myModal = Modal.create(app, modalOptions); + +// Unsubscribe only from modal open and close actions +myModal.unsubscribe(false); + +// The buttons above can be reused in a new modal +// Their subscriptions will be left intact + +const newModalOptions = { + title: 'Confirm', + message: 'Are you sure?', + footer: { + buttons: { + primary: okButton, + secondary: [cancelButton], + }, + }, +}; + +const confirmModal = Modal.create(app, newModalOptions); +``` + +## Dispatch actions + +```js +const modalOptions = { + title: 'My Modal', + url: 'http://example.com', +}; + +const myModal = Modal.create(app, modalOptions); + +myModal.dispatch(Modal.Action.OPEN); + +// Close modal +myModal.dispatch(Modal.Action.CLOSE); +``` + +## Update options + +You can call the `set` method with partial modal options to update the options of an existing modal. This automatically triggers the `update` action on the modal and merges the given options with the existing options. + +```js +const modalOptions = { + title: 'My Modal', + url: 'http://example.com', +}; + +const myModal = Modal.create(app, modalOptions); + +myModal.set({title: 'My new title'}); +``` + +## Update footer buttons + +You can update buttons attached to a modal's footer. Any updates made to the modal's children automatically trigger an `update` action on the modal: + +```js +const okButton = Button.create(app, {label: 'Ok'}); +const cancelButton = Button.create(app, {label: 'Cancel'}); +const modalOptions = { + title: 'My Modal', + message: 'Hello world!', + footer: { + buttons: { + primary: okButton, + secondary: [cancelButton], + }, + }, +}; + +const myModal = Modal.create(app, modalOptions); +myModal.dispatch(Modal.Action.OPEN); + +okButton.set({label: 'Good to go!'}); +``` + +## Set modal size + +By default, modals have a size of `small`. You can customize the size of a modal by passing in a different `Modal.Size` value: + +```js +const modalOptions = { + title: 'My Modal', + message: 'Hello world!', + size: Modal.Size.Large, +}; + +const myModal = Modal.create(app, modalOptions); +myModal.dispatch(Modal.Action.OPEN); +``` + +You can also set the modal size to full screen: + +```js +const modalOptions = { + title: 'My Modal', + message: 'Hello world!', + size: Modal.Size.Full, +}; + +const myModal = Modal.create(app, modalOptions); +myModal.dispatch(Modal.Action.OPEN); +``` diff --git a/src/actions/Modal/actions.ts b/src/actions/Modal/actions.ts new file mode 100644 index 0000000..5eee81a --- /dev/null +++ b/src/actions/Modal/actions.ts @@ -0,0 +1,330 @@ +/** + * @module Modal + */ +import {ClientApplication} from '../../client'; +import {getSingleButton} from '../buttonHelper'; +import { + actionWrapper, + getMergedProps, + updateActionFromPayload, + ActionSetWithChildren, +} from '../helper'; +import {ActionSetProps, ClickAction, ComponentType, Group, MetaAction} from '../types'; +import {clickButton, Button, Payload as ButtonPayload} from '../Button'; +import { + Action, + ActionType, + ClosePayload, + Footer, + FooterOptions, + IframeOptions, + IframePayload, + MessageOptions, + MessagePayload, + Size, +} from './types'; + +const FOOTER_BUTTON_PROPS = { + group: Group.Modal, + subgroups: ['Footer'], + type: ComponentType.Button, +}; + +export interface ActionBase extends MetaAction { + readonly group: typeof Group.Modal; +} + +export interface OpenAction extends ActionBase { + readonly type: typeof ActionType.OPEN; + readonly payload: MessagePayload | IframePayload; +} + +export type UpdateAction = OpenAction; + +export interface CloseAction extends ActionBase { + readonly type: typeof ActionType.CLOSE; +} + +export type ModalAction = OpenAction | UpdateAction | CloseAction | MetaAction; + +export function openModal(modalPayload: MessagePayload | IframePayload): OpenAction { + return actionWrapper({ + group: Group.Modal, + payload: modalPayload, + type: ActionType.OPEN, + }); +} + +export function closeModal(modalClosePayload: ClosePayload): CloseAction { + return actionWrapper({ + group: Group.Modal, + payload: modalClosePayload, + type: ActionType.CLOSE, + }); +} + +export function clickFooterButton(id: string, payload?: any): ClickAction { + const component = {id, ...FOOTER_BUTTON_PROPS}; + + return clickButton(Group.Modal, component, payload); +} + +export function update(payload: MessagePayload | IframePayload): UpdateAction { + return actionWrapper({ + payload, + group: Group.Modal, + type: ActionType.UPDATE, + }); +} + +export function isIframeModal( + options: MessagePayload | IframePayload | object, +): options is IframePayload { + return ( + typeof (options as IframePayload).url === 'string' || + typeof (options as IframePayload).path === 'string' + ); +} + +export function isMessageModal( + options: MessagePayload | IframePayload | object, +): options is MessagePayload { + return typeof (options as MessagePayload).message === 'string'; +} + +export abstract class Modal extends ActionSetWithChildren { + title?: string; + size = Size.Small; + footerPrimary?: ButtonPayload; + footerPrimaryOptions?: Button; + footerSecondary?: ButtonPayload[]; + footerSecondaryOptions?: Button[]; + + get footer(): Footer | undefined { + if (!this.footerPrimary && !this.footerSecondary) { + return undefined; + } + + return { + buttons: { + primary: this.footerPrimary, + secondary: this.footerSecondary, + }, + }; + } + + get footerOptions(): FooterOptions | undefined { + if (!this.footerPrimaryOptions && !this.footerSecondaryOptions) { + return undefined; + } + + return { + buttons: { + primary: this.footerPrimaryOptions, + secondary: this.footerSecondaryOptions, + }, + }; + } + + protected close() { + this.app.dispatch(closeModal({id: this.id})); + } + + protected setFooterPrimaryButton(newOptions: Button | undefined, updateCb: () => void) { + const {subgroups} = FOOTER_BUTTON_PROPS; + this.footerPrimaryOptions = this.getChildButton(newOptions, this.footerPrimaryOptions); + this.footerPrimary = this.footerPrimaryOptions + ? getSingleButton(this, this.footerPrimaryOptions, subgroups, (newPayload: ButtonPayload) => { + this.updatePrimaryFooterButton(newPayload, updateCb); + }) + : undefined; + } + + protected setFooterSecondaryButtons(newOptions: Button[] | undefined, updateCb: () => void) { + const {subgroups} = FOOTER_BUTTON_PROPS; + const newButtons = newOptions || []; + const currentOptions = (this.footerOptions && this.footerOptions.buttons.secondary) || []; + this.footerSecondaryOptions = this.getUpdatedChildActions(newButtons, currentOptions); + + this.footerSecondary = this.footerSecondaryOptions + ? this.footerSecondaryOptions.map(action => + getSingleButton(this, action, subgroups, (newPayload: ButtonPayload) => { + this.updateSecondaryFooterButton(newPayload, updateCb); + }), + ) + : undefined; + } + + protected getChildButton(newAction: undefined | Button, currentAction: undefined | Button) { + const newButtons = newAction ? [newAction] : []; + const currentButtons = currentAction ? [currentAction] : []; + const updatedButton = this.getUpdatedChildActions(newButtons, currentButtons); + + return updatedButton ? updatedButton[0] : undefined; + } + + protected updatePrimaryFooterButton(newPayload: ButtonPayload, updateCb: () => void) { + if (!this.footer || !this.footer.buttons.primary) { + return; + } + if (updateActionFromPayload(this.footer.buttons.primary, newPayload)) { + updateCb(); + } + } + + protected updateSecondaryFooterButton(newPayload: ButtonPayload, updateCb: () => void) { + if (!this.footer || !this.footer.buttons || !this.footer.buttons.secondary) { + return; + } + let updated; + for (const action of this.footer.buttons.secondary) { + updated = updateActionFromPayload(action, newPayload); + if (updated) { + break; + } + } + if (updated) { + updateCb(); + } + } +} + +export class ModalMessage extends Modal implements ActionSetProps { + message!: string; + + constructor(app: ClientApplication, options: MessageOptions) { + super(app, Group.Modal, Group.Modal); + this.set(options, false); + } + + get payload() { + return { + ...this.options, + footer: this.footer, + id: this.id, + }; + } + + get options() { + return { + footer: this.footerOptions, + message: this.message, + size: this.size, + title: this.title, + }; + } + + set(options: Partial, shouldUpdate = true) { + const mergedOptions = getMergedProps(this.options, options); + const {title, footer, message, size} = mergedOptions; + + this.title = title; + this.message = message; + this.size = size; + this.setFooterPrimaryButton(footer ? footer.buttons.primary : undefined, () => { + this.dispatch(Action.UPDATE); + }); + + this.setFooterSecondaryButtons(footer ? footer.buttons.secondary : undefined, () => { + this.dispatch(Action.UPDATE); + }); + + if (shouldUpdate) { + this.dispatch(Action.UPDATE); + } + + return this; + } + + dispatch(action: Action) { + switch (action) { + case Action.OPEN: + this.app.dispatch(openModal(this.payload)); + break; + case Action.CLOSE: + this.close(); + break; + case Action.UPDATE: + this.app.dispatch(update(this.payload)); + break; + } + + return this; + } +} + +export class ModalIframe extends Modal implements ActionSetProps { + url?: string; + path?: string; + + constructor(app: ClientApplication, options: IframeOptions) { + super(app, Group.Modal, Group.Modal); + this.set(options, false); + } + + get payload() { + return { + ...this.options, + footer: this.footer, + id: this.id, + }; + } + + get options() { + return { + footer: this.footerOptions, + path: this.path, + size: this.size, + title: this.title, + url: this.url, + }; + } + + set(options: Partial, shouldUpdate = true) { + const mergedOptions = getMergedProps(this.options, options); + const {title, footer, path, url, size} = mergedOptions; + + this.title = title; + this.url = url; + this.path = path; + this.size = size; + + this.setFooterPrimaryButton(footer ? footer.buttons.primary : undefined, () => { + this.dispatch(Action.UPDATE); + }); + + this.setFooterSecondaryButtons(footer ? footer.buttons.secondary : undefined, () => { + this.dispatch(Action.UPDATE); + }); + + if (shouldUpdate) { + this.dispatch(Action.UPDATE); + } + + return this; + } + + dispatch(action: Action) { + switch (action) { + case Action.OPEN: + this.app.dispatch(openModal(this.payload)); + break; + case Action.CLOSE: + this.close(); + break; + case Action.UPDATE: + this.app.dispatch(update(this.payload)); + break; + } + + return this; + } +} + +export const create = (app: ClientApplication, options: MessageOptions | IframeOptions) => { + if (isIframeModal(options)) { + return new ModalIframe(app, options); + } + + return new ModalMessage(app, options); +}; diff --git a/src/actions/Modal/index.ts b/src/actions/Modal/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/Modal/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/Modal/tests/actions.test.ts b/src/actions/Modal/tests/actions.test.ts new file mode 100644 index 0000000..d7a6b2d --- /dev/null +++ b/src/actions/Modal/tests/actions.test.ts @@ -0,0 +1,357 @@ +import {ClientApplication} from '../../../client'; +import {createMockApp} from 'test/helper'; +import * as ButtonHelper from '../../buttonHelper'; +import * as Helper from '../../helper'; +import {Action as ButtonAction, Button} from '../../Button'; +import { + clickFooterButton, + closeModal, + create, + openModal, + update, + Modal, + ModalIframe, + ModalMessage, +} from '../actions'; +import {Action, ActionType, IframePayload, MessagePayload} from '../types'; + +jest.mock('../../uuid', (fakeId = 'fakeId') => jest.fn().mockReturnValue(fakeId)); + +describe('Modal Actions', () => { + beforeEach(() => { + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('openModal returns expected action', () => { + const fakePayload: MessagePayload = { + message: 'Hi there!', + title: 'My Modal', + }; + const expectedAction = { + group: 'Modal', + payload: fakePayload, + type: 'APP::MODAL::OPEN', + }; + expect(openModal(fakePayload)).toEqual(expectedAction); + expect(Helper.actionWrapper).toHaveBeenCalledWith(expectedAction); + }); + + it('openModal with url returns expected action', () => { + const fakePayload: IframePayload = { + title: 'My Iframe Modal', + url: 'https://example.com', + }; + const expectedAction = { + group: 'Modal', + payload: fakePayload, + type: 'APP::MODAL::OPEN', + }; + expect(openModal(fakePayload)).toEqual(expectedAction); + expect(Helper.actionWrapper).toHaveBeenCalledWith(expectedAction); + }); + + it('closeModal returns expected action', () => { + const expectedId = 'Test123'; + const expectedAction = { + group: 'Modal', + payload: { + id: expectedId, + }, + type: 'APP::MODAL::CLOSE', + }; + expect(closeModal({id: expectedId})).toEqual(expectedAction); + expect(Helper.actionWrapper).toHaveBeenCalledWith(expectedAction); + }); + + it('clickFooterButton returns expected action', () => { + const expectedId = 'Test123'; + const expectedPayload = {id: expectedId, payload: {data: 'some data'}}; + const expectedAction = { + group: 'Modal', + payload: expectedPayload, + type: 'APP::MODAL::FOOTER::BUTTON::CLICK', + }; + expect(clickFooterButton(expectedId, {data: 'some data'})).toEqual(expectedAction); + expect(Helper.actionWrapper).toHaveBeenCalledWith(expectedAction); + }); +}); + +describe('Modal footer', () => { + let app; + const defaultOptions = {title: 'My Message Modal', message: 'My content'}; + + beforeEach(() => { + app = createMockApp(); + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("sets button existing subscriptions to modal's footer's namespace on initialization", () => { + const buttonOptions = {label: 'Test'}; + const button = new Button(app, buttonOptions); + const buttonPayload = { + message: 'hi', + }; + + button.subscribe(ButtonAction.CLICK, jest.fn()); + + button.dispatch(ButtonAction.CLICK, buttonPayload); + // Should use default button namespace + const buttonAction = { + group: 'Button', + payload: { + id: button.id, + payload: buttonPayload, + }, + type: 'APP::BUTTON::CLICK', + }; + expect(app.dispatch).toHaveBeenCalledWith(buttonAction); + + const options = { + ...defaultOptions, + footer: { + buttons: {primary: button}, + }, + }; + const modal = new ModalMessage(app, options); + + button.dispatch(ButtonAction.CLICK, buttonPayload); + + // Should use parent namespace + const footerButtonAction = { + group: 'Modal', + payload: { + id: button.id, + payload: buttonPayload, + }, + type: 'APP::MODAL::FOOTER::BUTTON::CLICK', + }; + expect(app.dispatch).toHaveBeenCalledWith(footerButtonAction); + }); + + it("sets subsequent button subscriptions to modal's footer's namespace", () => { + const buttonOptions = {label: 'Test'}; + const button = new Button(app, buttonOptions); + const buttonPayload = { + message: 'hi', + }; + + const options = { + ...defaultOptions, + footer: { + buttons: {primary: button}, + }, + }; + const modal = new ModalMessage(app, options); + button.subscribe(ButtonAction.CLICK, jest.fn()); + button.dispatch(ButtonAction.CLICK, buttonPayload); + + // Should use parent namespace + const footerButtonAction = { + group: 'Modal', + payload: { + id: button.id, + payload: buttonPayload, + }, + type: 'APP::MODAL::FOOTER::BUTTON::CLICK', + }; + expect(app.dispatch).toHaveBeenCalledWith(footerButtonAction); + }); + + it('resets button subscription when unsubscribe is called with unsubscribeChildren = `false`', () => { + const buttonOptions = {label: 'Test'}; + const button = new Button(app, buttonOptions); + const buttonPayload = { + message: 'hi', + }; + button.subscribe(ButtonAction.CLICK, jest.fn()); + + const options = { + ...defaultOptions, + footer: { + buttons: {primary: button}, + }, + }; + const modal = new ModalMessage(app, options); + modal.subscribe(Action.OPEN, jest.fn()); + + button.dispatch(ButtonAction.CLICK, buttonPayload); + + // Should use parent namespace + const footerButtonAction = { + group: 'Modal', + payload: { + id: button.id, + payload: buttonPayload, + }, + type: 'APP::MODAL::FOOTER::BUTTON::CLICK', + }; + expect(app.dispatch).toHaveBeenCalledWith(footerButtonAction); + + modal.unsubscribe(false); + + button.dispatch(ButtonAction.CLICK, buttonPayload); + + // Should use default button namespace + const buttonAction = { + group: 'Button', + payload: { + id: button.id, + payload: buttonPayload, + }, + type: 'APP::BUTTON::CLICK', + }; + expect(app.dispatch).toHaveBeenCalledWith(buttonAction); + }); + + it("unsubcribes all subscriptions after modal's unsubscribe is called with unsubscribeChildren = `true`", () => { + const unsubscribeStub = jest.fn(); + app.subscribe = () => unsubscribeStub; + + const buttonOptions = {label: 'Test'}; + + const buttonPayload = { + message: 'hi', + }; + + const button = new Button(app, buttonOptions); + + const options = { + ...defaultOptions, + footer: { + buttons: {primary: button}, + }, + }; + const modal = new ModalMessage(app, options); + button.subscribe(ButtonAction.CLICK, jest.fn()); + modal.subscribe(Action.OPEN, jest.fn()); + + unsubscribeStub.mockClear(); + modal.unsubscribe(true); + // Should be called three times - once for modal click, once for modal update (when child button changes), and once for child button + expect(unsubscribeStub.mock.calls.length).toBe(3); + }); + + it('setFooterPrimaryButton should call getSingleButton for primary button and set footer primary button as expected', () => { + const button = new Button(app, {label: 'Primary'}); + const modal = new ModalMessage(app, defaultOptions); + + const updatePrimaryFooterButtonSpy = jest.spyOn(modal, 'updatePrimaryFooterButton'); + const dispatchSpy = jest.spyOn(modal, 'dispatch').mockImplementation(jest.fn()); + const getSingleButtonSpy = jest.spyOn(ButtonHelper, 'getSingleButton'); + + modal.set({ + footer: { + buttons: { + primary: button, + }, + }, + }); + + expect(getSingleButtonSpy).toHaveBeenCalledTimes(1); + + expect(getSingleButtonSpy).toHaveBeenCalledWith( + modal, + button, + ['Footer'], + expect.any(Function), + ); + + const updateHandler = getSingleButtonSpy.mock.calls[0][3]; + + const updatePayload = {id: button.id, label: 'New label'}; + updateHandler(updatePayload); + expect(updatePrimaryFooterButtonSpy).toHaveBeenCalledWith(updatePayload, expect.any(Function)); + + expect(dispatchSpy).toHaveBeenCalledWith(Action.UPDATE); + }); + + it('setFooterPrimaryButton should call getUpdatedChildActions with expected params', () => { + const button = new Button(app, {label: 'Button 1'}); + const modal = new ModalMessage(app, defaultOptions); + + const getUpdatedChildActionsSpy = jest.spyOn(modal, 'getUpdatedChildActions'); + const dispatchSpy = jest.spyOn(modal, 'dispatch').mockImplementation(jest.fn()); + + const newOptions = { + footer: { + buttons: { + primary: button, + }, + }, + }; + + modal.set(newOptions); + + expect(getUpdatedChildActionsSpy).toHaveBeenCalledWith([newOptions.footer.buttons.primary], []); + }); + + it('setFooterSecondaryButtons should call getSingleButton for each secondary button', () => { + const button1 = new Button(app, {label: 'Button 1'}); + const button2 = new Button(app, {label: 'Button 2'}); + const modal = new ModalMessage(app, defaultOptions); + + const getSingleButtonSpy = jest.spyOn(ButtonHelper, 'getSingleButton'); + const updateSecondaryFooterButtonSpy = jest.spyOn(modal, 'updateSecondaryFooterButton'); + const dispatchSpy = jest.spyOn(modal, 'dispatch').mockImplementation(jest.fn()); + + modal.set({ + footer: { + buttons: { + secondary: [button1, button2], + }, + }, + }); + + expect(getSingleButtonSpy).toHaveBeenCalledTimes(2); + + expect(getSingleButtonSpy).toHaveBeenCalledWith( + modal, + button1, + ['Footer'], + expect.any(Function), + ); + + expect(getSingleButtonSpy).toHaveBeenCalledWith( + modal, + button2, + ['Footer'], + expect.any(Function), + ); + + const updateHandler = getSingleButtonSpy.mock.calls[0][3]; + + const updatePayload = {id: button1.id, label: 'New label'}; + updateHandler(updatePayload); + expect(updateSecondaryFooterButtonSpy).toHaveBeenCalledWith( + updatePayload, + expect.any(Function), + ); + + expect(dispatchSpy).toHaveBeenCalledWith(Action.UPDATE); + }); + + it('setFooterSecondaryButtons should call getUpdatedChildActions with expected params', () => { + const button1 = new Button(app, {label: 'Button 1'}); + const button2 = new Button(app, {label: 'Button 2'}); + const modal = new ModalMessage(app, defaultOptions); + + const getUpdatedChildActionsSpy = jest.spyOn(modal, 'getUpdatedChildActions'); + const dispatchSpy = jest.spyOn(modal, 'dispatch').mockImplementation(jest.fn()); + + const newOptions = { + footer: { + buttons: { + secondary: [button1, button2], + }, + }, + }; + + modal.set(newOptions); + + expect(getUpdatedChildActionsSpy).toHaveBeenCalledWith(newOptions.footer.buttons.secondary, []); + }); +}); diff --git a/src/actions/Modal/types.ts b/src/actions/Modal/types.ts new file mode 100644 index 0000000..d41f49c --- /dev/null +++ b/src/actions/Modal/types.ts @@ -0,0 +1,68 @@ +/** + * @module Modal + */ + +import {Button, Payload as ButtonPayload} from '../Button'; + +export enum Action { + OPEN = 'OPEN', + CLOSE = 'CLOSE', + UPDATE = 'UPDATE', +} + +export enum ActionType { + OPEN = 'APP::MODAL::OPEN', + CLOSE = 'APP::MODAL::CLOSE', + UPDATE = 'APP::MODAL::UPDATE', + FOOTER_BUTTON_CLICK = 'APP::MODAL::FOOTER::BUTTON::CLICK', + FOOTER_BUTTON_UPDATE = 'APP::MODAL::FOOTER::BUTTON::UPDATE', +} + +export enum Size { + Small = 'small', + Medium = 'medium', + Large = 'large', + Full = 'full', +} + +export interface BasePayload { + readonly id?: string; + size?: Size; + title?: string; + footer?: Footer; +} + +export interface MessagePayload extends BasePayload { + message: string; +} + +export interface IframePayload extends BasePayload { + url?: string; + path?: string; +} + +export interface ClosePayload { + readonly id?: string; +} + +export interface FooterOptions { + buttons: { + primary?: Button; + secondary?: Button[]; + }; +} + +export interface Footer { + buttons: { + primary?: ButtonPayload; + secondary?: ButtonPayload[]; + }; +} + +export interface MessageOptions extends MessagePayload { + footer?: FooterOptions; +} + +export interface IframeOptions extends IframePayload { + footer?: FooterOptions; +} diff --git a/src/actions/Navigation/History/README.md b/src/actions/Navigation/History/README.md new file mode 100644 index 0000000..936b59d --- /dev/null +++ b/src/actions/Navigation/History/README.md @@ -0,0 +1,66 @@ +# History + +## Setup + +Create an app and import the `History` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {History} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); + +const history = History.create(app); +``` + +## Push a new history entry containing the relative app path + +The path is relative to the app origin and must be prefixed with a slash. Adding a history entry does not redirect the app: + +```js +// Pushes {appOrigin}/settings to the history +history.dispatch(History.Action.PUSH, '/settings'); +``` + +## Replace the current history entry with the relative app path + +The path is relative to the app origin and must be prefixed with a slash. Replacing the history entry does not redirect the app: + +```js +// Replaces the current history url with {appOrigin}/settings +history.dispatch(History.Action.REPLACE, '/settings'); +``` + +## Subscribe to actions + +You can subscribe to actions dispatched through the specific history action set: + +```js +history.subscribe(History.Action.REPLACE, (payload: History.Payload) => { + // Do something with the history replace action + console.log(`Updated the history entry to path: ${payload.path}`); +}); + +history.subscribe(History.Action.PUSH, (payload: History.Payload) => { + // Do something with the history push action + console.log(`Added a history entry with the path: ${payload.path}`); +}); +``` + +## Subscribe to all history actions + +You can subscribe to all history actions, regardless of which action sets trigger the actions: + +```js +app.subscribe(History.Action.REPLACE, (payload: History.Payload) => { + // Do something with the history replace action + console.log(`Updated the history entry to path: ${payload.path}`); +}); + +app.subscribe(History.Action.PUSH, (payload: History.Payload) => { + // Do something with the history push action + console.log(`Added a history entry with the path: ${payload.path}`); +}); +``` diff --git a/src/actions/Navigation/History/actions.ts b/src/actions/Navigation/History/actions.ts new file mode 100644 index 0000000..54ec928 --- /dev/null +++ b/src/actions/Navigation/History/actions.ts @@ -0,0 +1,56 @@ +/** + * @module History + */ +import {ClientApplication} from '../../../client'; +import {actionWrapper, ActionSet} from '../../helper'; +import {ComplexDispatch, Group, MetaAction} from '../../types'; +import {Action, ActionType, Payload} from './types'; + +export interface HistoryAction extends MetaAction { + payload: Payload; +} + +export function push(payload: Payload): HistoryAction { + return actionWrapper({ + payload, + group: Group.Navigation, + type: ActionType.PUSH, + }); +} + +export function replace(payload: Payload): HistoryAction { + return actionWrapper({ + payload, + group: Group.Navigation, + type: ActionType.REPLACE, + }); +} + +export class History extends ActionSet implements ComplexDispatch { + constructor(app: ClientApplication) { + super(app, 'History', Group.Navigation); + } + + get payload() { + return {id: this.id}; + } + + dispatch(type: Action, path: string) { + const payload = {...this.payload, path}; + switch (type) { + case Action.PUSH: + this.app.dispatch(push(payload)); + break; + + case Action.REPLACE: + this.app.dispatch(replace(payload)); + break; + } + + return this; + } +} + +export function create(app: ClientApplication) { + return new History(app); +} diff --git a/src/actions/Navigation/History/index.ts b/src/actions/Navigation/History/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/Navigation/History/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/Navigation/History/tests/actions.test.ts b/src/actions/Navigation/History/tests/actions.test.ts new file mode 100644 index 0000000..2989aad --- /dev/null +++ b/src/actions/Navigation/History/tests/actions.test.ts @@ -0,0 +1,73 @@ +import {createMockApp} from 'test/helper'; +import * as Helper from '../../../helper'; +import {create, push, replace, History} from '../actions'; +import {Action} from '../types'; + +describe('History Actions', () => { + beforeEach(() => { + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('dispatches push', () => { + const payload = { + path: '/settings', + }; + const expectedAction = { + payload, + group: 'Navigation', + type: 'APP::NAVIGATION::HISTORY::PUSH', + }; + expect(push(payload)).toEqual(expectedAction); + }); + + it('dispatches replace', () => { + const payload = { + path: '/settings', + }; + const expectedAction = { + payload, + group: 'Navigation', + type: 'APP::NAVIGATION::HISTORY::REPLACE', + }; + expect(replace(payload)).toEqual(expectedAction); + }); +}); + +describe('History', () => { + let app; + beforeEach(() => { + app = createMockApp(); + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('dispatches push action on push', () => { + const history = new History(app); + const expectedAction = { + group: 'Navigation', + payload: {id: history.id, path: '/settings'}, + type: 'APP::NAVIGATION::HISTORY::PUSH', + }; + history.dispatch(Action.PUSH, '/settings'); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatches push action on push', () => { + const history = new History(app); + const expectedAction = { + group: 'Navigation', + payload: {id: history.id, path: '/settings'}, + type: 'APP::NAVIGATION::HISTORY::REPLACE', + }; + history.dispatch(Action.REPLACE, '/settings'); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('create generates a new History instance', () => { + const obj = create(app); + expect(obj instanceof History).toBe(true); + expect(obj.group).toEqual('Navigation'); + expect(obj.type).toEqual('History'); + }); +}); diff --git a/src/actions/Navigation/History/types.ts b/src/actions/Navigation/History/types.ts new file mode 100644 index 0000000..03eb4d2 --- /dev/null +++ b/src/actions/Navigation/History/types.ts @@ -0,0 +1,18 @@ +/** + * @module History + */ + +export enum ActionType { + PUSH = 'APP::NAVIGATION::HISTORY::PUSH', + REPLACE = 'APP::NAVIGATION::HISTORY::REPLACE', +} + +export enum Action { + PUSH = 'PUSH', + REPLACE = 'REPLACE', +} + +export interface Payload { + id?: string; + path: string; +} diff --git a/src/actions/Navigation/Redirect/README.md b/src/actions/Navigation/Redirect/README.md new file mode 100644 index 0000000..5014902 --- /dev/null +++ b/src/actions/Navigation/Redirect/README.md @@ -0,0 +1,156 @@ +# Redirect + +## Setup + +Create an app and import the `Redirect` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {Redirect} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); + +const redirect = Redirect.create(app); +``` + +## Redirect to a relative path in the app + +Redirect to a local app path. The path must be prefixed with a slash and is treated as relative to the app origin: + +```js +// Go to {appOrigin}/settings +redirect.dispatch(Redirect.Action.APP, '/settings'); +``` + +## Redirect to an absolute URL outside of the app and outside of Shopify admin. + +```js +// Go to http://example.com +redirect.dispatch(Redirect.Action.REMOTE, 'http://example.com'); + +// Go to http://example.com with newContext +redirect.dispatch(Redirect.Action.REMOTE, { + url: 'http://example.com', + newContext: true, +}); +``` + +## Redirect to a relative path in Shopify admin + +Redirect to the customers section in Shopify admin. The path must be prefixed with a slash. + +```js +// Go to {shopUrl}/admin/customers +redirect.dispatch(Redirect.Action.ADMIN_PATH, '/customers'); + +// Go to {shopUrl}/admin/customers with newContext +redirect.dispatch(Redirect.Action.ADMIN_PATH, { + path: '/customers', + newContext: true, +}); +``` + +## Redirect to a named section in Shopify admin + +Redirect to the **Products** section in the Shopify admin: + +```js +// Go to {shopUrl}/admin/products +redirect.dispatch(Redirect.Action.ADMIN_SECTION, { + name: Redirect.ResourceType.Product, +}); + +// Go to {shopUrl}/admin/products with newContext +redirect.dispatch(Redirect.Action.ADMIN_SECTION, { + section: { + name: Redirect.ResourceType.Product, + }, + newContext: true, +}); +``` + +## Redirect to a specific resource in Shopify admin. + +Redirect to the collection with the ID `123` in the Shopify admin: + +```js +// Go to {shopUrl}/admin/collections/123 +redirect.dispatch(Redirect.Action.ADMIN_SECTION, { + name: Redirect.ResourceType.Collection, + resource: { + id: '123', + }, +}); +``` + +## Redirect to create a product in Shopify admin. + +Redirect to `{shopUrl}/admin/products/new` in the Shopify admin: + +```js +// Go to {shopUrl}/admin/products/new +redirect.dispatch(Redirect.Action.ADMIN_SECTION, { + name: Redirect.ResourceType.Product, + resource: { + create: true, + }, +}); +``` + +## Redirect to a product variant in Shopify admin. + +Redirect to the collection with the id '123' in Shopify admin: + +```js +// Go to {shopUrl}/admin/products/123/variant/456 +redirect.dispatch(Redirect.Action.ADMIN_SECTION, { + name: Redirect.ResourceType.Product, + resource: { + id: '123', + variant: { + id: '456', + }, + }, +}); +``` + +## Redirect to create a new product variant in Shopify admin. + +Redirect to `{shopUrl}/admin/products/123/variants/new` in the Shopify admin: + +```js +// Go to {shopUrl}/admin/products/123/variants/new +redirect.dispatch(Redirect.Action.ADMIN_SECTION, { + name: Redirect.ResourceType.Product, + resource: { + id: '123', + variant: { + create: true, + }, + }, +}); +``` + +## Subscribe to actions + +You can subscribe to actions dispatched through the redirect action set: + +```js +redirect.subscribe(Redirect.Action.APP, (payload: Redirect.AppPayload) => { + // Do something with the redirect + console.log(`Navigated to ${payload.path}`); +}); +``` + +## Subscribe to all redirect actions + +You can subscribe to all redirect actions, regardless of which action sets trigger the actions: + +```js +app.subscribe(Redirect.Action.APP, (payload: Redirect.AppPayload) => { + // Do something with the redirect + console.log(`Navigated to ${payload.path}`); +}); +``` diff --git a/src/actions/Navigation/Redirect/actions.ts b/src/actions/Navigation/Redirect/actions.ts new file mode 100644 index 0000000..c24a881 --- /dev/null +++ b/src/actions/Navigation/Redirect/actions.ts @@ -0,0 +1,181 @@ +/** + * @module Redirect + */ + +import {ClientApplication} from '../../../client'; +import {actionWrapper, ActionSet} from '../../helper'; +import {ComplexDispatch, Group, MetaAction} from '../../types'; +import { + Action, + ActionType, + AdminPathPayload, + AdminSectionPayload, + AppPayload, + CreateResource, + ProductVariantResource, + RemotePayload, + ResourceInfo, + Section, +} from './types'; + +export interface ActionBase extends MetaAction { + readonly group: typeof Group.Navigation; +} + +export interface AdminPathAction extends ActionBase { + readonly type: typeof ActionType.ADMIN_PATH; + readonly payload: AdminPathPayload; +} + +export interface AdminSectionAction extends ActionBase { + readonly type: typeof ActionType.ADMIN_SECTION; + readonly payload: AdminSectionPayload; +} + +export interface AppAction extends ActionBase { + readonly type: typeof ActionType.APP; + readonly payload: AdminPathPayload; +} + +export interface RemoteAction extends ActionBase { + readonly type: typeof ActionType.REMOTE; + readonly payload: RemotePayload; +} + +export type RedirectAction = + | AdminPathAction + | RemoteAction + | AppAction + | AdminSectionAction + | MetaAction; + +export function isResourcePayload(resource: ResourceInfo | object): resource is ResourceInfo { + // tslint:disable-next-line:no-boolean-literal-compare + return typeof (resource as ResourceInfo).id === 'string'; +} + +export function isCreateResourcePayload( + resource: CreateResource | object, +): resource is CreateResource { + // tslint:disable-next-line:no-boolean-literal-compare + return (resource as CreateResource).create === true; +} + +export function isProductVariantResourcePayload( + resource: ProductVariantResource | object, +): resource is ProductVariantResource { + const castResource = resource as ProductVariantResource; + + // tslint:disable-next-line:no-boolean-literal-compare + return castResource.id !== undefined && castResource.variant !== undefined; +} + +export function isProductVariantCreateResourcePayload( + resource: ProductVariantResource | object, +): resource is ProductVariantResource & {variant: CreateResource} { + if (!isProductVariantResourcePayload(resource)) { + return false; + } + + return isCreateResourcePayload(resource.variant); +} + +export function toAdminPath(payload: AdminPathPayload): AdminPathAction { + return actionWrapper({ + payload, + group: Group.Navigation, + type: ActionType.ADMIN_PATH, + }); +} + +export function toAdminSection(payload: AdminSectionPayload): AdminSectionAction { + return actionWrapper({ + payload, + group: Group.Navigation, + type: ActionType.ADMIN_SECTION, + }); +} + +export function toRemote(payload: RemotePayload): RemoteAction { + return actionWrapper({ + payload, + group: Group.Navigation, + type: ActionType.REMOTE, + }); +} + +export function toApp(payload: AppPayload): AppAction { + return actionWrapper({ + payload, + group: Group.Navigation, + type: ActionType.APP, + }); +} + +function isAdminPathPayload(payload: any): payload is AdminPathPayload { + return typeof payload === 'object' && payload.hasOwnProperty('path'); +} + +function isAdminSectionPayload(payload: any): payload is AdminSectionPayload { + return ( + typeof payload === 'object' && + typeof payload.section === 'object' && + payload.section.hasOwnProperty('name') + ); +} + +function isRemotePayload(payload: any): payload is RemotePayload { + return typeof payload === 'object' && payload.hasOwnProperty('url'); +} + +export class Redirect extends ActionSet implements ComplexDispatch
{ + constructor(app: ClientApplication) { + super(app, 'Redirect', Group.Navigation); + } + + get payload() { + return {id: this.id}; + } + + dispatch(action: Action.ADMIN_SECTION, payload: Section | AdminSectionPayload): ActionSet; + + dispatch(action: Action.ADMIN_PATH, payload: string | AdminPathPayload): ActionSet; + + dispatch(action: Action.REMOTE, payload: string | RemotePayload): ActionSet; + + dispatch(action: Action.APP, payload: string): ActionSet; + + dispatch( + action: Action, + payload: Section | string | AdminSectionPayload | AdminPathPayload | RemotePayload, + ) { + switch (action) { + case Action.ADMIN_PATH: + const adminPathPayload = isAdminPathPayload(payload) ? payload : {path: payload as string}; + this.app.dispatch(toAdminPath({...this.payload, ...adminPathPayload})); + break; + + case Action.ADMIN_SECTION: + const adminSectionPayload = isAdminSectionPayload(payload) + ? payload + : {section: payload as Section}; + this.app.dispatch(toAdminSection({...this.payload, ...adminSectionPayload})); + break; + + case Action.APP: + this.app.dispatch(toApp({...this.payload, path: payload as string})); + break; + + case Action.REMOTE: + const remotePayload = isRemotePayload(payload) ? payload : {url: payload as string}; + this.app.dispatch(toRemote({...this.payload, ...remotePayload})); + break; + } + + return this; + } +} + +export function create(app: ClientApplication) { + return new Redirect(app); +} diff --git a/src/actions/Navigation/Redirect/index.ts b/src/actions/Navigation/Redirect/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/Navigation/Redirect/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/Navigation/Redirect/tests/actions.test.ts b/src/actions/Navigation/Redirect/tests/actions.test.ts new file mode 100644 index 0000000..40ec48d --- /dev/null +++ b/src/actions/Navigation/Redirect/tests/actions.test.ts @@ -0,0 +1,300 @@ +import {createMockApp} from 'test/helper'; +import * as Helper from '../../../helper'; +import { + create, + isCreateResourcePayload, + isProductVariantCreateResourcePayload, + isProductVariantResourcePayload, + toAdminPath, + toAdminSection, + toApp, + toRemote, + Redirect, +} from '../actions'; +import {Action, ResourceType} from '../types'; + +describe('Redirect Actions', () => { + const specificResource = { + section: { + name: ResourceType.Collection, + resource: { + id: '123', + }, + }, + }; + + const createResource = { + section: { + name: ResourceType.Collection, + resource: { + create: true, + }, + }, + }; + + const productVariantResource = { + section: { + name: ResourceType.Product, + resource: { + id: '123', + variant: { + id: '456', + }, + }, + }, + }; + + const productVariantCreateResource = { + section: { + name: ResourceType.Product, + resource: { + id: '123', + variant: { + create: true, + }, + }, + }, + }; + + const adminSection = { + section: { + name: ResourceType.Collection, + }, + }; + + beforeEach(() => { + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('dispatches redirect to remote url', () => { + const payload = { + url: 'http://example.com', + }; + const expectedAction = { + payload, + group: 'Navigation', + type: 'APP::NAVIGATION::REDIRECT::REMOTE', + }; + expect(toRemote(payload)).toEqual(expectedAction); + }); + + it('dispatches redirect to app path', () => { + const payload = { + path: '/settings', + }; + const expectedAction = { + payload, + group: 'Navigation', + type: 'APP::NAVIGATION::REDIRECT::APP', + }; + expect(toApp(payload)).toEqual(expectedAction); + }); + + it('dispatches redirect to admin path', () => { + const payload = { + path: '/products', + }; + const expectedAction = { + payload, + group: 'Navigation', + type: 'APP::NAVIGATION::REDIRECT::ADMIN::PATH', + }; + expect(toAdminPath(payload)).toEqual(expectedAction); + }); + + it('dispatches redirect to admin section', () => { + const expectedAction = { + group: 'Navigation', + payload: adminSection, + type: 'APP::NAVIGATION::REDIRECT::ADMIN::SECTION', + }; + expect(toAdminSection(adminSection)).toEqual(expectedAction); + }); + + it('isProductVariantResourcePayload returns correct value', () => { + const tests = [ + { + input: specificResource.section.resource, + result: false, + }, + { + input: createResource.section.resource, + result: false, + }, + { + input: productVariantResource.section.resource, + result: true, + }, + { + input: productVariantCreateResource.section.resource, + result: true, + }, + ]; + + tests.forEach(test => { + const {input, result} = test; + expect(isProductVariantResourcePayload(input)).toBe(result); + }); + }); + + it('isCreateResourcePayload returns correct value', () => { + const tests = [ + { + input: specificResource.section.resource, + result: false, + }, + { + input: createResource.section.resource, + result: true, + }, + { + input: productVariantResource.section.resource, + result: false, + }, + { + input: productVariantCreateResource.section.resource, + result: false, + }, + ]; + + tests.forEach(test => { + const {input, result} = test; + expect(isCreateResourcePayload(input)).toBe(result); + }); + }); + + it('isProductVariantCreateResourcePayload returns correct value', () => { + const tests = [ + { + input: specificResource.section.resource, + result: false, + }, + { + input: createResource.section.resource, + result: false, + }, + { + input: productVariantResource.section.resource, + result: false, + }, + { + input: productVariantCreateResource.section.resource, + result: true, + }, + ]; + + tests.forEach(test => { + const {input, result} = test; + expect(isProductVariantCreateResourcePayload(input)).toBe(result); + }); + }); +}); + +describe('Redirect', () => { + let app; + beforeEach(() => { + app = createMockApp(); + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('dispatches app action', () => { + const redirect = new Redirect(app); + const path = '/settings'; + const expectedAction = { + group: 'Navigation', + payload: {id: redirect.id, path}, + type: 'APP::NAVIGATION::REDIRECT::APP', + }; + redirect.dispatch(Action.APP, path); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatches admin path action', () => { + const redirect = new Redirect(app); + const path = '/apps'; + const expectedAction = { + group: 'Navigation', + payload: {id: redirect.id, path}, + type: 'APP::NAVIGATION::REDIRECT::ADMIN::PATH', + }; + redirect.dispatch(Action.ADMIN_PATH, path); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatches admin path action with newContext', () => { + const redirect = new Redirect(app); + const path = '/apps'; + const newContext = true; + const expectedAction = { + group: 'Navigation', + payload: {id: redirect.id, path, newContext}, + type: 'APP::NAVIGATION::REDIRECT::ADMIN::PATH', + }; + redirect.dispatch(Action.ADMIN_PATH, {newContext, path}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatches admin section action', () => { + const redirect = new Redirect(app); + const section = {name: ResourceType.Product}; + const expectedAction = { + group: 'Navigation', + payload: {id: redirect.id, section}, + type: 'APP::NAVIGATION::REDIRECT::ADMIN::SECTION', + }; + redirect.dispatch(Action.ADMIN_SECTION, section); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatches admin section action with newContext', () => { + const redirect = new Redirect(app); + const section = {name: ResourceType.Product}; + const newContext = true; + const expectedAction = { + group: 'Navigation', + payload: {id: redirect.id, section, newContext}, + type: 'APP::NAVIGATION::REDIRECT::ADMIN::SECTION', + }; + redirect.dispatch(Action.ADMIN_SECTION, {section, newContext}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatches remote action', () => { + const redirect = new Redirect(app); + const url = '/settings'; + const expectedAction = { + group: 'Navigation', + payload: {id: redirect.id, url}, + type: 'APP::NAVIGATION::REDIRECT::REMOTE', + }; + redirect.dispatch(Action.REMOTE, url); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatches remote action with newContext', () => { + const redirect = new Redirect(app); + const url = '/settings'; + const newContext = true; + const expectedAction = { + group: 'Navigation', + payload: {id: redirect.id, url, newContext}, + type: 'APP::NAVIGATION::REDIRECT::REMOTE', + }; + redirect.dispatch(Action.REMOTE, {url, newContext}); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('create generates a new Redirect instance', () => { + const obj = create(app); + expect(obj instanceof Redirect).toBe(true); + expect(obj.group).toEqual('Navigation'); + expect(obj.type).toEqual('Redirect'); + }); +}); diff --git a/src/actions/Navigation/Redirect/types.ts b/src/actions/Navigation/Redirect/types.ts new file mode 100644 index 0000000..e8c24ad --- /dev/null +++ b/src/actions/Navigation/Redirect/types.ts @@ -0,0 +1,62 @@ +export interface BasePayload { + id?: string; +} + +export interface BaseAdminPayload extends BasePayload { + newContext?: boolean; +} + +export interface AppPayload extends BasePayload { + path: string; +} + +export interface AdminPathPayload extends BaseAdminPayload { + path: string; +} + +export interface RemotePayload extends BaseAdminPayload { + url: string; +} + +export interface CreateResource { + create: boolean; +} + +export interface ResourceInfo { + id: string; +} + +export interface Section { + name: ResourceType; + resource?: CreateResource | ResourceInfo | ProductVariantResource; +} + +export interface AdminSectionPayload extends BaseAdminPayload { + section: Section; +} + +export interface ProductVariantResource extends ResourceInfo { + variant: CreateResource | ResourceInfo; +} + +export enum Action { + ADMIN_PATH = 'ADMIN::PATH', + ADMIN_SECTION = 'ADMIN::SECTION', + REMOTE = 'REMOTE', + APP = 'APP', +} + +export enum ActionType { + ADMIN_SECTION = 'APP::NAVIGATION::REDIRECT::ADMIN::SECTION', + ADMIN_PATH = 'APP::NAVIGATION::REDIRECT::ADMIN::PATH', + REMOTE = 'APP::NAVIGATION::REDIRECT::REMOTE', + APP = 'APP::NAVIGATION::REDIRECT::APP', +} + +export enum ResourceType { + Product = 'products', + Collection = 'collections', + Order = 'orders', + Customer = 'customers', + Discount = 'discounts', +} diff --git a/src/actions/Print/README.md b/src/actions/Print/README.md new file mode 100644 index 0000000..c2718d8 --- /dev/null +++ b/src/actions/Print/README.md @@ -0,0 +1,22 @@ +# Print + +## Setup + +Create an app and import the `Print` module from `@shopify/easdk/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/easdk'; +import {Print} from '@shopify/easdk/actions'; + +const app = createApp({ + apiKey: '12345', +}); +``` + +## Print app + +Open the print dialog with the content of the app: + +```js +app.dispatch(Print.app()); +``` diff --git a/src/actions/Print/actions.ts b/src/actions/Print/actions.ts new file mode 100644 index 0000000..d7dce9c --- /dev/null +++ b/src/actions/Print/actions.ts @@ -0,0 +1,16 @@ +/** + * @module Print + */ + +import {actionWrapper} from '../helper'; +import {Group, MetaAction} from '../types'; +import {ActionType} from './types'; + +export type PrintAction = MetaAction; + +export function app(): PrintAction { + return actionWrapper({ + group: Group.Print, + type: ActionType.APP, + }); +} diff --git a/src/actions/Print/index.ts b/src/actions/Print/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/Print/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/Print/tests/actions.test.ts b/src/actions/Print/tests/actions.test.ts new file mode 100644 index 0000000..62aaab2 --- /dev/null +++ b/src/actions/Print/tests/actions.test.ts @@ -0,0 +1,16 @@ +import * as Helper from '../../helper'; +import {app} from '../actions'; + +describe('Print', () => { + beforeEach(() => { + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('dispatches app', () => { + const expectedAction = { + group: 'Print', + type: 'APP::PRINT::APP', + }; + expect(app()).toEqual(expectedAction); + }); +}); diff --git a/src/actions/Print/types.ts b/src/actions/Print/types.ts new file mode 100644 index 0000000..1a8a745 --- /dev/null +++ b/src/actions/Print/types.ts @@ -0,0 +1,11 @@ +/** + * @module Print + */ + +export enum ActionType { + APP = 'APP::PRINT::APP', +} + +export enum Action { + APP = 'APP', +} diff --git a/src/actions/README.md b/src/actions/README.md new file mode 100644 index 0000000..eb3f2df --- /dev/null +++ b/src/actions/README.md @@ -0,0 +1,333 @@ +# Actions + +Shopify App Bridge introduces a new concept of actions. An action provides a way for applications and hosts to trigger events with a statically-typed payload. The following actions are currently supported: + +* [Button](./Button) +* [ButtonGroup](./ButtonGroup) +* [Cart](./Cart) +* [Error](./Error) +* [Flash](./Flash) +* [Loading](./Loading) +* [Modal](./Modal) +* [Navigation - History](./Navigation/History) +* [Navigation - Redirect](./Navigation/Redirect) +* [ResourcePicker](./ResourcePicker) +* [TitleBar](./TitleBar) +* [Toast](./Toast) + +## Simple actions + +Simple actions can be dispatched by both hosts and apps. Hosts can subscribe to actions through the App Bridge middleware. Here is an example of a simple action: + +### In an app + +An app can dispatch actions: + +```ts +import createApp from '@shopify/app-bridge'; +import {Redirect} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); + +// App dispatches a remote redirect action +app.dispatch( + Redirect.toRemote({ + url: 'http://example.com', + }), +); +``` + +### In a host using a Redux store + +A Redux store can dispatch and listen to actions: + +```ts +import {Redirect} from '@shopify/app-bridge/actions'; +import {createStore, AnyAction} from 'redux'; + +interface AppStore { + title: string; +} + +function app(state: AppStore, action: Redirect.RemoteAction | AnyAction): AppStore { + switch (action.type) { + case Redirect.ActionType.REMOTE: + const payload = (action as Redirect.RemoteAction).payload; + //Set the window.location.href to the URL in the payload + window.location.href = payload.url; + + return state; + default: + return state; + } +} + +const store = createStore(app); + +// Dispatch the remote redirect action +store.dispatch( + Redirect.toRemote({ + url: 'http://example.com', + }), +); +``` + +## Action sets + +Action sets are groups of simple actions that are created and used only by apps. They are generated with a unique ID, and provide the additional capability for apps to subscribe directly to them. They can be thought of as a persisted set of actions that can be dispatched or subscribed to at any time. + +The following examples show the [toast](./src/actions/Toast) action set in an app and in a host using a Redux store. + +### In an app + +```ts +import createApp from '@shopify/app-bridge'; +import {Toast} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); + +const toastOptions = { + message: 'Product saved', + duration: 5000, +}; + +const toastNotice = Toast.create(app, toastOptions); +toastNotice.subscribe(Toast.Action.SHOW, data => { + // Do something with the show action +}); + +toastNotice.subscribe(Toast.Action.CLEAR, data => { + // Do something with the clear action +}); + +// Dispatch the show toast action, using the toastOptions above +toastNotice.dispatch(Toast.Action.SHOW); +``` + +### In a host using a Redux store + +Hosts can dispatch actions that are tied to a specific instance by including the unique ID: + +```ts +import {Toast} from '@shopify/app-bridge/actions'; +import { createStore, AnyAction } from 'redux' + +interface ToastMessage { + id: string; + content: string; +} +interface AppStore { + toastMessage?: ToastMessage; +} + +function app(state: AppStore, action: Redirect.RemoteAction | AnyAction, +): AppStore { + switch (action.type) { + case Toast.ActionType.SHOW: { + const payload = (action as Toast.ToastAction).payload; + const {id, content} = payload; + // Save the unique id of the Toast + return { + toastMessage: { + id, + content, + } + ...state, + }; + } + default: + return state; + } +} + +const store = createStore(app); + +// Get the current toast message in the store +const {toastMessage} = store.getState(); +// Dispatching a clear action on a specific instance of the toast action +store.dispatch( + Toast.clear({ + id: toastMessage.id, + }) +); +``` + +## Creating a action + +Shopify App Bridge actions are similar to Redux actions. They consist of the following: + +* a type +* a group +* a version +* a payload + +You can use the `actionWrapper` helper method to create actions that conform to the required structure. The following example shows how to create a brand new group of actions called `Card`: + +```ts +// All actions can be extended from the MetaAction interface +import {MetaAction} from '../types'; +import {actionWrapper} from '../helper'; + +// Create a new group for this action +const GROUP = 'Card'; + +// Specify the actions types available in this group +export enum ActionType { + SHOW = 'CARD::SHOW', + HIDE = 'CARD::HIDE', +} + +// Specify the actions available in this group +export enum Action { + SHOW = 'SHOW', + HIDE = 'HIDE', +} + +// Specify the props that can be given to this action +export interface CardOptions { + content: string; +} + +// Specify the props that will be sent as a payload when an action is triggered +export interface CardPayload extends CardOptions { + // id should always be sent + readonly id?: string; +} + +// Specify the props that will be sent as a payload when a hide action is triggered +export interface CardHidePayload { + // id should always be sent + readonly id?: string; +} + +// Define the show action interface +export interface ShowAction extends MetaAction { + readonly type: typeof ActionType.SHOW; + readonly payload: CardPayload; + readonly group: typeof GROUP; +} + +// Define the hide action interface +export interface HideAction extends MetaAction { + readonly type: typeof ActionType.CLEAR; + readonly payload: CardHidePayload; + readonly group: typeof GROUP; +} + +// Use the actionWrapper helper method to create a show action +export function show(payload: CardPayload): ShowAction { + /** Creates the following object: + { + group: 'Card', + payload: { + id: string, + content: string, + }, + type: 'CARD::SHOW', + version: '1.0.0' + } + * */ + return actionWrapper({ + payload, + group: GROUP, + type: ActionType.SHOW, + }); +} + +// Use the actionWrapper helper method to create a hide action +export function hide(payload: CardHidePayload): HideAction { + /** Creates the following object: + { + group: 'Card', + payload: { + id: string, + }, + type: 'CARD::HIDE', + version: '1.0.0' + } + * */ + return actionWrapper({ + payload, + group: GROUP, + type: ActionType.HIDE, + }); +} +``` + +## Creating a new action set + +Shopify App Bridge provides an `ActionSet` abstract class, which can be used to create new action sets. The abstract class handles the unique ID generation and logic for subscribing and unsubscribing. The action typically implements the following methods: + +* `get payload` +* `get options` +* `set` +* `dispatch` + +The following example shows a card action set: + +```ts +// Using the CardOptions and CardPayload interface above +export class CardAction extends ActionSet implements ActionSetProps { + content: string; + + constructor(app: ClientApplication, options: CardOptions) { + //Set the component type and group for this action + //The group and type can be set to the same value if this action should be the parent class + super(app, 'Card', 'Card'); + this.set(options); + } + + /** + * Called when getting the props of this action + * */ + get options(): CardOptions { + return { + content: this.content, + }; + } + + /** + * Called when getting the payload to be sent when an action is dispatched + * */ + get payload(): CardPayload { + return { + id: this.id, + ...this.options, + }; + } + /** + * Called when updating the properties of this action + * */ + set(options: CardPayload) { + // Calls a helper method to merge the new options with existing options + const mergedOptions = getMergedProps(this.options, options); + const {content} = mergedOptions; + + // Update options + this.content = content; + + return this; + } + /** + * Called when triggering actions + * */ + dispatch(action: Action) { + switch (action) { + // Using the card actions in the example above + case Action.SHOW: + this.app.dispatch(show(this.payload)); + break; + case Action.HIDE: + this.app.dispatch(hide({id: this.id})); + break; + default: + } + + return this; + } +} +``` diff --git a/src/actions/ResourcePicker/README.md b/src/actions/ResourcePicker/README.md new file mode 100644 index 0000000..ea73bae --- /dev/null +++ b/src/actions/ResourcePicker/README.md @@ -0,0 +1,168 @@ +# Resource Picker + +## Setup + +Create an app and import the `ResourcePicker` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {ResourcePicker} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); +``` + +## Create a product picker + +```js +const productPicker = ResourcePicker.create(app, { + resourceType: ResourcePicker.ResourceType.Product, +}); +``` + +## Create a product variant picker + +```js +const variantPicker = ResourcePicker.create(app, { + resourceType: ResourcePicker.ResourceType.ProductVariant, +}); +``` + +## Create a collection picker + +```js +const collectionPicker = ResourcePicker.create(app, { + resourceType: ResourcePicker.ResourceType.Collection, +}); +``` + +## Picker selection and cancellation + +Resource pickers have two main actions that you can subscribe to: + +* `ResourcePicker.Action.CANCEL` - This action is dispatched when the user cancels the resource picker without making a selection. +* `ResourcePicker.Action.SELECT` - This action is dispatched after the user makes a selection and confirms it. This action receives a `SelectPayload` argument, which is an `Object` with `id` and `selection` keys. The `selection` key is an array of all the selected resources. + +```js +const picker = ResourcePicker.create(app, { + resourceType: ResourcePicker.ResourceType.Product, +}); + +picker.subscribe(ResourcePicker.Action.SELECT, ({selection}) => { + // Do something with `selection` +}); + +picker.subscribe(ResourcePicker.Action.CANCEL, () => { + // Picker was cancelled +}); +``` + +## Options + +#### initialQuery + +- default value: `undefined` +- optional +- type: `string` + +#### selectMultiple + +- default value: `true` +- optional +- type: `boolean` + +#### showHidden + +- default value: `false` +- optional +- type: `boolean` + +#### showVariants + +- default value: `true` +- optional +- type: `string` +- note: Only applies to the `Product` resource type picker + +## Subscribe to actions + +You can subscribe to modal actions by calling `subscribe`. This returns a method that you can call to unsubscribe from the action: + +```js +const productPicker = ResourcePicker.create(app, { + resourceType: ResourcePicker.ResourceType.Product, + options: { + selectMultiple: true, + showHidden: false, + }, +}); + +const selectUnsubscribe = productPicker.subscribe(ResourcePicker.Action.SELECT, ({selection}) => { + // Do something with `selection` +}); + +const cancelUnsubscribe = productPicker.subscribe(ResourcePicker.Action.CANCEL, () => { + // Picker was cancelled +}); + +// Unsubscribe to actions +selectUnsubscribe(); +cancelUnsubscribe(); +``` + +## Unsubscribe + +You can call `unsubscribe` to remove all subscriptions on the resource picker: + +```js +const productPicker = ResourcePicker.create(app, { + resourceType: ResourcePicker.ResourceType.Product, + options: { + selectMultiple: true, + showHidden: false, + }, +}); + +productPicker.subscribe(ResourcePicker.Action.SELECT, () => { + // Do something with `selection` +}); + +productPicker.subscribe(ResourcePicker.Action.CANCEL, () => { + // Picker was cancelled +}); + +// Unsubscribe from all actions +productPicker.unsubscribe(); +``` + +## Dispatch actions + +```js +const collectionPicker = ResourcePicker.create(app, { + resourceType: ResourcePicker.ResourceType.Collection, + options: { + selectMultiple: true + showHidden: false, + } +}); + +// Open the collection picker +collectionPicker.dispatch(ResourcePicker.Action.OPEN); +``` + +## Update options + +You can call the `set` method with partial picker options. This automatically triggers the `update` action on the modal and merges the given options with the existing options: + +```js +const collectionPicker = ResourcePicker.create(app, { + resourceType: ResourcePicker.ResourceType.Collection, + options: { + selectMultiple: true, + showHidden: false, + }, +}); + +collectionPicker.set({showHidden: true}); +``` diff --git a/src/actions/ResourcePicker/actions.ts b/src/actions/ResourcePicker/actions.ts new file mode 100644 index 0000000..961a877 --- /dev/null +++ b/src/actions/ResourcePicker/actions.ts @@ -0,0 +1,200 @@ +/** + * @module ResourcePicker + */ + +import {ClientApplication} from '../../client'; + +import {actionWrapper, getMergedProps, ActionSet} from '../helper'; +import {ActionSetProps, Group, MetaAction} from '../types'; + +import { + Action, + ActionType, + BaseOptions, + CancelPayload, + ClosePayload, + Options, + Payload, + ProductOptions, + ResourceSelection, + ResourceType, + SelectPayload, +} from './types'; + +export interface ActionBase extends MetaAction { + readonly group: typeof Group.ResourcePicker; +} + +export interface SelectAction extends ActionBase { + readonly type: typeof ActionType.SELECT; + readonly payload: SelectPayload; +} + +export interface UpdateAction extends ActionBase { + readonly type: typeof ActionType.UPDATE; + readonly payload: Payload; +} + +export interface CancelAction extends ActionBase { + readonly type: typeof ActionType.CANCEL; +} + +export type CloseAction = CancelAction; + +export interface OpenAction extends ActionBase { + readonly type: typeof ActionType.OPEN; + readonly payload: Payload; +} + +export type ResourcePickerAction = + | SelectAction + | UpdateAction + | CancelAction + | OpenAction + | MetaAction; + +export function select(payload: SelectPayload): SelectAction { + return actionWrapper({ + payload, + group: Group.ResourcePicker, + type: ActionType.SELECT, + }); +} + +export function open(payload: Payload): OpenAction { + return actionWrapper({ + payload, + group: Group.ResourcePicker, + type: ActionType.OPEN, + }); +} + +export function cancel(payload: CancelPayload): CancelAction { + return actionWrapper({ + payload, + group: Group.ResourcePicker, + type: ActionType.CANCEL, + }); +} + +export function close(payload: ClosePayload): CloseAction { + return actionWrapper({ + payload, + group: Group.ResourcePicker, + type: ActionType.CANCEL, + }); +} + +export function update(payload: Payload): UpdateAction { + return actionWrapper({ + payload, + group: Group.ResourcePicker, + type: ActionType.UPDATE, + }); +} + +export class ResourcePicker extends ActionSet + implements ActionSetProps { + readonly resourceType!: ResourceType; + initialQuery?: string; + selectMultiple?: boolean; + selection: ResourceSelection[] = []; + showHidden?: boolean; + showVariants?: boolean; + + constructor( + app: ClientApplication, + options: Options | ProductOptions, + resourceType: ResourceType, + ) { + super(app, Group.ResourcePicker, Group.ResourcePicker); + + this.resourceType = resourceType; + this.set(options, false); + } + + get payload() { + return { + ...this.options, + id: this.id, + resourceType: this.resourceType, + }; + } + + get options() { + const options: Options = { + initialQuery: this.initialQuery, + selectMultiple: this.selectMultiple, + showHidden: this.showHidden, + }; + + if (this.resourceType === ResourceType.Product) { + const productOptions: ProductOptions = { + ...options, + showVariants: this.showVariants, + }; + + return productOptions; + } + + return options; + } + + set(options: Partial, shouldUpdate = true) { + const mergedOptions: ProductOptions = getMergedProps(this.options, options); + const { + initialQuery, + showHidden = false, + showVariants = true, + selectMultiple = true, + } = mergedOptions; + + this.initialQuery = initialQuery; + this.showHidden = Boolean(showHidden); + this.showVariants = Boolean(showVariants); + this.selectMultiple = Boolean(selectMultiple); + + if (shouldUpdate) { + this.update(); + } + + return this; + } + + dispatch(action: Action, selection?: ResourceSelection[]) { + if (action === Action.OPEN) { + this.open(); + } else if (action === Action.UPDATE) { + this.update(); + } else if (action === Action.CLOSE || action === Action.CANCEL) { + this.cancel(); + } else if (action === Action.SELECT) { + this.selection = selection as ResourceSelection[]; + this.app.dispatch(select({id: this.id, selection: this.selection})); + } + + return this; + } + + protected update() { + this.app.dispatch(update(this.payload)); + } + + protected open() { + this.app.dispatch(open(this.payload)); + } + + protected cancel() { + this.app.dispatch(cancel({id: this.id})); + } + + protected close() { + this.cancel(); + } +} + +export const create = (app: ClientApplication, baseOptions: BaseOptions) => { + const {resourceType, options = {}} = baseOptions; + + return new ResourcePicker(app, options, resourceType); +}; diff --git a/src/actions/ResourcePicker/index.ts b/src/actions/ResourcePicker/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/ResourcePicker/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/ResourcePicker/tests/actions.test.ts b/src/actions/ResourcePicker/tests/actions.test.ts new file mode 100644 index 0000000..4dace5f --- /dev/null +++ b/src/actions/ResourcePicker/tests/actions.test.ts @@ -0,0 +1,105 @@ +import {createMockApp} from 'test/helper'; +import * as Helper from '../../helper'; +import {cancel, close, open, select, update, ResourcePicker} from '../actions'; +import { + Action, + CancelPayload, + ClosePayload, + Payload, + Product, + ResourceType, + SelectPayload, +} from '../types'; + +describe('ResourcePicker Actions', () => { + beforeEach(() => { + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('open returns expected action', () => { + const fakePayload: Payload = { + id: 'Test1234', + resourceType: ResourceType.Product, + }; + const expectedAction = { + group: 'Resource_Picker', + payload: fakePayload, + type: 'APP::RESOURCE_PICKER::OPEN', + }; + expect(open(fakePayload)).toEqual(expectedAction); + expect(Helper.actionWrapper).toHaveBeenCalledWith(expectedAction); + }); + + it('cancel returns expected action', () => { + const fakePayload: CancelPayload = { + id: 'Test1234', + }; + const expectedAction = { + group: 'Resource_Picker', + payload: fakePayload, + type: 'APP::RESOURCE_PICKER::CANCEL', + }; + expect(cancel(fakePayload)).toEqual(expectedAction); + expect(Helper.actionWrapper).toHaveBeenCalledWith(expectedAction); + }); + + it('close returns a cancel action', () => { + const fakePayload: ClosePayload = { + id: 'Test1234', + }; + const expectedAction = { + group: 'Resource_Picker', + payload: fakePayload, + type: 'APP::RESOURCE_PICKER::CANCEL', + }; + expect(close(fakePayload)).toEqual(expectedAction); + expect(Helper.actionWrapper).toHaveBeenCalledWith(expectedAction); + }); + + it('update returns expected action', () => { + const fakePayload: Payload = { + resourceType: ResourceType.Product, + }; + const expectedAction = { + group: 'Resource_Picker', + payload: fakePayload, + type: 'APP::RESOURCE_PICKER::UPDATE', + }; + expect(update(fakePayload)).toEqual(expectedAction); + expect(Helper.actionWrapper).toHaveBeenCalledWith(expectedAction); + }); + + it('select returns expected action', () => { + const fakeProduct: Partial = { + id: 'productId', + variants: [ + { + id: 'variantId', + }, + ], + }; + const fakePayload: SelectPayload = { + id: 'Test1234', + selection: [fakeProduct as Product], + }; + const expectedAction = { + group: 'Resource_Picker', + payload: fakePayload, + type: 'APP::RESOURCE_PICKER::SELECT', + }; + expect(select(fakePayload)).toEqual(expectedAction); + expect(Helper.actionWrapper).toHaveBeenCalledWith(expectedAction); + }); + + describe('dispatch()', () => { + it('calls cancel() when it receives the close action', () => { + const app = createMockApp(); + const resourcePicker = new ResourcePicker(app, {}, ResourceType.Product); + const cancelSpy = jest.spyOn(resourcePicker as any, 'cancel'); + + resourcePicker.dispatch(Action.CLOSE); + + expect(cancelSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/actions/ResourcePicker/types.ts b/src/actions/ResourcePicker/types.ts new file mode 100644 index 0000000..6bb61f5 --- /dev/null +++ b/src/actions/ResourcePicker/types.ts @@ -0,0 +1,185 @@ +/** + * @module ResourcePicker + */ + +export enum Action { + OPEN = 'OPEN', + SELECT = 'SELECT', + CLOSE = 'CLOSE', // Deprecated in 0.5.0 + UPDATE = 'UPDATE', + CANCEL = 'CANCEL', +} + +export enum ActionType { + OPEN = 'APP::RESOURCE_PICKER::OPEN', + SELECT = 'APP::RESOURCE_PICKER::SELECT', + CLOSE = 'APP::RESOURCE_PICKER::CLOSE', // Deprecated in 0.5.0 + UPDATE = 'APP::RESOURCE_PICKER::UPDATE', + CANCEL = 'APP::RESOURCE_PICKER::CANCEL', +} + +export type Money = string; + +export enum CollectionSortOrder { + Manual = 'MANUAL', + BestSelling = 'BEST_SELLING', + AlphaAsc = 'ALPHA_ASC', + AlphaDesc = 'ALPHA_DESC', + PriceDesc = 'PRICE_DESC', + PriceAsc = 'PRICE_ASC', + CreatedDesc = 'CREATED_DESC', + Created = 'CREATED', +} + +export enum FulfillmentServiceType { + GiftCard = 'GIFT_CARD', + Manual = 'MANUAL', + ThirdParty = 'THIRD_PARTY', +} + +export enum WeightUnit { + Kilograms = 'KILOGRAMS', + Grams = 'GRAMS', + Pounds = 'POUNDS', + Ounces = 'OUNCES', +} + +export enum ProductVariantInventoryPolicy { + Deny = 'DENY', + Continue = 'CONTINUE', +} + +export enum ProductVariantInventoryManagement { + Shopify = 'SHOPIFY', + NotManaged = 'NOT_MANAGED', + FulfillmentService = 'FULFILLMENT_SERVICE', +} + +export interface Image { + id: string; + altText?: string; + originalSrc: string; +} + +export interface Resource { + id: string; + updatedAt: string; +} + +export interface Collection extends Resource { + availablePublicationCount: number; + description: string; + descriptionHtml: string; + handle: string; + id: string; + image?: Image | null; + productsAutomaticallySortedCount: number; + productsCount: number; + productsManuallySortedCount: number; + publicationCount: number; + seo: { + description?: string | null; + title?: string | null; + }; + sortOrder: CollectionSortOrder; + storefrontId: string; + templateSuffix?: string | null; + title: string; +} + +export interface ProductVariant extends Resource { + availableForSale: boolean; + barcode?: string | null; + compareAtPrice?: Money | null; + createdAt: string; + displayName: string; + fulfillmentService?: { + id: string; + inventoryManagement: boolean; + productBased: boolean; + serviceName: string; + type: FulfillmentServiceType; + }; + image?: Image | null; + inventoryItem: {id: string}; + inventoryManagement: ProductVariantInventoryManagement; + inventoryPolicy: ProductVariantInventoryPolicy; + inventoryQuantity?: number | null; + position: number; + price: Money; + product: Partial; + requiresShipping: boolean; + selectedOptions: {value?: string | null}[]; + sku?: string | null; + taxable: boolean; + title: string; + weight?: number | null; + weightUnit: WeightUnit; +} + +export interface Product extends Resource { + availablePublicationCount: number; + createdAt: string; + descriptionHtml: string; + handle: string; + hasOnlyDefaultVariant: boolean; + images: Image[]; + options: { + id: string; + name: string; + position: number; + values: string[]; + }[]; + productType: string; + publishedAt?: string | null; + tags: string[]; + templateSuffix?: string | null; + title: string; + totalInventory: number; + tracksInventory: boolean; + variants: Partial[]; + vendor: string; +} + +export interface CancelPayload { + readonly id?: string; +} + +export type ClosePayload = CancelPayload; + +export interface Payload { + readonly id?: string; + initialQuery?: string; + selectMultiple?: boolean; + showHidden?: boolean; + showVariants?: boolean; + resourceType: ResourceType; +} + +export type ResourceSelection = Product | ProductVariant | Collection; + +export interface SelectPayload { + readonly id?: string; + selection: ResourceSelection[]; +} + +export interface Options { + initialQuery?: string; + showHidden?: boolean; + selectMultiple?: boolean; +} + +export interface ProductOptions extends Options { + showVariants?: boolean; +} + +export interface BaseOptions { + resourceType: ResourceType; + options?: Options | ProductOptions; +} + +export enum ResourceType { + Product = 'product', + ProductVariant = 'variant', + Collection = 'collection', +} diff --git a/src/actions/TitleBar/README.md b/src/actions/TitleBar/README.md new file mode 100644 index 0000000..def3931 --- /dev/null +++ b/src/actions/TitleBar/README.md @@ -0,0 +1,244 @@ +# TitleBar + +## Setup + +Create an app and import the `TitleBar` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {TitleBar} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); +``` + +## Create a title bar + +Create a title bar with the title set to `My page title`: + +```js +const titleBarOptions = { + title: 'My page title', +}; + +const myTitleBar = TitleBar.create(app, titleBarOptions); +``` + +## Create a title bar with a primary button + +You can set the title bar's primary button to a button. To learn more about buttons, see [Button](../Button). + +```js +const saveButton = Button.create(app, {label: 'Save'}); +const titleBarOptions = { + title: 'My page title', + buttons: { + primary: saveButton, + }, +}; + +const myTitleBar = TitleBar.create(app, titleBarOptions); +``` + +## Create a title bar with secondary buttons + +You can set the title bar's secondary buttons to one or more buttons. The following example creates a secondary action with the label **Settings**, which triggers a redirect to a settings page local to the app. + +To learn more, see [Button](../Button) and [Redirect](../Redirect). + +```js +import {TitleBar, Button, Redirect} from '@shopify/app-bridge/actions'; +const settingsButton = Button.create(app, {label: 'Settings'}); +const redirect = Redirect.create(app); +settingsButton.subscribe('click', () => { + redirect.dispatch({ + type: Redirect.Action.APP, + payload: {path: '/settings'}, + }); +}); +const titleBarOptions = { + title: 'My new title', + buttons: { + secondary: [settingsButton], + }, +}; +const myTitleBar = TitleBar.create(app, titleBarOptions); +``` + +## Create a title bar with grouped secondary buttons + +You can set the title bar's secondary buttons to one or more button groups. The following example creates a grouped secondary action with the label **More actions**, which contains two child buttons. + +To learn more, see [ButtonGroup](../ButtonGroup) and [Button](../Button). + +```js +import {TitleBar, Button, ButtonGroup} from '@shopify/app-bridge/actions'; + +const button1 = Button.create(app, {label: 'Show toast message'}); +const button2 = Button.create(app, {label: 'Open modal'}); + +const moreActions = ButtonGroup.create(app, { + label: 'More actions', + buttons: [button1, button2], +}); + +const titleBarOptions = { + title: 'My new title', + buttons: { + secondary: [moreActions], + }, +}; +const myTitleBar = TitleBar.create(app, titleBarOptions); +``` + +## Update title bar options + +You can call the `set` method with partial title bar options to update the options of an existing title bar. This automatically triggers the `update` action on the title bar and merges the given options with the existing options: + +```js +const titleBarOptions = { + title: 'My page title', +}; + +const myTitleBar = TitleBar.create(app, titleBarOptions); +// Update the title + +myTitleBar.set({ + title: 'My new title', +}); +``` + +## Update title bar primary/secondary buttons + +You can update buttons attached to a title bar. Any updates made to the title bar's children automatically trigger an `update` action on the title bar: + +```js +import {TitleBar, Button, ButtonGroup} from '@shopify/app-bridge/actions'; + +const button1 = Button.create(app, {label: 'Show toast message'}); +const button2 = Button.create(app, {label: 'Open modal'}); + +const moreActions = ButtonGroup.create(app, { + label: 'More actions', + buttons: [button1, button2], +}); + +const titleBarOptions = { + title: 'My new title', + buttons: { + secondary: [moreActions], + }, +}; + +const myTitleBar = TitleBar.create(app, titleBarOptions); +// Update more button's label - changes automatically get propagated to the parent title bar +moreActions.set({ + label: 'Additional options', +}); +``` + +## Create a title bar with breadcrumbs + +You can enable breadcrumbs in the title bar by setting a button as the breadcrumb option. You can disable it by setting the option to `undefined`. **Note:** Breadcrumbs aren't shown without a title. The following example creates a breadcrumb with the label 'My Breadcrumb', which links to '/breadcrumb-link'. + +To learn more, see [Button](../Button) and [Redirect](../Redirect). + +```js +import {TitleBar, Button, Redirect} from '@shopify/app-bridge/actions'; + +const breadcrumb = Button.create(app, {label: 'My Breadcrumb'}); + +breadcrumb.subscribe(Button.Action.CLICK, () => { + app.dispatch(Redirect.toApp({path: '/breadcrumb-link'})); +}); + +const titleBarOptions = { + title: 'My new title', + breadcrumbs: breadcrumb, +}; + +const myTitleBar = TitleBar.create(app, titleBarOptions); +``` + +## Subscribe to title bar updates + +You can subscribe to the title bar update action by calling `subscribe`. This returns a method that you can call to unsubscribe from the action: + +```js +// Using the same title bar as above +const updateUnsubscribe = myTitleBar.subscribe(ButtonGroup.Action.UPDATE, data => { + // Do something when the button group is updated + // The data is in the following shape: {id: string, label: string, buttons: [{id: string, label: string, disabled: boolean} ...]} +}); + +// Unsubscribe +updateUnsubscribe(); +``` + +## Unsubscribe + +You call `unsubscribe` to remove all subscriptions on the titlebar and its children: + +```js +const settingsButton = Button.create(app, {label: 'Settings'}); +settingsButton.subscribe('click', () => { + redirect.dispatch({ + type: Redirect.Action.APP, + payload: {path: '/settings'}, + }); +}); +const titleBarOptions = { + title: 'My new title', + buttons: { + secondary: [settingsButton], + }, +}; +const myTitleBar = TitleBar.create(app, titleBarOptions); + +myTitleBar.subscribe(TitleBar.Action.UPDATE, data => { + // Do something with the udpate action +}); + +// Unsubscribe from the button group update action +// Unsubscribe from settingsButton click action +myTitleBar.unsubscribe(); +``` + +## Unsubscribe from titlebar actions only + +You call `unsubscribe` with `false` to remove only titlebar subscriptions while leaving child subscriptions intact. For example, you might want to unsubscribe from the title bar but keep button listeners so that the buttons can be reused in a different actions (such as a modal). + +```js +const settingsButton = Button.create(app, {label: 'Settings'}); +settingsButton.subscribe('click', () => { + redirect.dispatch({ + type: Redirect.Action.APP, + payload: {path: '/settings'}, + }); +}); +const titleBarOptions = { + title: 'My new title', + buttons: { + secondary: [settingsButton], + }, +}; +const myTitleBar = TitleBar.create(app, titleBarOptions); + +myTitleBar.subscribe(TitleBar.Action.UPDATE, data => { + // Do something with the udpate action + // The data is in the following shape: {id: string, title: string, buttons: [{id: string, label: string, disabled: boolean} ...]} +}); + +// Unsubscribe from the titlebar update action +myTitleBar.unsubscribe(false); + +// Reuse settingsButton in a modal +const modalOptions = { + title: 'My Modal', + message: 'Hello world!', + footer: {primary: settingsButton}, +}; + +const myModal = Modal.create(app, modalOptions); +``` diff --git a/src/actions/TitleBar/actions.ts b/src/actions/TitleBar/actions.ts new file mode 100644 index 0000000..cf58c5f --- /dev/null +++ b/src/actions/TitleBar/actions.ts @@ -0,0 +1,241 @@ +/** + * @module TitleBar + */ + +import {clickButton, Button, Payload as ButtonPayload} from '../Button'; +import {isGroupedButtonPayload, ButtonGroup, Payload as ButtonGroupPayload} from '../ButtonGroup'; + +import {ClientApplication} from '../../client'; +import {getGroupedButton} from '../buttonGroupHelper'; +import {getSingleButton} from '../buttonHelper'; +import { + actionWrapper, + getMergedProps, + updateActionFromPayload, + ActionSetWithChildren, +} from '../helper'; +import {ActionSetProps, ClickAction, ComponentType, Group, MetaAction} from '../types'; +import { + Action, + ActionType, + ButtonsOptions as TitleBarButtonsOptions, + ButtonsPayload as TitleBarButtonsPayload, + Options, + Payload, +} from './types'; + +const TITLEBAR_BUTTON_PROPS = { + group: Group.TitleBar, + subgroups: ['Buttons'], +}; + +const BREADCRUMB_BUTTON_PROPS = { + group: Group.TitleBar, + subgroups: ['Breadcrumbs'], + type: ComponentType.Button, +}; + +export interface UpdateAction extends MetaAction { + readonly group: typeof Group.TitleBar; + payload: Payload; +} + +export type TitleBarAction = UpdateAction | ClickAction | MetaAction; + +export function clickActionButton(id: string, payload?: any): ClickAction { + const type = ComponentType.Button; + const component = {id, type, ...TITLEBAR_BUTTON_PROPS}; + + return clickButton(Group.TitleBar, component, payload); +} + +export function clickBreadcrumb(id: string, payload?: any): ClickAction { + const component = {id, ...BREADCRUMB_BUTTON_PROPS}; + + return clickButton(Group.TitleBar, component, payload); +} + +export function update(payload: Payload): UpdateAction { + return actionWrapper({ + payload, + group: Group.TitleBar, + type: ActionType.UPDATE, + }); +} + +export class TitleBar extends ActionSetWithChildren implements ActionSetProps { + title?: string; + primary?: ButtonPayload; + secondary?: (ButtonPayload | ButtonGroupPayload)[]; + primaryOptions?: Button; + secondaryOptions?: (ButtonGroup | Button)[]; + breadcrumb?: ButtonPayload; + breadcrumbsOption?: Button; + + constructor(app: ClientApplication, options: Options) { + super(app, Group.TitleBar, Group.TitleBar); + // Trigger 'update' on creation + this.set(options); + } + + get buttons(): TitleBarButtonsPayload | undefined { + if (!this.primary && !this.secondary) { + return undefined; + } + + return { + primary: this.primary, + secondary: this.secondary, + }; + } + + get buttonsOptions(): TitleBarButtonsOptions | undefined { + if (!this.primaryOptions && !this.secondaryOptions) { + return undefined; + } + + return { + primary: this.primaryOptions, + secondary: this.secondaryOptions, + }; + } + + get options(): Options { + return { + breadcrumbs: this.breadcrumbsOption, + buttons: this.buttonsOptions, + title: this.title, + }; + } + + get payload(): Payload { + return { + ...this.options, + breadcrumbs: this.breadcrumb, + buttons: this.buttons, + id: this.id, + }; + } + + set(options: Partial, shouldUpdate = true) { + const mergedOptions: Options = getMergedProps(this.options, options); + const {title, buttons, breadcrumbs} = mergedOptions; + + this.title = title; + this.setBreadcrumbs(breadcrumbs); + this.setPrimaryButton(buttons ? buttons.primary : undefined); + this.setSecondaryButton(buttons ? buttons.secondary : undefined); + + if (shouldUpdate) { + this.dispatch(Action.UPDATE); + } + + return this; + } + + dispatch(action: Action) { + switch (action) { + case Action.UPDATE: + this.app.dispatch(update(this.payload)); + break; + } + + return this; + } + + protected getButton( + button: Button | ButtonGroup, + subgroups: string[], + updateCb: (newPayload: ButtonPayload | ButtonGroupPayload) => void, + ): ButtonPayload | ButtonGroupPayload { + if (button instanceof ButtonGroup) { + return getGroupedButton(this, button, subgroups, updateCb); + } + + return getSingleButton(this, button, subgroups, updateCb); + } + + protected updatePrimaryButton(newPayload: ButtonPayload) { + if (!this.primary) { + return; + } + if (updateActionFromPayload(this.primary, newPayload)) { + this.dispatch(Action.UPDATE); + } + } + + protected updateSecondaryButtons(newPayload: ButtonPayload | ButtonGroupPayload) { + if (!this.secondary) { + return; + } + const buttonToUpdate = this.secondary.find(action => action.id === newPayload.id); + if (!buttonToUpdate) { + return; + } + + let updated = false; + if (isGroupedButtonPayload(newPayload)) { + updated = updateActionFromPayload(buttonToUpdate, newPayload); + } else { + updated = updateActionFromPayload(buttonToUpdate, newPayload); + } + if (updated) { + this.dispatch(Action.UPDATE); + } + } + + protected updateBreadcrumbButton(newPayload: ButtonPayload) { + if (!this.breadcrumb) { + return; + } + if (updateActionFromPayload(this.breadcrumb, newPayload)) { + this.dispatch(Action.UPDATE); + } + } + + protected setPrimaryButton(newOptions?: Button) { + this.primaryOptions = this.getChildButton(newOptions, this.primaryOptions); + this.primary = this.primaryOptions + ? this.getButton( + this.primaryOptions, + TITLEBAR_BUTTON_PROPS.subgroups, + this.updatePrimaryButton, + ) + : undefined; + } + + protected setSecondaryButton(newOptions?: (ButtonGroup | Button)[]) { + const newButtons = newOptions || []; + const currentButtons = this.secondaryOptions || []; + this.secondaryOptions = this.getUpdatedChildActions(newButtons, currentButtons); + + this.secondary = this.secondaryOptions + ? this.secondaryOptions.map(action => + this.getButton(action, TITLEBAR_BUTTON_PROPS.subgroups, this.updateSecondaryButtons), + ) + : undefined; + } + + protected setBreadcrumbs(breadcrumb?: Button) { + this.breadcrumbsOption = this.getChildButton(breadcrumb, this.breadcrumbsOption); + this.breadcrumb = this.breadcrumbsOption + ? this.getButton( + this.breadcrumbsOption, + BREADCRUMB_BUTTON_PROPS.subgroups, + this.updateBreadcrumbButton, + ) + : undefined; + } + + protected getChildButton(newAction: undefined | Button, currentAction: undefined | Button) { + const newButtons = newAction ? [newAction] : []; + const currentButtons = currentAction ? [currentAction] : []; + const updatedButton = this.getUpdatedChildActions(newButtons, currentButtons); + + return updatedButton ? updatedButton[0] : undefined; + } +} + +export function create(app: ClientApplication, options: Options) { + return new TitleBar(app, options); +} diff --git a/src/actions/TitleBar/index.ts b/src/actions/TitleBar/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/TitleBar/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/TitleBar/tests/actions.test.ts b/src/actions/TitleBar/tests/actions.test.ts new file mode 100644 index 0000000..dad24ea --- /dev/null +++ b/src/actions/TitleBar/tests/actions.test.ts @@ -0,0 +1,375 @@ +import {createMockApp} from 'test/helper'; +import * as ButtonGroupHelper from '../../buttonGroupHelper'; +import * as ButtonHelper from '../../buttonHelper'; +import * as Helper from '../../helper'; +import {Button} from '../../Button'; +import {ButtonGroup} from '../../ButtonGroup'; + +import {clickActionButton, clickBreadcrumb, TitleBar} from '../../TitleBar'; +import {create} from '../actions'; +import {Action} from '../types'; + +describe('TitleBar', () => { + let app; + let buttonPrimary; + let groupedButton1; + let groupedButton2; + let breadcrumbButton; + let buttonGroup; + let defaultOptions; + let primarySecondaryOptions; + let allOptions; + let actionsGroupSubgroups; + let breadcrumbsGroupSubgroups; + let expectedAllOptions; + let updateFromPayloadMock; + + beforeEach(() => { + app = createMockApp(); + buttonPrimary = new Button(app, {label: 'Primary button'}); + groupedButton1 = new Button(app, {label: 'Grouped button 1'}); + groupedButton2 = new Button(app, {label: 'Grouped button 2'}); + breadcrumbButton = new Button(app, {label: 'My breadcrumb'}); + buttonGroup = new ButtonGroup(app, { + buttons: [groupedButton1, groupedButton2], + label: 'More actions', + }); + + defaultOptions = {title: 'Page title'}; + primarySecondaryOptions = { + ...defaultOptions, + buttons: { + primary: buttonPrimary, + secondary: [buttonGroup], + }, + }; + + allOptions = { + ...primarySecondaryOptions, + breadcrumbs: breadcrumbButton, + }; + + actionsGroupSubgroups = {group: 'TitleBar', subgroups: ['Buttons']}; + breadcrumbsGroupSubgroups = {group: 'TitleBar', subgroups: ['Breadcrumbs']}; + expectedAllOptions = { + breadcrumbs: {...breadcrumbButton, ...breadcrumbsGroupSubgroups}, + buttons: { + primary: {...buttonPrimary, ...actionsGroupSubgroups}, + secondary: [{...buttonGroup, ...actionsGroupSubgroups}], + }, + title: defaultOptions.title, + }; + + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + + updateFromPayloadMock = jest + .spyOn(Helper, 'updateActionFromPayload') + .mockImplementation(jest.fn(_ => true)); + }); + + it('sets expected properties', () => { + const titleBar = new TitleBar(app, defaultOptions); + const expectedProps = {group: 'TitleBar', type: 'TitleBar', title: defaultOptions.title}; + expect(titleBar).toMatchObject(expectedProps); + }); + + it('sets buttons as expected for primary and secondary buttons', () => { + const titleBar = new TitleBar(app, primarySecondaryOptions); + const expectedButtons = { + primary: buttonPrimary.payload, + secondary: [buttonGroup.payload], + }; + expect(titleBar.buttons).toEqual(expectedButtons); + }); + + it('sets buttonOptions as expected for primary and secondary buttons', () => { + const titleBar = new TitleBar(app, primarySecondaryOptions); + const expectedButtonsOptions = { + primary: {...buttonPrimary, ...actionsGroupSubgroups}, + secondary: [{...buttonGroup, ...actionsGroupSubgroups}], + }; + expect(titleBar.buttonsOptions).toEqual(expectedButtonsOptions); + }); + + it('get options returns expected properties', () => { + const titleBar = new TitleBar(app, allOptions); + expect(titleBar.options).toEqual(expectedAllOptions); + }); + + it('get payload returns expected properties', () => { + const titleBar = new TitleBar(app, allOptions); + const expectedPayload = { + breadcrumbs: breadcrumbButton.payload, + buttons: { + primary: buttonPrimary.payload, + secondary: [buttonGroup.payload], + }, + id: titleBar.id, + title: defaultOptions.title, + }; + expect(titleBar.payload).toEqual(expectedPayload); + }); + + it('calls getSingleButton for primary and secondary buttons with expected args', () => { + const titleBar = new TitleBar(app, defaultOptions); + const getSingleButtonSpy = jest.spyOn(ButtonHelper, 'getSingleButton'); + const updateSecondaryButtonsSpy = jest.spyOn(titleBar, 'updateSecondaryButtons'); + const updatePrimaryButtonSpy = jest.spyOn(titleBar, 'updatePrimaryButton'); + + titleBar.set({ + buttons: { + primary: buttonPrimary, + secondary: [groupedButton1, groupedButton2], + }, + }); + + expect(getSingleButtonSpy).toHaveBeenCalledTimes(3); + + expect(getSingleButtonSpy).toHaveBeenCalledWith( + titleBar, + {...buttonPrimary, ...actionsGroupSubgroups}, + actionsGroupSubgroups.subgroups, + updatePrimaryButtonSpy, + ); + + expect(getSingleButtonSpy).toHaveBeenCalledWith( + titleBar, + {...groupedButton1, ...actionsGroupSubgroups}, + actionsGroupSubgroups.subgroups, + updateSecondaryButtonsSpy, + ); + + expect(getSingleButtonSpy).toHaveBeenCalledWith( + titleBar, + {...groupedButton2, ...actionsGroupSubgroups}, + actionsGroupSubgroups.subgroups, + updateSecondaryButtonsSpy, + ); + }); + + it('calls getGroupedButton for grouped secondary buttons with expected args', () => { + const titleBar = new TitleBar(app, defaultOptions); + const updateSecondaryButtonsSpy = jest.spyOn(titleBar, 'updateSecondaryButtons'); + const getGroupedButtonSpy = jest.spyOn(ButtonGroupHelper, 'getGroupedButton'); + titleBar.set({ + buttons: { + secondary: [buttonGroup], + }, + }); + + expect(getGroupedButtonSpy).toHaveBeenCalledWith( + titleBar, + {...buttonGroup, ...actionsGroupSubgroups}, + actionsGroupSubgroups.subgroups, + updateSecondaryButtonsSpy, + ); + }); + + it('calls getSingleButton for breadcrumbs with expected args', () => { + const titleBar = new TitleBar(app, defaultOptions); + const getSingleButtonSpy = jest.spyOn(ButtonHelper, 'getSingleButton'); + const updateBreadcrumbButtonSpy = jest.spyOn(titleBar, 'updateBreadcrumbButton'); + titleBar.set({ + breadcrumbs: breadcrumbButton, + }); + + expect(getSingleButtonSpy).toHaveBeenCalledWith( + titleBar, + {...breadcrumbButton, ...breadcrumbsGroupSubgroups}, + breadcrumbsGroupSubgroups.subgroups, + updateBreadcrumbButtonSpy, + ); + }); + + it('subscribes to children updates for primary, secondary and breadcrumbs', () => { + const subscribeSpy = jest.spyOn(TitleBar.prototype, 'subscribe'); + const titleBar = new TitleBar(app, allOptions); + + expect(subscribeSpy).toHaveBeenCalledTimes(3); + + expect(subscribeSpy).toHaveBeenCalledWith(Action.UPDATE, expect.any(Function), { + ...buttonPrimary.component, + subgroups: actionsGroupSubgroups.subgroups, + }); + + expect(subscribeSpy).toHaveBeenCalledWith(Action.UPDATE, expect.any(Function), { + ...buttonGroup.component, + subgroups: actionsGroupSubgroups.subgroups, + }); + + expect(subscribeSpy).toHaveBeenCalledWith(Action.UPDATE, expect.any(Function), { + ...breadcrumbButton.component, + subgroups: breadcrumbsGroupSubgroups.subgroups, + }); + }); + + it('updatePrimaryButton calls updateActionFromPayload with expected args and dispatch update action', () => { + const updateSpy = jest.spyOn(TitleBar.prototype, 'dispatch'); + const titleBar = new TitleBar(app, primarySecondaryOptions); + const expectedButton = { + ...buttonPrimary.options, + id: buttonPrimary.id, + }; + const newButtonPayload = { + id: buttonPrimary.id, + label: 'Hello', + }; + titleBar.updatePrimaryButton(newButtonPayload); + expect(updateFromPayloadMock).toHaveBeenCalledWith(expectedButton, newButtonPayload); + expect(updateSpy).toHaveBeenCalledWith(Action.UPDATE); + }); + + it('updateSecondaryButtons calls updateActionFromPayload with expected args and dispatch update action for grouped buttons', () => { + const updateSpy = jest.spyOn(TitleBar.prototype, 'dispatch'); + const titleBar = new TitleBar(app, primarySecondaryOptions); + + const newButtonPayload = { + buttons: [ + { + id: '123', + label: 'New button', + }, + ], + id: buttonGroup.id, + label: 'Updated label', + }; + titleBar.updateSecondaryButtons(newButtonPayload); + expect(updateFromPayloadMock).toHaveBeenCalledWith(buttonGroup.payload, newButtonPayload); + expect(updateSpy).toHaveBeenCalledWith(Action.UPDATE); + }); + + it('updateSecondaryButtons calls updateActionFromPayload with expected args and dispatch update action for single buttons', () => { + const updateSpy = jest.spyOn(TitleBar.prototype, 'dispatch'); + const titleBar = new TitleBar(app, { + ...defaultOptions, + buttons: { + secondary: [groupedButton1], + }, + }); + + const newButtonPayload = { + id: groupedButton1.id, + label: 'Updated label', + }; + titleBar.updateSecondaryButtons(newButtonPayload); + expect(updateFromPayloadMock).toHaveBeenCalledWith(groupedButton1.payload, newButtonPayload); + expect(updateSpy).toHaveBeenCalledWith(Action.UPDATE); + }); + + it('updateBreadcrumbButton calls updateActionFromPayload with expected args and dispatch update action for breadcrumbs', () => { + const updateSpy = jest.spyOn(TitleBar.prototype, 'dispatch'); + const titleBar = new TitleBar(app, { + ...defaultOptions, + breadcrumbs: breadcrumbButton, + }); + + const newButtonPayload = { + id: breadcrumbButton.id, + label: 'Updated label', + }; + titleBar.updateBreadcrumbButton(newButtonPayload); + expect(updateFromPayloadMock).toHaveBeenCalledWith(breadcrumbButton.payload, newButtonPayload); + expect(updateSpy).toHaveBeenCalledWith(Action.UPDATE); + }); + + it('dispatches expected update action on update', () => { + const titleBar = new TitleBar(app, defaultOptions); + const expectedAction = { + group: 'TitleBar', + payload: { + id: titleBar.id, + title: defaultOptions.title, + }, + type: 'APP::TITLEBAR::UPDATE', + }; + + app.dispatch.mockReset(); + titleBar.dispatch(Action.UPDATE); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('set updates options, payload and dispatch update action', () => { + const titleBar = new TitleBar(app, defaultOptions); + const newOptions = {title: 'New title', ...allOptions}; + const expectedOptions = {...expectedAllOptions, ...newOptions}; + const expectedPayload = { + breadcrumbs: breadcrumbButton.payload, + buttons: { + primary: buttonPrimary.payload, + secondary: [buttonGroup.payload], + }, + id: titleBar.id, + title: defaultOptions.title, + }; + + const expectedAction = { + payload: { + id: titleBar.id, + ...expectedPayload, + }, + type: 'APP::TITLEBAR::UPDATE', + }; + + app.dispatch.mockReset(); + + titleBar.set(newOptions); + + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(titleBar.options).toMatchObject(expectedOptions); + expect(titleBar.payload).toEqual(expectedPayload); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatches update action on initialize', () => { + const updateSpy = jest.spyOn(TitleBar.prototype, 'dispatch'); + const titleBar = new TitleBar(app, defaultOptions); + expect(updateSpy).toHaveBeenCalledWith(Action.UPDATE); + }); + + it('set does not dispatch update action if shouldUpdate = false', () => { + const titleBar = new TitleBar(app, defaultOptions); + const newOptions = {title: 'New label'}; + app.dispatch.mockReset(); + titleBar.set(newOptions, false); + expect(app.dispatch).not.toHaveBeenCalled(); + }); + + it('create generates a new TitleBar instance when given Options', () => { + const obj = create(app, allOptions); + expect(obj instanceof TitleBar).toBe(true); + expect(obj.options).toMatchObject(expectedAllOptions); + }); + + it('clickActionButton returns expected action', () => { + const fakeButtonId = '123'; + const fakeButtonPayload = { + message: 'Hi', + }; + const expectedAction = { + group: 'TitleBar', + payload: { + id: fakeButtonId, + payload: fakeButtonPayload, + }, + type: 'APP::TITLEBAR::BUTTONS::BUTTON::CLICK', + }; + expect(clickActionButton(fakeButtonId, fakeButtonPayload)).toEqual(expectedAction); + }); + + it('clickBreadcrumb returns expected action', () => { + const fakeButtonId = '123'; + const fakeButtonPayload = { + message: 'Hi', + }; + const expectedAction = { + group: 'TitleBar', + payload: { + id: fakeButtonId, + payload: fakeButtonPayload, + }, + type: 'APP::TITLEBAR::BREADCRUMBS::BUTTON::CLICK', + }; + expect(clickBreadcrumb(fakeButtonId, fakeButtonPayload)).toEqual(expectedAction); + }); +}); diff --git a/src/actions/TitleBar/types.ts b/src/actions/TitleBar/types.ts new file mode 100644 index 0000000..0d33dfe --- /dev/null +++ b/src/actions/TitleBar/types.ts @@ -0,0 +1,46 @@ +/** + * @module TitleBar + */ +import {Button, Payload as ButtonPayload} from '../Button'; +import {ButtonGroup, Payload as ButtonGroupPayload} from '../ButtonGroup'; + +export enum Action { + UPDATE = 'UPDATE', +} + +export enum ActionType { + UPDATE = 'APP::TITLEBAR::UPDATE', + BUTTON_CLICK = 'APP::TITLEBAR::BUTTONS::BUTTON::CLICK', + BUTTON_UPDATE = 'APP::TITLEBAR::BUTTONS::BUTTON::UPDATE', + BUTTON_GROUP_UPDATE = 'APP::TITLEBAR::BUTTONS::BUTTONGROUP::UPDATE', + BREADCRUMBS_CLICK = 'APP::TITLEBAR::BREADCRUMBS::BUTTON::CLICK', + BREADCRUMBS_UPDATE = 'APP::TITLEBAR::BREADCRUMBS::BUTTON::UPDATE', +} + +export interface Breadcrumb { + content: string; + url: string; +} + +export interface ButtonsOptions { + primary?: Button; + secondary?: (ButtonGroup | Button)[]; +} + +export interface Options { + title?: string; + buttons?: ButtonsOptions; + breadcrumbs?: Button; +} + +export interface ButtonsPayload { + primary?: ButtonPayload; + secondary?: (ButtonPayload | ButtonGroupPayload)[]; +} + +export interface Payload { + readonly id?: string; + title?: string; + buttons?: ButtonsPayload; + breadcrumbs?: ButtonPayload; +} diff --git a/src/actions/Toast/README.md b/src/actions/Toast/README.md new file mode 100644 index 0000000..55efb0c --- /dev/null +++ b/src/actions/Toast/README.md @@ -0,0 +1,89 @@ +# Toast + +## Setup + +Create an app and import the `Toast` module from `@shopify/app-bridge/actions`. Note that we'll be referring to this sample application throughout the examples below. + +```js +import createApp from '@shopify/app-bridge'; +import {Toast} from '@shopify/app-bridge/actions'; + +const app = createApp({ + apiKey: '12345', +}); +``` + +## Create a toast notice + +Generate a toast notice: + +```js +const toastOptions = { + message: 'Product saved', + duration: 5000, +}; +const toastNotice = Toast.create(app, toastOptions); +``` + +## Create a toast error message + +Generate an error toast notice: + +```js +const toastOptions = { + message: 'Error saving', + duration: 5000, + isError: true, +}; +const toastError = Toast.create(app, toastOptions); +``` + +## Subscribe to actions + +You can subscribe to toast actions by calling `subscribe`. This returns a method that you can call to unsubscribe from the action: + +```js +const toastNotice = Toast.create(app, {message: 'Product saved'}); +const showUnsubscribe = toastNotice.subscribe(Toast.Action.SHOW, data => { + // Do something with the show action +}); + +const clearUnsubscribe = toastNotice.subscribe(Toast.Action.CLEAR, data => { + // Do something with the clear action +}); + +// Unsubscribe +showUnsubscribe(); +clearUnsubscribe(); +``` + +## Unsubscribe + +You call `unsubscribe` to remove all current subscriptions on the toast message: + +```js +const toastNotice = Toast.create(app, {message: 'Product saved'}); +toastNotice.subscribe(Toast.Action.SHOW, data => { + // Do something with the show action +}); +toastNotice.subscribe(Toast.Action.CLEAR, data => { + // Do something with the clear action +}); + +// Unsubscribe +toastNotice.unsubscribe(); +``` + +## Dispatch show action + +```js +const toastNotice = Toast.create(app, {message: 'Product saved'}); +toastNotice.dispatch(Toast.Action.SHOW); +``` + +## Dispatch clear action + +```js +const toastNotice = Toast.create(app, {message: 'Product saved'}); +toastNotice.dispatch(Toast.Action.CLEAR); +``` diff --git a/src/actions/Toast/actions.ts b/src/actions/Toast/actions.ts new file mode 100644 index 0000000..3a60704 --- /dev/null +++ b/src/actions/Toast/actions.ts @@ -0,0 +1,92 @@ +/** + * @module Toast + */ + +import {ClientApplication} from '../../client'; +import {actionWrapper, getMergedProps, ActionSet} from '../helper'; +import {ActionSetProps, Group, MetaAction} from '../types'; +import {Action, ActionType, ClearPayload, Options, Payload} from './types'; + +export interface ActionBase extends MetaAction { + readonly group: typeof Group.Toast; +} +export interface ShowAction extends ActionBase { + readonly type: typeof ActionType.SHOW; + readonly payload: Payload; +} +export interface ClearAction extends ActionBase { + readonly type: typeof ActionType.CLEAR; +} + +export type ToastAction = ShowAction | ClearAction | MetaAction; + +export function show(toastMessage: Payload): ShowAction { + return actionWrapper({ + group: Group.Toast, + payload: toastMessage, + type: ActionType.SHOW, + }); +} + +export function clear(payload: ClearPayload): ClearAction { + return actionWrapper({ + payload, + group: Group.Toast, + type: ActionType.CLEAR, + }); +} + +export class Toast extends ActionSet implements ActionSetProps { + message = ''; + duration = 3000; + isError?: boolean; + + constructor(app: ClientApplication, options: Options) { + super(app, Group.Toast, Group.Toast); + this.set(options); + } + + get options(): Options { + return { + duration: this.duration, + isError: this.isError, + message: this.message, + }; + } + + get payload(): Payload { + return { + id: this.id, + ...this.options, + }; + } + + set(options: Partial) { + const mergedOptions = getMergedProps(this.options, options); + const {message, duration, isError} = mergedOptions; + + this.message = message; + this.duration = duration; + this.isError = isError; + + return this; + } + + dispatch(action: Action) { + switch (action) { + case Action.SHOW: + const openAction = show(this.payload); + this.app.dispatch(openAction); + break; + case Action.CLEAR: + this.app.dispatch(clear({id: this.id})); + break; + } + + return this; + } +} + +export function create(app: ClientApplication, options: Options) { + return new Toast(app, options); +} diff --git a/src/actions/Toast/index.ts b/src/actions/Toast/index.ts new file mode 100644 index 0000000..146469c --- /dev/null +++ b/src/actions/Toast/index.ts @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './types'; diff --git a/src/actions/Toast/tests/actions.test.ts b/src/actions/Toast/tests/actions.test.ts new file mode 100644 index 0000000..6c098bc --- /dev/null +++ b/src/actions/Toast/tests/actions.test.ts @@ -0,0 +1,91 @@ +import {createMockApp} from 'test/helper'; +import * as Helper from '../../helper'; +import {clear, create, show, Toast} from '../actions'; +import {Action, Payload} from '../types'; + +jest.mock('../../uuid', (fakeId = 'fakeId') => jest.fn().mockReturnValue(fakeId)); + +describe('Toast Actions', () => { + beforeEach(() => { + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('show returns expected action', () => { + const fakePayload: Payload = { + duration: 5000, + isError: true, + message: 'toastContent', + }; + const expectedAction = { + group: 'Toast', + payload: fakePayload, + type: 'APP::TOAST::SHOW', + }; + expect(show(fakePayload)).toEqual(expectedAction); + expect(Helper.actionWrapper).toHaveBeenCalledWith(expectedAction); + }); + + it('clear returns expected action', () => { + const expectedId = 'Test123'; + const expectedAction = { + group: 'Toast', + payload: { + id: expectedId, + }, + type: 'APP::TOAST::CLEAR', + }; + expect(clear({id: expectedId})).toEqual(expectedAction); + expect(Helper.actionWrapper).toHaveBeenCalledWith(expectedAction); + }); +}); + +describe('Toast', () => { + let app; + const defaultOptions = {message: 'Hi there', isError: true, duration: 5000}; + + beforeEach(() => { + app = createMockApp(); + jest.spyOn(Helper, 'actionWrapper').mockImplementation(jest.fn(obj => obj)); + }); + + it('sets expected properties', () => { + const toastMesage = new Toast(app, defaultOptions); + const expectedProps = {group: 'Toast', type: 'Toast', ...defaultOptions}; + expect(toastMesage).toMatchObject(expectedProps); + }); + + it('get options returns expected properties', () => { + const toastMesage = new Toast(app, defaultOptions); + expect(toastMesage.options).toEqual(defaultOptions); + }); + + it('dispatches show action on show', () => { + const toastMesage = new Toast(app, defaultOptions); + const expectedAction = { + group: 'Toast', + payload: defaultOptions, + type: 'APP::TOAST::SHOW', + }; + toastMesage.dispatch(Action.SHOW); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('dispatches clear action on clear', () => { + const toastMesage = new Toast(app, defaultOptions); + const expectedAction = { + group: 'Toast', + payload: {id: 'fakeId'}, + type: 'APP::TOAST::CLEAR', + }; + toastMesage.dispatch(Action.CLEAR); + expect(app.dispatch).toHaveBeenCalledTimes(1); + expect(app.dispatch.mock.calls[0][0]).toMatchObject(expectedAction); + }); + + it('create generates a new Toast instance when given ToastOptions', () => { + const obj = create(app, defaultOptions); + expect(obj instanceof Toast).toBe(true); + expect(obj.options).toEqual(defaultOptions); + }); +}); diff --git a/src/actions/Toast/types.ts b/src/actions/Toast/types.ts new file mode 100644 index 0000000..f3cb470 --- /dev/null +++ b/src/actions/Toast/types.ts @@ -0,0 +1,28 @@ +/** + * @module Toast + */ + +export enum ActionType { + SHOW = 'APP::TOAST::SHOW', + CLEAR = 'APP::TOAST::CLEAR', +} + +export enum Action { + SHOW = 'SHOW', + CLEAR = 'CLEAR', +} + +export interface Options { + duration: number; + isDismissible?: boolean; + isError?: boolean; + message: string; +} + +export interface ClearPayload { + readonly id?: string; +} + +export interface Payload extends Options { + readonly id?: string; +} diff --git a/src/actions/buttonGroupHelper.ts b/src/actions/buttonGroupHelper.ts new file mode 100644 index 0000000..ba138e1 --- /dev/null +++ b/src/actions/buttonGroupHelper.ts @@ -0,0 +1,25 @@ +import {Payload as ButtonPayload} from '../actions/Button'; +import { + Action as ButtonGroupAction, + ButtonGroup, + Payload as ButtonGroupPayload, +} from '../actions/ButtonGroup'; +import {ActionSetWithChildren} from './helper'; + +/** + * @internal + */ +export function getGroupedButton( + action: ActionSetWithChildren, + button: ButtonGroup, + subgroups: string[], + updateCb: (newPayload: ButtonPayload | ButtonGroupPayload) => void, +): ButtonGroupPayload { + action.addChild(button, action.group, subgroups); + + const {id, label, disabled, buttons} = button; + + action.subscribeToChild(button, ButtonGroupAction.UPDATE, updateCb); + + return {id, label, buttons, disabled}; +} diff --git a/src/actions/buttonHelper.ts b/src/actions/buttonHelper.ts new file mode 100644 index 0000000..4982b4d --- /dev/null +++ b/src/actions/buttonHelper.ts @@ -0,0 +1,18 @@ +import {Action as ButtonAction, Button, Payload} from '../actions/Button'; +import {ActionSetWithChildren} from './helper'; + +/** + * @internal + */ +export function getSingleButton( + action: ActionSetWithChildren, + button: Button, + subgroups: string[], + updateCb: (newPayload: Payload) => void, +): Payload { + action.addChild(button, action.group, subgroups); + + action.subscribeToChild(button, ButtonAction.UPDATE, updateCb); + + return button.payload; +} diff --git a/src/actions/constants.ts b/src/actions/constants.ts new file mode 100644 index 0000000..bef03f1 --- /dev/null +++ b/src/actions/constants.ts @@ -0,0 +1 @@ +export const PREFIX = 'APP'; diff --git a/src/actions/helper.ts b/src/actions/helper.ts new file mode 100644 index 0000000..3a10aaf --- /dev/null +++ b/src/actions/helper.ts @@ -0,0 +1,402 @@ +import { + isErrorEventName, + throwError, + Action as ErrorActions, + ActionType as ErrorActionType, +} from '../actions/Error'; +import {ClientApplication, LifecycleHook} from '../client'; +import {removeFromCollection} from '../util/collection'; +import {PREFIX} from './constants'; +import mergeProps, {Indexable} from './merge'; +import { + ActionCallback, + ActionSetInterface, + ActionSubscription, + Component, + ErrorCallback, + Group, + Unsubscribe, +} from './types'; +import generateUuid from './uuid'; + +const packageJson = require('../package.json'); + +const SEPARATOR = '::'; + +export function actionWrapper(action: any): any { + return {...action, version: getVersion()}; +} + +export function getVersion() { + return packageJson.version; +} + +export function getEventNameSpace(group: string, eventName: string, component?: Component): string { + let eventNameSpace = group.toUpperCase(); + if (component) { + const {subgroups, type} = component; + if (subgroups && subgroups.length > 0) { + eventNameSpace += eventNameSpace.length > 0 ? SEPARATOR : ''; + subgroups.forEach((subgroup, index) => { + eventNameSpace += `${subgroup.toUpperCase()}${ + index < subgroups.length - 1 ? SEPARATOR : '' + }`; + }); + } + if (type !== group && type) { + eventNameSpace += `${eventNameSpace.length > 0 ? SEPARATOR : ''}${type.toUpperCase()}`; + } + } + if (eventNameSpace) { + eventNameSpace += `${eventNameSpace.length > 0 ? SEPARATOR : ''}${eventName.toUpperCase()}`; + } + + return `${PREFIX}${SEPARATOR}${eventNameSpace}`; +} + +export function isValidOptionalNumber(value?: number): boolean { + return value === null || value === undefined || typeof value === 'number'; +} + +export function isValidOptionalString(value?: string): boolean { + return value === null || value === undefined || typeof value === 'string'; +} + +export abstract class ActionSet implements ActionSetInterface { + readonly id: string; + readonly defaultGroup: string; + subgroups: string[] = []; + subscriptions: ActionSubscription[] = []; + + constructor( + public app: ClientApplication, + public type: string, + public group: string, + id?: string, + ) { + if (!app) { + throwError(ErrorActionType.INVALID_OPTIONS, 'Missing required `app`'); + } + + this.id = id || generateUuid(); + this.defaultGroup = group; + + const defaultSet = this.set; + this.set = (...args: any[]) => { + if (!this.app.hooks) { + return defaultSet.apply(this, args); + } + return this.app.hooks.run(LifecycleHook.UpdateAction, defaultSet, this, ...args); + }; + } + + set(..._: any[]) {} + + get component(): Component { + return { + id: this.id, + subgroups: this.subgroups, + type: this.type, + }; + } + + updateSubscription( + subscriptionToRemove: ActionSubscription, + group: string, + subgroups: string[], + ): Unsubscribe { + const {eventType, callback, component} = subscriptionToRemove; + let currentIndex; + currentIndex = this.subscriptions.findIndex( + subscription => subscription === subscriptionToRemove, + ); + if (currentIndex >= 0) { + this.subscriptions[currentIndex].unsubscribe(); + } else { + currentIndex = undefined; + } + this.group = group; + this.subgroups = subgroups; + + Object.assign(component, {subgroups: this.subgroups}); + + return this.subscribe(eventType, callback, component, currentIndex); + } + + error(callback: ErrorCallback): Unsubscribe { + const subscriptionIndices: number[] = []; + forEachInEnum(ErrorActions, eventNameSpace => { + // Keep track of subscription index so we can call unsubscribe later + // This ensure it will continue to work even when the subscription has been updated + subscriptionIndices.push(this.subscriptions.length); + this.subscribe(eventNameSpace, callback); + }); + + return () => { + const subscriptionsToRemove = subscriptionIndices.map(index => this.subscriptions[index]); + + subscriptionsToRemove.forEach(toRemove => { + removeFromCollection(this.subscriptions, toRemove, (removed: ActionSubscription) => { + removed.unsubscribe(); + }); + }); + }; + } + + subscribe( + eventName: string, + callback: ActionCallback, + component?: Component, + currentIndex?: number, + ): Unsubscribe { + const eventComponent = component || this.component; + const eventType = eventName.toUpperCase(); + const boundedCallback = typeof currentIndex === 'number' ? callback : callback.bind(this); + + let eventNameSpace; + if (isErrorEventName(eventName)) { + eventNameSpace = getEventNameSpace(Group.Error, eventName, { + ...eventComponent, + type: '', + }); + } else { + eventNameSpace = getEventNameSpace(this.group, eventName, eventComponent); + } + + const unsubscribe = this.app.subscribe( + eventNameSpace, + boundedCallback, + component ? component.id : this.id, + ); + const subscription: ActionSubscription = { + eventType, + unsubscribe, + callback: boundedCallback, + component: eventComponent, + updateSubscribe: (group, subgroups) => + this.updateSubscription.call(this, subscription, group, subgroups), + }; + + if ( + typeof currentIndex === 'number' && + currentIndex >= 0 && + currentIndex < this.subscriptions.length + ) { + this.subscriptions[currentIndex] = subscription; + } else { + this.subscriptions.push(subscription); + } + + return unsubscribe; + } + + unsubscribe(resetOnly = false) { + unsubscribeActions(this.subscriptions, this.defaultGroup, resetOnly); + + return this; + } +} + +export abstract class ActionSetWithChildren extends ActionSet { + children: ActionSetChildAction[] = []; + unsubscribe(unsubscribeChildren = true, resetParentOnly = false) { + unsubscribeActions(this.subscriptions, this.defaultGroup, resetParentOnly); + this.children.forEach(child => { + if (ActionSetWithChildren.prototype.isPrototypeOf(child)) { + (child as ActionSetWithChildren).unsubscribe( + unsubscribeChildren, + unsubscribeChildren ? false : true, + ); + } else { + child.unsubscribe(unsubscribeChildren ? false : true); + } + }); + + return this; + } + getChild(id: string): ActionSetChildAction | undefined { + const childIndex = this.children.findIndex(child => child.id === id); + + return childIndex >= 0 ? this.children[childIndex] : undefined; + } + + getChildIndex(id: string): number { + return this.children.findIndex(child => child.id === id); + } + + getChildSubscriptions(id: string, eventType?: string): ActionSubscription[] { + return this.subscriptions.filter( + sub => sub.component.id === id && (!eventType || eventType === sub.eventType), + ); + } + + addChild(child: ActionSetChildAction, group: string, subgroups: string[]) { + const {subscriptions} = child; + const existingChild = this.getChild(child.id); + // Add child if it doesn't already exist + if (!existingChild) { + this.children.push(child); + } + if (!subscriptions || (group === child.group && subgroups === child.subgroups)) { + return this; + } + + subscriptions.forEach((subscription: ActionSubscription) => { + const {updateSubscribe} = subscription; + updateSubscribe(group, subgroups); + }); + + // Update child's group and subgroups + Object.assign(child, {group, subgroups}); + + // Update child's children subscriptions + if (ActionSetWithChildren.prototype.isPrototypeOf(child)) { + (child as ActionSetWithChildren).children.forEach(c => this.addChild(c, group, subgroups)); + } + + return this; + } + + removeChild(id: string) { + removeFromCollection(this.children, this.getChild(id), () => { + const toBeRemoved = this.subscriptions.filter(subs => subs.component.id === id); + toBeRemoved.forEach(toRemove => { + removeFromCollection(this.subscriptions, toRemove, (removed: ActionSubscription) => { + removed.unsubscribe(); + }); + }); + }); + + return this; + } + + subscribeToChild( + child: ActionSetChildAction, + eventName: string | string[], + callback: (childData: any) => void, + ) { + const boundedCallback = callback.bind(this); + if (eventName instanceof Array) { + eventName.forEach(e => this.subscribeToChild(child, e, callback)); + + return this; + } + if (typeof eventName !== 'string') { + return this; + } + const eventType = eventName.toUpperCase(); + const currentSubscriptions = this.getChildSubscriptions(child.id, eventType); + if (currentSubscriptions.length > 0) { + // Subscription is already there, simply update it + currentSubscriptions.forEach(subs => subs.updateSubscribe(this.group, child.subgroups)); + } else { + const childComponent = { + id: child.id, + subgroups: child.subgroups, + type: child.type, + }; + this.subscribe(eventType, boundedCallback, childComponent); + } + + return this; + } + + getUpdatedChildActions( + newActions: A[], + currentActions: A[], + ): A[] | undefined { + if (newActions.length === 0) { + while (currentActions.length > 0) { + const action = currentActions.pop(); + if (!action) { + break; + } + this.removeChild(action.id); + } + + return undefined; + } + // Only allow unique actions + const uniqueActions = newActions.filter( + (action, index, actionsArr) => index === actionsArr.indexOf(action), + ); + const newActionIds = uniqueActions.map(action => action.id); + // Remove unused actions + const unusedActions = currentActions.filter(action => { + return newActionIds.indexOf(action.id) < 0; + }); + + while (unusedActions.length > 0) { + const action = unusedActions.pop(); + if (!action) { + break; + } + this.removeChild(action.id); + } + + return uniqueActions; + } +} + +export type ActionSetChildAction = ActionSet | ActionSetWithChildren; + +function unsubscribeActions( + subscriptions: ActionSubscription[], + defaultGroup: string, + reassign = false, +) { + subscriptions.forEach(subscription => { + if (reassign) { + const {updateSubscribe} = subscription; + // TODO: Support cases where we don't wipe out group and subgroups to defaults + updateSubscribe(defaultGroup, []); + } else { + const {unsubscribe} = subscription; + unsubscribe(); + } + }); + if (!reassign) { + subscriptions.length = 0; + } +} + +export function updateActionFromPayload>( + action: A, + newProps: A, +): boolean { + const {id} = action; + if (id === newProps.id) { + // Merge new properties + Object.assign(action, getMergedProps(action, newProps)); + + return true; + } + + return false; +} + +export function getMergedProps(props: Prop, newProps: Partial): Prop { + const merged = mergeProps(props, newProps); + if (!merged) { + // tslint:disable-next-line:prefer-object-spread + const cloned = Object.assign(props, newProps); + + return cloned; + } + + return merged as Prop; +} + +export function forEachInEnum(types: E, callback: (prop: string) => void) { + Object.keys(types).forEach((key: string) => { + callback(types[key]); + }); +} + +export function findMatchInEnum(types: E, lookup: string): string | undefined { + const match = Object.keys(types).find((key: string) => { + return lookup === types[key]; + }); + + return match ? types[match] : undefined; +} diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 0000000..58b1388 --- /dev/null +++ b/src/actions/index.ts @@ -0,0 +1,40 @@ +import * as Button from './Button'; +import * as ButtonGroup from './ButtonGroup'; +import * as Cart from './Cart'; +import * as Camera from './Camera'; +import * as Error from './Error'; +import * as Flash from './Flash'; +import * as Features from './Features'; +import * as Loading from './Loading'; +import * as Modal from './Modal'; +import * as History from './Navigation/History'; +import * as Redirect from './Navigation/Redirect'; +import * as Print from './Print'; +import * as ResourcePicker from './ResourcePicker'; +import * as TitleBar from './TitleBar'; +import * as Toast from './Toast'; + +/** + * @public + */ +export {isAppBridgeAction} from './validator'; + +export { + Button, + ButtonGroup, + Camera, + Cart, + Error, + Flash, + Features, + Toast, + History, + Loading, + Modal, + Print, + Redirect, + ResourcePicker, + TitleBar, +}; + +export * from './types'; diff --git a/src/actions/merge.ts b/src/actions/merge.ts new file mode 100644 index 0000000..d0c719c --- /dev/null +++ b/src/actions/merge.ts @@ -0,0 +1,51 @@ +export interface Indexable { + [key: string]: any; +} + +/** + * @internal + */ +export default function mergeProps( + obj: T, + newObj: T2, +): T | T2 | undefined { + if (newObj === undefined) { + return undefined; + } + // If setting to a different prototype or a non-object or non-array, don't merge any props + if ( + typeof obj === 'undefined' || + !Object.getPrototypeOf(obj).isPrototypeOf(newObj) || + (newObj.constructor.name !== 'Object' && newObj.constructor.name !== 'Array') + ) { + return newObj; + } + + const clone: any = {}; + + Object.keys(newObj).forEach(key => { + const exists = obj.hasOwnProperty(key); + if (!exists) { + clone[key] = newObj[key]; + } else { + if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) { + clone[key] = mergeProps(obj[key] as Indexable, newObj[key] as Indexable); + } else { + clone[key] = newObj[key]; + } + } + }); + + // Copy old props that are not present in new object only if this is a simple object + Object.keys(obj).forEach(key => { + const exists = newObj.hasOwnProperty(key); + if (!exists) { + clone[key] = obj[key]; + } + }); + + // Set prototype of cloned object to match original + Object.setPrototypeOf(clone, Object.getPrototypeOf(obj)); + + return clone; +} diff --git a/src/actions/tests/helper.test.ts b/src/actions/tests/helper.test.ts new file mode 100644 index 0000000..a6e4b60 --- /dev/null +++ b/src/actions/tests/helper.test.ts @@ -0,0 +1,892 @@ +import {createMockApp} from 'test/helper'; +import {ClientApplication, LifecycleHook} from '../../client'; +import Hooks from '../../client/Hooks'; +import {getGroupedButton} from '../buttonGroupHelper'; +import {getSingleButton} from '../buttonHelper'; +import { + actionWrapper, + forEachInEnum, + getEventNameSpace, + getMergedProps, + isValidOptionalNumber, + isValidOptionalString, + updateActionFromPayload, + ActionSet, + ActionSetWithChildren, +} from '../helper'; +import * as DeepMerge from '../merge'; +import {ActionSetInterface, ActionSubscription} from '../types'; +import {clickButton, Action as ButtonAction, Button} from '../Button'; +import {Action as ButtonGroupAction, ButtonGroup} from '../ButtonGroup'; +import {Action as ErrorActions} from '../Error/types'; + +class ParentActionSet extends ActionSetWithChildren { + constructor(myApp: ClientApplication) { + super(myApp, 'ParentType', 'SomeParentGroup'); + } + dispatch(action: string, payload?: any) { + switch (action) { + case 'click': + this.app.dispatch(clickButton(this.group, this.component, payload)); + break; + default: + } + } +} + +class ChildActionSet extends ActionSet { + constructor(myApp: ClientApplication) { + super(myApp, 'ChildType', 'SomeChildGroup'); + } + dispatch(action: string, payload?: any) { + switch (action) { + case 'click': + this.app.dispatch(clickButton(this.group, this.component, payload)); + break; + default: + } + } +} + +class FakeButton extends ChildActionSet { + label: string; + constructor(myApp: ClientApplication, options: {label: string}) { + super(myApp); + this.label = options.label; + } +} + +class FakeButtonGroup extends ChildActionSet { + label: string; + buttons: FakeButton[]; + disabled: boolean; + constructor( + myApp: ClientApplication, + options: {label: string; buttons: FakeButton[]; disabled: boolean}, + ) { + super(myApp); + this.label = options.label; + this.buttons = options.buttons; + } +} + +describe('Helper', () => { + it('actionWrapper wraps an object with the version number', () => { + const actionObj = {name: 'Dispatch'}; + const wrappedObj = actionWrapper(actionObj); + const version = require('../../../package.json').version; + expect(wrappedObj).toMatchObject({version}); + }); + + it('isValidOptionalNumber should return expected result', () => { + expect(isValidOptionalNumber(null)).toBe(true); + expect(isValidOptionalNumber(undefined)).toBe(true); + expect(isValidOptionalNumber('1')).toBe(false); + expect(isValidOptionalNumber(false)).toBe(false); + expect(isValidOptionalNumber(1)).toBe(true); + }); + + it('isValidOptionalString should return expected result', () => { + expect(isValidOptionalString(null)).toBe(true); + expect(isValidOptionalString(undefined)).toBe(true); + expect(isValidOptionalString('1')).toBe(true); + expect(isValidOptionalString(false)).toBe(false); + expect(isValidOptionalString(1)).toBe(false); + }); +}); + +describe('ActionSet', () => { + const unsubscribeStub = jest.fn(); + const app = createMockApp(); + + beforeEach(() => { + app.subscribe = () => unsubscribeStub; + jest.resetAllMocks(); + }); + + it('subscribe method should use given component', () => { + const fakeComponent = new ChildActionSet(app); + const subscriptionCbSpy = jest.fn(); + const component = { + id: '123', + type: 'test', + }; + app.subscribe = jest.fn().mockReturnValue(jest.fn()); + fakeComponent.subscribe('SOME_EVENT', subscriptionCbSpy, component); + + expect(app.subscribe).toHaveBeenCalledWith( + getEventNameSpace(fakeComponent.group, 'SOME_EVENT', component), + expect.any(Function), + component.id, + ); + expect(fakeComponent.subscriptions[0]).toEqual({ + component, + callback: expect.any(Function), + eventType: 'SOME_EVENT', + unsubscribe: expect.any(Function), + updateSubscribe: expect.any(Function), + }); + }); + + it('subscribe method should use this.component by default', () => { + const fakeComponent = new ChildActionSet(app); + const subscriptionCbSpy = jest.fn(); + + app.subscribe = jest.fn().mockReturnValue(jest.fn()); + fakeComponent.subscribe('SOME_EVENT', subscriptionCbSpy); + + expect(app.subscribe).toHaveBeenCalledWith( + getEventNameSpace(fakeComponent.group, 'SOME_EVENT', fakeComponent.component), + expect.any(Function), + fakeComponent.component.id, + ); + expect(fakeComponent.subscriptions[0]).toEqual({ + callback: expect.any(Function), + component: fakeComponent.component, + eventType: 'SOME_EVENT', + unsubscribe: expect.any(Function), + updateSubscribe: expect.any(Function), + }); + }); + + it('subscribe method calls app.subscribe and creates subscription as expected', () => { + const fakeComponent = new ChildActionSet(app); + const updateSubscribtionSpy = jest.spyOn(fakeComponent, 'updateSubscription'); + const subscriptionCbSpy = jest.fn(); + const unsubscribeSpy = jest.fn(); + app.subscribe = jest.fn().mockReturnValue(unsubscribeSpy); + fakeComponent.subscribe('SOME_EVENT', subscriptionCbSpy); + + expect(app.subscribe).toHaveBeenCalledWith( + getEventNameSpace(fakeComponent.group, 'SOME_EVENT', fakeComponent.component), + expect.any(Function), + fakeComponent.component.id, + ); + + const expectedSubscription = { + callback: expect.any(Function), + component: fakeComponent.component, + eventType: 'SOME_EVENT', + unsubscribe: unsubscribeSpy, + updateSubscribe: expect.any(Function), + }; + expect(fakeComponent.subscriptions[0]).toEqual(expectedSubscription); + + fakeComponent.subscriptions[0].unsubscribe(); + expect(unsubscribeSpy).toHaveBeenCalled(); + + fakeComponent.subscriptions[0].callback(); + expect(subscriptionCbSpy).toHaveBeenCalled(); + + fakeComponent.subscriptions[0].updateSubscribe('NewGroup', ['New Subgroup']); + + const expectedUpdatedSubscription = { + callback: expect.any(Function), + component: {...fakeComponent.component, subgroups: ['New Subgroup']}, + eventType: 'SOME_EVENT', + unsubscribe: unsubscribeSpy, + updateSubscribe: expect.any(Function), + }; + + expect(updateSubscribtionSpy).toHaveBeenCalledWith(expectedUpdatedSubscription, 'NewGroup', [ + 'New Subgroup', + ]); + }); + + it('subscribe method returns unsubscribe function created from calling app.subscribe', () => { + const fakeComponent = new ChildActionSet(app); + const subscriptionCbSpy = jest.fn(); + const unsubscribeSpy = jest.fn(); + app.subscribe = jest.fn().mockReturnValue(unsubscribeSpy); + const result = fakeComponent.subscribe('SOME_EVENT', subscriptionCbSpy); + + expect(result).toBe(unsubscribeSpy); + }); + + it('error method calls subscribe for each error type', () => { + const fakeComponent = new ChildActionSet(app); + const subscriptionCbSpy = jest.spyOn(fakeComponent, 'subscribe'); + const unsubscribeSpy = jest.fn(); + const errorHandler = jest.fn(); + app.subscribe = jest.fn().mockReturnValue(unsubscribeSpy); + + fakeComponent.error(errorHandler); + + forEachInEnum(ErrorActions, action => { + expect(subscriptionCbSpy).toHaveBeenCalledWith(action, errorHandler); + }); + }); + + it('error method returns an unsubscribe method that unsubscribes the handler from all errors', () => { + const fakeComponent = new ChildActionSet(app); + const unsubscribeSpy = jest.fn(); + const errorHandler = jest.fn(); + + let errorTypesCount = 0; + forEachInEnum(ErrorActions, action => { + errorTypesCount++; + }); + + app.subscribe = jest.fn().mockReturnValue(unsubscribeSpy); + + const unsubscribe = fakeComponent.error(errorHandler); + fakeComponent.subscribe('SOME_EVENT', jest.fn()); + + expect(fakeComponent.subscriptions.length).toBe(errorTypesCount + 1); + unsubscribe(); + + expect(unsubscribeSpy).toHaveBeenCalledTimes(errorTypesCount); + + expect(fakeComponent.subscriptions.length).toBe(1); + expect(fakeComponent.subscriptions[0]).toMatchObject({ + eventType: 'SOME_EVENT', + }); + }); + + it('error method returns an unsubscribe method that unsubscribes the handler from all errors, even if error subscriptions have been updated', () => { + const fakeComponent = new ChildActionSet(app); + const unsubscribeSpy = jest.fn(); + const errorHandler = jest.fn(); + + let errorTypesCount = 0; + forEachInEnum(ErrorActions, action => { + errorTypesCount++; + }); + + app.subscribe = jest.fn().mockReturnValue(unsubscribeSpy); + + const unsubscribe = fakeComponent.error(errorHandler); + fakeComponent.subscribe('SOME_EVENT', jest.fn()); + + expect(fakeComponent.subscriptions.length).toBe(errorTypesCount + 1); + + fakeComponent.subscriptions.forEach(subscription => + subscription.updateSubscribe('NewGroup', ['New Subgroup']), + ); + unsubscribeSpy.mockClear(); + + unsubscribe(); + + expect(unsubscribeSpy).toHaveBeenCalledTimes(errorTypesCount); + + expect(fakeComponent.subscriptions.length).toBe(1); + expect(fakeComponent.subscriptions[0]).toMatchObject({ + eventType: 'SOME_EVENT', + }); + }); + + it('subscribe method updates existing subscription if given a valid subscription index', () => { + const fakeComponent = new ChildActionSet(app); + const subscriptionCbSpy = jest.fn(); + const fakeSubscription: ActionSubscription = { + callback: subscriptionCbSpy, + component: { + id: '123', + type: 'test', + }, + eventType: 'SOME_EVENT', + unsubscribe: jest.fn(), + updateSubscribe: jest.fn(), + }; + + app.subscribe = jest.fn().mockReturnValue(jest.fn()); + + fakeComponent.subscriptions = [fakeSubscription]; + + fakeComponent.subscribe('SOME_EVENT', subscriptionCbSpy, fakeComponent.component, 0); + + expect(app.subscribe).toHaveBeenCalledWith( + getEventNameSpace(fakeComponent.group, 'SOME_EVENT', fakeComponent.component), + subscriptionCbSpy, + fakeComponent.id, + ); + expect(fakeSubscription).toEqual({ + callback: subscriptionCbSpy, + component: { + id: '123', + type: 'test', + }, + eventType: 'SOME_EVENT', + unsubscribe: expect.any(Function), + updateSubscribe: expect.any(Function), + }); + }); + + it('updateSubscription unsubscribes existing subscription and then calls subscribe with index of existing subscription', () => { + const fakeComponent = new ChildActionSet(app); + const subscriptionCbSpy = jest.fn(); + const subscriptionCbSpy2 = jest.fn(); + const unsubscribeSpy = jest.fn(); + app.subscribe = jest.fn().mockReturnValue(unsubscribeSpy); + + fakeComponent.subscribe('SOME_EVENT', subscriptionCbSpy); + fakeComponent.subscribe('SOME_OTHER_EVENT', subscriptionCbSpy2); + const subscribeSpy = jest.spyOn(fakeComponent, 'subscribe'); + + const subscriptionToUpdate = fakeComponent.subscriptions[1]; + const result = fakeComponent.updateSubscription(subscriptionToUpdate, 'New Group', [ + 'New sub group', + ]); + + expect(subscribeSpy).toHaveBeenCalledWith( + subscriptionToUpdate.eventType, + subscriptionToUpdate.callback, + subscriptionToUpdate.component, + 1, + ); + expect(unsubscribeSpy).toHaveBeenCalledTimes(1); + }); + + it('updateSubscription does NOT call unsubscribe if subscription does not already exist', () => { + const fakeComponent = new ChildActionSet(app); + const subscriptionCbSpy = jest.fn(); + + const unsubscribeSpy = jest.fn(); + app.subscribe = jest.fn().mockReturnValue(unsubscribeSpy); + + fakeComponent.subscribe('SOME_EVENT', subscriptionCbSpy); + + const subscribeSpy = jest.spyOn(fakeComponent, 'subscribe'); + + const subscriptionToUpdate = { + callback: subscriptionCbSpy, + component: { + id: '123', + type: 'test', + }, + eventType: 'SOME_EVENT', + unsubscribe: jest.fn(), + updateSubscribe: jest.fn(), + }; + + const result = fakeComponent.updateSubscription(subscriptionToUpdate, 'New Group', [ + 'New sub group', + ]); + + expect(subscribeSpy).toHaveBeenCalledWith( + subscriptionToUpdate.eventType, + subscriptionToUpdate.callback, + subscriptionToUpdate.component, + undefined, + ); + + expect(unsubscribeSpy).not.toHaveBeenCalled(); + }); + + it('updateSubscription updates groups and subgroups and component as expected', () => { + const fakeComponent = new ChildActionSet(app); + const subscriptionCbSpy = jest.fn(); + + const unsubscribeSpy = jest.fn(); + app.subscribe = jest.fn().mockReturnValue(unsubscribeSpy); + + fakeComponent.subscribe('SOME_EVENT', subscriptionCbSpy); + + const subscribeSpy = jest.spyOn(fakeComponent, 'subscribe'); + + const result = fakeComponent.updateSubscription(fakeComponent.subscriptions[0], 'New Group', [ + 'New sub group', + ]); + + expect(fakeComponent.group).toEqual('New Group'); + expect(fakeComponent.subgroups).toEqual(['New sub group']); + + expect(fakeComponent.subscriptions[0].component).toEqual({ + ...fakeComponent.subscriptions[0].component, + subgroups: ['New sub group'], + }); + }); + + it('set returns result of child class set method', () => { + class MyActionSet extends ActionSet { + constructor(myApp: ClientApplication) { + super(myApp, 'ChildType', 'SomeChildGroup'); + } + set() { + return 'mama mia!'; + } + } + + const fakeComponent = new MyActionSet(app); + + expect(fakeComponent.set()).toEqual('mama mia!'); + }); + + describe('with Hooks', () => { + it('set calls to run UpdateAction hook with current action instance and given options', () => { + app.hooks = new Hooks(); + const fakeComponent = new ChildActionSet(app); + const hookRunSpy = jest.spyOn(app.hooks, 'run'); + const options = {type: 'linguini'}; + + fakeComponent.set(options); + + expect(hookRunSpy).toHaveBeenCalledWith( + LifecycleHook.UpdateAction, + expect.any(Function), + fakeComponent, + options, + ); + }); + + it('set returns result of child class set method', () => { + app.hooks = new Hooks(); + + class MyActionSet extends ActionSet { + constructor(myApp: ClientApplication) { + super(myApp, 'ChildType', 'SomeChildGroup'); + } + set() { + return 'mama mia!'; + } + } + + const fakeComponent = new MyActionSet(app); + + expect(fakeComponent.set()).toEqual('mama mia!'); + }); + }); +}); + +describe('ActionSetWithChildren', () => { + const unsubscribeStub = jest.fn(); + const app = createMockApp(); + const fakeButton = new Button(app, {label: 'Button A'}); + + const fakeButtonGroup = new ButtonGroup(app, { + buttons: [fakeButton], + disabled: false, + label: 'Button Group', + }); + + beforeEach(() => { + app.subscribe = () => unsubscribeStub; + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + it('unsubscribes removes ONLY parent subscriptions and resets group/subgroups for children when called with unsubscribeChildren = `false`', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + fakeParent.addChild(fakeChild, fakeParent.group, ['SomeSubgroup']); + fakeParent.subscribe('click', jest.fn()); + fakeChild.subscribe('click', jest.fn()); + + unsubscribeStub.mockClear(); + fakeParent.unsubscribe(false); + expect(fakeParent.subscriptions.length).toBe(0); + + // Child should still have a subscription and its group and subgroups should have been reset + expect(fakeChild.subscriptions.length).toBe(1); + expect(fakeChild).toMatchObject({ + group: 'SomeChildGroup', + subgroups: [], + }); + }); + + it('unsubscribes removes subscriptions for parent and all children by default', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + + fakeChild.subscribe('click', jest.fn()); + fakeParent.addChild(fakeChild, fakeParent.group, ['SomeSubgroup']); + fakeParent.subscribe('click', jest.fn()); + + unsubscribeStub.mockClear(); + fakeParent.unsubscribe(); + + expect(fakeParent.subscriptions.length).toBe(0); + expect(fakeChild.subscriptions.length).toBe(0); + }); + + it('unsubscribes keeps all subscriptions and resets group/subgroups for parent and children when unsubscribe is called with unsubscribeChildren = `false` and resetParent = `true`', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + fakeParent.group = 'SomeNewGroup'; + + fakeParent.addChild(fakeChild, fakeParent.group, ['SomeSubgroup']); + fakeParent.subscribe('click', jest.fn()); + fakeChild.subscribe('click', jest.fn()); + + unsubscribeStub.mockClear(); + + // Subscription should match new group + expect(fakeParent.subscriptions.length).toBe(1); + expect(fakeParent).toMatchObject({ + group: 'SomeNewGroup', + subgroups: [], + }); + + // Subscription should match new parent group and given subgroups + expect(fakeChild.subscriptions.length).toBe(1); + expect(fakeChild).toMatchObject({ + group: 'SomeNewGroup', + subgroups: ['SomeSubgroup'], + }); + + unsubscribeStub.mockClear(); + + fakeParent.unsubscribe(false, true); + + // Subscription resets to default parent group + expect(fakeParent.subscriptions.length).toBe(1); + expect(fakeParent).toMatchObject({ + group: 'SomeParentGroup', + subgroups: [], + }); + + // Subscription resets to default child group + expect(fakeChild.subscriptions.length).toBe(1); + expect(fakeChild).toMatchObject({ + group: 'SomeChildGroup', + subgroups: [], + }); + }); + + it('unsubscribes removes resets group/subgroups for parent and removes all children subscriptions when unsubscribe is called with unsubscribeChildren = `true` and resetParent = `true`', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + fakeParent.group = 'SomeNewGroup'; + + fakeParent.addChild(fakeChild, fakeParent.group, ['SomeSubgroup']); + fakeParent.subscribe('click', jest.fn()); + fakeChild.subscribe('click', jest.fn()); + + unsubscribeStub.mockClear(); + fakeParent.unsubscribe(true, true); + + // Subscription resets to default parent group + expect(fakeParent.subscriptions.length).toBe(1); + expect(fakeParent).toMatchObject({ + group: 'SomeParentGroup', + subgroups: [], + }); + + expect(fakeChild.subscriptions.length).toBe(0); + }); + + it('subscribeToChild updates subscription if it already exists', () => { + const eventName = 'SOME_ACTION'; + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + const updateCb = jest.fn(); + fakeParent.addChild(fakeChild, fakeParent.group, fakeParent.subgroups); + + fakeParent.subscribeToChild(fakeChild, eventName, updateCb); + + // Update group and subgroup + fakeParent.group = 'New parent group'; + fakeChild.subgroups = ['New subgroup']; + + fakeParent.subscribeToChild(fakeChild, eventName, updateCb); + + const subscriptions = fakeParent.getChildSubscriptions(fakeChild.id, eventName); + expect(subscriptions).toHaveLength(1); + + expect(subscriptions[0]).toMatchObject({ + component: { + id: fakeChild.id, + subgroups: fakeChild.subgroups, + type: fakeChild.type, + }, + eventType: eventName, + }); + }); + + it('subscribeToChild accepts an array of event names and subscribes to each one', () => { + const eventNames = ['SOME_ACTION', 'ANOTHER_ACTION']; + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + const updateCb = jest.fn(); + fakeParent.addChild(fakeChild, fakeParent.group, fakeParent.subgroups); + + fakeParent.subscribeToChild(fakeChild, eventNames, updateCb); + + const subscriptions = fakeParent.getChildSubscriptions(fakeChild.id); + expect(subscriptions).toHaveLength(2); + subscriptions.forEach((sub, index) => { + expect(sub.eventType).toBe(eventNames[index]); + }); + }); + + it('removeChild removes child from parent and remove any subscriptions to the child', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + fakeParent.addChild(fakeChild, fakeParent.group, fakeParent.subgroups); + fakeParent.subscribeToChild(fakeChild, 'SOME_ACTION', jest.fn()); + + expect(fakeParent.getChildSubscriptions(fakeChild.id, 'SOME_ACTION')).toHaveLength(1); + expect(fakeParent.getChild(fakeChild.id)).toBe(fakeChild); + fakeParent.removeChild(fakeChild.id); + + expect(fakeParent.getChildSubscriptions(fakeChild.id, 'SOME_ACTION')).toHaveLength(0); + expect(fakeParent.getChild(fakeChild.id)).not.toBeDefined(); + }); + + it('getSingleButton calls addChild with expected args', () => { + const fakeParent = new ParentActionSet(app); + const addChildSpy = jest.spyOn(fakeParent, 'addChild'); + fakeParent.group = 'SomeNewGroup'; + + getSingleButton(fakeParent, fakeButton, ['SomeSubgroup'], jest.fn()); + + expect(addChildSpy).toHaveBeenCalledWith(fakeButton, 'SomeNewGroup', ['SomeSubgroup']); + }); + + it('getSingleButton calls subscribeToChild with expected args', () => { + const fakeParent = new ParentActionSet(app); + fakeParent.group = 'SomeGroup'; + + const updateStub = jest.fn(); + const subscribeToChildSpy = jest.spyOn(fakeParent, 'subscribeToChild'); + getSingleButton(fakeParent, fakeButton, ['SomeSubgroup'], updateStub); + + expect(subscribeToChildSpy).toHaveBeenCalledWith(fakeButton, ButtonAction.UPDATE, updateStub); + }); + + it('getGroupedButton calls addChild with expected args', () => { + const fakeParent = new ParentActionSet(app); + const addChildSpy = jest.spyOn(fakeParent, 'addChild'); + fakeParent.group = 'SomeGroup'; + + getGroupedButton(fakeParent, fakeButtonGroup, ['SomeSubgroup'], jest.fn()); + + expect(addChildSpy).toHaveBeenCalledWith(fakeButtonGroup, 'SomeGroup', ['SomeSubgroup']); + }); + + it('getGroupedButton calls subscribeToChild with expected args', () => { + const fakeParent = new ParentActionSet(app); + const subscribeToChildSpy = jest.spyOn(fakeParent, 'subscribeToChild'); + fakeParent.group = 'SomeGroup'; + + const updateStub = jest.fn(); + + getGroupedButton(fakeParent, fakeButtonGroup, ['SomeSubgroup'], updateStub); + expect(subscribeToChildSpy).toHaveBeenCalledWith( + fakeButtonGroup, + ButtonGroupAction.UPDATE, + updateStub, + ); + }); + + it('getChildIndex returns correct index for child matching given id', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild1 = new ChildActionSet(app); + const fakeChild2 = new ChildActionSet(app); + const fakeChild3 = new ChildActionSet(app); + + fakeParent.addChild(fakeChild1, fakeParent.group, fakeParent.subgroups); + fakeParent.addChild(fakeChild2, fakeParent.group, fakeParent.subgroups); + fakeParent.addChild(fakeChild3, fakeParent.group, fakeParent.subgroups); + expect(fakeParent.getChildIndex(fakeChild2.id)).toBe(1); + }); + + it('getChild returns child matching given id', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + fakeParent.addChild(fakeChild, fakeParent.group, fakeParent.subgroups); + expect(fakeParent.getChild(fakeChild.id)).toBe(fakeChild); + }); + + it('getChild returns undefined when no child exists for given id', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + fakeParent.addChild(fakeChild, fakeParent.group, fakeParent.subgroups); + expect(fakeParent.getChild('1234')).toBe(undefined); + }); + + it('getUpdatedChildActions calls to remove all children if new array of children is empty', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild1 = new ChildActionSet(app); + const fakeChild2 = new ChildActionSet(app); + const fakeChild3 = new ChildActionSet(app); + + fakeParent.addChild(fakeChild1, fakeParent.group, fakeParent.subgroups); + fakeParent.addChild(fakeChild2, fakeParent.group, fakeParent.subgroups); + fakeParent.addChild(fakeChild3, fakeParent.group, fakeParent.subgroups); + + const removeSpy = jest.spyOn(fakeParent, 'removeChild'); + fakeParent.getUpdatedChildActions([], fakeParent.children); + expect(removeSpy).toHaveBeenCalledTimes(3); + expect(removeSpy).toHaveBeenCalledWith(fakeChild1.id); + expect(removeSpy).toHaveBeenCalledWith(fakeChild2.id); + expect(removeSpy).toHaveBeenCalledWith(fakeChild3.id); + }); + + it('getUpdatedChildActions calls to remove all children that do not exist in the new child array', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild1 = new ChildActionSet(app); + const fakeChild2 = new ChildActionSet(app); + const fakeChild3 = new ChildActionSet(app); + + fakeParent.addChild(fakeChild1, fakeParent.group, fakeParent.subgroups); + fakeParent.addChild(fakeChild2, fakeParent.group, fakeParent.subgroups); + fakeParent.addChild(fakeChild3, fakeParent.group, fakeParent.subgroups); + + const removeSpy = jest.spyOn(fakeParent, 'removeChild'); + fakeParent.getUpdatedChildActions([fakeChild1], fakeParent.children); + expect(removeSpy).toHaveBeenCalledTimes(2); + expect(removeSpy).toHaveBeenCalledWith(fakeChild2.id); + expect(removeSpy).toHaveBeenCalledWith(fakeChild3.id); + }); + + it('getUpdatedChildActions returns a list of unique children', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild1 = new ChildActionSet(app); + const fakeChild2 = new ChildActionSet(app); + const fakeChild3 = new ChildActionSet(app); + + fakeParent.addChild(fakeChild1, fakeParent.group, fakeParent.subgroups); + + const newActions = fakeParent.getUpdatedChildActions( + [fakeChild1, fakeChild2, fakeChild2, fakeChild2, fakeChild3], + fakeParent.children, + ); + expect(newActions).toEqual([fakeChild1, fakeChild2, fakeChild3]); + }); + + it("addChild only adds child if it doesn't already exist", () => { + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + fakeParent.addChild(fakeChild, fakeParent.group, fakeParent.subgroups); + + expect(fakeParent.children).toEqual([fakeChild]); + fakeParent.addChild(fakeChild, fakeParent.group, fakeParent.subgroups); + + expect(fakeParent.children).toEqual([fakeChild]); + }); + + it('addChild assigns new subgroups and group for child', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + + fakeParent.addChild(fakeChild, 'SomeGroup', ['SomeSubgroup']); + expect(fakeChild.group).toEqual('SomeGroup'); + expect(fakeChild.subgroups).toEqual(['SomeSubgroup']); + }); + + it('addChild call updateSubscribe for each child subscription', () => { + const fakeParent = new ParentActionSet(app); + const fakeChild = new ChildActionSet(app); + + const fakeSubscription1: ActionSubscription = { + callback: jest.fn(), + component: { + id: '123', + type: 'test', + }, + eventType: 'SOME_EVENT', + unsubscribe: jest.fn(), + updateSubscribe: jest.fn(), + }; + const fakeSubscription2: ActionSubscription = { + callback: jest.fn(), + component: { + id: '456', + type: 'test', + }, + eventType: 'SOME_EVENT', + unsubscribe: jest.fn(), + updateSubscribe: jest.fn(), + }; + + fakeChild.subscriptions = [fakeSubscription1, fakeSubscription2]; + + fakeParent.addChild(fakeChild, 'SomeGroup', ['SomeSubgroup']); + expect(fakeSubscription1.updateSubscribe).toHaveBeenLastCalledWith('SomeGroup', [ + 'SomeSubgroup', + ]); + expect(fakeSubscription2.updateSubscribe).toHaveBeenLastCalledWith('SomeGroup', [ + 'SomeSubgroup', + ]); + }); + + it("addChild calls addChild for child's children with correct group and subgroup", () => { + const fakeParent = new ParentActionSet(app); + const fakeChildWithChildrenChild = new ParentActionSet(app); + const fakeChild1 = new ChildActionSet(app); + const fakeChild2 = new ChildActionSet(app); + fakeChildWithChildrenChild.children = [fakeChild1, fakeChild2]; + + const addChildSpy = jest.spyOn(fakeParent, 'addChild'); + fakeParent.addChild(fakeChildWithChildrenChild, 'SomeGroup', ['SomeSubgroup']); + + expect(addChildSpy).toHaveBeenCalledTimes(3); + expect(addChildSpy).toHaveBeenCalledWith(fakeChild1, 'SomeGroup', ['SomeSubgroup']); + expect(addChildSpy).toHaveBeenCalledWith(fakeChild2, 'SomeGroup', ['SomeSubgroup']); + }); +}); + +describe('updateActionFromPayload', () => { + interface Greeting extends ActionSetInterface { + greeting: string; + name: string; + } + const org: Greeting = { + app: createMockApp(), + defaultGroup: '', + greeting: 'Hello', + group: 'Friendly', + id: '123', + name: 'You', + subgroups: [], + subscriptions: [], + type: 'Greeting', + }; + + it('returns true if object was updated with new props', () => { + const newProps: Partial = { + greeting: 'Howdy', + id: '123', + }; + expect(updateActionFromPayload(org, newProps)).toBe(true); + }); + + it('returns false if object was NOT updated with new props', () => { + const newProps: Partial = { + greeting: 'Morning!', + id: '456', + }; + + const newProps2: Partial = { + greeting: 'Howdy', + }; + expect(updateActionFromPayload(org, newProps)).toBe(false); + expect(updateActionFromPayload(org, newProps2)).toBe(false); + }); + + it('updates object with new props if id matches', () => { + const newProps: Partial = { + greeting: 'Morning!', + id: '123', + type: 'Daytime Greeting', + }; + updateActionFromPayload(org, newProps); + expect(org).toEqual({...org, greeting: 'Morning!', type: 'Daytime Greeting'}); + }); +}); + +describe('getMergedProps', () => { + it('returns shallow merged object if deepMerge results in undefined', () => { + jest.spyOn(DeepMerge, 'default').mockImplementationOnce(jest.fn().mockReturnValue(undefined)); + const result = getMergedProps( + {label: 'test', component: {type: 'some type'}}, + {label: 'updated', component: {something: 'a'}}, + ); + expect(result).toEqual({ + component: {something: 'a'}, + label: 'updated', + }); + }); + + it('returns deep merged object', () => { + const result = getMergedProps( + {label: 'test', component: {type: 'some type'}}, + {label: 'updated', component: {something: 'a'}}, + ); + expect(result).toEqual({ + label: 'updated', + component: {type: 'some type', something: 'a'}, + }); + }); +}); diff --git a/src/actions/tests/merge.test.ts b/src/actions/tests/merge.test.ts new file mode 100644 index 0000000..6d618bd --- /dev/null +++ b/src/actions/tests/merge.test.ts @@ -0,0 +1,215 @@ +import {ClientApplication} from '../../client'; +import {createMockApp} from 'test/helper'; +import mergeProps from '../merge'; +import {Button} from '../Button'; +import {ButtonGroup} from '../ButtonGroup'; + +describe('mergeProps', () => { + it('should update existing nested props and add new nested props', () => { + const org = { + attributes: { + component: { + group: { + subgroup: { + subSubGroup: { + a: 1, + b: 2, + }, + }, + }, + id: '123', + }, + type: 'My Type', + }, + name: 'A', + }; + const newProps = { + attributes: { + component: { + id: '234', + }, + options: { + a: 1, + b: 2, + c: { + name: 'C', + }, + }, + }, + name: 'B', + }; + const result = mergeProps(org, newProps); + expect(result).toEqual({ + attributes: { + component: { + group: { + subgroup: { + subSubGroup: { + a: 1, + b: 2, + }, + }, + }, + id: '234', + }, + options: { + a: 1, + b: 2, + c: { + name: 'C', + }, + }, + type: 'My Type', + }, + name: 'B', + }); + }); + + it('should merge updated props with value set to undefined', () => { + const org = { + attributes: { + component: { + group: { + subgroup: { + subSubGroup: { + a: 1, + b: 2, + }, + }, + }, + id: '123', + }, + type: 'My Type', + }, + name: 'A', + }; + const newProps = { + attributes: { + component: { + group: { + subgroup: undefined, + }, + }, + options: { + a: 1, + b: 2, + }, + }, + name: 'B', + }; + const result = mergeProps(org, newProps); + expect(result).toEqual({ + attributes: { + component: { + group: { + subgroup: undefined, + }, + id: '123', + }, + options: { + a: 1, + b: 2, + }, + type: 'My Type', + }, + name: 'B', + }); + }); + + it('should replace entire array when updating props', () => { + const org = { + attributes: { + component: { + groups: [ + { + name: 'X', + }, + { + name: 'Y', + }, + { + name: 'Z', + }, + ], + id: '123', + }, + type: 'My Type', + }, + name: 'A', + }; + const newProps = { + attributes: { + component: { + groups: [ + { + name: 'ZZZ', + }, + ], + }, + options: { + a: 1, + b: 2, + }, + }, + name: 'B', + }; + const result = mergeProps(org, newProps); + expect(result).toEqual({ + attributes: { + component: { + groups: [ + { + name: 'ZZZ', + }, + ], + id: '123', + }, + options: { + a: 1, + b: 2, + }, + type: 'My Type', + }, + name: 'B', + }); + }); + + it('should merge complex objects without cloning', () => { + const app = createMockApp(); + const button1 = new Button(app, {label: 'Button A', disabled: true}); + const button2 = new Button(app, {label: 'Button B', disabled: false}); + const button3 = new Button(app, {label: 'Button A - New', disabled: false}); + const button4 = new Button(app, {label: 'Brand new button', disabled: false}); + const buttonGroup = new ButtonGroup(app, {label: 'Button C', disabled: true, buttons: []}); + + const org = { + buttonA: button1, + buttonB: button2, + buttonC: button4, + name: '123', + }; + const newProps = { + buttonA: button3, + buttonB: buttonGroup, + name: '456', + }; + const result = mergeProps(org, newProps); + expect(result).toEqual({ + buttonA: button3, + buttonB: buttonGroup, + buttonC: button4, + name: '456', + }); + expect(result.buttonA).toBe(button3); + expect(result.buttonB).toBe(buttonGroup); + expect(result.buttonC).toBe(button4); + }); + + it('should merge new props when existing object is not defined', () => { + const newProps = { + name: 'A', + }; + const result = mergeProps(undefined, newProps); + expect(result).toEqual(newProps); + }); +}); diff --git a/src/actions/tests/validator.test.ts b/src/actions/tests/validator.test.ts new file mode 100644 index 0000000..cd6df99 --- /dev/null +++ b/src/actions/tests/validator.test.ts @@ -0,0 +1,38 @@ +import {isAppMessage, isAppBridgeAction, isFromApp} from '../validator'; + +describe('validator', () => { + it('isAppBridgeAction returns expected result', () => { + expect(isAppBridgeAction({type: 'APP::SOME_ACTION'})).toBe(true); + expect(isAppBridgeAction({type: 'SOME_ACTION'})).toBe(false); + expect(isAppBridgeAction({type: 'SOMEACTION::APP::SOMETHING'})).toBe(false); + expect(isAppBridgeAction({})).toBe(false); + expect(isAppBridgeAction()).toBe(false); + expect(isAppBridgeAction(1234)).toBe(false); + }); + + it('isFromApp returns expected result', () => { + expect(isFromApp({source: {apiKey: '1234'}})).toBe(true); + expect(isFromApp({source: 1234})).toBe(false); + expect(isFromApp({source: '1234'})).toBe(false); + expect(isFromApp({type: 'SOMEACTION::APP::SOMETHING'})).toBe(false); + expect(isFromApp({})).toBe(false); + expect(isFromApp()).toBe(false); + expect(isFromApp(1234)).toBe(false); + }); + + it('isAppMessage returns true when message data type is `dispatch`', () => { + expect(isAppMessage({data: {type: 'dispatch'}})).toBe(true); + }); + it('isAppMessage returns true when message data type is `getState`', () => { + expect(isAppMessage({data: {type: 'getState'}})).toBe(true); + }); + + it('isAppMessage returns false when message data does not match `dispatch` or `getState`', () => { + expect(isAppMessage({data: 'some data'})).toBe(false); + expect(isAppMessage({data: {type: 'something else'}})).toBe(false); + expect(isAppMessage({data: null})).toBe(false); + expect(isAppMessage({data: 1234})).toBe(false); + expect(isAppMessage({})).toBe(false); + expect(isAppMessage()).toBe(false); + }); +}); diff --git a/src/actions/types.ts b/src/actions/types.ts new file mode 100644 index 0000000..1cb960c --- /dev/null +++ b/src/actions/types.ts @@ -0,0 +1,199 @@ +import {ClientApplication} from '../client'; +import {ActionSet} from './helper'; +import {ErrorAction} from './Error/actions'; + +/** + * Various action groups. + * @public + */ +export enum Group { + Button = 'Button', + ButtonGroup = 'ButtonGroup', + Camera = 'Camera', + Cart = 'Cart', + Error = 'Error', + Features = 'Features', + Toast = 'Toast', + Loading = 'Loading', + Modal = 'Modal', + Navigation = 'Navigation', + Print = 'Print', + TitleBar = 'TitleBar', + ResourcePicker = 'Resource_Picker', +} + +/** + * @internal + */ +export enum ComponentType { + Button = 'Button', + ButtonGroup = 'ButtonGroup', +} + +/** + * Base action interface. + * @remarks + * All action implementations should inherit from this interface. + * @internalremarks + * Should we remove the extraProps definition here, pushing it on sub-types? + * @public + */ +export interface AnyAction { + type: any; + [extraProps: string]: any; +} + +/** + * @public + */ +export interface MetaAction extends AnyAction { + readonly version: string; + readonly group: string; + readonly type: string; + payload?: any; +} + +/** + * @public + */ +export interface ClickAction extends MetaAction { + payload: { + id: string; + payload?: any; + }; +} + +/** + * @public + */ +export interface ActionCallback { + (data: any): void; +} + +/** + * @public + */ +export interface ErrorCallback { + (data: ErrorAction): void; +} + +/** + * @public + */ +export interface UpdateSubscribe { + (group: string, subgroups: string[]): void; +} + +/** + * @public + */ +export interface Unsubscribe { + (): void; +} + +/** + * @public + */ +export interface ErrorSubscriber { + (callback: ErrorCallback): Unsubscribe; +} + +/** + * @internal + */ +export interface ActionSubscription { + component: Component; + eventType: string; + callback: ActionCallback; + unsubscribe: Unsubscribe; + updateSubscribe: UpdateSubscribe; +} + +/** + * @internal + */ +export interface UpdateSubscription { + (subscriptionToRemove: ActionSubscription, group: string, subgroups: string[]): void; +} + +/** + * @public + */ +export interface Component { + readonly id: string; + readonly type: string; + subgroups?: string[]; +} + +/** + * @public + */ +export interface ActionSetInterface extends Component { + readonly app: ClientApplication; + readonly defaultGroup: string; + group: string; + component: Component; + subscriptions: ActionSubscription[]; + updateSubscription: UpdateSubscription; + error: ErrorSubscriber; + subscribe( + eventName: string, + callback: ActionCallback, + component?: Component, + currentIndex?: number, + ): Unsubscribe; + unsubscribe(resetOnly: boolean): ActionSetInterface; +} + +/** + * @public + */ +export interface DispatchAction { + type: string; + payload: any; +} + +/** + * @public + */ +export interface SimpleDispatch { + dispatch(action: string): ActionSet; +} + +/** + * @public + */ +export interface ComplexDispatch

{ + dispatch(action: string, payload: P): ActionSet; +} + +/** + * @public + */ +export interface ActionSetProps extends SimpleDispatch { + options: T; + payload: P; + set(options: Partial): ActionSet; +} + +/** + * @public + */ +export interface ActionSetPayload

extends SimpleDispatch { + payload: P; +} + +/** + * @public + */ +export interface ActionSetOptions { + options: T; + set(options: Partial): ActionSet; +} + +/** + * @public + */ +export interface Dispatch<_> { + (action: A): A; +} diff --git a/src/actions/uuid.ts b/src/actions/uuid.ts new file mode 100644 index 0000000..ff59964 --- /dev/null +++ b/src/actions/uuid.ts @@ -0,0 +1,60 @@ +/** + * Convert a number or array of integers to a string of padded hex octets. + * @internal + */ +function asHex(value: number[] | Uint8Array): string { + return Array.from(value) + .map(i => `00${i.toString(16)}`.slice(-2)) + .join(''); +} + +/** + * Attempt to securely generate random bytes/ + * @internal + */ +function getRandomBytes(size: number): number[] | Uint8Array { + // SPRNG + if (typeof Uint8Array === 'function' && window.crypto) { + const buffer = new Uint8Array(size); + const randomValues = window.crypto.getRandomValues(buffer) as Uint8Array; + + if (randomValues) { + return randomValues; + } + } + + // Insecure random + return Array.from(new Array(size), () => (Math.random() * 255) | 0); +} + +/** + * Generate a RFC4122-compliant v4 UUID. + * + * @see http://www.ietf.org/rfc/rfc4122.txt + * @internal + */ +export function generateUuid(): string { + const version = 0b01000000; + const clockSeqHiAndReserved = getRandomBytes(1); + const timeHiAndVersion = getRandomBytes(2); + + clockSeqHiAndReserved[0] &= 0b00111111 | 0b10000000; + // tslint:disable-next-line:binary-expression-operand-order + timeHiAndVersion[0] &= 0b00001111 | version; + + return [ + asHex(getRandomBytes(4)), // time-low + '-', + asHex(getRandomBytes(2)), // time-mid + '-', + asHex(timeHiAndVersion), // time-high-and-version + '-', + asHex(clockSeqHiAndReserved), // clock-seq-and-reserved + asHex(getRandomBytes(1)), // clock-seq-loq + '-', + asHex(getRandomBytes(6)), // node + ].join(''); +} + +// Default +export default generateUuid; diff --git a/src/actions/validator.ts b/src/actions/validator.ts new file mode 100644 index 0000000..28ff5e9 --- /dev/null +++ b/src/actions/validator.ts @@ -0,0 +1,40 @@ +import {MetaAction} from '../actions/types'; +import {PREFIX} from './constants'; + +/** + * Predicate to determine if an action is an App Bridge action. + * @public + */ +export function isAppBridgeAction(action: any): action is MetaAction { + return ( + action instanceof Object && + action.hasOwnProperty('type') && + action.type.toString().startsWith(PREFIX) + ); +} + +/** + * Predicate to determine if an action originated from an application. + * @internal + */ +export function isFromApp(action: any) { + if (typeof action !== 'object' || typeof action.source !== 'object') { + return false; + } + + return typeof action.source.apiKey === 'string'; +} + +/** + * Predicate to determine if an event originated from an application. + * @internal + */ +export function isAppMessage(event: any) { + if (typeof event !== 'object' || !event.data || typeof event.data !== 'object') { + return false; + } + + const {data} = event; + + return data.hasOwnProperty('type') && (data.type === 'getState' || data.type === 'dispatch'); +} diff --git a/src/client/Client.ts b/src/client/Client.ts new file mode 100644 index 0000000..689a584 --- /dev/null +++ b/src/client/Client.ts @@ -0,0 +1,298 @@ +import {Group} from '../actions'; +import {findMatchInEnum, forEachInEnum} from '../actions/helper'; +import {throwError, ActionType as ErrorTypes, fromAction, AppActionType} from '../actions/Error'; +import {ActionType as PrintActionType} from '../actions/Print'; +import {fromWindow} from '../MessageTransport'; +import {addAndRemoveFromCollection} from '../util/collection'; +import {isDevelopmentClient} from '../util/env'; +import {handleAppPrint} from './print'; +import {getLocation, getWindow, redirect, shouldRedirect} from './redirect'; +import { + ActionListener, + ActionListenersMap, + AppConfig, + AppMiddleware, + ClientApplication, + ClientApplicationCreator, + ClientApplicationTransportInjector, + Handler, + HandlerData, + LifecycleHook, + Params, + Unsubscribe, +} from './types'; +import Hooks from './Hooks'; + +const WINDOW_UNDEFINED_MESSAGE = + 'window is not defined. Running an app outside a browser is not supported'; + +function redirectHandler(hostFrame: Window, config: AppConfig) { + const {apiKey, shopOrigin, forceRedirect = !isDevelopmentClient} = config; + const location = getLocation(); + if (!location) { + return; + } + if (forceRedirect && shouldRedirect(hostFrame) && apiKey && shopOrigin) { + const url = `https://${shopOrigin}/admin/apps/${apiKey}${location.pathname}${location.search || + ''}`; + redirect(url); + } +} + +function appSetUp(app: ClientApplication) { + app.subscribe(PrintActionType.APP, handleAppPrint); +} + +/** + * Extracts the query parameters from the current URL. + * @deprecated This function has been deprecated. + * @public + */ +export function getUrlParams() { + const params: Params = {}; + const location = getLocation(); + + if (!location) { + return params; + } + + const hashes = location.search.slice(location.search.indexOf('?') + 1).split('&'); + + return hashes.reduce((acc, hash) => { + const [key, val] = hash.split('='); + + return { + ...acc, + [decodeURIComponent(key)]: decodeURIComponent(val), + }; + }, params); +} + +/** + * Extracts the `shop` query parameter from the current URL. + * @deprecated This function has been deprecated, see {@link https://help.shopify.com/api/embedded-apps/shop-origin} + * @public + */ +export function getShopOrigin() { + const params = getUrlParams(); + + return params.shop; +} + +/** + * @internal + */ +export const createClientApp: ClientApplicationTransportInjector = ( + transport, + middlewares = [], +) => { + const getStateListeners: Function[] = []; + const listeners: ActionListener[] = []; + const actionListeners: ActionListenersMap = {}; + + const invokeCallbacks = (type: string, payload?: any) => { + let hasCallback = false; + if (actionListeners.hasOwnProperty(type)) { + for (const listener of actionListeners[type]) { + const {id, callback} = listener; + const matchId = payload && payload.id === id; + if (matchId || !id) { + callback(payload); + hasCallback = true; + } + } + } + if (hasCallback) { + return; + } + // Throw an error if there are no subscriptions to this error + const errorType = findMatchInEnum(ErrorTypes, type); + if (errorType) { + throwError(errorType, payload); + } + }; + + const handler: Handler = (event: HandlerData) => { + const action = event.data; + + switch (action.type) { + case 'getState': + const resolvers = getStateListeners.splice(0); + resolvers.forEach(resolver => resolver(action.payload)); + break; + + case 'dispatch': + const {payload} = action; + invokeCallbacks(payload.type, payload.payload); + listeners.forEach(listener => listener.callback(payload)); + break; + + default: + // Silently swallow unknown actions + } + }; + + transport.subscribe(handler); + + return (config: AppConfig /*initialState*/) => { + if (!config.shopOrigin) { + throw fromAction('shopOrigin must be provided', AppActionType.INVALID_CONFIG); + } + + if (!config.apiKey) { + throw fromAction('apiKey must be provided', AppActionType.INVALID_CONFIG); + } + + redirectHandler(transport.hostFrame, config); + + const dispatch = (action: A) => { + const augmentedAction = { + payload: action, + source: config, + type: 'dispatch', + }; + + handler({data: augmentedAction}); + transport.dispatch(augmentedAction); + + return action; + }; + + const hooks = new Hooks(); + const app: ClientApplication = { + localOrigin: transport.localOrigin, + + hooks, + + dispatch(action: A) { + if (!app.hooks) { + return dispatch(action); + } + return app.hooks.run(LifecycleHook.DispatchAction, dispatch, app, action); + }, + + featuresAvailable(features?: Group[]) { + return app.getState('features').then(state => { + if (features) { + Object.keys(state).forEach(feature => { + if (!features.includes(feature as Group)) { + delete state[feature]; + } + }); + } + + return state; + }); + }, + + getState(query?: string) { + return new Promise(resolve => { + getStateListeners.push(resolve); + transport.dispatch({ + payload: undefined, + source: config, + type: 'getState', + }); + }).then((state: any) => { + if (query) { + return query.split('.').reduce((value, key) => { + if (typeof state !== 'object' || Array.isArray(state)) { + return undefined; + } + value = state[key]; + state = value; + + return value; + }, undefined); + } + + return state; + }); + }, + + subscribe() { + if (arguments.length < 2) { + return addAndRemoveFromCollection(listeners, {callback: arguments[0]}); + } + + const eventNameSpace = arguments[0]; + const callback = arguments[1]; + const id = arguments[2]; + const actionCallback: ActionListener = {callback, id}; + if (!actionListeners.hasOwnProperty(eventNameSpace)) { + actionListeners[eventNameSpace] = []; + } + + return addAndRemoveFromCollection(actionListeners[eventNameSpace], actionCallback); + }, + + error(listener, id?) { + const unsubscribeCb: Unsubscribe[] = []; + forEachInEnum(ErrorTypes, eventNameSpace => { + // tslint:disable-next-line:no-invalid-this + unsubscribeCb.push(this.subscribe(eventNameSpace, listener, id)); + }); + + return () => { + unsubscribeCb.forEach(unsubscribe => unsubscribe()); + }; + }, + }; + + for (const middleware of middlewares) { + middleware(hooks, app); + } + + appSetUp(app); + + return app; + }; +}; + +/** + * @public + */ +export function createAppWrapper( + frame: Window, + localOrigin?: string, + middleware: AppMiddleware[] = [], +): ClientApplicationCreator { + if (!frame) { + throw fromAction(WINDOW_UNDEFINED_MESSAGE, AppActionType.WINDOW_UNDEFINED); + } + + const location = getLocation(); + const origin = localOrigin || (location && location.origin); + + if (!origin) { + throw fromAction('local origin cannot be blank', AppActionType.MISSING_LOCAL_ORIGIN); + } + + const transport = fromWindow(frame, origin); + const appCreator = createClientApp(transport, middleware); + + return appCreator; +} + +/** + * Creates your application instance. + * @param config - Both `apiKey` and `shopOrigin` are required. + * @remarks + * You will need to store `shopOrigin` during the authentication process and then retrieve it for the code to work properly. To learn more about this process, see {@link https://help.shopify.com/api/embedded-apps/shop-origin | Getting and storing the shop origin}. + * @public + */ +export function createApp(config: AppConfig): ClientApplication { + const currentWindow = getWindow(); + + if (!currentWindow) { + throw fromAction(WINDOW_UNDEFINED_MESSAGE, AppActionType.WINDOW_UNDEFINED); + } + + return createAppWrapper(currentWindow.top)(config); +} + +/** + * {@inheritdocs createApp} + * @public + */ +export default createApp; diff --git a/src/client/Hooks.ts b/src/client/Hooks.ts new file mode 100644 index 0000000..f1a5203 --- /dev/null +++ b/src/client/Hooks.ts @@ -0,0 +1,39 @@ +import {HooksInterface, Hook, HookMap, LifecycleHandler, LifecycleHook} from './types'; +import {addAndRemoveFromCollection} from '../util/collection'; + +export default class Hooks implements HooksInterface { + private map: HookMap = {}; + + set(hook: LifecycleHook, handler: LifecycleHandler) { + if (!this.map.hasOwnProperty(hook)) { + this.map[hook] = []; + } + + let value: Hook = {handler, remove: () => {}}; + const remove = addAndRemoveFromCollection(this.map[hook], value); + value = {handler, remove}; + return remove; + } + + get(hook: LifecycleHook) { + const value = this.map[hook]; + return value ? value.map(val => val.handler) : undefined; + } + + run(hook: LifecycleHook, final: Function, context: C, ...initialArgs: any[]) { + let index = 0; + const handlers = this.get(hook) || []; + + function handler(...args: any[]) { + const childHandler = handlers[index++]; + + if (childHandler) { + return childHandler(handler).apply(context, args); + } + + return final.apply(context, args); + } + + return handler.apply(context, initialArgs); + } +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..18d1245 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,6 @@ +import createClientApp from './Client'; + +export * from './types'; +export * from './Client'; + +export default createClientApp; diff --git a/src/client/print.ts b/src/client/print.ts new file mode 100644 index 0000000..eb61b33 --- /dev/null +++ b/src/client/print.ts @@ -0,0 +1,50 @@ +/** + * @module client + * + */ + +import {getWindow} from './redirect'; + +function isRunningOniOS() { + return navigator.userAgent.indexOf('iOS') >= 0; +} + +function createHiddenInput() { + const currentWindow = getWindow(); + if (!currentWindow || !currentWindow.document || !currentWindow.document.body) { + return; + } + const inputElement = window.document.createElement('input'); + inputElement.style.display = 'none'; + + window.document.body.appendChild(inputElement); + + return inputElement; +} + +function printWindow() { + if (!getWindow()) { + return; + } + // @ts-ignore: Fixed in TypeScript 2.8.2 + window.print(); +} + +function handleMobileAppPrint() { + const input = createHiddenInput(); + if (!input) { + return; + } + + input.select(); + printWindow(); + input.remove(); +} + +export function handleAppPrint() { + if (isRunningOniOS()) { + handleMobileAppPrint(); + } else { + printWindow(); + } +} diff --git a/src/client/redirect.ts b/src/client/redirect.ts new file mode 100644 index 0000000..c79e0bb --- /dev/null +++ b/src/client/redirect.ts @@ -0,0 +1,28 @@ +/** + * @module client + * + */ + +export function shouldRedirect(frame: Window): boolean { + return frame === window; +} + +export function redirect(url: string) { + const location = getLocation(); + if (!location) { + return; + } + location.assign(url); +} + +export function getLocation() { + return hasWindow() ? window.location : undefined; +} + +export function getWindow() { + return hasWindow() ? window : undefined; +} + +function hasWindow() { + return typeof window !== 'undefined'; +} diff --git a/src/client/tests/Client.test.ts b/src/client/tests/Client.test.ts new file mode 100644 index 0000000..b6e023e --- /dev/null +++ b/src/client/tests/Client.test.ts @@ -0,0 +1,567 @@ +import {forEachInEnum} from '../../actions/helper'; +import {ActionType as ErrorActionType} from '../../actions/Error/types'; +import {app as printApp} from '../../actions/Print'; +import {MessageTransport} from '../../MessageTransport'; +import * as MessageTransportModule from '../../MessageTransport'; +import * as redirect from '../redirect'; +import Hooks from '../Hooks'; + +import createAppDefault, { + createAppWrapper, + createClientApp, + getShopOrigin, + getUrlParams, +} from '../Client'; +import {LifecycleHook} from '../types'; + +jest.mock('../print'); + +jest.mock('../../actions/validator', () => ({ + validatedAction: (action: any) => action, +})); + +const print = require('../print'); + +const mockMessageTransport = (): MessageTransport => ({ + dispatch: jest.fn(), + hostFrame: null, + localOrigin: 'https://example.com', + subscribe: jest.fn(), +}); + +describe('Client', () => { + let mockFromWindow; + beforeEach(() => { + mockFromWindow = jest.fn(); + jest.mock('../../MessageTransport', () => ({ + fromWindow: mockFromWindow, + })); + }); + + describe('default export', () => { + it(' returns a function', () => { + expect(typeof createAppWrapper({} as any)).toBe('function'); + }); + + it('calls fromWindow with the top window and the current window origin', () => { + const spy = jest.spyOn(MessageTransportModule, 'fromWindow'); + createAppDefault({apiKey: '123', shopOrigin: 'shop1.myshopify.io'}); + expect(spy).toHaveBeenCalledWith(window.top, window.location.origin); + }); + + it('calls to create hooks', () => { + const app = createAppDefault({apiKey: '123', shopOrigin: 'shop1.myshopify.io'}); + expect(app.hooks).toBeInstanceOf(Hooks); + }); + + it('throws an error if window is undefined', () => { + jest.spyOn(redirect, 'getWindow').mockReturnValueOnce(undefined); + expect(() => createAppDefault({apiKey: '123', shopOrigin: 'shop1.myshopify.io'})).toThrow(); + }); + + it('throws an error if shopOrigin is missing', () => { + expect(() => createAppDefault({apiKey: '123', shopOrigin: null})).toThrowError( + 'APP::ERROR::INVALID_CONFIG: shopOrigin must be provided', + ); + }); + + it('throws an error if apiKey is missing', () => { + expect(() => createAppDefault({apiKey: null, shopOrigin: 'shop1.myshopify.io'})).toThrowError( + 'APP::ERROR::INVALID_CONFIG: apiKey must be provided', + ); + }); + }); + + describe('createAppWrapper', () => { + it('calls fromWindow with the given window and the current window origin', () => { + const contentWindow = {} as any; + const spy = jest.spyOn(MessageTransportModule, 'fromWindow'); + createAppWrapper(contentWindow); + + expect(spy).toHaveBeenCalledWith(contentWindow, window.location.origin); + }); + + it('calls fromWindow with the given window and origin', () => { + const contentWindow = {} as any; + const spy = jest.spyOn(MessageTransportModule, 'fromWindow'); + createAppWrapper(contentWindow, 'https://example.com'); + expect(spy).toHaveBeenCalledWith(contentWindow, 'https://example.com'); + }); + + it('calls to create hooks with provided middlewares', () => { + const middleware1 = jest.fn(); + const middleware2 = jest.fn(); + const app = createAppWrapper({} as Window, 'https://example.com', [middleware1, middleware2])( + { + apiKey: '123', + shopOrigin: 'shop1.myshopify.io', + }, + ); + + expect(app.hooks).toBeInstanceOf(Hooks); + + expect(middleware1).toHaveBeenCalledWith(app.hooks, app); + expect(middleware2).toHaveBeenCalledWith(app.hooks, app); + }); + + it('throws an error if window.location is undefined and localOrigin is not provided', () => { + const contentWindow = {} as any; + jest.spyOn(redirect, 'getLocation').mockReturnValueOnce(undefined); + expect(() => createAppWrapper(contentWindow)).toThrow(); + }); + }); + + describe('redirects', () => { + let clientConfig; + let redirectSpy; + + beforeEach(() => { + clientConfig = { + apiKey: 'foobar', + forceRedirect: true, + shopOrigin: 'shop1.myshopify.io', + }; + redirectSpy = jest.spyOn(redirect, 'redirect').mockImplementation(jest.fn()); + }); + + afterEach(() => { + redirectSpy.mockRestore(); + }); + + it('redirects if window is equivalent to host frame and `forceRedirect` is true', () => { + const messageTransport = mockMessageTransport(); + messageTransport.hostFrame = window; + createClientApp(messageTransport)(clientConfig); + expect(redirectSpy).toHaveBeenCalledWith('https://shop1.myshopify.io/admin/apps/foobar/'); + }); + + it('does not redirect if window is equivalent to host frame and `forceRedirect` is false', () => { + const messageTransport = mockMessageTransport(); + messageTransport.hostFrame = window; + createClientApp(messageTransport)({...clientConfig, forceRedirect: false}); + + expect(redirectSpy).not.toHaveBeenCalled(); + }); + + it('does not redirect if window is not equivalent to host frame', () => { + const messageTransport = mockMessageTransport(); + messageTransport.hostFrame = {} as Window; + createClientApp(messageTransport)(clientConfig); + + expect(redirectSpy).not.toHaveBeenCalled(); + }); + + it('does not redirect if apiKey and shopOrigin is missing', () => { + const messageTransport = mockMessageTransport(); + messageTransport.hostFrame = window; + expect(() => createClientApp(messageTransport)({forceRedirect: true})).toThrow(); + expect(redirect.redirect).not.toHaveBeenCalled(); + }); + + it('does not redirect if location is undefined', () => { + const messageTransport = mockMessageTransport(); + messageTransport.hostFrame = window; + + jest.spyOn(redirect, 'getLocation').mockReturnValueOnce(undefined); + + createClientApp(messageTransport)(clientConfig); + + expect(redirectSpy).not.toHaveBeenCalled(); + }); + }); + + describe('createClientApp', function() { + let app; + let config; + let transport; + + beforeEach(() => { + config = {apiKey: 'fifty', shopOrigin: 'shop1.myshopify.io'}; + transport = mockMessageTransport(); + + app = createClientApp(transport)(config, {}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('subscribes to events on the given transport', function() { + expect(transport.subscribe).toHaveBeenCalledTimes(1); + }); + + it('dispatches an given action on the transport with source included', function() { + app.dispatch('hi'); + expect(transport.dispatch).toHaveBeenCalledWith({ + payload: 'hi', + source: config, + type: 'dispatch', + }); + }); + + it('calls local handlers when dispatching an action', function() { + const handler = jest.fn(); + const event = { + data: { + payload: { + payload: 'lasagna', + type: 'pasta', + }, + type: 'dispatch', + }, + }; + + app.subscribe(handler); + + app.dispatch('hi'); + + expect(handler).toHaveBeenCalledWith('hi'); + }); + + it('asynchronously returns the application’s state and includes the source in the request', function() { + const event = { + data: { + payload: 'hi', + type: 'getState', + }, + }; + + expect(app.getState()).resolves.toEqual('hi'); + expect(transport.dispatch).toHaveBeenCalledWith({ + payload: undefined, + source: config, + type: 'getState', + }); + + transport.subscribe.mock.calls[0][0](event); + }); + + it('calls all subscribed handlers when actions are received', function() { + const handler1 = jest.fn(); + const handler2 = jest.fn(); + const event = { + data: { + payload: { + payload: 'lasagna', + type: 'pasta', + }, + type: 'dispatch', + }, + }; + + app.subscribe(handler1); + app.subscribe(handler2); + + transport.subscribe.mock.calls[0][0](event); + + expect(handler1).toHaveBeenCalledWith(event.data.payload); + expect(handler2).toHaveBeenCalledWith(event.data.payload); + }); + + it('does not call unsubscribed handlers when actions are received', function() { + const handler1 = jest.fn(); + const handler2 = jest.fn(); + const handler3 = jest.fn(); + const event = { + data: { + payload: { + payload: 'ditali', + type: 'pasta', + }, + type: 'dispatch', + }, + }; + + app.subscribe(handler1); + const unsubscribe = app.subscribe(handler2); + app.subscribe(handler3); + + unsubscribe(); + transport.subscribe.mock.calls[0][0](event); + + expect(handler1).toHaveBeenCalledWith(event.data.payload); + expect(handler2).not.toHaveBeenCalled(); + expect(handler3).toHaveBeenCalledWith(event.data.payload); + }); + + it('calls all subscribed handlers for specific actions when those actions are received', function() { + const handler1 = jest.fn(); + const handler2 = jest.fn(); + const event = { + data: { + payload: { + payload: 'rigatoni', + type: 'pasta', + }, + type: 'dispatch', + }, + }; + + app.subscribe('pasta', handler1); + app.subscribe('zuppa', handler2); + + transport.subscribe.mock.calls[0][0](event); + + expect(handler1).toHaveBeenCalledWith('rigatoni'); + expect(handler2).not.toHaveBeenCalled(); + }); + + it('does not call unsubscribed handlers for specific actions when those actions are received', function() { + const handler1 = jest.fn(); + const handler2 = jest.fn(); + const event = { + data: { + payload: { + payload: 'fusili', + type: 'pasta', + }, + type: 'dispatch', + }, + }; + + app.subscribe('pasta', handler1); + const unsubscribe = app.subscribe('pasta', handler2); + + unsubscribe(); + + transport.subscribe.mock.calls[0][0](event); + + expect(handler1).toHaveBeenCalledWith('fusili'); + expect(handler2).not.toHaveBeenCalled(); + }); + + it('calls handler with specific id and generic handler when action with id is received', function() { + const id = '1234'; + const handler1 = jest.fn(); + const handler2 = jest.fn(); + const handler3 = jest.fn(); + const payload = { + id, + item: 'rigatoni', + }; + const event = { + data: { + payload: { + payload, + type: 'pasta', + }, + type: 'dispatch', + }, + }; + + app.subscribe('pasta', handler1, id); + app.subscribe('pasta', handler2); + app.subscribe('zuppa', handler3); + + transport.subscribe.mock.calls[0][0](event); + + expect(handler1).toHaveBeenCalledWith(payload); + expect(handler2).toHaveBeenCalledWith(payload); + expect(handler3).not.toHaveBeenCalled(); + }); + + it('calls to throw an error when no subscriptions exist for error action', function() { + const event = { + data: { + payload: { + payload: 'fusili', + type: ErrorActionType.INVALID_ACTION, + }, + type: 'dispatch', + }, + }; + + expect(() => transport.subscribe.mock.calls[0][0](event)).toThrow( + new RegExp(ErrorActionType.INVALID_ACTION), + ); + }); + + it('calls error handler for specific error action when that error action is received', function() { + const invalidActionHandler = jest.fn(); + const event = { + data: { + payload: { + payload: 'fusili', + type: ErrorActionType.INVALID_ACTION, + }, + type: 'dispatch', + }, + }; + + app.subscribe(ErrorActionType.INVALID_ACTION, invalidActionHandler); + expect(() => transport.subscribe.mock.calls[0][0](event)).not.toThrow(); + + expect(invalidActionHandler).toHaveBeenCalledWith(event.data.payload.payload); + }); + + it('when given an id, calls only error handler with specific id and generic handler when that error action is received', function() { + const id = '1234'; + const id2 = '5678'; + const handler1 = jest.fn(); + const handler2 = jest.fn(); + const handler3 = jest.fn(); + const payload = { + id, + item: 'rigatoni', + }; + const event = { + data: { + payload: { + payload, + type: ErrorActionType.INVALID_ACTION, + }, + type: 'dispatch', + }, + }; + + app.subscribe(ErrorActionType.INVALID_ACTION, handler1, id); + app.subscribe(ErrorActionType.INVALID_ACTION, handler2, id2); + app.subscribe(ErrorActionType.INVALID_ACTION, handler3); + + transport.subscribe.mock.calls[0][0](event); + + expect(handler1).toHaveBeenCalledWith(payload); + expect(handler2).not.toHaveBeenCalled(); + expect(handler3).toHaveBeenCalledWith(payload); + }); + + it('calls error handler for all errors when subscriptions to error actions are created using app.error', function() { + const errorHandler = jest.fn(); + app.error(errorHandler); + + forEachInEnum(ErrorActionType, errorType => { + const event = { + data: { + payload: { + payload: 'fusili', + type: errorType, + }, + type: 'dispatch', + }, + }; + + errorHandler.mockClear(); + transport.subscribe.mock.calls[0][0](event); + + expect(errorHandler).toHaveBeenCalledWith(event.data.payload.payload); + }); + }); + + it('calls error handler with specific id when subscriptions to error actions are created using app.error', function() { + const id = '1234'; + + const payload = { + id, + item: 'rigatoni', + }; + const errorHandler = jest.fn(); + const errorHandler2 = jest.fn(); + + app.error(errorHandler, id); + app.error(errorHandler2); + + forEachInEnum(ErrorActionType, errorType => { + const event = { + data: { + payload: { + payload, + type: errorType, + }, + type: 'dispatch', + }, + }; + errorHandler.mockClear(); + errorHandler2.mockClear(); + transport.subscribe.mock.calls[0][0](event); + + expect(errorHandler).toHaveBeenCalledWith(payload); + expect(errorHandler2).toHaveBeenCalledWith(payload); + }); + }); + + it('usubscribes error handler from all errors', function() { + const errorHandler = jest.fn(); + const unsubscribe = app.error(errorHandler); + + unsubscribe(); + + forEachInEnum(ErrorActionType, errorType => { + const event = { + data: { + payload: { + payload: { + id: 1234, + item: 'fusili', + }, + type: errorType, + }, + type: 'dispatch', + }, + }; + errorHandler.mockClear(); + + expect(() => transport.subscribe.mock.calls[0][0](event)).toThrow(); + + expect(errorHandler).not.toHaveBeenCalled(); + }); + }); + + it('calls handleAppPrint on print action', () => { + app.dispatch(printApp()); + + setTimeout(() => { + expect(print.handleAppPrint).toHaveBeenCalledWith(app); + }); + }); + + it('calls DispatchAction hook and return the action when dispatching an action', () => { + const action = {type: 'EAT', payload: {food: 'sushi'}}; + const hookRunSpy = jest.spyOn(app.hooks, 'run'); + const dispatchedAction = app.dispatch(action); + + expect(hookRunSpy).toHaveBeenCalledWith( + LifecycleHook.DispatchAction, + expect.any(Function), + app, + action, + ); + + expect(dispatchedAction).toBe(action); + }); + + it('returns the action when dispatching an action without hooks', () => { + const action = {type: 'EAT', payload: {food: 'sushi'}}; + app.hooks = null; + const dispatchedAction = app.dispatch(action); + expect(dispatchedAction).toBe(action); + }); + }); + + it('getShopOrigin returns the shop URL param', () => { + jest + .spyOn(redirect, 'getLocation') + .mockReturnValueOnce({search: '?shop=pet.myshopify.com&pets=cat'}); + expect(getShopOrigin()).toEqual('pet.myshopify.com'); + }); + + it('getUrlParams returns decoded URL params', () => { + jest.spyOn(redirect, 'getLocation').mockReturnValueOnce({ + search: + '?shop=pet.myshopify.com&pets=cat%20and%20dog&link=http%3A%2F%2Fexample.com&other%20animals=snake%26otter', + }); + + const expectedObj = { + link: 'http://example.com', + pets: 'cat and dog', + shop: 'pet.myshopify.com', + }; + expectedObj['other animals'] = 'snake&otter'; + + expect(getUrlParams()).toEqual(expectedObj); + }); + + it('getUrlParams returns an empty object when window location is not defined', () => { + jest.spyOn(redirect, 'getLocation').mockReturnValueOnce(undefined); + expect(getUrlParams()).toEqual({}); + }); +}); diff --git a/src/client/tests/Hooks.test.ts b/src/client/tests/Hooks.test.ts new file mode 100644 index 0000000..7dffc9c --- /dev/null +++ b/src/client/tests/Hooks.test.ts @@ -0,0 +1,199 @@ +import Hooks from '../Hooks'; +import {LifecycleHook} from '../types'; + +describe('Hooks', () => { + it('get returns an array of handlers if handlers for event exists', () => { + const cb1 = jest.fn().mockImplementation(next => args => { + return next(args); + }); + const cb2 = jest.fn().mockImplementation(next => args => { + return next(args); + }); + + const cb3 = jest.fn().mockImplementation(next => args => { + return next(args); + }); + + const hooks = new Hooks(); + + hooks.set(LifecycleHook.UpdateAction, cb1); + hooks.set(LifecycleHook.UpdateAction, cb2); + hooks.set(LifecycleHook.DispatchAction, cb3); + + expect(hooks.get(LifecycleHook.UpdateAction)).toEqual([cb1, cb2]); + expect(hooks.get(LifecycleHook.DispatchAction)).toEqual([cb3]); + }); + + it('get returns undefined if handlers for event does not exists', () => { + const hooks = new Hooks(); + expect(hooks.get(LifecycleHook.UpdateAction)).toEqual(undefined); + }); + + describe('run', () => { + it('calls only the final callback with given arguments if no handlers are found', () => { + const final = jest.fn(); + const hooks = new Hooks(); + + hooks.run(LifecycleHook.UpdateAction, final, {}, 'myValue'); + + expect(final).toHaveBeenCalledWith('myValue'); + }); + + it('calls each handler and the final callback with given arguments', () => { + const cb1 = jest.fn().mockImplementation(next => value => { + expect(value).toEqual('myValue'); + return next(value); + }); + const cb2 = jest.fn().mockImplementation(next => value => { + expect(value).toEqual('myValue'); + return next(value); + }); + + const final = jest.fn(); + const hooks = new Hooks(); + + hooks.set(LifecycleHook.UpdateAction, cb1); + hooks.set(LifecycleHook.UpdateAction, cb2); + + hooks.run(LifecycleHook.UpdateAction, final, {}, 'myValue'); + + expect(cb1).toHaveBeenCalled(); + expect(cb2).toHaveBeenCalled(); + expect(final).toHaveBeenCalledWith('myValue'); + }); + + it('does not call the final callback if middleware interrupts the chain', () => { + const cb1 = jest.fn().mockImplementation(next => args => { + return next(args); + }); + const cb2 = jest.fn().mockImplementation(next => args => {}); + const final = jest.fn(); + const hooks = new Hooks(); + + hooks.set(LifecycleHook.UpdateAction, cb1); + hooks.set(LifecycleHook.UpdateAction, cb2); + + hooks.run(LifecycleHook.UpdateAction, final, {}, 'myValue'); + + expect(cb1).toHaveBeenCalled(); + expect(cb2).toHaveBeenCalled(); + expect(final).not.toHaveBeenCalledWith('myValue'); + }); + + it('calls the final callback with modified arguments', () => { + const cb1 = jest.fn().mockImplementation(next => value => { + expect(value).toEqual('1'); + return next('2'); + }); + const cb2 = jest.fn().mockImplementation(next => value => { + expect(value).toEqual('2'); + return next('3'); + }); + const final = jest.fn(); + const hooks = new Hooks(); + + hooks.set(LifecycleHook.UpdateAction, cb1); + hooks.set(LifecycleHook.UpdateAction, cb2); + + hooks.run(LifecycleHook.UpdateAction, final, {}, '1'); + + expect(cb1).toHaveBeenCalled(); + expect(cb2).toHaveBeenCalled(); + + expect(final).toHaveBeenCalledWith('3'); + }); + + it('does not call a handler that has been removed', () => { + const cb1 = jest.fn().mockImplementation(next => args => { + return next(args); + }); + const cb2 = jest.fn().mockImplementation(next => args => { + return next(args); + }); + const final = jest.fn(); + const hooks = new Hooks(); + + const unsubscribeCb1 = hooks.set(LifecycleHook.UpdateAction, cb1); + hooks.set(LifecycleHook.UpdateAction, cb2); + + unsubscribeCb1(); + + hooks.run(LifecycleHook.UpdateAction, final, {}, 'myValue'); + + expect(cb1).not.toHaveBeenCalled(); + + expect(cb2).toHaveBeenCalled(); + expect(final).toHaveBeenCalledWith('myValue'); + }); + + it('returns the result of the final callback', () => { + const cb1 = jest.fn().mockImplementation(next => args => { + return next('2'); + }); + const cb2 = jest.fn().mockImplementation(next => args => { + return next('3'); + }); + const final = value => { + expect(value).toEqual('3'); + return 'all done!'; + }; + const hooks = new Hooks(); + + hooks.set(LifecycleHook.UpdateAction, cb1); + hooks.set(LifecycleHook.UpdateAction, cb2); + + const result = hooks.run(LifecycleHook.UpdateAction, final, {}, '1'); + + expect(result).toEqual('all done!'); + }); + + it('returns the result of the handler if it interrupts the chain', () => { + const cb1 = jest.fn().mockImplementation(next => args => { + return next('2'); + }); + const cb2 = jest.fn().mockImplementation(next => args => 'interrupted'); + const final = jest.fn(); + const hooks = new Hooks(); + + hooks.set(LifecycleHook.UpdateAction, cb1); + hooks.set(LifecycleHook.UpdateAction, cb2); + + const result = hooks.run(LifecycleHook.UpdateAction, final, {}, '1'); + + expect(result).toEqual('interrupted'); + }); + }); + + it('maintains the given context through the callback chain', () => { + const context = {haha: 'lol'}; + const cb1 = jest.fn().mockImplementation(function(next) { + return function() { + expect(this).toBe(context); + return next('2'); + }; + }); + const cb2 = jest.fn().mockImplementation(function(next) { + return function() { + expect(this).toBe(context); + return next('3'); + }; + }); + const final = jest.fn().mockImplementation(function(next) { + return function() { + expect(this).toBe(context); + return 'done'; + }; + }); + + const hooks = new Hooks(); + + hooks.set(LifecycleHook.UpdateAction, cb1); + hooks.set(LifecycleHook.UpdateAction, cb2); + + hooks.run(LifecycleHook.UpdateAction, final, context, '1'); + + expect(cb1).toHaveBeenCalled(); + expect(cb2).toHaveBeenCalled(); + expect(final).toHaveBeenCalled(); + }); +}); diff --git a/src/client/tests/print.test.ts b/src/client/tests/print.test.ts new file mode 100644 index 0000000..1363e5b --- /dev/null +++ b/src/client/tests/print.test.ts @@ -0,0 +1,76 @@ +import {handleAppPrint} from '../print'; +import * as Redirect from '../redirect'; + +describe('handleAppPrint', () => { + let windowPrintSpy; + beforeEach(() => { + windowPrintSpy = jest.fn(); + Object.assign(window, {print: windowPrintSpy}); + }); + + afterEach(() => { + Object.defineProperty(window, 'print', {value: undefined}); + }); + + it('calls window.print', () => { + handleAppPrint(); + setTimeout(() => { + expect(windowPrintSpy).toHaveBeenCalled(); + }); + }); + + describe('iOS', () => { + beforeEach(() => { + Object.defineProperty(window.navigator, 'userAgent', { + value: 'iOS', + }); + }); + + it('calls window.print', () => { + handleAppPrint(); + setTimeout(() => { + expect(windowPrintSpy).toHaveBeenCalled(); + }); + }); + + it('it creates and then removes the hidden input after print', () => { + const mockInput = window.document.createElement('input'); + const createElementSpy = jest + .spyOn(window.document, 'createElement') + .mockReturnValue(mockInput); + + jest.spyOn(mockInput, 'select'); + jest.spyOn(mockInput, 'remove'); + + handleAppPrint(); + + expect(mockInput.style.display).toEqual('none'); + expect(mockInput.select).toHaveBeenCalled(); + expect(mockInput.remove).toHaveBeenCalled(); + }); + + it('it does not call window.print if window.document is missing', () => { + Object.defineProperty(window, 'document', {value: null}); + handleAppPrint(); + setTimeout(() => { + expect(windowPrintSpy).not.toHaveBeenCalled(); + }); + }); + + it('it does not call window.print if window.document.body is missing', () => { + Object.defineProperty(window, 'document', {value: {}}); + handleAppPrint(); + setTimeout(() => { + expect(windowPrintSpy).not.toHaveBeenCalled(); + }); + }); + + it('it does not call window.print if window is not defined', () => { + jest.spyOn(Redirect, 'getWindow').mockReturnValueOnce(undefined); + handleAppPrint(); + setTimeout(() => { + expect(windowPrintSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/client/tests/redirect.test.ts b/src/client/tests/redirect.test.ts new file mode 100644 index 0000000..0cd6360 --- /dev/null +++ b/src/client/tests/redirect.test.ts @@ -0,0 +1,20 @@ +import {getLocation, getWindow, shouldRedirect} from '../redirect'; + +describe('Redirect', () => { + it('returns true if the app frame is the top frame', () => { + expect(shouldRedirect(window)).toEqual(true); + }); + + it('returns false if the app frame is not the top frame', () => { + const parentWindow = {} as any; + expect(shouldRedirect(parentWindow)).toEqual(false); + }); + + it('getWindow returns window', () => { + expect(getWindow()).toBe(window); + }); + + it('getLocation returns window.location', () => { + expect(getLocation()).toBe(window.location); + }); +}); diff --git a/src/client/types.ts b/src/client/types.ts new file mode 100644 index 0000000..2f48e90 --- /dev/null +++ b/src/client/types.ts @@ -0,0 +1,174 @@ +import { + ActionCallback, + ActionSetInterface, + ActionSetOptions, + AnyAction, + Dispatch, + ErrorSubscriber, + Group, + MetaAction, + Unsubscribe, +} from '../actions/types'; +import {Handler, HandlerData, MessageTransport} from '../MessageTransport'; + +/** + * @public + */ +export interface AppConfig { + apiKey: string; + shopOrigin: string; + forceRedirect?: boolean; +} + +/** + * @internal + */ +export interface FeaturesAction { + [key: string]: boolean; +} + +/** + * @internal + */ +export type FeaturesAvailable = {[key in Group]?: FeaturesAction}; + +/** + * Application instance, required for use with actions. + * @public + */ +export interface ClientApplication { + dispatch: Dispatch; + localOrigin: string; + error: ErrorSubscriber; + hooks?: HooksInterface; + getState(query?: string): Promise; + featuresAvailable(features?: Group[]): Promise; + subscribe(callback: ActionCallback, id?: string): Unsubscribe; + subscribe(eventNameSpace: string, callback: ActionCallback, id?: string): Unsubscribe; +} + +/** + * @internal + */ +export interface ClientApplicationCreator { + (config: AppConfig, initialState?: S): ClientApplication; +} + +/** + * @internalremarks + * TODO: Generalize—pramaterize return type + * @internal + */ +export interface ClientApplicationTransportInjector { + (transport: MessageTransport, middleware?: AppMiddleware[]): ClientApplicationCreator; +} + +/** + * @internal + */ +export interface ActionListenersMap { + [index: string]: ActionListener[]; +} + +/** + * @internal + */ +export interface ActionListener { + id?: string; + callback(data: any): void; +} + +/** + * @internal + */ +export interface TransportDispatch { + type: 'getState' | 'dispatch'; + source: AppConfig; + payload?: AnyAction; +} + +/** + * @deprecated Not to be used, there is no replacement. + * @internal + */ +export interface Params { + [key: string]: string; +} + +/** + * @internal + */ +export enum LifecycleHook { + UpdateAction = 'UpdateAction', + DispatchAction = 'DispatchAction', +} + +/** + * @internal + */ +export interface Hook { + handler: LifecycleHandler; + remove: Unsubscribe; +} + +/** + * @internal + */ +export interface HookMap { + [key: string]: Hook[]; +} + +/** + * @internal + */ +export interface HooksInterface { + set(hook: LifecycleHook.UpdateAction, handler: UpdateActionHook): any; + set(hook: LifecycleHook.DispatchAction, handler: DispatchActionHook): any; + set(hook: LifecycleHook, handler: LifecycleHandler): any; + get(hook: LifecycleHook): LifecycleHandler[] | undefined; + run(hook: LifecycleHook, final: Function, context: C, ...arg: any[]): any; +} + +/** + * @internal + */ +export interface AppMiddleware { + (hooks: HooksInterface, app: ClientApplication): void; +} + +/** + * @internal + */ +export interface LifecycleHandler { + (next: Function): (...args: any[]) => any; +} + +/** + * @internal + */ +export interface UpdateActionHandler { + (this: ActionSetInterface & ActionSetOptions, options: O): any; +} + +/** + * @internal + */ +export interface UpdateActionHook { + (next: Function): UpdateActionHandler; +} + +/** + * @internal + */ +export interface DispatchActionHandler { + (this: ClientApplication, action: A): any; +} + +/** + * @internal + */ +export interface DispatchActionHook { + (next: Function): DispatchActionHandler; +} + +export {AnyAction, Dispatch, Handler, HandlerData, Unsubscribe}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..efd113f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,7 @@ +import * as actions from './actions'; +import {createApp} from './client'; + +export default createApp; +export {actions}; +export * from './MessageTransport'; +export * from './client'; diff --git a/src/package.json b/src/package.json new file mode 120000 index 0000000..4e26811 --- /dev/null +++ b/src/package.json @@ -0,0 +1 @@ +../package.json \ No newline at end of file diff --git a/src/tests/MessageTransport.test.ts b/src/tests/MessageTransport.test.ts new file mode 100644 index 0000000..00fc985 --- /dev/null +++ b/src/tests/MessageTransport.test.ts @@ -0,0 +1,236 @@ +import {start as LoadingStart} from '../actions/Loading'; +import {fromFrame, fromWindow} from '../MessageTransport'; + +const mockFrame = () => ({ + contentWindow: { + postMessage: jest.fn(), + }, + ownerDocument: { + defaultView: { + addEventListener: jest.fn(), + }, + }, +}); + +const mockWindow = (origin = 'https://example.com') => ({ + location: {origin}, + postMessage: jest.fn(), +}); + +const postMessage = (frame, event) => { + const handlers = frame.ownerDocument.defaultView.addEventListener.mock.calls.map( + ([_, handler]) => handler, + ); + + for (const handler of handlers) { + handler(event); + } +}; + +describe('MessageTransport', function() { + describe('fromFrame', function() { + let frame; + const origin = 'https://example.com'; + beforeEach(function() { + frame = mockFrame(); + }); + + it('sets the transport local origin', function() { + const testOrigin = 'https://example.com'; + const transport = fromFrame(frame, testOrigin); + expect(transport.localOrigin).toBe(testOrigin); + }); + + it('triggers handlers for subscriptions when the event is an app message', function() { + const event = {origin, data: {type: 'dispatch', payload: {condiment: 'maple syrup'}}}; + const handler = jest.fn(); + const transport = fromFrame(frame, origin); + + transport.subscribe(handler); + + postMessage(frame, event); + + expect(handler).toHaveBeenCalledWith(event); + }); + + it('does not trigger handlers when messages are not app messages', function() { + const localFromFrame = require('../MessageTransport').fromFrame; + const event = {origin, data: {type: 'unknown', payload: {condiment: 'maple syrup'}}}; + const handler = jest.fn(); + const transport = localFromFrame(frame, origin); + + transport.subscribe(handler); + + postMessage(frame, event); + + expect(handler).not.toHaveBeenCalledWith(event); + }); + + it('does not trigger handlers when messages origin does not match local origin', function() { + const event = { + data: {type: 'dispatch', payload: {condiment: 'maple syrup'}}, + origin: 'https://somethingelse.com', + }; + const handler = jest.fn(); + const transport = fromFrame(frame, origin); + + transport.subscribe(handler); + + postMessage(frame, event); + + expect(handler).not.toHaveBeenCalledWith(event); + }); + + it('does not trigger handlers that have been unsubscribed', function() { + const event = {origin, data: {condiment: 'maple syrup'}}; + const handler = jest.fn(); + const transport = fromFrame(frame, origin); + const unsubscribe = transport.subscribe(handler); + + unsubscribe(); + + postMessage(frame, event); + + expect(handler).not.toHaveBeenCalledWith(event); + }); + + it('dispatches messages to the frame’s window via `postMessage`', function() { + const event = {ketchup: 'bad', mustard: 'good'}; + const transport = fromFrame(frame, origin); + + transport.dispatch(event); + + expect(frame.contentWindow.postMessage).toHaveBeenCalledWith(event, '*'); + }); + }); + + describe('fromWindow', function() { + let contentWindow; + let listenerSpy; + + beforeEach(function() { + contentWindow = mockWindow(); + listenerSpy = jest.fn(); + + // @ts-ignore: Actually, there _is_ an `addEventListener` in `window` + global.addEventListener = listenerSpy; + }); + + it('sets the transport local origin to given origin', function() { + const transport = fromWindow(contentWindow, 'https://supercoolsite.com'); + expect(transport.localOrigin).toBe('https://supercoolsite.com'); + }); + + it('triggers handlers for subscriptions for App Bridge actions messages from matching source', function() { + const event = {data: {type: 'dispatch', payload: LoadingStart()}, source: contentWindow}; + const handler = jest.fn(); + const transport = fromWindow(contentWindow, ''); + + transport.subscribe(handler); + + listenerSpy.mock.calls[0][1](event); + + expect(handler).toHaveBeenCalledWith(event); + }); + + it('triggers handlers when message is AppMessage getState', function() { + const event = {data: {type: 'getState', payload: {any: 'payload'}}, source: contentWindow}; + const handler = jest.fn(); + const transport = fromWindow(contentWindow, 'http://example.com'); + + transport.subscribe(handler); + + listenerSpy.mock.calls[0][1](event); + + expect(handler).toHaveBeenCalledWith(event); + }); + + it('triggers handlers when message is AppMessage dispatch', function() { + const event = {data: {type: 'dispatch', payload: {any: 'payload'}}, source: contentWindow}; + const handler = jest.fn(); + const transport = fromWindow(contentWindow, 'http://example.com'); + + transport.subscribe(handler); + + listenerSpy.mock.calls[0][1](event); + + expect(handler).toHaveBeenCalledWith(event); + }); + + it('does not trigger handlers when messages are from a different source', function() { + const event = {data: {type: 'dispatch', payload: LoadingStart()}, source: {}}; + const handler = jest.fn(); + const transport = fromWindow(contentWindow, 'http://example.com'); + + transport.subscribe(handler); + + listenerSpy.mock.calls[0][1](event); + + expect(handler).not.toHaveBeenCalledWith(event); + }); + + it('does not trigger handlers when messages are not App Bridge actions', function() { + const event = {data: {condiment: 'maple syrup'}, source: contentWindow}; + const handler = jest.fn(); + const transport = fromWindow(contentWindow, 'http://example.com'); + + transport.subscribe(handler); + + listenerSpy.mock.calls[0][1](event); + + expect(handler).not.toHaveBeenCalledWith(event); + }); + + it('does not trigger handlers that have been unsubscribed', function() { + const event = {data: {type: 'dispatch', payload: LoadingStart()}, source: contentWindow}; + const handler = jest.fn(); + const transport = fromWindow(contentWindow, 'http://example.com'); + const unsubscribe = transport.subscribe(handler); + + unsubscribe(); + + listenerSpy.mock.calls[0][1](event); + + expect(handler).not.toHaveBeenCalledWith(event); + }); + + it('dispatches messages to the content window via `postMessage` with the origin from event source', function() { + const event = {ketchup: 'bad', mustard: 'good', source: {shopOrigin: 'myCoolShop.com'}}; + const transport = fromWindow(contentWindow, 'http://example.com'); + + transport.dispatch(event); + + expect(contentWindow.postMessage).toHaveBeenCalledWith(event, 'https://myCoolShop.com'); + }); + + it('does not dispatch a message to the content window via `postMessage` if source is not provided', function() { + const event = {ketchup: 'bad', mustard: 'good'}; + const transport = fromWindow(contentWindow, 'http://example.com'); + + transport.dispatch(event); + + expect(contentWindow.postMessage).not.toHaveBeenCalled(); + }); + + it('does not dispatch a message to the content window via `postMessage` if source shop origin is not provided', function() { + const event = {ketchup: 'bad', mustard: 'good', source: {}}; + const transport = fromWindow(contentWindow, 'http://example.com'); + + transport.dispatch(event); + + expect(contentWindow.postMessage).not.toHaveBeenCalled(); + }); + + it('does NOT add a window message handler if the contentWindow provided is the same as the current window', function() { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + fromWindow(window, 'https://supercoolsite.com'); + expect(addEventListenerSpy).not.toHaveBeenCalled(); + }); + + it('adds a window message handler if the contentWindow provided is NOT same as the current window', function() { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + fromWindow(contentWindow, 'https://supercoolsite.com'); + expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); + }); + }); +}); diff --git a/src/util/collection.ts b/src/util/collection.ts new file mode 100644 index 0000000..304ecdb --- /dev/null +++ b/src/util/collection.ts @@ -0,0 +1,35 @@ +/** + * @module util + */ + +/** + * Add an item to a collection, return a function that can then be used to + * remove the item from the collection. Optionally accepting a callback that is + * invoked when the item is removed from the collection. + */ +export function addAndRemoveFromCollection(collection: T[], item: T, then?: Function) { + collection.push(item); + + return () => { + return removeFromCollection(collection, item, then); + }; +} + +/** + * Remove the item from the collection. Optionally accepting a callback that is + * invoked when the item is removed from the collection. + */ +export function removeFromCollection(collection: T[], item: T, then?: Function): boolean { + const idx = collection.findIndex(i => i === item); + + if (idx >= 0) { + collection.splice(idx, 1); + if (then) { + then(item); + } + + return true; + } + + return false; +} diff --git a/src/util/env.ts b/src/util/env.ts new file mode 100644 index 0000000..5a6af2c --- /dev/null +++ b/src/util/env.ts @@ -0,0 +1,5 @@ +export const isServer = typeof window === 'undefined'; +export const isClient = !isServer; +export const isProduction = process.env.NODE_ENV === 'production'; +export const isDevelopment = !isProduction; +export const isDevelopmentClient = isDevelopment && isClient; diff --git a/src/util/tests/collection.test.ts b/src/util/tests/collection.test.ts new file mode 100644 index 0000000..5a3af1b --- /dev/null +++ b/src/util/tests/collection.test.ts @@ -0,0 +1,54 @@ +import {addAndRemoveFromCollection} from '../collection'; + +describe('collection', function() { + describe('addAndRemoveFromCollection', function() { + it('adds an item to a collection', function() { + const collection = []; + const item1 = {item: 1}; + const item2 = {item: 2}; + + addAndRemoveFromCollection(collection, item1); + addAndRemoveFromCollection(collection, item2); + + expect(collection.length).toBe(2); + // @ts-ignore Actually arrays do have an `includes` method... + expect(collection.includes(item1)).toBe(true); + // @ts-ignore Actually arrays do have an `includes` method... + expect(collection.includes(item2)).toBe(true); + }); + + it('returns a function that removes an item from the collection', function() { + const collection = []; + const item1 = {item: 1}; + const item2 = {item: 2}; + const item3 = {item: 3}; + + addAndRemoveFromCollection(collection, item1); + const remove = addAndRemoveFromCollection(collection, item2); + addAndRemoveFromCollection(collection, item3); + + remove(); + + expect(collection.length).toBe(2); + // @ts-ignore Actually arrays do have an `includes` method... + expect(collection.includes(item1)).toBe(true); + // @ts-ignore Actually arrays do have an `includes` method... + expect(collection.includes(item2)).toBe(false); + // @ts-ignore Actually arrays do have an `includes` method... + expect(collection.includes(item3)).toBe(true); + }); + + it('invokes the given callback when the item is removed from the collection', function() { + const collection = []; + const item = {item: 1}; + const then = jest.fn(); + + const remove = addAndRemoveFromCollection(collection, item, then); + + remove(); + + expect(collection.length).toBe(0); + expect(then).toHaveBeenCalled(); + }); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..e278222 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "allowJs": false, + "allowSyntheticDefaultImports": true, + "declaration": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "lib": ["es2016", "es2015", "dom"], + "module": "commonjs", + "moduleResolution": "node", + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "es5", + "typeRoots": ["./node_modules/@types"] + }, + "exclude": ["node_modules", "tests", "**/*.test.ts", "**/*.test.tsx"], + "typedocOptions": { + "out": "docs", + "readme": "none", + "excludeProtected": true, + "excludeExternals": true, + "excludePrivate": true, + "ignoreCompilerErrors": true, + "mode": "modules", + "exclude": "**/*+(test|index|helper|uuid).ts", + "theme": "markdown" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..470be05 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.base.json", + "compileOnSave": false, + "compilerOptions": { + "rootDir": "./src", + "outDir": "./", + "types": ["node"], + "composite": true + }, + "include": ["./src"] +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..eb58107 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,211 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@microsoft/api-documenter@^7.0.13": + version "7.0.13" + resolved "https://registry.yarnpkg.com/@microsoft/api-documenter/-/api-documenter-7.0.13.tgz#77a68819765d494f937963cb08f886e24fedb6c2" + integrity sha512-voUxWMvfzb6CymzEwRZiol+9HjY/B6e2fvf8YiQnjkSjqS9KdJ8t5AL54jrTyG2mRF7o/y/kwZXGEcAhqD22Kg== + dependencies: + "@microsoft/api-extractor" "7.0.9" + "@microsoft/node-core-library" "3.9.0" + "@microsoft/ts-command-line" "4.2.3" + "@microsoft/tsdoc" "0.12.4" + colors "~1.2.1" + js-yaml "~3.9.1" + +"@microsoft/api-extractor@7.0.9", "@microsoft/api-extractor@^7.0.9": + version "7.0.9" + resolved "https://registry.yarnpkg.com/@microsoft/api-extractor/-/api-extractor-7.0.9.tgz#ea58777d1a9c2808dfbeeb0e53108fb3ee7180b9" + integrity sha512-AsWUTUG3m/W+cEsRiJxiaXtPS7zyYD1PG9avGLPF3hrUdwpkhJtR6NRqS5tV1itvpV688sQM+TD77pNt2lBX+A== + dependencies: + "@microsoft/node-core-library" "3.9.0" + "@microsoft/ts-command-line" "4.2.3" + "@microsoft/tsdoc" "0.12.4" + "@types/node" "8.5.8" + "@types/z-schema" "3.16.31" + colors "~1.2.1" + lodash "~4.17.5" + resolve "1.8.1" + typescript "~3.1.6" + z-schema "~3.18.3" + +"@microsoft/node-core-library@3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@microsoft/node-core-library/-/node-core-library-3.9.0.tgz#a999c15c45707bfd5e2329518e00cb2c8d33b55c" + integrity sha512-zmP6zNddcIrRXbg8NX9oHX2iCBLU9hZF/+7GeUi3hLbp13xMDHfdT4KepoT+8ZMOMZzRF1lcqiTiVy24VYvCEg== + dependencies: + "@types/fs-extra" "5.0.4" + "@types/jju" "~1.4.0" + "@types/node" "8.5.8" + "@types/z-schema" "3.16.31" + colors "~1.2.1" + fs-extra "~7.0.1" + jju "~1.4.0" + z-schema "~3.18.3" + +"@microsoft/ts-command-line@4.2.3": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@microsoft/ts-command-line/-/ts-command-line-4.2.3.tgz#20d6a1684148b9fc0df25ee7335c3bb227d47d4f" + integrity sha512-SIs4q7RcG7efBbh5Ffrf6V4jVLxWihD4NDRY3+gPiOG8CYawBzE22tTEloZ1yj/FBvBZQkQ0GYwXoPhn6ElYXA== + dependencies: + "@types/argparse" "1.0.33" + "@types/node" "8.5.8" + argparse "~1.0.9" + colors "~1.2.1" + +"@microsoft/tsdoc@0.12.4": + version "0.12.4" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.12.4.tgz#42159590f2b12e23f6028c70aed41dd5b11275c3" + integrity sha512-nQZVQg3fXygj+9JT/FSPZOz3vqAIVAAR3ZuAuUdU4DSM/ubJq5lbl1hpLtl+REFmEq1rkvDmmPoJAbSoqjcmZQ== + +"@types/argparse@1.0.33": + version "1.0.33" + resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.33.tgz#2728669427cdd74a99e53c9f457ca2866a37c52d" + integrity sha512-VQgHxyPMTj3hIlq9SY1mctqx+Jj8kpQfoLvDlVSDNOyuYs8JYfkuY3OW/4+dO657yPmNhHpePRx0/Tje5ImNVQ== + +"@types/fs-extra@5.0.4": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-5.0.4.tgz#b971134d162cc0497d221adde3dbb67502225599" + integrity sha512-DsknoBvD8s+RFfSGjmERJ7ZOP1HI0UZRA3FSI+Zakhrc/Gy26YQsLI+m5V5DHxroHRJqCDLKJp7Hixn8zyaF7g== + dependencies: + "@types/node" "*" + +"@types/jju@~1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@types/jju/-/jju-1.4.0.tgz#ee074af79540c0e187426f46f12acbe8f6c31232" + integrity sha512-s6l49zLzFiXYHaTXbA+FNcDRo8ufZgC2/T5/jH+Wfr+ZV2tYbrBpEpN9oPkXXfug+y7pTZFkdeyQ0L/88Z34JA== + +"@types/node@*", "@types/node@^10.12.5": + version "10.12.18" + resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.18.tgz#1d3ca764718915584fcd9f6344621b7672665c67" + integrity sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ== + +"@types/node@8.5.8": + version "8.5.8" + resolved "https://registry.yarnpkg.com/@types/node/-/node-8.5.8.tgz#92509422653f10e9c0ac18d87e0610b39f9821c7" + integrity sha512-8KmlRxwbKZfjUHFIt3q8TF5S2B+/E5BaAoo/3mgc5h6FJzqxXkCK/VMetO+IRDtwtU6HUvovHMBn+XRj7SV9Qg== + +"@types/z-schema@3.16.31": + version "3.16.31" + resolved "https://registry.yarnpkg.com/@types/z-schema/-/z-schema-3.16.31.tgz#2eb1d00a5e4ec3fa58c76afde12e182b66dc5c1c" + integrity sha1-LrHQCl5Ow/pYx2r94S4YK2bcXBw= + +argparse@^1.0.7, argparse@~1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +colors@~1.2.1: + version "1.2.5" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.2.5.tgz#89c7ad9a374bc030df8013241f68136ed8835afc" + integrity sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg== + +commander@^2.7.1: + version "2.19.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" + integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg== + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +fs-extra@~7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" + integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + +graceful-fs@^4.1.2, graceful-fs@^4.1.6: + version "4.1.15" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" + integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA== + +jju@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" + integrity sha1-o6vicYryQaKykE+EpiWXDzia4yo= + +js-yaml@~3.9.1: + version "3.9.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.9.1.tgz#08775cebdfdd359209f0d2acd383c8f86a6904a0" + integrity sha512-CbcG379L1e+mWBnLvHWWeLs8GyV/EMw862uLI3c+GxVyDHWZcjZinwuBd3iW2pgxgIlksW/1vNJa4to+RvDOww== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= + optionalDependencies: + graceful-fs "^4.1.6" + +lodash.get@^4.0.0: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= + +lodash.isequal@^4.0.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + +lodash@~4.17.5: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" + integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== + +path-parse@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + +resolve@1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" + integrity sha512-AicPrAC7Qu1JxPCZ9ZgCZlY35QgFnNqc+0LtbRNxnVw4TXvjQ72wnuL9JQcEBgXkI9JM8MsT9kaQoHcpCRJOYA== + dependencies: + path-parse "^1.0.5" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + +typescript@3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.2.1.tgz#0b7a04b8cf3868188de914d9568bd030f0c56192" + integrity sha512-jw7P2z/h6aPT4AENXDGjcfHTu5CSqzsbZc6YlUIebTyBAq8XaKp78x7VcSh30xwSCcsu5irZkYZUSFP1MrAMbg== + +typescript@~3.1.6: + version "3.1.6" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.1.6.tgz#b6543a83cfc8c2befb3f4c8fba6896f5b0c9be68" + integrity sha512-tDMYfVtvpb96msS1lDX9MEdHrW4yOuZ4Kdc4Him9oU796XldPYF/t2+uKoX0BBa0hXXwDlqYQbXY5Rzjzc5hBA== + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +validator@^8.0.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-8.2.0.tgz#3c1237290e37092355344fef78c231249dab77b9" + integrity sha512-Yw5wW34fSv5spzTXNkokD6S6/Oq92d8q/t14TqsS3fAiA1RYnxSFSIZ+CY3n6PGGRCq5HhJTSepQvFUS2QUDxA== + +z-schema@~3.18.3: + version "3.18.4" + resolved "https://registry.yarnpkg.com/z-schema/-/z-schema-3.18.4.tgz#ea8132b279533ee60be2485a02f7e3e42541a9a2" + integrity sha512-DUOKC/IhbkdLKKiV89gw9DUauTV8U/8yJl1sjf6MtDmzevLKOF2duNJ495S3MFVjqZarr+qNGCPbkg4mu4PpLw== + dependencies: + lodash.get "^4.0.0" + lodash.isequal "^4.0.0" + validator "^8.0.0" + optionalDependencies: + commander "^2.7.1"