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/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 7be2a93a97..0000000000 --- a/packages/venia-concept/src/actions/app.js +++ /dev/null @@ -1,30 +0,0 @@ -import { createActions } from 'redux-actions'; - -import { store } from 'src'; - -const prefix = 'APP'; -const actionTypes = ['TOGGLE_DRAWER']; - -const actions = createActions(...actionTypes, { prefix }); -export default actions; - -/* async action creators */ - -export const loadReducers = payload => - async function thunk() { - try { - const reducers = await Promise.all(payload); - - reducers.forEach(({ default: reducer, name }) => { - store.addReducer(name, reducer); - }); - } catch (error) { - console.log(error); - } - }; - -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/__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/__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.js b/packages/venia-concept/src/actions/cart/asyncActions.js similarity index 79% rename from packages/venia-concept/src/actions/cart.js rename to packages/venia-concept/src/actions/cart/asyncActions.js index a014deb804..86bab0ef94 100644 --- a/packages/venia-concept/src/actions/cart.js +++ b/packages/venia-concept/src/actions/cart/asyncActions.js @@ -1,23 +1,9 @@ -import { createActions } from 'redux-actions'; import { RestApi } from '@magento/peregrine'; import { closeDrawer, toggleDrawer } from 'src/actions/app'; import checkoutActions from 'src/actions/checkout'; import BrowserPersistence from 'src/util/simplePersistence'; - -const prefix = 'CART'; -const actionTypes = [ - 'ADD_ITEM', - 'REQUEST_GUEST_CART', - 'RECEIVE_GUEST_CART', - 'REQUEST_DETAILS', - 'UPDATE_DETAILS' -]; - -const actions = createActions(...actionTypes, { prefix }); -export default actions; - -/* async action creators */ +import actions from './actions'; const { request } = RestApi.Magento2; const storage = new BrowserPersistence(); @@ -39,12 +25,12 @@ export const createGuestCart = () => // if a guest cart exists in storage, act like we just received it if (guestCartId) { - dispatch(actions.receiveGuestCart(guestCartId)); + dispatch(actions.getGuestCart.receive(guestCartId)); return; } // otherwise, request a new guest cart - dispatch(actions.requestGuestCart()); + dispatch(actions.getGuestCart.request()); try { const id = await request('/rest/V1/guest-carts', { @@ -52,11 +38,10 @@ export const createGuestCart = () => }); // write to storage in the background - storage.setItem('guestCartId', id); - - dispatch(actions.receiveGuestCart(id)); + saveGuestCartId(id); + dispatch(actions.getGuestCart.receive(id)); } catch (error) { - dispatch(actions.createGuestCart(error)); + dispatch(actions.getGuestCart.receive(error)); } }; @@ -66,6 +51,8 @@ export const addItemToCart = (payload = {}) => { writeImageToCache(item); return async function thunk(dispatch, getState) { + dispatch(actions.addItem.request(payload)); + try { const { cart } = getState(); const { guestCartId } = cart; @@ -89,10 +76,12 @@ export const addItemToCart = (payload = {}) => { } ); - dispatch(actions.addItem({ cartItem, item, quantity })); + 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 @@ -100,8 +89,6 @@ export const addItemToCart = (payload = {}) => { // then retry this operation return thunk(...arguments); } - - dispatch(actions.addItem(error)); } await Promise.all([ @@ -118,7 +105,7 @@ export const getCartDetails = (payload = {}) => { const { cart } = getState(); const { guestCartId } = cart; - dispatch(actions.requestDetails(guestCartId)); + dispatch(actions.getDetails.request(guestCartId)); // if there isn't a guest cart, create one // then retry this operation @@ -138,14 +125,22 @@ export const getCartDetails = (payload = {}) => { }) ]); - details.items.forEach(item => { - item.image = item.image || imageCache[item.sku] || {}; - }); + 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.updateDetails({ details, totals })); + 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 @@ -153,8 +148,6 @@ export const getCartDetails = (payload = {}) => { // then retry this operation return thunk(...arguments); } - - dispatch(actions.updateDetails(error)); } }; }; @@ -170,14 +163,14 @@ export const toggleCart = () => // if the cart drawer is open, close it if (app.drawer === 'cart') { - await dispatch(closeDrawer()); - return; + return dispatch(closeDrawer()); } // otherwise open the cart and load its contents - await Promise.all[ - (dispatch(toggleDrawer('cart')), dispatch(getCartDetails())) - ]; + await Promise.all([ + dispatch(toggleDrawer('cart')), + dispatch(getCartDetails()) + ]); }; /* helpers */ @@ -196,6 +189,10 @@ 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'); } @@ -208,7 +205,7 @@ async function saveImageCache(cache) { return storage.setItem('imagesBySku', cache); } -async function writeImageToCache(item) { +async function writeImageToCache(item = {}) { const { media_gallery_entries: media, sku } = item; if (sku) { 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/__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.js b/packages/venia-concept/src/actions/checkout/asyncActions.js similarity index 77% rename from packages/venia-concept/src/actions/checkout.js rename to packages/venia-concept/src/actions/checkout/asyncActions.js index e7573c3dca..e722e20801 100644 --- a/packages/venia-concept/src/actions/checkout.js +++ b/packages/venia-concept/src/actions/checkout/asyncActions.js @@ -1,41 +1,17 @@ -import { createActions } from 'redux-actions'; import { RestApi } from '@magento/peregrine'; import { closeDrawer } from 'src/actions/app'; import { clearGuestCartId, getCartDetails } from 'src/actions/cart'; import { getCountries } from 'src/actions/directory'; - -const prefix = 'CHECKOUT'; -const actionTypes = ['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 = { - CART: { - SUBMIT: null, - ACCEPT: null, - REJECT: null - }, - INPUT: { - SUBMIT: null, - ACCEPT: null, - REJECT: null - }, - ORDER: { - SUBMIT: null, - ACCEPT: null, - REJECT: null - } -}; - -const actions = createActions(actionMap, ...actionTypes, { prefix }); -export default actions; - -/* async action creators */ +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()); @@ -47,28 +23,27 @@ export const editOrder = section => dispatch(actions.edit(section)); }; -export const submitCart = () => - async function thunk(dispatch) { - dispatch(actions.cart.accept()); - }; - export const submitInput = payload => async function thunk(dispatch, getState) { - const { cart } = 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'); } - dispatch(actions.input.submit(payload)); - await dispatch(getCountries()); - - const { directory } = getState(); - const { countries } = directory; + try { + address = formatAddress(address, countries); + } catch (error) { + throw error; + } try { - const address = formatAddress(payload.formValues, countries); const response = await request( `/rest/V1/guest-carts/${guestCartId}/shipping-information`, { @@ -127,7 +102,7 @@ export const submitOrder = () => /* helpers */ -function formatAddress(address = {}, countries = []) { +export function formatAddress(address = {}, countries = []) { const country = countries.find(({ id }) => id === 'US'); if (!country) { @@ -135,7 +110,12 @@ function formatAddress(address = {}, countries = []) { } const { region_code } = address; - const regions = country.available_regions || []; + 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) { 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.js b/packages/venia-concept/src/actions/directory/asyncActions.js similarity index 64% rename from packages/venia-concept/src/actions/directory.js rename to packages/venia-concept/src/actions/directory/asyncActions.js index d75783e5db..536ecb3c28 100644 --- a/packages/venia-concept/src/actions/directory.js +++ b/packages/venia-concept/src/actions/directory/asyncActions.js @@ -1,13 +1,6 @@ -import { createActions } from 'redux-actions'; import { RestApi } from '@magento/peregrine'; -const prefix = 'DIRECTORY'; -const actionTypes = ['GET_COUNTRIES']; - -const actions = createActions(...actionTypes, { prefix }); -export default actions; - -/* async action creators */ +import actions from './actions'; const { request } = RestApi.Magento2; @@ -15,7 +8,7 @@ export const getCountries = () => async function thunk(dispatch, getState) { const { directory } = getState(); - if (directory.countries) { + if (directory && directory.countries) { return; } 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/cart.js b/packages/venia-concept/src/components/Checkout/cart.js index cbaa290605..995a48a97c 100644 --- a/packages/venia-concept/src/components/Checkout/cart.js +++ b/packages/venia-concept/src/components/Checkout/cart.js @@ -7,16 +7,16 @@ import defaultClasses from './cart.css'; class Cart extends Component { static propTypes = { + beginCheckout: func.isRequired, classes: shape({ root: string }), ready: bool.isRequired, - submitCart: func.isRequired, submitting: bool.isRequired }; render() { - const { classes, ready, submitCart, submitting } = this.props; + const { beginCheckout, classes, ready, submitting } = this.props; return (
@@ -24,7 +24,7 @@ class Cart extends Component {
diff --git a/packages/venia-concept/src/components/Checkout/checkoutButton.js b/packages/venia-concept/src/components/Checkout/checkoutButton.js index 67ce547ff1..01df52dac9 100644 --- a/packages/venia-concept/src/components/Checkout/checkoutButton.js +++ b/packages/venia-concept/src/components/Checkout/checkoutButton.js @@ -10,16 +10,16 @@ const isDisabled = (busy, valid) => busy || !valid; class CheckoutButton extends Component { static propTypes = { ready: bool.isRequired, - submitCart: func.isRequired, + submit: func.isRequired, submitting: bool.isRequired }; render() { - const { ready, submitCart, submitting } = this.props; + const { ready, submit, submitting } = this.props; const disabled = isDisabled(submitting, ready); return ( - diff --git a/packages/venia-concept/src/components/Checkout/flow.js b/packages/venia-concept/src/components/Checkout/flow.js index cdfa589908..e33d1e7482 100644 --- a/packages/venia-concept/src/components/Checkout/flow.js +++ b/packages/venia-concept/src/components/Checkout/flow.js @@ -19,9 +19,9 @@ const isAddressValid = address => !!(address && address.email); class Flow extends Component { static propTypes = { actions: shape({ + beginCheckout: func.isRequired, editOrder: func.isRequired, resetCheckout: func.isRequired, - submitCart: func.isRequired, submitInput: func.isRequired, submitOrder: func.isRequired }).isRequired, @@ -43,9 +43,9 @@ class Flow extends Component { get child() { const { actions, cart, checkout } = this.props; const { + beginCheckout, editOrder, resetCheckout, - submitCart, submitInput, submitOrder } = actions; @@ -56,7 +56,7 @@ class Flow extends Component { switch (stepMap[step]) { case 1: { - const stepProps = { ready, submitCart, submitting }; + const stepProps = { beginCheckout, ready, submitting }; return ; } diff --git a/packages/venia-concept/src/components/Checkout/wrapper.js b/packages/venia-concept/src/components/Checkout/wrapper.js index 3e50ed110d..cdcc9823af 100644 --- a/packages/venia-concept/src/components/Checkout/wrapper.js +++ b/packages/venia-concept/src/components/Checkout/wrapper.js @@ -3,9 +3,9 @@ import { connect } from 'react-redux'; import { bool, func, object, oneOf, shape, string } from 'prop-types'; import { + beginCheckout, editOrder, resetCheckout, - submitCart, submitInput, submitOrder } from 'src/actions/checkout'; @@ -13,6 +13,7 @@ import Flow from './flow'; class Wrapper extends Component { static propTypes = { + beginCheckout: func.isRequired, cart: shape({ details: object, guestCartId: string, @@ -25,7 +26,6 @@ class Wrapper extends Component { }), editOrder: func.isRequired, resetCheckout: func.isRequired, - submitCart: func.isRequired, submitInput: func.isRequired, submitOrder: func.isRequired }; @@ -34,9 +34,9 @@ class Wrapper extends Component { const { cart, checkout, + beginCheckout, editOrder, resetCheckout, - submitCart, submitInput, submitOrder } = this.props; @@ -47,9 +47,9 @@ class Wrapper extends Component { } const actions = { + beginCheckout, editOrder, resetCheckout, - submitCart, submitInput, submitOrder }; @@ -61,9 +61,9 @@ class Wrapper extends Component { } const mapDispatchToProps = { + beginCheckout, editOrder, resetCheckout, - submitCart, submitInput, submitOrder }; diff --git a/packages/venia-concept/src/components/Gallery/__tests__/item.spec.js b/packages/venia-concept/src/components/Gallery/__tests__/item.spec.js index 9fb6c72e70..c3be0de305 100644 --- a/packages/venia-concept/src/components/Gallery/__tests__/item.spec.js +++ b/packages/venia-concept/src/components/Gallery/__tests__/item.spec.js @@ -1,7 +1,7 @@ import { createElement } from 'react'; -import { configure, mount, shallow } from 'enzyme'; +import { configure, shallow } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; -import { BrowserRouter } from 'react-router-dom'; +import { Link, MemoryRouter } from 'react-router-dom'; import Item from '../item'; @@ -59,22 +59,24 @@ test('passes classnames to the placeholder item', () => { expect(wrapper.hasClass(classes.root_pending)); }); -test('renders Anchor elements for navigating to a PDP', () => { - const wrapper = mount( - +test('renders Link elements for navigating to a PDP', () => { + const wrapper = shallow( + - + ); - const expectedSelector = `a[href="/${validItem.url_key}.html"]`; - const imageLink = wrapper - .find({ className: classes.images }) - .find(expectedSelector); - const nameLink = wrapper - .find({ className: classes.name }) - .find(expectedSelector); - - expect(imageLink).toHaveLength(1); - expect(nameLink).toHaveLength(1); + + expect( + wrapper + .childAt(0) + .dive() + .dive() + .findWhere( + node => + node.type() === Link && + node.prop('to') === `/${validItem.url_key}.html` + ) + ).toHaveLength(2); }); /** diff --git a/packages/venia-concept/src/components/Header/navTrigger.js b/packages/venia-concept/src/components/Header/navTrigger.js index b90c1f4d9d..5d788d42e5 100644 --- a/packages/venia-concept/src/components/Header/navTrigger.js +++ b/packages/venia-concept/src/components/Header/navTrigger.js @@ -4,6 +4,7 @@ import { compose } from 'redux'; import PropTypes from 'prop-types'; import classify from 'src/classify'; +import { toggleDrawer } from 'src/actions/app'; import defaultClasses from './navTrigger.css'; class Trigger extends Component { diff --git a/packages/venia-concept/src/components/MiniCart/miniCart.js b/packages/venia-concept/src/components/MiniCart/miniCart.js index a120913dd4..8cfcc218b5 100644 --- a/packages/venia-concept/src/components/MiniCart/miniCart.js +++ b/packages/venia-concept/src/components/MiniCart/miniCart.js @@ -5,7 +5,7 @@ import { shape, string } from 'prop-types'; import { Price } from '@magento/peregrine'; import classify from 'src/classify'; -import { loadReducers } from 'src/actions/app'; +import { addReducer } from 'src/store'; import { getCartDetails } from 'src/actions/cart'; import Icon from 'src/components/Icon'; import ProductList from './productList'; @@ -31,17 +31,19 @@ class MiniCart extends Component { }; async componentDidMount() { - const { getCartDetails, loadReducers } = this.props; - - loadReducers([ + const { getCartDetails } = this.props; + const reducers = await Promise.all([ import('src/reducers/cart'), import('src/reducers/checkout') ]); + reducers.forEach(mod => { + addReducer(mod.name, mod.default); + }); + await getCartDetails(); + const CheckoutModule = await import('src/components/Checkout'); Checkout = CheckoutModule.default; - - getCartDetails(); } get productList() { @@ -116,7 +118,7 @@ const mapStateToProps = ({ cart }) => { }; }; -const mapDispatchToProps = { getCartDetails, loadReducers }; +const mapDispatchToProps = { getCartDetails }; export default compose( classify(defaultClasses), diff --git a/packages/venia-concept/src/index.js b/packages/venia-concept/src/index.js index 8dd09a5cd8..0bf19f40c9 100644 --- a/packages/venia-concept/src/index.js +++ b/packages/venia-concept/src/index.js @@ -1,21 +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 appReducer from 'src/reducers/app'; -import directoryReducer from 'src/reducers/directory'; +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', appReducer); -store.addReducer('directory', directoryReducer); - const apolloClient = new ApolloClient(); ReactDOM.render( @@ -37,5 +27,3 @@ if (process.env.SERVICE_WORKER && 'serviceWorker' in navigator) { }); }); } - -export { store }; diff --git a/packages/venia-concept/src/reducers/cart.js b/packages/venia-concept/src/reducers/cart.js index b5fcfe1ebb..d49bfc8c1c 100644 --- a/packages/venia-concept/src/reducers/cart.js +++ b/packages/venia-concept/src/reducers/cart.js @@ -12,13 +12,17 @@ const initialState = { }; const reducerMap = { - [actions.receiveGuestCart]: (state, { payload }) => { + [actions.getGuestCart.receive]: (state, { payload, error }) => { + if (error) { + return state; + } + return { ...state, guestCartId: payload }; }, - [actions.updateDetails]: (state, { payload, error }) => { + [actions.getDetails.receive]: (state, { payload, error }) => { if (error) { return state; } diff --git a/packages/venia-concept/src/reducers/checkout.js b/packages/venia-concept/src/reducers/checkout.js index c500e6f09e..56be629745 100644 --- a/packages/venia-concept/src/reducers/checkout.js +++ b/packages/venia-concept/src/reducers/checkout.js @@ -11,30 +11,17 @@ const initialState = { }; const reducerMap = { - [actions.edit]: (state, { payload }) => { - return { - ...state, - editing: payload - }; - }, - [actions.cart.submit]: state => { - return { - ...state, - submitting: true - }; - }, - [actions.cart.accept]: state => { + [actions.begin]: state => { return { ...state, editing: null, - step: 'form', - submitting: false + step: 'form' }; }, - [actions.cart.reject]: state => { + [actions.edit]: (state, { payload }) => { return { ...state, - submitting: false + editing: payload }; }, [actions.input.submit]: state => { 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;