From 8b0fc4263f3641f45dbfa66ee3464aef4cabbe94 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Sat, 22 Mar 2025 16:16:34 +0100 Subject: [PATCH 01/35] feat(frontend): Skelet voor de implementatie van views & services voor leerpaden aangemaakt. --- frontend/src/components/LearningPath.vue | 7 - frontend/src/router/index.ts | 7 + .../src/services/learning-paths/language.ts | 186 ++++++++++++++++++ .../learning-paths/learning-object.ts | 37 ++++ .../learning-paths/learning-path-service.ts | 0 .../services/learning-paths/learning-path.ts | 40 ++++ .../views/learning-paths/LearningPathPage.vue | 20 ++ 7 files changed, 290 insertions(+), 7 deletions(-) delete mode 100644 frontend/src/components/LearningPath.vue create mode 100644 frontend/src/services/learning-paths/language.ts create mode 100644 frontend/src/services/learning-paths/learning-object.ts create mode 100644 frontend/src/services/learning-paths/learning-path-service.ts create mode 100644 frontend/src/services/learning-paths/learning-path.ts create mode 100644 frontend/src/views/learning-paths/LearningPathPage.vue diff --git a/frontend/src/components/LearningPath.vue b/frontend/src/components/LearningPath.vue deleted file mode 100644 index 1a35a59f..00000000 --- a/frontend/src/components/LearningPath.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 5f2c4624..be11c0df 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -13,6 +13,7 @@ import UserDiscussions from "@/views/discussions/UserDiscussions.vue"; 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"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -99,6 +100,12 @@ const router = createRouter({ component: SingleDiscussion, meta: { requiresAuth: true }, }, + { + path: "/learningPath/:hruid/:language", + name: "LearningPath", + component: LearningPathPage, + meta: { requiresAuth: false } + }, { path: "/:catchAll(.*)", name: "NotFound", diff --git a/frontend/src/services/learning-paths/language.ts b/frontend/src/services/learning-paths/language.ts new file mode 100644 index 00000000..d7687331 --- /dev/null +++ b/frontend/src/services/learning-paths/language.ts @@ -0,0 +1,186 @@ +export enum Language { + Afar = 'aa', + Abkhazian = 'ab', + Afrikaans = 'af', + Akan = 'ak', + Albanian = 'sq', + Amharic = 'am', + Arabic = 'ar', + Aragonese = 'an', + Armenian = 'hy', + Assamese = 'as', + Avaric = 'av', + Avestan = 'ae', + Aymara = 'ay', + Azerbaijani = 'az', + Bashkir = 'ba', + Bambara = 'bm', + Basque = 'eu', + Belarusian = 'be', + Bengali = 'bn', + Bihari = 'bh', + Bislama = 'bi', + Bosnian = 'bs', + Breton = 'br', + Bulgarian = 'bg', + Burmese = 'my', + Catalan = 'ca', + Chamorro = 'ch', + Chechen = 'ce', + Chinese = 'zh', + ChurchSlavic = 'cu', + Chuvash = 'cv', + Cornish = 'kw', + Corsican = 'co', + Cree = 'cr', + Czech = 'cs', + Danish = 'da', + Divehi = 'dv', + Dutch = 'nl', + Dzongkha = 'dz', + English = 'en', + Esperanto = 'eo', + Estonian = 'et', + Ewe = 'ee', + Faroese = 'fo', + Fijian = 'fj', + Finnish = 'fi', + French = 'fr', + Frisian = 'fy', + Fulah = 'ff', + Georgian = 'ka', + German = 'de', + Gaelic = 'gd', + Irish = 'ga', + Galician = 'gl', + Manx = 'gv', + Greek = 'el', + Guarani = 'gn', + Gujarati = 'gu', + Haitian = 'ht', + Hausa = 'ha', + Hebrew = 'he', + Herero = 'hz', + Hindi = 'hi', + HiriMotu = 'ho', + Croatian = 'hr', + Hungarian = 'hu', + Igbo = 'ig', + Icelandic = 'is', + Ido = 'io', + SichuanYi = 'ii', + Inuktitut = 'iu', + Interlingue = 'ie', + Interlingua = 'ia', + Indonesian = 'id', + Inupiaq = 'ik', + Italian = 'it', + Javanese = 'jv', + Japanese = 'ja', + Kalaallisut = 'kl', + Kannada = 'kn', + Kashmiri = 'ks', + Kanuri = 'kr', + Kazakh = 'kk', + Khmer = 'km', + Kikuyu = 'ki', + Kinyarwanda = 'rw', + Kirghiz = 'ky', + Komi = 'kv', + Kongo = 'kg', + Korean = 'ko', + Kuanyama = 'kj', + Kurdish = 'ku', + Lao = 'lo', + Latin = 'la', + Latvian = 'lv', + Limburgan = 'li', + Lingala = 'ln', + Lithuanian = 'lt', + Luxembourgish = 'lb', + LubaKatanga = 'lu', + Ganda = 'lg', + Macedonian = 'mk', + Marshallese = 'mh', + Malayalam = 'ml', + Maori = 'mi', + Marathi = 'mr', + Malay = 'ms', + Malagasy = 'mg', + Maltese = 'mt', + Mongolian = 'mn', + Nauru = 'na', + Navajo = 'nv', + SouthNdebele = 'nr', + NorthNdebele = 'nd', + Ndonga = 'ng', + Nepali = 'ne', + NorwegianNynorsk = 'nn', + NorwegianBokmal = 'nb', + Norwegian = 'no', + Chichewa = 'ny', + Occitan = 'oc', + Ojibwa = 'oj', + Oriya = 'or', + Oromo = 'om', + Ossetian = 'os', + Punjabi = 'pa', + Persian = 'fa', + Pali = 'pi', + Polish = 'pl', + Portuguese = 'pt', + Pashto = 'ps', + Quechua = 'qu', + Romansh = 'rm', + Romanian = 'ro', + Rundi = 'rn', + Russian = 'ru', + Sango = 'sg', + Sanskrit = 'sa', + Sinhala = 'si', + Slovak = 'sk', + Slovenian = 'sl', + NorthernSami = 'se', + Samoan = 'sm', + Shona = 'sn', + Sindhi = 'sd', + Somali = 'so', + Sotho = 'st', + Spanish = 'es', + Sardinian = 'sc', + Serbian = 'sr', + Swati = 'ss', + Sundanese = 'su', + Swahili = 'sw', + Swedish = 'sv', + Tahitian = 'ty', + Tamil = 'ta', + Tatar = 'tt', + Telugu = 'te', + Tajik = 'tg', + Tagalog = 'tl', + Thai = 'th', + Tibetan = 'bo', + Tigrinya = 'ti', + Tonga = 'to', + Tswana = 'tn', + Tsonga = 'ts', + Turkmen = 'tk', + Turkish = 'tr', + Twi = 'tw', + Uighur = 'ug', + Ukrainian = 'uk', + Urdu = 'ur', + Uzbek = 'uz', + Venda = 've', + Vietnamese = 'vi', + Volapuk = 'vo', + Welsh = 'cy', + Walloon = 'wa', + Wolof = 'wo', + Xhosa = 'xh', + Yiddish = 'yi', + Yoruba = 'yo', + Zhuang = 'za', + Zulu = 'zu', +} diff --git a/frontend/src/services/learning-paths/learning-object.ts b/frontend/src/services/learning-paths/learning-object.ts new file mode 100644 index 00000000..cde6915e --- /dev/null +++ b/frontend/src/services/learning-paths/learning-object.ts @@ -0,0 +1,37 @@ +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 new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/services/learning-paths/learning-path.ts b/frontend/src/services/learning-paths/learning-path.ts new file mode 100644 index 00000000..2347b04e --- /dev/null +++ b/frontend/src/services/learning-paths/learning-path.ts @@ -0,0 +1,40 @@ +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/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue new file mode 100644 index 00000000..14e911e2 --- /dev/null +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -0,0 +1,20 @@ + + + + + From 3c3fddb7d016ed4a05ac3363de65431f47e18941 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Sun, 23 Mar 2025 08:56:34 +0100 Subject: [PATCH 02/35] =?UTF-8?q?feat(frontend):=20LearningObjectService?= =?UTF-8?q?=20en=20LearningPathService=20ge=C3=AFmplementeerd.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/UsingRemoteResource.vue | 39 ++++++ frontend/src/i18n/locale/de.json | 3 +- frontend/src/i18n/locale/en.json | 3 +- frontend/src/i18n/locale/fr.json | 3 +- frontend/src/i18n/locale/nl.json | 3 +- frontend/src/main.ts | 8 ++ .../services/{ => api-client}/api-client.ts | 0 .../services/api-client/api-exceptions.d.ts | 10 ++ .../api-client/endpoints/delete-endpoint.ts | 10 ++ .../api-client/endpoints/get-endpoint.ts | 10 ++ .../api-client/endpoints/post-endpoint.ts | 10 ++ .../api-client/endpoints/rest-endpoint.ts | 30 +++++ .../services/api-client/remote-resource.ts | 70 +++++++++++ .../src/services/auth/auth-config-loader.ts | 2 +- frontend/src/services/auth/auth-service.ts | 2 +- .../language.ts | 0 .../learning-object-service.ts | 13 ++ .../learning-content/learning-object.ts | 33 +++++ .../learning-content/learning-path-service.ts | 13 ++ .../learning-content/learning-path.ts | 118 ++++++++++++++++++ .../learning-paths/learning-object.ts | 37 ------ .../learning-paths/learning-path-service.ts | 0 .../services/learning-paths/learning-path.ts | 40 ------ frontend/src/views/HomePage.vue | 2 +- 24 files changed, 375 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/UsingRemoteResource.vue rename frontend/src/services/{ => api-client}/api-client.ts (100%) create mode 100644 frontend/src/services/api-client/api-exceptions.d.ts create mode 100644 frontend/src/services/api-client/endpoints/delete-endpoint.ts create mode 100644 frontend/src/services/api-client/endpoints/get-endpoint.ts create mode 100644 frontend/src/services/api-client/endpoints/post-endpoint.ts create mode 100644 frontend/src/services/api-client/endpoints/rest-endpoint.ts create mode 100644 frontend/src/services/api-client/remote-resource.ts rename frontend/src/services/{learning-paths => learning-content}/language.ts (100%) create mode 100644 frontend/src/services/learning-content/learning-object-service.ts create mode 100644 frontend/src/services/learning-content/learning-object.ts create mode 100644 frontend/src/services/learning-content/learning-path-service.ts create mode 100644 frontend/src/services/learning-content/learning-path.ts delete mode 100644 frontend/src/services/learning-paths/learning-object.ts delete mode 100644 frontend/src/services/learning-paths/learning-path-service.ts delete mode 100644 frontend/src/services/learning-paths/learning-path.ts 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 @@ 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 @@ diff --git a/frontend/src/services/api-client/endpoints/delete-endpoint.ts b/frontend/src/services/api-client/endpoints/delete-endpoint.ts index 554e1855..0c01e55a 100644 --- a/frontend/src/services/api-client/endpoints/delete-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/delete-endpoint.ts @@ -1,10 +1,9 @@ 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 { + public delete(pathParams: PP, queryParams: QP): Promise { 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 index 1d9a086f..4393e288 100644 --- a/frontend/src/services/api-client/endpoints/get-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/get-endpoint.ts @@ -1,10 +1,9 @@ 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 { + public get(pathParams: PP, queryParams: QP): Promise { return super.request(pathParams, queryParams, undefined); } } diff --git a/frontend/src/services/api-client/endpoints/get-html-endpoint.ts b/frontend/src/services/api-client/endpoints/get-html-endpoint.ts index 2b646c16..9bb2e523 100644 --- a/frontend/src/services/api-client/endpoints/get-html-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/get-html-endpoint.ts @@ -1,10 +1,9 @@ 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 { + public get(pathParams: PP, queryParams: QP): Promise { return super.request(pathParams, queryParams, undefined, "document"); } } diff --git a/frontend/src/services/api-client/endpoints/post-endpoint.ts b/frontend/src/services/api-client/endpoints/post-endpoint.ts index 6fde53e8..9b5fd96f 100644 --- a/frontend/src/services/api-client/endpoints/post-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/post-endpoint.ts @@ -1,10 +1,9 @@ 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 { + public post(pathParams: PP, queryParams: QP, body: B): Promise { 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 index cbe18aa0..4438214f 100644 --- a/frontend/src/services/api-client/endpoints/rest-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/rest-endpoint.ts @@ -1,4 +1,3 @@ -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.ts"; import type {ResponseType} from "axios"; @@ -8,27 +7,21 @@ export abstract class RestEndpoint { constructor(public readonly url: string) { } - protected request(pathParams: PP, queryParams: QP, body: B, responseType?: ResponseType): RemoteResource { + protected async request(pathParams: PP, queryParams: QP, body: B, responseType?: ResponseType): Promise { 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); - } - return response.data; + 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); + } + return response.data; } } diff --git a/frontend/src/services/api-client/remote-resource.ts b/frontend/src/services/api-client/remote-resource.ts index 4e3cbdfb..24982135 100644 --- a/frontend/src/services/api-client/remote-resource.ts +++ b/frontend/src/services/api-client/remote-resource.ts @@ -1,78 +1,4 @@ -export class RemoteResource { - static NOT_LOADED: NotLoadedState = {type: "notLoaded"}; - static LOADING: LoadingState = {type: "loading"}; - - 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; - 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 state(): RemoteResourceState { - return this._state; - } - - 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."); - } - }); - } -} +import {type ShallowReactive, shallowReactive} from "vue"; export type NotLoadedState = { type: "notLoaded" @@ -82,8 +8,6 @@ export type LoadingState = { }; export type ErrorState = { type: "error", - errorCode?: number, - message?: string, error: any }; export type SuccessState = { @@ -91,3 +15,23 @@ export type SuccessState = { data: T }; export type RemoteResourceState = NotLoadedState | LoadingState | ErrorState | SuccessState; + +export type RemoteResource = ShallowReactive<{ + state: RemoteResourceState +}>; + +export function remoteResource(): RemoteResource { + return shallowReactive({ + state: { + type: "notLoaded" + } + }); +} + +export function loadResource(resource: RemoteResource, promise: Promise): void { + resource.state = { type: "loading" } + promise.then( + data => resource.state = { type: "success", data }, + error => resource.state = { type: "error", error } + ); +} diff --git a/frontend/src/services/learning-content/learning-object-service.ts b/frontend/src/services/learning-content/learning-object-service.ts index 076a662e..c6148025 100644 --- a/frontend/src/services/learning-content/learning-object-service.ts +++ b/frontend/src/services/learning-content/learning-object-service.ts @@ -1,13 +1,28 @@ 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"; +import {GetHtmlEndpoint} from "@/services/api-client/endpoints/get-html-endpoint.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}); +const getLearningObjectHtmlEndpoint = new GetHtmlEndpoint<{hruid: string}, {language: Language, version: number}>( + "/learningObject/:hruid/html" +); + +export function getLearningObjectMetadata( + hruid: string, + language: Language, + version: number +): Promise { + return getLearningObjectMetadataEndpoint.get({hruid}, {language, version}); +} + +export function getLearningObjectHTML( + hruid: string, + language: Language, + version: number +): Promise { + return getLearningObjectHtmlEndpoint.get({hruid}, {language, version}); } diff --git a/frontend/src/services/learning-content/learning-path-service.ts b/frontend/src/services/learning-content/learning-path-service.ts index c1af58c9..6c0043ad 100644 --- a/frontend/src/services/learning-content/learning-path-service.ts +++ b/frontend/src/services/learning-content/learning-path-service.ts @@ -1,6 +1,5 @@ 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"; @@ -8,16 +7,12 @@ const learningPathEndpoint = new GetEndpoint<{}, {search?: string, hruid?: strin "/learningPath" ); -export function searchLearningPaths(query: string): RemoteResource { - return learningPathEndpoint - .get({}, {search: query}) - .map(dtos => dtos.map(dto => LearningPath.fromDTO(dto))); +export async function searchLearningPaths(query: string): Promise { + let dtos = await learningPathEndpoint.get({}, {search: query}) + return 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))); +export async function getLearningPath(hruid: string, language: Language): Promise { + let dtos = await learningPathEndpoint.get({}, {hruid, language}); + return 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 19a94339..a07faf9c 100644 --- a/frontend/src/services/learning-content/learning-path.ts +++ b/frontend/src/services/learning-content/learning-path.ts @@ -1,5 +1,4 @@ import type {Language} from "@/services/learning-content/language.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"; @@ -43,7 +42,6 @@ interface LearningPathTransitionDTO { } export class LearningPathNode { - public learningObject: RemoteResource constructor( public readonly learningobjectHruid: string, @@ -53,7 +51,10 @@ export class LearningPathNode { public readonly createdAt: Date, public readonly updatedAt: Date ) { - this.learningObject = getLearningObjectMetadata(learningobjectHruid, language, version); + } + + get learningObject(): Promise { + return getLearningObjectMetadata(this.learningobjectHruid, this.language, this.version); } static fromDTOAndOtherNodes(dto: LearningPathNodeDTO, otherNodes: LearningPathNodeDTO[]): LearningPathNode { @@ -109,8 +110,8 @@ export class LearningPath { return list; } - public get learningObjectsAsList(): RemoteResource { - return RemoteResource.join(this.nodesAsList.map(node => node.learningObject)); + public get learningObjectsAsList(): Promise { + return Promise.all(this.nodesAsList.map(node => node.learningObject)); } static fromDTO(dto: LearningPathDTO): LearningPath { diff --git a/frontend/src/views/learning-paths/LearningObjectView.vue b/frontend/src/views/learning-paths/LearningObjectView.vue new file mode 100644 index 00000000..064965ce --- /dev/null +++ b/frontend/src/views/learning-paths/LearningObjectView.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue index 541f0fbe..cadc02c0 100644 --- a/frontend/src/views/learning-paths/LearningPathPage.vue +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -2,31 +2,52 @@ import {Language} from "@/services/learning-content/language.ts"; import {getLearningPath} from "@/services/learning-content/learning-path-service.ts"; import UsingRemoteResource from "@/components/UsingRemoteResource.vue"; - import type {LearningPath} from "@/services/learning-content/learning-path.ts"; - import {onMounted, reactive, watch} from "vue"; + import {type LearningPath} from "@/services/learning-content/learning-path.ts"; + import {computed, watch, watchEffect} from "vue"; import type {LearningObject} from "@/services/learning-content/learning-object.ts"; import {useRouter} from "vue-router"; - import type {SuccessState} from "@/services/api-client/remote-resource.ts"; + import {loadResource, remoteResource, type SuccessState} from "@/services/api-client/remote-resource.ts"; + import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; const router = useRouter(); const props = defineProps<{hruid: string, language: Language, learningObjectHruid?: string}>() - const learningPathResource = reactive(getLearningPath(props.hruid, props.language)); + const learningPathResource = remoteResource(); + watchEffect(() => { + loadResource(learningPathResource, getLearningPath(props.hruid, props.language)); + }); + + const learningObjectListResource = remoteResource(); + watch(learningPathResource, () => { + if (learningPathResource.state.type === "success") { + loadResource(learningObjectListResource, learningPathResource.state.data.learningObjectsAsList) + } + }, {immediate: true}); + + const currentNode = computed(() => { + let currentHruid = props.learningObjectHruid; + if (learningPathResource.state.type === "success") { + return learningPathResource.state.data.nodesAsList.filter(it => it.learningobjectHruid === currentHruid)[0] + } else { + return undefined; + } + }); if (!props.learningObjectHruid) { watch(() => learningPathResource.state, (newValue) => { - console.log("state changed!!"); if (newValue.type === "success") { router.push(router.currentRoute.value.path + "/" + (newValue as SuccessState).data.startNode.learningobjectHruid); } }); } - From 4356a1ccd20e8583ca5bbf92007cabdf2dd2f829 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Mon, 24 Mar 2025 22:58:44 +0100 Subject: [PATCH 05/35] feat(frontend): "Volgende" en "vorige"-knop toegevoegd aan leerpadpagina. --- .../src/components/UsingRemoteResource.vue | 1 + frontend/src/i18n/locale/de.json | 4 +- frontend/src/i18n/locale/en.json | 4 +- frontend/src/i18n/locale/fr.json | 4 +- frontend/src/i18n/locale/nl.json | 4 +- .../learning-content/learning-path-service.ts | 15 ++- .../learning-content/learning-path.ts | 4 +- .../views/learning-paths/LearningPathPage.vue | 98 ++++++++++++++++--- 8 files changed, 112 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/UsingRemoteResource.vue b/frontend/src/components/UsingRemoteResource.vue index 5248d7a9..a33e327b 100644 --- a/frontend/src/components/UsingRemoteResource.vue +++ b/frontend/src/components/UsingRemoteResource.vue @@ -35,6 +35,7 @@ diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json index b9a087fc..13638aaa 100644 --- a/frontend/src/i18n/locale/de.json +++ b/frontend/src/i18n/locale/de.json @@ -1,4 +1,6 @@ { "welcome": "Willkommen", - "error_title": "Fehler" + "error_title": "Fehler", + "previous": "Zurück", + "next": "Weiter" } diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index a04b3fa5..3e149759 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -6,5 +6,7 @@ "classes": "classes", "discussions": "discussions", "logout": "log out", - "error_title": "Error" + "error_title": "Error", + "previous": "Previous", + "next": "Next" } diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json index 1d59c4dd..924dc94b 100644 --- a/frontend/src/i18n/locale/fr.json +++ b/frontend/src/i18n/locale/fr.json @@ -1,4 +1,6 @@ { "welcome": "Bienvenue", - "error_title": "Erreur" + "error_title": "Erreur", + "previous": "Précédente", + "next": "Suivante" } diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json index 576a95b4..8e058396 100644 --- a/frontend/src/i18n/locale/nl.json +++ b/frontend/src/i18n/locale/nl.json @@ -6,5 +6,7 @@ "classes": "klassen", "discussions": "discussies", "logout": "log uit", - "error_title": "Fout" + "error_title": "Fout", + "previous": "Vorige", + "next": "Volgende" } diff --git a/frontend/src/services/learning-content/learning-path-service.ts b/frontend/src/services/learning-content/learning-path-service.ts index 6c0043ad..a0a9f1a9 100644 --- a/frontend/src/services/learning-content/learning-path-service.ts +++ b/frontend/src/services/learning-content/learning-path-service.ts @@ -3,16 +3,21 @@ import {LearningPath, type LearningPathDTO} from "@/services/learning-content/le import type {Language} from "@/services/learning-content/language.ts"; import {single} from "@/utils/response-assertions.ts"; -const learningPathEndpoint = new GetEndpoint<{}, {search?: string, hruid?: string, language?: Language}, LearningPathDTO[]>( - "/learningPath" -); +const learningPathEndpoint = new GetEndpoint< + {}, + {search?: string, hruid?: string, language?: Language, forGroup?: string, forStudent?: string}, + LearningPathDTO[] +>("/learningPath"); export async function searchLearningPaths(query: string): Promise { let dtos = await learningPathEndpoint.get({}, {search: query}) return dtos.map(dto => LearningPath.fromDTO(dto)); } -export async function getLearningPath(hruid: string, language: Language): Promise { - let dtos = await learningPathEndpoint.get({}, {hruid, language}); +export async function getLearningPath(hruid: string, language: Language, options?: {forGroup?: string, forStudent?: string}): Promise { + let dtos = await learningPathEndpoint.get( + {}, + {hruid, language, forGroup: options?.forGroup, forStudent: options?.forStudent} + ); return 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 a07faf9c..2bbef7f2 100644 --- a/frontend/src/services/learning-content/learning-path.ts +++ b/frontend/src/services/learning-content/learning-path.ts @@ -49,7 +49,8 @@ export class LearningPathNode { public readonly language: Language, public readonly transitions: {next: LearningPathNode, default: boolean}[], public readonly createdAt: Date, - public readonly updatedAt: Date + public readonly updatedAt: Date, + public readonly done: boolean = false ) { } @@ -80,6 +81,7 @@ export class LearningPathNode { }), new Date(dto.created_at), new Date(dto.updatedAt), + dto.done ) } } diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue index cadc02c0..05f0e116 100644 --- a/frontend/src/views/learning-paths/LearningPathPage.vue +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -2,20 +2,36 @@ import {Language} from "@/services/learning-content/language.ts"; import {getLearningPath} from "@/services/learning-content/learning-path-service.ts"; import UsingRemoteResource from "@/components/UsingRemoteResource.vue"; - import {type LearningPath} from "@/services/learning-content/learning-path.ts"; - import {computed, watch, watchEffect} from "vue"; + import {type LearningPath, LearningPathNode} from "@/services/learning-content/learning-path.ts"; + import {computed, type ComputedRef, watch} from "vue"; import type {LearningObject} from "@/services/learning-content/learning-object.ts"; - import {useRouter} from "vue-router"; + import {useRoute, useRouter} from "vue-router"; import {loadResource, remoteResource, type SuccessState} from "@/services/api-client/remote-resource.ts"; import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; + import {useI18n} from "vue-i18n"; const router = useRouter(); + const route = useRoute(); + const { t } = useI18n(); + const props = defineProps<{hruid: string, language: Language, learningObjectHruid?: string}>() + interface QueryParams { + forStudent?: string, + forGroup?: string + } + const learningPathResource = remoteResource(); - watchEffect(() => { - loadResource(learningPathResource, getLearningPath(props.hruid, props.language)); - }); + watch([() => props.hruid, () => props.language, () => route.query.forStudent, () => route.query.forGroup], () => { + loadResource( + learningPathResource, + getLearningPath( + props.hruid, + props.language, + route.query as QueryParams + ) + ) + }, {immediate: true}); const learningObjectListResource = remoteResource(); watch(learningPathResource, () => { @@ -24,12 +40,36 @@ } }, {immediate: true}); - const currentNode = computed(() => { - let currentHruid = props.learningObjectHruid; + const nodesList: ComputedRef = computed(() => { if (learningPathResource.state.type === "success") { - return learningPathResource.state.data.nodesAsList.filter(it => it.learningobjectHruid === currentHruid)[0] + return learningPathResource.state.data.nodesAsList; } else { - return undefined; + return null; + } + }) + + const currentNode = computed(() => { + const currentHruid = props.learningObjectHruid; + if (nodesList.value) { + return nodesList.value.filter(it => it.learningobjectHruid === currentHruid)[0] + } + }); + + const nextNode = computed(() => { + if (!currentNode.value || !nodesList.value) + return; + const currentIndex = nodesList.value?.indexOf(currentNode.value); + if (currentIndex < nodesList.value?.length) { + return nodesList.value?.[currentIndex + 1]; + } + }); + + const previousNode = computed(() => { + if (!currentNode.value || !nodesList.value) + return; + const currentIndex = nodesList.value?.indexOf(currentNode.value); + if (currentIndex < nodesList.value?.length) { + return nodesList.value?.[currentIndex - 1]; } }); @@ -41,6 +81,17 @@ } }); } + + function isLearningObjectCompleted(learningObject: LearningObject): boolean { + if (learningPathResource.state.type === "success") { + return learningPathResource.state.data.nodesAsList.filter(it => + it.learningobjectHruid === learningObject.key + && it.version === learningObject.version + && it.language == learningObject.language + )[0].done; + } + return false; + }