Skip to content

Commit

Permalink
Add error management on front end
Browse files Browse the repository at this point in the history
  • Loading branch information
Quetzacoalt91 committed Jan 23, 2025
1 parent 59ebcd1 commit 90ca504
Show file tree
Hide file tree
Showing 22 changed files with 352 additions and 128 deletions.
31 changes: 13 additions & 18 deletions _dev/src/ts/api/RequestHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class RequestHandler {
*/
public async post(

Check failure on line 17 in _dev/src/ts/api/RequestHandler.ts

View workflow job for this annotation

GitHub Actions / JS linter syntax check

Replace `⏎····route:·string,⏎····data?:·FormData,⏎····fromPopState?:·boolean⏎··` with `route:·string,·data?:·FormData,·fromPopState?:·boolean`
route: string,
data: FormData = new FormData(),
data?: FormData,
fromPopState?: boolean
): Promise<void> {
// Cancel any previous request if it exists
Expand All @@ -28,9 +28,6 @@ export class RequestHandler {
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 @@ -40,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);
if (error) {
await this.#handleError(error as AxiosError);
}
}
}
Expand All @@ -63,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 @@ -94,6 +81,14 @@ export class RequestHandler {
new Hydration().hydrate(response, fromPopState);
}
}

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

Check failure on line 89 in _dev/src/ts/api/RequestHandler.ts

View workflow job for this annotation

GitHub Actions / JS linter syntax check

Delete `,`
});
}
}

const api = new RequestHandler();
Expand Down
10 changes: 9 additions & 1 deletion _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,

Check failure on line 12 in _dev/src/ts/api/baseApi.ts

View workflow job for this annotation

GitHub Actions / JS linter syntax check

Delete `,`
},

Check failure on line 13 in _dev/src/ts/api/baseApi.ts

View workflow job for this annotation

GitHub Actions / JS linter syntax check

Delete `,`
});

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";

Check failure on line 1 in _dev/src/ts/api/requestInterceptor.ts

View workflow job for this annotation

GitHub Actions / JS linter syntax check

Replace `"axios"` with `'axios'`

const requestFulfilledInterceptor = (config: InternalAxiosRequestConfig<FormData>) => {
if (!config.data) {

Check failure on line 4 in _dev/src/ts/api/requestInterceptor.ts

View workflow job for this annotation

GitHub Actions / JS linter syntax check

Delete `··`
config.data = new FormData();

Check failure on line 5 in _dev/src/ts/api/requestInterceptor.ts

View workflow job for this annotation

GitHub Actions / JS linter syntax check

Delete `··`
}

Check failure on line 6 in _dev/src/ts/api/requestInterceptor.ts

View workflow job for this annotation

GitHub Actions / JS linter syntax check

Delete `··`
config.data?.append('dir', window.AutoUpgradeVariables.admin_dir);

Check failure on line 7 in _dev/src/ts/api/requestInterceptor.ts

View workflow job for this annotation

GitHub Actions / JS linter syntax check

Delete `··`
return config;

Check failure on line 8 in _dev/src/ts/api/requestInterceptor.ts

View workflow job for this annotation

GitHub Actions / JS linter syntax check

Delete `··`
};

export const addRequestInterceptor = (axios: AxiosInstance): void => {
axios.interceptors.request.use(requestFulfilledInterceptor);
}
35 changes: 35 additions & 0 deletions _dev/src/ts/api/responseInterceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { AxiosError, AxiosInstance, AxiosResponse } from "axios";
import { APP_ERR_RESPONSE_BAD_TYPE, APP_ERR_RESPONSE_INVALID } from "../types/apiTypes";

const responseFulfilledInterceptor = (response: AxiosResponse<any, FormData>) => {
console.log('Checking response', 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.kind) {
throw new AxiosError('The response contents is invalid', APP_ERR_RESPONSE_INVALID, response.config, response.request, response);
}

return response;
};

const responseErroredInterceptor = (error: any) => {
const errorSilenced = [AxiosError.ERR_CANCELED];
// Ignore some errors
if (error instanceof AxiosError) {
if (error.code && errorSilenced.includes(error.code)) {
return Promise.reject(null);
}
}

return Promise.reject(error);
};

export const addResponseInterceptor = (axios: AxiosInstance): void => {
axios.interceptors.response.use(responseFulfilledInterceptor, responseErroredInterceptor);
}
122 changes: 114 additions & 8 deletions _dev/src/ts/pages/ErrorPage.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,129 @@
import DomLifecycle from '../types/DomLifecycle';
import ErrorPage404 from './error/ErrorPage404';
import api from '../api/RequestHandler';
import { ApiError } from '../types/apiTypes';
import Hydration from '../utils/Hydration';
import PageAbstract from './PageAbstract';

export default class ErrorPage extends PageAbstract {
errorPage?: DomLifecycle;
public static templateId: string = 'error-page-template';
// TODO: Improve this by putting the target in the template and sent it from the back end
public static targetElementIdToUpdate: string = 'ua_page';

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),
// initalize 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));
};

