diff --git a/frontend/src/components/UsingRemoteResource.vue b/frontend/src/components/UsingRemoteResource.vue new file mode 100644 index 00000000..6bc19234 --- /dev/null +++ b/frontend/src/components/UsingRemoteResource.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json index a1a699e5..b9a087fc 100644 --- a/frontend/src/i18n/locale/de.json +++ b/frontend/src/i18n/locale/de.json @@ -1,3 +1,4 @@ { - "welcome": "Willkommen" + "welcome": "Willkommen", + "error_title": "Fehler" } diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index c75bfc5d..a04b3fa5 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -5,5 +5,6 @@ "assignments": "assignments", "classes": "classes", "discussions": "discussions", - "logout": "log out" + "logout": "log out", + "error_title": "Error" } diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json index 86fe964d..1d59c4dd 100644 --- a/frontend/src/i18n/locale/fr.json +++ b/frontend/src/i18n/locale/fr.json @@ -1,3 +1,4 @@ { - "welcome": "Bienvenue" + "welcome": "Bienvenue", + "error_title": "Erreur" } diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json index 97ec9b49..576a95b4 100644 --- a/frontend/src/i18n/locale/nl.json +++ b/frontend/src/i18n/locale/nl.json @@ -5,5 +5,6 @@ "assignments": "opdrachten", "classes": "klassen", "discussions": "discussies", - "logout": "log uit" + "logout": "log uit", + "error_title": "Fout" } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e4843dae..556395c3 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -10,6 +10,7 @@ import i18n from "./i18n/i18n.ts"; // Components import App from "./App.vue"; import router from "./router"; +import {aliases, mdi} from "vuetify/iconsets/mdi"; const app = createApp(App); @@ -23,6 +24,13 @@ document.head.appendChild(link); const vuetify = createVuetify({ components, directives, + icons: { + defaultSet: "mdi", + aliases, + sets: { + mdi + } + } }); app.use(vuetify); app.use(i18n); diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client/api-client.ts similarity index 100% rename from frontend/src/services/api-client.ts rename to frontend/src/services/api-client/api-client.ts diff --git a/frontend/src/services/api-client/api-exceptions.d.ts b/frontend/src/services/api-client/api-exceptions.d.ts new file mode 100644 index 00000000..28df1bc1 --- /dev/null +++ b/frontend/src/services/api-client/api-exceptions.d.ts @@ -0,0 +1,10 @@ +import type {AxiosResponse} from "axios"; + +export class HttpErrorStatusException extends Error { + public readonly statusCode: number; + + constructor(response: AxiosResponse) { + super(`${response.statusText} (${response.status})`); + this.statusCode = response.status; + } +} diff --git a/frontend/src/services/api-client/endpoints/delete-endpoint.ts b/frontend/src/services/api-client/endpoints/delete-endpoint.ts new file mode 100644 index 00000000..554e1855 --- /dev/null +++ b/frontend/src/services/api-client/endpoints/delete-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 DeleteEndpoint extends RestEndpoint { + readonly method = "GET"; + + public delete(pathParams: PP, queryParams: QP): RemoteResource { + return super.request(pathParams, queryParams, undefined); + } +} diff --git a/frontend/src/services/api-client/endpoints/get-endpoint.ts b/frontend/src/services/api-client/endpoints/get-endpoint.ts new file mode 100644 index 00000000..1d9a086f --- /dev/null +++ b/frontend/src/services/api-client/endpoints/get-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 GetEndpoint extends RestEndpoint { + readonly method = "GET"; + + public get(pathParams: PP, queryParams: QP): RemoteResource { + return super.request(pathParams, queryParams, undefined); + } +} diff --git a/frontend/src/services/api-client/endpoints/post-endpoint.ts b/frontend/src/services/api-client/endpoints/post-endpoint.ts new file mode 100644 index 00000000..6fde53e8 --- /dev/null +++ b/frontend/src/services/api-client/endpoints/post-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 PostEndpoint extends RestEndpoint { + readonly method = "POST"; + + public post(pathParams: PP, queryParams: QP, body: B): RemoteResource { + return super.request(pathParams, queryParams, body); + } +} diff --git a/frontend/src/services/api-client/endpoints/rest-endpoint.ts b/frontend/src/services/api-client/endpoints/rest-endpoint.ts new file mode 100644 index 00000000..3f6bb5cf --- /dev/null +++ b/frontend/src/services/api-client/endpoints/rest-endpoint.ts @@ -0,0 +1,30 @@ +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"; + +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 + ); + return new RemoteResource(async () => { + const response = await apiClient.request({ + url: urlFilledIn, + method: this.method, + params: queryParams, + data: body, + }); + if (response.status / 100 !== 2) { + throw new HttpErrorStatusException(response); + } + return response.data; + }); + } +} + +export type Params = {[key: string]: string | number | boolean}; diff --git a/frontend/src/services/api-client/remote-resource.ts b/frontend/src/services/api-client/remote-resource.ts new file mode 100644 index 00000000..b1add8b9 --- /dev/null +++ b/frontend/src/services/api-client/remote-resource.ts @@ -0,0 +1,70 @@ +export class RemoteResource { + static NOT_LOADED: NotLoadedState = {type: "notLoaded"}; + static LOADING: LoadingState = {type: "loading"}; + + private state: NotLoadedState | LoadingState | ErrorState | SuccessState = RemoteResource.NOT_LOADED; + + constructor(private readonly requestFn: () => Promise) { + } + + public async request(): Promise { + this.state = RemoteResource.LOADING; + try { + let resource = await this.requestFn(); + this.state = { + type: "success", + data: resource + }; + return resource; + } catch (e: any) { + this.state = { + type: "error", + errorCode: e.statusCode, + message: e.message, + error: e + }; + } + } + + public startRequestInBackground(): RemoteResource { + this.request().then(); + return this; + } + + public get data(): T | undefined { + 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; + } else { + throw new Error("Fetched resource, but afterwards, it was neither in a success nor in an error state. " + + "This should never happen."); + } + }); + } +} + +type NotLoadedState = { + type: "notLoaded" +}; +type LoadingState = { + type: "loading" +}; +type ErrorState = { + type: "error", + errorCode?: number, + message?: string, + error: any +}; +type SuccessState = { + type: "success", + data: T +} diff --git a/frontend/src/services/auth/auth-config-loader.ts b/frontend/src/services/auth/auth-config-loader.ts index ce8a33ca..ef5b63c3 100644 --- a/frontend/src/services/auth/auth-config-loader.ts +++ b/frontend/src/services/auth/auth-config-loader.ts @@ -1,4 +1,4 @@ -import apiClient from "@/services/api-client.ts"; +import apiClient from "@/services/api-client/api-client.ts"; import type { FrontendAuthConfig } from "@/services/auth/auth.d.ts"; /** diff --git a/frontend/src/services/auth/auth-service.ts b/frontend/src/services/auth/auth-service.ts index 2b3d2807..2963d8f4 100644 --- a/frontend/src/services/auth/auth-service.ts +++ b/frontend/src/services/auth/auth-service.ts @@ -8,7 +8,7 @@ import { User, UserManager } from "oidc-client-ts"; import { loadAuthConfig } from "@/services/auth/auth-config-loader.ts"; import authStorage from "./auth-storage.ts"; import { loginRoute } from "@/config.ts"; -import apiClient from "@/services/api-client.ts"; +import apiClient from "@/services/api-client/api-client.ts"; import router from "@/router"; import type { AxiosError } from "axios"; diff --git a/frontend/src/services/learning-paths/language.ts b/frontend/src/services/learning-content/language.ts similarity index 100% rename from frontend/src/services/learning-paths/language.ts rename to frontend/src/services/learning-content/language.ts diff --git a/frontend/src/services/learning-content/learning-object-service.ts b/frontend/src/services/learning-content/learning-object-service.ts new file mode 100644 index 00000000..076a662e --- /dev/null +++ b/frontend/src/services/learning-content/learning-object-service.ts @@ -0,0 +1,13 @@ +import {GetEndpoint} from "@/services/api-client/endpoints/get-endpoint.ts"; +import type {LearningObject} from "@/services/learning-content/learning-object.ts"; +import type {Language} from "@/services/learning-content/language.ts"; +import type {RemoteResource} from "@/services/api-client/remote-resource.ts"; + +const getLearningObjectMetadataEndpoint = new GetEndpoint<{hruid: string}, {language: Language, version: number}, LearningObject>( + "/learningObject/:hruid" +); + +export function getLearningObjectMetadata(hruid: string, language: Language, version: number): RemoteResource { + return getLearningObjectMetadataEndpoint + .get({hruid}, {language, version}); +} diff --git a/frontend/src/services/learning-content/learning-object.ts b/frontend/src/services/learning-content/learning-object.ts new file mode 100644 index 00000000..ff660420 --- /dev/null +++ b/frontend/src/services/learning-content/learning-object.ts @@ -0,0 +1,33 @@ +import type {Language} from "@/services/learning-content/language.ts"; + +export interface EducationalGoal { + source: string; + id: string; +} + +export interface ReturnValue { + callback_url: string; + callback_schema: Record; +} + +export interface LearningObject { + key: string; + _id: string; + uuid: string; + version: number; + title: string; + htmlUrl: string; + language: Language; + difficulty: number; + estimatedTime?: number; + available: boolean; + teacherExclusive: boolean; + educationalGoals: EducationalGoal[]; + keywords: string[]; + description: string; + targetAges: number[]; + contentType: string; + contentLocation?: string; + skosConcepts?: string[]; + returnValue?: ReturnValue; +} diff --git a/frontend/src/services/learning-content/learning-path-service.ts b/frontend/src/services/learning-content/learning-path-service.ts new file mode 100644 index 00000000..be9dffd5 --- /dev/null +++ b/frontend/src/services/learning-content/learning-path-service.ts @@ -0,0 +1,13 @@ +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"; + +const searchLearningPathsEndpoint = new GetEndpoint<{}, {query: string}, LearningPathDTO[]>( + "/learningObjects/:query" +); + +export function searchLearningPaths(query: string): RemoteResource { + return searchLearningPathsEndpoint + .get({}, {query: query}) + .map(dtos => dtos.map(dto => LearningPath.fromDTO(dto))); +} diff --git a/frontend/src/services/learning-content/learning-path.ts b/frontend/src/services/learning-content/learning-path.ts new file mode 100644 index 00000000..7aefa28e --- /dev/null +++ b/frontend/src/services/learning-content/learning-path.ts @@ -0,0 +1,118 @@ +import type {Language} from "@/services/learning-content/language.ts"; +import type {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"; + +export interface LearningPathDTO { + language: string; + hruid: string; + title: string; + description: string; + image?: string; // Image might be missing, so it's optional + num_nodes: number; + num_nodes_left: number; + nodes: LearningPathNodeDTO[]; + keywords: string; + target_ages: number[]; + min_age: number; + max_age: number; + __order: number; +} + +interface LearningPathNodeDTO { + _id: string; + learningobject_hruid: string; + version: number; + language: Language; + start_node?: boolean; + transitions: LearningPathTransitionDTO[]; + created_at: string; + updatedAt: string; + done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. +} + +interface LearningPathTransitionDTO { + default: boolean; + _id: string; + next: { + _id: string; + hruid: string; + version: number; + language: string; + }; +} + +export class LearningPathNode { + public learningObject: RemoteResource + + constructor( + public readonly learningobjectHruid: string, + public readonly version: number, + public readonly language: Language, + public readonly transitions: {next: LearningPathNode, default: boolean}[], + public readonly createdAt: Date, + public readonly updatedAt: Date + ) { + this.learningObject = getLearningObjectMetadata(learningobjectHruid, language, version); + } + + static fromDTOAndOtherNodes(dto: LearningPathNodeDTO, otherNodes: LearningPathNodeDTO[]): LearningPathNode { + return new LearningPathNode( + dto.learningobject_hruid, + dto.version, + dto.language, + dto.transitions.map(transDto => { + let nextNodeDto = otherNodes.filter(it => + it.learningobject_hruid === transDto.next.hruid + && it.language === transDto.next.language + && it.version === transDto.next.version + ); + if (nextNodeDto.length !== 1) { + throw new Error(`Invalid learning path! There is a transition to node` + + `${transDto.next.hruid}/${transDto.next.language}/${transDto.next.version}, but there are` + + `${nextNodeDto.length} such nodes.`); + } + return { + next: LearningPathNode.fromDTOAndOtherNodes(nextNodeDto[0], otherNodes), + default: transDto.default + } + }), + new Date(dto.created_at), + new Date(dto.updatedAt), + ) + } +} + +export class LearningPath { + constructor( + public readonly language: string, + public readonly hruid: string, + public readonly title: string, + public readonly description: string, + public readonly amountOfNodes: number, + public readonly amountOfNodesLeft: number, + public readonly keywords: string[], + public readonly targetAges: {min: number; max: number}, + public readonly startNode: LearningPathNode, + public readonly image?: string // Image might be missing, so it's optional + ) { + } + + static fromDTO(dto: LearningPathDTO): LearningPath { + let startNodeDto = dto.nodes.filter(it => it.start_node); + if (startNodeDto.length !== 1) { + throw new Error(`Invalid learning path! Expected precisely one start node, but there were ${startNodeDto.length}.`); + } + return new LearningPath( + dto.language, + dto.hruid, + dto.title, + dto.description, + dto.num_nodes, + dto.num_nodes_left, + dto.keywords.split(' '), + {min: dto.min_age, max: dto.max_age}, + LearningPathNode.fromDTOAndOtherNodes(startNodeDto[0], dto.nodes) + ) + } +} diff --git a/frontend/src/services/learning-paths/learning-object.ts b/frontend/src/services/learning-paths/learning-object.ts deleted file mode 100644 index cde6915e..00000000 --- a/frontend/src/services/learning-paths/learning-object.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type {Language} from "@/services/learning-paths/language.ts"; - -export interface LearningPathIdentifier { - hruid: string; - language: Language; -} - -export interface EducationalGoal { - source: string; - id: string; -} - -export interface ReturnValue { - callback_url: string; - callback_schema: Record; -} - -export interface LearningObjectMetadata { - _id: string; - uuid: string; - hruid: string; - version: number; - language: Language; - title: string; - description: string; - difficulty: number; - estimated_time: number; - available: boolean; - teacher_exclusive: boolean; - educational_goals: EducationalGoal[]; - keywords: string[]; - target_ages: number[]; - content_type: string; // Markdown, image, etc. - content_location?: string; - skos_concepts?: string[]; - return_value?: ReturnValue; -} diff --git a/frontend/src/services/learning-paths/learning-path-service.ts b/frontend/src/services/learning-paths/learning-path-service.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/services/learning-paths/learning-path.ts b/frontend/src/services/learning-paths/learning-path.ts deleted file mode 100644 index 2347b04e..00000000 --- a/frontend/src/services/learning-paths/learning-path.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type {Language} from "@/services/learning-paths/language.ts"; - -export interface LearningPath { - language: string; - hruid: string; - title: string; - description: string; - image?: string; // Image might be missing, so it's optional - num_nodes: number; - num_nodes_left: number; - nodes: LearningObjectNode[]; - keywords: string; - target_ages: number[]; - min_age: number; - max_age: number; - __order: number; -} - -export interface LearningObjectNode { - _id: string; - learningobject_hruid: string; - version: number; - language: Language; - start_node?: boolean; - transitions: Transition[]; - created_at: string; - updatedAt: string; - done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. -} - -export interface Transition { - default: boolean; - _id: string; - next: { - _id: string; - hruid: string; - version: number; - language: string; - }; -} diff --git a/frontend/src/views/HomePage.vue b/frontend/src/views/HomePage.vue index e9d53770..c17ca147 100644 --- a/frontend/src/views/HomePage.vue +++ b/frontend/src/views/HomePage.vue @@ -1,6 +1,6 @@