From 07340de2e37b2b10f44ed3cbbbdc77335af6a3b1 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Sun, 23 Mar 2025 19:20:56 +0100 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Navigatie=20voor=20leerpad=20?= =?UTF-8?q?ge=C3=AFmplementeerd.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/controllers/learning-objects.ts | 10 ++- frontend/src/App.vue | 8 ++- frontend/src/components/MenuBar.vue | 8 ++- .../src/components/UsingRemoteResource.vue | 34 +++++++---- frontend/src/router/index.ts | 11 +++- ...{api-exceptions.d.ts => api-exceptions.ts} | 12 ++++ .../api-client/endpoints/get-html-endpoint.ts | 10 +++ .../api-client/endpoints/rest-endpoint.ts | 17 ++++-- .../services/api-client/remote-resource.ts | 53 +++++++++++----- .../learning-content/learning-path-service.ts | 18 ++++-- .../learning-content/learning-path.ts | 17 +++++- frontend/src/utils/response-assertions.ts | 11 ++++ .../views/learning-paths/LearningPathPage.vue | 61 ++++++++++++++++--- 13 files changed, 216 insertions(+), 54 deletions(-) rename frontend/src/services/api-client/{api-exceptions.d.ts => api-exceptions.ts} (56%) create mode 100644 frontend/src/services/api-client/endpoints/get-html-endpoint.ts create mode 100644 frontend/src/utils/response-assertions.ts diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts index 455a4006..a74d745d 100644 --- a/backend/src/controllers/learning-objects.ts +++ b/backend/src/controllers/learning-objects.ts @@ -4,9 +4,8 @@ import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifie import learningObjectService from '../services/learning-objects/learning-object-service.js'; import { EnvVars, getEnvVar } from '../util/envvars.js'; import { Language } from '../entities/content/language.js'; -import { BadRequestException } from '../exceptions.js'; +import {BadRequestException, NotFoundException} from '../exceptions.js'; import attachmentService from '../services/learning-objects/attachment-service.js'; -import { NotFoundError } from '@mikro-orm/core'; function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { if (!req.params.hruid) { @@ -47,6 +46,11 @@ export async function getLearningObject(req: Request, res: Response): Promise const attachment = await attachmentService.getAttachment(learningObjectId, name); if (!attachment) { - throw new NotFoundError(`Attachment ${name} not found`); + throw new NotFoundException(`Attachment ${name} not found`); } res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d355c43d..2dabe392 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,10 +1,16 @@ diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index f942babc..0e22ec71 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -37,7 +37,7 @@ diff --git a/frontend/src/components/UsingRemoteResource.vue b/frontend/src/components/UsingRemoteResource.vue index 6bc19234..96654ce9 100644 --- a/frontend/src/components/UsingRemoteResource.vue +++ b/frontend/src/components/UsingRemoteResource.vue @@ -1,39 +1,51 @@ diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index be11c0df..f096a0ba 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -14,6 +14,7 @@ import UserClasses from "@/views/classes/UserClasses.vue"; import UserAssignments from "@/views/classes/UserAssignments.vue"; import authState from "@/services/auth/auth-service.ts"; import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue"; +import path from "path"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -104,7 +105,15 @@ const router = createRouter({ path: "/learningPath/:hruid/:language", name: "LearningPath", component: LearningPathPage, - meta: { requiresAuth: false } + props: true, + meta: { requiresAuth: false }, + children: [ + { + path: ":learningObjectHruid", + component: LearningPathPage, + props: true, + } + ] }, { path: "/:catchAll(.*)", diff --git a/frontend/src/services/api-client/api-exceptions.d.ts b/frontend/src/services/api-client/api-exceptions.ts similarity index 56% rename from frontend/src/services/api-client/api-exceptions.d.ts rename to frontend/src/services/api-client/api-exceptions.ts index 28df1bc1..d2fa2ac2 100644 --- a/frontend/src/services/api-client/api-exceptions.d.ts +++ b/frontend/src/services/api-client/api-exceptions.ts @@ -8,3 +8,15 @@ export class HttpErrorStatusException extends Error { this.statusCode = response.status; } } + +export class NotFoundException extends Error { + constructor(message: string) { + super(message); + } +} + +export class InvalidResponseException extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/frontend/src/services/api-client/endpoints/get-html-endpoint.ts b/frontend/src/services/api-client/endpoints/get-html-endpoint.ts new file mode 100644 index 00000000..2b646c16 --- /dev/null +++ b/frontend/src/services/api-client/endpoints/get-html-endpoint.ts @@ -0,0 +1,10 @@ +import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts"; +import {RemoteResource} from "@/services/api-client/remote-resource.ts"; + +export class GetHtmlEndpoint extends RestEndpoint { + readonly method: "GET" | "POST" | "PUT" | "DELETE" = "GET"; + + public get(pathParams: PP, queryParams: QP): RemoteResource { + return super.request(pathParams, queryParams, undefined, "document"); + } +} diff --git a/frontend/src/services/api-client/endpoints/rest-endpoint.ts b/frontend/src/services/api-client/endpoints/rest-endpoint.ts index 3f6bb5cf..cbe18aa0 100644 --- a/frontend/src/services/api-client/endpoints/rest-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/rest-endpoint.ts @@ -1,23 +1,28 @@ import {RemoteResource} from "@/services/api-client/remote-resource.ts"; import apiClient from "@/services/api-client/api-client.ts"; -import {HttpErrorStatusException} from "@/services/api-client/api-exceptions"; +import {HttpErrorStatusException} from "@/services/api-client/api-exceptions.ts"; +import type {ResponseType} from "axios"; export abstract class RestEndpoint { public abstract readonly method: "GET" | "POST" | "PUT" | "DELETE"; constructor(public readonly url: string) { } - protected request(pathParams: PP, queryParams: QP, body: B): RemoteResource { - let urlFilledIn = this.url; - urlFilledIn.replace(/:(\w+)([/$])/g, (_, key, after) => - (key in pathParams ? encodeURIComponent(pathParams[key]) : `:${key}`) + after + protected request(pathParams: PP, queryParams: QP, body: B, responseType?: ResponseType): RemoteResource { + let urlFilledIn = this.url.replace(/:(\w+)(\/|$)/g, (_, key, after) => + (pathParams[key] ? encodeURIComponent(pathParams[key]) : `:${key}`) + after ); + console.log(this.url); + console.log(/:(\w+)(\W|$)/g.test(this.url)) + console.log(pathParams); + console.log("--> filled in: " + urlFilledIn); return new RemoteResource(async () => { const response = await apiClient.request({ url: urlFilledIn, method: this.method, params: queryParams, data: body, + responseType: responseType || 'json' }); if (response.status / 100 !== 2) { throw new HttpErrorStatusException(response); @@ -27,4 +32,4 @@ export abstract class RestEndpoint { } } -export type Params = {[key: string]: string | number | boolean}; +export type Params = {[key: string]: string | number | boolean | undefined}; diff --git a/frontend/src/services/api-client/remote-resource.ts b/frontend/src/services/api-client/remote-resource.ts index b1add8b9..4e3cbdfb 100644 --- a/frontend/src/services/api-client/remote-resource.ts +++ b/frontend/src/services/api-client/remote-resource.ts @@ -2,22 +2,40 @@ export class RemoteResource { static NOT_LOADED: NotLoadedState = {type: "notLoaded"}; static LOADING: LoadingState = {type: "loading"}; - private state: NotLoadedState | LoadingState | ErrorState | SuccessState = RemoteResource.NOT_LOADED; + private _state: RemoteResourceState = RemoteResource.NOT_LOADED; constructor(private readonly requestFn: () => Promise) { } + public static join(resources: RemoteResource[]): RemoteResource { + return new RemoteResource(async () => { + console.log("joined fetch"); + const promises = resources.map(it => it.request()); + const data = await Promise.all(promises); + const failed = resources + .filter(it => it.state.type === "error") + .map(it => it.state as ErrorState); + if (failed.length > 0) { + console.log("joined error!"); + throw failed[0].error; + } + console.log("succ"); + console.log(data); + return data.map(it => it!); + }); + } + public async request(): Promise { - this.state = RemoteResource.LOADING; + this._state = RemoteResource.LOADING; try { let resource = await this.requestFn(); - this.state = { + this._state = { type: "success", data: resource }; return resource; } catch (e: any) { - this.state = { + this._state = { type: "error", errorCode: e.statusCode, message: e.message, @@ -31,19 +49,23 @@ export class RemoteResource { return this; } + public get state(): RemoteResourceState { + return this._state; + } + public get data(): T | undefined { - if (this.state.type === "success") { - return this.state.data; + if (this._state.type === "success") { + return this._state.data; } } public map(mappingFn: (content: T) => U): RemoteResource { return new RemoteResource(async () => { await this.request(); - if (this.state.type === "success") { - return mappingFn(this.state.data); - } else if (this.state.type === "error") { - throw this.state.error; + if (this._state.type === "success") { + return mappingFn(this._state.data); + } else if (this._state.type === "error") { + throw this._state.error; } else { throw new Error("Fetched resource, but afterwards, it was neither in a success nor in an error state. " + "This should never happen."); @@ -52,19 +74,20 @@ export class RemoteResource { } } -type NotLoadedState = { +export type NotLoadedState = { type: "notLoaded" }; -type LoadingState = { +export type LoadingState = { type: "loading" }; -type ErrorState = { +export type ErrorState = { type: "error", errorCode?: number, message?: string, error: any }; -type SuccessState = { +export type SuccessState = { type: "success", data: T -} +}; +export type RemoteResourceState = NotLoadedState | LoadingState | ErrorState | SuccessState; diff --git a/frontend/src/services/learning-content/learning-path-service.ts b/frontend/src/services/learning-content/learning-path-service.ts index be9dffd5..c1af58c9 100644 --- a/frontend/src/services/learning-content/learning-path-service.ts +++ b/frontend/src/services/learning-content/learning-path-service.ts @@ -1,13 +1,23 @@ import {GetEndpoint} from "@/services/api-client/endpoints/get-endpoint.ts"; import {LearningPath, type LearningPathDTO} from "@/services/learning-content/learning-path.ts"; import type {RemoteResource} from "@/services/api-client/remote-resource.ts"; +import type {Language} from "@/services/learning-content/language.ts"; +import {single} from "@/utils/response-assertions.ts"; -const searchLearningPathsEndpoint = new GetEndpoint<{}, {query: string}, LearningPathDTO[]>( - "/learningObjects/:query" +const learningPathEndpoint = new GetEndpoint<{}, {search?: string, hruid?: string, language?: Language}, LearningPathDTO[]>( + "/learningPath" ); export function searchLearningPaths(query: string): RemoteResource { - return searchLearningPathsEndpoint - .get({}, {query: query}) + return learningPathEndpoint + .get({}, {search: query}) .map(dtos => dtos.map(dto => LearningPath.fromDTO(dto))); } + +export function getLearningPath(hruid: string, language: Language): RemoteResource { + console.log({hruid, language}) + return learningPathEndpoint + .get({}, {hruid, language}) + .map(it => {console.log(it); return it;}) + .map(dtos => LearningPath.fromDTO(single(dtos))); +} diff --git a/frontend/src/services/learning-content/learning-path.ts b/frontend/src/services/learning-content/learning-path.ts index 7aefa28e..19a94339 100644 --- a/frontend/src/services/learning-content/learning-path.ts +++ b/frontend/src/services/learning-content/learning-path.ts @@ -1,5 +1,5 @@ import type {Language} from "@/services/learning-content/language.ts"; -import type {RemoteResource} from "@/services/api-client/remote-resource.ts"; +import {RemoteResource} from "@/services/api-client/remote-resource.ts"; import type {LearningObject} from "@/services/learning-content/learning-object.ts"; import {getLearningObjectMetadata} from "@/services/learning-content/learning-object-service.ts"; @@ -98,6 +98,21 @@ export class LearningPath { ) { } + public get nodesAsList(): LearningPathNode[] { + let list: LearningPathNode[] = []; + let currentNode = this.startNode; + while (currentNode) { + list.push(currentNode); + currentNode = currentNode.transitions.filter(it => it.default)[0]?.next + || currentNode.transitions[0]?.next; + } + return list; + } + + public get learningObjectsAsList(): RemoteResource { + return RemoteResource.join(this.nodesAsList.map(node => node.learningObject)); + } + static fromDTO(dto: LearningPathDTO): LearningPath { let startNodeDto = dto.nodes.filter(it => it.start_node); if (startNodeDto.length !== 1) { diff --git a/frontend/src/utils/response-assertions.ts b/frontend/src/utils/response-assertions.ts new file mode 100644 index 00000000..5be421ff --- /dev/null +++ b/frontend/src/utils/response-assertions.ts @@ -0,0 +1,11 @@ +import {InvalidResponseException, NotFoundException} from "@/services/api-client/api-exceptions.ts"; + +export function single(list: T[]): T { + if (list.length === 1) { + return list[0]; + } else if (list.length === 0) { + throw new NotFoundException("Expected list with exactly one element, but got an empty list."); + } else { + throw new InvalidResponseException(`Expected list with exactly one element, but got one with ${list.length} elements.`); + } +} diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue index 14e911e2..541f0fbe 100644 --- a/frontend/src/views/learning-paths/LearningPathPage.vue +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -1,18 +1,59 @@