From fcca4006aa5d2cb17ec3de130ac738deed7f5407 Mon Sep 17 00:00:00 2001 From: Jimmy Sanford Date: Fri, 14 Sep 2018 13:07:20 -0700 Subject: [PATCH] Reorganize redux, add address form - refactor(redux): Reorganize actions and reducers (#212) - feature(checkout): Add address form to checkout flow (#226) - test(redux): Add tests for redux actions (#239) --- package-lock.json | 51 ++++ package.json | 2 + .../{src => }/__mocks__/@magento/peregrine.js | 8 + packages/venia-concept/package.json | 56 ++-- packages/venia-concept/src/__mocks__/store.js | 7 + packages/venia-concept/src/actions/app.js | 15 -- .../src/actions/app/__tests__/actions.spec.js | 20 ++ .../app/__tests__/asyncActions.spec.js | 47 ++++ .../venia-concept/src/actions/app/actions.js | 6 + .../src/actions/app/asyncActions.js | 7 + .../venia-concept/src/actions/app/index.js | 2 + packages/venia-concept/src/actions/cart.js | 183 ------------- .../actions/cart/__tests__/actions.spec.js | 93 +++++++ .../cart/__tests__/asyncActions.spec.js | 244 +++++++++++++++++ .../venia-concept/src/actions/cart/actions.js | 20 ++ .../src/actions/cart/asyncActions.js | 227 ++++++++++++++++ .../venia-concept/src/actions/cart/index.js | 2 + .../venia-concept/src/actions/checkout.js | 103 -------- .../checkout/__tests__/actions.spec.js | 139 ++++++++++ .../checkout/__tests__/asyncActions.spec.js | 248 ++++++++++++++++++ .../src/actions/checkout/actions.js | 22 ++ .../src/actions/checkout/asyncActions.js | 132 ++++++++++ .../src/actions/checkout/index.js | 2 + .../directory/__tests__/actions.spec.js | 20 ++ .../directory/__tests__/asyncActions.spec.js | 71 +++++ .../src/actions/directory/actions.js | 6 + .../src/actions/directory/asyncActions.js | 22 ++ .../src/actions/directory/index.js | 2 + .../src/components/Checkout/address.css | 56 ++++ .../src/components/Checkout/address.js | 170 ++++++++++++ .../src/components/Checkout/cart.css | 3 + .../src/components/Checkout/cart.js | 35 +++ .../src/components/Checkout/checkoutButton.js | 17 +- .../src/components/Checkout/entrance.css | 7 - .../src/components/Checkout/entrance.js | 28 -- .../src/components/Checkout/flow.css | 40 ++- .../src/components/Checkout/flow.js | 111 ++++---- .../src/components/Checkout/form.css | 21 +- .../src/components/Checkout/form.js | 122 ++++++--- .../src/components/Checkout/label.css | 8 + .../src/components/Checkout/label.js | 25 ++ .../Checkout/{exit.css => receipt.css} | 0 .../Checkout/{exit.js => receipt.js} | 2 +- .../src/components/Checkout/section.css | 4 + .../src/components/Checkout/submitButton.js | 15 +- .../src/components/Checkout/wrapper.js | 65 +++-- .../components/Gallery/__tests__/item.spec.js | 34 +-- .../src/components/Header/navTrigger.js | 3 +- .../src/components/MiniCart/miniCart.js | 75 +++--- .../src/components/MiniCart/trigger.js | 5 +- .../src/components/Navigation/trigger.js | 15 +- .../venia-concept/src/components/Page/page.js | 8 +- packages/venia-concept/src/index.css | 8 +- packages/venia-concept/src/index.js | 12 +- packages/venia-concept/src/reducers/app.js | 35 +-- packages/venia-concept/src/reducers/cart.js | 111 +++----- .../venia-concept/src/reducers/checkout.js | 130 ++++----- .../venia-concept/src/reducers/directory.js | 22 ++ .../venia-concept/src/shared/durations.js | 5 +- packages/venia-concept/src/store.js | 16 ++ .../src/util/__mocks__/simplePersistence.js | 11 + 61 files changed, 2218 insertions(+), 758 deletions(-) rename packages/venia-concept/{src => }/__mocks__/@magento/peregrine.js (65%) create mode 100644 packages/venia-concept/src/__mocks__/store.js delete mode 100644 packages/venia-concept/src/actions/app.js create mode 100644 packages/venia-concept/src/actions/app/__tests__/actions.spec.js create mode 100644 packages/venia-concept/src/actions/app/__tests__/asyncActions.spec.js create mode 100644 packages/venia-concept/src/actions/app/actions.js create mode 100644 packages/venia-concept/src/actions/app/asyncActions.js create mode 100644 packages/venia-concept/src/actions/app/index.js delete mode 100644 packages/venia-concept/src/actions/cart.js create mode 100644 packages/venia-concept/src/actions/cart/__tests__/actions.spec.js create mode 100644 packages/venia-concept/src/actions/cart/__tests__/asyncActions.spec.js create mode 100644 packages/venia-concept/src/actions/cart/actions.js create mode 100644 packages/venia-concept/src/actions/cart/asyncActions.js create mode 100644 packages/venia-concept/src/actions/cart/index.js delete mode 100644 packages/venia-concept/src/actions/checkout.js create mode 100644 packages/venia-concept/src/actions/checkout/__tests__/actions.spec.js create mode 100644 packages/venia-concept/src/actions/checkout/__tests__/asyncActions.spec.js create mode 100644 packages/venia-concept/src/actions/checkout/actions.js create mode 100644 packages/venia-concept/src/actions/checkout/asyncActions.js create mode 100644 packages/venia-concept/src/actions/checkout/index.js create mode 100644 packages/venia-concept/src/actions/directory/__tests__/actions.spec.js create mode 100644 packages/venia-concept/src/actions/directory/__tests__/asyncActions.spec.js create mode 100644 packages/venia-concept/src/actions/directory/actions.js create mode 100644 packages/venia-concept/src/actions/directory/asyncActions.js create mode 100644 packages/venia-concept/src/actions/directory/index.js create mode 100644 packages/venia-concept/src/components/Checkout/address.css create mode 100644 packages/venia-concept/src/components/Checkout/address.js create mode 100644 packages/venia-concept/src/components/Checkout/cart.css create mode 100644 packages/venia-concept/src/components/Checkout/cart.js delete mode 100644 packages/venia-concept/src/components/Checkout/entrance.css delete mode 100644 packages/venia-concept/src/components/Checkout/entrance.js create mode 100644 packages/venia-concept/src/components/Checkout/label.css create mode 100644 packages/venia-concept/src/components/Checkout/label.js rename packages/venia-concept/src/components/Checkout/{exit.css => receipt.css} (100%) rename packages/venia-concept/src/components/Checkout/{exit.js => receipt.js} (94%) create mode 100644 packages/venia-concept/src/reducers/directory.js create mode 100644 packages/venia-concept/src/store.js create mode 100644 packages/venia-concept/src/util/__mocks__/simplePersistence.js diff --git a/package-lock.json b/package-lock.json index 0b451658d1..a82e86dcc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,24 @@ "integrity": "sha1-H0XrYXv5Rj1IKywE00nZ5O2/SJI=", "dev": true }, + "@babel/runtime-corejs2": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.0.0.tgz", + "integrity": "sha512-Yww0jXgolNtkhcK+Txo5JN+DjBpNmmAtD7G99HOebhEjBzjnACG09Tip9C8lSOF6PrhA56OeJWeOZduNJaKxBA==", + "dev": true, + "requires": { + "core-js": "^2.5.7", + "regenerator-runtime": "^0.12.0" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz", + "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==", + "dev": true + } + } + }, "@babel/template": { "version": "7.0.0-beta.44", "resolved": "http://registry.npmjs.org/@babel/template/-/template-7.0.0-beta.44.tgz", @@ -12909,6 +12927,15 @@ "wrappy": "1" } }, + "informed": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/informed/-/informed-1.10.5.tgz", + "integrity": "sha512-3/lF958pr6JFc8GyAw4W+HWDYLLnF8DAjSAiPIyj6QZ4ayP/VYfaEYdM6wJwa5B8IbsQCv3eQCxaN5SeaXvbIw==", + "dev": true, + "requires": { + "@babel/runtime-corejs2": "^7.0.0-rc.1" + } + }, "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", @@ -15455,6 +15482,12 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" }, + "lodash.curry": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz", + "integrity": "sha1-JI42By7ekGUB11lmIAqG2riyMXA=", + "dev": true + }, "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -20398,6 +20431,12 @@ } } }, + "reduce-reducers": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/reduce-reducers/-/reduce-reducers-0.1.5.tgz", + "integrity": "sha512-uoVmQnZQ+BtKKDKpBdbBri5SLNyIK9ULZGOA504++VbHcwouWE+fJDIo8AuESPF9/EYSkI0v05LDEQK6stCbTA==", + "dev": true + }, "redux": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.0.tgz", @@ -20408,6 +20447,18 @@ "symbol-observable": "^1.2.0" } }, + "redux-actions": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/redux-actions/-/redux-actions-2.6.1.tgz", + "integrity": "sha1-QsBulHOfvm2zXbNgWrsQW9s3JNg=", + "dev": true, + "requires": { + "invariant": "^2.2.1", + "lodash.camelcase": "^4.3.0", + "lodash.curry": "^4.1.1", + "reduce-reducers": "^0.1.0" + } + }, "redux-thunk": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", diff --git a/package.json b/package.json index 4ba808a1a0..dfc9eddf23 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "graphql": "~0", "graphql-tag": "^2.9.2", "identity-obj-proxy": "^3.0.0", + "informed": "^1.9.0", "intl": "^1.2.5", "jest": "^23.4.0", "jest-fetch-mock": "^1.4.1", @@ -93,6 +94,7 @@ "react-redux": "^5.0.7", "react-router-dom": "^4.2.2", "redux": "^4.0.0", + "redux-actions": "2.6.1", "redux-thunk": "^2.3.0", "rimraf": "^2.6.2", "storybook-readme": "^3.3.0", diff --git a/packages/venia-concept/src/__mocks__/@magento/peregrine.js b/packages/venia-concept/__mocks__/@magento/peregrine.js similarity index 65% rename from packages/venia-concept/src/__mocks__/@magento/peregrine.js rename to packages/venia-concept/__mocks__/@magento/peregrine.js index a9c6b0f3c1..ebd823551c 100644 --- a/packages/venia-concept/src/__mocks__/@magento/peregrine.js +++ b/packages/venia-concept/__mocks__/@magento/peregrine.js @@ -1,5 +1,13 @@ import { createElement } from 'react'; +export const mockRequest = jest.fn(); + +export const RestApi = { + Magento2: { + request: mockRequest + } +}; + /** * the Price component from @magento/peregrine * has browser-specific functionality and cannot diff --git a/packages/venia-concept/package.json b/packages/venia-concept/package.json index 3f264f3e4b..ff70a512b8 100644 --- a/packages/venia-concept/package.json +++ b/packages/venia-concept/package.json @@ -1,30 +1,30 @@ { - "name": "theme-frontend-venia", - "version": "0.1.1", - "description": "Venia PWA Concept Theme for Magento 2", - "license": "(OSL-3.0 OR AFL-3.0)", - "author": "Magento Commerce", - "main": "src/index.js", - "repository": "github:magento-research/pwa-studio", - "bugs": { - "url": "https://github.com/magento-research/pwa-studio/issues" - }, - "homepage": "https://github.com/magento-research/pwa-studio/tree/master/packages/venia-concept#readme", - "scripts": { - "build": "webpack --color --env.phase production", - "clean": "rimraf web/js", - "prepare": "npm-merge-driver install", - "start": "webpack-dev-server --progress --color --env.phase development", - "start:debug": "node --inspect-brk ./node_modules/.bin/webpack-dev-server --progress --color --env.phase development", - "watch": "npm run -s start" - }, - "dependencies": {}, - "devDependencies": { - "@magento/peregrine": "*", - "@magento/pwa-buildpack": "*", - "npm-merge-driver": "^2.3.5", - "rimraf": "^2.6.2", - "webpack": "3.11.0", - "webpack-dev-server": "2.11.0" - } + "name": "theme-frontend-venia", + "version": "0.1.1", + "description": "Venia PWA Concept Theme for Magento 2", + "license": "(OSL-3.0 OR AFL-3.0)", + "author": "Magento Commerce", + "main": "src/index.js", + "repository": "github:magento-research/pwa-studio", + "bugs": { + "url": "https://github.com/magento-research/pwa-studio/issues" + }, + "homepage": "https://github.com/magento-research/pwa-studio/tree/master/packages/venia-concept#readme", + "scripts": { + "build": "webpack --color --env.phase production", + "clean": "rimraf web/js", + "prepare": "npm-merge-driver install", + "start": "webpack-dev-server --progress --color --env.phase development", + "start:debug": "node --inspect-brk ./node_modules/.bin/webpack-dev-server --progress --color --env.phase development", + "watch": "npm run -s start" + }, + "dependencies": {}, + "devDependencies": { + "@magento/peregrine": "*", + "@magento/pwa-buildpack": "*", + "npm-merge-driver": "^2.3.5", + "rimraf": "^2.6.2", + "webpack": "3.11.0", + "webpack-dev-server": "2.11.0" + } } diff --git a/packages/venia-concept/src/__mocks__/store.js b/packages/venia-concept/src/__mocks__/store.js new file mode 100644 index 0000000000..21c5688832 --- /dev/null +++ b/packages/venia-concept/src/__mocks__/store.js @@ -0,0 +1,7 @@ +const initialState = {}; + +export const addReducer = jest.fn(); +export const dispatch = jest.fn(); +export const getState = jest.fn(() => initialState); + +export default { addReducer, dispatch, getState }; diff --git a/packages/venia-concept/src/actions/app.js b/packages/venia-concept/src/actions/app.js deleted file mode 100644 index d96cf18142..0000000000 --- a/packages/venia-concept/src/actions/app.js +++ /dev/null @@ -1,15 +0,0 @@ -import timeout from 'src/util/timeout'; -import { - drawerClose as closeMs, - drawerOpen as openMs -} from 'src/shared/durations'; - -export const toggleDrawer = drawerName => dispatch => { - dispatch({ - type: 'TOGGLE_DRAWER', - payload: drawerName - }); - return timeout(drawerName ? openMs : closeMs); -}; - -export const closeDrawer = () => toggleDrawer(null); diff --git a/packages/venia-concept/src/actions/app/__tests__/actions.spec.js b/packages/venia-concept/src/actions/app/__tests__/actions.spec.js new file mode 100644 index 0000000000..a2da227f14 --- /dev/null +++ b/packages/venia-concept/src/actions/app/__tests__/actions.spec.js @@ -0,0 +1,20 @@ +import actions from '../actions'; + +const payload = 'FOO'; +const error = new Error('BAR'); + +test('toggleDrawer.toString() returns the proper action type', () => { + expect(actions.toggleDrawer.toString()).toBe('APP/TOGGLE_DRAWER'); +}); + +test('toggleDrawer() returns a proper action object', () => { + expect(actions.toggleDrawer(payload)).toEqual({ + type: 'APP/TOGGLE_DRAWER', + payload + }); + expect(actions.toggleDrawer(error)).toEqual({ + type: 'APP/TOGGLE_DRAWER', + payload: error, + error: true + }); +}); diff --git a/packages/venia-concept/src/actions/app/__tests__/asyncActions.spec.js b/packages/venia-concept/src/actions/app/__tests__/asyncActions.spec.js new file mode 100644 index 0000000000..af482a1821 --- /dev/null +++ b/packages/venia-concept/src/actions/app/__tests__/asyncActions.spec.js @@ -0,0 +1,47 @@ +import { dispatch, getState } from 'src/store'; +import actions from '../actions'; +import { closeDrawer, toggleDrawer } from '../asyncActions'; + +jest.mock('src/store'); + +const thunkArgs = [dispatch, getState]; + +afterEach(() => { + dispatch.mockClear(); +}); + +test('toggleDrawer() to return a thunk', () => { + expect(toggleDrawer()).toBeInstanceOf(Function); +}); + +test('toggleDrawer thunk returns undefined', async () => { + const payload = 'FOO'; + const result = await toggleDrawer(payload)(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('toggleDrawer thunk dispatches actions', async () => { + const payload = 'FOO'; + await toggleDrawer(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.toggleDrawer(payload)); + expect(dispatch).toHaveBeenCalledTimes(1); +}); + +test('closeDrawer() to return a thunk ', () => { + expect(closeDrawer()).toBeInstanceOf(Function); +}); + +test('closeDrawer thunk returns undefined', async () => { + const result = await closeDrawer()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('closeDrawer thunk dispatches actions', async () => { + await closeDrawer()(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.toggleDrawer(null)); + expect(dispatch).toHaveBeenCalledTimes(1); +}); diff --git a/packages/venia-concept/src/actions/app/actions.js b/packages/venia-concept/src/actions/app/actions.js new file mode 100644 index 0000000000..972465ba4d --- /dev/null +++ b/packages/venia-concept/src/actions/app/actions.js @@ -0,0 +1,6 @@ +import { createActions } from 'redux-actions'; + +const prefix = 'APP'; +const actionTypes = ['TOGGLE_DRAWER']; + +export default createActions(...actionTypes, { prefix }); diff --git a/packages/venia-concept/src/actions/app/asyncActions.js b/packages/venia-concept/src/actions/app/asyncActions.js new file mode 100644 index 0000000000..b7e2c2e7a1 --- /dev/null +++ b/packages/venia-concept/src/actions/app/asyncActions.js @@ -0,0 +1,7 @@ +import actions from './actions'; + +export const toggleDrawer = name => async dispatch => + dispatch(actions.toggleDrawer(name)); + +export const closeDrawer = () => async dispatch => + dispatch(actions.toggleDrawer(null)); diff --git a/packages/venia-concept/src/actions/app/index.js b/packages/venia-concept/src/actions/app/index.js new file mode 100644 index 0000000000..c6c32a70b8 --- /dev/null +++ b/packages/venia-concept/src/actions/app/index.js @@ -0,0 +1,2 @@ +export { default } from './actions'; +export * from './asyncActions'; diff --git a/packages/venia-concept/src/actions/cart.js b/packages/venia-concept/src/actions/cart.js deleted file mode 100644 index 755a149535..0000000000 --- a/packages/venia-concept/src/actions/cart.js +++ /dev/null @@ -1,183 +0,0 @@ -import { RestApi } from '@magento/peregrine'; - -import { closeDrawer, toggleDrawer } from 'src/actions/app'; - -const { request } = RestApi.Magento2; - -const createGuestCart = () => - async function thunk(...args) { - const [dispatch, getState] = args; - const { checkout } = getState(); - - if (checkout && checkout.status === 'ACCEPTED') { - dispatch({ type: 'RESET_CHECKOUT' }); - } - - try { - const response = await request('/rest/V1/guest-carts', { - method: 'POST' - }); - - dispatch({ - type: 'CREATE_GUEST_CART', - payload: response - }); - } catch (error) { - dispatch({ - type: 'CREATE_GUEST_CART', - payload: error, - error: true - }); - } - }; - -const addItemToCart = payload => { - const { item, quantity } = payload; - - return async function thunk(...args) { - const [dispatch] = args; - const guestCartId = await getGuestCartId(...args); - - try { - const cartItem = await request( - `/rest/V1/guest-carts/${guestCartId}/items`, - { - method: 'POST', - body: JSON.stringify({ - cartItem: { - qty: quantity, - sku: item.sku, - name: item.name, - quote_id: guestCartId - } - }) - } - ); - - dispatch({ - type: 'ADD_ITEM_TO_CART', - payload: { - cartItem, - item, - quantity - } - }); - } catch (error) { - const { response } = error; - - if (response && response.status === 404) { - // guest cart expired! - await dispatch(createGuestCart()); - // re-execute this thunk - return thunk(...args); - } - - dispatch({ - type: 'ADD_ITEM_TO_CART', - payload: error, - error: true - }); - } - - await Promise.all([ - getCartDetails({ forceRefresh: true })(...args), - toggleCart()(...args) - ]); - - return payload; - }; -}; - -const getCartDetails = (payload = {}) => { - const { forceRefresh } = payload; - - return async function thunk(...args) { - const [dispatch] = args; - const guestCartId = await getGuestCartId(...args); - - try { - const [details, totals] = await Promise.all([ - fetchCartPart({ guestCartId, forceRefresh }), - fetchCartPart({ - guestCartId, - forceRefresh, - subResource: 'totals' - }) - ]); - - dispatch({ - type: 'GET_CART_DETAILS', - payload: { details, totals } - }); - } catch (error) { - const { response } = error; - - if (response && response.status === 404) { - // guest cart expired! - await dispatch(createGuestCart()); - // re-execute this thunk - return thunk(...args); - } - - dispatch({ - type: 'GET_CART_DETAILS', - payload: error, - error: true - }); - } - - return payload; - }; -}; - -const toggleCart = () => - async function thunk(...args) { - const [dispatch, getState] = args; - const { app, cart } = getState(); - - // ensure state slices are present - if (!app || !cart) { - return; - } - - // if the cart drawer is open, close it - if (app.drawer === 'cart') { - await dispatch(closeDrawer()); - return; - } - - // otherwise open the cart and load its contents - await Promise.all([ - dispatch(getCartDetails()), - dispatch(toggleDrawer('cart')) - ]); - }; - -async function fetchCartPart({ guestCartId, forceRefresh, subResource = '' }) { - if (!guestCartId) { - return null; - } - - return request(`/rest/V1/guest-carts/${guestCartId}/${subResource}`, { - cache: forceRefresh ? 'reload' : 'default' - }); -} - -async function getGuestCartId(dispatch, getState) { - const { cart } = getState(); - - // reducers may be added asynchronously - if (!cart) { - return null; - } - - // create a guest cart if one hasn't been created yet - if (!cart.guestCartId) { - await dispatch(createGuestCart()); - } - - // retrieve app state again - return getState().cart.guestCartId; -} - -export { addItemToCart, getCartDetails, getGuestCartId, toggleCart }; diff --git a/packages/venia-concept/src/actions/cart/__tests__/actions.spec.js b/packages/venia-concept/src/actions/cart/__tests__/actions.spec.js new file mode 100644 index 0000000000..73a0179212 --- /dev/null +++ b/packages/venia-concept/src/actions/cart/__tests__/actions.spec.js @@ -0,0 +1,93 @@ +import actions from '../actions'; + +const payload = 'PAYLOAD'; +const error = new Error('ERROR'); + +test('addItem.request.toString() returns the proper action type', () => { + expect(actions.addItem.request.toString()).toBe('CART/ADD_ITEM/REQUEST'); +}); + +test('addItem.request() returns a proper action object', () => { + expect(actions.addItem.request(payload)).toEqual({ + type: 'CART/ADD_ITEM/REQUEST', + payload + }); +}); + +test('addItem.receive.toString() returns the proper action type', () => { + expect(actions.addItem.receive.toString()).toBe('CART/ADD_ITEM/RECEIVE'); +}); + +test('addItem.receive() returns a proper action object', () => { + expect(actions.addItem.receive(payload)).toEqual({ + type: 'CART/ADD_ITEM/RECEIVE', + payload + }); + expect(actions.addItem.receive(error)).toEqual({ + type: 'CART/ADD_ITEM/RECEIVE', + payload: error, + error: true + }); +}); + +test('getGuestCart.request.toString() returns the proper action type', () => { + expect(actions.getGuestCart.request.toString()).toBe( + 'CART/GET_GUEST_CART/REQUEST' + ); +}); + +test('getGuestCart.request() returns a proper action object', () => { + expect(actions.getGuestCart.request(payload)).toEqual({ + type: 'CART/GET_GUEST_CART/REQUEST', + payload + }); +}); + +test('getGuestCart.receive.toString() returns the proper action type', () => { + expect(actions.getGuestCart.receive.toString()).toBe( + 'CART/GET_GUEST_CART/RECEIVE' + ); +}); + +test('getGuestCart.receive() returns a proper action object', () => { + expect(actions.getGuestCart.receive(payload)).toEqual({ + type: 'CART/GET_GUEST_CART/RECEIVE', + payload + }); + expect(actions.getGuestCart.receive(error)).toEqual({ + type: 'CART/GET_GUEST_CART/RECEIVE', + payload: error, + error: true + }); +}); + +test('getDetails.request.toString() returns the proper action type', () => { + expect(actions.getDetails.request.toString()).toBe( + 'CART/GET_DETAILS/REQUEST' + ); +}); + +test('getDetails.request() returns a proper action object', () => { + expect(actions.getDetails.request(payload)).toEqual({ + type: 'CART/GET_DETAILS/REQUEST', + payload + }); +}); + +test('getDetails.receive.toString() returns the proper action type', () => { + expect(actions.getDetails.receive.toString()).toBe( + 'CART/GET_DETAILS/RECEIVE' + ); +}); + +test('getDetails.receive() returns a proper action object', () => { + expect(actions.getDetails.receive(payload)).toEqual({ + type: 'CART/GET_DETAILS/RECEIVE', + payload + }); + expect(actions.getDetails.receive(error)).toEqual({ + type: 'CART/GET_DETAILS/RECEIVE', + payload: error, + error: true + }); +}); diff --git a/packages/venia-concept/src/actions/cart/__tests__/asyncActions.spec.js b/packages/venia-concept/src/actions/cart/__tests__/asyncActions.spec.js new file mode 100644 index 0000000000..09dc204938 --- /dev/null +++ b/packages/venia-concept/src/actions/cart/__tests__/asyncActions.spec.js @@ -0,0 +1,244 @@ +import { RestApi } from '@magento/peregrine'; + +import { dispatch, getState } from 'src/store'; +import checkoutActions from 'src/actions/checkout'; +import { mockGetItem, mockSetItem } from 'src/util/simplePersistence'; +import actions from '../actions'; +import { + addItemToCart, + createGuestCart, + getCartDetails, + toggleCart +} from '../asyncActions'; + +jest.mock('src/store'); +jest.mock('src/util/simplePersistence'); + +const thunkArgs = [dispatch, getState]; +const { request } = RestApi.Magento2; + +beforeAll(() => { + getState.mockImplementation(() => ({ + app: { drawer: null }, + cart: { guestCartId: 'GUEST_CART_ID' } + })); +}); + +afterEach(() => { + dispatch.mockClear(); + request.mockClear(); +}); + +afterAll(() => { + getState.mockRestore(); +}); + +test('createGuestCart() returns a thunk', () => { + expect(createGuestCart()).toBeInstanceOf(Function); +}); + +test('createGuestCart thunk returns undefined', async () => { + const result = await createGuestCart()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('createGuestCart thunk does nothing if a guest cart exists', async () => { + await createGuestCart()(...thunkArgs); + + expect(dispatch).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); +}); + +test('createGuestCart thunk uses the guest cart from storage', async () => { + const storedGuestCartId = 'STORED_GUEST_CART_ID'; + getState.mockImplementationOnce(() => ({ cart: {} })); + mockGetItem.mockImplementationOnce(() => storedGuestCartId); + + await createGuestCart()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, checkoutActions.reset()); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getGuestCart.receive(storedGuestCartId) + ); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(request).not.toHaveBeenCalled(); +}); + +test('createGuestCart thunk dispatches actions on success', async () => { + const response = 'NEW_GUEST_CART_ID'; + + request.mockResolvedValueOnce(response); + getState.mockImplementationOnce(() => ({ cart: {} })); + + await createGuestCart()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, checkoutActions.reset()); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.getGuestCart.request()); + expect(dispatch).toHaveBeenNthCalledWith( + 3, + actions.getGuestCart.receive(response) + ); + expect(dispatch).toHaveBeenCalledTimes(3); + expect(mockSetItem).toHaveBeenCalled(); +}); + +test('createGuestCart thunk dispatches actions on failure', async () => { + const error = new Error('ERROR'); + + request.mockResolvedValueOnce(error); + getState.mockImplementationOnce(() => ({ cart: {} })); + + await createGuestCart()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, checkoutActions.reset()); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.getGuestCart.request()); + expect(dispatch).toHaveBeenNthCalledWith( + 3, + actions.getGuestCart.receive(error) + ); + expect(dispatch).toHaveBeenCalledTimes(3); +}); + +test('addItemToCart() returns a thunk', () => { + expect(addItemToCart()).toBeInstanceOf(Function); +}); + +test('addItemToCart thunk returns undefined', async () => { + const result = await addItemToCart()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('addItemToCart thunk dispatches actions on success', async () => { + const payload = { item: 'ITEM', quantity: 1 }; + const cartItem = 'CART_ITEM'; + + request.mockResolvedValueOnce(cartItem); + await addItemToCart(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.addItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.addItem.receive({ cartItem, ...payload }) + ); + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(4, expect.any(Function)); + expect(dispatch).toHaveBeenCalledTimes(4); +}); + +test('addItemToCart thunk dispatches actions on failure', async () => { + const payload = { item: 'ITEM', quantity: 1 }; + const error = new Error('ERROR'); + + request.mockRejectedValueOnce(error); + await addItemToCart(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.addItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.addItem.receive(error)); + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(4, expect.any(Function)); + expect(dispatch).toHaveBeenCalledTimes(4); +}); + +test('getCartDetails() returns a thunk', () => { + expect(getCartDetails()).toBeInstanceOf(Function); +}); + +test('getCartDetails thunk returns undefined', async () => { + const result = await getCartDetails()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('getCartDetails thunk dispatches actions on success', async () => { + request.mockResolvedValueOnce(1); + request.mockResolvedValueOnce(2); + + await getCartDetails()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.getDetails.request('GUEST_CART_ID') + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getDetails.receive({ details: 1, totals: 2 }) + ); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('getCartDetails thunk dispatches actions on failure', async () => { + const error = new Error('ERROR'); + request.mockRejectedValueOnce(error); + + await getCartDetails()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.getDetails.request('GUEST_CART_ID') + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getDetails.receive(error) + ); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('getCartDetails thunk merges cached item images into details', async () => { + const cache = { SKU_1: 'IMAGE_1' }; + const items = [{ image: 'IMAGE_0', sku: 'SKU_0' }, { sku: 'SKU_1' }]; + const expected = [items[0], { ...items[1], image: cache.SKU_1 }]; + + mockGetItem.mockResolvedValueOnce(cache); + request.mockResolvedValueOnce({ items }); + request.mockResolvedValueOnce(2); + + await getCartDetails()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getDetails.receive({ details: { items: expected }, totals: 2 }) + ); +}); + +test('toggleCart() returns a thunk', () => { + expect(toggleCart()).toBeInstanceOf(Function); +}); + +test('toggleCart thunk returns undefined', async () => { + const result = await toggleCart()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('toggleCart thunk exits if app state is not present', async () => { + getState.mockImplementationOnce(() => ({ cart: {} })); + + await toggleCart()(...thunkArgs); + + expect(dispatch).not.toHaveBeenCalled(); +}); + +test('toggleCart thunk exits if cart state is not present', async () => { + getState.mockImplementationOnce(() => ({ app: {} })); + + await toggleCart()(...thunkArgs); + + expect(dispatch).not.toHaveBeenCalled(); +}); + +test('toggleCart thunk opens the drawer and refreshes the cart', async () => { + await toggleCart()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(dispatch).toHaveBeenCalledTimes(2); +}); diff --git a/packages/venia-concept/src/actions/cart/actions.js b/packages/venia-concept/src/actions/cart/actions.js new file mode 100644 index 0000000000..6c40dd45cc --- /dev/null +++ b/packages/venia-concept/src/actions/cart/actions.js @@ -0,0 +1,20 @@ +import { createActions } from 'redux-actions'; + +const prefix = 'CART'; + +const actionMap = { + ADD_ITEM: { + REQUEST: null, + RECEIVE: null + }, + GET_GUEST_CART: { + REQUEST: null, + RECEIVE: null + }, + GET_DETAILS: { + REQUEST: null, + RECEIVE: null + } +}; + +export default createActions(actionMap, { prefix }); diff --git a/packages/venia-concept/src/actions/cart/asyncActions.js b/packages/venia-concept/src/actions/cart/asyncActions.js new file mode 100644 index 0000000000..86bab0ef94 --- /dev/null +++ b/packages/venia-concept/src/actions/cart/asyncActions.js @@ -0,0 +1,227 @@ +import { RestApi } from '@magento/peregrine'; + +import { closeDrawer, toggleDrawer } from 'src/actions/app'; +import checkoutActions from 'src/actions/checkout'; +import BrowserPersistence from 'src/util/simplePersistence'; +import actions from './actions'; + +const { request } = RestApi.Magento2; +const storage = new BrowserPersistence(); + +export const createGuestCart = () => + async function thunk(dispatch, getState) { + const { cart } = getState(); + + // if a guest cart already exists, exit + if (cart.guestCartId) { + return; + } + + // reset the checkout workflow + // in case the user has already completed an order this session + dispatch(checkoutActions.reset()); + + const guestCartId = await retrieveGuestCartId(); + + // if a guest cart exists in storage, act like we just received it + if (guestCartId) { + dispatch(actions.getGuestCart.receive(guestCartId)); + return; + } + + // otherwise, request a new guest cart + dispatch(actions.getGuestCart.request()); + + try { + const id = await request('/rest/V1/guest-carts', { + method: 'POST' + }); + + // write to storage in the background + saveGuestCartId(id); + dispatch(actions.getGuestCart.receive(id)); + } catch (error) { + dispatch(actions.getGuestCart.receive(error)); + } + }; + +export const addItemToCart = (payload = {}) => { + const { item, quantity } = payload; + + writeImageToCache(item); + + return async function thunk(dispatch, getState) { + dispatch(actions.addItem.request(payload)); + + try { + const { cart } = getState(); + const { guestCartId } = cart; + + if (!guestCartId) { + throw new Error('Missing required information: guestCartId'); + } + + const cartItem = await request( + `/rest/V1/guest-carts/${guestCartId}/items`, + { + method: 'POST', + body: JSON.stringify({ + cartItem: { + qty: quantity, + sku: item.sku, + name: item.name, + quote_id: guestCartId + } + }) + } + ); + + dispatch(actions.addItem.receive({ cartItem, item, quantity })); + } catch (error) { + const { response } = error; + + dispatch(actions.addItem.receive(error)); + + // check if the guest cart has expired + if (response && response.status === 404) { + // if so, create a new one + await dispatch(createGuestCart()); + // then retry this operation + return thunk(...arguments); + } + } + + await Promise.all([ + dispatch(toggleDrawer('cart')), + dispatch(getCartDetails({ forceRefresh: true })) + ]); + }; +}; + +export const getCartDetails = (payload = {}) => { + const { forceRefresh } = payload; + + return async function thunk(dispatch, getState) { + const { cart } = getState(); + const { guestCartId } = cart; + + dispatch(actions.getDetails.request(guestCartId)); + + // if there isn't a guest cart, create one + // then retry this operation + if (!guestCartId) { + await dispatch(createGuestCart()); + return thunk(...arguments); + } + + try { + const [imageCache, details, totals] = await Promise.all([ + retrieveImageCache(), + fetchCartPart({ guestCartId, forceRefresh }), + fetchCartPart({ + guestCartId, + forceRefresh, + subResource: 'totals' + }) + ]); + + const { items } = details; + + // for each item in the cart, look up its image in the cache + // and merge it into the item object + if (imageCache && Array.isArray(items) && items.length) { + items.forEach(item => { + item.image = item.image || imageCache[item.sku] || {}; + }); + } + + dispatch(actions.getDetails.receive({ details, totals })); + } catch (error) { + const { response } = error; + + dispatch(actions.getDetails.receive(error)); + + // check if the guest cart has expired + if (response && response.status === 404) { + // if so, create a new one + await dispatch(createGuestCart()); + // then retry this operation + return thunk(...arguments); + } + } + }; +}; + +export const toggleCart = () => + async function thunk(dispatch, getState) { + const { app, cart } = getState(); + + // ensure state slices are present + if (!app || !cart) { + return; + } + + // if the cart drawer is open, close it + if (app.drawer === 'cart') { + return dispatch(closeDrawer()); + } + + // otherwise open the cart and load its contents + await Promise.all([ + dispatch(toggleDrawer('cart')), + dispatch(getCartDetails()) + ]); + }; + +/* helpers */ + +async function fetchCartPart({ guestCartId, forceRefresh, subResource = '' }) { + if (!guestCartId) { + throw new Error('Missing required information: guestCartId'); + } + + return request(`/rest/V1/guest-carts/${guestCartId}/${subResource}`, { + cache: forceRefresh ? 'reload' : 'default' + }); +} + +export async function retrieveGuestCartId() { + return storage.getItem('guestCartId'); +} + +export async function saveGuestCartId(id) { + return storage.setItem('guestCartId', id); +} + +export async function clearGuestCartId() { + return storage.removeItem('guestCartId'); +} + +async function retrieveImageCache() { + return storage.getItem('imagesBySku') || {}; +} + +async function saveImageCache(cache) { + return storage.setItem('imagesBySku', cache); +} + +async function writeImageToCache(item = {}) { + const { media_gallery_entries: media, sku } = item; + + if (sku) { + const image = media.find(m => m.position === 1) || media[0]; + + if (image) { + const imageCache = await retrieveImageCache(); + + // if there is an image and it differs from cache + // write to cache and save in the background + if (imageCache[sku] !== image) { + imageCache[sku] = image; + saveImageCache(imageCache); + + return image; + } + } + } +} diff --git a/packages/venia-concept/src/actions/cart/index.js b/packages/venia-concept/src/actions/cart/index.js new file mode 100644 index 0000000000..c6c32a70b8 --- /dev/null +++ b/packages/venia-concept/src/actions/cart/index.js @@ -0,0 +1,2 @@ +export { default } from './actions'; +export * from './asyncActions'; diff --git a/packages/venia-concept/src/actions/checkout.js b/packages/venia-concept/src/actions/checkout.js deleted file mode 100644 index 14943029e6..0000000000 --- a/packages/venia-concept/src/actions/checkout.js +++ /dev/null @@ -1,103 +0,0 @@ -import { RestApi } from '@magento/peregrine'; - -import { closeDrawer } from 'src/actions/app'; -import { getGuestCartId } from 'src/actions/cart'; -import * as durations from 'src/shared/durations'; -import timeout from 'src/util/timeout'; - -const { request } = RestApi.Magento2; - -const mockAddress = { - country_id: 'US', - firstname: 'Veronica', - lastname: 'Costello', - street: ['6146 Honey Bluff Parkway'], - city: 'Calder', - postcode: '49628-7978', - region_id: 33, - region_code: 'MI', - region: 'Michigan', - telephone: '(555) 229-3326', - email: 'veronica@example.com' -}; - -const enterSubflow = () => - async function thunk(dispatch) { - dispatch({ type: 'SUBMIT_SHIPPING_INFORMATION' }); - - try { - const guestCartId = await getGuestCartId(...arguments); - const payload = await request( - `/rest/V1/guest-carts/${guestCartId}/shipping-information`, - { - method: 'POST', - // TODO: replace with real data from cart state - body: JSON.stringify({ - addressInformation: { - billing_address: mockAddress, - shipping_address: mockAddress, - shipping_method_code: 'flatrate', - shipping_carrier_code: 'flatrate' - } - }) - } - ); - - dispatch({ - type: 'ACCEPT_SHIPPING_INFORMATION', - payload - }); - } catch (error) { - dispatch({ - type: 'REJECT_SHIPPING_INFORMATION', - payload: error, - error: true - }); - } - }; - -const resetCheckout = () => async dispatch => { - await closeDrawer()(dispatch); - dispatch({ type: 'RESET_CHECKOUT' }); -}; - -const requestOrder = () => async dispatch => { - dispatch({ type: 'REQUEST_ORDER' }); - // TODO: replace with api call - await timeout(durations.requestOrder); - dispatch({ type: 'RECEIVE_ORDER' }); -}; - -const submitOrder = () => - async function thunk(dispatch) { - dispatch({ type: 'SUBMIT_ORDER' }); - - try { - const guestCartId = await getGuestCartId(...arguments); - const payload = await request( - `/rest/V1/guest-carts/${guestCartId}/order`, - { - method: 'PUT', - // TODO: replace with real data from cart state - body: JSON.stringify({ - paymentMethod: { - method: 'checkmo' - } - }) - } - ); - - dispatch({ - type: 'ACCEPT_ORDER', - payload - }); - } catch (error) { - dispatch({ - type: 'REJECT_ORDER', - payload: error, - error: true - }); - } - }; - -export { enterSubflow, requestOrder, resetCheckout, submitOrder }; diff --git a/packages/venia-concept/src/actions/checkout/__tests__/actions.spec.js b/packages/venia-concept/src/actions/checkout/__tests__/actions.spec.js new file mode 100644 index 0000000000..ec3368eb5d --- /dev/null +++ b/packages/venia-concept/src/actions/checkout/__tests__/actions.spec.js @@ -0,0 +1,139 @@ +import actions from '../actions'; + +const payload = 'PAYLOAD'; +const error = new Error('ERROR'); + +test('begin.toString() returns the proper action type', () => { + expect(actions.begin.toString()).toBe('CHECKOUT/BEGIN'); +}); + +test('begin() returns a proper action object', () => { + expect(actions.begin(payload)).toEqual({ type: 'CHECKOUT/BEGIN', payload }); + expect(actions.begin(error)).toEqual({ + type: 'CHECKOUT/BEGIN', + payload: error, + error: true + }); +}); + +test('edit.toString() returns the proper action type', () => { + expect(actions.edit.toString()).toBe('CHECKOUT/EDIT'); +}); + +test('edit() returns a proper action object', () => { + expect(actions.edit(payload)).toEqual({ type: 'CHECKOUT/EDIT', payload }); + expect(actions.edit(error)).toEqual({ + type: 'CHECKOUT/EDIT', + payload: error, + error: true + }); +}); + +test('reset.toString() returns the proper action type', () => { + expect(actions.reset.toString()).toBe('CHECKOUT/RESET'); +}); + +test('reset() returns a proper action object', () => { + expect(actions.reset(payload)).toEqual({ type: 'CHECKOUT/RESET', payload }); + expect(actions.reset(error)).toEqual({ + type: 'CHECKOUT/RESET', + payload: error, + error: true + }); +}); + +test('input.submit.toString() returns the proper action type', () => { + expect(actions.input.submit.toString()).toBe('CHECKOUT/INPUT/SUBMIT'); +}); + +test('input.submit() returns a proper action object', () => { + expect(actions.input.submit(payload)).toEqual({ + type: 'CHECKOUT/INPUT/SUBMIT', + payload + }); + expect(actions.input.submit(error)).toEqual({ + type: 'CHECKOUT/INPUT/SUBMIT', + payload: error, + error: true + }); +}); + +test('input.accept.toString() returns the proper action type', () => { + expect(actions.input.accept.toString()).toBe('CHECKOUT/INPUT/ACCEPT'); +}); + +test('input.accept() returns a proper action object', () => { + expect(actions.input.accept(payload)).toEqual({ + type: 'CHECKOUT/INPUT/ACCEPT', + payload + }); + expect(actions.input.accept(error)).toEqual({ + type: 'CHECKOUT/INPUT/ACCEPT', + payload: error, + error: true + }); +}); + +test('input.reject.toString() returns the proper action type', () => { + expect(actions.input.reject.toString()).toBe('CHECKOUT/INPUT/REJECT'); +}); + +test('input.reject() returns a proper action object', () => { + expect(actions.input.reject(payload)).toEqual({ + type: 'CHECKOUT/INPUT/REJECT', + payload + }); + expect(actions.input.reject(error)).toEqual({ + type: 'CHECKOUT/INPUT/REJECT', + payload: error, + error: true + }); +}); + +test('order.submit.toString() returns the proper action type', () => { + expect(actions.order.submit.toString()).toBe('CHECKOUT/ORDER/SUBMIT'); +}); + +test('order.submit() returns a proper action object', () => { + expect(actions.order.submit(payload)).toEqual({ + type: 'CHECKOUT/ORDER/SUBMIT', + payload + }); + expect(actions.order.submit(error)).toEqual({ + type: 'CHECKOUT/ORDER/SUBMIT', + payload: error, + error: true + }); +}); + +test('order.accept.toString() returns the proper action type', () => { + expect(actions.order.accept.toString()).toBe('CHECKOUT/ORDER/ACCEPT'); +}); + +test('order.accept() returns a proper action object', () => { + expect(actions.order.accept(payload)).toEqual({ + type: 'CHECKOUT/ORDER/ACCEPT', + payload + }); + expect(actions.order.accept(error)).toEqual({ + type: 'CHECKOUT/ORDER/ACCEPT', + payload: error, + error: true + }); +}); + +test('order.reject.toString() returns the proper action type', () => { + expect(actions.order.reject.toString()).toBe('CHECKOUT/ORDER/REJECT'); +}); + +test('order.reject() returns a proper action object', () => { + expect(actions.order.reject(payload)).toEqual({ + type: 'CHECKOUT/ORDER/REJECT', + payload + }); + expect(actions.order.reject(error)).toEqual({ + type: 'CHECKOUT/ORDER/REJECT', + payload: error, + error: true + }); +}); diff --git a/packages/venia-concept/src/actions/checkout/__tests__/asyncActions.spec.js b/packages/venia-concept/src/actions/checkout/__tests__/asyncActions.spec.js new file mode 100644 index 0000000000..694681e650 --- /dev/null +++ b/packages/venia-concept/src/actions/checkout/__tests__/asyncActions.spec.js @@ -0,0 +1,248 @@ +import { RestApi } from '@magento/peregrine'; + +import { dispatch, getState } from 'src/store'; +import actions from '../actions'; +import { + beginCheckout, + editOrder, + formatAddress, + resetCheckout, + submitInput, + submitOrder +} from '../asyncActions'; + +jest.mock('src/store'); + +const thunkArgs = [dispatch, getState]; +const { request } = RestApi.Magento2; + +const address = { + country_id: 'US', + firstname: 'Veronica', + lastname: 'Costello', + street: ['6146 Honey Bluff Parkway'], + city: 'Calder', + postcode: '49628-7978', + region_id: 33, + region_code: 'MI', + region: 'Michigan', + telephone: '(555) 229-3326', + email: 'veronica@example.com' +}; + +const countries = [ + { id: 'US', available_regions: [{ id: 33, code: 'MI', name: 'Michigan' }] } +]; + +beforeAll(() => { + getState.mockImplementation(() => ({ + cart: { guestCartId: 'GUEST_CART_ID' }, + directory: { countries } + })); +}); + +afterEach(() => { + dispatch.mockClear(); + request.mockClear(); +}); + +afterAll(() => { + getState.mockRestore(); +}); + +test('beginCheckout() returns a thunk', () => { + expect(beginCheckout()).toBeInstanceOf(Function); +}); + +test('beginCheckout thunk returns undefined', async () => { + const result = await beginCheckout()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('beginCheckout thunk dispatches actions', async () => { + await beginCheckout()(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.begin()); + expect(dispatch).toHaveBeenCalledTimes(1); +}); + +test('resetCheckout() returns a thunk', () => { + expect(resetCheckout()).toBeInstanceOf(Function); +}); + +test('resetCheckout thunk returns undefined', async () => { + const result = await resetCheckout()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('resetCheckout thunk dispatches actions', async () => { + await resetCheckout()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.reset()); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('editOrder() returns a thunk', () => { + expect(editOrder()).toBeInstanceOf(Function); +}); + +test('editOrder thunk returns undefined', async () => { + const result = await editOrder()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('editOrder thunk dispatches actions', async () => { + const payload = 'PAYLOAD'; + await editOrder(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.edit(payload)); + expect(dispatch).toHaveBeenCalledTimes(1); +}); + +test('submitInput() returns a thunk', () => { + expect(submitInput()).toBeInstanceOf(Function); +}); + +test('submitInput thunk returns undefined', async () => { + const payload = { type: 'address', formValues: address }; + const result = await submitInput(payload)(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('submitInput thunk dispatches actions on success', async () => { + const payload = { type: 'address', formValues: address }; + const response = true; + + request.mockResolvedValueOnce(response); + await submitInput(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, actions.input.submit(payload)); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(4, actions.input.accept(response)); + expect(dispatch).toHaveBeenCalledTimes(4); +}); + +test('submitInput thunk dispatches actions on failure', async () => { + const payload = { type: 'address', formValues: address }; + const error = new Error('ERROR'); + + request.mockRejectedValueOnce(error); + await submitInput(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, actions.input.submit(payload)); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(3, actions.input.reject(error)); + expect(dispatch).toHaveBeenCalledTimes(3); +}); + +test('submitInput thunk throws if there is no guest cart', async () => { + const payload = { type: 'address', formValues: address }; + + getState.mockImplementationOnce(() => ({ + cart: {}, + directory: { countries } + })); + + await expect(submitInput(payload)(...thunkArgs)).rejects.toThrow( + 'guestCartId' + ); +}); + +test('submitInput thunk throws if payload is invalid', async () => { + const payload = { type: 'address', formValues: {} }; + + await expect(submitInput(payload)(...thunkArgs)).rejects.toThrow(); + expect(dispatch).toHaveBeenNthCalledWith(1, actions.input.submit(payload)); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('submitOrder() returns a thunk', () => { + expect(submitOrder()).toBeInstanceOf(Function); +}); + +test('submitOrder thunk returns undefined', async () => { + const result = await submitOrder()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('submitOrder thunk dispatches actions on success', async () => { + const response = true; + + request.mockResolvedValueOnce(response); + await submitOrder()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, actions.order.submit()); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.order.accept(response)); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('submitOrder thunk dispatches actions on failure', async () => { + const error = new Error('ERROR'); + + request.mockRejectedValueOnce(error); + await submitOrder()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, actions.order.submit()); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.order.reject(error)); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('submitOrder thunk throws if there is no guest cart', async () => { + getState.mockImplementationOnce(() => ({ + cart: {}, + directory: { countries } + })); + + await expect(submitOrder()(...thunkArgs)).rejects.toThrow('guestCartId'); +}); + +test('formatAddress throws if countries does not include country_id', () => { + const shouldThrow = () => formatAddress(); + + expect(shouldThrow).toThrow(); +}); + +test('formatAddress returns a new object', () => { + const result = formatAddress(address, countries); + + expect(result).toEqual(address); + expect(result).not.toBe(address); +}); + +test('formatAddress looks up and adds region data', () => { + const values = { region_code: address.region_code }; + const result = formatAddress(values, countries); + + expect(result).toHaveProperty('region', address.region); + expect(result).toHaveProperty('region_id', address.region_id); + expect(result).toHaveProperty('region_code', address.region_code); +}); + +test('formatAddress throws if country is not found', () => { + const shouldThrow = () => formatAddress(); + + expect(shouldThrow).toThrow('country'); +}); + +test('formatAddress throws if country contains no regions', () => { + const values = { region_code: address.region_code }; + const countries = [{ id: 'US' }]; + const shouldThrow = () => formatAddress(values, countries); + + expect(shouldThrow).toThrow('regions'); +}); + +test('formatAddress throws if region is not found', () => { + const values = { region_code: '|||' }; + const shouldThrow = () => formatAddress(values, countries); + + expect(shouldThrow).toThrow('region'); +}); diff --git a/packages/venia-concept/src/actions/checkout/actions.js b/packages/venia-concept/src/actions/checkout/actions.js new file mode 100644 index 0000000000..8cc83e12df --- /dev/null +++ b/packages/venia-concept/src/actions/checkout/actions.js @@ -0,0 +1,22 @@ +import { createActions } from 'redux-actions'; + +const prefix = 'CHECKOUT'; +const actionTypes = ['BEGIN', 'EDIT', 'RESET']; + +// classify action creators by domain +// e.g., `actions.order.submit` => CHECKOUT/ORDER/SUBMIT +// a `null` value corresponds to the default creator function +const actionMap = { + INPUT: { + SUBMIT: null, + ACCEPT: null, + REJECT: null + }, + ORDER: { + SUBMIT: null, + ACCEPT: null, + REJECT: null + } +}; + +export default createActions(actionMap, ...actionTypes, { prefix }); diff --git a/packages/venia-concept/src/actions/checkout/asyncActions.js b/packages/venia-concept/src/actions/checkout/asyncActions.js new file mode 100644 index 0000000000..e722e20801 --- /dev/null +++ b/packages/venia-concept/src/actions/checkout/asyncActions.js @@ -0,0 +1,132 @@ +import { RestApi } from '@magento/peregrine'; + +import { closeDrawer } from 'src/actions/app'; +import { clearGuestCartId, getCartDetails } from 'src/actions/cart'; +import { getCountries } from 'src/actions/directory'; +import actions from './actions'; + +const { request } = RestApi.Magento2; + +export const beginCheckout = () => + async function thunk(dispatch) { + dispatch(actions.begin()); + }; + +export const resetCheckout = () => + async function thunk(dispatch) { + await dispatch(closeDrawer()); + dispatch(actions.reset()); + }; + +export const editOrder = section => + async function thunk(dispatch) { + dispatch(actions.edit(section)); + }; + +export const submitInput = payload => + async function thunk(dispatch, getState) { + dispatch(actions.input.submit(payload)); + await dispatch(getCountries()); + + const { cart, directory } = getState(); + const { guestCartId } = cart; + const { countries } = directory; + let { formValues: address } = payload; + + if (!guestCartId) { + throw new Error('Missing required information: guestCartId'); + } + + try { + address = formatAddress(address, countries); + } catch (error) { + throw error; + } + + try { + const response = await request( + `/rest/V1/guest-carts/${guestCartId}/shipping-information`, + { + method: 'POST', + body: JSON.stringify({ + addressInformation: { + billing_address: address, + shipping_address: address, + shipping_method_code: 'flatrate', + shipping_carrier_code: 'flatrate' + } + }) + } + ); + + // refresh cart before returning to checkout overview + // to avoid flash of old data and layout thrashing + await dispatch(getCartDetails({ forceRefresh: true })); + dispatch(actions.input.accept(response)); + } catch (error) { + dispatch(actions.input.reject(error)); + } + }; + +export const submitOrder = () => + async function thunk(dispatch, getState) { + const { cart } = getState(); + const { guestCartId } = cart; + + if (!guestCartId) { + throw new Error('Missing required information: guestCartId'); + } + + dispatch(actions.order.submit()); + + try { + const response = await request( + `/rest/V1/guest-carts/${guestCartId}/order`, + { + method: 'PUT', + // TODO: replace with real data from cart state + body: JSON.stringify({ + paymentMethod: { + method: 'checkmo' + } + }) + } + ); + + dispatch(actions.order.accept(response)); + clearGuestCartId(); + } catch (error) { + dispatch(actions.order.reject(error)); + } + }; + +/* helpers */ + +export function formatAddress(address = {}, countries = []) { + const country = countries.find(({ id }) => id === 'US'); + + if (!country) { + throw new Error('Country "US" is not an available country.'); + } + + const { region_code } = address; + const { available_regions: regions } = country; + + if (!(Array.isArray(regions) && regions.length)) { + throw new Error('Country "US" does not contain any available regions.'); + } + + const region = regions.find(({ code }) => code === region_code); + + if (!region) { + throw new Error(`Region "${region_code}" is not an available region.`); + } + + return { + country_id: 'US', + region_id: region.id, + region_code: region.code, + region: region.name, + ...address + }; +} diff --git a/packages/venia-concept/src/actions/checkout/index.js b/packages/venia-concept/src/actions/checkout/index.js new file mode 100644 index 0000000000..c6c32a70b8 --- /dev/null +++ b/packages/venia-concept/src/actions/checkout/index.js @@ -0,0 +1,2 @@ +export { default } from './actions'; +export * from './asyncActions'; diff --git a/packages/venia-concept/src/actions/directory/__tests__/actions.spec.js b/packages/venia-concept/src/actions/directory/__tests__/actions.spec.js new file mode 100644 index 0000000000..7172235d68 --- /dev/null +++ b/packages/venia-concept/src/actions/directory/__tests__/actions.spec.js @@ -0,0 +1,20 @@ +import actions from '../actions'; + +const payload = 'FOO'; +const error = new Error('BAR'); + +test('getCountries.toString() returns the proper action type', () => { + expect(actions.getCountries.toString()).toBe('DIRECTORY/GET_COUNTRIES'); +}); + +test('getCountries() returns a proper action object', () => { + expect(actions.getCountries(payload)).toEqual({ + type: 'DIRECTORY/GET_COUNTRIES', + payload + }); + expect(actions.getCountries(error)).toEqual({ + type: 'DIRECTORY/GET_COUNTRIES', + payload: error, + error: true + }); +}); diff --git a/packages/venia-concept/src/actions/directory/__tests__/asyncActions.spec.js b/packages/venia-concept/src/actions/directory/__tests__/asyncActions.spec.js new file mode 100644 index 0000000000..af5e044433 --- /dev/null +++ b/packages/venia-concept/src/actions/directory/__tests__/asyncActions.spec.js @@ -0,0 +1,71 @@ +import { RestApi } from '@magento/peregrine'; + +import { dispatch, getState } from 'src/store'; +import actions from '../actions'; +import { getCountries } from '../asyncActions'; + +jest.mock('src/store'); + +const thunkArgs = [dispatch, getState]; +const { request } = RestApi.Magento2; + +afterEach(() => { + dispatch.mockClear(); + request.mockClear(); +}); + +test('getCountries() to return a thunk', () => { + expect(getCountries()).toBeInstanceOf(Function); +}); + +test('getCountries thunk returns undefined', async () => { + const thunk = getCountries(); + + await expect(thunk(...thunkArgs)).resolves.toBeUndefined(); +}); + +test('getCountries thunk does nothing if data is present', async () => { + getState.mockImplementationOnce(() => ({ + directory: { + countries: [] + } + })); + + await getCountries()(...thunkArgs); + + expect(request).not.toHaveBeenCalled(); +}); + +test('getCountries thunk requests API data', async () => { + await getCountries()(...thunkArgs); + + expect(request).toHaveBeenCalled(); +}); + +test('getCountries thunk dispatches actions on success', async () => { + const response = 'FOO'; + + getState.mockImplementationOnce(() => ({ + directory: {} + })); + + request.mockResolvedValueOnce(response); + await getCountries()(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.getCountries(response)); + expect(dispatch).toHaveBeenCalledTimes(1); +}); + +test('getCountries thunk dispatches actions on failure', async () => { + const error = new Error('BAR'); + + getState.mockImplementationOnce(() => ({ + directory: {} + })); + + request.mockRejectedValueOnce(error); + await getCountries()(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.getCountries(error)); + expect(dispatch).toHaveBeenCalledTimes(1); +}); diff --git a/packages/venia-concept/src/actions/directory/actions.js b/packages/venia-concept/src/actions/directory/actions.js new file mode 100644 index 0000000000..4dc5e10e5b --- /dev/null +++ b/packages/venia-concept/src/actions/directory/actions.js @@ -0,0 +1,6 @@ +import { createActions } from 'redux-actions'; + +const prefix = 'DIRECTORY'; +const actionTypes = ['GET_COUNTRIES']; + +export default createActions(...actionTypes, { prefix }); diff --git a/packages/venia-concept/src/actions/directory/asyncActions.js b/packages/venia-concept/src/actions/directory/asyncActions.js new file mode 100644 index 0000000000..536ecb3c28 --- /dev/null +++ b/packages/venia-concept/src/actions/directory/asyncActions.js @@ -0,0 +1,22 @@ +import { RestApi } from '@magento/peregrine'; + +import actions from './actions'; + +const { request } = RestApi.Magento2; + +export const getCountries = () => + async function thunk(dispatch, getState) { + const { directory } = getState(); + + if (directory && directory.countries) { + return; + } + + try { + const response = await request('/rest/V1/directory/countries'); + + dispatch(actions.getCountries(response)); + } catch (error) { + dispatch(actions.getCountries(error)); + } + }; diff --git a/packages/venia-concept/src/actions/directory/index.js b/packages/venia-concept/src/actions/directory/index.js new file mode 100644 index 0000000000..c6c32a70b8 --- /dev/null +++ b/packages/venia-concept/src/actions/directory/index.js @@ -0,0 +1,2 @@ +export { default } from './actions'; +export * from './asyncActions'; diff --git a/packages/venia-concept/src/components/Checkout/address.css b/packages/venia-concept/src/components/Checkout/address.css new file mode 100644 index 0000000000..99b8411f8a --- /dev/null +++ b/packages/venia-concept/src/components/Checkout/address.css @@ -0,0 +1,56 @@ +.root { +} + +.body { + composes: body from './form.css'; + grid-gap: 0.5rem; + padding: 0 1.5rem; +} + +.footer { + composes: footer from './form.css'; +} + +.heading { + font-size: 0.875rem; + font-weight: 600; + grid-column-end: span 2; + line-height: 1rem; + padding: 0.75rem 0; + text-align: center; + text-transform: uppercase; +} + +/* fields */ + +.textInput { + background: white; + border: 1px solid rgb(var(--venia-text-alt)); + border-radius: 2px; + color: rgb(var(--venia-text)); + display: inline-flex; + flex: 0 0 100%; + font-size: 0.9375rem; + height: 2.25rem; + padding: calc(0.375rem - 1px) calc(0.625rem - 1px); + width: 100%; +} + +.textInput:focus { + border-color: rgb(var(--venia-text)); + outline: 0 none; +} + +.city, +.firstname, +.lastname, +.postcode, +.region_code, +.telephone { + grid-column-end: span 1; +} + +.email, +.street0 { + grid-column-end: span 2; +} diff --git a/packages/venia-concept/src/components/Checkout/address.js b/packages/venia-concept/src/components/Checkout/address.js new file mode 100644 index 0000000000..60cc4e82f4 --- /dev/null +++ b/packages/venia-concept/src/components/Checkout/address.js @@ -0,0 +1,170 @@ +import { Component, Fragment, createElement } from 'react'; +import { Form, Text } from 'informed'; +import memoize from 'memoize-one'; +import { func, shape, string } from 'prop-types'; + +import classify from 'src/classify'; +import Button from 'src/components/Button'; +import Label from './label'; +import defaultClasses from './address.css'; + +const fields = [ + 'city', + 'email', + 'firstname', + 'lastname', + 'postcode', + 'region_code', + 'street', + 'telephone' +]; + +const filterInitialValues = memoize(values => + fields.reduce((acc, key) => { + acc[key] = values[key]; + return acc; + }, {}) +); + +class AddressForm extends Component { + static propTypes = { + cancel: func, + classes: shape({ + body: string, + city: string, + email: string, + firstname: string, + footer: string, + lastname: string, + postcode: string, + region_code: string, + street0: string, + telephone: string + }), + submit: func + }; + + render() { + const { children, props } = this; + const { classes, initialValues } = props; + const values = filterInitialValues(initialValues); + + return ( +
+ {children} +
+ ); + } + + children = () => { + const { classes, submitting } = this.props; + + return ( + +
+

Shipping Address

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ ); + }; + + cancel = () => { + this.props.cancel(); + }; + + submit = values => { + this.props.submit(values); + }; +} + +export default classify(defaultClasses)(AddressForm); + +/* +const mockAddress = { + country_id: 'US', + firstname: 'Veronica', + lastname: 'Costello', + street: ['6146 Honey Bluff Parkway'], + city: 'Calder', + postcode: '49628-7978', + region_id: 33, + region_code: 'MI', + region: 'Michigan', + telephone: '(555) 229-3326', + email: 'veronica@example.com' +}; +*/ diff --git a/packages/venia-concept/src/components/Checkout/cart.css b/packages/venia-concept/src/components/Checkout/cart.css new file mode 100644 index 0000000000..7bc2d18931 --- /dev/null +++ b/packages/venia-concept/src/components/Checkout/cart.css @@ -0,0 +1,3 @@ +.root { + composes: footer from './flow.css'; +} diff --git a/packages/venia-concept/src/components/Checkout/cart.js b/packages/venia-concept/src/components/Checkout/cart.js new file mode 100644 index 0000000000..995a48a97c --- /dev/null +++ b/packages/venia-concept/src/components/Checkout/cart.js @@ -0,0 +1,35 @@ +import { Component, createElement } from 'react'; +import { bool, func, shape, string } from 'prop-types'; + +import classify from 'src/classify'; +import CheckoutButton from './checkoutButton'; +import defaultClasses from './cart.css'; + +class Cart extends Component { + static propTypes = { + beginCheckout: func.isRequired, + classes: shape({ + root: string + }), + ready: bool.isRequired, + submitting: bool.isRequired + }; + + render() { + const { beginCheckout, classes, ready, submitting } = this.props; + + return ( +
+
+ +
+
+ ); + } +} + +export default classify(defaultClasses)(Cart); diff --git a/packages/venia-concept/src/components/Checkout/checkoutButton.js b/packages/venia-concept/src/components/Checkout/checkoutButton.js index ca7a43820e..01df52dac9 100644 --- a/packages/venia-concept/src/components/Checkout/checkoutButton.js +++ b/packages/venia-concept/src/components/Checkout/checkoutButton.js @@ -1,24 +1,25 @@ import { Component, createElement } from 'react'; -import { func, string } from 'prop-types'; +import { bool, func } from 'prop-types'; import Button from 'src/components/Button'; import Icon from 'src/components/Icon'; -const isDisabled = status => ['ACCEPTED', 'REQUESTING'].includes(status); +const iconDimensions = { height: 16, width: 16 }; +const isDisabled = (busy, valid) => busy || !valid; class CheckoutButton extends Component { static propTypes = { - requestOrder: func.isRequired, - status: string.isRequired + ready: bool.isRequired, + submit: func.isRequired, + submitting: bool.isRequired }; render() { - const { requestOrder, status } = this.props; - const disabled = isDisabled(status); - const iconDimensions = { height: 16, width: 16 }; + const { ready, submit, submitting } = this.props; + const disabled = isDisabled(submitting, ready); return ( - diff --git a/packages/venia-concept/src/components/Checkout/entrance.css b/packages/venia-concept/src/components/Checkout/entrance.css deleted file mode 100644 index 45f3ccb9c2..0000000000 --- a/packages/venia-concept/src/components/Checkout/entrance.css +++ /dev/null @@ -1,7 +0,0 @@ -.root { - align-items: center; - display: flex; - justify-content: center; - margin: 0 1.5rem; - padding: 1rem 0; -} diff --git a/packages/venia-concept/src/components/Checkout/entrance.js b/packages/venia-concept/src/components/Checkout/entrance.js deleted file mode 100644 index f49963ecc1..0000000000 --- a/packages/venia-concept/src/components/Checkout/entrance.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Component, createElement } from 'react'; -import { func, shape, string } from 'prop-types'; - -import classify from 'src/classify'; -import CheckoutButton from './checkoutButton'; -import defaultClasses from './entrance.css'; - -class Entrance extends Component { - static propTypes = { - classes: shape({ - root: string - }), - requestOrder: func.isRequired, - status: string.isRequired - }; - - render() { - const { classes, requestOrder, status } = this.props; - - return ( -
- -
- ); - } -} - -export default classify(defaultClasses)(Entrance); diff --git a/packages/venia-concept/src/components/Checkout/flow.css b/packages/venia-concept/src/components/Checkout/flow.css index 24b4b66983..8de3cd28e3 100644 --- a/packages/venia-concept/src/components/Checkout/flow.css +++ b/packages/venia-concept/src/components/Checkout/flow.css @@ -1,4 +1,42 @@ .root { - height: 5rem; position: relative; } + +.body { + animation-duration: 224ms; + animation-iteration-count: 1; + animation-name: enter; + background-color: white; + bottom: 5.5rem; + box-shadow: 0 -1px rgb(var(--venia-border)); + display: grid; + left: 0; + position: absolute; + right: 0; +} + +.footer { + align-items: center; + background-color: white; + display: grid; + grid-auto-columns: min-content; + grid-auto-flow: column; + grid-gap: 0.75rem; + height: 5.5rem; + justify-content: center; + justify-items: center; + margin: 0 1.5rem; + padding: 1.5rem 0 1rem; + position: relative; +} + +@keyframes enter { + from { + opacity: 0; + transform: translate3d(0, 100%, 0); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} diff --git a/packages/venia-concept/src/components/Checkout/flow.js b/packages/venia-concept/src/components/Checkout/flow.js index 256de8e589..e33d1e7482 100644 --- a/packages/venia-concept/src/components/Checkout/flow.js +++ b/packages/venia-concept/src/components/Checkout/flow.js @@ -1,79 +1,92 @@ import { Component, createElement } from 'react'; -import { bool, func, oneOf, shape, string } from 'prop-types'; +import { bool, func, object, shape, string } from 'prop-types'; import classify from 'src/classify'; -import Entrance from './entrance'; -import Exit from './exit'; +import Cart from './cart'; import Form from './form'; +import Receipt from './receipt'; import defaultClasses from './flow.css'; const stepMap = { - READY: 'STEP_1', - REQUESTING: 'STEP_1', - MODIFYING: 'STEP_2', - SUBMITTING: 'STEP_2', - ACCEPTED: 'STEP_3' + cart: 1, + form: 2, + receipt: 3 }; -const stepEnum = Object.keys(stepMap); +const isCartReady = items => items > 0; +const isAddressValid = address => !!(address && address.email); class Flow extends Component { static propTypes = { + actions: shape({ + beginCheckout: func.isRequired, + editOrder: func.isRequired, + resetCheckout: func.isRequired, + submitInput: func.isRequired, + submitOrder: func.isRequired + }).isRequired, + cart: shape({ + details: object, + guestCartId: string, + totals: object + }), + checkout: shape({ + editing: string, + step: string, + submitting: bool + }), classes: shape({ root: string - }), - ready: bool, - resetCheckout: func.isRequired, - requestOrder: func.isRequired, - status: oneOf(stepEnum).isRequired, - submitOrder: func.isRequired + }) }; - render() { + get child() { + const { actions, cart, checkout } = this.props; const { - classes, - enterSubflow, - ready, + beginCheckout, + editOrder, resetCheckout, - requestOrder, - status, + submitInput, submitOrder - } = this.props; + } = actions; + const { editing, step, submitting } = checkout; + const { details } = cart; + const ready = isCartReady(details.items_count); + const valid = isAddressValid(details.billing_address); - const step = stepMap[status]; - let child = null; + switch (stepMap[step]) { + case 1: { + const stepProps = { beginCheckout, ready, submitting }; - switch (step) { - case 'STEP_1': { - child = ( - - ); - break; + return ; } - case 'STEP_2': { - child = ( -
- ); - break; + case 2: { + const stepProps = { + cart, + editOrder, + editing, + submitInput, + submitOrder, + submitting, + valid + }; + + return ; } - case 'STEP_3': { - child = ; - break; + case 3: { + const stepProps = { resetCheckout }; + + return ; } default: { - const message = - 'Checkout is in an invalid state. ' + - 'Expected `status` to be one of the following: ' + - stepEnum.map(s => `\`${s}\``).join(', '); - - throw new Error(message); + return null; } } + } + + render() { + const { child, props } = this; + const { classes } = props; return
{child}
; } diff --git a/packages/venia-concept/src/components/Checkout/form.css b/packages/venia-concept/src/components/Checkout/form.css index ca457c19ac..b6a99b27fb 100644 --- a/packages/venia-concept/src/components/Checkout/form.css +++ b/packages/venia-concept/src/components/Checkout/form.css @@ -1,29 +1,12 @@ .root { - background-color: white; - bottom: 0; - box-shadow: 0 -1px rgb(var(--venia-border)), 0 -1px 6px rgb(0, 0, 0, 0.2); - left: 0; - min-height: 8rem; - position: absolute; - right: 0; } .body { - animation-duration: 224ms; - animation-iteration-count: 1; - animation-name: enter; - display: grid; - position: relative; + composes: body from './flow.css'; } .footer { - align-items: center; - background-color: white; - display: flex; - justify-content: center; - margin: 0 1.5rem; - padding: 1.5rem 0 1rem; - position: relative; + composes: footer from './flow.css'; } @keyframes enter { diff --git a/packages/venia-concept/src/components/Checkout/form.js b/packages/venia-concept/src/components/Checkout/form.js index 034c9b8ebd..bf45e2b8bc 100644 --- a/packages/venia-concept/src/components/Checkout/form.js +++ b/packages/venia-concept/src/components/Checkout/form.js @@ -1,70 +1,126 @@ -import { Component, createElement } from 'react'; -import { bool, func, shape, string } from 'prop-types'; +import { Component, Fragment, createElement } from 'react'; +import { bool, func, object, shape, string } from 'prop-types'; import classify from 'src/classify'; import Section from './section'; import SubmitButton from './submitButton'; import defaultClasses from './form.css'; +import AddressForm from './address'; + class Form extends Component { static propTypes = { + cart: shape({ + details: object, + guestCartId: string, + totals: object + }).isRequired, classes: shape({ body: string, footer: string, root: string }), - ready: bool, - status: string.isRequired, - submitOrder: func.isRequired + editing: string, + editOrder: func.isRequired, + submitInput: func.isRequired, + submitOrder: func.isRequired, + submitting: bool.isRequired, + valid: bool.isRequired }; - render() { - const { classes, ready, status, submitOrder } = this.props; - const text = ready ? 'Complete' : 'Click to fill out'; + get editableForm() { + const { cart, editing, submitting } = this.props; + + switch (editing) { + case 'address': { + const { details } = cart; + + return ( + + ); + } + default: { + return null; + } + } + } + + get addressSnippet() { + const { cart, valid } = this.props; + const address = cart.details.billing_address; + + if (!valid) { + return Click to edit; + } + + const name = `${address.firstname} ${address.lastname}`; + const street = `${address.street.join(' ')}`; return ( -
+ + {name} +
+ {street} +
+ ); + } + + get overview() { + const { classes, submitOrder, submitting, valid } = this.props; + + return ( +
-
- {text} +
+ {this.addressSnippet}
-
- {text} +
+ Check +
+ Personal check or money order
-
- {text} +
+ December 25, 2018 +
+ Flat Rate Shipping
-
+ ); } - modifyBillingAddress = () => { - this.props.enterSubflow('BILLING_ADDRESS'); + render() { + const { classes, editing } = this.props; + const children = editing ? this.editableForm : this.overview; + + return
{children}
; + } + + editAddress = () => { + this.props.editOrder('address'); }; - modifyShippingAddress = () => { - this.props.enterSubflow('SHIPPING_ADDRESS'); + submitAddress = formValues => { + this.props.submitInput({ + type: 'address', + formValues + }); }; - modifyShippingMethod = () => { - this.props.enterSubflow('SHIPPING_METHOD'); + stopEditing = () => { + this.props.editOrder(null); }; } diff --git a/packages/venia-concept/src/components/Checkout/label.css b/packages/venia-concept/src/components/Checkout/label.css new file mode 100644 index 0000000000..d8daf5db81 --- /dev/null +++ b/packages/venia-concept/src/components/Checkout/label.css @@ -0,0 +1,8 @@ +.root { + align-items: center; + color: rgb(var(--venia-text-alt)); + display: inline-flex; + font-size: 0.8125rem; + line-height: 1rem; + padding: 0.125rem; +} diff --git a/packages/venia-concept/src/components/Checkout/label.js b/packages/venia-concept/src/components/Checkout/label.js new file mode 100644 index 0000000000..c52440e3fc --- /dev/null +++ b/packages/venia-concept/src/components/Checkout/label.js @@ -0,0 +1,25 @@ +import { Component, createElement } from 'react'; +import { bool, node, shape, string } from 'prop-types'; + +import classify from 'src/classify'; +import defaultClasses from './label.css'; + +class Label extends Component { + static propTypes = { + children: node, + classes: shape({ + root: string + }), + plain: bool + }; + + render() { + const { children, classes, plain, ...restProps } = this.props; + const elementType = plain ? 'span' : 'label'; + const labelProps = { ...restProps, className: classes.root }; + + return createElement(elementType, labelProps, children); + } +} + +export default classify(defaultClasses)(Label); diff --git a/packages/venia-concept/src/components/Checkout/exit.css b/packages/venia-concept/src/components/Checkout/receipt.css similarity index 100% rename from packages/venia-concept/src/components/Checkout/exit.css rename to packages/venia-concept/src/components/Checkout/receipt.css diff --git a/packages/venia-concept/src/components/Checkout/exit.js b/packages/venia-concept/src/components/Checkout/receipt.js similarity index 94% rename from packages/venia-concept/src/components/Checkout/exit.js rename to packages/venia-concept/src/components/Checkout/receipt.js index d7979b4971..87e8c15049 100644 --- a/packages/venia-concept/src/components/Checkout/exit.js +++ b/packages/venia-concept/src/components/Checkout/receipt.js @@ -3,7 +3,7 @@ import { func, shape, string } from 'prop-types'; import classify from 'src/classify'; import ResetButton from './resetButton'; -import defaultClasses from './exit.css'; +import defaultClasses from './receipt.css'; class Exit extends Component { static propTypes = { diff --git a/packages/venia-concept/src/components/Checkout/section.css b/packages/venia-concept/src/components/Checkout/section.css index 275d3f982f..44ccdbe423 100644 --- a/packages/venia-concept/src/components/Checkout/section.css +++ b/packages/venia-concept/src/components/Checkout/section.css @@ -26,5 +26,9 @@ .summary { font-size: 13px; + justify-self: stretch; line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } diff --git a/packages/venia-concept/src/components/Checkout/submitButton.js b/packages/venia-concept/src/components/Checkout/submitButton.js index 9b998b270e..49fb6a9bf1 100644 --- a/packages/venia-concept/src/components/Checkout/submitButton.js +++ b/packages/venia-concept/src/components/Checkout/submitButton.js @@ -1,21 +1,20 @@ import { Component, createElement } from 'react'; -import { bool, func, string } from 'prop-types'; +import { bool, func } from 'prop-types'; import Button from 'src/components/Button'; -const isDisabled = (ready, status) => - !ready || ['ACCEPTED', 'SUBMITTING'].includes(status); +const isDisabled = (busy, valid) => busy || !valid; class SubmitButton extends Component { static propTypes = { - ready: bool, - status: string.isRequired, - submitOrder: func.isRequired + submitOrder: func.isRequired, + submitting: bool.isRequired, + valid: bool.isRequired }; render() { - const { ready, status, submitOrder } = this.props; - const disabled = isDisabled(ready, status); + const { submitOrder, submitting, valid } = this.props; + const disabled = isDisabled(submitting, valid); return ( ); } } -const mapDispatchToProps = dispatch => ({ - closeNav: () => dispatch({ type: 'TOGGLE_DRAWER', payload: null }) -}); +const mapDispatchToProps = { closeDrawer }; export default compose( classify(defaultClasses), diff --git a/packages/venia-concept/src/components/Page/page.js b/packages/venia-concept/src/components/Page/page.js index bf6134aa46..a146df755d 100644 --- a/packages/venia-concept/src/components/Page/page.js +++ b/packages/venia-concept/src/components/Page/page.js @@ -4,10 +4,10 @@ import { compose } from 'redux'; import PropTypes from 'prop-types'; import classify from 'src/classify'; +import { closeDrawer } from 'src/actions/app'; import Main from 'src/components/Main'; import MiniCart from 'src/components/MiniCart'; import Navigation from 'src/components/Navigation'; -import { selectAppState } from 'src/reducers/app'; import Mask from './mask'; import defaultClasses from './page.css'; @@ -37,14 +37,12 @@ class Page extends Component { } } -const mapDispatchToProps = dispatch => ({ - closeDrawer: () => dispatch({ type: 'TOGGLE_DRAWER', payload: null }) -}); +const mapDispatchToProps = { closeDrawer }; export default compose( classify(defaultClasses), connect( - selectAppState, + ({ app }) => ({ app }), mapDispatchToProps ) )(Page); diff --git a/packages/venia-concept/src/index.css b/packages/venia-concept/src/index.css index 20f8be69fe..2de45f38a4 100644 --- a/packages/venia-concept/src/index.css +++ b/packages/venia-concept/src/index.css @@ -15,7 +15,6 @@ html { html { background-color: white; - font-family: var(--venia-font); font-size: 100%; font-weight: 400; line-height: 1; @@ -30,6 +29,13 @@ body { padding: 0; } +body, +input, +select, +textarea { + font-family: var(--venia-font); +} + h1, h2, h3, diff --git a/packages/venia-concept/src/index.js b/packages/venia-concept/src/index.js index 57455a3e00..0bf19f40c9 100644 --- a/packages/venia-concept/src/index.js +++ b/packages/venia-concept/src/index.js @@ -1,19 +1,11 @@ import { createElement } from 'react'; import ReactDOM from 'react-dom'; -import bootstrap from '@magento/peregrine'; import ApolloClient from 'apollo-boost'; import { ApolloProvider } from 'react-apollo'; -import reducer from 'src/reducers/app'; +import { Provider } from './store'; import './index.css'; -const { Provider, store } = bootstrap({ - apiBase: new URL('/graphql', location.origin).toString(), - __tmp_webpack_public_path__: __webpack_public_path__ -}); - -store.addReducer('app', reducer); - const apolloClient = new ApolloClient(); ReactDOM.render( @@ -35,5 +27,3 @@ if (process.env.SERVICE_WORKER && 'serviceWorker' in navigator) { }); }); } - -export { store }; diff --git a/packages/venia-concept/src/reducers/app.js b/packages/venia-concept/src/reducers/app.js index 6577fa36d4..e657da621d 100644 --- a/packages/venia-concept/src/reducers/app.js +++ b/packages/venia-concept/src/reducers/app.js @@ -1,30 +1,23 @@ +import { handleActions } from 'redux-actions'; + +import actions from 'src/actions/app'; + +export const name = 'app'; + const initialState = { drawer: null, overlay: false, pending: {} }; -const reducer = (state = initialState, { error, payload, type }) => { - switch (type) { - case 'TOGGLE_DRAWER': { - return { - ...state, - drawer: payload, - overlay: !!payload - }; - } - default: { - if (error) { - return { - ...state, - error - }; - } - return state; - } +const reducerMap = { + [actions.toggleDrawer]: (state, { payload }) => { + return { + ...state, + drawer: payload, + overlay: !!payload + }; } }; -const selectAppState = ({ app }) => ({ app }); - -export { reducer as default, selectAppState }; +export default handleActions(reducerMap, initialState); diff --git a/packages/venia-concept/src/reducers/cart.js b/packages/venia-concept/src/reducers/cart.js index f5e843d85f..d49bfc8c1c 100644 --- a/packages/venia-concept/src/reducers/cart.js +++ b/packages/venia-concept/src/reducers/cart.js @@ -1,77 +1,40 @@ -import debounce from 'lodash.debounce'; +import { handleActions } from 'redux-actions'; -import BrowserPersistence from 'src/util/simplePersistence'; +import actions from 'src/actions/cart'; +import checkoutActions from 'src/actions/checkout'; -export default async function makeCartReducer() { - const storage = new BrowserPersistence(); - const imagesBySku = (await storage.getItem('imagesBySku')) || {}; - const saveImagesBySkuCache = debounce( - () => storage.setItem('imagesBySku', imagesBySku), - 1000 - ); - const guestCartId = await storage.getItem('guestCartId'); - const getInitialState = () => ({ - guestCartId, - details: { items: [] }, - totals: {} - }); - const reducer = (state = getInitialState(), { error, payload, type }) => { - switch (type) { - case 'CREATE_GUEST_CART': { - // don't await the save, it can happen in the background - storage.setItem('guestCartId', payload); - return { - ...state, - guestCartId: payload - }; - } - case 'GET_CART_DETAILS': { - return { - ...state, - ...payload, - details: { - ...payload.details, - items: payload.details.items.map(item => ({ - ...item, - image: item.image || imagesBySku[item.sku] || '' - })) - } - }; - } - case 'ADD_ITEM_TO_CART': { - // cart items don't have images in the REST API; - // this is the most efficient way to manage that, - // but it should go in a data layer - const { item } = payload; - const media = item.media_gallery_entries || []; - const cartImage = - media.find(image => image.position === 1) || media[0]; - if ( - item.sku && - cartImage && - imagesBySku[item.sku] !== cartImage - ) { - imagesBySku[item.sku] = cartImage; - // don't await the save, it can happen in the background - saveImagesBySkuCache(); - } - return { - ...state, - showError: error - }; - } - case 'ACCEPT_ORDER': { - storage.removeItem('guestCartId'); - return { - ...getInitialState(), - guestCartId: null - }; - } - default: { - return state; - } +export const name = 'cart'; + +const initialState = { + details: {}, + guestCartId: null, + totals: {} +}; + +const reducerMap = { + [actions.getGuestCart.receive]: (state, { payload, error }) => { + if (error) { + return state; + } + + return { + ...state, + guestCartId: payload + }; + }, + [actions.getDetails.receive]: (state, { payload, error }) => { + if (error) { + return state; } - }; - reducer.selectAppState = ({ cart }) => ({ cart }); - return reducer; -} + + return { + ...state, + ...payload + }; + }, + [checkoutActions.order.accept]: () => { + return initialState; + } +}; + +export default handleActions(reducerMap, initialState); diff --git a/packages/venia-concept/src/reducers/checkout.js b/packages/venia-concept/src/reducers/checkout.js index 5c863c3d0c..56be629745 100644 --- a/packages/venia-concept/src/reducers/checkout.js +++ b/packages/venia-concept/src/reducers/checkout.js @@ -1,70 +1,70 @@ +import { handleActions } from 'redux-actions'; + +import actions from 'src/actions/checkout'; + +export const name = 'checkout'; + const initialState = { - shippingInformation: false, - status: 'READY', - subflow: null + editing: null, + step: 'cart', + submitting: false }; -const reducer = (state = initialState, { payload, type }) => { - switch (type) { - case 'REQUEST_ORDER': { - return { - ...state, - status: 'REQUESTING' - }; - } - case 'RECEIVE_ORDER': { - return { - ...state, - status: 'MODIFYING' - }; - } - case 'ENTER_SUBFLOW': { - return { - ...state, - status: 'MODIFYING', - subflow: payload - }; - } - case 'EXIT_SUBFLOW': { - return { - ...state, - status: 'MODIFYING', - subflow: null - }; - } - case 'SUBMIT_SHIPPING_INFORMATION': { - return { - ...state, - shippingInformation: true - }; - } - case 'SUBMIT_ORDER': { - return { - ...state, - status: 'SUBMITTING' - }; - } - case 'REJECT_ORDER': { - return { - ...state, - status: 'MODIFYING' - }; - } - case 'ACCEPT_ORDER': { - return { - ...state, - status: 'ACCEPTED' - }; - } - case 'RESET_CHECKOUT': { - return initialState; - } - default: { - return state; - } - } +const reducerMap = { + [actions.begin]: state => { + return { + ...state, + editing: null, + step: 'form' + }; + }, + [actions.edit]: (state, { payload }) => { + return { + ...state, + editing: payload + }; + }, + [actions.input.submit]: state => { + return { + ...state, + submitting: true + }; + }, + [actions.input.accept]: state => { + return { + ...state, + editing: null, + step: 'form', + submitting: false + }; + }, + [actions.input.reject]: state => { + return { + ...state, + submitting: false + }; + }, + [actions.order.submit]: state => { + return { + ...state, + submitting: true + }; + }, + [actions.order.accept]: state => { + return { + ...state, + editing: null, + step: 'receipt', + submitting: false + }; + }, + [actions.order.reject]: state => { + return { + ...state, + submitting: false + }; + }, + [actions.reset]: () => initialState }; -const selectCheckoutState = ({ checkout }) => ({ checkout }); - -export { reducer as default, selectCheckoutState }; +export default handleActions(reducerMap, initialState); diff --git a/packages/venia-concept/src/reducers/directory.js b/packages/venia-concept/src/reducers/directory.js new file mode 100644 index 0000000000..d720dc9b21 --- /dev/null +++ b/packages/venia-concept/src/reducers/directory.js @@ -0,0 +1,22 @@ +import { handleActions } from 'redux-actions'; + +import actions from 'src/actions/directory'; + +export const name = 'directory'; + +const initialState = {}; + +const reducerMap = { + [actions.getCountries]: (state, { payload, error }) => { + if (error) { + return state; + } + + return { + ...state, + countries: payload + }; + } +}; + +export default handleActions(reducerMap, initialState); diff --git a/packages/venia-concept/src/shared/durations.js b/packages/venia-concept/src/shared/durations.js index a55863f115..64e183e741 100644 --- a/packages/venia-concept/src/shared/durations.js +++ b/packages/venia-concept/src/shared/durations.js @@ -1,6 +1,3 @@ +export const artificialDelay = 1000; // simulate unimplemented API calls export const drawerClose = 192; export const drawerOpen = 224; -// TODO: these delays are here to simulate awaiting unimplemented API calls. -// Remove them when the real functionality arrives. -export const requestOrder = 1000; -export const submitOrder = 1200; diff --git a/packages/venia-concept/src/store.js b/packages/venia-concept/src/store.js new file mode 100644 index 0000000000..646cbde219 --- /dev/null +++ b/packages/venia-concept/src/store.js @@ -0,0 +1,16 @@ +import bootstrap from '@magento/peregrine'; + +import appReducer from 'src/reducers/app'; +import directoryReducer from 'src/reducers/directory'; + +const { Provider, store } = bootstrap({ + apiBase: new URL('/graphql', location.origin).toString(), + __tmp_webpack_public_path__: __webpack_public_path__ +}); + +const { addReducer, dispatch, getState } = store; + +addReducer('app', appReducer); +addReducer('directory', directoryReducer); + +export { Provider, addReducer, dispatch, getState }; diff --git a/packages/venia-concept/src/util/__mocks__/simplePersistence.js b/packages/venia-concept/src/util/__mocks__/simplePersistence.js new file mode 100644 index 0000000000..33722ce87d --- /dev/null +++ b/packages/venia-concept/src/util/__mocks__/simplePersistence.js @@ -0,0 +1,11 @@ +export const mockGetItem = jest.fn(); +export const mockRemoveItem = jest.fn(); +export const mockSetItem = jest.fn(); + +const mock = jest.fn().mockImplementation(() => ({ + getItem: mockGetItem, + removeItem: mockRemoveItem, + setItem: mockSetItem +})); + +export default mock;