diff --git a/api/composables.api.md b/api/composables.api.md index 09b734e85..b89f98c0b 100644 --- a/api/composables.api.md +++ b/api/composables.api.md @@ -42,6 +42,7 @@ import { SessionContext } from '@shopware-pwa/commons/interfaces/response/Sessio import { ShippingAddress } from '@shopware-pwa/commons/interfaces/models/checkout/customer/ShippingAddress'; import { ShippingMethod } from '@shopware-pwa/commons/interfaces/models/checkout/shipping/ShippingMethod'; import { ShopwareApiInstance } from '@shopware-pwa/shopware-6-client'; +import { ShopwareError } from '@shopware-pwa/commons/interfaces/errors/ApiError'; import { ShopwareSearchParams } from '@shopware-pwa/commons/interfaces/search/SearchCriteria'; import { Sort } from '@shopware-pwa/commons/interfaces/search/SearchCriteria'; import { StoreNavigationElement } from '@shopware-pwa/commons/interfaces/models/content/navigation/Navigation'; @@ -379,8 +380,11 @@ export interface IUseUser { error: Ref; // (undocumented) errors: UnwrapRef<{ - login: string; - register: string[]; + login: ShopwareError[]; + register: ShopwareError[]; + resetPassword: ShopwareError[]; + updatePassword: ShopwareError[]; + updateEmail: ShopwareError[]; }>; // (undocumented) getOrderDetails: (orderId: string) => Promise; diff --git a/api/helpers.api.md b/api/helpers.api.md index e021fb54a..c0cd1163f 100644 --- a/api/helpers.api.md +++ b/api/helpers.api.md @@ -111,7 +111,7 @@ export function getListingAvailableFilters(aggregations: Aggregations | undefine // @beta (undocumented) export function getListingFilters(aggregations: Aggregations | undefined | null): ListingFilter[]; -// @alpha +// @alpha @deprecated export function getMessagesFromErrorsArray(errors: ShopwareError[]): string[]; // @alpha diff --git a/docs/.vuepress/sidebar.js b/docs/.vuepress/sidebar.js index 1c02f0bcb..76e90b446 100644 --- a/docs/.vuepress/sidebar.js +++ b/docs/.vuepress/sidebar.js @@ -39,6 +39,7 @@ module.exports = { "/landing/concepts/payment", "/landing/concepts/snippets", "/landing/concepts/interceptor", + "/landing/concepts/api-client-errors", ], }, { diff --git a/docs/landing/concepts/README.md b/docs/landing/concepts/README.md index bb0f5521c..e96cf4c6e 100644 --- a/docs/landing/concepts/README.md +++ b/docs/landing/concepts/README.md @@ -20,6 +20,12 @@ Checkout and payment are critical parts within every eCommerce application. Lear → [Payment Guide](./payment) +## Client API Errors and handling + +Shopware-pwa provides an unified error's structure for all errors that may appear during working with Shopware 6 API. + +→ [API Errors Guide](./api-client-errors) + ## User Management How do you manage user data, newsletter sign-ins, address updates? We've got you covered. Follow our guide to show how customize the self-service experience within the PWA. diff --git a/docs/landing/concepts/api-client-errors.md b/docs/landing/concepts/api-client-errors.md new file mode 100644 index 000000000..bb06d9ad7 --- /dev/null +++ b/docs/landing/concepts/api-client-errors.md @@ -0,0 +1,147 @@ +# API specific errors & handling + +Shopware-pwa provides an unified error's structure for all errors that may appear during working with Shopware 6 API. + +## Shopware 6 API error structure + +API throws an error in specific format. The API responds error details as an array of errors (always, even there is only one error in the response). + +Default response containing errors looks like this: +```ts +{ + "errors": ShopwareError[] +} + +``` +where, `ShopwareError` interface is: +```ts +interface ShopwareError { + status: string; // HTTP Status code, like "403" + code: string; // internal error code, like "CHECKOUT__CUSTOMER_NOT_LOGGED_IN", or "VIOLATION::IS_BLANK_ERROR" + title: string; // title of an error, like "Forbidden" or "Not found" + detail: string; // additional information, like "Customer is not logged in." + source?: any; // only for HTTP 400 type errors, like `{"pointer": "/email"}` + meta: any; // unknown data that can be passed on backend side, like stacktrace in API's development mode +} +``` + +## Other API client's errors + +Besides errors that may be returned by API, shopware-pwa recognize another type of errors: the Client errors itself, independent from API response: +- timeout (axios waits too long for the response, and timeout setting is reached) +- network error (there are some connection issues) + +Each one is transformed into consistent format and gets appriopriate status code. + +## Consisten format + +`@shopware-pwa/shopware-6-client` package is responsible for connection layer between **shopware-pwa and API**. The error interceptor translates every (or almost every) type of an error into consistent one. Thanks to this, every error can be handled in the same way in the application in the next layers. + + +```ts +interface ClientApiError { + messages: ShopwareError[]; // contains array of ShopwareError objects, even if it's an issue on axios side + statusCode: number; // HTTP status code +} +``` + +## Example from the code + +Here's a simple scenario of what may happen durin login and how to deal with such errors relying on `ClientApiError` & `SwErrorsList` vue component. + +1. Let's say we are trying to log in. The code below show what it can look like: + + ```ts + // somewhere in the logic + + const errors = ref([]); // errors reference that can be imported in the Vue component. + + try { + await apiLogout(); + broadcast(INTERCEPTOR_KEYS.USER_LOGOUT); + } catch (e) { // we expect the ClientApiError, always + const err: ClientApiError = e; + errors.value = err.messages; // (3) and need only array of messages to be displayed later on + + broadcast(INTERCEPTOR_KEYS.ERROR, { // (4) optionally, you can plug into broadcasted error using interceptors (useIntercept composable) to show notifications or do something with an error. + methodName: `[${contextName}][logout]`, + inputParams: {}, + error: err, + }); + } + + ``` + +2. The customer provides the wrong data +3. `errors` object is fullfilled with `ClientApiErrors`'s `messages` array. +4. `SwErrorsList` component receives `loginErrors` (our `errors` reference from previous step). + ```js + import SwErrorsList from "@/components/SwErrorsList.vue" + + + ``` +5. Component displays the errors. + +## SwErrorsList component + +The component is located at `@/components/SwErrorsList.vue` and accepts only one prop: `list` and in fact that's the `ShopwareError[]` interface. + +```ts +props: { + list: { + type: Array, + default: [], + }, + }, +``` + +1. The component detects if there is only one message or more. If an amount of errors is more than 1 -> the errors are shown as a bullet list and prepended with `encountered problems` title. Otherwise the error message is only one string. + +2. The error can be "field" related as well, that means the error comes together with HTTP 400 error and should display additional details like `field` that causes validation errors. + + +## Intercept the errors + +The errors that may occure in the logic layer (composables) can be broadcasted and intercepted in one place as well. There are many places the errors are broadcasted in order to be listened by some functions like additional logger or own way of error notification. + +Let's try to intercept the error from the `## Example from the code` above. + +```js +broadcast(INTERCEPTOR_KEYS.ERROR, { + methodName: `[${contextName}][logout]`, + inputParams: {}, + error: err, + } +); +``` + +Example of how to deal with broadcasted errors. The example of a [nuxt plugin](https://nuxtjs.org/docs/2.x/directory-structure/plugins) below shows how to listen for ERROR type event within `useIntercept` functionality and do something about it. In this case, plugin subscribes the events for `INTERCEPTOR_KEYS.ERROR` key and pushes errors to the external logs server using UDP. + +```js +import { useIntercept, INTERCEPTOR_KEYS } from "@shopware-pwa/composables" +import { configure, getLogger } from 'log4js' + +export default ({ app }) => { + configure({ + appenders: { + logstash: { + type: '@log4js-node/logstashudp', // UDP "driver" works only on SSR + host: 'mylogstash.server', // for demo only; use value from env instead + port: 5000 // for demo only; use value from env instead + } + }, + categories: { + default: { appenders: ['logstash'], level: 'info' } + } + }) + const logger = getLogger() // get the logstash client instance + + const { intercept } = useIntercept(app) + intercept(INTERCEPTOR_KEYS.ERROR, (payload, rootContext) => { + logger.error(payload) // send the error to the logstash server + }) + +} +``` + +Thanks to this, all the errors can be captured in one place. Of course there can be some conditions and filtering needed depending on the case. \ No newline at end of file diff --git a/docs/landing/resources/api/composables.iuseuser.errors.md b/docs/landing/resources/api/composables.iuseuser.errors.md index 8429c6a27..aaf980278 100644 --- a/docs/landing/resources/api/composables.iuseuser.errors.md +++ b/docs/landing/resources/api/composables.iuseuser.errors.md @@ -11,7 +11,10 @@ ```typescript errors: UnwrapRef<{ - login: string; - register: string[]; + login: ShopwareError[]; + register: ShopwareError[]; + resetPassword: ShopwareError[]; + updatePassword: ShopwareError[]; + updateEmail: ShopwareError[]; }>; ``` diff --git a/docs/landing/resources/api/composables.iuseuser.md b/docs/landing/resources/api/composables.iuseuser.md index bd146694f..7e691bfb0 100644 --- a/docs/landing/resources/api/composables.iuseuser.md +++ b/docs/landing/resources/api/composables.iuseuser.md @@ -24,7 +24,7 @@ export interface IUseUser | [country](./composables.iuseuser.country.md) | Ref<Country \| null> | (BETA) | | [deleteAddress](./composables.iuseuser.deleteaddress.md) | (addressId: string) => Promise<boolean> | (BETA) | | [error](./composables.iuseuser.error.md) | Ref<any> | (BETA) | -| [errors](./composables.iuseuser.errors.md) | UnwrapRef<{ login: string; register: string\[\]; }> | (BETA) | +| [errors](./composables.iuseuser.errors.md) | UnwrapRef<{ login: ShopwareError\[\]; register: ShopwareError\[\]; resetPassword: ShopwareError\[\]; updatePassword: ShopwareError\[\]; updateEmail: ShopwareError\[\]; }> | (BETA) | | [getOrderDetails](./composables.iuseuser.getorderdetails.md) | (orderId: string) => Promise<Order \| undefined> | (BETA) | | [isCustomerSession](./composables.iuseuser.iscustomersession.md) | ComputedRef<boolean> | (BETA) | | [isGuestSession](./composables.iuseuser.isguestsession.md) | ComputedRef<boolean> | (BETA) | diff --git a/packages/cli/src/templates/project-template/_commitlint.config.js b/packages/cli/src/templates/project-template/_commitlint.config.js index 98ee7dfc2..5717d2571 100644 --- a/packages/cli/src/templates/project-template/_commitlint.config.js +++ b/packages/cli/src/templates/project-template/_commitlint.config.js @@ -1,3 +1,3 @@ module.exports = { - extends: ['@commitlint/config-conventional'], + extends: ["@commitlint/config-conventional"], } diff --git a/packages/cli/src/templates/project-template/jest.config.js b/packages/cli/src/templates/project-template/jest.config.js index 0c8736309..cef0b3094 100644 --- a/packages/cli/src/templates/project-template/jest.config.js +++ b/packages/cli/src/templates/project-template/jest.config.js @@ -1,17 +1,17 @@ module.exports = { moduleNameMapper: { - '^@/(.*)$': '/$1', - '^~/(.*)$': '/$1', - '^vue$': 'vue/dist/vue.common.js', + "^@/(.*)$": "/$1", + "^~/(.*)$": "/$1", + "^vue$": "vue/dist/vue.common.js", }, - moduleFileExtensions: ['js', 'vue', 'json'], + moduleFileExtensions: ["js", "vue", "json"], transform: { - '^.+\\.js$': 'babel-jest', - '.*\\.(vue)$': 'vue-jest', + "^.+\\.js$": "babel-jest", + ".*\\.(vue)$": "vue-jest", }, collectCoverage: true, collectCoverageFrom: [ - '/components/**/*.vue', - '/pages/**/*.vue', + "/components/**/*.vue", + "/pages/**/*.vue", ], } diff --git a/packages/cli/src/templates/project-template/nuxt.config.js b/packages/cli/src/templates/project-template/nuxt.config.js index 342650bc0..8500c83d3 100644 --- a/packages/cli/src/templates/project-template/nuxt.config.js +++ b/packages/cli/src/templates/project-template/nuxt.config.js @@ -1,8 +1,8 @@ -import extendNuxtConfig from '@shopware-pwa/nuxt-module/config' +import extendNuxtConfig from "@shopware-pwa/nuxt-module/config" export default extendNuxtConfig({ head: { - title: 'Shopware PWA', - meta: [{ hid: 'description', name: 'description', content: '' }], + title: "Shopware PWA", + meta: [{ hid: "description", name: "description", content: "" }], }, }) diff --git a/packages/commons/interfaces/errors/ApiError.ts b/packages/commons/interfaces/errors/ApiError.ts index 732e542b3..4956a730a 100644 --- a/packages/commons/interfaces/errors/ApiError.ts +++ b/packages/commons/interfaces/errors/ApiError.ts @@ -1,7 +1,7 @@ import { AxiosResponse, AxiosError } from "axios"; /** - * @alpha + * @beta */ export interface ShopwareError { status: string; @@ -25,6 +25,6 @@ export interface ShopwareApiError extends AxiosError { * @alpha */ export interface ClientApiError { - message: string | ShopwareError[]; + messages: ShopwareError[]; statusCode: number; } diff --git a/packages/composables/__tests__/useCart.spec.ts b/packages/composables/__tests__/useCart.spec.ts index cf59e6c56..e3a095414 100644 --- a/packages/composables/__tests__/useCart.spec.ts +++ b/packages/composables/__tests__/useCart.spec.ts @@ -259,11 +259,11 @@ describe("Composables - useCart", () => { it("should show an error when cart is not refreshed", async () => { const { count, refreshCart, error } = useCart(rootContextMock); mockedShopwareClient.getCart.mockRejectedValueOnce({ - message: "Some problem", + messages: [{ detail: "Some problem" }], }); await refreshCart(); expect(count.value).toEqual(0); - expect(error.value).toEqual("Some problem"); + expect(error.value).toEqual([{ detail: "Some problem" }]); }); }); diff --git a/packages/composables/__tests__/useCountries.spec.ts b/packages/composables/__tests__/useCountries.spec.ts index 4afa81742..035f2b69d 100644 --- a/packages/composables/__tests__/useCountries.spec.ts +++ b/packages/composables/__tests__/useCountries.spec.ts @@ -84,7 +84,7 @@ describe("Composables - useCountries", () => { describe("fetchCoutries", () => { it("should assing error to error message if getAvailableCountries throws one", async () => { mockedApiClient.getAvailableCountries.mockRejectedValueOnce({ - message: "Couldn't fetch available countries.", + messages: "Couldn't fetch available countries.", }); const { fetchCountries, error } = useCountries(rootContextMock); await fetchCountries(); diff --git a/packages/composables/__tests__/useNavigation.spec.ts b/packages/composables/__tests__/useNavigation.spec.ts index b541f09e9..49bffe34e 100644 --- a/packages/composables/__tests__/useNavigation.spec.ts +++ b/packages/composables/__tests__/useNavigation.spec.ts @@ -104,14 +104,16 @@ describe("Composables - useNavigation", () => { }); it("should assign empty array for navigation if the response throws an error", async () => { - mockedGetPage.getStoreNavigation.mockRejectedValueOnce("some error"); + mockedGetPage.getStoreNavigation.mockRejectedValueOnce({ + messages: [{ detail: "some error" }], + }); const { navigationElements, loadNavigationElements } = useNavigation(rootContextMock); await loadNavigationElements({ depth: 2 }); expect(navigationElements.value).toEqual([]); expect(consoleErrorSpy).toBeCalledWith( "[useNavigation][loadNavigationElements]", - "some error" + [{ detail: "some error" }] ); }); }); diff --git a/packages/composables/__tests__/useProduct.spec.ts b/packages/composables/__tests__/useProduct.spec.ts index b8b820356..4f97d5cc5 100644 --- a/packages/composables/__tests__/useProduct.spec.ts +++ b/packages/composables/__tests__/useProduct.spec.ts @@ -128,12 +128,12 @@ describe("Composables - useProduct", () => { it("should have failed on bad url settings", async () => { const { search, product, error } = useProduct(rootContextMock); mockedAxios.getProduct.mockRejectedValueOnce({ - message: "Something went wrong...", + messages: [{ detail: "Something went wrong..." }], } as ClientApiError); expect(product.value).toBeUndefined(); await search(""); expect(product.value).toBeUndefined(); - expect(error.value).toEqual("Something went wrong..."); + expect(error.value).toEqual([{ detail: "Something went wrong..." }]); }); }); }); diff --git a/packages/composables/__tests__/useSalutations.spec.ts b/packages/composables/__tests__/useSalutations.spec.ts index 6afad26fb..b3d49717b 100644 --- a/packages/composables/__tests__/useSalutations.spec.ts +++ b/packages/composables/__tests__/useSalutations.spec.ts @@ -86,13 +86,13 @@ describe("Composables - useSalutations", () => { describe("fetchSalutations", () => { it("should assing error to error message if getAvailableSalutations throws one", async () => { mockedApiClient.getAvailableSalutations.mockRejectedValueOnce({ - message: "Couldn't fetch available salutations.", + messages: [{ detail: "Couldn't fetch available salutations." }], }); const { fetchSalutations, error } = useSalutations(rootContextMock); await fetchSalutations(); - expect(error.value.toString()).toBe( - "Couldn't fetch available salutations." - ); + expect(error.value).toStrictEqual([ + { detail: "Couldn't fetch available salutations." }, + ]); }); }); describe("onMounted", () => { diff --git a/packages/composables/__tests__/useUser.spec.ts b/packages/composables/__tests__/useUser.spec.ts index 2f6d07f64..ce5045e48 100644 --- a/packages/composables/__tests__/useUser.spec.ts +++ b/packages/composables/__tests__/useUser.spec.ts @@ -173,28 +173,30 @@ describe("Composables - useUser", () => { }); describe("login", () => { it("should not login user without credentials", async () => { - mockedApiClient.login.mockRejectedValueOnce( - new Error("Provide username and password for login") - ); - const { isLoggedIn, error, login } = useUser(rootContextMock); + mockedApiClient.login.mockRejectedValueOnce({ + messages: [{ detail: "Provide username and password for login" }], + } as any); + const { isLoggedIn, errors, login } = useUser(rootContextMock); const result = await login(undefined as any); expect(result).toEqual(false); expect(isLoggedIn.value).toBeFalsy(); - expect(error.value).toEqual("Provide username and password for login"); + expect(errors.login).toEqual([ + { detail: "Provide username and password for login" }, + ]); }); it("should not login user with bad credentials", async () => { - mockedApiClient.login.mockRejectedValueOnce( - new Error("Bad user credentials") - ); - const { isLoggedIn, error, login } = useUser(rootContextMock); + mockedApiClient.login.mockRejectedValueOnce({ + messages: [{ detail: "Bad user credentials" }], + } as any); + const { isLoggedIn, errors, login } = useUser(rootContextMock); const result = await login({ username: "qwe@qwe.com", password: "fakePassword", }); expect(result).toEqual(false); expect(isLoggedIn.value).toBeFalsy(); - expect(error.value).toEqual("Bad user credentials"); + expect(errors.login).toEqual([{ detail: "Bad user credentials" }]); }); it("error message should be appriopriate to the 401 HTTP status code", async () => { @@ -203,13 +205,13 @@ describe("Composables - useUser", () => { status: 401, }, }); - const { error, login } = useUser(rootContextMock); + const { errors, login } = useUser(rootContextMock); const result = await login({ username: "qwe@qwe.com", password: "fakePassword", }); expect(result).toEqual(false); - expect(error.value).toEqual(undefined); + expect(errors.login).toEqual(undefined); }); it("should login user successfully", async () => { @@ -247,15 +249,19 @@ describe("Composables - useUser", () => { describe("register", () => { it("should not invoke user register without any data", async () => { - mockedApiClient.register.mockRejectedValueOnce( - new Error("Provide requested information to create user account") - ); + mockedApiClient.register.mockRejectedValueOnce({ + messages: [ + { detail: "Provide requested information to create user account" }, + ], + } as any); const { isLoggedIn, errors, register } = useUser(rootContextMock); const result = await register(undefined as any); expect(result).toEqual(false); expect(isLoggedIn.value).toBeFalsy(); expect(errors.register).toEqual([ - "Provide requested information to create user account", + { + detail: "Provide requested information to create user account", + }, ]); }); it("should register user successfully", async () => { @@ -321,9 +327,9 @@ describe("Composables - useUser", () => { it("should show an error when user is not logged out", async () => { stateUser.value = { id: "111" }; - mockedApiClient.logout.mockRejectedValueOnce( - new Error("Something wrong with logout") - ); + mockedApiClient.logout.mockRejectedValueOnce({ + messages: [{ detail: "Something wrong with logout" }], + }); mockedApiClient.getCustomer.mockResolvedValueOnce({ id: "111", } as any); @@ -331,7 +337,9 @@ describe("Composables - useUser", () => { expect(isLoggedIn.value).toBeTruthy(); await logout(); expect(isLoggedIn.value).toBeTruthy(); - expect(error.value).toEqual("Something wrong with logout"); + expect(error.value).toEqual([ + { detail: "Something wrong with logout" }, + ]); }); }); @@ -405,13 +413,15 @@ describe("Composables - useUser", () => { }); it("should not add empty address", async () => { mockedApiClient.createCustomerAddress.mockRejectedValueOnce({ - message: "There is no address provided", + messages: [{ detail: "There is no address provided" }], } as ClientApiError); const { addAddress, error } = useUser(rootContextMock); const response = await addAddress(null as any); expect(mockedApiClient.createCustomerAddress).toBeCalledTimes(1); expect(response).toBeUndefined(); - expect(error.value).toEqual("There is no address provided"); + expect(error.value).toEqual([ + { detail: "There is no address provided" }, + ]); }); }); @@ -467,12 +477,14 @@ describe("Composables - useUser", () => { it("should invoke client getCustomerAddresses method and assign error message if client request is rejected", async () => { mockedApiClient.getCustomerAddresses.mockRejectedValueOnce({ - message: "Something went wrong...", + messages: [{ detail: "Something went wrong..." }], }); const { loadAddresses, error } = useUser(rootContextMock); await loadAddresses(); expect(mockedApiClient.getCustomerAddresses).toBeCalledTimes(1); - expect(error.value).toBe("Something went wrong..."); + expect(error.value).toStrictEqual([ + { detail: "Something went wrong..." }, + ]); }); }); @@ -499,13 +511,15 @@ describe("Composables - useUser", () => { it("should invoke client getUserCountry method and assign error message if client request is rejected", async () => { mockedApiClient.getUserCountry.mockRejectedValueOnce({ - message: "Something went wrong...", + messages: [{ detail: "Something went wrong..." }], }); const { loadCountry, error } = useUser(rootContextMock); const salutationId = "123qwe"; await loadCountry(salutationId); expect(mockedApiClient.getUserCountry).toBeCalledTimes(1); - expect(error.value).toBe("Something went wrong..."); + expect(error.value).toStrictEqual([ + { detail: "Something went wrong..." }, + ]); }); }); @@ -532,13 +546,15 @@ describe("Composables - useUser", () => { it("should invoke client getUserSalutation method and assign error message if client request is rejected", async () => { mockedApiClient.getUserSalutation.mockRejectedValueOnce({ - message: "Something went wrong...", + messages: [{ detail: "Something went wrong..." }], }); const { loadSalutation, error } = useUser(rootContextMock); const userId = "123qwe"; await loadSalutation(userId); expect(mockedApiClient.getUserSalutation).toBeCalledTimes(1); - expect(error.value).toBe("Something went wrong..."); + expect(error.value).toStrictEqual([ + { detail: "Something went wrong..." }, + ]); }); }); @@ -591,7 +607,7 @@ describe("Composables - useUser", () => { it("should return false and set the error.value on api-client rejection", async () => { mockedApiClient.setDefaultCustomerShippingAddress.mockRejectedValueOnce( { - message: "Error occurred", + messages: [{ detail: "Error occured" }], } ); const { markAddressAsDefault, error } = useUser(rootContextMock); @@ -604,7 +620,7 @@ describe("Composables - useUser", () => { ).toBeCalledTimes(1); expect(response).toBe(false); - expect(error.value).toBe("Error occurred"); + expect(error.value).toStrictEqual([{ detail: "Error occured" }]); }); }); describe("updatePersonalInfo", () => { @@ -654,10 +670,10 @@ describe("Composables - useUser", () => { expect(response).toBeTruthy(); }); it("should return false and set the error.value on api-client on updatePassword rejection", async () => { - mockedApiClient.updatePassword.mockImplementationOnce(async () => - Promise.reject("Password must be at least 8 characters long") - ); - const { updatePassword, error } = useUser(rootContextMock); + mockedApiClient.updatePassword.mockRejectedValueOnce({ + messages: [{ detail: "Password must be at least 8 characters long" }], + }); + const { updatePassword, errors } = useUser(rootContextMock); const response = await updatePassword({ password: "qweqweqwe", newPassword: "qwe", @@ -665,9 +681,9 @@ describe("Composables - useUser", () => { }); expect(mockedApiClient.updatePassword).toBeCalledTimes(1); expect(response).toBeFalsy(); - expect(error.value).toEqual( - "Password must be at least 8 characters long" - ); + expect(errors.updatePassword).toEqual([ + { detail: "Password must be at least 8 characters long" }, + ]); }); }); describe("resetPassword", () => { @@ -683,16 +699,18 @@ describe("Composables - useUser", () => { expect(response).toBeTruthy(); }); it("should return false and set the error.value on api-client on resetPassword rejection", async () => { - mockedApiClient.resetPassword.mockImplementationOnce(async () => - Promise.reject("Email does not fit to any in Sales Channel") - ); - const { resetPassword, error } = useUser(rootContextMock); + mockedApiClient.resetPassword.mockRejectedValueOnce({ + messages: [{ detail: "Email does not fit to any in Sales Channel" }], + }); + const { resetPassword, errors } = useUser(rootContextMock); const response = await resetPassword({ email: "qweqwe@qwe.com", }); expect(mockedApiClient.resetPassword).toBeCalledTimes(1); expect(response).toBeFalsy(); - expect(error.value).toEqual("Email does not fit to any in Sales Channel"); + expect(errors.resetPassword).toEqual([ + { detail: "Email does not fit to any in Sales Channel" }, + ]); }); }); describe("updateEmail", () => { @@ -710,10 +728,13 @@ describe("Composables - useUser", () => { expect(response).toBeTruthy(); }); it("should return false and set the error.value on api-client on updatePassword rejection", async () => { - mockedApiClient.updateEmail.mockImplementationOnce(async () => - Promise.reject("Email confirmation does not match to the first one") - ); - const { updateEmail, error } = useUser(rootContextMock); + mockedApiClient.updateEmail.mockRejectedValueOnce({ + messages: [ + { detail: "Email confirmation does not match to the first one" }, + ], + }); + + const { updateEmail, errors } = useUser(rootContextMock); const response = await updateEmail({ password: "qweqweqwe", email: "qweqwe@qwe.com", @@ -721,9 +742,9 @@ describe("Composables - useUser", () => { }); expect(mockedApiClient.updateEmail).toBeCalledTimes(1); expect(response).toBeFalsy(); - expect(error.value).toEqual( - "Email confirmation does not match to the first one" - ); + expect(errors.updateEmail).toEqual([ + { detail: "Email confirmation does not match to the first one" }, + ]); }); }); }); diff --git a/packages/composables/src/hooks/useCart/index.ts b/packages/composables/src/hooks/useCart/index.ts index 6af498727..15067e859 100644 --- a/packages/composables/src/hooks/useCart/index.ts +++ b/packages/composables/src/hooks/useCart/index.ts @@ -87,7 +87,7 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => { _storeCart.value = result; } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; } finally { loading.value = false; } diff --git a/packages/composables/src/hooks/useCountries.ts b/packages/composables/src/hooks/useCountries.ts index 4e721ff68..8fa3c8e0c 100644 --- a/packages/composables/src/hooks/useCountries.ts +++ b/packages/composables/src/hooks/useCountries.ts @@ -34,7 +34,7 @@ export const useCountries = ( _sharedCountried.value = elements; } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; } }; diff --git a/packages/composables/src/hooks/useNavigation.ts b/packages/composables/src/hooks/useNavigation.ts index 192a25141..ef1f1671b 100644 --- a/packages/composables/src/hooks/useNavigation.ts +++ b/packages/composables/src/hooks/useNavigation.ts @@ -85,7 +85,7 @@ export const useNavigation = ( sharedElements.value = navigationResponse || []; } catch (e) { sharedElements.value = []; - console.error("[useNavigation][loadNavigationElements]", e); + console.error("[useNavigation][loadNavigationElements]", e.messages); } }; diff --git a/packages/composables/src/hooks/useProduct/index.ts b/packages/composables/src/hooks/useProduct/index.ts index 4ac35e8f4..c0146f211 100644 --- a/packages/composables/src/hooks/useProduct/index.ts +++ b/packages/composables/src/hooks/useProduct/index.ts @@ -70,7 +70,7 @@ export const useProduct = ( return result; } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; } finally { loading.value = false; } diff --git a/packages/composables/src/hooks/useSalutations.ts b/packages/composables/src/hooks/useSalutations.ts index 4fec7a602..fb760a013 100644 --- a/packages/composables/src/hooks/useSalutations.ts +++ b/packages/composables/src/hooks/useSalutations.ts @@ -35,7 +35,7 @@ export const useSalutations = ( _salutations.value = elements; } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; } }; diff --git a/packages/composables/src/hooks/useUser.ts b/packages/composables/src/hooks/useUser.ts index 439b1d5d8..35fcce688 100644 --- a/packages/composables/src/hooks/useUser.ts +++ b/packages/composables/src/hooks/useUser.ts @@ -30,7 +30,10 @@ import { AddressType, } from "@shopware-pwa/commons/interfaces/models/checkout/customer/CustomerAddress"; import { CustomerRegistrationParams } from "@shopware-pwa/commons/interfaces/request/CustomerRegistrationParams"; -import { ClientApiError } from "@shopware-pwa/commons/interfaces/errors/ApiError"; +import { + ClientApiError, + ShopwareError, +} from "@shopware-pwa/commons/interfaces/errors/ApiError"; import { Country } from "@shopware-pwa/commons/interfaces/models/system/country/Country"; import { Salutation } from "@shopware-pwa/commons/interfaces/models/system/salutation/Salutation"; import { @@ -63,8 +66,11 @@ export interface IUseUser { loading: Ref; error: Ref; errors: UnwrapRef<{ - login: string; - register: string[]; + login: ShopwareError[]; + register: ShopwareError[]; + resetPassword: ShopwareError[]; + updatePassword: ShopwareError[]; + updateEmail: ShopwareError[]; }>; isLoggedIn: ComputedRef; isCustomerSession: ComputedRef; @@ -131,11 +137,17 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { const loading: Ref = ref(false); const error: Ref = ref(null); const errors: UnwrapRef<{ - login: string; - register: string[]; + login: ShopwareError[]; + register: ShopwareError[]; + resetPassword: ShopwareError[]; + updatePassword: ShopwareError[]; + updateEmail: ShopwareError[]; }> = reactive({ - login: "", + login: [], register: [], + resetPassword: [], + updatePassword: [], + updateEmail: [], }); const orders: Ref = ref(null); const addresses = computed(() => storeAddresses.value); @@ -149,6 +161,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { }: { username?: string; password?: string } = {}): Promise => { loading.value = true; error.value = null; + errors.login = [] as any; try { await apiLogin({ username, password }, apiInstance); await refreshUser(); @@ -158,7 +171,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { return true; } catch (e) { const err: ClientApiError = e; - error.value = err.message; + errors.login = err.messages; broadcast(INTERCEPTOR_KEYS.ERROR, { methodName: `[${contextName}][login]`, inputParams: {}, @@ -187,7 +200,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { const err: ClientApiError = e; // temporary workaround - get rid of such hacks in the future // TODO: https://github.com/vuestorefront/shopware-pwa/issues/1498 - errors.register = [err.message as string]; + errors.register = err.messages; broadcast(INTERCEPTOR_KEYS.ERROR, { methodName: `[${contextName}][register]`, inputParams: {}, @@ -205,7 +218,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { broadcast(INTERCEPTOR_KEYS.USER_LOGOUT); } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; broadcast(INTERCEPTOR_KEYS.ERROR, { methodName: `[${contextName}][logout]`, inputParams: {}, @@ -249,7 +262,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { storeAddresses.value = response?.elements; } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; } }; @@ -258,7 +271,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { country.value = await getUserCountry(userId, apiInstance); } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; } }; @@ -267,7 +280,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { salutation.value = await getUserSalutation(salutationId, apiInstance); } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; } }; @@ -296,7 +309,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { await refreshUser(); } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; return false; } @@ -311,7 +324,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { return id; } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; } }; @@ -323,7 +336,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { return id; } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; } }; @@ -333,7 +346,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { return true; } catch (e) { const err: ClientApiError = e; - error.value = err.message; + error.value = err.messages; } return false; @@ -355,9 +368,10 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { updatePasswordData: CustomerUpdatePasswordParam ): Promise => { try { + errors.updatePassword = []; await apiUpdatePassword(updatePasswordData, apiInstance); } catch (e) { - error.value = e; + errors.updatePassword = e.messages; return false; } return true; @@ -369,7 +383,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { try { await apiResetPassword(resetPasswordData, apiInstance); } catch (e) { - error.value = e; + errors.resetPassword = e.messages; return false; } return true; @@ -381,7 +395,7 @@ export const useUser = (rootContext: ApplicationVueContext): IUseUser => { try { await apiUpdateEmail(updateEmailData, apiInstance); } catch (e) { - error.value = e; + errors.updateEmail = e.messages; return false; } return true; diff --git a/packages/default-theme/src/cms/elements/CmsElementCategoryNavigation.vue b/packages/default-theme/src/cms/elements/CmsElementCategoryNavigation.vue index 100c2158d..4b211cc21 100644 --- a/packages/default-theme/src/cms/elements/CmsElementCategoryNavigation.vue +++ b/packages/default-theme/src/cms/elements/CmsElementCategoryNavigation.vue @@ -108,7 +108,7 @@ export default { } catch (error) { console.warn( "CmsElementCategoryNavigation:onMounted:getStoreNavigation", - error + error.messages ) } }) diff --git a/packages/default-theme/src/cms/elements/CmsElementContactForm.vue b/packages/default-theme/src/cms/elements/CmsElementContactForm.vue index c0922953e..9dcde3336 100644 --- a/packages/default-theme/src/cms/elements/CmsElementContactForm.vue +++ b/packages/default-theme/src/cms/elements/CmsElementContactForm.vue @@ -132,10 +132,7 @@ import { SfSelect, SfInput, SfIcon, SfHeading } from "@storefront-ui/vue" import useVuelidate from "@vuelidate/core" import { required, email, minLength } from "@vuelidate/validators" -import { - mapSalutations, - getMessagesFromErrorsArray, -} from "@shopware-pwa/helpers" +import { mapSalutations } from "@shopware-pwa/helpers" import { useSalutations, getApplicationContext, @@ -202,7 +199,7 @@ export default { ) formSent.value = true } catch (e) { - errorMessages.value = getMessagesFromErrorsArray(e.message) + errorMessages.value = e.messages } } @@ -260,7 +257,11 @@ export default { this.$v.$touch() if (this.$v.$invalid) { this.errorMessages = [ - this.$t("Please fill form data and check regulations acceptance."), + { + detail: this.$t( + "Please fill form data and check regulations acceptance." + ), + }, ] return } diff --git a/packages/default-theme/src/cms/elements/CmsElementNesletterForm.vue b/packages/default-theme/src/cms/elements/CmsElementNesletterForm.vue index ea37a5cc4..44bacf728 100644 --- a/packages/default-theme/src/cms/elements/CmsElementNesletterForm.vue +++ b/packages/default-theme/src/cms/elements/CmsElementNesletterForm.vue @@ -75,7 +75,6 @@ import SwButton from "@/components/atoms/SwButton.vue" import { newsletterSubscribe } from "@shopware-pwa/shopware-6-client" import { ref } from "@vue/composition-api" import { getApplicationContext } from "@shopware-pwa/composables" -import { getMessagesFromErrorsArray } from "@shopware-pwa/helpers" import SwErrorsList from "@/components/SwErrorsList.vue" export default { @@ -117,7 +116,7 @@ export default { ) formSent.value = true } catch (e) { - errorMessages.value = getMessagesFromErrorsArray(e.message) + errorMessages.value = e.messages } } diff --git a/packages/default-theme/src/components/SwBottomMenu.vue b/packages/default-theme/src/components/SwBottomMenu.vue index d04c592b0..3525fc43a 100644 --- a/packages/default-theme/src/components/SwBottomMenu.vue +++ b/packages/default-theme/src/components/SwBottomMenu.vue @@ -81,7 +81,7 @@ export default { try { await loadNavigationElements({ depth: 3 }) } catch (e) { - console.error("[SwBottomMenu]", e) + console.error("[SwBottomMenu]", e.messages) } }) return { diff --git a/packages/default-theme/src/components/SwCart.vue b/packages/default-theme/src/components/SwCart.vue index f6c6660a3..98d963d61 100644 --- a/packages/default-theme/src/components/SwCart.vue +++ b/packages/default-theme/src/components/SwCart.vue @@ -147,7 +147,7 @@ export default { ) additionalItemsData.value = result.elements } catch (error) { - console.error("[SwCart][setup][onMounted]", error) + console.error("[SwCart][setup][onMounted]", error.messages) } } diff --git a/packages/default-theme/src/components/SwErrorsList.vue b/packages/default-theme/src/components/SwErrorsList.vue index 1765e8da7..e968ef483 100644 --- a/packages/default-theme/src/components/SwErrorsList.vue +++ b/packages/default-theme/src/components/SwErrorsList.vue @@ -1,16 +1,28 @@ @@ -31,6 +55,10 @@ export default { color: var(--_c-red-primary); font-size: var(--font-size--sm); + &__single { + margin-bottom: var(--spacer-sm); + } + .list { margin-top: var(--spacer-xs); padding-left: var(--spacer-xs); diff --git a/packages/default-theme/src/components/SwLogin.vue b/packages/default-theme/src/components/SwLogin.vue index ed57ae79f..73f23f43b 100644 --- a/packages/default-theme/src/components/SwLogin.vue +++ b/packages/default-theme/src/components/SwLogin.vue @@ -2,13 +2,7 @@