diff --git a/_dev/.stylelintrc.cjs b/_dev/.stylelintrc.cjs index 798612146..c0b60603a 100644 --- a/_dev/.stylelintrc.cjs +++ b/_dev/.stylelintrc.cjs @@ -4,6 +4,7 @@ module.exports = { 'comment-empty-line-before': null, 'no-unknown-animations': null, 'scss/at-import-no-partial-leading-underscore': null, - 'scss/function-color-relative': null + 'scss/function-color-relative': null, + 'scss/percent-placeholder-pattern': null } }; diff --git a/_dev/jest.setup.ts b/_dev/jest.setup.ts index 4b5861a53..b0e760f84 100644 --- a/_dev/jest.setup.ts +++ b/_dev/jest.setup.ts @@ -16,6 +16,10 @@ * @copyright Since 2007 PrestaShop SA and Contributors * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 */ +import { TextEncoder, TextDecoder } from 'util'; +// Needed to avoid error "ReferenceError: TextEncoder is not defined" when using JSDOM in tests +Object.assign(global, { TextDecoder, TextEncoder }); + // We don't wait for the call to beforeAll to define window properties. window.AutoUpgradeVariables = { token: 'test-token', diff --git a/_dev/src/scss/layouts/_error.scss b/_dev/src/scss/layouts/_error.scss index 08758abeb..018b9a3a8 100644 --- a/_dev/src/scss/layouts/_error.scss +++ b/_dev/src/scss/layouts/_error.scss @@ -31,6 +31,11 @@ $e: ".error-page"; padding: 4rem; background-color: var(--#{$ua-prefix}white); border: 1px solid var(--#{$ua-prefix}border-color); + + &:has(#{$e}__code.hidden) { + grid-template-columns: auto; + place-content: center; + } } &__code { @@ -62,18 +67,29 @@ $e: ".error-page"; margin-block-end: 2rem; font-size: 1rem; font-weight: 500; + + > [class^="error-page__desc"] { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + p, + ul { + gap: 0.25rem; + margin: 0; + font-size: 1rem; + line-height: 1.375rem; + } } &__buttons { display: flex; gap: 1rem 2rem; - } - &__button { - padding: 0.875rem 1rem; - font-size: 0.875rem; - font-weight: 400; - white-space: initial; + .btn { + @extend %btn--error-page; + } } @container ua-error (max-width: 700px) { @@ -92,10 +108,15 @@ $e: ".error-page"; &__buttons { flex-direction: column; - } - &__button { - justify-content: center; + > * { + width: 100%; + } + + .btn { + justify-content: center; + width: 100%; + } } } } @@ -156,3 +177,12 @@ html { } } } + +// Placeholder for buttons +%btn--error-page { + padding: 0.875rem 1rem; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.25rem; + white-space: initial; +} diff --git a/_dev/src/ts/api/RequestHandler.ts b/_dev/src/ts/api/RequestHandler.ts index 0f1bd7af6..5fcc6a795 100644 --- a/_dev/src/ts/api/RequestHandler.ts +++ b/_dev/src/ts/api/RequestHandler.ts @@ -17,7 +17,7 @@ * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 */ import baseApi from './baseApi'; -import { ApiResponse, ApiResponseAction } from '../types/apiTypes'; +import { ApiResponse, ApiResponseAction, ApiResponseUnknown } from '../types/apiTypes'; import Hydration from '../utils/Hydration'; import { AxiosError } from 'axios'; @@ -39,20 +39,13 @@ export class RequestHandler { * @returns {Promise} * @description Sends a POST request to the specified route with optional data and pop state indicator. Cancels any ongoing request before initiating a new one. */ - public async post( - route: string, - data: FormData = new FormData(), - fromPopState?: boolean - ): Promise { + public async post(route: string, data?: FormData, fromPopState?: boolean): Promise { this.abortCurrentPost(); // Create a new AbortController for the current request (used to cancel previous request) this.#currentRequestAbortController = new AbortController(); const { signal } = this.#currentRequestAbortController; - // Append admin dir required by backend - data.append('dir', window.AutoUpgradeVariables.admin_dir); - try { const response = await baseApi.post('', data, { params: { route }, @@ -62,16 +55,8 @@ export class RequestHandler { const responseData = response.data; await this.#handleResponse(responseData, fromPopState); } catch (error) { - // A couple or errors are returned in an actual response (i.e 404 or 500) if (error instanceof AxiosError) { - if (error.response?.data) { - const responseData = error.response.data; - responseData.new_route = 'error-page'; - await this.#handleResponse(responseData, true); - } - } else { - // TODO: catch errors - console.error(error); + await this.#handleError(error); } } } @@ -85,13 +70,11 @@ export class RequestHandler { */ public async postAction(action: string): Promise { const data = new FormData(); - - data.append('dir', window.AutoUpgradeVariables.admin_dir); data.append('action', action); try { - const response = await baseApi.post('', data); - return response.data as ApiResponseAction; + const response = await baseApi.post('', data); + return response.data; } catch (error: unknown) { if (error instanceof AxiosError && error?.response?.data?.error) { return error.response.data as ApiResponseAction; @@ -116,6 +99,15 @@ export class RequestHandler { new Hydration().hydrate(response, fromPopState); } } + + async #handleError(error: AxiosError): Promise { + new Hydration().hydrateError({ + code: error.status, + type: error.code, + requestParams: error.request, + additionalContents: error.response?.data + }); + } } const api = new RequestHandler(); diff --git a/_dev/src/ts/api/baseApi.ts b/_dev/src/ts/api/baseApi.ts index 835c8c47d..c673d8639 100644 --- a/_dev/src/ts/api/baseApi.ts +++ b/_dev/src/ts/api/baseApi.ts @@ -17,13 +17,21 @@ * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 */ import axios from 'axios'; +import { addRequestInterceptor } from './requestInterceptor'; +import { addResponseInterceptor } from './responseInterceptor'; const baseApi = axios.create({ baseURL: `${window.AutoUpgradeVariables.admin_url}/autoupgrade/ajax-upgradetab.php`, headers: { 'X-Requested-With': 'XMLHttpRequest', Authorization: `Bearer ${() => window.AutoUpgradeVariables.token}` + }, + transitional: { + clarifyTimeoutError: true } }); +addRequestInterceptor(baseApi); +addResponseInterceptor(baseApi); + export default baseApi; diff --git a/_dev/src/ts/api/requestInterceptor.ts b/_dev/src/ts/api/requestInterceptor.ts new file mode 100644 index 000000000..1916cd777 --- /dev/null +++ b/_dev/src/ts/api/requestInterceptor.ts @@ -0,0 +1,31 @@ +/** + * Copyright since 2007 PrestaShop SA and Contributors + * PrestaShop is an International Registered Trademark & Property of PrestaShop SA + * + * NOTICE OF LICENSE + * + * This source file is subject to the Academic Free License version 3.0 + * that is bundled with this package in the file LICENSE.md. + * It is also available through the world-wide-web at this URL: + * https://opensource.org/licenses/AFL-3.0 + * If you did not receive a copy of the license and are unable to + * obtain it through the world-wide-web, please send an email + * to license@prestashop.com so we can send you a copy immediately. + * + * @author PrestaShop SA and Contributors + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ +import { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; + +const requestFulfilledInterceptor = (config: InternalAxiosRequestConfig) => { + if (!config.data) { + config.data = new FormData(); + } + config.data?.append('dir', window.AutoUpgradeVariables.admin_dir); + return config; +}; + +export const addRequestInterceptor = (axios: AxiosInstance): void => { + axios.interceptors.request.use(requestFulfilledInterceptor); +}; diff --git a/_dev/src/ts/api/responseInterceptor.ts b/_dev/src/ts/api/responseInterceptor.ts new file mode 100644 index 000000000..6a453ac0e --- /dev/null +++ b/_dev/src/ts/api/responseInterceptor.ts @@ -0,0 +1,77 @@ +/** + * Copyright since 2007 PrestaShop SA and Contributors + * PrestaShop is an International Registered Trademark & Property of PrestaShop SA + * + * NOTICE OF LICENSE + * + * This source file is subject to the Academic Free License version 3.0 + * that is bundled with this package in the file LICENSE.md. + * It is also available through the world-wide-web at this URL: + * https://opensource.org/licenses/AFL-3.0 + * If you did not receive a copy of the license and are unable to + * obtain it through the world-wide-web, please send an email + * to license@prestashop.com so we can send you a copy immediately. + * + * @author PrestaShop SA and Contributors + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ +import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { + ApiResponseUnknown, + ApiResponseUnknownObject, + APP_ERR_RESPONSE_BAD_TYPE, + APP_ERR_RESPONSE_EMPTY, + APP_ERR_RESPONSE_INVALID, + SilencedApiError +} from '../types/apiTypes'; + +const responseFulfilledInterceptor = (response: AxiosResponse) => { + if (!response?.data) { + throw new AxiosError( + 'The response is empty', + APP_ERR_RESPONSE_EMPTY, + response.config, + response.request, + response + ); + } + // All responses must be a parsed JSON. If we get another type of response, + // this means something went wrong, i.e Another software answered. + if (Object.prototype.toString.call(response.data) !== '[object Object]') { + throw new AxiosError( + 'The response does not have a valid type', + APP_ERR_RESPONSE_BAD_TYPE, + response.config, + response.request, + response + ); + } + + // Make sure the response contains the expected data + if (!(response.data as ApiResponseUnknownObject)?.kind) { + throw new AxiosError( + 'The response contents is invalid', + APP_ERR_RESPONSE_INVALID, + response.config, + response.request, + response + ); + } + + return response; +}; + +const responseErroredInterceptor = (error: Error) => { + const errorSilenced = [AxiosError.ERR_CANCELED]; + // Ignore some errors + if (error instanceof AxiosError && error.code && errorSilenced.includes(error.code)) { + return Promise.reject(new SilencedApiError()); + } + + return Promise.reject(error); +}; + +export const addResponseInterceptor = (axios: AxiosInstance): void => { + axios.interceptors.response.use(responseFulfilledInterceptor, responseErroredInterceptor); +}; diff --git a/_dev/src/ts/components/ErrorPageBuilder.ts b/_dev/src/ts/components/ErrorPageBuilder.ts new file mode 100644 index 000000000..7a55eabd3 --- /dev/null +++ b/_dev/src/ts/components/ErrorPageBuilder.ts @@ -0,0 +1,67 @@ +/** + * Copyright since 2007 PrestaShop SA and Contributors + * PrestaShop is an International Registered Trademark & Property of PrestaShop SA + * + * NOTICE OF LICENSE + * + * This source file is subject to the Academic Free License version 3.0 + * that is bundled with this package in the file LICENSE.md. + * It is also available through the world-wide-web at this URL: + * https://opensource.org/licenses/AFL-3.0 + * If you did not receive a copy of the license and are unable to + * obtain it through the world-wide-web, please send an email + * to license@prestashop.com so we can send you a copy immediately. + * + * @author PrestaShop SA and Contributors + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ +import { ApiError } from '../types/apiTypes'; + +export default class ErrorPageBuilder { + public constructor(private readonly errorElement: DocumentFragment) {} + + /** + * Replace the id of the cloned element + */ + public updateId(type: ApiError['type']): void { + const errorChild = this.errorElement.getElementById('ua_error_placeholder'); + if (errorChild) { + errorChild.id = `ua_error_${type}`; + } + } + + /** + * If code is a HTTP error number (i.e 404, 500 etc.), let's change the text in the left column with it. + */ + public updateLeftColumn(code: ApiError['code']): void { + if (this.#isHttpErrorCode(code)) { + const stringifiedCode = (code as number).toString().replaceAll('0', 'O'); + const errorCodeSlotElements = this.errorElement.querySelectorAll('.error-page__code-char'); + errorCodeSlotElements.forEach((element: Element, index: number) => { + element.innerHTML = stringifiedCode[index]; + }); + } else { + this.errorElement.querySelector('.error-page__code')?.classList.add('hidden'); + } + } + + /** + * Display a user friendly text related to the code if it exists, otherwise write the error code. + */ + public updateDescriptionBlock(errorDetails: Pick): void { + const errorDescriptionElement = this.errorElement.querySelector('.error-page__desc'); + const userFriendlyDescriptionElement = errorDescriptionElement?.querySelector( + `.error-page__desc-${this.#isHttpErrorCode(errorDetails.code) ? errorDetails.code : errorDetails.type}` + ); + if (userFriendlyDescriptionElement) { + userFriendlyDescriptionElement.classList.remove('hidden'); + } else if (errorDescriptionElement && errorDetails.type) { + errorDescriptionElement.innerHTML = errorDetails.type; + } + } + + #isHttpErrorCode(code?: number): boolean { + return typeof code === 'number' && code >= 300 && code.toString().length === 3; + } +} diff --git a/_dev/src/ts/pages/ErrorPage.ts b/_dev/src/ts/pages/ErrorPage.ts index d5bf7f784..7bb7003bb 100644 --- a/_dev/src/ts/pages/ErrorPage.ts +++ b/_dev/src/ts/pages/ErrorPage.ts @@ -16,26 +16,164 @@ * @copyright Since 2007 PrestaShop SA and Contributors * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 */ -import DomLifecycle from '../types/DomLifecycle'; -import ErrorPage404 from './error/ErrorPage404'; +import api from '../api/RequestHandler'; +import ErrorPageBuilder from '../components/ErrorPageBuilder'; +import { logStore } from '../store/LogStore'; +import { ApiError } from '../types/apiTypes'; +import { Severity } from '../types/logsTypes'; +import Hydration from '../utils/Hydration'; import PageAbstract from './PageAbstract'; export default class ErrorPage extends PageAbstract { - errorPage?: DomLifecycle; + public static readonly templateId: string = 'error-page-template'; + + isOnHomePage: boolean = false; constructor() { super(); - if (document.getElementById('ua_error_404')) { - this.errorPage = new ErrorPage404(); - } + this.isOnHomePage = new URLSearchParams(window.location.search).get('route') === 'home-page'; } public mount = (): void => { - this.errorPage?.mount(); + // If the error page is already present on the DOM (For instance on a whole page refresh), + // initialize it at once instead of waiting for an event. + const errorPageFromBackEnd = document.querySelector('.error-page'); + if (errorPageFromBackEnd) { + this.#mountErrorPage(errorPageFromBackEnd); + } else { + this.#errorTemplateElement.addEventListener( + Hydration.hydrationEventName, + this.#onError.bind(this), + { once: true } + ); + } }; public beforeDestroy = (): void => { - this.errorPage?.beforeDestroy(); + this.#errorTemplateElement.removeEventListener( + Hydration.hydrationEventName, + this.#onError.bind(this) + ); + this.#submitErrorReportForm?.removeEventListener('submit', this.#onSubmit); + logStore.clearLogs(); + }; + + get #errorTemplateElement(): HTMLTemplateElement { + const element = document.getElementById(ErrorPage.templateId); + + if (!element) { + throw new Error('Error template not found'); + } + + ['target'].forEach((data) => { + if (!element.dataset[data]) { + throw new Error(`Missing data ${data} from element dataset.`); + } + }); + + return element as HTMLTemplateElement; + } + + #createErrorPage(event: CustomEvent): void { + // Duplicate the error template before alteration + const errorElement = this.#errorTemplateElement.content.cloneNode(true) as DocumentFragment; + + const pageBuilder = new ErrorPageBuilder(errorElement); + pageBuilder.updateId(event.detail.type); + pageBuilder.updateLeftColumn(event.detail.code); + pageBuilder.updateDescriptionBlock(event.detail); + + // Store the contents in the logs so it can be used in the error reporting modal + if (event.detail.additionalContents) { + const logsContents = + typeof event.detail.additionalContents === 'object' + ? JSON.stringify(event.detail.additionalContents) + : event.detail.additionalContents; + + logStore.addLog({ + severity: Severity.SUCCESS, + height: 0, + offsetTop: 0, + message: logsContents + }); + } + + // Finally, append the result on the page + const targetElementToUpdate = document.getElementById( + this.#errorTemplateElement.dataset.target! + ); + if (!targetElementToUpdate) { + throw new Error('Target element cannot be found'); + } + targetElementToUpdate.replaceChildren(errorElement); + + // Retrieve the route we called to fill in the context. + let route: string | null = null; + if (event.detail.requestParams?.responseURL) { + const params = new URLSearchParams(new URL(event.detail.requestParams?.responseURL)?.search); + route = params?.get('route'); + } + + logStore.addLog({ + severity: Severity.ERROR, + height: 0, + offsetTop: 0, + message: `HTTP request failed: Route ${route ?? 'N/A'} - Type: ${event.detail.type ?? 'N/A'} - Code ${event.detail.code ?? 'N/A'}` + }); + + // Enable events and page features + this.#mountErrorPage(document.querySelector('.error-page')!); + } + + #mountErrorPage(errorPage: Element): void { + this.#form.addEventListener('submit', this.#onSubmit, { once: true }); + + this.#submitErrorReportForm?.addEventListener('submit', this.#onSubmit); + + // Display the proper action buttons + const activeButtonElement = this.isOnHomePage + ? errorPage.querySelector('#exit-button') + : errorPage.querySelector('#home-page-form'); + + if (activeButtonElement) { + activeButtonElement.classList.remove('hidden'); + } + } + + get #form(): HTMLFormElement { + const form = document.forms.namedItem('home-page-form'); + if (!form) { + throw new Error('Form not found'); + } + + ['routeToSubmit'].forEach((data) => { + if (!form.dataset[data]) { + throw new Error(`Missing data ${data} from form dataset.`); + } + }); + + return form; + } + + get #submitErrorReportForm(): HTMLFormElement | null { + return document.forms.namedItem('submit-error-report'); + } + + readonly #onError = async (event: Event | CustomEvent): Promise => { + if (!(event instanceof CustomEvent)) { + console.debug('Unexpected type of event received.'); + return; + } + this.#createErrorPage(event); + }; + + readonly #onSubmit = async (event: SubmitEvent): Promise => { + event.preventDefault(); + + await api.post( + (event.target as HTMLFormElement).dataset.routeToSubmit!, + new FormData(this.#form) + ); }; } diff --git a/_dev/src/ts/pages/error/ErrorPage404.ts b/_dev/src/ts/pages/error/ErrorPage404.ts deleted file mode 100644 index a89c850c2..000000000 --- a/_dev/src/ts/pages/error/ErrorPage404.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright since 2007 PrestaShop SA and Contributors - * PrestaShop is an International Registered Trademark & Property of PrestaShop SA - * - * NOTICE OF LICENSE - * - * This source file is subject to the Academic Free License version 3.0 - * that is bundled with this package in the file LICENSE.md. - * It is also available through the world-wide-web at this URL: - * https://opensource.org/licenses/AFL-3.0 - * If you did not receive a copy of the license and are unable to - * obtain it through the world-wide-web, please send an email - * to license@prestashop.com so we can send you a copy immediately. - * - * @author PrestaShop SA and Contributors - * @copyright Since 2007 PrestaShop SA and Contributors - * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 - */ -import api from '../../api/RequestHandler'; -import DomLifecycle from '../../types/DomLifecycle'; - -export default class ErrorPage404 implements DomLifecycle { - isOnHomePage: boolean = false; - - public constructor() { - this.isOnHomePage = new URLSearchParams(window.location.search).get('route') === 'home-page'; - } - - public mount = (): void => { - this.#activeActionButton.classList.remove('hidden'); - this.#form.addEventListener('submit', this.#onSubmit); - }; - - public beforeDestroy = (): void => { - this.#form.removeEventListener('submit', this.#onSubmit); - }; - - get #activeActionButton(): HTMLFormElement | HTMLAnchorElement { - return this.isOnHomePage ? this.#exitButton : this.#form; - } - - get #form(): HTMLFormElement { - const form = document.forms.namedItem('home-page-form'); - if (!form) { - throw new Error('Form not found'); - } - - ['routeToSubmit'].forEach((data) => { - if (!form.dataset[data]) { - throw new Error(`Missing data ${data} from form dataset.`); - } - }); - - return form; - } - - get #exitButton(): HTMLAnchorElement { - const link = document.getElementById('exit-button'); - - if (!link || !(link instanceof HTMLAnchorElement)) { - throw new Error('Link is not found or invalid'); - } - return link; - } - - readonly #onSubmit = async (event: Event): Promise => { - event.preventDefault(); - - await api.post(this.#form.dataset.routeToSubmit!, new FormData(this.#form)); - }; -} diff --git a/_dev/src/ts/routing/ScriptHandler.ts b/_dev/src/ts/routing/ScriptHandler.ts index b24565d25..1bb26d7dc 100644 --- a/_dev/src/ts/routing/ScriptHandler.ts +++ b/_dev/src/ts/routing/ScriptHandler.ts @@ -99,7 +99,9 @@ export default class ScriptHandler { console.debug(`No matching class found for ID: ${scriptID}`); // Outside a hydration, the scriptID matches the route query param. // If it does not exist, we load the error management script instead. - this.loadScript('error-page'); + if (!this.#currentScripts[ScriptType.PAGE]) { + this.loadScript('error-page'); + } return; } diff --git a/_dev/src/ts/types/apiTypes.ts b/_dev/src/ts/types/apiTypes.ts index f333902ab..95423401d 100644 --- a/_dev/src/ts/types/apiTypes.ts +++ b/_dev/src/ts/types/apiTypes.ts @@ -17,6 +17,7 @@ * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 */ interface ApiResponseHydration { + kind: 'hydrate'; hydration: boolean; new_content: string; new_route?: string; @@ -25,10 +26,12 @@ interface ApiResponseHydration { } interface ApiResponseNextRoute { + kind: 'next_route'; next_route: string; } interface ApiResponseAction { + kind: 'action'; error: null | boolean; stepDone: null | boolean; next: string; @@ -42,6 +45,23 @@ interface ApiResponseAction { }; } +export interface ApiError { + code?: number; + type?: string; + requestParams?: XMLHttpRequest; + additionalContents?: string | object; +} +export class SilencedApiError extends Error {} + +export type ApiResponseUnknownObject = { + kind?: Pick; +}; +export type ApiResponseUnknown = string | ApiResponseUnknownObject | undefined; + type ApiResponse = ApiResponseHydration | ApiResponseNextRoute | ApiResponseAction; +export const APP_ERR_RESPONSE_BAD_TYPE = 'APP_ERR_RESPONSE_BAD_TYPE'; +export const APP_ERR_RESPONSE_INVALID = 'APP_ERR_RESPONSE_INVALID'; +export const APP_ERR_RESPONSE_EMPTY = 'APP_ERR_RESPONSE_EMPTY'; + export type { ApiResponseHydration, ApiResponseNextRoute, ApiResponseAction, ApiResponse }; diff --git a/_dev/src/ts/utils/Hydration.ts b/_dev/src/ts/utils/Hydration.ts index 9145fbe2f..7805bc580 100644 --- a/_dev/src/ts/utils/Hydration.ts +++ b/_dev/src/ts/utils/Hydration.ts @@ -16,9 +16,10 @@ * @copyright Since 2007 PrestaShop SA and Contributors * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 */ -import { ApiResponseHydration } from '../types/apiTypes'; +import { ApiError, ApiResponseHydration } from '../types/apiTypes'; import { dialogContainer, routeHandler, scriptHandler } from '../autoUpgrade'; import { ScriptType } from '../types/scriptHandlerTypes'; +import ErrorPage from '../pages/ErrorPage'; export default class Hydration { /** @@ -71,4 +72,14 @@ export default class Hydration { elementToUpdate.dispatchEvent(this.hydrationEvent); } } + + public hydrateError(error: ApiError): void { + scriptHandler.unloadScriptType(ScriptType.PAGE); + scriptHandler.loadScript('error-page'); + + const elementToUpdate = document.getElementById(ErrorPage.templateId); + elementToUpdate?.dispatchEvent( + new CustomEvent(Hydration.hydrationEventName, { detail: error }) + ); + } } diff --git a/_dev/tests/api/RequestHandler.test.ts b/_dev/tests/api/RequestHandler.test.ts index c587a87cf..38cf241a4 100644 --- a/_dev/tests/api/RequestHandler.test.ts +++ b/_dev/tests/api/RequestHandler.test.ts @@ -41,22 +41,8 @@ describe('RequestHandler', () => { mockHydrate.mockClear(); }); - it('should append admin_dir to FormData and call baseApi.post', async () => { - const formData = new FormData(); - const route = 'some_route'; - (baseApi.post as jest.Mock).mockResolvedValue({ data: {} }); - - await requestHandler.post(route, formData); - - expect(formData.get('dir')).toBe(window.AutoUpgradeVariables.admin_dir); - expect(baseApi.post).toHaveBeenCalledWith('', formData, { - params: { route }, - signal: expect.any(AbortSignal) - }); - }); - it('should handle response with next_route and make two API calls', async () => { - const response: ApiResponse = { next_route: 'next_route' }; + const response: ApiResponse = { kind: 'next_route', next_route: 'next_route' }; (baseApi.post as jest.Mock).mockResolvedValueOnce({ data: response }); const formData = new FormData(); @@ -69,7 +55,7 @@ describe('RequestHandler', () => { params: { route }, signal: expect.any(AbortSignal) }); - expect(baseApi.post).toHaveBeenNthCalledWith(2, '', formData, { + expect(baseApi.post).toHaveBeenNthCalledWith(2, '', undefined, { params: { route: 'next_route' }, signal: expect.any(AbortSignal) }); @@ -77,6 +63,7 @@ describe('RequestHandler', () => { it('should handle hydration response', async () => { const response: ApiResponse = { + kind: 'hydrate', hydration: true, new_content: 'new content', parent_to_update: 'parent', @@ -96,6 +83,7 @@ describe('RequestHandler', () => { it('should handle action response', async () => { const response: ApiResponseAction = { + kind: 'action', error: null, stepDone: false, next: 'Update', diff --git a/_dev/tests/components/ErrorPageBuilder.test.ts b/_dev/tests/components/ErrorPageBuilder.test.ts new file mode 100644 index 000000000..c925aa761 --- /dev/null +++ b/_dev/tests/components/ErrorPageBuilder.test.ts @@ -0,0 +1,172 @@ +/** + * Copyright since 2007 PrestaShop SA and Contributors + * PrestaShop is an International Registered Trademark & Property of PrestaShop SA + * + * NOTICE OF LICENSE + * + * This source file is subject to the Academic Free License version 3.0 + * that is bundled with this package in the file LICENSE.md. + * It is also available through the world-wide-web at this URL: + * https://opensource.org/licenses/AFL-3.0 + * If you did not receive a copy of the license and are unable to + * obtain it through the world-wide-web, please send an email + * to license@prestashop.com so we can send you a copy immediately. + * + * @author PrestaShop SA and Contributors + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ +import { JSDOM } from 'jsdom'; +import ErrorPageBuilder from '../../src/ts/components/ErrorPageBuilder'; + +describe('ErrorPageBuilder', () => { + let errorElement: DocumentFragment; + let errorPageBuilder: ErrorPageBuilder; + + beforeEach(() => { + errorElement = JSDOM.fragment(`
+
+
+ + +
+ +
+

