From 90dc4c001ff6ac73c97e28811f7f909743c3af71 Mon Sep 17 00:00:00 2001 From: Maciek Kucmus Date: Tue, 20 Apr 2021 12:38:43 +0200 Subject: [PATCH] feat: cart errors handling (#1422) Co-authored-by: patzick <13100280+patzick@users.noreply.github.com> --- api/composables.api.md | 5 + .../api/composables.interceptor_keys.md | 2 + .../api/composables.iusecart.carterrors.md | 14 ++ .../resources/api/composables.iusecart.md | 1 + .../interfaces/models/checkout/cart/Cart.ts | 13 +- .../interfaces/models/common/EntityError.ts | 27 +++ .../__tests__/helpers/errorHandler.spec.ts | 76 ++++++++ .../composables/__tests__/useCart.spec.ts | 163 ++++++++++++++---- .../composables/src/hooks/useCart/index.ts | 39 ++++- .../src/internalHelpers/errorHandler.ts | 42 +++++ .../composables/src/logic/useIntercept.ts | 18 ++ .../src/logic/notifications/index.js | 5 + .../src/plugins/notifications.js | 2 + 13 files changed, 369 insertions(+), 38 deletions(-) create mode 100644 docs/landing/resources/api/composables.iusecart.carterrors.md create mode 100644 packages/commons/interfaces/models/common/EntityError.ts create mode 100644 packages/composables/__tests__/helpers/errorHandler.spec.ts create mode 100644 packages/composables/src/internalHelpers/errorHandler.ts diff --git a/api/composables.api.md b/api/composables.api.md index c567007d9..1f5a1a857 100644 --- a/api/composables.api.md +++ b/api/composables.api.md @@ -21,6 +21,7 @@ import { CustomerResetPasswordParam } from '@shopware-pwa/shopware-6-client'; import { CustomerUpdateEmailParam } from '@shopware-pwa/shopware-6-client'; import { CustomerUpdatePasswordParam } from '@shopware-pwa/shopware-6-client'; import { CustomerUpdateProfileParam } from '@shopware-pwa/shopware-6-client'; +import { EntityError } from '@shopware-pwa/commons/interfaces/models/common/EntityError'; import { EqualsFilter } from '@shopware-pwa/commons/interfaces/search/SearchFilter'; import { GuestOrderParams } from '@shopware-pwa/commons/interfaces/request/GuestOrderParams'; import { Includes } from '@shopware-pwa/commons/interfaces/search/SearchCriteria'; @@ -184,6 +185,8 @@ export const INTERCEPTOR_KEYS: { ADD_TO_WISHLIST: string; ADD_PROMOTION_CODE: string; ERROR: string; + WARNING: string; + NOTICE: string; ORDER_PLACE: string; SESSION_SET_CURRENCY: string; SESSION_SET_PAYMENT_METHOD: string; @@ -221,6 +224,8 @@ export interface IUseCart { // (undocumented) cart: ComputedRef; // (undocumented) + cartErrors: ComputedRef; + // (undocumented) cartItems: ComputedRef; // (undocumented) changeProductQuantity: ({ id, quantity, }: { diff --git a/docs/landing/resources/api/composables.interceptor_keys.md b/docs/landing/resources/api/composables.interceptor_keys.md index 9fb7291b4..680b34c76 100644 --- a/docs/landing/resources/api/composables.interceptor_keys.md +++ b/docs/landing/resources/api/composables.interceptor_keys.md @@ -17,6 +17,8 @@ INTERCEPTOR_KEYS: { ADD_TO_WISHLIST: string; ADD_PROMOTION_CODE: string; ERROR: string; + WARNING: string; + NOTICE: string; ORDER_PLACE: string; SESSION_SET_CURRENCY: string; SESSION_SET_PAYMENT_METHOD: string; diff --git a/docs/landing/resources/api/composables.iusecart.carterrors.md b/docs/landing/resources/api/composables.iusecart.carterrors.md new file mode 100644 index 000000000..8e67925de --- /dev/null +++ b/docs/landing/resources/api/composables.iusecart.carterrors.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [@shopware-pwa/composables](./composables.md) > [IUseCart](./composables.iusecart.md) > [cartErrors](./composables.iusecart.carterrors.md) + +## IUseCart.cartErrors property + +> This API is provided as a preview for developers and may change based on feedback that we receive. Do not use this API in a production environment. +> + +Signature: + +```typescript +cartErrors: ComputedRef; +``` diff --git a/docs/landing/resources/api/composables.iusecart.md b/docs/landing/resources/api/composables.iusecart.md index 0873476af..1a47df91f 100644 --- a/docs/landing/resources/api/composables.iusecart.md +++ b/docs/landing/resources/api/composables.iusecart.md @@ -23,6 +23,7 @@ export interface IUseCart | [addPromotionCode](./composables.iusecart.addpromotioncode.md) | (promotionCode: string) => Promise<void> | (BETA) | | [appliedPromotionCodes](./composables.iusecart.appliedpromotioncodes.md) | ComputedRef<LineItem\[\]> | (BETA) | | [cart](./composables.iusecart.cart.md) | ComputedRef<Cart \| null> | (BETA) | +| [cartErrors](./composables.iusecart.carterrors.md) | ComputedRef<EntityError\[\]> | (BETA) | | [cartItems](./composables.iusecart.cartitems.md) | ComputedRef<LineItem\[\]> | (BETA) | | [changeProductQuantity](./composables.iusecart.changeproductquantity.md) | ({ id, quantity, }: { id: string; quantity: number; }) => void | (BETA) | | [count](./composables.iusecart.count.md) | ComputedRef<number> | (BETA) | diff --git a/packages/commons/interfaces/models/checkout/cart/Cart.ts b/packages/commons/interfaces/models/checkout/cart/Cart.ts index 14a1928ee..b4e4915c8 100644 --- a/packages/commons/interfaces/models/checkout/cart/Cart.ts +++ b/packages/commons/interfaces/models/checkout/cart/Cart.ts @@ -1,18 +1,25 @@ import { LineItem } from "./line-item/LineItem"; import { CartPrice } from "./price/CartPrice"; -import { Error } from "./error/Error"; import { Delivery } from "../delivery/Delivery"; import { Transaction } from "./transaction/Transaction"; +import { EntityError } from "../../common/EntityError"; /** - * @alpha + * @beta + */ +export interface CartErrors { + [key: string]: EntityError; +} + +/** + * @beta */ export interface Cart { name: string; token: string; price: CartPrice; lineItems: LineItem[]; - errors: Error[]; + errors: CartErrors; deliveries: Delivery[]; transactions: Transaction[]; modified: boolean; diff --git a/packages/commons/interfaces/models/common/EntityError.ts b/packages/commons/interfaces/models/common/EntityError.ts new file mode 100644 index 000000000..5d52ada9d --- /dev/null +++ b/packages/commons/interfaces/models/common/EntityError.ts @@ -0,0 +1,27 @@ +/** + * @beta + */ +export enum ErrorLevel { + NOTICE = 0, + WARNING = 10, + ERROR = 20, +} + +/** + * @beta + */ +export interface EntityError { + id: string; + name: string; + quantity: number; + message: string; + code: number; + key: string; + level: ErrorLevel | number; + messageKey: + | "product-stock-reached" + | "product-out-of-stock" + | "product-not-found" + | "purchase-steps-quantity" + | string; +} diff --git a/packages/composables/__tests__/helpers/errorHandler.spec.ts b/packages/composables/__tests__/helpers/errorHandler.spec.ts new file mode 100644 index 000000000..95d7df489 --- /dev/null +++ b/packages/composables/__tests__/helpers/errorHandler.spec.ts @@ -0,0 +1,76 @@ +import { broadcastErrors } from "../../src/internalHelpers/errorHandler"; +import * as Composables from "@shopware-pwa/composables"; +import { EntityError } from "@shopware-pwa/commons/interfaces/models/common/EntityError"; +jest.mock("@shopware-pwa/composables"); +const mockedComposables = Composables as jest.Mocked; +jest.spyOn(console, "error"); +describe("composables errorHandler", () => { + const broadcastMock = jest.fn(); + + beforeEach(() => { + mockedComposables.getApplicationContext.mockImplementation(() => { + return {} as any; + }); + mockedComposables.useIntercept.mockImplementation(() => { + return { + broadcast: broadcastMock, + } as any; + }); + }); + describe("broadcastErrors", () => { + it("should do nothing if some of arguments does not match the interface", () => { + broadcastErrors(undefined as any, "testMethod", {} as any); + expect(broadcastMock).toBeCalledTimes(0); + }); + it("should broadcast errors if any provided", () => { + const errors: EntityError[] = [ + { + id: "someId", + name: "product-stock-reached", + quantity: 1, + message: "you reached the available quantity of the product", + code: 10, + key: "product-stock-reached-someId", + level: 0, + messageKey: "product-stock-reached", + }, + { + id: "someId", + name: "product-stock-reached", + quantity: 1, + message: "you reached the available quantity of the product", + code: 30, + key: "product-stock-reached-someId", + level: 10, + messageKey: "product-stock-reached", + }, + { + id: "someId", + name: "product-stock-reached", + quantity: 1, + message: "you reached the available quantity of the product", + code: 10, + key: "product-stock-reached-someId", + level: 20, + messageKey: "product-stock-reached", + }, + ]; + broadcastErrors(errors, "testMethod", {} as any); + expect(broadcastMock).toBeCalledTimes(3); + expect(broadcastMock).toBeCalledWith("notice", { + inputParams: {}, + methodName: "testMethod", + notice: { + code: 10, + id: "someId", + key: "product-stock-reached-someId", + level: 0, + message: "you reached the available quantity of the product", + messageKey: "product-stock-reached", + name: "product-stock-reached", + quantity: 1, + }, + }); + }); + }); +}); diff --git a/packages/composables/__tests__/useCart.spec.ts b/packages/composables/__tests__/useCart.spec.ts index b6bb1bdc6..a733042d6 100644 --- a/packages/composables/__tests__/useCart.spec.ts +++ b/packages/composables/__tests__/useCart.spec.ts @@ -10,11 +10,15 @@ const mockedShopwareClient = shopwareClient as jest.Mocked< typeof shopwareClient >; const consoleWarnSpy = jest.spyOn(console, "warn"); - +const consoleErrorSpy = jest.spyOn(console, "error"); import * as Composables from "@shopware-pwa/composables"; jest.mock("@shopware-pwa/composables"); const mockedComposables = Composables as jest.Mocked; +import * as ErrorHandler from "../src/internalHelpers/errorHandler"; +const mockedErrorHandler = ErrorHandler as jest.Mocked; +jest.mock("@shopware-pwa/composables/src/internalHelpers/errorHandler"); + import { useCart } from "../src/hooks/useCart"; describe("Composables - useCart", () => { @@ -28,6 +32,7 @@ describe("Composables - useCart", () => { jest.resetAllMocks(); stateCart.value = null; consoleWarnSpy.mockImplementationOnce(() => {}); + consoleErrorSpy.mockImplementationOnce(() => {}); mockedComposables.getApplicationContext.mockImplementation(() => { return { @@ -41,6 +46,7 @@ describe("Composables - useCart", () => { } as any; }); + mockedErrorHandler.broadcastErrors.mockImplementation(() => jest.fn()); mockedComposables.useIntercept.mockImplementation(() => { return { broadcast: broadcastMock, @@ -49,44 +55,72 @@ describe("Composables - useCart", () => { }); }); describe("computed", () => { - describe("shippingTotal", () => { - it("should return default value 0 (zero) if cart is empty", () => { + describe("cartErrors", () => { + it("should return default value [] (empty array) if cart is empty", () => { stateCart.value = undefined as any; - const { shippingTotal } = useCart(rootContextMock); - expect(shippingTotal.value).toBe(0); - }); - it("should return default value 0 (zero) if there is no delivery in cart", () => { - stateCart.value = { - deliveries: undefined, - }; - const { shippingTotal } = useCart(rootContextMock); - expect(shippingTotal.value).toBe(0); + const { cartErrors } = useCart(rootContextMock); + expect(cartErrors.value).toStrictEqual([]); }); - it("should return default value 0 (zero) if shipping costs are empty", () => { + it("should return array of {EntityError} typed Objects", () => { stateCart.value = { - deliveries: [ - { - shippingCosts: undefined, + errors: { + "error-1": { + key: "error-1", }, - ], - }; - const { shippingTotal } = useCart(rootContextMock); - expect(shippingTotal.value).toBe(0); - }); - it("should return total price from shipping cost of the first delivery from cart", () => { - stateCart.value = { - deliveries: [ - { - shippingCosts: { - totalPrice: 199.5, - }, + "error-2": { + key: "error-2", }, - ], - }; - const { shippingTotal } = useCart(rootContextMock); - expect(shippingTotal.value).toBe(199.5); + }, + } as any; + const { cartErrors } = useCart(rootContextMock); + expect(cartErrors.value).toStrictEqual([ + { + key: "error-1", + }, + { + key: "error-2", + }, + ]); + }); + }), + describe("shippingTotal", () => { + it("should return default value 0 (zero) if cart is empty", () => { + stateCart.value = undefined as any; + const { shippingTotal } = useCart(rootContextMock); + expect(shippingTotal.value).toBe(0); + }); + it("should return default value 0 (zero) if there is no delivery in cart", () => { + stateCart.value = { + deliveries: undefined, + }; + const { shippingTotal } = useCart(rootContextMock); + expect(shippingTotal.value).toBe(0); + }); + it("should return default value 0 (zero) if shipping costs are empty", () => { + stateCart.value = { + deliveries: [ + { + shippingCosts: undefined, + }, + ], + }; + const { shippingTotal } = useCart(rootContextMock); + expect(shippingTotal.value).toBe(0); + }); + it("should return total price from shipping cost of the first delivery from cart", () => { + stateCart.value = { + deliveries: [ + { + shippingCosts: { + totalPrice: 199.5, + }, + }, + ], + }; + const { shippingTotal } = useCart(rootContextMock); + expect(shippingTotal.value).toBe(199.5); + }); }); - }); describe("cart", () => { it("should be null on not loaded cart", () => { stateCart.value = null; @@ -378,5 +412,68 @@ describe("Composables - useCart", () => { ); }); }); + describe("broadcastUpcomingErrors", () => { + it("should catch the exception while the errors are trying to be broadcasted", async () => { + const { addProduct } = useCart(rootContextMock); + mockedShopwareClient.addProductToCart.mockResolvedValueOnce({ + errors: { + "error-1": { + key: "some-error-key", + }, + }, + } as any); + mockedErrorHandler.broadcastErrors.mockImplementation(() => { + throw new Error("An error occured"); + }); + await addProduct({ id: "someId", quantity: 1 }); + expect(consoleErrorSpy).toBeCalledWith( + "[useCart][broadcastUpcomingErrors]", + expect.any(Object) + ); + }); + it("should not invoke broadcastErrors helper when there is no new cart result - changeCartItemQuantity", async () => { + const { changeProductQuantity } = useCart(rootContextMock); + mockedShopwareClient.changeCartItemQuantity.mockResolvedValueOnce( + undefined as any + ); + await changeProductQuantity({ id: "qwerty", quantity: 6 }); + expect(mockedErrorHandler.broadcastErrors).toBeCalledTimes(0); + }); + it("should not invoke broadcastErrors helper when there is no new cart result - addProduct", async () => { + const { addProduct } = useCart(rootContextMock); + mockedShopwareClient.addProductToCart.mockResolvedValueOnce( + undefined as any + ); + await addProduct({ id: "qwerty", quantity: 6 }); + expect(mockedErrorHandler.broadcastErrors).toBeCalledTimes(0); + }); + it("should not invoke broadcastErrors helper when there is no new cart result - removeItem", async () => { + const { removeItem } = useCart(rootContextMock); + mockedShopwareClient.removeCartItem.mockResolvedValueOnce( + undefined as any + ); + await removeItem({ referencedId: "qwerty" } as any); + expect(mockedErrorHandler.broadcastErrors).toBeCalledTimes(0); + }); + it("should invoke broadcastErrors helper once there is a new error in cart response", async () => { + const { removeItem } = useCart(rootContextMock); + mockedShopwareClient.removeCartItem.mockResolvedValueOnce({ + errors: { + "error-id": { + id: "error-id", + name: "product stock reached", + quantity: 55, + message: "too many products added to cart", + code: 0, + key: "product-stock-reached-productId", + level: 10, + messageKey: "product-stock-reached", + }, + }, + } as any); + await removeItem({ referencedId: "qwerty" } as any); + expect(mockedErrorHandler.broadcastErrors).toBeCalledTimes(1); + }); + }); }); }); diff --git a/packages/composables/src/hooks/useCart/index.ts b/packages/composables/src/hooks/useCart/index.ts index 31d1a5660..d7e7c3118 100644 --- a/packages/composables/src/hooks/useCart/index.ts +++ b/packages/composables/src/hooks/useCart/index.ts @@ -8,6 +8,8 @@ import { } from "@shopware-pwa/shopware-6-client"; import { ClientApiError } from "@shopware-pwa/commons/interfaces/errors/ApiError"; import { Cart } from "@shopware-pwa/commons/interfaces/models/checkout/cart/Cart"; +import { EntityError } from "@shopware-pwa/commons/interfaces/models/common/EntityError"; + import { Product } from "@shopware-pwa/commons/interfaces/models/content/product/Product"; import { LineItem } from "@shopware-pwa/commons/interfaces/models/checkout/cart/line-item/LineItem"; import { @@ -17,6 +19,7 @@ import { useSharedState, } from "@shopware-pwa/composables"; import { ApplicationVueContext } from "../../appContext"; +import { broadcastErrors } from "../../internalHelpers/errorHandler"; import { deprecationWarning } from "@shopware-pwa/commons"; /** @@ -55,6 +58,7 @@ export interface IUseCart { totalPrice: ComputedRef; shippingTotal: ComputedRef; subtotal: ComputedRef; + cartErrors: ComputedRef; } /** @@ -79,6 +83,7 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => { loading.value = true; try { const result = await getCart(apiInstance); + broadcastUpcomingErrors(result); _storeCart.value = result; } catch (e) { const err: ClientApiError = e; @@ -95,12 +100,14 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => { id: string; quantity?: number; }) { - const result = await addProductToCart(id, quantity, apiInstance); - _storeCart.value = result; + const addToCartResult = await addProductToCart(id, quantity, apiInstance); + broadcastUpcomingErrors(addToCartResult); + _storeCart.value = addToCartResult; } async function removeItem({ id }: LineItem) { const result = await removeCartItem(id, apiInstance); + broadcastUpcomingErrors(result); _storeCart.value = result; } @@ -116,12 +123,14 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => { async function changeProductQuantity({ id, quantity }: any) { const result = await changeCartItemQuantity(id, quantity, apiInstance); + broadcastUpcomingErrors(result); _storeCart.value = result; } async function submitPromotionCode(promotionCode: string) { if (promotionCode) { const result = await addPromotionCode(promotionCode, apiInstance); + ``; _storeCart.value = result; broadcast(INTERCEPTOR_KEYS.ADD_PROMOTION_CODE, { result, @@ -130,6 +139,27 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => { } } + function broadcastUpcomingErrors(cartResult: Cart): void { + if (!cartResult) { + return; + } + + try { + const cartErrorsKeys = Object.keys(_storeCart.value?.errors || {}); + const cartResultErrorKeys = Object.keys(cartResult.errors || {}); + const upcomingErrorsKeys = cartResultErrorKeys.filter( + (resultErrorKey) => !cartErrorsKeys.includes(resultErrorKey) + ); + const entityErrors: EntityError[] = Object.values( + cartResult.errors || {} + ).filter((entityError) => upcomingErrorsKeys.includes(entityError.key)); + + broadcastErrors(entityErrors, `[${contextName}][cartError]`, rootContext); + } catch (error) { + console.error("[useCart][broadcastUpcomingErrors]", error); + } + } + const appliedPromotionCodes = computed(() => { return cartItems.value.filter( (cartItem: LineItem) => cartItem.type === "promotion" @@ -169,6 +199,10 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => { return cartPrice || 0; }); + const cartErrors: ComputedRef = computed( + () => (cart.value?.errors && Object.values(cart.value.errors)) || [] + ); + return { addProduct, addPromotionCode: submitPromotionCode, @@ -185,5 +219,6 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => { totalPrice, shippingTotal, subtotal, + cartErrors, }; }; diff --git a/packages/composables/src/internalHelpers/errorHandler.ts b/packages/composables/src/internalHelpers/errorHandler.ts new file mode 100644 index 000000000..6ce6041cc --- /dev/null +++ b/packages/composables/src/internalHelpers/errorHandler.ts @@ -0,0 +1,42 @@ +import { + EntityError, + ErrorLevel, +} from "@shopware-pwa/commons/interfaces/models/common/EntityError"; + +import { INTERCEPTOR_KEYS, useIntercept } from "@shopware-pwa/composables"; +import { ApplicationVueContext } from "../appContext"; + +/** + * @beta + */ +export const broadcastErrors = ( + errors: EntityError[], + methodName: string, + rootContext: ApplicationVueContext +): void => { + if (!Array.isArray(errors) || !errors.length || !methodName || !rootContext) { + return; + } + const { broadcast } = useIntercept(rootContext); + + errors.forEach((error) => { + let interceptorKey; + + switch (error.level) { + case ErrorLevel.NOTICE: + interceptorKey = INTERCEPTOR_KEYS.NOTICE; + break; + case ErrorLevel.WARNING: + interceptorKey = INTERCEPTOR_KEYS.WARNING; + break; + default: + interceptorKey = INTERCEPTOR_KEYS.ERROR; + } + + broadcast(interceptorKey, { + methodName: methodName, + inputParams: {}, + [interceptorKey]: error, + }); + }); +}; diff --git a/packages/composables/src/logic/useIntercept.ts b/packages/composables/src/logic/useIntercept.ts index 4b3a7e6a9..2df7695ea 100644 --- a/packages/composables/src/logic/useIntercept.ts +++ b/packages/composables/src/logic/useIntercept.ts @@ -35,6 +35,24 @@ export const INTERCEPTOR_KEYS = { * - error - string - message of the error */ ERROR: "error", + /** + * Broadcasted through application in case of relevant warning. + * Can be used to inform end-user about current request's problems. + * As a parameter passes: + * - methodName - string - method where error occured + * - inputParams - Object - input params of the method + * - warning - Object - error object with specific message, like CartError object + */ + WARNING: "warning", + /** + * Broadcasted through application in case of relevant notice message. + * Can be used to inform end-user about current request's problems. + * As a parameter passes: + * - methodName - string - method where error occured + * - inputParams - Object - input params of the method + * - notice - Object - error object with specific message, like CartError object + */ + NOTICE: "notice", /** * Broadcasted by useCheckout, createOrder method. * As a parameter passes: diff --git a/packages/default-theme/src/logic/notifications/index.js b/packages/default-theme/src/logic/notifications/index.js index c15a210fa..5f7b3e27c 100644 --- a/packages/default-theme/src/logic/notifications/index.js +++ b/packages/default-theme/src/logic/notifications/index.js @@ -1,5 +1,10 @@ import { useNotifications } from "@shopware-pwa/composables" +export const warningNotification = ({ warning }, rootContext) => { + const { pushWarning } = useNotifications(rootContext) + pushWarning(warning.message) +} + export const addToWishlistNotification = (payload, rootContext) => { const { pushSuccess } = useNotifications(rootContext) pushSuccess( diff --git a/packages/default-theme/src/plugins/notifications.js b/packages/default-theme/src/plugins/notifications.js index 4821b9253..cf8529eab 100644 --- a/packages/default-theme/src/plugins/notifications.js +++ b/packages/default-theme/src/plugins/notifications.js @@ -3,6 +3,7 @@ import { addPromotionCodeNotification, addToCartNotification, addToWishlistNotification, + warningNotification, } from "@/logic/notifications" export default ({ app }) => { @@ -10,4 +11,5 @@ export default ({ app }) => { intercept(INTERCEPTOR_KEYS.ADD_TO_CART, addToCartNotification) intercept(INTERCEPTOR_KEYS.ADD_PROMOTION_CODE, addPromotionCodeNotification) intercept(INTERCEPTOR_KEYS.ADD_TO_WISHLIST, addToWishlistNotification) + intercept(INTERCEPTOR_KEYS.WARNING, warningNotification) }