get #errorTemplateElement(): HTMLTemplateElement {
const element = document.getElementById(ErrorPage.templateId);

if (!element) {
throw new Error('Error template not found');
}

return element as HTMLTemplateElement;
}

#onError = async (event: CustomEvent<ApiError>): Promise<void> => {
this.#createErrorPage(event);
}

#createErrorPage(event: CustomEvent<ApiError>): void {
// Duplicate the error template before alteration
const errorElement = this.#errorTemplateElement.content.cloneNode(true) as DocumentFragment;

// Set the id of the cloned element
const errorChild = errorElement.getElementById('ua_error_placeholder');
if (errorChild) {
errorChild.id = `ua_error_${event.detail.type}`;
}

// If code is a HTTP error number (i.e 404, 500 etc.), let's change the text in the left column with it.
if (typeof event.detail.code === 'number' && event.detail.code >= 300 && event.detail.code.toString().length === 3) {
const strigifiedCode = event.detail.code.toString().replaceAll('0', 'O');
const errorCodeSlotElements = errorElement.querySelectorAll('.error-page__code-char');
errorCodeSlotElements.forEach((element: Element, index: number) => {
element.innerHTML = strigifiedCode[index];
});
errorElement.querySelector('.error-page__code-missing')?.classList.add('hidden');
}

// Display a user friendly text related to the code if it exists, otherwise write the error code.
const errorDescriptionElement = errorElement.querySelector('.error-page__desc');
const userFriendlyDescriptionElement = errorDescriptionElement?.querySelector(`.error-page__desc-${event.detail.code || event.detail.type}`);
if (userFriendlyDescriptionElement) {
userFriendlyDescriptionElement.classList.remove('hidden');
} else if (errorDescriptionElement && event.detail.type) {
errorDescriptionElement.innerHTML = event.detail.type;
}

// Store the contents in the hidden field so it can be used in the error reporting modal
const additionalContentsElement = errorElement.querySelector('.error-page__contents');
if (additionalContentsElement && event.detail.additionalContents) {
additionalContentsElement.innerHTML = new String(event.detail.additionalContents).toString();
}

// Finally, append the result on the page
const targetElementToUpdate = document.getElementById(ErrorPage.targetElementIdToUpdate);
if (!targetElementToUpdate) {
throw new Error('Target element cannot be found');
}
targetElementToUpdate.replaceChildren(errorElement);

// Enable events and page features
this.#mountErrorPage(document.querySelector('.error-page')!);
}

#mountErrorPage(errorPage: Element): void {
console.log('mounting', errorPage);
this.#form.addEventListener('submit', this.#onSubmit, {once: true});

// Display the proper action buttons
const activeButtonElement = this.isOnHomePage
? errorPage.querySelector('.error-page__exit-button')
: errorPage.querySelector('.error-page__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;
}

readonly #onSubmit = async (event: Event): Promise<void> => {
event.preventDefault();

await api.post(this.#form.dataset.routeToSubmit!, new FormData(this.#form));
};
}
18 changes: 18 additions & 0 deletions _dev/src/ts/pages/HomePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export default class HomePage extends PageAbstract {
this.checkForm();
this.form.addEventListener('change', this.checkForm);
this.form.addEventListener('submit', this.handleSubmit);

document.getElementById('update_assistant')?.addEventListener('click', this.#onClick);
}
};

Expand All @@ -27,6 +29,22 @@ export default class HomePage extends PageAbstract {
this.form.removeEventListener('change', this.checkForm);
this.form.removeEventListener('submit', this.handleSubmit);
}
document.getElementById('update_assistant')?.removeEventListener('click', this.#onClick);
};

readonly #onClick = async (ev: Event): Promise<void> => {
if ((ev.target as HTMLElement).id === 'trigger-1') {
await api.post('fake-error-500');
}
if ((ev.target as HTMLElement).id === 'trigger-2') {
await api.post('fake-error-502');
}
if ((ev.target as HTMLElement).id === 'trigger-3') {
await api.post('fake-invalid-response');
}
if ((ev.target as HTMLElement).id === 'trigger-4') {
await api.post('fake-timeout');
}
};

private checkForm = () => {
Expand Down
53 changes: 0 additions & 53 deletions _dev/src/ts/pages/error/ErrorPage404.ts

This file was deleted.

12 changes: 12 additions & 0 deletions _dev/src/ts/types/apiTypes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
interface ApiResponseHydration {
kind: 'hydrate',
hydration: boolean;
new_content: string;
new_route?: string;
Expand All @@ -7,10 +8,12 @@ interface ApiResponseHydration {
}

interface ApiResponseNextRoute {
kind: 'next',
next_route: string;
}

interface ApiResponseAction {
kind: 'action',
error: null | boolean;
stepDone: null | boolean;
next: string;
Expand All @@ -24,6 +27,15 @@ interface ApiResponseAction {
};
}

export interface ApiError {
code?: number,
type?: string,
additionalContents?: string|object
}

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 type { ApiResponseHydration, ApiResponseNextRoute, ApiResponseAction, ApiResponse };
Loading

0 comments on commit 90ca504

Please sign in to comment.