+ Something went wrong... +

+ +
+ + + + + + + + + + + +
+ +
+
+ +
+ + + + +
+
+
+
`); + errorPageBuilder = new ErrorPageBuilder(errorElement); + }); + + test('updateId should update the id of the error placeholder', () => { + const referenceToDiv = errorElement.getElementById('ua_error_placeholder'); + errorPageBuilder.updateId('404'); + expect(referenceToDiv!.id).toBe('ua_error_404'); + }); + + test('updateLeftColumn should update error code display with HTTP 404', () => { + errorPageBuilder.updateLeftColumn(404); + const chars = errorElement.querySelectorAll('.error-page__code-char'); + expect(chars[0].innerHTML).toBe('4'); + expect(chars[1].innerHTML).toBe('O'); + expect(chars[2].innerHTML).toBe('4'); + }); + + test('updateLeftColumn should update error code display with HTTP 500', () => { + errorPageBuilder.updateLeftColumn(500); + const chars = errorElement.querySelectorAll('.error-page__code-char'); + expect(chars[0].innerHTML).toBe('5'); + expect(chars[1].innerHTML).toBe('O'); + expect(chars[2].innerHTML).toBe('O'); + }); + + test('updateLeftColumn should hide the panel if not an HTTP error', () => { + errorPageBuilder.updateLeftColumn(1234); + expect(errorElement.querySelector('.error-page__code')!.classList.contains('hidden')).toBe( + true + ); + }); + + test('updateLeftColumn should hide the panel if code is empty', () => { + errorPageBuilder.updateLeftColumn(undefined); + expect(errorElement.querySelector('.error-page__code')!.classList.contains('hidden')).toBe( + true + ); + }); + + test('updateDescriptionBlock should show a user-friendly message of a HTTP code if available', () => { + errorPageBuilder.updateDescriptionBlock({ code: 404, type: 'NOT_FOUND' }); + expect(errorElement.querySelector('.error-page__desc-404')!.classList.contains('hidden')).toBe( + false + ); + }); + + test('updateDescriptionBlock should show a user-friendly message of a error type if available', () => { + errorPageBuilder.updateDescriptionBlock({ code: undefined, type: 'APP_ERR_RESPONSE_EMPTY' }); + expect( + errorElement + .querySelector('.error-page__desc-APP_ERR_RESPONSE_EMPTY')! + .classList.contains('hidden') + ).toBe(false); + }); + + test('updateDescriptionBlock should set error type as text if no message available', () => { + errorPageBuilder.updateDescriptionBlock({ code: 999, type: 'CUSTOM_ERROR' }); + expect(errorElement.querySelector('.error-page__desc')!.innerHTML).toBe('CUSTOM_ERROR'); + }); +}); diff --git a/_dev/tests/components/LogsViewer.test.ts b/_dev/tests/components/LogsViewer.test.ts index 46ee0f4b9..061114dfc 100644 --- a/_dev/tests/components/LogsViewer.test.ts +++ b/_dev/tests/components/LogsViewer.test.ts @@ -18,7 +18,6 @@ */ import LogsViewer from '../../src/ts/components/LogsViewer'; import { logStore } from '../../src/ts/store/LogStore'; -import SpyInstance = jest.SpyInstance; // add this mock to avoid unnecessary error jest.mock('../../src/ts/routing/ScriptHandler', () => { @@ -30,10 +29,9 @@ jest.mock('../../src/ts/routing/ScriptHandler', () => { describe('LogsViewer', () => { let logsViewer: LogsViewer; let container: HTMLElement; - let errorSpy: SpyInstance; beforeEach(() => { - errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); logStore.clearLogs(); container = document.createElement('div'); @@ -129,12 +127,6 @@ describe('LogsViewer', () => { 'WARNING - Second warning' ]); await logsViewer.displaySummary(); - // add this spy to avoid error return - expect(errorSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'The string did not match the expected pattern.' - }) - ); const summaryContainer = container.querySelector('[data-slot-component="summary"]'); expect(summaryContainer).not.toBeNull(); diff --git a/_dev/tests/utils/Hydration.test.ts b/_dev/tests/utils/Hydration.test.ts index ecf3143af..da3e1cfde 100644 --- a/_dev/tests/utils/Hydration.test.ts +++ b/_dev/tests/utils/Hydration.test.ts @@ -36,6 +36,15 @@ jest.mock('../../src/ts/components/DialogContainer', () => { }); }); +jest.mock('../../src/ts/pages/ErrorPage', () => { + return jest.fn().mockImplementation(() => { + return { + mount: () => {}, + beforeDestroy: () => {} + }; + }); +}); + jest.mock('../../src/ts/pages/HomePage', () => { return jest.fn().mockImplementation(() => { return { @@ -79,6 +88,7 @@ describe('Hydration', () => { it('should update the innerHTML of the target element', () => { const response: ApiResponseHydration = { + kind: 'hydrate', hydration: true, new_content: `

