diff --git a/_dev/src/ts/api/RequestHandler.ts b/_dev/src/ts/api/RequestHandler.ts index dea18a67b..dc3a82a36 100644 --- a/_dev/src/ts/api/RequestHandler.ts +++ b/_dev/src/ts/api/RequestHandler.ts @@ -5,30 +5,55 @@ import Hydration from '../utils/Hydration'; export class RequestHandler { private currentRequestAbortController: AbortController | null = null; - public post(route: string, data = new FormData(), fromPopState?: boolean) { + /** + * @public + * @param {string} route - Target route for the POST request. + * @param {FormData}[data=new FormData()] - Form data to send with the request by default we send FormData with admin dir required by backend. + * @param {boolean} [fromPopState] - Indicates if the request originated from a popstate event need by hydration. + * @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 { + // Cancel any previous request if it exists if (this.currentRequestAbortController) { this.currentRequestAbortController.abort(); } + // 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); - baseApi - .post('', data, { - params: { route: route }, + try { + const response = await baseApi.post('', data, { + params: { route }, signal - }) - .then((response) => { - const data = response.data as ApiResponse; - this.handleResponse(data, fromPopState); }); + + const responseData = response.data as ApiResponse; + await this.#handleResponse(responseData, fromPopState); + } catch (error) { + // TODO: catch errors + console.error(error); + } } - private handleResponse(response: ApiResponse, fromPopState?: boolean) { + /** + * @private + * @param {ApiResponse} response - The response data from the API. + * @param {boolean} [fromPopState] - Indicates if the request originated from a popstate event need by hydration. + * @returns {Promise} + * @description Handles the API response by checking for next route or hydration data. + */ + async #handleResponse(response: ApiResponse, fromPopState?: boolean): Promise { if ('next_route' in response) { - this.post(response.next_route); + await this.post(response.next_route); } if ('hydration' in response) { new Hydration().hydrate(response, fromPopState); diff --git a/_dev/src/ts/pages/HomePage.ts b/_dev/src/ts/pages/HomePage.ts index ddd1d5ace..257d6e7b4 100644 --- a/_dev/src/ts/pages/HomePage.ts +++ b/_dev/src/ts/pages/HomePage.ts @@ -37,13 +37,13 @@ export default class HomePage extends PageAbstract { } }; - private handleSubmit = (event: Event) => { + private handleSubmit = async (event: Event) => { event.preventDefault(); const routeToSubmit = this.form?.dataset.routeToSubmit; if (routeToSubmit) { const formData = new FormData(this.form); - api.post(routeToSubmit, formData); + await api.post(routeToSubmit, formData); } }; diff --git a/_dev/src/ts/pages/PageAbstract.ts b/_dev/src/ts/pages/PageAbstract.ts index 65f40f177..546e64a6b 100644 --- a/_dev/src/ts/pages/PageAbstract.ts +++ b/_dev/src/ts/pages/PageAbstract.ts @@ -1,4 +1,19 @@ +/** + * @abstract + * @description Base abstract class defining the structure for page components, requiring implementation of lifecycle methods for mounting and destruction. + */ export default abstract class PageAbstract { + /** + * @abstract + * @description Method to initialize and mount the page component. Should be implemented by subclasses to set up event listeners, render content, etc. + * @returns {void} + */ abstract mount(): void; + + /** + * @abstract + * @description Method to clean up and perform necessary teardown operations before the page component is destroyed. Should be implemented by subclasses to remove event listeners, clear timers, etc. + * @returns {void} + */ abstract beforeDestroy(): void; } diff --git a/_dev/src/ts/pages/UpdatePageVersionChoice.ts b/_dev/src/ts/pages/UpdatePageVersionChoice.ts index 3efdb5a82..891d4c38f 100644 --- a/_dev/src/ts/pages/UpdatePageVersionChoice.ts +++ b/_dev/src/ts/pages/UpdatePageVersionChoice.ts @@ -35,9 +35,9 @@ export default class UpdatePageVersionChoice extends UpdatePage { }); }; - private sendForm = (routeToSend: string) => { + private sendForm = async (routeToSend: string) => { const formData = new FormData(this.form!); - api.post(routeToSend, formData); + await api.post(routeToSend, formData); }; private addListenerToCheckRequirementsAgainButtons = () => { @@ -61,7 +61,7 @@ export default class UpdatePageVersionChoice extends UpdatePage { } }; - private saveForm = () => { + private saveForm = async () => { const routeToSave = this.form!.dataset.routeToSave; if (!routeToSave) { @@ -82,7 +82,7 @@ export default class UpdatePageVersionChoice extends UpdatePage { currentInputCheck.removeAttribute('data-requirements-are-ok'); this.toggleNextButton(); currentInputCheck.classList.add(this.radioLoadingClass); - this.sendForm(routeToSave); + await this.sendForm(routeToSave); } }; diff --git a/_dev/src/ts/routing/RouteHandler.ts b/_dev/src/ts/routing/RouteHandler.ts index 097631f4f..4c3719c7b 100644 --- a/_dev/src/ts/routing/RouteHandler.ts +++ b/_dev/src/ts/routing/RouteHandler.ts @@ -1,38 +1,72 @@ import api from '../api/RequestHandler'; export default class RouteHandler { + /** + * @constructor + * @description Initializes the RouteHandler by setting the current route to 'home-page' + * if no route is present. Sets up an event listener for the 'popstate' event + * to handle route changes. + */ constructor() { if (!this.getCurrentRoute()) { this.setNewRoute('home-page'); } - window.addEventListener('popstate', () => this.handleRouteChange()); + window.addEventListener('popstate', () => this.#handleRouteChange()); } - private getCurrentUrl(): URL { + /** + * @private + * @returns {URL} The current URL object. + * @description Retrieves the current URL of the window. + */ + #getCurrentUrl(): URL { return new URL(window.location.href); } - private getQueryParams(): URLSearchParams { - return this.getCurrentUrl().searchParams; + /** + * @private + * @returns {URLSearchParams} The URLSearchParams object containing the query parameters. + * @description Retrieves the query parameters from the current URL. + */ + #getQueryParams(): URLSearchParams { + return this.#getCurrentUrl().searchParams; } + /** + * @public + * @returns {string | null} The current route name, or null if not found. + * @description Gets the current route from the query parameters. + */ public getCurrentRoute(): string | null { - return this.getQueryParams().get('route'); + return this.#getQueryParams().get('route'); } + /** + * @public + * @param {string} newRoute - The new route name to set. + * @description Sets a new route by updating the query parameters and pushing the new URL + * to the browser's history. + */ public setNewRoute(newRoute: string): void { - const queryParams = this.getQueryParams(); + const queryParams = this.#getQueryParams(); queryParams.set('route', newRoute); - const newUrl = `${this.getCurrentUrl().pathname}?${queryParams.toString()}`; + const newUrl = `${this.#getCurrentUrl().pathname}?${queryParams.toString()}`; window.history.pushState(null, '', newUrl); } - private handleRouteChange() { + /** + * @private + * @async + * @returns {Promise} A promise that resolves when the request is complete. + * @description Handles changes to the route by sending a POST request to the new route. + * If the new route is not null, it makes a request using the api module. + */ + async #handleRouteChange() { const newRoute = this.getCurrentRoute(); if (newRoute !== null) { - api.post(newRoute, new FormData(), true); + await api.post(newRoute, new FormData(), true); } } } diff --git a/_dev/src/ts/routing/ScriptHandler.ts b/_dev/src/ts/routing/ScriptHandler.ts index e450ca8c3..b67950e6e 100644 --- a/_dev/src/ts/routing/ScriptHandler.ts +++ b/_dev/src/ts/routing/ScriptHandler.ts @@ -9,17 +9,14 @@ import { RoutesMatching } from '../types/scriptHandlerTypes'; import { routeHandler } from '../autoUpgrade'; export default class ScriptHandler { - constructor() { - const currentRoute = routeHandler.getCurrentRoute(); - - if (currentRoute) { - this.loadScript(currentRoute); - } - } - - private currentScript: PageAbstract | undefined; + #currentScript: PageAbstract | undefined; - private routesMatching: RoutesMatching = { + /** + * @private + * @type {RoutesMatching} + * @description Maps route names to their corresponding page classes. + */ + readonly #routesMatching: RoutesMatching = { 'home-page': HomePage, 'update-page-version-choice': UpdatePageVersionChoice, 'update-page-update-options': UpdatePageUpdateOptions, @@ -28,16 +25,47 @@ export default class ScriptHandler { 'update-page-post-update': UpdatePagePostUpdate }; - private loadScript(routeName: string) { - if (this.routesMatching[routeName]) { - const pageClass = this.routesMatching[routeName]; - this.currentScript = new pageClass(); - this.currentScript.mount(); + /** + * @constructor + * @description Initializes the `ScriptHandler` by loading the page script associated with the current route. + */ + constructor() { + const currentRoute = routeHandler.getCurrentRoute(); + + if (currentRoute) { + this.#loadScript(currentRoute); + } + } + + /** + * @private + * @param {string} routeName - The name of the route to load his associated script. + * @returns void + * @description Loads and mounts the page script associated with the specified route name. + */ + #loadScript(routeName: string) { + const pageClass = this.#routesMatching[routeName]; + if (pageClass) { + try { + this.#currentScript = new pageClass(); + this.#currentScript.mount(); + } catch (error) { + console.error(`Failed to load script for route ${routeName}:`, error); + } + } else { + console.debug(`No matching page Class found for route: ${routeName}`); } } + /** + * @public + * @param {string} newRoute - The name of the route to load his associated script. + * @returns void + * @description Updates the currently loaded route script by destroying the current + * page instance and loading a new one based on the provided route name. + */ public updateRouteScript(newRoute: string) { - this.currentScript?.beforeDestroy(); - this.loadScript(newRoute); + this.#currentScript?.beforeDestroy(); + this.#loadScript(newRoute); } } diff --git a/_dev/src/ts/utils/Hydration.ts b/_dev/src/ts/utils/Hydration.ts index bf67243b6..4a8d870a6 100644 --- a/_dev/src/ts/utils/Hydration.ts +++ b/_dev/src/ts/utils/Hydration.ts @@ -2,9 +2,27 @@ import { ApiResponseHydration } from '../types/apiTypes'; import { routeHandler, scriptHandler } from '../autoUpgrade'; export default class Hydration { - public static hydrationEventName = 'hydrate'; - public hydrationEvent = new Event(Hydration.hydrationEventName); + /** + * @public + * @static + * @type {string} + * @description The name of the hydration event. + */ + public static hydrationEventName: string = 'hydrate'; + /** + * @public + * @type {Event} + * @description The hydration event instance. + */ + public hydrationEvent: Event = new Event(Hydration.hydrationEventName); + + /** + * @public + * @param {ApiResponseHydration} data - The data containing new content and routing information. + * @param {boolean} [fromPopState=false] - Indicates if the hydration is triggered from a popstate event. + * @description Hydrates the specified element with new content and updates the route if necessary. + */ public hydrate(data: ApiResponseHydration, fromPopState?: boolean) { const elementToUpdate = document.getElementById(data.parent_to_update); diff --git a/_dev/src/ts/utils/Stepper.ts b/_dev/src/ts/utils/Stepper.ts index fb4179f64..e22cc6391 100644 --- a/_dev/src/ts/utils/Stepper.ts +++ b/_dev/src/ts/utils/Stepper.ts @@ -9,6 +9,11 @@ export default class Stepper { private doneClass = `${this.baseClass}--done`; private normalClass = `${this.baseClass}--normal`; + /** + * @constructor + * @throws Will throw an error if the stepper or its steps are not found in the DOM. + * @description Initializes the Stepper by finding the parent element in the DOM and setting up the steps. + */ constructor() { const stepper = document.getElementById( window.AutoUpgradeVariables.stepper_parent_id @@ -39,6 +44,11 @@ export default class Stepper { }); } + /** + * @public + * @param {string} currentStep - The code of the current step to be set. + * @description Sets the current step in the stepper and updates the classes for each step accordingly. + */ public setCurrentStep = (currentStep: string) => { let isBeforeCurrentStep = true; diff --git a/_dev/tests/api/RequestHandler.test.ts b/_dev/tests/api/RequestHandler.test.ts index ef46f9038..e5d13d085 100644 --- a/_dev/tests/api/RequestHandler.test.ts +++ b/_dev/tests/api/RequestHandler.test.ts @@ -7,7 +7,6 @@ jest.mock('../../src/ts/api/baseApi', () => ({ })); const mockHydrate = jest.fn(); -const mockAbort = jest.fn(); jest.mock('../../src/ts/utils/Hydration', () => { return jest.fn().mockImplementation(() => ({ @@ -22,48 +21,40 @@ describe('RequestHandler', () => { requestHandler = new RequestHandler(); (baseApi.post as jest.Mock).mockClear(); mockHydrate.mockClear(); - mockAbort.mockClear(); }); - it('should append admin_dir to FormData and call baseApi.post', () => { + 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: {} }); - requestHandler.post(route, formData); + await requestHandler.post(route, formData); - const expectedConfig = { + expect(formData.get('dir')).toBe(window.AutoUpgradeVariables.admin_dir); + expect(baseApi.post).toHaveBeenCalledWith('', formData, { params: { route }, signal: expect.any(AbortSignal) - }; - - expect(formData.get('dir')).toBe(window.AutoUpgradeVariables.admin_dir); - expect(baseApi.post).toHaveBeenCalledWith('', formData, expectedConfig); + }); }); it('should handle response with next_route and make two API calls', async () => { const response: ApiResponse = { next_route: 'next_route' }; - (baseApi.post as jest.Mock).mockResolvedValue({ data: response }); + (baseApi.post as jest.Mock).mockResolvedValueOnce({ data: response }); const formData = new FormData(); const route = 'some_route'; await requestHandler.post(route, formData); - const expectedFirstCallConfig = { + expect(baseApi.post).toHaveBeenCalledTimes(2); + expect(baseApi.post).toHaveBeenNthCalledWith(1, '', formData, { params: { route }, signal: expect.any(AbortSignal) - }; - - const expectedSecondCallConfig = { + }); + expect(baseApi.post).toHaveBeenNthCalledWith(2, '', formData, { params: { route: 'next_route' }, signal: expect.any(AbortSignal) - }; - - expect(baseApi.post).toHaveBeenCalledTimes(2); - expect(baseApi.post).toHaveBeenNthCalledWith(1, '', formData, expectedFirstCallConfig); - expect(baseApi.post).toHaveBeenNthCalledWith(2, '', formData, expectedSecondCallConfig); + }); }); it('should handle hydration response', async () => { @@ -74,7 +65,7 @@ describe('RequestHandler', () => { new_route: 'home_page' }; - (baseApi.post as jest.Mock).mockResolvedValue({ data: response }); + (baseApi.post as jest.Mock).mockResolvedValueOnce({ data: response }); const formData = new FormData(); const route = 'some_route'; @@ -91,8 +82,8 @@ describe('RequestHandler', () => { const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); - requestHandler.post(route, formData); - requestHandler.post(route, formData); + await requestHandler.post(route, formData); + await requestHandler.post(route, formData); expect(abortSpy).toHaveBeenCalledTimes(1); diff --git a/_dev/tests/routing/ScriptHandler.test.ts b/_dev/tests/routing/ScriptHandler.test.ts index 059d70c08..480d5be91 100644 --- a/_dev/tests/routing/ScriptHandler.test.ts +++ b/_dev/tests/routing/ScriptHandler.test.ts @@ -52,15 +52,6 @@ describe('ScriptHandler', () => { expect(updateMount).toHaveBeenCalledTimes(1); }); - it('should not load any script if the route does not match', () => { - (routeHandler.getCurrentRoute as jest.Mock).mockReturnValue('unknown-route'); - - scriptHandler = new ScriptHandler(); - - expect(HomePage).not.toHaveBeenCalled(); - expect(UpdatePageVersionChoice).not.toHaveBeenCalled(); - }); - it('should update the route script and destroy the previous one', () => { (routeHandler.getCurrentRoute as jest.Mock).mockReturnValue('home-page'); scriptHandler = new ScriptHandler(); @@ -73,4 +64,36 @@ describe('ScriptHandler', () => { expect(UpdatePageVersionChoice).toHaveBeenCalledTimes(1); expect(updateMount).toHaveBeenCalledTimes(1); }); + + it('should catch en log warning if no matching class is found for the route', () => { + const consoleDebugSpy = jest.spyOn(console, 'debug').mockImplementation(); + const route = 'unknown-route'; + (routeHandler.getCurrentRoute as jest.Mock).mockReturnValue(route); + + scriptHandler = new ScriptHandler(); + + expect(consoleDebugSpy).toHaveBeenCalledWith( + `No matching page Class found for route: ${route}` + ); + }); + + it('should catch and log errors if page instantiation or mount fails', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const errorMessage = 'Test error'; + + updateMount.mockImplementation(() => { + throw new Error(errorMessage); + }); + + const route = 'update-page-version-choice'; + (routeHandler.getCurrentRoute as jest.Mock).mockReturnValue('home-route'); + + scriptHandler = new ScriptHandler(); + scriptHandler.updateRouteScript(route); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Failed to load script for route ${route}:`, + expect.any(Error) + ); + }); });