Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable error management on page running a process #1154

Open
wants to merge 15 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion _dev/.stylelintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
};
4 changes: 4 additions & 0 deletions _dev/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
48 changes: 39 additions & 9 deletions _dev/src/scss/layouts/_error.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -92,10 +108,15 @@ $e: ".error-page";

&__buttons {
flex-direction: column;
}

&__button {
justify-content: center;
> * {
width: 100%;
}

.btn {
justify-content: center;
width: 100%;
}
}
}
}
Expand Down Expand Up @@ -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;
}
42 changes: 15 additions & 27 deletions _dev/src/ts/api/RequestHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
* @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';
import { toApiError, toApiResponseAction } from './axiosError';

export class RequestHandler {
#currentRequestAbortController: AbortController | null = null;
Expand All @@ -39,20 +40,13 @@ export class RequestHandler {
* @returns {Promise<void>}
* @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<void> {
public async post(route: string, data?: FormData, fromPopState?: boolean): Promise<void> {
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<ApiResponse>('', data, {
params: { route },
Expand All @@ -62,16 +56,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);
}
}
}
Expand All @@ -83,21 +69,19 @@ export class RequestHandler {
* @description Sends a POST request to the API with the specified action.
* Automatically includes the `admin_dir` required by the backend.
*/
public async postAction(action: string): Promise<ApiResponseAction | void> {
public async postAction(action: string): Promise<ApiResponseAction> {
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<ApiResponseAction>('', data);
return response.data;
} catch (error: unknown) {
if (error instanceof AxiosError && error?.response?.data?.error) {
return error.response.data as ApiResponseAction;
if (error instanceof AxiosError) {
return toApiResponseAction(error);
}
// TODO: catch errors
console.error(error);

throw error;
}
}

Expand All @@ -116,6 +100,10 @@ export class RequestHandler {
new Hydration().hydrate(response, fromPopState);
}
}

async #handleError(error: AxiosError<ApiResponseUnknown, XMLHttpRequest>): Promise<void> {
new Hydration().hydrateError(toApiError(error));
}
}

const api = new RequestHandler();
Expand Down
51 changes: 51 additions & 0 deletions _dev/src/ts/api/axiosError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* 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 [email protected] so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <[email protected]>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
*/
import { AxiosError } from 'axios';
import { ApiError, ApiResponseAction } from '../types/apiTypes';

export const toApiError = (error: AxiosError): ApiError => ({
code: error.status,
type: error.code,
requestParams: error.request,
additionalContents: formatResponseContents(error)
});

export const toApiResponseAction = (error: AxiosError): ApiResponseAction => ({
kind: 'action',
error: true,
next: 'Error',
stepDone: null,
status: 'error',
next_desc: 'Error',
nextParams: {
progressPercentage: 0
},
nextQuickInfo: [],
apiError: toApiError(error)
});

export const isHttpErrorCode = (code?: number): boolean => {
return typeof code === 'number' && code >= 300 && code.toString().length === 3;
};

const formatResponseContents = (error: AxiosError): string | undefined => {
return typeof error.response?.data === 'string'
? error.response?.data
: JSON.stringify(error.response?.data);
};
8 changes: 8 additions & 0 deletions _dev/src/ts/api/baseApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
31 changes: 31 additions & 0 deletions _dev/src/ts/api/requestInterceptor.ts
Original file line number Diff line number Diff line change
@@ -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 [email protected] so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <[email protected]>
* @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<FormData>) => {
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);
};
77 changes: 77 additions & 0 deletions _dev/src/ts/api/responseInterceptor.ts
Original file line number Diff line number Diff line change
@@ -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 [email protected] so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <[email protected]>
* @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<ApiResponseUnknown, FormData>) => {
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);
};
Loading
Loading