New Content

`, parent_to_update: 'parent', @@ -93,6 +103,7 @@ describe('Hydration', () => { it('should call scriptHandler.loadScript when new_route is provided', () => { const response: ApiResponseHydration = { + kind: 'hydrate', hydration: true, new_content: `

New Content

`, parent_to_update: 'parent', @@ -109,6 +120,7 @@ describe('Hydration', () => { it('should call scriptHandler.loadScript when add_script is provided', () => { const response: ApiResponseHydration = { + kind: 'hydrate', hydration: true, new_content: `

New Content

`, parent_to_update: 'parent', @@ -122,6 +134,7 @@ describe('Hydration', () => { it('should call routeHandler.setNewRoute when new_route is provided and fromPopState is false', () => { const response: ApiResponseHydration = { + kind: 'hydrate', hydration: true, new_content: `

New Content

`, parent_to_update: 'parent', @@ -135,6 +148,7 @@ describe('Hydration', () => { it('should not call routeHandler.setNewRoute when fromPopState is true', () => { const response: ApiResponseHydration = { + kind: 'hydrate', hydration: true, new_content: `

New Content

`, parent_to_update: 'parent', @@ -148,6 +162,7 @@ describe('Hydration', () => { it('should not update the content if the element does not exist', () => { const response: ApiResponseHydration = { + kind: 'hydrate', hydration: true, new_content: `

