From 5ad63a749e24919e06282cead8ae78a8e2fb17fb Mon Sep 17 00:00:00 2001 From: Maryna <49429739+MarynaVozniuk@users.noreply.github.com> Date: Tue, 23 Apr 2019 01:44:26 +0300 Subject: [PATCH 1/4] Header logo width changed on as other header elements have (#1129) --- packages/venia-concept/src/components/Header/header.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/venia-concept/src/components/Header/header.css b/packages/venia-concept/src/components/Header/header.css index 3554a6bcd9..80e3f558d7 100755 --- a/packages/venia-concept/src/components/Header/header.css +++ b/packages/venia-concept/src/components/Header/header.css @@ -41,6 +41,7 @@ .logo { grid-area: title; + width: 3rem; } .primaryActions { From 3c0e7ed3378cf870cdc54537fcd1e84461484533 Mon Sep 17 00:00:00 2001 From: Andy Terranova <13182778+supernova-at@users.noreply.github.com> Date: Tue, 23 Apr 2019 12:35:14 -0500 Subject: [PATCH 2/4] Authenticated Users (#968) * Changes guestCartId to cartId. Adds endpoints for signed in users. * Updates receipt to hide create account prompt for signed in users * Fixes bug where cartId is expected to be a String, not a number * Fixes for missing test and missed refactor naming * Add casting back * Adds reset action to cart * Removes direct call to assignGuestCartToCustomer * Completes the authorized customer checkout flow * Fixes cart bug after customer signs out * Adds unit tests for signIn and signOut user action * Adds unit tests for removeCart function * Updates documentation. * Renames createGuestCart to createCart in documentation and comments * Adds and uses clearToken function for consistency * Adds workaround for price zero bug --- .../src/RestApi/Magento2/MulticastCache.js | 2 +- .../src/RootComponents/Product/Product.js | 10 +- .../actions/cart/__tests__/actions.spec.js | 28 +- .../cart/__tests__/asyncActions.spec.js | 1538 ++++++++--------- .../venia-concept/src/actions/cart/actions.js | 4 +- .../src/actions/cart/asyncActions.js | 408 ++--- .../checkout/__tests__/asyncActions.spec.js | 53 +- .../src/actions/checkout/asyncActions.js | 150 +- .../user/__tests__/asyncActions.spec.js | 158 +- .../src/actions/user/asyncActions.js | 63 +- .../Receipt/__tests__/receipt.spec.js | 14 +- .../components/Checkout/Receipt/receipt.js | 38 +- .../Checkout/__tests__/form.spec.js | 2 +- .../src/components/Checkout/flow.js | 16 +- .../src/components/Checkout/form.js | 4 +- .../src/components/Checkout/wrapper.js | 24 +- .../src/components/MiniCart/miniCart.js | 2 +- .../src/components/SignIn/container.js | 4 +- .../src/components/SignIn/signIn.js | 5 +- packages/venia-concept/src/index.js | 3 +- .../src/reducers/__tests__/cart.spec.js | 16 +- packages/venia-concept/src/reducers/cart.js | 15 +- .../features/checkout/index.md | 7 +- 23 files changed, 1294 insertions(+), 1270 deletions(-) diff --git a/packages/peregrine/src/RestApi/Magento2/MulticastCache.js b/packages/peregrine/src/RestApi/Magento2/MulticastCache.js index b9d6e4d33a..2bebfe4c62 100644 --- a/packages/peregrine/src/RestApi/Magento2/MulticastCache.js +++ b/packages/peregrine/src/RestApi/Magento2/MulticastCache.js @@ -4,7 +4,7 @@ * Resource matching is determined by a string composite [method, path, body]. * * (M2ApiRequests know not to use this cache for create operations, except for - * singleton create operations like createGuestCart, which have no body.) + * singleton create operations like createCart, which have no body.) * @module MulticastCache */ diff --git a/packages/venia-concept/src/RootComponents/Product/Product.js b/packages/venia-concept/src/RootComponents/Product/Product.js index ee98c08ed4..e62ca6a447 100644 --- a/packages/venia-concept/src/RootComponents/Product/Product.js +++ b/packages/venia-concept/src/RootComponents/Product/Product.js @@ -1,4 +1,5 @@ import React, { Component } from 'react'; +import { string, func } from 'prop-types'; import { connect, Query } from 'src/drivers'; import { addItemToCart } from 'src/actions/cart'; @@ -15,9 +16,14 @@ import productQuery from 'src/queries/getProductDetail.graphql'; * TODO: Replace with a single product query when possible. */ class Product extends Component { + static propTypes = { + addItemToCart: func.isRequired, + cartId: string + }; + addToCart = async (item, quantity) => { - const { guestCartId } = this.props; - await this.props.addItemToCart({ guestCartId, item, quantity }); + const { addItemToCart, cartId } = this.props; + await addItemToCart({ cartId, item, quantity }); }; componentDidMount() { diff --git a/packages/venia-concept/src/actions/cart/__tests__/actions.spec.js b/packages/venia-concept/src/actions/cart/__tests__/actions.spec.js index 1f4e595c21..0175d5fd21 100644 --- a/packages/venia-concept/src/actions/cart/__tests__/actions.spec.js +++ b/packages/venia-concept/src/actions/cart/__tests__/actions.spec.js @@ -61,32 +61,28 @@ test('removeItem.receive() returns a proper action object', () => { }); }); -test('getGuestCart.request.toString() returns the proper action type', () => { - expect(actions.getGuestCart.request.toString()).toBe( - 'CART/GET_GUEST_CART/REQUEST' - ); +test('getCart.request.toString() returns the proper action type', () => { + expect(actions.getCart.request.toString()).toBe('CART/GET_CART/REQUEST'); }); -test('getGuestCart.request() returns a proper action object', () => { - expect(actions.getGuestCart.request(payload)).toEqual({ - type: 'CART/GET_GUEST_CART/REQUEST', +test('getCart.request() returns a proper action object', () => { + expect(actions.getCart.request(payload)).toEqual({ + type: 'CART/GET_CART/REQUEST', payload }); }); -test('getGuestCart.receive.toString() returns the proper action type', () => { - expect(actions.getGuestCart.receive.toString()).toBe( - 'CART/GET_GUEST_CART/RECEIVE' - ); +test('getCart.receive.toString() returns the proper action type', () => { + expect(actions.getCart.receive.toString()).toBe('CART/GET_CART/RECEIVE'); }); -test('getGuestCart.receive() returns a proper action object', () => { - expect(actions.getGuestCart.receive(payload)).toEqual({ - type: 'CART/GET_GUEST_CART/RECEIVE', +test('getCart.receive() returns a proper action object', () => { + expect(actions.getCart.receive(payload)).toEqual({ + type: 'CART/GET_CART/RECEIVE', payload }); - expect(actions.getGuestCart.receive(error)).toEqual({ - type: 'CART/GET_GUEST_CART/RECEIVE', + expect(actions.getCart.receive(error)).toEqual({ + type: 'CART/GET_CART/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 index 985c5c5341..bffcae5f59 100644 --- a/packages/venia-concept/src/actions/cart/__tests__/asyncActions.spec.js +++ b/packages/venia-concept/src/actions/cart/__tests__/asyncActions.spec.js @@ -12,9 +12,11 @@ import { addItemToCart, updateItemInCart, removeItemFromCart, - createGuestCart, + createCart, getCartDetails, - toggleCart + removeCart, + toggleCart, + writeImageToCache } from '../asyncActions'; jest.mock('src/store'); @@ -25,7 +27,7 @@ const { request } = RestApi.Magento2; beforeAll(() => { getState.mockImplementation(() => ({ app: { drawer: null }, - cart: { guestCartId: 'GUEST_CART_ID' }, + cart: { cartId: 'CART_ID' }, user: { isSignedIn: false } })); }); @@ -43,896 +45,836 @@ afterAll(() => { getState.mockRestore(); }); -test('createGuestCart() returns a thunk', () => { - expect(createGuestCart()).toBeInstanceOf(Function); -}); +describe('createCart', () => { + test('it returns a thunk', () => { + expect(createCart()).toBeInstanceOf(Function); + }); -test('createGuestCart thunk returns undefined', async () => { - const result = await createGuestCart()(...thunkArgs); + test('its thunk returns undefined', async () => { + getState.mockImplementationOnce(() => ({ + cart: {}, + user: { isSignedIn: false } + })); - expect(result).toBeUndefined(); -}); + const result = await createCart()(...thunkArgs); -test('createGuestCart thunk does nothing if a guest cart exists in state', async () => { - await createGuestCart()(...thunkArgs); + expect(result).toBeUndefined(); + }); - expect(dispatch).not.toHaveBeenCalled(); - expect(request).not.toHaveBeenCalled(); -}); + test('its thunk earlies out if a cartId already exists in state', async () => { + await createCart()(...thunkArgs); -test('createGuestCart thunk uses the guest cart from storage', async () => { - const storedGuestCartId = 'STORED_GUEST_CART_ID'; - getState.mockImplementationOnce(() => ({ - cart: {}, - user: { isSignedIn: false } - })); - mockGetItem.mockImplementationOnce(() => storedGuestCartId); - - await createGuestCart()(...thunkArgs); - - expect(mockGetItem).toHaveBeenCalledWith('guestCartId'); - expect(dispatch).toHaveBeenNthCalledWith(1, checkoutActions.reset()); - expect(dispatch).toHaveBeenNthCalledWith( - 2, - actions.getGuestCart.receive(storedGuestCartId) - ); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(request).not.toHaveBeenCalled(); -}); + expect(dispatch).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); + }); -test('createGuestCart thunk dispatches actions on success', async () => { - const response = 'NEW_GUEST_CART_ID'; + test('its thunk uses the cart from storage', async () => { + getState.mockImplementationOnce(() => ({ + cart: {}, + user: { isSignedIn: false } + })); + const storedCartId = 'STORED_CART_ID'; + mockGetItem.mockImplementationOnce(() => storedCartId); + + await createCart()(...thunkArgs); + + expect(mockGetItem).toHaveBeenCalledWith('cartId'); + expect(dispatch).toHaveBeenNthCalledWith(1, checkoutActions.reset()); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getCart.receive(storedCartId) + ); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(request).not.toHaveBeenCalled(); + }); - request.mockResolvedValueOnce(response); - getState.mockImplementationOnce(() => ({ - cart: {}, - user: { isSignedIn: false } - })); + test('its thunk dispatches actions on success', async () => { + getState.mockImplementationOnce(() => ({ + cart: {}, + user: { isSignedIn: false } + })); + const response = 'NEW_CART_ID'; + request.mockResolvedValueOnce(response); + + await createCart()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, checkoutActions.reset()); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.getCart.request()); + expect(dispatch).toHaveBeenNthCalledWith( + 3, + actions.getCart.receive(response) + ); + expect(dispatch).toHaveBeenCalledTimes(3); + expect(mockSetItem).toHaveBeenCalledWith('cartId', response); + }); - await createGuestCart()(...thunkArgs); + test('its thunk calls the appropriate endpoints when user is signed in', async () => { + getState.mockImplementationOnce(() => ({ + cart: {}, + user: { isSignedIn: true } + })); + const response = 'NEW_CART_ID'; + request.mockResolvedValueOnce(response); - 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(); -}); + await createCart()(...thunkArgs); -test('createGuestCart thunk dispatches actions on failure', async () => { - const error = new Error('ERROR'); + expect(request).toHaveBeenCalledTimes(2); - request.mockRejectedValueOnce(error); - getState.mockImplementationOnce(() => ({ - cart: {}, - user: { isSignedIn: false } - })); - mockGetItem.mockImplementationOnce(() => {}); + const authedEndpoint = '/rest/V1/carts/mine'; + expect(request).toHaveBeenNthCalledWith(1, authedEndpoint, { + method: 'POST' + }); - await createGuestCart()(...thunkArgs); + const billingEndpoint = '/rest/V1/carts/mine/billing-address'; + expect(request).toHaveBeenNthCalledWith(2, billingEndpoint, { + method: 'POST', + body: JSON.stringify({ + address: {}, + cartId: response + }) + }); + }); - 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('its thunk dispatches actions on failure', async () => { + mockGetItem.mockImplementationOnce(() => {}); + getState.mockImplementationOnce(() => ({ + cart: {}, + user: { isSignedIn: false } + })); + const error = new Error('ERROR'); + request.mockRejectedValueOnce(error); + + await createCart()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, checkoutActions.reset()); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.getCart.request()); + expect(dispatch).toHaveBeenNthCalledWith( + 3, + actions.getCart.receive(error) + ); + expect(dispatch).toHaveBeenCalledTimes(3); + }); }); -test('addItemToCart() returns a thunk', () => { - expect(addItemToCart()).toBeInstanceOf(Function); -}); +describe('addItemToCart', () => { + const payload = { item: 'ITEM', quantity: 1 }; -test('addItemToCart thunk returns undefined', async () => { - const result = await addItemToCart()(...thunkArgs); + test('it returns a thunk', () => { + expect(addItemToCart()).toBeInstanceOf(Function); + }); - expect(result).toBeUndefined(); -}); + test('its thunk returns undefined', async () => { + const result = await addItemToCart()(...thunkArgs); -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, expect.any(Function)); - expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); - expect(dispatch).toHaveBeenNthCalledWith( - 4, - actions.addItem.receive({ cartItem, ...payload }) - ); - expect(dispatch).toHaveBeenCalledTimes(4); -}); + expect(result).toBeUndefined(); + }); -test('addItemToCart thunk skips image cache if no sku or image', async () => { - const noSku = { - quantity: 1, - item: { - media_gallery_entries: [ - { - position: 1, - url: 'http://example.com' - } - ] - } - }; - await addItemToCart(noSku)(...thunkArgs); - expect(mockGetItem).not.toHaveBeenCalled; - const noImages = { - quantity: 1, - item: { - sku: 'INVISIBLE' - } - }; - await addItemToCart(noImages)(...thunkArgs); - expect(mockGetItem).not.toHaveBeenCalled; - const emptyImages = { - quantity: 1, - item: { - sku: 'INVISIBLE', - media_gallery_entries: [] - } - }; - await addItemToCart(emptyImages)(...thunkArgs); - expect(mockGetItem).not.toHaveBeenCalled; -}); + // 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, expect.any(Function)); + // expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + // expect(dispatch).toHaveBeenNthCalledWith( + // 4, + // actions.addItem.receive({ cartItem, ...payload }) + // ); + // expect(dispatch).toHaveBeenCalledTimes(4); + // }); + + test('its thunk dispatches actions on success', async () => { + // Test setup. + const cartItem = 'CART_ITEM'; + request.mockResolvedValueOnce(cartItem); + + // Call the function. + await addItemToCart(payload)(...thunkArgs); + + // Make assertions. + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.addItem.request(payload) + ); + // getCartDetails + expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); + // toggleDrawer + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith( + 4, + actions.addItem.receive({ + cartItem, + item: payload.item, + quantity: payload.quantity + }) + ); + expect(dispatch).toHaveBeenCalledTimes(4); + }); -test('addItemToCart stores product images in local cache for use in cart', async () => { - const itemWithImages = { - quantity: 1, - item: { - sku: 'HELLO', - media_gallery_entries: [ - { - position: 2, - url: 'http://example.com/second' - }, - { - position: 1, - url: 'http://example.com/first' - } - ] - } - }; - await addItemToCart(itemWithImages)(...thunkArgs); - expect(mockGetItem).toHaveBeenCalledWith('imagesBySku'); - expect(mockSetItem).toHaveBeenCalledWith( - 'imagesBySku', - expect.objectContaining({ - HELLO: { position: 1, url: 'http://example.com/first' } - }) - ); - - const itemWithUnpositionedImages = { - quantity: 1, - item: { - sku: 'GOODBYE', - media_gallery_entries: [ - { - url: 'http://example.com' - } - ] - } - }; - await addItemToCart(itemWithUnpositionedImages)(...thunkArgs); - expect(mockGetItem).toHaveBeenCalledTimes(2); - expect(mockSetItem).toHaveBeenCalledWith( - 'imagesBySku', - expect.objectContaining({ - GOODBYE: { url: 'http://example.com' } - }) - ); -}); + // test('it calls writeImageToCache', async () => { + // writeImageToCache.mockImplementationOnce(() => {}); -test('addItemToCart reuses product images from cache', async () => { - const sameItem = { - sku: 'SAME_ITEM', - media_gallery_entries: [{ url: 'http://example.com/same/item' }] - }; - const fakeImageCache = {}; - mockGetItem.mockReturnValueOnce(fakeImageCache); - await addItemToCart({ quantity: 1, item: sameItem })(...thunkArgs); - mockGetItem.mockReturnValueOnce(fakeImageCache); - expect(mockSetItem).toHaveBeenCalledTimes(1); - await addItemToCart({ quantity: 4, item: sameItem })(...thunkArgs); - expect(mockSetItem).toHaveBeenCalledTimes(1); -}); + // await updateItemInCart(payload)(...thunkArgs); -test('addItemToCart thunk dispatches special failure if guestCartId is not present', async () => { - const payload = { - item: { sku: 'ITEM_SKU', name: 'ITEM_NAME' }, - quantity: 1 - }; - const error = new Error('Missing required information: guestCartId'); - error.noGuestCartId = true; - getState.mockImplementationOnce(() => ({ - cart: {}, - user: { isSignedIn: false } - })); - getState.mockImplementationOnce(() => ({ - cart: {}, - user: { isSignedIn: false } - })); - await addItemToCart(payload)(...thunkArgs); - expect(dispatch).toHaveBeenNthCalledWith( - 1, - actions.addItem.request(payload) - ); - expect(dispatch).toHaveBeenNthCalledWith(2, actions.addItem.receive(error)); - // and now, the the createGuestCart thunk - expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); -}); + // expect(writeImageToCache).toHaveBeenCalled(); + // }); -test('addItemToCart tries to recreate a guest cart on 404 failure', async () => { - getState - .mockImplementationOnce(() => ({ - cart: { guestCartId: 'OLD_AND_BUSTED' }, - user: { isSignedIn: false } - })) - .mockImplementationOnce(() => ({ - cart: { guestCartId: 'CACHED_CART' }, - user: { isSignedIn: false } - })) - .mockImplementationOnce(() => ({ - cart: {}, + test('its thunk dispatches special failure if cartId is not present', async () => { + getState.mockImplementationOnce(() => ({ + cart: { + /* Purposefully no cartId here */ + }, user: { isSignedIn: false } })); - const payload = { item: 'ITEM', quantity: 1 }; - const error = new Error('ERROR'); - error.response = { - status: 404 - }; - // image cache - mockGetItem.mockResolvedValueOnce('CACHED_CART'); - request.mockRejectedValueOnce(error); + const error = new Error('Missing required information: cartId'); + error.noCartId = true; + + await addItemToCart(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.addItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.addItem.receive(error) + ); + // createCart + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + }); - await addItemToCart(payload)(...thunkArgs); + test('its thunk tries to recreate a cart on 404 failure', async () => { + const error = new Error('ERROR'); + error.response = { + status: 404 + }; + request.mockRejectedValueOnce(error); + + await addItemToCart(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.addItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.addItem.receive(error) + ); + // createCart + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + + // And then the thunk is called again. + + expect(request).toHaveBeenCalledTimes(2); + }); - expect(dispatch.mock.calls).toMatchObject([ - [ - { - payload: { - item: 'ITEM', - quantity: 1 - }, - type: 'CART/ADD_ITEM/REQUEST' - } - ], - [ - { - error: true, - payload: expect.any(Error), - type: 'CART/ADD_ITEM/RECEIVE' - } - ], - [expect.any(Function)], - [ - { - payload: { - item: 'ITEM', - quantity: 1 - }, - type: 'CART/ADD_ITEM/REQUEST' - } - ], - [expect.any(Function)], - [expect.any(Function)], - [ - { - payload: { - cartItem: undefined, - item: 'ITEM', - quantity: 1 - }, - type: 'CART/ADD_ITEM/RECEIVE' - } - ] - ]); -}); + test('its thunk uses the appropriate endpoint when user is signed in', async () => { + getState.mockImplementationOnce(() => ({ + cart: { cartId: 'SOME_CART_ID' }, + user: { isSignedIn: true } + })); -test('addItemToCart opens drawer and gets cart details on success', async () => { - const payload = { item: 'ITEM', quantity: 1 }; - const fakeCart = { - cart: { guestCartId: 'NEW_GUEST_CART_ID' }, - user: { isSignedIn: false } - }; - const cartItem = 'CART_ITEM'; - - getState.mockReturnValueOnce(fakeCart).mockReturnValueOnce(fakeCart); - const fakeDispatch = fn => - typeof fn === 'function' && fn(dispatch, getState); - dispatch - .mockImplementationOnce(fakeDispatch) - .mockImplementationOnce(fakeDispatch) - .mockImplementationOnce(fakeDispatch) - .mockImplementationOnce(fakeDispatch) - .mockImplementationOnce(fakeDispatch) - .mockImplementationOnce(fakeDispatch); - - request.mockResolvedValueOnce(cartItem).mockResolvedValueOnce(cartItem); - await addItemToCart(payload)(...thunkArgs); - - expect(getState).toHaveBeenCalledTimes(3); - expect(dispatch).toHaveBeenCalledTimes(7); - expect(request).toHaveBeenCalledTimes(4); -}); + await addItemToCart(payload)(...thunkArgs); -test('removeItemFromCart() returns a thunk', () => { - expect(removeItemFromCart({})).toBeInstanceOf(Function); + const authedEndpoint = '/rest/V1/carts/mine/items'; + expect(request).toHaveBeenCalledWith(authedEndpoint, { + method: 'POST', + body: expect.any(String) + }); + }); }); -test('removeItemFromCart thunk returns undefined', async () => { - const result = await removeItemFromCart({})(...thunkArgs); +describe('removeItemFromCart', () => { + const payload = { item: { item_id: 1 } }; - expect(result).toBeUndefined(); -}); + test('it returns a thunk', () => { + expect(removeItemFromCart(payload)).toBeInstanceOf(Function); + }); -test('updateItemInCart() returns a thunk', () => { - expect(updateItemInCart()).toBeInstanceOf(Function); -}); + test('its thunk returns undefined', async () => { + const result = await removeItemFromCart(payload)(...thunkArgs); -test('updateItemInCart thunk returns undefined', async () => { - const result = await updateItemInCart()(...thunkArgs); + expect(result).toBeUndefined(); + }); - expect(result).toBeUndefined(); -}); + test('its thunk dispatches actions on success', async () => { + const response = 1; + request.mockResolvedValueOnce(response); + + await removeItemFromCart(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenCalledTimes(3); + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.removeItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.removeItem.receive({ + cartItem: response, + item: payload.item, + cartItemCount: 0 + }) + ); + // getCartDetails + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + }); -test('updateItemInCart thunk dispatches actions on success', async () => { - const payload = { item: 'ITEM', quantity: 1 }; - const cartItem = 'CART_ITEM'; - - request.mockResolvedValueOnce(cartItem); - await updateItemInCart(payload)(...thunkArgs); - - expect(dispatch).toHaveBeenNthCalledWith( - 1, - actions.updateItem.request(payload) - ); - expect(dispatch).toHaveBeenNthCalledWith( - 2, - actions.updateItem.receive({ cartItem, ...payload }) - ); - expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); - expect(dispatch).toHaveBeenNthCalledWith(4, expect.any(Function)); - // Additional dispatch occurs to close the options drawer - expect(dispatch).toHaveBeenCalledTimes(5); -}); + test('its thunk dispatches special failure if cartId is not present', async () => { + getState.mockImplementationOnce(() => ({ cart: {} })); -test('updateItemInCart thunk skips image cache if no sku or image', async () => { - const noSku = { - quantity: 1, - item: { - media_gallery_entries: [ - { - position: 1, - url: 'http://example.com' - } - ] - } - }; - await updateItemInCart(noSku)(...thunkArgs); - expect(mockGetItem).not.toHaveBeenCalled; + const error = new Error('Missing required information: cartId'); + error.noCartId = true; - const noImages = { - quantity: 1, - item: { - sku: 'INVISIBLE' - } - }; - await updateItemInCart(noImages)(...thunkArgs); - expect(mockGetItem).not.toHaveBeenCalled; + await removeItemFromCart(payload)(...thunkArgs); - const emptyImages = { - quantity: 1, - item: { - sku: 'INVISIBLE', - media_gallery_entries: [] - } - }; - await updateItemInCart(emptyImages)(...thunkArgs); - expect(mockGetItem).not.toHaveBeenCalled; -}); + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.removeItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.removeItem.receive(error) + ); + // createCart + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); -test('updateItemInCart stores product images in local cache for use in cart', async () => { - const itemWithImages = { - quantity: 1, - item: { - sku: 'HELLO', - media_gallery_entries: [ - { - position: 2, - url: 'http://example.com/second' - }, - { - position: 1, - url: 'http://example.com/first' - } - ] - } - }; - await updateItemInCart(itemWithImages)(...thunkArgs); - expect(mockGetItem).toHaveBeenCalledWith('imagesBySku'); - expect(mockSetItem).toHaveBeenCalledWith( - 'imagesBySku', - expect.objectContaining({ - HELLO: { position: 1, url: 'http://example.com/first' } - }) - ); - - const itemWithUnpositionedImages = { - quantity: 1, - item: { - sku: 'GOODBYE', - media_gallery_entries: [ - { - url: 'http://example.com' - } - ] - } - }; - await updateItemInCart(itemWithUnpositionedImages)(...thunkArgs); - expect(mockGetItem).toHaveBeenCalledTimes(2); - expect(mockSetItem).toHaveBeenCalledWith( - 'imagesBySku', - expect.objectContaining({ - GOODBYE: { url: 'http://example.com' } - }) - ); -}); + expect(mockRemoveItem).toHaveBeenCalledWith('cartId'); + }); -test('updateItemInCart reuses product images from cache', async () => { - const sameItem = { - sku: 'SAME_ITEM', - media_gallery_entries: [{ url: 'http://example.com/same/item' }] - }; - const fakeImageCache = {}; + test('its thunk tries to recreate a cart on 404 failure', async () => { + const error = new Error('ERROR'); + error.response = { + status: 404 + }; + request.mockRejectedValueOnce(error); + + await removeItemFromCart(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.removeItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.removeItem.receive(error) + ); + // createCart + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + + // And then the thunk is called again. + + expect(request).toHaveBeenCalledTimes(2); + }); - mockGetItem.mockReturnValueOnce(fakeImageCache); - await updateItemInCart({ quantity: 1, item: sameItem })(...thunkArgs); - mockGetItem.mockReturnValueOnce(fakeImageCache); - expect(mockSetItem).toHaveBeenCalledTimes(1); + test('its thunk clears the cartId when removing the last item in the cart', async () => { + getState.mockImplementationOnce(() => ({ + cart: { cartId: 'CART', details: { items_count: 1 } }, + user: { isSignedIn: false } + })); - await updateItemInCart({ quantity: 4, item: sameItem })(...thunkArgs); - expect(mockSetItem).toHaveBeenCalledTimes(1); + await removeItemFromCart(payload)(...thunkArgs); + + expect(mockRemoveItem).toHaveBeenCalledWith('cartId'); + }); + + test('its thunk uses the proper endpoint when user is signed in', async () => { + getState.mockImplementationOnce(() => ({ + cart: { cartId: 'UNIT_TEST' }, + user: { isSignedIn: true } + })); + + await removeItemFromCart(payload)(...thunkArgs); + + const authedEndpoint = `/rest/V1/carts/mine/items/${ + payload.item.item_id + }`; + expect(request).toHaveBeenCalledWith(authedEndpoint, { + method: 'DELETE' + }); + }); }); -test('updateItemInCart thunk dispatches special failure if guestCartId is not present', async () => { +describe('updateItemInCart', () => { const payload = { - item: { sku: 'ITEM_SKU', name: 'ITEM_NAME' }, + item: { item_id: 1 }, quantity: 1 }; - const error = new Error('Missing required information: guestCartId'); - error.noGuestCartId = true; - getState.mockImplementationOnce(() => ({ - cart: {}, - user: { isSignedIn: false } - })); - getState.mockImplementationOnce(() => ({ - cart: {}, - user: { isSignedIn: false } - })); - await updateItemInCart(payload)(...thunkArgs); - expect(dispatch).toHaveBeenNthCalledWith( - 1, - actions.updateItem.request(payload) - ); - expect(dispatch).toHaveBeenNthCalledWith( - 2, - actions.updateItem.receive(error) - ); - // and now, the createGuestCart thunk - expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); -}); + const targetItemId = 2; -test('updateItemInCart tries to recreate a guest cart on 404 failure', async () => { - getState - .mockImplementationOnce(() => ({ - cart: { guestCartId: 'OLD_AND_BUSTED' }, - user: { isSignedIn: false } - })) - .mockImplementationOnce(() => ({ - cart: { guestCartId: 'CACHED_CART' }, - user: { isSignedIn: false } - })) - .mockImplementationOnce(() => ({ - cart: {}, + test('it returns a thunk', () => { + expect(updateItemInCart(payload, targetItemId)).toBeInstanceOf( + Function + ); + }); + + test('its thunk returns undefined', async () => { + const result = await updateItemInCart(payload, targetItemId)( + ...thunkArgs + ); + + expect(result).toBeUndefined(); + }); + + test('its thunk dispatches actions on success', async () => { + const response = 7; + request.mockResolvedValueOnce(response); + + await updateItemInCart(payload, targetItemId)(...thunkArgs); + + expect(dispatch).toHaveBeenCalledTimes(4); + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.updateItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.updateItem.receive({ + cartItem: response, + item: payload.item, + quantity: payload.quantity + }) + ); + // getCartDetails + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + // closeOptionsDrawer + expect(dispatch).toHaveBeenNthCalledWith(4, expect.any(Function)); + }); + + // test('it calls writeImageToCache', async () => { + // writeImageToCache.mockImplementationOnce(() => {}); + + // await updateItemInCart(payload, targetItemId)(...thunkArgs); + + // expect(writeImageToCache).toHaveBeenCalled(); + // }); + + test('its thunk dispatches special failure if cartId is not present', async () => { + getState.mockImplementationOnce(() => ({ + cart: { + /* cartId is purposefully not present */ + }, user: { isSignedIn: false } })); - const payload = { item: 'ITEM', quantity: 1 }; - const error = new Error('ERROR'); - error.response = { - status: 404 - }; - // image cache - mockGetItem.mockResolvedValueOnce('CACHED_CART'); - request.mockRejectedValueOnce(error); + const error = new Error('Missing required information: cartId'); + error.noCartId = true; + + await updateItemInCart(payload, targetItemId)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.updateItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.updateItem.receive(error) + ); + // createCart + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + }); - await updateItemInCart(payload)(...thunkArgs); + test('its thunk tries to recreate a cart on 404 failure', async () => { + const error = new Error('ERROR'); + error.response = { + status: 404 + }; + request.mockRejectedValueOnce(error); + + await updateItemInCart(payload, targetItemId)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.updateItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.updateItem.receive(error) + ); + // createCart + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + + // And then the thunk is called again. + + expect(request).toHaveBeenCalledTimes(2); + }); - expect(dispatch.mock.calls).toMatchObject([ - [ - { - payload: { - item: 'ITEM', - quantity: 1 - }, - type: 'CART/UPDATE_ITEM/REQUEST' - } - ], - [ - { - error: true, - payload: expect.any(Error), - type: 'CART/UPDATE_ITEM/RECEIVE' - } - ], - [expect.any(Function)], - [ - { - payload: { - item: 'ITEM', - quantity: 1 - }, - type: 'CART/UPDATE_ITEM/REQUEST' - } - ], - [ - { - payload: { - cartItem: undefined, - item: 'ITEM', - quantity: 1 - }, - type: 'CART/UPDATE_ITEM/RECEIVE' - } - ], - [expect.any(Function)], - [expect.any(Function)], - [expect.any(Function)] - ]); -}); + test('its thunk uses the proper endpoint when the user is signed in', async () => { + getState.mockImplementationOnce(() => ({ + cart: { cartId: 'UNIT_TEST' }, + user: { isSignedIn: true } + })); -test('removeItemFromCart thunk dispatches actions on success', async () => { - const payload = { item: 'ITEM' }; - const cartItem = 'CART_ITEM'; - - request.mockResolvedValueOnce(cartItem); - await removeItemFromCart(payload)(...thunkArgs); - - expect(dispatch).toHaveBeenNthCalledWith( - 1, - actions.removeItem.request(payload) - ); - expect(dispatch).toHaveBeenNthCalledWith( - 2, - actions.removeItem.receive({ cartItem, cartItemCount: 0, ...payload }) - ); - expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); - expect(dispatch).toHaveBeenCalledTimes(3); -}); + await updateItemInCart(payload, targetItemId)(...thunkArgs); -test('removeItemFromCart thunk dispatches special failure if guestCartId is not present', async () => { - const payload = { item: 'ITEM' }; - const error = new Error('Missing required information: guestCartId'); - error.noGuestCartId = true; - getState.mockImplementationOnce(() => ({ cart: {} })); - await removeItemFromCart(payload)(...thunkArgs); - expect(mockRemoveItem).toHaveBeenCalledWith('guestCartId'); - expect(dispatch).toHaveBeenNthCalledWith( - 1, - actions.removeItem.request(payload) - ); - expect(dispatch).toHaveBeenNthCalledWith( - 2, - actions.removeItem.receive(error) - ); - expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + const authedEndpoint = `/rest/V1/carts/mine/items/${targetItemId}`; + expect(request).toHaveBeenCalledWith(authedEndpoint, { + method: 'PUT', + body: expect.any(String) + }); + }); }); -test('removeItemFromCart tries to recreate a guest cart on 404 failure', async () => { - getState.mockImplementationOnce(() => ({ - cart: { guestCartId: 'OLD_AND_BUSTED' } - })); - const payload = { item: 'ITEM' }; - const error = new Error('ERROR'); - error.response = { - status: 404 - }; +describe('getCartDetails', () => { + const payload = { forceRefresh: true }; - request.mockRejectedValueOnce(error); + test('it returns a thunk', () => { + expect(getCartDetails(payload)).toBeInstanceOf(Function); + }); - await removeItemFromCart(payload)(...thunkArgs); + test('its thunk returns undefined', async () => { + const result = await getCartDetails(payload)(...thunkArgs); - expect(request).toHaveBeenCalledTimes(2); -}); + expect(result).toBeUndefined(); + }); -test('removeItemFromCart resets the guest cart when removing the last item in the cart', async () => { - getState.mockImplementationOnce(() => ({ - cart: { guestCartId: 'CART', details: { items_count: 1 } } - })); - const payload = { item: 'ITEM' }; + test('its thunk creates a cart if no id is found', async () => { + getState.mockImplementationOnce(() => ({ + cart: { + /* cartId purposefully not present */ + }, + user: { isSignedIn: false } + })); - // removeItemFromCart() calls storage.removeItem() to clear the guestCartId - // but only if there's 1 item left in the cart - mockRemoveItem.mockImplementationOnce(() => {}); + await getCartDetails(payload)(...thunkArgs); - await removeItemFromCart(payload)(...thunkArgs); + // createCart + expect(dispatch).toHaveBeenNthCalledWith(1, expect.any(Function)); + }); - expect(mockRemoveItem).toHaveBeenCalled(); -}); + test('its thunk dispatches actions on success', async () => { + const mockDetails = { items: [] }; + request + // fetchCartPart (details) + .mockResolvedValueOnce(mockDetails) + // fetchCartPart (payment methods) + .mockResolvedValueOnce(2) + // fetchCartPart (totals) + .mockResolvedValueOnce(3); + + await getCartDetails(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.getDetails.request('CART_ID') + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getDetails.receive({ + details: mockDetails, + paymentMethods: 2, + totals: 3 + }) + ); + }); -test('getCartDetails() returns a thunk', () => { - expect(getCartDetails()).toBeInstanceOf(Function); -}); + test('its thunk dispatches actions on failure', async () => { + const error = new Error('ERROR'); + request.mockRejectedValueOnce(error); + + await getCartDetails(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.getDetails.request('CART_ID') + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getDetails.receive(error) + ); + }); -test('getCartDetails thunk returns undefined', async () => { - getState.mockImplementationOnce(() => ({ - user: { isSignedIn: false }, - cart: { guestCartId: 'NEW_GUEST_CART_ID' } - })); + test('its thunk tries to recreate a cart on 404 failure', async () => { + const error = new Error('ERROR'); + error.response = { + status: 404 + }; + request.mockRejectedValueOnce(error); + + await getCartDetails(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.getDetails.request('CART_ID') + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getDetails.receive(error) + ); + // createCart + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + + // And then the thunk is called again. + + // three (3) fetchCartParts x two (2) thunk calls (initial, then the retry) = 6. + expect(request).toHaveBeenCalledTimes(6); + }); + + test('its thunk merges cached item images into details', async () => { + // Mock getting the image cache from storage. + const cache = { SKU_1: 'IMAGE_1' }; + mockGetItem.mockResolvedValueOnce(cache); + + const items = [ + { image: 'IMAGE_0', sku: 'SKU_0' }, + { sku: 'SKU_1' }, + { sku: 'SKU_2' } + ]; + const expected = [ + items[0], + { ...items[1], image: cache.SKU_1, options: [] }, + { ...items[2], image: {}, options: [] } + ]; + const mockDetails = { items }; + request + // fetchCartPart (details) + .mockResolvedValueOnce(mockDetails) + // fetchCartPart (payment methods) + .mockResolvedValueOnce(2) + // fetchCartPart (totals) + .mockResolvedValueOnce(3); + + await getCartDetails(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.getDetails.request('CART_ID') + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getDetails.receive({ + details: { items: expected }, + paymentMethods: 2, + totals: 3 + }) + ); + }); - const result = await getCartDetails()(...thunkArgs); + test('its thunk uses the proper endpoint when the user is signed in', async () => { + getState.mockImplementationOnce(() => ({ + cart: { cartId: 'UNIT_TEST' }, + user: { isSignedIn: true } + })); - expect(result).toBeUndefined(); + await getCartDetails(payload)(...thunkArgs); + + const authedEndpoints = { + details: '/rest/V1/carts/mine/', + paymentMethods: '/rest/V1/carts/mine/payment-methods', + totals: '/rest/V1/carts/mine/totals' + }; + const cacheArg = expect.any(Object); + expect(request).toHaveBeenNthCalledWith( + 1, + authedEndpoints.details, + cacheArg + ); + expect(request).toHaveBeenNthCalledWith( + 2, + authedEndpoints.paymentMethods, + cacheArg + ); + expect(request).toHaveBeenNthCalledWith( + 3, + authedEndpoints.totals, + cacheArg + ); + }); }); -test('getCartDetails thunk creates a guest cart if no ID is found', async () => { - getState.mockClear(); - getState - // for the getCartDetails state check - .mockImplementationOnce(() => ({ - cart: {}, - user: { isSignedIn: false } - })) - // for the createGuestCart check - .mockImplementationOnce(() => ({ - cart: {}, - user: { isSignedIn: false } - })) - // for the subsequent getCartDetails re-check - .mockImplementationOnce(() => ({ - cart: {}, - user: { isSignedIn: false } - })); - dispatch - // for not dispatching the sync notifier action - .mockImplementationOnce(() => {}) - // for actually dispatching the createGuestCart action - .mockImplementationOnce(fn => fn(...thunkArgs)); - mockGetItem.mockImplementationOnce(() => {}); - request - // for createGuestCart - .mockResolvedValueOnce('GUEST_CART_ID') - // for getCartDetails - .mockResolvedValueOnce({ - id: 1, - guestCartId: 'GUEST_CART_ID', - items: [] - }); +describe('removeCart', () => { + test('it returns a thunk', () => { + expect(removeCart()).toBeInstanceOf(Function); + }); + + test('its thunk returns undefined', async () => { + const result = await removeCart()(...thunkArgs); + + expect(result).toBeUndefined(); + }); - await getCartDetails()(...thunkArgs); + test('it clears the cartId from local storage', async () => { + await removeCart()(...thunkArgs); - expect(getState).toHaveBeenCalledTimes(4); - expect(mockGetItem).toHaveBeenCalled(); - const createCallArgs = request.mock.calls[0]; - const retrieveCallArgs = request.mock.calls[1]; - expect(createCallArgs[0]).toBe('/rest/V1/guest-carts'); - expect(createCallArgs[1]).toHaveProperty('method', 'POST'); - expect(retrieveCallArgs[0]).toBe('/rest/V1/guest-carts/GUEST_CART_ID/'); + expect(mockRemoveItem).toHaveBeenCalledWith('cartId'); + }); + + test('it clears the cart from the redux store', async () => { + await removeCart()(...thunkArgs); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith(actions.reset()); + }); }); -test('getCartDetails thunk deletes an old cart id and recreates a guest cart if cart ID is expired', async () => { - const tempStorage = {}; - mockSetItem.mockImplementation((key, value) => { - tempStorage[key] = value; +describe('toggleCart', () => { + test('it returns a thunk', () => { + expect(toggleCart()).toBeInstanceOf(Function); }); - mockGetItem.mockImplementation(key => { - return tempStorage[key]; + + test('its thunk returns undefined', async () => { + const result = await toggleCart()(...thunkArgs); + + expect(result).toBeUndefined(); }); - getState - // for the getCartDetails state check - .mockImplementationOnce(() => ({ - cart: { guestCartId: 'EXPIRED_CART_ID' }, - user: { isSignedIn: false } - })) - // for the createGuestCart check - .mockImplementationOnce(() => ({ - cart: { guestCartId: tempStorage.guestCartId }, - user: { isSignedIn: false } - })) - // for the subsequent createGuestCart check - .mockImplementationOnce(() => ({ - cart: { guestCartId: null }, - user: { isSignedIn: false } - })) - // for the subsequent getCartDetails re-check - .mockImplementationOnce(() => ({ - cart: { guestCartId: 'BRAND_NEW_CART' }, - user: { isSignedIn: false } + + test('its thunk exits if app state is not present', async () => { + getState.mockImplementationOnce(() => ({ + /* app is purposefully not present */ + cart: {} })); - dispatch - // for not dispatching the sync notifier action - .mockImplementationOnce(() => {}) - // for not dispatching the create notifier action - .mockImplementationOnce(() => {}) - // for actually dispatching the createGuestCart action - .mockImplementationOnce(fn => fn(...thunkArgs)); - request - // for for getting expired cart - .mockRejectedValueOnce({ response: { status: 404 } }) - // for getting expired cart payment methods - .mockRejectedValueOnce({ response: { status: 404 } }) - // for for getting expired cart totals - .mockRejectedValueOnce({ response: { status: 404 } }) - // for createNewCart - .mockResolvedValueOnce('BRAND_NEW_CART') - // for getCartDetails - .mockResolvedValueOnce({ - id: 1, - guestCartId: 'BRAND_NEW_CART', - items: [{ sku: 'SKU', name: 'NAME', image: 'IMAGE' }] - }); - await getCartDetails()(...thunkArgs); - - expect(getState).toHaveBeenCalledTimes(4); - expect(mockGetItem).toHaveBeenCalledWith('imagesBySku'); - expect(mockRemoveItem).toHaveBeenCalledWith('guestCartId'); - expect(mockSetItem).toHaveBeenCalledWith('guestCartId', 'BRAND_NEW_CART'); - const [ - retrieveExpiredCallArgs, - retrieveExpiredPaymentMethodsArgs, - retrieveExpiredTotalsArgs, - createCallArgs, - retrieveCallArgs - ] = request.mock.calls; - expect(retrieveExpiredCallArgs[0]).toBe( - '/rest/V1/guest-carts/EXPIRED_CART_ID/' - ); - expect(retrieveExpiredPaymentMethodsArgs[0]).toBe( - '/rest/V1/guest-carts/EXPIRED_CART_ID/payment-methods' - ); - expect(retrieveExpiredTotalsArgs[0]).toBe( - '/rest/V1/guest-carts/EXPIRED_CART_ID/totals' - ); - expect(createCallArgs[0]).toBe('/rest/V1/guest-carts'); - expect(createCallArgs[1]).toHaveProperty('method', 'POST'); - expect(retrieveCallArgs[0]).toBe('/rest/V1/guest-carts/BRAND_NEW_CART/'); -}); + await toggleCart()(...thunkArgs); -test('getCartDetails thunk dispatches actions on success', async () => { - getState.mockImplementationOnce(() => ({ - cart: { guestCartId: 'GUEST_CART_ID' }, - user: { isSignedIn: false } - })); - // For getting details. - request.mockResolvedValueOnce(1); - // For getting payment methods. - request.mockResolvedValueOnce(2); - // For getting totals. - request.mockResolvedValueOnce(3); - - await getCartDetails()(...thunkArgs); - - expect(dispatch).toHaveBeenNthCalledWith( - 1, - actions.getDetails.request('GUEST_CART_ID') - ); - expect(dispatch).toHaveBeenNthCalledWith( - 2, - actions.getDetails.receive({ details: 1, paymentMethods: 2, totals: 3 }) - ); - expect(dispatch).toHaveBeenCalledTimes(2); -}); + expect(dispatch).not.toHaveBeenCalled(); + }); -test('getCartDetails thunk dispatches actions on failure', async () => { - getState.mockImplementationOnce(() => ({ - cart: { guestCartId: 'GUEST_CART_ID' }, - user: { isSignedIn: false } - })); - 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('its thunk exits if cart state is not present', async () => { + getState.mockImplementationOnce(() => ({ + app: {} + /* cart is purposefully not present */ + })); -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' }, - { sku: 'SKU_2' } - ]; - const expected = [ - items[0], - { ...items[1], image: cache.SKU_1, options: [] }, - { ...items[2], image: {}, options: [] } - ]; - - mockGetItem.mockResolvedValueOnce(cache); - // For getting details. - request.mockResolvedValueOnce({ items }); - // For getting payment methods. - request.mockResolvedValueOnce(2); - // For getting totals. - request.mockResolvedValueOnce(3); - - await getCartDetails()(...thunkArgs); - - expect(dispatch).toHaveBeenNthCalledWith( - 2, - actions.getDetails.receive({ - details: { items: expected }, - paymentMethods: 2, - totals: 3 - }) - ); -}); + await toggleCart()(...thunkArgs); -test('toggleCart() returns a thunk', () => { - expect(toggleCart()).toBeInstanceOf(Function); -}); + expect(dispatch).not.toHaveBeenCalled(); + }); -test('toggleCart thunk returns undefined', async () => { - const result = await toggleCart()(...thunkArgs); + test('its thunk closes the drawer if it is open', async () => { + getState.mockImplementationOnce(() => ({ + app: { drawer: 'cart' }, + cart: {} + })); - expect(result).toBeUndefined(); -}); + await toggleCart()(...thunkArgs); -test('toggleCart thunk exits if app state is not present', async () => { - getState.mockImplementationOnce(() => ({ - cart: {}, - user: { isSignedIn: false } - })); + const closeDrawer = expect.any(Function); + expect(dispatch).toHaveBeenNthCalledWith(1, closeDrawer); + }); - await toggleCart()(...thunkArgs); + test('its thunk opens the drawer and refreshes the cart', async () => { + await toggleCart()(...thunkArgs); - expect(dispatch).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(2); + const toggleDrawer = expect.any(Function); + expect(dispatch).toHaveBeenNthCalledWith(1, toggleDrawer); + const getCartDetails = expect.any(Function); + expect(dispatch).toHaveBeenNthCalledWith(2, getCartDetails); + }); }); -test('toggleCart thunk exits if cart state is not present', async () => { - getState.mockImplementationOnce(() => ({ - app: {}, - user: { isSignedIn: false } - })); +describe('writeImageToCache', () => { + test('it does nothing when the item has no sku', async () => { + const noSku = { + media_gallery_entries: [ + { + position: 1, + url: 'http://example.com' + } + ] + }; - await toggleCart()(...thunkArgs); + await writeImageToCache(noSku); - expect(dispatch).not.toHaveBeenCalled(); -}); + expect(mockGetItem).not.toHaveBeenCalled(); + }); + + test('it does nothing when the item is missing entries', async () => { + const noImages = { + sku: 'INVISIBLE' + /* media_gallery_entries is purposefully omitted */ + }; -test('toggleCart thunk opens the drawer and refreshes the cart', async () => { - await toggleCart()(...thunkArgs); + await writeImageToCache(noImages); - expect(dispatch).toHaveBeenNthCalledWith(1, expect.any(Function)); - expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); - expect(dispatch).toHaveBeenCalledTimes(2); -}); + expect(mockGetItem).not.toHaveBeenCalled(); + }); + + test('it does nothing when the item has zero entries', async () => { + const emptyImages = { + sku: 'INVISIBLE', + media_gallery_entries: [] + }; + + await addItemToCart(emptyImages); -test('toggleCart thunk closes the drawer', async () => { - getState.mockReturnValueOnce({ app: { drawer: 'cart' }, cart: {} }); - await toggleCart()(...thunkArgs); + expect(mockGetItem).not.toHaveBeenCalled(); + }); - expect(dispatch).toHaveBeenNthCalledWith(1, expect.any(Function)); - expect(dispatch).toHaveBeenCalledTimes(1); + test('it stores product images in local cache when they have positions', async () => { + const item = { + sku: 'HELLO', + media_gallery_entries: [ + { + position: 2, + url: 'http://example.com/second' + }, + { + position: 1, + url: 'http://example.com/first' + } + ] + }; + + await writeImageToCache(item); + + expect(mockGetItem).toHaveBeenCalledWith('imagesBySku'); + expect(mockSetItem).toHaveBeenCalledWith( + 'imagesBySku', + expect.objectContaining({ + HELLO: { position: 1, url: 'http://example.com/first' } + }) + ); + }); + + test('it stores product images when they do not have positions', async () => { + // With unpositioned images. + const itemWithUnpositionedImages = { + sku: 'GOODBYE', + media_gallery_entries: [ + { + url: 'http://example.com' + } + ] + }; + + await writeImageToCache(itemWithUnpositionedImages); + + expect(mockGetItem).toHaveBeenCalledWith('imagesBySku'); + expect(mockSetItem).toHaveBeenCalledWith( + 'imagesBySku', + expect.objectContaining({ + GOODBYE: { url: 'http://example.com' } + }) + ); + }); + + test('it reuses product images from cache', async () => { + const sameItem = { + sku: 'SAME_ITEM', + media_gallery_entries: [{ url: 'http://example.com/same/item' }] + }; + + const fakeImageCache = {}; + mockGetItem + .mockReturnValueOnce(fakeImageCache) + .mockReturnValueOnce(fakeImageCache); + + await writeImageToCache(sameItem); + expect(mockSetItem).toHaveBeenCalledTimes(1); + + await writeImageToCache(sameItem); + // mockSetItem should still have only been called once. + expect(mockSetItem).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/venia-concept/src/actions/cart/actions.js b/packages/venia-concept/src/actions/cart/actions.js index 8a9be86009..a62660dcc8 100644 --- a/packages/venia-concept/src/actions/cart/actions.js +++ b/packages/venia-concept/src/actions/cart/actions.js @@ -7,7 +7,7 @@ const actionMap = { REQUEST: null, RECEIVE: null }, - GET_GUEST_CART: { + GET_CART: { REQUEST: null, RECEIVE: null }, @@ -25,6 +25,6 @@ const actionMap = { } }; -const actionTypes = ['OPEN_OPTIONS_DRAWER', 'CLOSE_OPTIONS_DRAWER']; +const actionTypes = ['CLOSE_OPTIONS_DRAWER', 'OPEN_OPTIONS_DRAWER', 'RESET']; export default createActions(actionMap, ...actionTypes, { prefix }); diff --git a/packages/venia-concept/src/actions/cart/asyncActions.js b/packages/venia-concept/src/actions/cart/asyncActions.js index f33a1bc72f..ecad990dc2 100644 --- a/packages/venia-concept/src/actions/cart/asyncActions.js +++ b/packages/venia-concept/src/actions/cart/asyncActions.js @@ -8,12 +8,12 @@ const { request } = RestApi.Magento2; const { BrowserPersistence } = Util; const storage = new BrowserPersistence(); -export const createGuestCart = () => +export const createCart = () => async function thunk(dispatch, getState) { - const { cart } = getState(); + const { cart, user } = getState(); - // if a guest cart already exists, exit - if (cart.guestCartId) { + // if a cart already exists in the store, exit + if (cart.cartId) { return; } @@ -21,89 +21,84 @@ export const createGuestCart = () => // 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)); + // if a cart exists in storage, act like we just received it + const cartId = await retrieveCartId(); + if (cartId) { + dispatch(actions.getCart.receive(cartId)); return; } - // otherwise, request a new guest cart - dispatch(actions.getGuestCart.request()); + // otherwise, request a new cart + dispatch(actions.getCart.request()); try { - const id = await request('/rest/V1/guest-carts', { + const guestCartEndpoint = '/rest/V1/guest-carts'; + const signedInCartEndpoint = '/rest/V1/carts/mine'; + const cartEndpoint = user.isSignedIn + ? signedInCartEndpoint + : guestCartEndpoint; + + const cartId = await request(cartEndpoint, { method: 'POST' }); // write to storage in the background - saveGuestCartId(id); - dispatch(actions.getGuestCart.receive(id)); + saveCartId(cartId); + + // There is currently an issue in Magento 2 + // where the first item added to an empty cart for an + // authenticated customer gets added with a price of zero. + // @see https://github.com/magento/magento2/issues/2991 + // This workaround is in place until that issue is resolved. + if (user.isSignedIn) { + await request('/rest/V1/carts/mine/billing-address', { + method: 'POST', + body: JSON.stringify({ + address: {}, + cartId + }) + }); + } + + dispatch(actions.getCart.receive(cartId)); } catch (error) { - dispatch(actions.getGuestCart.receive(error)); + dispatch(actions.getCart.receive(error)); } }; export const addItemToCart = (payload = {}) => { - const { item, options, parentSku, productType, quantity } = payload; + const { item, quantity } = payload; const writingImageToCache = writeImageToCache(item); return async function thunk(dispatch, getState) { await writingImageToCache; dispatch(actions.addItem.request(payload)); - const { user } = getState(); - if (user.isSignedIn) { - // TODO: handle authed carts - // if a user creates an account, - // then the guest cart will be transferred to their account - // causing `/guest-carts` to 400 - return; - } - try { - const { cart } = getState(); - const { guestCartId } = cart; + const { cart, user } = getState(); + const { cartId } = cart; - if (!guestCartId) { - const missingGuestCartError = new Error( - 'Missing required information: guestCartId' + if (!cartId) { + const missingCartIdError = new Error( + 'Missing required information: cartId' ); - missingGuestCartError.noGuestCartId = true; - throw missingGuestCartError; + missingCartIdError.noCartId = true; + throw missingCartIdError; } - // TODO: change to GraphQL mutation - // for now, manually transform the payload for REST - const itemPayload = { - qty: quantity, - sku: item.sku, - name: item.name, - quote_id: guestCartId - }; - - if (productType === 'ConfigurableProduct') { - Object.assign(itemPayload, { - sku: parentSku, - product_type: 'configurable', - product_option: { - extension_attributes: { - configurable_item_options: options - } - } - }); - } + const cartItem = toRESTCartItem(cartId, payload); - const cartItem = await request( - `/rest/V1/guest-carts/${guestCartId}/items`, - { - method: 'POST', - body: JSON.stringify({ - cartItem: itemPayload - }) - } - ); + const { isSignedIn } = user; + const guestCartEndpoint = `/rest/V1/guest-carts/${cartId}/items`; + const signedInCartEndpoint = '/rest/V1/carts/mine/items'; + const cartEndpoint = isSignedIn + ? signedInCartEndpoint + : guestCartEndpoint; + + const response = await request(cartEndpoint, { + method: 'POST', + body: JSON.stringify({ cartItem }) + }); // 2019-02-07 Moved these dispatches to the success clause of // addItemToCart. The cart should only open on success. @@ -111,21 +106,23 @@ export const addItemToCart = (payload = {}) => { // so a successful retry will wind up here anyway. await dispatch(getCartDetails({ forceRefresh: true })); await dispatch(toggleDrawer('cart')); - dispatch(actions.addItem.receive({ cartItem, item, quantity })); + dispatch( + actions.addItem.receive({ cartItem: response, item, quantity }) + ); } catch (error) { - const { response, noGuestCartId } = error; + const { response, noCartId } = error; dispatch(actions.addItem.receive(error)); // check if the guest cart has expired - if (noGuestCartId || (response && response.status === 404)) { + if (noCartId || (response && response.status === 404)) { // if so, then delete the cached ID... // in contrast to the save, make sure storage deletion is // complete before dispatching the error--you don't want an // upstream action to try and reuse the known-bad ID. - await clearGuestCartId(); + await clearCartId(); // then create a new one - await dispatch(createGuestCart()); + await dispatch(createCart()); // then retry this operation return thunk(...arguments); } @@ -134,92 +131,73 @@ export const addItemToCart = (payload = {}) => { }; export const updateItemInCart = (payload = {}, targetItemId) => { - const { item, options, parentSku, productType, quantity } = payload; + const { item, quantity } = payload; const writingImageToCache = writeImageToCache(item); return async function thunk(dispatch, getState) { await writingImageToCache; dispatch(actions.updateItem.request(payload)); - const { user } = getState(); - if (user.isSignedIn) { - // TODO: handle authed carts - // if a user creates an account, - // then the guest cart will be transferred to their account - // causing `/guest-carts` to 400 - return; - } - try { - const { cart } = getState(); - const { guestCartId } = cart; + const { cart, user } = getState(); + const { cartId } = cart; - if (!guestCartId) { - const missingGuestCartError = new Error( - 'Missing required information: guestCartId' + if (!cartId) { + const missingCartIdError = new Error( + 'Missing required information: cartId' ); - missingGuestCartError.noGuestCartId = true; - throw missingGuestCartError; + missingCartIdError.noCartId = true; + throw missingCartIdError; } - // TODO: change to GraphQL mutation - // for now, manually transform the payload for REST - const itemPayload = { - qty: quantity, - sku: item.sku, - name: item.name, - quote_id: guestCartId - }; - - if (productType === 'ConfigurableProduct') { - Object.assign(itemPayload, { - sku: parentSku, - product_type: 'configurable', - product_option: { - extension_attributes: { - configurable_item_options: options - } - } - }); - } + const cartItem = toRESTCartItem(cartId, payload); - const cartItem = await request( - `/rest/V1/guest-carts/${guestCartId}/items/${targetItemId}`, - { - method: 'PUT', - body: JSON.stringify({ - cartItem: itemPayload - }) - } - ); + const { isSignedIn } = user; + const guestCartEndpoint = `/rest/V1/guest-carts/${cartId}/items/${targetItemId}`; + const signedInCartEndpoint = `/rest/V1/carts/mine/items/${targetItemId}`; + const cartEndpoint = isSignedIn + ? signedInCartEndpoint + : guestCartEndpoint; + + const response = await request(cartEndpoint, { + method: 'PUT', + body: JSON.stringify({ cartItem }) + }); - dispatch(actions.updateItem.receive({ cartItem, item, quantity })); + dispatch( + actions.updateItem.receive({ + cartItem: response, + item, + quantity + }) + ); } catch (error) { - const { response, noGuestCartId } = error; + const { response, noCartId } = error; dispatch(actions.updateItem.receive(error)); // check if the guest cart has expired - if (noGuestCartId || (response && response.status === 404)) { + if (noCartId || (response && response.status === 404)) { // if so, then delete the cached ID... // in contrast to the save, make sure storage deletion is // complete before dispatching the error--you don't want an // upstream action to try and reuse the known-bad ID. - await clearGuestCartId(); + await clearCartId(); // then create a new one - await dispatch(createGuestCart()); + await dispatch(createCart()); // then retry this operation return thunk(...arguments); } } - await Promise.all([ - dispatch(toggleDrawer('cart')), - dispatch(getCartDetails({ forceRefresh: true })) - ]); - // This is done here as a dispatch instead of as part of - // updateItem.receive() so that the cart will close the options - // drawer only after it's finished updating + // await Promise.all([ + // dispatch(getCartDetails({ forceRefresh: true })), + // dispatch(toggleDrawer('cart')) + // ]); + + await dispatch(getCartDetails({ forceRefresh: true })); + + // Close the options drawer only after the cart is finished updating. dispatch(closeOptionsDrawer()); }; }; @@ -231,48 +209,61 @@ export const removeItemFromCart = payload => { dispatch(actions.removeItem.request(payload)); try { - const { cart } = getState(); - const { guestCartId } = cart; - const cartItemCount = cart.details ? cart.details.items_count : 0; + const { cart, user } = getState(); + const { cartId } = cart; - if (!guestCartId) { - const missingGuestCartError = new Error( - 'Missing required information: guestCartId' + if (!cartId) { + const missingCartIdError = new Error( + 'Missing required information: cartId' ); - missingGuestCartError.noGuestCartId = true; - throw missingGuestCartError; + missingCartIdError.noCartId = true; + throw missingCartIdError; } - const cartItem = await request( - `/rest/V1/guest-carts/${guestCartId}/items/${item.item_id}`, - { - method: 'DELETE' - } - ); + const { isSignedIn } = user; + const guestCartEndpoint = `/rest/V1/guest-carts/${cartId}/items/${ + item.item_id + }`; + const signedInCartEndpoint = `/rest/V1/carts/mine/items/${ + item.item_id + }`; + const cartEndpoint = isSignedIn + ? signedInCartEndpoint + : guestCartEndpoint; + + const response = await request(cartEndpoint, { + method: 'DELETE' + }); + // When removing the last item in the cart, perform a reset // to prevent a bug where the next item added to the cart has // a price of 0 - if (cartItemCount == 1) { - await clearGuestCartId(); + const cartItemCount = cart.details ? cart.details.items_count : 0; + if (cartItemCount === 1) { + await clearCartId(); } dispatch( - actions.removeItem.receive({ cartItem, item, cartItemCount }) + actions.removeItem.receive({ + cartItem: response, + item, + cartItemCount + }) ); } catch (error) { - const { response, noGuestCartId } = error; + const { response, noCartId } = error; dispatch(actions.removeItem.receive(error)); // check if the guest cart has expired - if (noGuestCartId || (response && response.status === 404)) { + if (noCartId || (response && response.status === 404)) { // if so, then delete the cached ID... // in contrast to the save, make sure storage deletion is // complete before dispatching the error--you don't want an // upstream action to try and reuse the known-bad ID. - await clearGuestCartId(); + await clearCartId(); // then create a new one - await dispatch(createGuestCart()); + await dispatch(createCart()); // then retry this operation return thunk(...arguments); } @@ -297,26 +288,19 @@ export const getCartDetails = (payload = {}) => { return async function thunk(dispatch, getState) { const { cart, user } = getState(); - const { guestCartId } = cart; + const { cartId } = cart; + const { isSignedIn } = user; - if (user.isSignedIn) { - // TODO: handle authed carts - // if a user creates an account, - // then the guest cart will be transferred to their account - // causing `/guest-carts` to 400 - return; - } - - // if there isn't a guest cart, create one + // if there isn't a cart, create one // then retry this operation - if (!guestCartId) { - await dispatch(createGuestCart()); + if (!cartId) { + await dispatch(createCart()); return thunk(...arguments); } // Once we have the cart id indicate that we are starting to make // async requests for the details. - dispatch(actions.getDetails.request(guestCartId)); + dispatch(actions.getDetails.request(cartId)); try { const [ @@ -327,17 +311,20 @@ export const getCartDetails = (payload = {}) => { ] = await Promise.all([ retrieveImageCache(), fetchCartPart({ - guestCartId, - forceRefresh + cartId, + forceRefresh, + isSignedIn }), fetchCartPart({ - guestCartId, + cartId, forceRefresh, + isSignedIn, subResource: 'payment-methods' }), fetchCartPart({ - guestCartId, + cartId, forceRefresh, + isSignedIn, subResource: 'totals' }) ]); @@ -379,9 +366,9 @@ export const getCartDetails = (payload = {}) => { // in contrast to the save, make sure storage deletion is // complete before dispatching the error--you don't want an // upstream action to try and reuse the known-bad ID. - await clearGuestCartId(); + await clearCartId(); // then create a new one - await dispatch(createGuestCart()); + await dispatch(createCart()); // then retry this operation return thunk(...arguments); } @@ -410,53 +397,59 @@ export const toggleCart = () => ]); }; -export const removeGuestCart = () => - async function thunk(...args) { - const [dispatch, getState] = args; - const { cart } = getState(); - // ensure state slices are present - if (!cart) { - return; - } - if (cart['guestCartId']) { - dispatch({ - type: 'REMOVE_GUEST_CART' - }); - } +export const removeCart = () => + async function thunk(dispatch) { + // Clear the cartId from local storage. + await clearCartId(); + + // Clear the cart info from the redux store. + await dispatch(actions.reset()); }; /* helpers */ -async function fetchCartPart({ guestCartId, forceRefresh, subResource = '' }) { - return request(`/rest/V1/guest-carts/${guestCartId}/${subResource}`, { - cache: forceRefresh ? 'reload' : 'default' - }); +async function fetchCartPart({ + cartId, + forceRefresh, + isSignedIn, + subResource = '' +}) { + const signedInEndpoint = `/rest/V1/carts/mine/${subResource}`; + const guestEndpoint = `/rest/V1/guest-carts/${cartId}/${subResource}`; + const endpoint = isSignedIn ? signedInEndpoint : guestEndpoint; + + const cache = forceRefresh ? 'reload' : 'default'; + + return request(endpoint, { cache }); } -export async function getGuestCartId(dispatch, getState) { +export async function getCartId(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()); + + // create a cart if one hasn't been created yet + if (!cart.cartId) { + await dispatch(createCart()); } + // retrieve app state again - return getState().cart.guestCartId; + return getState().cart.cartId; } -export async function retrieveGuestCartId() { - return storage.getItem('guestCartId'); +export async function retrieveCartId() { + return storage.getItem('cartId'); } -export async function saveGuestCartId(id) { - return storage.setItem('guestCartId', id); +export async function saveCartId(id) { + return storage.setItem('cartId', id); } -export async function clearGuestCartId() { - return storage.removeItem('guestCartId'); +export async function clearCartId() { + return storage.removeItem('cartId'); } async function retrieveImageCache() { @@ -467,7 +460,36 @@ async function saveImageCache(cache) { return storage.setItem('imagesBySku', cache); } -async function writeImageToCache(item = {}) { +/** + * Transforms an item payload to a shape that the REST endpoints expect. + * When GraphQL comes online we can drop this. + */ +function toRESTCartItem(cartId, payload) { + const { item, productType, quantity } = payload; + + const cartItem = { + qty: quantity, + sku: item.sku, + name: item.name, + quote_id: cartId + }; + + if (productType === 'ConfigurableProduct') { + const { options, parentSku } = payload; + + cartItem.sku = parentSku; + cartItem.product_type = 'configurable'; + cartItem.product_option = { + extension_attributes: { + configurable_item_options: options + } + }; + } + + return cartItem; +} + +export async function writeImageToCache(item = {}) { const { media_gallery_entries: media, sku } = item; if (sku) { diff --git a/packages/venia-concept/src/actions/checkout/__tests__/asyncActions.spec.js b/packages/venia-concept/src/actions/checkout/__tests__/asyncActions.spec.js index 89ccb5a7b2..afdfd88c91 100644 --- a/packages/venia-concept/src/actions/checkout/__tests__/asyncActions.spec.js +++ b/packages/venia-concept/src/actions/checkout/__tests__/asyncActions.spec.js @@ -53,8 +53,9 @@ const paymentMethod = { beforeAll(() => { getState.mockImplementation(() => ({ - cart: { guestCartId: 'GUEST_CART_ID' }, - directory: { countries } + cart: { cartId: 'CART_ID' }, + directory: { countries }, + user: { isSignedIn: false } })); }); @@ -153,7 +154,7 @@ describe('getShippingMethods', () => { expect(dispatch).toHaveBeenNthCalledWith( 1, - actions.getShippingMethods.request('GUEST_CART_ID') + actions.getShippingMethods.request('CART_ID') ); expect(dispatch).toHaveBeenNthCalledWith( 2, @@ -171,7 +172,7 @@ describe('getShippingMethods', () => { expect(dispatch).toHaveBeenNthCalledWith( 1, - actions.getShippingMethods.request('GUEST_CART_ID') + actions.getShippingMethods.request('CART_ID') ); expect(dispatch).toHaveBeenNthCalledWith( 2, @@ -179,6 +180,10 @@ describe('getShippingMethods', () => { ); expect(dispatch).toHaveBeenCalledTimes(2); }); + + test('its thunk uses the proper endpoint when the user is signed in', async () => { + // TODO + }); }); describe('submitPaymentMethodAndBillingAddress', () => { @@ -279,14 +284,14 @@ describe('submitBillingAddress', () => { }); }); - test('submitBillingAddress thunk throws if there is no guest cart', async () => { + test('submitBillingAddress thunk throws if there is no cart', async () => { getState.mockImplementationOnce(() => ({ cart: {}, directory: { countries } })); await expect( submitBillingAddress(sameAsShippingPayload)(...thunkArgs) - ).rejects.toThrow('guestCartId'); + ).rejects.toThrow('cartId'); }); }); @@ -323,14 +328,14 @@ describe('submitShippingAddress', () => { expect(mockSetItem).toHaveBeenCalledWith('shipping_address', address); }); - test('submitShippingAddress thunk throws if there is no guest cart', async () => { + test('submitShippingAddress thunk throws if there is no cart', async () => { getState.mockImplementationOnce(() => ({ cart: {}, directory: { countries } })); await expect( submitShippingAddress(payload)(...thunkArgs) - ).rejects.toThrow('guestCartId'); + ).rejects.toThrow('cartId'); }); }); @@ -370,14 +375,14 @@ describe('submitPaymentMethod', () => { ); }); - test('submitPaymentMethod thunk throws if there is no guest cart', async () => { + test('submitPaymentMethod thunk throws if there is no cart', async () => { getState.mockImplementationOnce(() => ({ cart: {} })); await expect( submitPaymentMethod(payload)(...thunkArgs) - ).rejects.toThrow('guestCartId'); + ).rejects.toThrow('cartId'); }); }); @@ -426,14 +431,14 @@ describe('submitShippingMethod', () => { expect(mockSetItem).toHaveBeenCalledTimes(1); }); - test('submitShippingMethod thunk throws if there is no guest cart', async () => { + test('submitShippingMethod thunk throws if there is no cart', async () => { getState.mockImplementationOnce(() => ({ cart: {} })); await expect( submitShippingMethod(payload)(...thunkArgs) - ).rejects.toThrow('guestCartId'); + ).rejects.toThrow('cartId'); }); }); @@ -487,9 +492,10 @@ describe('submitOrder', () => { details: { billing_address: address }, - guestCartId: 'GUEST_CART_ID' + cartId: 'CART_ID' }, - directory: { countries } + directory: { countries }, + user: { isSignedIn: false } }; getState @@ -522,7 +528,7 @@ describe('submitOrder', () => { expect(dispatch).toHaveBeenCalledTimes(3); expect(mockRemoveItem).toHaveBeenNthCalledWith(1, 'billing_address'); - expect(mockRemoveItem).toHaveBeenNthCalledWith(2, 'guestCartId'); + expect(mockRemoveItem).toHaveBeenNthCalledWith(2, 'cartId'); expect(mockRemoveItem).toHaveBeenNthCalledWith(3, 'paymentMethod'); expect(mockRemoveItem).toHaveBeenNthCalledWith(4, 'shipping_address'); expect(mockRemoveItem).toHaveBeenNthCalledWith(5, 'shippingMethod'); @@ -535,9 +541,10 @@ describe('submitOrder', () => { details: { billing_address: address }, - guestCartId: 'GUEST_CART_ID' + cartId: 'CART_ID' }, - directory: { countries } + directory: { countries }, + user: { isSignedIn: false } }; getState @@ -572,7 +579,7 @@ describe('submitOrder', () => { expect(dispatch).toHaveBeenCalledTimes(3); expect(mockRemoveItem).toHaveBeenNthCalledWith(1, 'billing_address'); - expect(mockRemoveItem).toHaveBeenNthCalledWith(2, 'guestCartId'); + expect(mockRemoveItem).toHaveBeenNthCalledWith(2, 'cartId'); expect(mockRemoveItem).toHaveBeenNthCalledWith(3, 'paymentMethod'); expect(mockRemoveItem).toHaveBeenNthCalledWith(4, 'shipping_address'); expect(mockRemoveItem).toHaveBeenNthCalledWith(5, 'shippingMethod'); @@ -599,14 +606,16 @@ describe('submitOrder', () => { expect(dispatch).toHaveBeenCalledTimes(2); }); - test('submitOrder thunk throws if there is no guest cart', async () => { + test('submitOrder thunk throws if there is no cart', async () => { getState.mockImplementationOnce(() => ({ cart: {} })); - await expect(submitOrder()(...thunkArgs)).rejects.toThrow( - 'guestCartId' - ); + await expect(submitOrder()(...thunkArgs)).rejects.toThrow('cartId'); + }); + + test('its thunk uses the proper endpoints when the user is signed in', async () => { + // TODO }); }); diff --git a/packages/venia-concept/src/actions/checkout/asyncActions.js b/packages/venia-concept/src/actions/checkout/asyncActions.js index b8ce04aff2..be064fdd9d 100644 --- a/packages/venia-concept/src/actions/checkout/asyncActions.js +++ b/packages/venia-concept/src/actions/checkout/asyncActions.js @@ -1,7 +1,7 @@ import { RestApi, Util } from '@magento/peregrine'; import { closeDrawer } from 'src/actions/app'; -import { clearGuestCartId, createGuestCart } from 'src/actions/cart'; +import { clearCartId, createCart } from 'src/actions/cart'; import { getCountries } from 'src/actions/directory'; import { getOrderInformation } from 'src/selectors/cart'; import { getAccountInformation } from 'src/selectors/checkoutReceipt'; @@ -27,7 +27,7 @@ export const cancelCheckout = () => export const resetCheckout = () => async function thunk(dispatch) { await dispatch(closeDrawer()); - await dispatch(createGuestCart()); + await dispatch(createCart()); dispatch(actions.reset()); }; @@ -38,31 +38,33 @@ export const editOrder = section => export const getShippingMethods = () => { return async function thunk(dispatch, getState) { - const { cart } = getState(); - const { guestCartId } = cart; + const { cart, user } = getState(); + const { cartId } = cart; try { // if there isn't a guest cart, create one // then retry this operation - if (!guestCartId) { - await dispatch(createGuestCart()); + if (!cartId) { + await dispatch(createCart()); return thunk(...arguments); } - dispatch(actions.getShippingMethods.request(guestCartId)); - - const response = await request( - `/rest/V1/guest-carts/${guestCartId}/estimate-shipping-methods`, - { - method: 'POST', - body: JSON.stringify({ - address: { - country_id: 'US', - postcode: null - } - }) - } - ); + dispatch(actions.getShippingMethods.request(cartId)); + + const guestEndpoint = `/rest/V1/guest-carts/${cartId}/estimate-shipping-methods`; + const authedEndpoint = + '/rest/V1/carts/mine/estimate-shipping-methods'; + const endpoint = user.isSignedIn ? authedEndpoint : guestEndpoint; + + const response = await request(endpoint, { + method: 'POST', + body: JSON.stringify({ + address: { + country_id: 'US', + postcode: null + } + }) + }); dispatch(actions.getShippingMethods.receive(response)); } catch (error) { @@ -73,8 +75,8 @@ export const getShippingMethods = () => { // check if the guest cart has expired if (response && response.status === 404) { // if so, clear it out, get a new one, and retry. - await clearGuestCartId(); - await dispatch(createGuestCart()); + await clearCartId(); + await dispatch(createCart()); return thunk(...arguments); } } @@ -99,9 +101,9 @@ export const submitBillingAddress = payload => const { cart, directory } = getState(); - const { guestCartId } = cart; - if (!guestCartId) { - throw new Error('Missing required information: guestCartId'); + const { cartId } = cart; + if (!cartId) { + throw new Error('Missing required information: cartId'); } let desiredBillingAddress = payload; @@ -125,9 +127,9 @@ export const submitPaymentMethod = payload => const { cart } = getState(); - const { guestCartId } = cart; - if (!guestCartId) { - throw new Error('Missing required information: guestCartId'); + const { cartId } = cart; + if (!cartId) { + throw new Error('Missing required information: cartId'); } await savePaymentMethod(payload); @@ -140,9 +142,9 @@ export const submitShippingAddress = payload => const { cart, directory } = getState(); - const { guestCartId } = cart; - if (!guestCartId) { - throw new Error('Missing required information: guestCartId'); + const { cartId } = cart; + if (!cartId) { + throw new Error('Missing required information: cartId'); } const { countries } = directory; @@ -167,9 +169,9 @@ export const submitShippingMethod = payload => dispatch(actions.shippingMethod.submit(payload)); const { cart } = getState(); - const { guestCartId } = cart; - if (!guestCartId) { - throw new Error('Missing required information: guestCartId'); + const { cartId } = cart; + if (!cartId) { + throw new Error('Missing required information: cartId'); } const desiredShippingMethod = payload.formValues.shippingMethod; @@ -181,10 +183,10 @@ export const submitOrder = () => async function thunk(dispatch, getState) { dispatch(actions.order.submit()); - const { cart } = getState(); - const { guestCartId } = cart; - if (!guestCartId) { - throw new Error('Missing required information: guestCartId'); + const { cart, user } = getState(); + const { cartId } = cart; + if (!cartId) { + throw new Error('Missing required information: cartId'); } let billing_address = await retrieveBillingAddress(); @@ -208,40 +210,48 @@ export const submitOrder = () => try { // POST to shipping-information to submit the shipping address and shipping method. - await request( - `/rest/V1/guest-carts/${guestCartId}/shipping-information`, - { - method: 'POST', - body: JSON.stringify({ - addressInformation: { - billing_address, - shipping_address, - shipping_carrier_code: shipping_method.carrier_code, - shipping_method_code: shipping_method.method_code - } - }) - } - ); + const guestShippingEndpoint = `/rest/V1/guest-carts/${cartId}/shipping-information`; + const authedShippingEndpoint = + '/rest/V1/carts/mine/shipping-information'; + const shippingEndpoint = user.isSignedIn + ? authedShippingEndpoint + : guestShippingEndpoint; + + await request(shippingEndpoint, { + method: 'POST', + body: JSON.stringify({ + addressInformation: { + billing_address, + shipping_address, + shipping_carrier_code: shipping_method.carrier_code, + shipping_method_code: shipping_method.method_code + } + }) + }); // POST to payment-information to submit the payment details and billing address, // Note: this endpoint also actually submits the order. - const response = await request( - `/rest/V1/guest-carts/${guestCartId}/payment-information`, - { - method: 'POST', - body: JSON.stringify({ - billingAddress: billing_address, - cartId: guestCartId, - email: shipping_address.email, - paymentMethod: { - additional_data: { - payment_method_nonce: paymentMethod.data.nonce - }, - method: paymentMethod.code - } - }) - } - ); + const guestPaymentEndpoint = `/rest/V1/guest-carts/${cartId}/payment-information`; + const authedPaymentEndpoint = + '/rest/V1/carts/mine/payment-information'; + const paymentEndpoint = user.isSignedIn + ? authedPaymentEndpoint + : guestPaymentEndpoint; + + const response = await request(paymentEndpoint, { + method: 'POST', + body: JSON.stringify({ + billingAddress: billing_address, + cartId: cartId, + email: shipping_address.email, + paymentMethod: { + additional_data: { + payment_method_nonce: paymentMethod.data.nonce + }, + method: paymentMethod.code + } + }) + }); dispatch( checkoutReceiptActions.setOrderInformation( @@ -251,7 +261,7 @@ export const submitOrder = () => // Clear out everything we've saved about this cart from local storage. await clearBillingAddress(); - await clearGuestCartId(); + await clearCartId(); await clearPaymentMethod(); await clearShippingAddress(); await clearShippingMethod(); diff --git a/packages/venia-concept/src/actions/user/__tests__/asyncActions.spec.js b/packages/venia-concept/src/actions/user/__tests__/asyncActions.spec.js index 2d20c1b018..a0bda8f0a5 100644 --- a/packages/venia-concept/src/actions/user/__tests__/asyncActions.spec.js +++ b/packages/venia-concept/src/actions/user/__tests__/asyncActions.spec.js @@ -9,11 +9,11 @@ import { import actions from '../actions'; import { signIn, + signOut, getUserDetails, completePasswordReset, createAccount, createNewUserRequest, - assignGuestCartToCustomer, resetPassword } from '../asyncActions'; @@ -53,10 +53,6 @@ afterAll(() => { getState.mockRestore(); }); -test('signIn() returns a thunk', () => { - expect(signIn()).toBeInstanceOf(Function); -}); - test('getUserDetails() returns a thunk', () => { expect(getUserDetails()).toBeInstanceOf(Function); }); @@ -69,54 +65,6 @@ test('createNewUserRequest() returns a thunk', () => { expect(createNewUserRequest()).toBeInstanceOf(Function); }); -test('assignGuestCartToCustomer() returns a thunk', () => { - expect(assignGuestCartToCustomer()).toBeInstanceOf(Function); -}); - -test('signIn thunk dispatches resetSignInError', async () => { - await signIn(credentials)(...thunkArgs); - - expect(dispatch).toHaveBeenCalledWith(actions.resetSignInError.request()); -}); - -test('signIn thunk dispatches signIn', async () => { - await signIn(credentials)(...thunkArgs); - - expect(dispatch).toHaveBeenCalledWith(actions.signIn.receive()); -}); - -test('signIn thunk dispatches signInError on failed request', async () => { - const error = new Error('ERROR'); - request.mockRejectedValueOnce(error); - await signIn(credentials)(...thunkArgs); - expect(dispatch).toHaveBeenNthCalledWith( - 2, - actions.signInError.receive(error) - ); -}); - -test('signIn thunk makes request to get customer token with credentials', async () => { - await signIn(credentials)(...thunkArgs); - - const firstRequest = request.mock.calls[0]; - - expect(firstRequest[0]).toBe('/rest/V1/integration/customer/token'); - expect(firstRequest[1]).toHaveProperty('method', 'POST'); - expect(JSON.parse(firstRequest[1].body)).toEqual({ - username: `${credentials.username}`, - password: `${credentials.password}` - }); -}); - -test('signIn thunk makes request to get customer details after sign in', async () => { - await signIn(credentials)(...thunkArgs); - - const secondRequest = request.mock.calls[1]; - - expect(secondRequest[0]).toBe('/rest/V1/customers/me'); - expect(secondRequest[1]).toHaveProperty('method', 'GET'); -}); - test('getUserDetails thunk makes request to get customer details if user is signed in', async () => { getState.mockImplementationOnce(() => ({ user: { isSignedIn: false } @@ -175,12 +123,6 @@ test('createNewUserRequest thunk dispatches signIn', async () => { expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); }); -test('createNewUserRequest thunk dispatches assignGuestCartToCustomer', async () => { - await createNewUserRequest(accountInfo)(...thunkArgs); - - expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); -}); - test('createNewUserRequest thunk dispatches createAccountError on invalid account info', async () => { const error = new TypeError('ERROR'); request.mockRejectedValueOnce(error); @@ -195,25 +137,95 @@ test('createNewUserRequest thunk dispatches createAccountError on invalid accoun ); }); -test('assignGuestCartToCustomer thunk retrieves guest cart with guestCartId', async () => { - getState.mockImplementationOnce(() => ({ - user: { isSignedIn: false, id: 'ID', storeId: 'STORE_ID' } - })); +describe('signIn', () => { + test('it returns a thunk', () => { + expect(signIn()).toBeInstanceOf(Function); + }); + + test('its thunk dispatches actions on success', async () => { + await signIn(credentials)(...thunkArgs); + + expect(dispatch).toHaveBeenCalledTimes(4); + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.resetSignInError.request() + ); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.signIn.receive()); + // removeCart + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + // getCartDetails + expect(dispatch).toHaveBeenNthCalledWith(4, expect.any(Function)); + }); - const storedGuestCartId = 'STORED_GUEST_CART_ID'; - mockGetItem.mockImplementationOnce(() => storedGuestCartId); + test('its thunk dispatches actions on error', async () => { + // Test setup. + const error = new Error('ERROR'); + request.mockRejectedValueOnce(error); - await assignGuestCartToCustomer({})(...thunkArgs); + // Execute the function. + await signIn(credentials)(...thunkArgs); - const firstRequest = request.mock.calls[0]; - expect(mockGetItem).toHaveBeenCalledWith('guestCartId'); - expect(firstRequest[0]).toBe(`/rest/V1/guest-carts/STORED_GUEST_CART_ID`); - expect(firstRequest[1]).toHaveProperty('method', 'PUT'); + // Make assertions. + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.resetSignInError.request() + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.signInError.receive(error) + ); + }); + + test('its thunk makes requests on success', async () => { + await signIn(credentials)(...thunkArgs); + + expect(request).toHaveBeenCalledTimes(2); + + const tokenRequest = request.mock.calls[0]; + const tokenEndpoint = tokenRequest[0]; + const tokenParams = tokenRequest[1]; + + expect(tokenEndpoint).toBe('/rest/V1/integration/customer/token'); + expect(tokenParams).toHaveProperty('method', 'POST'); + expect(tokenParams).toHaveProperty('body'); + + const tokenBody = JSON.parse(tokenParams.body); + expect(tokenBody).toEqual({ + username: `${credentials.username}`, + password: `${credentials.password}` + }); + + const detailsRequest = request.mock.calls[1]; + const detailsEndpoint = detailsRequest[0]; + const detailsParams = detailsRequest[1]; + + expect(detailsEndpoint).toBe('/rest/V1/customers/me'); + expect(detailsParams).toHaveProperty('method', 'GET'); + }); }); -test('assignGuestCartToCustomer thunk dispatches removeGuestCart()', async () => { - await assignGuestCartToCustomer({})(...thunkArgs); - expect(dispatch).toHaveBeenNthCalledWith(1, expect.any(Function)); +describe('signOut', () => { + const mockParam = { + history: { + go: jest.fn() + } + }; + + test('it returns a thunk', () => { + expect(signOut(mockParam)).toBeInstanceOf(Function); + }); + + test('its thunk dispatches actions on success', async () => { + await signOut(mockParam)(...thunkArgs); + + expect(dispatch).toHaveBeenCalledTimes(3); + expect(dispatch).toHaveBeenNthCalledWith(1, actions.signIn.reset()); + const removeCart = expect.any(Function); + expect(dispatch).toHaveBeenNthCalledWith(2, removeCart); + const getCartDetails = expect.any(Function); + expect(dispatch).toHaveBeenNthCalledWith(3, getCartDetails); + }); }); describe('resetPassword', () => { diff --git a/packages/venia-concept/src/actions/user/asyncActions.js b/packages/venia-concept/src/actions/user/asyncActions.js index 631759877d..ef221b3fe4 100755 --- a/packages/venia-concept/src/actions/user/asyncActions.js +++ b/packages/venia-concept/src/actions/user/asyncActions.js @@ -1,12 +1,13 @@ import { RestApi } from '@magento/peregrine'; import { Util } from '@magento/peregrine'; -import { removeGuestCart } from 'src/actions/cart'; import { refresh } from 'src/util/router-helpers'; +import { getCartDetails, removeCart } from 'src/actions/cart'; + +import actions from './actions'; const { request } = RestApi.Magento2; const { BrowserPersistence } = Util; - -import actions from './actions'; +const storage = new BrowserPersistence(); export const signIn = credentials => async function thunk(...args) { @@ -16,8 +17,6 @@ export const signIn = credentials => try { const body = { - // username: 'roni_cost@example.com', - // password: 'roni_cost3@example.com' username: credentials.username, password: credentials.password }; @@ -36,17 +35,28 @@ export const signIn = credentials => method: 'GET' }); - dispatch(actions.signIn.receive(userDetails)); + await dispatch(actions.signIn.receive(userDetails)); + + // Now that we're signed in, forget the old (guest) cart + // and fetch this customer's cart. + await dispatch(removeCart()); + dispatch(getCartDetails({ forceRefresh: true })); } catch (error) { dispatch(actions.signInError.receive(error)); } }; -export const signOut = ({ history }) => dispatch => { - setToken(null); +export const signOut = ({ history }) => async dispatch => { + // Sign the user out in local storage and Redux. + await clearToken(); + await dispatch(actions.signIn.reset()); - dispatch(actions.signIn.reset()); + // Now that we're signed out, forget the old (customer) cart + // and fetch a new guest cart. + await dispatch(removeCart()); + dispatch(getCartDetails({ forceRefresh: true })); + // Finally, go back to the first page of the browser history. refresh({ history }); }; @@ -54,8 +64,10 @@ export const getUserDetails = () => async function thunk(...args) { const [dispatch, getState] = args; const { user } = getState(); + if (user.isSignedIn) { dispatch(actions.resetSignInError.request()); + try { const userDetails = await request('/rest/V1/customers/me', { method: 'GET' @@ -78,13 +90,13 @@ export const createNewUserRequest = accountInfo => method: 'POST', body: JSON.stringify(accountInfo) }); + await dispatch( signIn({ username: accountInfo.customer.email, password: accountInfo.password }) ); - dispatch(assignGuestCartToCustomer()); } catch (error) { dispatch(actions.createAccountError.receive(error)); @@ -106,30 +118,6 @@ export const createAccount = accountInfo => async dispatch => { } catch (e) {} }; -export const assignGuestCartToCustomer = () => - async function thunk(...args) { - const [dispatch, getState] = args; - const { user } = getState(); - - try { - const storage = new BrowserPersistence(); - const guestCartId = storage.getItem('guestCartId'); - const payload = { - customerId: user.id, - storeId: user.store_id - }; - // TODO: Check if guestCartId exists - await request(`/rest/V1/guest-carts/${guestCartId}`, { - method: 'PUT', - body: JSON.stringify(payload) - }); - dispatch(removeGuestCart()); - } catch (error) { - // TODO: Handle error - console.log(error); - } - }; - export const resetPassword = ({ email }) => async function thunk(...args) { const [dispatch] = args; @@ -147,7 +135,10 @@ export const completePasswordReset = email => async dispatch => dispatch(actions.completePasswordReset(email)); async function setToken(token) { - const storage = new BrowserPersistence(); // TODO: Get correct token expire time from API - storage.setItem('signin_token', token, 3600); + return storage.setItem('signin_token', token, 3600); +} + +async function clearToken() { + return storage.removeItem('signin_token'); } diff --git a/packages/venia-concept/src/components/Checkout/Receipt/__tests__/receipt.spec.js b/packages/venia-concept/src/components/Checkout/Receipt/__tests__/receipt.spec.js index b03cc19cf9..dcfda7d171 100644 --- a/packages/venia-concept/src/components/Checkout/Receipt/__tests__/receipt.spec.js +++ b/packages/venia-concept/src/components/Checkout/Receipt/__tests__/receipt.spec.js @@ -13,12 +13,15 @@ const classes = { jest.mock('src/classify'); +const userProp = { isSignedIn: false }; + test('renders a Receipt component correctly', () => { const props = { continueShopping: jest.fn(), order: { id: '123' }, createAccount: jest.fn(), - reset: jest.fn() + reset: jest.fn(), + user: userProp }; const component = testRenderer.create(); @@ -33,6 +36,7 @@ test('calls `handleContinueShopping` when `Continue Shopping` button is pressed' ).dive(); wrapper @@ -46,7 +50,11 @@ test('calls `handleCreateAccount` when `Create an Account` button is pressed', ( const handleCreateAccountMock = jest.fn(); const wrapper = shallow( - + ).dive(); wrapper @@ -61,7 +69,7 @@ test('calls `reset` when component was unmounted', () => { const resetHandlerMock = jest.fn(); const wrapper = shallow( - + ).dive(); wrapper.unmount(); diff --git a/packages/venia-concept/src/components/Checkout/Receipt/receipt.js b/packages/venia-concept/src/components/Checkout/Receipt/receipt.js index b3a3a10328..115bb85218 100644 --- a/packages/venia-concept/src/components/Checkout/Receipt/receipt.js +++ b/packages/venia-concept/src/components/Checkout/Receipt/receipt.js @@ -1,5 +1,5 @@ -import React, { Component } from 'react'; -import { func, shape, string } from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { bool, func, shape, string } from 'prop-types'; import classify from 'src/classify'; import Button from 'src/components/Button'; import defaultClasses from './receipt.css'; @@ -19,7 +19,10 @@ class Receipt extends Component { id: string }).isRequired, createAccount: func.isRequired, - reset: func.isRequired + reset: func.isRequired, + user: shape({ + isSignedIn: bool + }) }; static defaultProps = { @@ -44,7 +47,8 @@ class Receipt extends Component { render() { const { classes, - order: { id } + order: { id }, + user } = this.props; return ( @@ -66,17 +70,21 @@ class Receipt extends Component { > Continue Shopping -
- Track order status and earn rewards for your purchase by - creating and account. -
- + {!user.isSignedIn && ( + +
+ Track order status and earn rewards for your + purchase by creating and account. +
+ +
+ )} ); diff --git a/packages/venia-concept/src/components/Checkout/__tests__/form.spec.js b/packages/venia-concept/src/components/Checkout/__tests__/form.spec.js index b292e1f2fe..bbc0b9f640 100644 --- a/packages/venia-concept/src/components/Checkout/__tests__/form.spec.js +++ b/packages/venia-concept/src/components/Checkout/__tests__/form.spec.js @@ -24,7 +24,7 @@ const defaultProps = { cancelCheckout: mockCancelCheckout, cart: { details: {}, - guestCartId: '123', + cartId: '123', totals: {} }, directory: { diff --git a/packages/venia-concept/src/components/Checkout/flow.js b/packages/venia-concept/src/components/Checkout/flow.js index 2eae08b38e..069c558150 100644 --- a/packages/venia-concept/src/components/Checkout/flow.js +++ b/packages/venia-concept/src/components/Checkout/flow.js @@ -20,7 +20,7 @@ class Flow extends Component { }).isRequired, cart: shape({ details: object, - guestCartId: string, + cartId: string, totals: object }), checkout: shape({ @@ -86,7 +86,10 @@ class Flow extends Component { nonce: string }), shippingMethod: string, - shippingTitle: string + shippingTitle: string, + user: shape({ + isSignedIn: bool + }) }; get child() { @@ -99,7 +102,8 @@ class Flow extends Component { hasShippingMethod, directory, isCartReady, - isCheckoutReady + isCheckoutReady, + user } = this.props; const { @@ -165,7 +169,11 @@ class Flow extends Component { return
; } case 'receipt': { - return ; + const stepProps = { + user + }; + + return ; } default: { return null; diff --git a/packages/venia-concept/src/components/Checkout/form.js b/packages/venia-concept/src/components/Checkout/form.js index 1b92db422f..64d2c5efac 100644 --- a/packages/venia-concept/src/components/Checkout/form.js +++ b/packages/venia-concept/src/components/Checkout/form.js @@ -31,7 +31,7 @@ class Form extends Component { cancelCheckout: func.isRequired, cart: shape({ details: object, - guestCartId: string, + cartId: string, totals: object }).isRequired, directory: shape({ @@ -183,7 +183,7 @@ class Form extends Component {

{cart.details.items_qty} Items diff --git a/packages/venia-concept/src/components/Checkout/wrapper.js b/packages/venia-concept/src/components/Checkout/wrapper.js index a991dfa940..f526255053 100644 --- a/packages/venia-concept/src/components/Checkout/wrapper.js +++ b/packages/venia-concept/src/components/Checkout/wrapper.js @@ -35,7 +35,7 @@ export class CheckoutWrapper extends Component { cancelCheckout: func, cart: shape({ details: object.isRequired, - guestCartId: string, + cartId: string, totals: object }).isRequired, checkout: shape({ @@ -89,7 +89,10 @@ export class CheckoutWrapper extends Component { submitShippingAddress: func, submitOrder: func, submitPaymentMethodAndBillingAddress: func, - submitShippingMethod: func + submitShippingMethod: func, + user: shape({ + isSignedIn: bool + }) }; render() { @@ -104,7 +107,8 @@ export class CheckoutWrapper extends Component { submitShippingAddress, submitOrder, submitPaymentMethodAndBillingAddress, - submitShippingMethod + submitShippingMethod, + user } = this.props; // ensure state slices are present @@ -139,16 +143,24 @@ export class CheckoutWrapper extends Component { isCheckoutReady: isCheckoutReady(checkout) }; - const flowProps = { actions, cart, checkout, directory, ...miscProps }; + const flowProps = { + actions, + cart, + checkout, + directory, + user, + ...miscProps + }; return ; } } -const mapStateToProps = ({ cart, checkout, directory }) => ({ +const mapStateToProps = ({ cart, checkout, directory, user }) => ({ cart, checkout, - directory + directory, + user }); const mapDispatchToProps = { diff --git a/packages/venia-concept/src/components/MiniCart/miniCart.js b/packages/venia-concept/src/components/MiniCart/miniCart.js index 70b00b608c..c8e0954469 100644 --- a/packages/venia-concept/src/components/MiniCart/miniCart.js +++ b/packages/venia-concept/src/components/MiniCart/miniCart.js @@ -33,7 +33,7 @@ class MiniCart extends Component { cancelCheckout: func.isRequired, cart: shape({ details: object, - guestCartId: string, + cartId: string, totals: object, isLoading: bool, isOptionsDrawerOpen: bool, diff --git a/packages/venia-concept/src/components/SignIn/container.js b/packages/venia-concept/src/components/SignIn/container.js index 80819e6abf..f7a3f8d974 100644 --- a/packages/venia-concept/src/components/SignIn/container.js +++ b/packages/venia-concept/src/components/SignIn/container.js @@ -1,6 +1,6 @@ import { connect } from 'src/drivers'; import SignIn from './signIn'; -import { signIn, assignGuestCartToCustomer } from 'src/actions/user'; +import { signIn } from 'src/actions/user'; const mapStateToProps = ({ user }) => { const { signInError } = user; @@ -9,7 +9,7 @@ const mapStateToProps = ({ user }) => { }; }; -const mapDispatchToProps = { signIn, assignGuestCartToCustomer }; +const mapDispatchToProps = { signIn }; export default connect( mapStateToProps, diff --git a/packages/venia-concept/src/components/SignIn/signIn.js b/packages/venia-concept/src/components/SignIn/signIn.js index 09a07382f3..02eac89029 100644 --- a/packages/venia-concept/src/components/SignIn/signIn.js +++ b/packages/venia-concept/src/components/SignIn/signIn.js @@ -17,10 +17,9 @@ class SignIn extends Component { signInError: PropTypes.string, showCreateAccountButton: PropTypes.string }), - - signInError: PropTypes.object, + onForgotPassword: PropTypes.func.isRequired, signIn: PropTypes.func, - onForgotPassword: PropTypes.func.isRequired + signInError: PropTypes.object }; state = { diff --git a/packages/venia-concept/src/index.js b/packages/venia-concept/src/index.js index 79ca5a7d2d..f709241379 100755 --- a/packages/venia-concept/src/index.js +++ b/packages/venia-concept/src/index.js @@ -18,9 +18,8 @@ const apiBase = new URL('/graphql', location.origin).toString(); * so we add an auth implementation here and prepend it to the Apollo Link list. */ const authLink = setContext((_, { headers }) => { - // get the authentication token from local storage if it exists + // get the authentication token from local storage if it exists. const storage = new BrowserPersistence(); - // TODO: Get correct token expire time from API const token = storage.getItem('signin_token'); // return the headers to the context so httpLink can read them diff --git a/packages/venia-concept/src/reducers/__tests__/cart.spec.js b/packages/venia-concept/src/reducers/__tests__/cart.spec.js index 78a4349447..048ed27c81 100644 --- a/packages/venia-concept/src/reducers/__tests__/cart.spec.js +++ b/packages/venia-concept/src/reducers/__tests__/cart.spec.js @@ -4,10 +4,10 @@ import checkoutActions from 'src/actions/checkout'; const state = { ...initialState }; -describe('getGuestCart.receive', () => { - const actionType = actions.getGuestCart.receive; +describe('getCart.receive', () => { + const actionType = actions.getCart.receive; - test('it sets guestCartId', () => { + test('it sets cartId', () => { const action = { error: null, payload: 1, @@ -16,7 +16,7 @@ describe('getGuestCart.receive', () => { const result = reducer(state, action); - expect(result).toHaveProperty('guestCartId', 1); + expect(result).toHaveProperty('cartId', '1'); }); test('it restores initial state on error', () => { @@ -35,7 +35,7 @@ describe('getGuestCart.receive', () => { describe('getDetails.request', () => { const actionType = actions.getDetails.request; - test('it sets guestCartId and the isLoading flag', () => { + test('it sets cartId and the isLoading flag', () => { const action = { payload: 1, type: actionType @@ -43,7 +43,7 @@ describe('getDetails.request', () => { const result = reducer(state, action); - expect(result).toHaveProperty('guestCartId', 1); + expect(result).toHaveProperty('cartId', '1'); expect(result).toHaveProperty('isLoading', true); }); }); @@ -64,7 +64,7 @@ describe('getDetails.receive', () => { expect(result).toHaveProperty('other', 'stuff'); }); - test('it sets isLoading to false and guestCartId to null on error', () => { + test('it sets isLoading to false and cartId to null on error', () => { const action = { error: true, payload: new Error('unit test'), @@ -74,7 +74,7 @@ describe('getDetails.receive', () => { const result = reducer(state, action); expect(result).toHaveProperty('isLoading', false); - expect(result).toHaveProperty('guestCartId', null); + expect(result).toHaveProperty('cartId', null); }); }); diff --git a/packages/venia-concept/src/reducers/cart.js b/packages/venia-concept/src/reducers/cart.js index 468625c35f..f191697134 100644 --- a/packages/venia-concept/src/reducers/cart.js +++ b/packages/venia-concept/src/reducers/cart.js @@ -6,8 +6,8 @@ import checkoutActions from 'src/actions/checkout'; export const name = 'cart'; export const initialState = { + cartId: null, details: {}, - guestCartId: null, isLoading: false, isOptionsDrawerOpen: false, isUpdatingItem: false, @@ -18,20 +18,20 @@ export const initialState = { }; const reducerMap = { - [actions.getGuestCart.receive]: (state, { payload, error }) => { + [actions.getCart.receive]: (state, { payload, error }) => { if (error) { return initialState; } return { ...state, - guestCartId: payload + cartId: String(payload) }; }, [actions.getDetails.request]: (state, { payload }) => { return { ...state, - guestCartId: payload, + cartId: String(payload), isLoading: true }; }, @@ -39,8 +39,8 @@ const reducerMap = { if (error) { return { ...state, - isLoading: false, - guestCartId: null + cartId: null, + isLoading: false }; } @@ -108,7 +108,8 @@ const reducerMap = { }, [checkoutActions.order.accept]: () => { return initialState; - } + }, + [actions.reset]: () => initialState }; export default handleActions(reducerMap, initialState); diff --git a/pwa-devdocs/src/venia-pwa-concept/features/checkout/index.md b/pwa-devdocs/src/venia-pwa-concept/features/checkout/index.md index 0fed44cae1..b4848f0526 100644 --- a/pwa-devdocs/src/venia-pwa-concept/features/checkout/index.md +++ b/pwa-devdocs/src/venia-pwa-concept/features/checkout/index.md @@ -40,14 +40,15 @@ The following steps summarize the basic checkout experience for a Venia shopper: ## Detailed technical flow -The following sections provide the technical details for each step in the checkout flow. +The following sections provide the technical details for each step in the checkout flow for a guest customer. +Authenticated (signed in) customers follow the same steps but may call different endpoints. ### Updating the cart 1. When the shopper clicks on the **Add To Cart** button, the application passes the shopper-specified product configuration to the `addItemToCart()` function. 2. Before the `addItemToCart()` function can add the product to the cart, it first checks the local storage for an existing cart ID. - If an existing cart ID does not exist, it calls the `createGuestCart()` function. + If an existing cart ID does not exist, it calls the `createCart()` function. This function creates a POST request to the `/V1/guest-carts` REST endpoint to get a cart ID to store in the local storage. After a cart ID is found, the `addItemToCart` function uses the product information passed in by the application to update the cart. @@ -71,7 +72,7 @@ The following sections provide the technical details for each step in the checko | Filename | Importance | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -| [src/actions/cart/asyncActions.js][] | Contains asynchronous functions for cart-related actions such as `addItemToCart()` and `createGuestCart()`. | +| [src/actions/cart/asyncActions.js][] | Contains asynchronous functions for cart-related actions such as `addItemToCart()` and `createCart()`. | | [src/actions/app/asyncActions.js][] | Contains the `toggleDrawer()` function | ### Gathering payment and shipping information From 2c81c820242a69844c5b655779966655749ac06e Mon Sep 17 00:00:00 2001 From: James Calcaben Date: Tue, 23 Apr 2019 16:26:51 -0500 Subject: [PATCH 3/4] [Docs] Client side caching topic (#1152) * Create client side caching topic file and TOC entry * Add content * Add the rest of the network cache strategy and apollo caching strategy sections * Apply suggestions from code review Co-Authored-By: jcalcaben * Update pwa-devdocs/src/technologies/basic-concepts/client-side-caching/index.md Co-Authored-By: jcalcaben * Update pwa-devdocs/src/technologies/basic-concepts/client-side-caching/index.md Co-Authored-By: jcalcaben --- pwa-devdocs/src/_data/technologies.yml | 58 ++++++------ .../client-side-caching/index.md | 89 +++++++++++++++++++ 2 files changed, 119 insertions(+), 28 deletions(-) create mode 100644 pwa-devdocs/src/technologies/basic-concepts/client-side-caching/index.md diff --git a/pwa-devdocs/src/_data/technologies.yml b/pwa-devdocs/src/_data/technologies.yml index 9ba2da891e..110a6015b9 100644 --- a/pwa-devdocs/src/_data/technologies.yml +++ b/pwa-devdocs/src/_data/technologies.yml @@ -1,41 +1,43 @@ title: Technologies entries: - - label: Overview - url: /technologies/overview/ + - label: Overview + url: /technologies/overview/ - - label: Magento compatibility - url: /technologies/magento-compatibility/ + - label: Magento compatibility + url: /technologies/magento-compatibility/ - - label: Magento theme vs PWA storefront - url: /technologies/theme-vs-storefront/ + - label: Magento theme vs PWA storefront + url: /technologies/theme-vs-storefront/ - - label: Tools and libraries - url: /technologies/tools-libraries/ + - label: Tools and libraries + url: /technologies/tools-libraries/ - - label: Basic concepts - url: /technologies/basic-concepts/ - entries: + - label: Basic concepts + url: /technologies/basic-concepts/ + entries: + - label: Application shell + url: /technologies/basic-concepts/app-shell/ - - label: Application shell - url: /technologies/basic-concepts/app-shell/ + - label: Container extensibility + url: /technologies/basic-concepts/container-extensibility/ - - label: Container extensibility - url: /technologies/basic-concepts/container-extensibility/ + - label: CSS modules + url: /technologies/basic-concepts/css-modules/ - - label: CSS modules - url: /technologies/basic-concepts/css-modules/ - - label: GraphQL - url: /technologies/basic-concepts/graphql/ + - label: GraphQL + url: /technologies/basic-concepts/graphql/ - - label: UPWARD - url: /technologies/upward/ - entries: + - label: Client-side caching + url: /technologies/basic-concepts/client-side-caching/ - - label: Reference implementation - url: /technologies/upward/reference-implementation/ + - label: UPWARD + url: /technologies/upward/ + entries: + - label: Reference implementation + url: /technologies/upward/reference-implementation/ - - label: Contributing to PWA Studio - url: /technologies/contribute/ + - label: Contributing to PWA Studio + url: /technologies/contribute/ - - label: Versioning strategy - url: /technologies/versioning/ + - label: Versioning strategy + url: /technologies/versioning/ diff --git a/pwa-devdocs/src/technologies/basic-concepts/client-side-caching/index.md b/pwa-devdocs/src/technologies/basic-concepts/client-side-caching/index.md new file mode 100644 index 0000000000..17b985fee8 --- /dev/null +++ b/pwa-devdocs/src/technologies/basic-concepts/client-side-caching/index.md @@ -0,0 +1,89 @@ +--- +title: Client-side caching +--- + +Client-server communication is slow and expensive. +Performance is an important feature for any Progressive Web Application (PWA), so +requests to the server should be minimized. + +Offline mode is also a required feature for a PWA. +In offline mode, the application must be able to serve pages that have been recently viewed. + +These features are implemented with the help of a client-side cache. +This local cache stores data from resources as they are fetched. +Once a resource has been cached, the service worker may consult the cache on subsequent requests for that resource to boost performance. + +## Service Worker caching + +A [service worker][] is a JavaScript file that runs in a separate thread from the main execution thread in a web application. +Service workers can intercept network requests and fetch cached data or store results from a network request into the cache. + +### Venia service worker + +Venia's service worker behavior is defined in the [`src/sw.js`][] file using Google's [Workbox][] library. + +You do not need to use Workbox to define service worker behavior, but +Workbox makes this task easier by removing boilerplate code that is always used when working with service workers. + +Venia uses the following [caching strategies][] with its service worker: + +#### [Stale-while-revalidate][] + +The stale-while-revalidate strategy tells the service worker to use a cached response if it exists. +A separate network request is made for that resource and the cache is updated for future requests. + +This strategy is used when the most up to date version of a resource is not necessary for an application. + +| Route pattern | Description | +| ------------------------------------------------- | -------------------- | +| `/` | The application root | +| `/.\\.js$` | JavaScript files | +| `/\/media\/catalog.*\.(?:png|gif|jpg|jpeg|svg)$/` | Catalog image files | + +#### [Network first][] + +The network first strategy tells the service worker to get a resource from the network first. +If a network connection cannot be made, the service worker uses the cache as a fallback. + +This strategy is used for data that may change frequently on the server. + +| Route pattern | Description | +| ------------- | ----------- | +| `\\.html$` | HTML pages | + +#### [Cache first][] + +The cache first strategy tells the service worker to use the data from the cache. +Unlike the stale-while-revalidate strategy, no network call is made to update the cache. + +If a response is not found in the cache, a network call is made to get the resource and cache the response. + +This strategy is used for non-critical assets that do not get updated very often. + +| Route pattern | Description | +| ------------- | --------------------------------------- | +| `images` | Image files served from the application | + +## Caching in the Apollo GraphQL client + +The Venia implementation storefront uses the Apollo GraphQL client to make requests to the Magento GraphQL endpoint. +It also incorporates the default [`InMemoryCache`][] implementation to add caching abilities to the client. + +The cache is persisted between browser sessions in `window.localstorage` using the [`apollo-cache-persist`][] module. +This lets the Apollo client maintain its cached data even when the user closes the application. + +By default, `InMemoryCache` uses a cache first strategy for all queries. +This strategy is set using the `fetchPolicy` prop on the `Query` component. + +Caching for Apollo is set up in the [`src/drivers/adapter.js`][] file. + +[service worker]: https://developers.google.com/web/ilt/pwa/introduction-to-service-worker +[`src/sw.js`]: https://github.com/magento-research/pwa-studio/blob/master/packages/venia-concept/src/sw.js +[workbox]: https://developers.google.com/web/tools/workbox/ +[caching strategies]: https://developers.google.com/web/tools/workbox/modules/workbox-strategies +[stale-while-revalidate]: https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#stale-while-revalidate +[network first]: https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#network-falling-back-to-cache +[cache first]: https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#cache-falling-back-to-network +[`inmemorycache`]: https://www.apollographql.com/docs/react/advanced/caching +[`apollo-cache-persist`]: https://github.com/apollographql/apollo-cache-persist +[`src/drivers/adapter.js`]: https://github.com/magento-research/pwa-studio/blob/master/packages/venia-concept/src/drivers/adapter.js From 0d812eaaab2c85bcc31c38aee2c02382bf14c87d Mon Sep 17 00:00:00 2001 From: James Calcaben Date: Wed, 24 Apr 2019 12:12:23 -0500 Subject: [PATCH 4/4] [Docs] Fix links in Basic Concepts (#1158) * Remove issue id from links and just redirect to project repo --- .../src/technologies/basic-concepts/index.md | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/pwa-devdocs/src/technologies/basic-concepts/index.md b/pwa-devdocs/src/technologies/basic-concepts/index.md index c4d1b3b6a3..18d706f73b 100644 --- a/pwa-devdocs/src/technologies/basic-concepts/index.md +++ b/pwa-devdocs/src/technologies/basic-concepts/index.md @@ -2,85 +2,85 @@ title: Basic Concepts --- -The tools provided by the Magento PWA Studio project allows you to create websites that are fast, mobile-friendly, and reliable. +The tools provided by the Magento PWA Studio project allows you to create websites that are fast, mobile-friendly, and reliable. This topic lists the basic concepts you need to know to work with the Magento PWA Studio tools. ## Application shell -An application shell provides the basic user interface structure for a progressive web application. +An application shell provides the basic user interface structure for a progressive web application. For more information, see [Application shell][]. ## Service worker A service worker is a script that runs in the background. -Progressive web applications use service workers for caching and resource retrieval. -[ *[Help write this topic][Service Worker]* ] +Progressive web applications use service workers for caching and resource retrieval. +[ _[Help write this topic][service worker]_ ] ## Component data binding Component data binding refers to the way data flows between the source and a UI component. -Progressive web applications use data binding patterns to connect dynamic data with the user interface. -[ *[Help write this topic][Component data binding]* ] +Progressive web applications use data binding patterns to connect dynamic data with the user interface. +[ _[Help write this topic][component data binding]_ ] ## GraphQL GraphQL is a specification for a data query language client side and a service layer on the server side. -It is used to request and push data in a progressive web application. +It is used to request and push data in a progressive web application. For more information, see [GraphQL][]. ## CSS modules CSS modules are modular and reusable CSS styles. -This allows you to develop components with styles that do not conflict with external style definitions. +This allows you to develop components with styles that do not conflict with external style definitions. For more information, see [CSS modules][]. ## Client state, reducers, and actions -Client state, reducers, and actions are [Redux] concepts used to manage and handle the state of a web application. -[ *[Help write this topic][Client state, reducers, and actions]* ] +Client state, reducers, and actions are [Redux][] concepts used to manage and handle the state of a web application. +[ _[Help write this topic][client state, reducers, and actions]_ ] ## Loading and offline states -Loading and offline are both states that must be handled by progressive web applications. -[ *[Help write this topic][Loading and offline states]* ] +Loading and offline are both states that must be handled by progressive web applications. +[ _[Help write this topic][loading and offline states]_ ] ## Container extensibility -Writing extensible containers allow others to re-use and alter your container without modifying the source. +Writing extensible containers allow others to re-use and alter your container without modifying the source. -For more information, see [Container extensibility]. +For more information, see [Container extensibility][]. ## Performance patterns Performance is an important feature for a progressive web app. -There are many strategies and patterns available to help boost the performance of a PWA. -[ *[Help write this topic][Performance patterns]* ] +There are many strategies and patterns available to help boost the performance of a PWA. +[ _[Help write this topic][performance patterns]_ ] ## Root components and routing The root component of an application is the DOM node under which all other nodes are managed by React. -Routing is the ability to map a URL pattern to the appropriate handler. -[ *[Help write this topic][Performance patterns]* ] +Routing is the ability to map a URL pattern to the appropriate handler. +[ _[Help write this topic][root components and routing]_ ] ## Critical path The critical path for rendering refers to the steps the browser takes to process the HTML, CSS, and JavaScript files to display a website. -Optimizing the critical path is important to get the best performance out of a progressive web application. -[ *[Help write this topic][Critical path]* ] - -[Redux]: https://redux.js.org/introduction/core-concepts - -[Service worker]: {{ site.data.vars.repo }}/issues/14 -[Component data binding]: {{ site.data.vars.repo }}/issues/9 -[Client state, reducers, and actions]: {{ site.data.vars.repo }}/issues/12 -[Loading and offline states]: {{ site.data.vars.repo }}/issues/13 -[Container extensibility]: {{ site.baseurl }}{%link technologies/basic-concepts/container-extensibility/index.md %} -[Performance patterns]: {{ site.data.vars.repo }}/issues/16 -[Root components and routing]: {{ site.data.vars.repo }}/issues/17 -[Critical path]: {{ site.data.vars.repo }}/issues/18 -[GraphQL]: {{ site.baseurl}}{%link technologies/basic-concepts/graphql/index.md %} -[CSS modules]: {{ site.baseurl }}{%link technologies/basic-concepts/css-modules/index.md %} -[Application shell]: {{site.baseurl}}{%link technologies/basic-concepts/app-shell/index.md %} +Optimizing the critical path is important to get the best performance out of a progressive web application. +[ _[Help write this topic][critical path]_ ] + +[container extensibility]: {{ site.baseurl }}{%link technologies/basic-concepts/container-extensibility/index.md %} +[graphql]: {{ site.baseurl}}{%link technologies/basic-concepts/graphql/index.md %} +[css modules]: {{ site.baseurl }}{%link technologies/basic-concepts/css-modules/index.md %} +[application shell]: {{site.baseurl}}{%link technologies/basic-concepts/app-shell/index.md %} + +[redux]: https://redux.js.org/introduction/core-concepts +[service worker]: https://github.com/magento-research/pwa-studio +[component data binding]: https://github.com/magento-research/pwa-studio +[client state, reducers, and actions]: https://github.com/magento-research/pwa-studio +[loading and offline states]: https://github.com/magento-research/pwa-studio +[performance patterns]: https://github.com/magento-research/pwa-studio +[root components and routing]: https://github.com/magento-research/pwa-studio +[critical path]: https://github.com/magento-research/pwa-studio