diff --git a/CHANGELOG.md b/CHANGELOG.md index aa7496023c..8ea12bd09e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added states.json in core/i18n/resource (#4531) - Added phone validation helper (#4980) - Configurable enabling min & max price aggregations +- Storing totals in localStorage to sync it between tabs ([#4733](https://github.com/vuestorefront/vue-storefront/issues/4733)) ### Fixed diff --git a/core/modules/cart/helpers/cartCacheHandler.ts b/core/modules/cart/helpers/cartCacheHandler.ts index 683d0cedc2..a870d1e072 100644 --- a/core/modules/cart/helpers/cartCacheHandler.ts +++ b/core/modules/cart/helpers/cartCacheHandler.ts @@ -3,33 +3,31 @@ import { Logger } from '@vue-storefront/core/lib/logger' import { StorageManager } from '@vue-storefront/core/lib/storage-manager' -export function cartCacheHandlerFactory (Vue) { - return (mutation, state) => { - const type = mutation.type; +export const cartCacheHandlerPlugin = (mutation, state) => { + const type = mutation.type; - if ( - type.endsWith(types.CART_LOAD_CART) || - type.endsWith(types.CART_ADD_ITEM) || - type.endsWith(types.CART_DEL_ITEM) || - type.endsWith(types.CART_UPD_ITEM) || - type.endsWith(types.CART_DEL_NON_CONFIRMED_ITEM) || - type.endsWith(types.CART_UPD_ITEM_PROPS) - ) { - return StorageManager.get('cart').setItem('current-cart', state.cart.cartItems).catch((reason) => { - Logger.error(reason)() // it doesn't work on SSR - }) // populate cache - } else if ( - type.endsWith(types.CART_LOAD_CART_SERVER_TOKEN) - ) { - return StorageManager.get('cart').setItem('current-cart-token', state.cart.cartServerToken).catch((reason) => { - Logger.error(reason)() - }) - } else if ( - type.endsWith(types.CART_SET_ITEMS_HASH) - ) { - return StorageManager.get('cart').setItem('current-cart-hash', state.cart.cartItemsHash).catch((reason) => { - Logger.error(reason)() - }) - } + if ( + type.endsWith(types.CART_LOAD_CART) || + type.endsWith(types.CART_ADD_ITEM) || + type.endsWith(types.CART_DEL_ITEM) || + type.endsWith(types.CART_UPD_ITEM) || + type.endsWith(types.CART_DEL_NON_CONFIRMED_ITEM) || + type.endsWith(types.CART_UPD_ITEM_PROPS) + ) { + return StorageManager.get('cart').setItem('current-cart', state.cart.cartItems).catch((reason) => { + Logger.error(reason)() // it doesn't work on SSR + }) // populate cache + } else if ( + type.endsWith(types.CART_LOAD_CART_SERVER_TOKEN) + ) { + return StorageManager.get('cart').setItem('current-cart-token', state.cart.cartServerToken).catch((reason) => { + Logger.error(reason)() + }) + } else if ( + type.endsWith(types.CART_SET_ITEMS_HASH) + ) { + return StorageManager.get('cart').setItem('current-cart-hash', state.cart.cartItemsHash).catch((reason) => { + Logger.error(reason)() + }) } } diff --git a/core/modules/cart/helpers/index.ts b/core/modules/cart/helpers/index.ts index 555b6e5a5e..e29ad43e58 100644 --- a/core/modules/cart/helpers/index.ts +++ b/core/modules/cart/helpers/index.ts @@ -1,4 +1,5 @@ -import { cartCacheHandlerFactory } from './cartCacheHandler' +import { cartCacheHandlerPlugin } from './cartCacheHandler' +import { totalsCacheHandlerPlugin } from './totalsCacheHandler' import optimizeProduct from './optimizeProduct' import prepareProductsToAdd from './prepareProductsToAdd' import productChecksum from './productChecksum' @@ -18,7 +19,8 @@ import createShippingInfoData from './createShippingInfoData' import * as syncCartWhenLocalStorageChange from './syncCartWhenLocalStorageChange' export { - cartCacheHandlerFactory, + cartCacheHandlerPlugin, + totalsCacheHandlerPlugin, optimizeProduct, prepareProductsToAdd, productChecksum, diff --git a/core/modules/cart/helpers/syncCartWhenLocalStorageChange.ts b/core/modules/cart/helpers/syncCartWhenLocalStorageChange.ts index ee82c996de..54968c35d0 100644 --- a/core/modules/cart/helpers/syncCartWhenLocalStorageChange.ts +++ b/core/modules/cart/helpers/syncCartWhenLocalStorageChange.ts @@ -1,9 +1,11 @@ import rootStore from '@vue-storefront/core/store'; function getItemsFromStorage ({ key }) { + const value = JSON.parse(localStorage[key]) if (key === 'shop/cart/current-cart') { - const storedItems = JSON.parse(localStorage[key]) - rootStore.dispatch('cart/syncCartWhenLocalStorageChange', { items: storedItems }) + rootStore.dispatch('cart/updateCart', { items: value }) + } else if (key === 'shop/cart/current-totals') { + rootStore.dispatch('cart/updateTotals', value) } } diff --git a/core/modules/cart/helpers/totalsCacheHandler.ts b/core/modules/cart/helpers/totalsCacheHandler.ts new file mode 100644 index 0000000000..515a347f27 --- /dev/null +++ b/core/modules/cart/helpers/totalsCacheHandler.ts @@ -0,0 +1,17 @@ +import * as types from '../store/mutation-types' +import { Logger } from '@vue-storefront/core/lib/logger' + +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' + +export const totalsCacheHandlerPlugin = ({ type }, state) => { + if ( + type.endsWith(types.CART_UPD_TOTALS) + ) { + return StorageManager.get('cart').setItem('current-totals', { + platformTotalSegments: state.cart.platformTotalSegments, + platformTotals: state.cart.platformTotals + }).catch((reason) => { + Logger.error(reason)() + }) + } +} diff --git a/core/modules/cart/index.ts b/core/modules/cart/index.ts index edb536a488..6e2126d41e 100644 --- a/core/modules/cart/index.ts +++ b/core/modules/cart/index.ts @@ -1,8 +1,7 @@ import { StorefrontModule } from '@vue-storefront/core/lib/modules' import { cartStore } from './store' -import { cartCacheHandlerFactory } from './helpers'; +import { cartCacheHandlerPlugin, totalsCacheHandlerPlugin } from './helpers'; import { isServer } from '@vue-storefront/core/helpers' -import Vue from 'vue' import { StorageManager } from '@vue-storefront/core/lib/storage-manager' export const CartModule: StorefrontModule = function ({ store }) { @@ -11,5 +10,6 @@ export const CartModule: StorefrontModule = function ({ store }) { store.registerModule('cart', cartStore) if (!isServer) store.dispatch('cart/load') - store.subscribe(cartCacheHandlerFactory(Vue)) + store.subscribe(cartCacheHandlerPlugin); + store.subscribe(totalsCacheHandlerPlugin); } diff --git a/core/modules/cart/store/actions/synchronizeActions.ts b/core/modules/cart/store/actions/synchronizeActions.ts index da181d8950..f332fabe2f 100644 --- a/core/modules/cart/store/actions/synchronizeActions.ts +++ b/core/modules/cart/store/actions/synchronizeActions.ts @@ -20,7 +20,7 @@ const synchronizeActions = { cartHooksExecutors.afterLoad(storedItems) }, - syncCartWhenLocalStorageChange ({ commit }, { items }) { + updateCart ({ commit }, { items }) { commit(types.CART_LOAD_CART, items) }, async synchronizeCart ({ commit, dispatch }, { forceClientState }) { diff --git a/core/modules/cart/store/actions/totalsActions.ts b/core/modules/cart/store/actions/totalsActions.ts index b70020ce39..cbaf541e34 100644 --- a/core/modules/cart/store/actions/totalsActions.ts +++ b/core/modules/cart/store/actions/totalsActions.ts @@ -10,6 +10,9 @@ import { import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' const totalsActions = { + async updateTotals ({ commit }, payload) { + commit(types.CART_UPD_TOTALS, payload) + }, async getTotals (context, { addressInformation, hasShippingInformation }) { if (hasShippingInformation) { return CartService.setShippingInfo(addressInformation) diff --git a/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts b/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts index f57bc3edeb..8f97923b46 100644 --- a/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts +++ b/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts @@ -12,12 +12,12 @@ const StorageManager = { return this[key] }, clear () { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { resolve() }) } }; -const cartCacheHandlerFactory = require('../../../helpers/cartCacheHandler').cartCacheHandlerFactory +const cartCacheHandlerPlugin = require('../../../helpers/cartCacheHandler').cartCacheHandlerPlugin jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ StorageManager })) jest.mock('@vue-storefront/core/helpers', () => ({ @@ -54,7 +54,7 @@ describe('Cart afterRegistration', () => { StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.resolve('foo')); - await cartCacheHandlerFactory(Vue)({ type: mutationType }, stateMock); + await cartCacheHandlerPlugin({ type: mutationType }, stateMock); expect(StorageManager.get('cart').setItem) .toBeCalledWith('current-cart', stateMock.cart.cartItems); @@ -71,7 +71,7 @@ describe('Cart afterRegistration', () => { StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.reject('foo')); - await cartCacheHandlerFactory(Vue)({ type: types.CART_LOAD_CART }, stateMock); + await cartCacheHandlerPlugin({ type: types.CART_LOAD_CART }, stateMock); expect(consoleErrorSpy).toBeCalled(); }); @@ -85,7 +85,7 @@ describe('Cart afterRegistration', () => { StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.resolve('foo')); - await cartCacheHandlerFactory(Vue)({ type: types.CART_LOAD_CART_SERVER_TOKEN }, stateMock); + await cartCacheHandlerPlugin({ type: types.CART_LOAD_CART_SERVER_TOKEN }, stateMock); expect(StorageManager.get('cart').setItem) .toBeCalledWith('current-cart-token', stateMock.cart.cartServerToken); @@ -102,7 +102,7 @@ describe('Cart afterRegistration', () => { StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.reject('foo')); - await cartCacheHandlerFactory(Vue)({ type: types.CART_LOAD_CART_SERVER_TOKEN }, stateMock); + await cartCacheHandlerPlugin({ type: types.CART_LOAD_CART_SERVER_TOKEN }, stateMock); expect(consoleErrorSpy).toBeCalled(); }); @@ -118,7 +118,7 @@ describe('Cart afterRegistration', () => { StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.reject('foo')); - await cartCacheHandlerFactory(Vue)({ type: 'bar' }, stateMock); + await cartCacheHandlerPlugin({ type: 'bar' }, stateMock); expect(consoleErrorSpy).not.toBeCalled(); }); diff --git a/core/modules/cart/test/unit/helpers/totalsCacheHandler.spec.ts b/core/modules/cart/test/unit/helpers/totalsCacheHandler.spec.ts new file mode 100644 index 0000000000..beb39fcae5 --- /dev/null +++ b/core/modules/cart/test/unit/helpers/totalsCacheHandler.spec.ts @@ -0,0 +1,92 @@ +import Vue from 'vue' +import Vuex from 'vuex' + +import * as types from '../../../store/mutation-types' +import { Logger } from '@vue-storefront/core/lib/logger' + +const StorageManager = { + cart: { + setItem: jest.fn() + }, + get (key) { + return this[key] + }, + clear () { + return new Promise((resolve, reject) => { + resolve() + }) + } +}; +const totalsCacheHandlerPlugin = require('../../../helpers/totalsCacheHandler').totalsCacheHandlerPlugin + +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ StorageManager })) +jest.mock('@vue-storefront/core/helpers', () => ({ + isServer: () => false +})); +jest.mock('@vue-storefront/core/app', () => ({ createApp: jest.fn() })) +jest.mock('@vue-storefront/i18n', () => ({ loadLanguageAsync: jest.fn() })) + +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + error: () => () => {} + } +})) + +Vue.use(Vuex); + +describe('Cart afterRegistration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('handler populates cart cache on mutation CART_UPD_TOTALS that modifies totals', async () => { + const stateMock = { + cart: { + platformTotalSegments: 1, + platformTotals: 2 + } + }; + + StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.resolve('foo')); + + await totalsCacheHandlerPlugin({ type: types.CART_UPD_TOTALS }, stateMock); + + expect(StorageManager.get('cart').setItem) + .toBeCalledWith('current-totals', { + platformTotalSegments: 1, + platformTotals: 2 + }); + }); + + it('handler logs error when populating cart cache with items fails', async () => { + const stateMock = { + cart: { + cartItems: [{}] + } + }; + + const consoleErrorSpy = jest.spyOn(Logger, 'error'); + + StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.reject('foo')); + + await totalsCacheHandlerPlugin({ type: types.CART_UPD_TOTALS }, stateMock); + + expect(consoleErrorSpy).toBeCalled(); + }); + + it('nothing happens for mutation different than CART_UPD_TOTALS', async () => { + const stateMock = { + cart: { + cartItems: [{}] + } + }; + + const consoleErrorSpy = jest.spyOn(Logger, 'error'); + const storageManagerSpy = jest.spyOn(StorageManager.get('cart'), 'setItem'); + + await totalsCacheHandlerPlugin({ type: 'abc' }, stateMock); + + expect(consoleErrorSpy).not.toBeCalled(); + expect(storageManagerSpy).not.toBeCalled(); + }); +});