New Content

`, parent_to_update: 'non_existent_id' @@ -163,6 +178,7 @@ describe('Hydration', () => { it('should dispatch the hydration event on the updated element', () => { const response: ApiResponseHydration = { + kind: 'hydrate', hydration: true, new_content: `

New Content

`, parent_to_update: 'parent', @@ -183,6 +199,7 @@ describe('Hydration', () => { it('should not refresh the dialog container if the DOM is untouched', () => { const response: ApiResponseHydration = { + kind: 'hydrate', hydration: true, new_content: `

New Content

`, parent_to_update: 'non_existent_id' @@ -213,6 +230,7 @@ describe('Hydration and scripts lifecycle', () => { it('should unload the current script safely before loading the next one', () => { const initialResponse: ApiResponseHydration = { + kind: 'hydrate', hydration: true, new_content: `

Old Content

`, parent_to_update: 'parent', @@ -224,6 +242,7 @@ describe('Hydration and scripts lifecycle', () => { expect(unloadRouteScriptMock).toHaveBeenCalledTimes(1); const nextResponse: ApiResponseHydration = { + kind: 'hydrate', hydration: true, new_content: `

New Content

`, parent_to_update: 'parent', diff --git a/classes/AjaxResponse.php b/classes/AjaxResponse.php index 930089f75..ad9de98c8 100644 --- a/classes/AjaxResponse.php +++ b/classes/AjaxResponse.php @@ -89,6 +89,7 @@ public function __construct(AbstractState $state, Logger $logger) public function getResponse(): array { return [ + 'kind' => 'action', 'error' => $this->error, 'stepDone' => $this->stepDone, 'next' => $this->next, diff --git a/classes/AjaxResponseBuilder.php b/classes/AjaxResponseBuilder.php index 2820cf509..1431b757e 100644 --- a/classes/AjaxResponseBuilder.php +++ b/classes/AjaxResponseBuilder.php @@ -30,6 +30,7 @@ class AjaxResponseBuilder public static function hydrationResponse(string $parentToUpdate, string $newContent, ?array $options = []): JsonResponse { $arrayToReturn = [ + 'kind' => 'hydrate', 'hydration' => true, 'parent_to_update' => $parentToUpdate, 'new_content' => $newContent, @@ -49,6 +50,7 @@ public static function hydrationResponse(string $parentToUpdate, string $newCont public static function nextRouteResponse(string $nextRoute): JsonResponse { return new JsonResponse([ + 'kind' => 'next', 'next_route' => $nextRoute, ]); } diff --git a/controllers/admin/self-managed/AbstractPageController.php b/controllers/admin/self-managed/AbstractPageController.php index f56398f81..ef5b80142 100644 --- a/controllers/admin/self-managed/AbstractPageController.php +++ b/controllers/admin/self-managed/AbstractPageController.php @@ -62,6 +62,12 @@ public function renderPage(string $page, array $params): string 'page' => $page, 'ps_version' => $this->getPsVersionClass(), 'data_transparency_link' => DocumentationLinks::PRESTASHOP_PROJECT_DATA_TRANSPARENCY_URL, + + // Data for generic error page + 'error_template_target' => PageSelectors::PAGE_PARENT_ID, + 'exit_to_shop_admin' => $this->upgradeContainer->getUrlGenerator()->getShopAdminAbsolutePathFromRequest($this->request), + 'exit_to_app_home' => Routes::HOME_PAGE, + 'submit_error_report_route' => Routes::DISPLAY_ERROR_REPORT_MODAL, ], $pageSelectors::getAllSelectors(), $params diff --git a/controllers/admin/self-managed/Error404Controller.php b/controllers/admin/self-managed/Error404Controller.php index a7e87ef88..5f9e2b377 100644 --- a/controllers/admin/self-managed/Error404Controller.php +++ b/controllers/admin/self-managed/Error404Controller.php @@ -21,7 +21,6 @@ namespace PrestaShop\Module\AutoUpgrade\Controller; -use PrestaShop\Module\AutoUpgrade\Router\Routes; use Symfony\Component\HttpFoundation\Response; class Error404Controller extends AbstractPageController @@ -51,9 +50,6 @@ protected function getParams(): array 'assets_base_path' => $this->upgradeContainer->getAssetsEnvironment()->getAssetsBaseUrl($this->request), 'error_code' => Response::HTTP_NOT_FOUND, - - 'exit_to_shop_admin' => $this->upgradeContainer->getUrlGenerator()->getShopAdminAbsolutePathFromRequest($this->request), - 'exit_to_app_home' => Routes::HOME_PAGE, ]; } } diff --git a/views/templates/pages/errors/500.html.twig b/views/templates/components/form-open-error-report.html.twig similarity index 59% rename from views/templates/pages/errors/500.html.twig rename to views/templates/components/form-open-error-report.html.twig index fdc8c05d7..8c24a7ad4 100644 --- a/views/templates/pages/errors/500.html.twig +++ b/views/templates/components/form-open-error-report.html.twig @@ -16,26 +16,13 @@ * @copyright Since 2007 PrestaShop SA and Contributors * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 *#} -{% extends "@ModuleAutoUpgrade/layouts/error.html.twig" %} - -{% block title %} -

- {{ 'Something went wrong...'|trans({}) }} -

-{% endblock %} - -{% block description %} -

- {{ 'The module is no longer responding. You can try again in a few moments.'|trans({}) }} -

-{% endblock %} - -{% block button %} - +
+ +
diff --git a/views/templates/layouts/error.html.twig b/views/templates/layouts/error.html.twig index 0aecdbdcc..f02f44e66 100644 --- a/views/templates/layouts/error.html.twig +++ b/views/templates/layouts/error.html.twig @@ -29,7 +29,9 @@
{% block title %} - {# Default title can go here, or it can be left empty so that children can override it #} +

+ {{ 'Something went wrong...'|trans({}) }} +

{% endblock %} {% block description %} @@ -38,7 +40,17 @@
{% block button %} - {# Default button can go here, or it can be left empty so that children can override it #} + {# Default buttons go here, and children can override them #} + + + {% endblock %}
diff --git a/views/templates/layouts/layout.html.twig b/views/templates/layouts/layout.html.twig index fae22242b..b90e971f4 100644 --- a/views/templates/layouts/layout.html.twig +++ b/views/templates/layouts/layout.html.twig @@ -21,4 +21,7 @@ {% include "@ModuleAutoUpgrade/pages/" ~ page ~ ".html.twig" %}
+ diff --git a/views/templates/pages/errors/404.html.twig b/views/templates/pages/errors/404.html.twig index daf92f5ad..b05c90905 100644 --- a/views/templates/pages/errors/404.html.twig +++ b/views/templates/pages/errors/404.html.twig @@ -18,27 +18,15 @@ *#} {% extends "@ModuleAutoUpgrade/layouts/error.html.twig" %} -{% block title %} -

- {{ 'Something went wrong...'|trans({}) }} -

-{% endblock %} - {% block description %} -

- {{ 'The page you requested cannot be found.'|trans({}) }} -

-{% endblock %} - -{% block button %} - - - +
+
+

{{ 'The requested page or resource could not be found. This might be due to:'|trans({}) }}

+
    +
  • {{ 'A broken or outdated link.'|trans({}) }}
  • +
  • {{ 'The page being moved or deleted.'|trans({}) }}
  • +
  • {{ 'A typo in the URL.'|trans({}) }}
  • +
+
+
{% endblock %} diff --git a/views/templates/pages/errors/generic.html.twig b/views/templates/pages/errors/generic.html.twig new file mode 100644 index 000000000..525a81593 --- /dev/null +++ b/views/templates/pages/errors/generic.html.twig @@ -0,0 +1,85 @@ +{#** + * Copyright since 2007 PrestaShop SA and Contributors + * PrestaShop is an International Registered Trademark & Property of PrestaShop SA + * + * NOTICE OF LICENSE + * + * This source file is subject to the Academic Free License version 3.0 + * that is bundled with this package in the file LICENSE.md. + * It is also available through the world-wide-web at this URL: + * https://opensource.org/licenses/AFL-3.0 + * If you did not receive a copy of the license and are unable to + * obtain it through the world-wide-web, please send an email + * to license@prestashop.com so we can send you a copy immediately. + * + * @author PrestaShop SA and Contributors + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + *#} +{% extends "@ModuleAutoUpgrade/layouts/error.html.twig" %} + +{% block error_code %} +
+ + +
+{% endblock %} + +{% block description %} +
+ + + + + + + + + + + +
+{% endblock %} + +{% block button %} + {% include "@ModuleAutoUpgrade/components/form-open-error-report.html.twig" %} + {{ parent() }} +{% endblock %} diff --git a/views/templates/steps/backup.html.twig b/views/templates/steps/backup.html.twig index fac14bd4a..334fbc706 100644 --- a/views/templates/steps/backup.html.twig +++ b/views/templates/steps/backup.html.twig @@ -60,16 +60,8 @@ {{ 'Update without backup'|trans({}) }} -
- -
+ {% include "@ModuleAutoUpgrade/components/form-open-error-report.html.twig" %} + + {% include "@ModuleAutoUpgrade/components/form-open-error-report.html.twig" %} + + {% include "@ModuleAutoUpgrade/components/form-open-error-report.html.twig" %} + {% if backup_available %}