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

Add error management on new app, all pages excepted during the excecution of a process #1135

Open
wants to merge 11 commits into
base: dev
Choose a base branch
from
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
@@ -1,3 +1,7 @@
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 @@ -13,6 +13,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 @@ -44,18 +49,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 @@ -74,10 +90,15 @@ $e: ".error-page";

&__buttons {
flex-direction: column;
}

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

.btn {
justify-content: center;
width: 100%;
}
}
}
}
Expand Down Expand Up @@ -138,3 +159,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;
}
36 changes: 14 additions & 22 deletions _dev/src/ts/api/RequestHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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';

Expand All @@ -21,20 +21,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 @@ -44,16 +37,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);
M0rgan01 marked this conversation as resolved.
Show resolved Hide resolved
await this.#handleError(error);
}
}
}
Expand All @@ -67,13 +52,11 @@ export class RequestHandler {
*/
public async postAction(action: string): Promise<ApiResponseAction | void> {
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;
Expand All @@ -98,6 +81,15 @@ export class RequestHandler {
new Hydration().hydrate(response, fromPopState);
}
}

async #handleError(error: AxiosError<ApiResponseUnknown, XMLHttpRequest>): Promise<void> {
new Hydration().hydrateError({
code: error.status,
type: error.code,
requestParams: error.request,
additionalContents: error.response?.data
});
}
}

const api = new RequestHandler();
Expand Down
8 changes: 8 additions & 0 deletions _dev/src/ts/api/baseApi.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
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;
13 changes: 13 additions & 0 deletions _dev/src/ts/api/requestInterceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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);
};
59 changes: 59 additions & 0 deletions _dev/src/ts/api/responseInterceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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);
M0rgan01 marked this conversation as resolved.
Show resolved Hide resolved
};
49 changes: 49 additions & 0 deletions _dev/src/ts/components/ErrorPageBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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<ApiError, 'code' | 'type'>): 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;
}
}
Loading
Loading