diff --git a/packages/theme/helpers/__tests__/asyncLocalStorage.spec.js b/packages/theme/helpers/__tests__/asyncLocalStorage.spec.js new file mode 100644 index 000000000..56b9d40a7 --- /dev/null +++ b/packages/theme/helpers/__tests__/asyncLocalStorage.spec.js @@ -0,0 +1,70 @@ +import { setItem, getItem, mergeItem, removeItem, clear } from '~/helpers/asyncLocalStorage'; + +describe('asyncLocalStorage :: Promised Based Localstorage Management', () => { + test('setItem :: should store to localStorage by key', async () => { + const KEY = 'jest' + const VSF_KEY = `vsf-${KEY}` + const VALUE = 'jest-localstorage-value'; + const VSF_VALUE = JSON.stringify(VALUE); + + await setItem(KEY, VALUE); + + expect(localStorage.setItem).toHaveBeenLastCalledWith(VSF_KEY, VSF_VALUE); + expect(localStorage.__STORE__[VSF_KEY]).toBe(VSF_VALUE); + expect(Object.keys(localStorage.__STORE__).length).toBe(1); + }); + + test('getItem :: should get value from localStorage by key', async () => { + const KEY = 'jest' + const VSF_KEY = `vsf-${KEY}` + const VSF_VALUE = 'jest-localstorage-value'; + + const VALUE = await getItem(KEY); + + expect(localStorage.getItem).toHaveBeenLastCalledWith(VSF_KEY); + expect(localStorage.__STORE__[VSF_KEY]).toBe(JSON.stringify(VALUE)); + expect(VSF_VALUE).toBe(VALUE); + }); + + test('merge :: should merge values to localStorage by key', async () => { + const KEY = 'jest' + const VSF_KEY = `vsf-${KEY}` + const VALUE = {row1: 'Lonely'}; + const VSF_VALUE = JSON.stringify(VALUE); + const MERGE_VALUE = {row2: 'Not anymore'}; + const VSF_MERGE_VALUE = JSON.stringify({ + row1: 'Lonely', + row2: 'Not anymore' + }) + + await setItem(KEY, VALUE); + + expect(localStorage.__STORE__[VSF_KEY]).toBe(VSF_VALUE); + expect(Object.keys(localStorage.__STORE__).length).toBe(1); + + await mergeItem(KEY, MERGE_VALUE); + + expect(localStorage.__STORE__[VSF_KEY]).toBe(VSF_MERGE_VALUE); + expect(Object.keys(localStorage.__STORE__).length).toBe(1); + }); + + test('remove :: should remove a key and value from localStorage', async () => { + const KEY = 'jest' + const VSF_KEY = `vsf-${KEY}` + + expect(Object.keys(localStorage.__STORE__).length).toBe(1); + + await removeItem(KEY); + + expect(localStorage.removeItem).toHaveBeenCalledWith(VSF_KEY); + expect(Object.keys(localStorage.__STORE__).length).toBe(0); + }); + + test('clear :: should clear all values and keys from localStorage', async () => { + await clear(); + + expect(localStorage.clear).toHaveBeenCalledWith(); + expect(Object.keys(localStorage.__STORE__).length).toBe(0); + }); +}); + diff --git a/packages/theme/helpers/asyncLocalStorage.ts b/packages/theme/helpers/asyncLocalStorage.ts new file mode 100644 index 000000000..87faac386 --- /dev/null +++ b/packages/theme/helpers/asyncLocalStorage.ts @@ -0,0 +1,42 @@ +const getVsfKey = (key) => `vsf-${key}`; + +const mergeLocalStorageItem = (key: string, value: string) => { + const oldValue = window.localStorage.getItem(key); + const oldObject = JSON.parse(oldValue); + const newObject = value; + const nextValue = JSON.stringify({ ...JSON.parse(JSON.stringify(oldObject)), ...JSON.parse(JSON.stringify(newObject)) }); + window.localStorage.setItem(key, nextValue); +}; + +const createPromise = (getValue, callback): Promise => new Promise((resolve, reject) => { + try { + const value = getValue(); + if (callback) { + callback(null, value); + } + resolve(value); + } catch (err) { + if (callback) { + callback(err); + } + reject(err); + } +}); + +// eslint-disable-next-line max-len +export const getItem = (key: string, callback?: Function): Promise => createPromise(() => JSON.parse(window.localStorage.getItem(getVsfKey(key))), callback); + +// eslint-disable-next-line max-len +export const setItem = (key: string, value: string, callback?: Function): Promise => createPromise(() => (window.localStorage.setItem(getVsfKey(key), JSON.stringify(value))), callback); + +// eslint-disable-next-line max-len +export const removeItem = (key: string, callback?: Function): Promise => createPromise(() => { + window.localStorage.removeItem(getVsfKey(key)); +}, callback); + +// eslint-disable-next-line max-len +export const mergeItem = (key: string, value: string, callback?: Function): Promise => createPromise(() => mergeLocalStorageItem(getVsfKey(key), value), callback); + +export const clear = (callback?: Function): Promise => createPromise(() => { + window.localStorage.clear(); +}, callback); diff --git a/packages/theme/helpers/checkout/__tests__/steps.spec.js b/packages/theme/helpers/checkout/__tests__/steps.spec.js new file mode 100644 index 000000000..4c43ee8aa --- /dev/null +++ b/packages/theme/helpers/checkout/__tests__/steps.spec.js @@ -0,0 +1,26 @@ +import { isPreviousStepValid } from '~/helpers/checkout/steps' + +const CHECKOUT_DATA = { + 'my-account': {a: 'b', c: 'd'} +} + +describe('steps :: steps helper for the checkout', () => { + + beforeEach(async () => { + localStorage.clear(); + + localStorage.setItem('vsf-checkout', JSON.stringify(CHECKOUT_DATA)) + }); + + test('user can continue', async () => { + const isValid = await isPreviousStepValid('my-account') + + expect(isValid).toBeTruthy(); + }); + + test('user can\'t continue', async () => { + const isValid = await isPreviousStepValid('billing') + + expect(isValid).toBeFalsy(); + }); +}); diff --git a/packages/theme/helpers/checkout/steps.ts b/packages/theme/helpers/checkout/steps.ts new file mode 100644 index 000000000..94305300f --- /dev/null +++ b/packages/theme/helpers/checkout/steps.ts @@ -0,0 +1,6 @@ +import { getItem } from '~/helpers/asyncLocalStorage'; + +export const isPreviousStepValid = async (stepToValidate: string) => { + const checkout = await getItem('checkout'); + return !(!checkout || !checkout[stepToValidate]); +}; diff --git a/packages/theme/pages/Checkout.vue b/packages/theme/pages/Checkout.vue index 3abe65350..be10e411e 100644 --- a/packages/theme/pages/Checkout.vue +++ b/packages/theme/pages/Checkout.vue @@ -11,7 +11,9 @@ @@ -48,19 +50,37 @@ export default defineComponent({ const { path } = route.value; const router = useRouter(); const currentStep = computed(() => path.split('/').pop()); - const STEPS = ref({ - 'user-account': 'User Account', - shipping: 'Shipping', - billing: 'Billing', - payment: 'Payment', - }); - const currentStepIndex = computed(() => Object.keys(STEPS.value) - .indexOf(currentStep.value)); + + const STEPS = ref( + [ + { + title: 'User Account', + url: 'user-account', + }, + { + title: 'Shipping', + url: 'shipping', + }, + { + title: 'Billing', + url: 'billing', + }, + { + title: 'Payment', + url: 'payment', + }, + ], + ); + + const currentStepIndex = computed(() => STEPS.value + .findIndex((step) => step.url === currentStep.value)); const isThankYou = computed(() => currentStep.value === 'thank-you'); const handleStepClick = async (stepIndex) => { - const key = Object.keys(STEPS.value)[stepIndex]; - await router.push(`${app.localePath(`/checkout/${key}`)}`); + if (stepIndex <= currentStepIndex.value) { + const { url } = STEPS.value[stepIndex]; + await router.push(`${app.localePath(`/checkout/${url}`)}`); + } }; return { diff --git a/packages/theme/pages/Checkout/Billing.vue b/packages/theme/pages/Checkout/Billing.vue index 15c7c6769..8f715c902 100644 --- a/packages/theme/pages/Checkout/Billing.vue +++ b/packages/theme/pages/Checkout/Billing.vue @@ -281,6 +281,8 @@ import { useContext, } from '@nuxtjs/composition-api'; import { addressFromApiToForm, formatAddressReturnToData } from '~/helpers/checkout/address'; +import { mergeItem } from '~/helpers/asyncLocalStorage'; +import { isPreviousStepValid } from '~/helpers/checkout/steps'; const NOT_SELECTED_ADDRESS = ''; @@ -363,13 +365,14 @@ export default defineComponent({ const handleAddressSubmit = (reset) => async () => { const addressId = currentAddressId.value; - await save({ + const billingDetailsData = { billingDetails: { ...billingDetails.value, customerAddressId: addressId, sameAsShipping: sameAsShipping.value, }, - }); + }; + await save(billingDetailsData); if (addressId !== NOT_SELECTED_ADDRESS && setAsDefault.value) { const chosenAddress = userBillingGetters.getAddresses( userBilling.value, @@ -380,6 +383,7 @@ export default defineComponent({ } } reset(); + await mergeItem('checkout', { billing: billingDetailsData }); await router.push(`${app.localePath('/checkout/payment')}`); isBillingDetailsStepCompleted.value = true; }; @@ -458,6 +462,11 @@ export default defineComponent({ }); onMounted(async () => { + const validStep = await isPreviousStepValid('shipping'); + if (!validStep) { + await router.push(app.localePath('/checkout/user-account')); + } + if (billingDetails.value?.country_code) { await searchCountry({ id: billingDetails.value.country_code }); } diff --git a/packages/theme/pages/Checkout/Payment.vue b/packages/theme/pages/Checkout/Payment.vue index 52e7a6c34..b8f47ac23 100644 --- a/packages/theme/pages/Checkout/Payment.vue +++ b/packages/theme/pages/Checkout/Payment.vue @@ -171,7 +171,7 @@ import { computed, defineComponent, useRouter, - useContext, + useContext, onMounted } from '@nuxtjs/composition-api'; import { useMakeOrder, @@ -179,6 +179,8 @@ import { cartGetters, } from '@vue-storefront/magento'; import getShippingMethodPrice from '~/helpers/checkout/getShippingMethodPrice'; +import { removeItem } from '~/helpers/asyncLocalStorage'; +import { isPreviousStepValid } from '~/helpers/checkout/steps'; export default defineComponent({ name: 'ReviewOrderAndPayment', @@ -209,11 +211,19 @@ export default defineComponent({ await load(); }); + onMounted(async () => { + const validStep = await isPreviousStepValid('billing'); + if (!validStep) { + await router.push(app.localePath('/checkout/user-account')); + } + }); + const processOrder = async () => { await make(); setCart(null); $magento.config.state.setCartId(); await load(); + await removeItem('checkout'); await router.push(`${app.localePath(`/checkout/thank-you?order=${order.value.order_number}`)}`); }; diff --git a/packages/theme/pages/Checkout/Shipping.vue b/packages/theme/pages/Checkout/Shipping.vue index 9d8dd1655..fbc14a6b5 100644 --- a/packages/theme/pages/Checkout/Shipping.vue +++ b/packages/theme/pages/Checkout/Shipping.vue @@ -251,7 +251,7 @@ import { computed, watch, onMounted, - defineComponent, + defineComponent, useRouter, useContext, } from '@nuxtjs/composition-api'; import { onSSR } from '@vue-storefront/core'; import { @@ -265,6 +265,8 @@ import { import { required, min, digits } from 'vee-validate/dist/rules'; import { ValidationProvider, ValidationObserver, extend } from 'vee-validate'; import { addressFromApiToForm } from '~/helpers/checkout/address'; +import { mergeItem } from '~/helpers/asyncLocalStorage'; +import { isPreviousStepValid } from '~/helpers/checkout/steps'; const NOT_SELECTED_ADDRESS = ''; @@ -294,6 +296,8 @@ export default defineComponent({ VsfShippingProvider: () => import('~/components/Checkout/VsfShippingProvider.vue'), }, setup() { + const router = useRouter(); + const { app } = useContext(); const { load, save, @@ -343,6 +347,7 @@ export default defineComponent({ ...shippingDetails.value, customerAddressId: addressId, }; + await mergeItem('checkout', { shipping: shippingDetailsData }); // @TODO remove ignore when https://github.com/vuestorefront/vue-storefront/issues/5967 is applied // @ts-ignore await save({ shippingDetails: shippingDetailsData }); @@ -409,6 +414,11 @@ export default defineComponent({ }); onMounted(async () => { + const validStep = await isPreviousStepValid('user-account'); + if (!validStep) { + await router.push(app.localePath('/checkout/user-account')); + } + if (shippingDetails.value?.country_code) { await searchCountry({ id: shippingDetails.value.country_code }); } diff --git a/packages/theme/pages/Checkout/UserAccount.vue b/packages/theme/pages/Checkout/UserAccount.vue index d8f63f9bb..36cb42fe8 100644 --- a/packages/theme/pages/Checkout/UserAccount.vue +++ b/packages/theme/pages/Checkout/UserAccount.vue @@ -139,15 +139,16 @@ import { computed, defineComponent, useRouter, - useContext, + useContext, onMounted, } from '@nuxtjs/composition-api'; import { useUser, useGuestUser } from '@vue-storefront/magento'; import { required, min, email, } from 'vee-validate/dist/rules'; import { ValidationProvider, ValidationObserver, extend } from 'vee-validate'; -import { customerPasswordRegExp, invalidPasswordMsg } from '../../helpers/customer/regex'; import { useUiNotification } from '~/composables'; +import { getItem, mergeItem } from '~/helpers/asyncLocalStorage'; +import { customerPasswordRegExp, invalidPasswordMsg } from '../../helpers/customer/regex'; extend('required', { ...required, @@ -234,6 +235,7 @@ export default defineComponent({ } if (!hasError.value) { + await mergeItem('checkout', { 'user-account': form.value }); await router.push(`${app.localePath('/checkout/shipping')}`); reset(); isFormSubmitted.value = true; @@ -251,7 +253,6 @@ export default defineComponent({ onSSR(async () => { await load(); - if (isAuthenticated.value) { form.value.firstname = user.value.firstname; form.value.lastname = user.value.lastname; @@ -259,6 +260,16 @@ export default defineComponent({ } }); + onMounted(async () => { + const checkout = await getItem('checkout'); + if (checkout && checkout['user-account']) { + const data = checkout['user-account']; + form.value.email = data.email; + form.value.firstname = data.firstname; + form.value.lastname = data.lastname; + } + }); + return { canMoveForward, createUserAccount, diff --git a/packages/theme/pages/Checkout/__tests__/Shipping.spec.js b/packages/theme/pages/Checkout/__tests__/Shipping.spec.js new file mode 100644 index 000000000..0cbf61439 --- /dev/null +++ b/packages/theme/pages/Checkout/__tests__/Shipping.spec.js @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/vue'; +import { useRouter } from '@nuxtjs/composition-api'; +import { useBilling, useUserBilling, useShipping, useCountrySearch, useGuestUser, useUser } from '@vue-storefront/magento'; +import { render, useBillingMock, useUserBillingMock, useShippingMock, useCountrySearchMock, useUserMock, useGuestUserMock } from '~/test-utils'; + +import Billing from '../Billing'; + +jest.mock('@vue-storefront/magento', () => ({ + useBilling: jest.fn(), + useCountrySearch: jest.fn(), + useGuestUser: jest.fn(), + useShipping: jest.fn(), + useUser: jest.fn(), + useUserBilling: jest.fn(), +})); + +jest.mock('@nuxtjs/composition-api', () => { + // Require the original module to not be mocked... + const originalModule = jest.requireActual('@nuxtjs/composition-api'); + + return { + ...originalModule, + useRouter: jest.fn(), + }; +}); + +describe('Checkout :: ', () => { + test.todo('Test onMount'); + // it('Form fields are rendered and validated', async () => { + // + // useBilling.mockReturnValue(useBillingMock()); + // useUserBilling.mockReturnValue(useUserBillingMock()); + // useShipping.mockReturnValue(useShippingMock()); + // useCountrySearch.mockReturnValue(useCountrySearchMock()); + // useUser.mockReturnValue(useUserMock()); + // useGuestUser.mockReturnValue(useGuestUserMock()); + // + // const { getByRole, findAllByText, html } = render(Billing); + // + // console.log(html) + // }); +}); diff --git a/packages/theme/test-utils/mocks/index.js b/packages/theme/test-utils/mocks/index.js index 2800c9c23..257d86936 100644 --- a/packages/theme/test-utils/mocks/index.js +++ b/packages/theme/test-utils/mocks/index.js @@ -1,5 +1,9 @@ +export * from './useBilling'; +export * from './useCart'; +export * from './useCountrySearch'; export * from './useGuestUser'; +export * from './useShipping'; export * from './useUser'; -export * from './useCart'; +export * from './useUserBilling'; export * from './useUiState'; export * from './cartGetters'; diff --git a/packages/theme/test-utils/mocks/useBilling.js b/packages/theme/test-utils/mocks/useBilling.js new file mode 100644 index 000000000..905745c38 --- /dev/null +++ b/packages/theme/test-utils/mocks/useBilling.js @@ -0,0 +1,16 @@ +export const useBillingMock = (billingData = {}) => ({ + load: () => {}, + save: () => {}, + loading: { + value: false, + }, + billing: { + value: {}, + }, + address: { + value: {}, + }, + ...billingData, +}); + +export default useBillingMock; diff --git a/packages/theme/test-utils/mocks/useCountrySearch.js b/packages/theme/test-utils/mocks/useCountrySearch.js new file mode 100644 index 000000000..6831aada0 --- /dev/null +++ b/packages/theme/test-utils/mocks/useCountrySearch.js @@ -0,0 +1,5 @@ +export const useCountrySearchMock = (countrySearchData = {}) => ({ + ...countrySearchData, +}); + +export default useCountrySearchMock; diff --git a/packages/theme/test-utils/mocks/useShipping.js b/packages/theme/test-utils/mocks/useShipping.js new file mode 100644 index 000000000..a05155d11 --- /dev/null +++ b/packages/theme/test-utils/mocks/useShipping.js @@ -0,0 +1,5 @@ +export const useShippingMock = (shippingData = {}) => ({ + ...shippingData, +}); + +export default useShippingMock; diff --git a/packages/theme/test-utils/mocks/useUserBilling.js b/packages/theme/test-utils/mocks/useUserBilling.js new file mode 100644 index 000000000..dbbe45e59 --- /dev/null +++ b/packages/theme/test-utils/mocks/useUserBilling.js @@ -0,0 +1,14 @@ +export const useUserBillingMock = (userBillingData = {}) => ({ + load: jest.fn(), + loading: { + value: false, + }, + error: { + value: { + register: null, + }, + }, + ...userBillingData, +}); + +export default useUserBillingMock;