feat(frontend): Navigatie voor leerpad geïmplementeerd.

This commit is contained in:
Gerald Schmittinger 2025-03-23 19:20:56 +01:00
parent 3c3fddb7d0
commit 07340de2e3
13 changed files with 216 additions and 54 deletions

View file

@ -4,9 +4,8 @@ import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifie
import learningObjectService from '../services/learning-objects/learning-object-service.js'; import learningObjectService from '../services/learning-objects/learning-object-service.js';
import { EnvVars, getEnvVar } from '../util/envvars.js'; import { EnvVars, getEnvVar } from '../util/envvars.js';
import { Language } from '../entities/content/language.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 attachmentService from '../services/learning-objects/attachment-service.js';
import { NotFoundError } from '@mikro-orm/core';
function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier {
if (!req.params.hruid) { if (!req.params.hruid) {
@ -47,6 +46,11 @@ export async function getLearningObject(req: Request, res: Response): Promise<vo
const learningObjectId = getLearningObjectIdentifierFromRequest(req); const learningObjectId = getLearningObjectIdentifierFromRequest(req);
const learningObject = await learningObjectService.getLearningObjectById(learningObjectId); const learningObject = await learningObjectService.getLearningObjectById(learningObjectId);
if (!learningObject) {
throw new NotFoundException("Learning object not found");
}
res.json(learningObject); res.json(learningObject);
} }
@ -63,7 +67,7 @@ export async function getAttachment(req: Request, res: Response): Promise<void>
const attachment = await attachmentService.getAttachment(learningObjectId, name); const attachment = await attachmentService.getAttachment(learningObjectId, name);
if (!attachment) { 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); res.setHeader('Content-Type', attachment.mimeType).send(attachment.content);
} }

View file

@ -1,10 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import auth from "@/services/auth/auth-service.ts"; import auth from "@/services/auth/auth-service.ts";
import MenuBar from "@/components/MenuBar.vue";
auth.loadUser(); auth.loadUser();
</script> </script>
<template> <template>
<v-app>
<menu-bar></menu-bar>
<v-main>
<router-view /> <router-view />
</v-main>
</v-app>
</template> </template>
<style scoped></style> <style scoped></style>

View file

@ -37,7 +37,7 @@
</script> </script>
<template> <template>
<main> <v-app-bar>
<nav class="menu"> <nav class="menu">
<div class="left"> <div class="left">
<ul> <ul>
@ -133,7 +133,7 @@
</li> </li>
</div> </div>
</nav> </nav>
</main> </v-app-bar>
</template> </template>
<style scoped> <style scoped>
@ -188,4 +188,8 @@
nav a.router-link-active { nav a.router-link-active {
font-weight: bold; font-weight: bold;
} }
nav {
width: 100%;
}
</style> </style>

View file

@ -1,39 +1,51 @@
<script setup lang="ts" generic="T"> <script setup lang="ts" generic="T">
import {RemoteResource} from "@/services/api-client/remote-resource.ts"; import {type ErrorState, RemoteResource, type RemoteResourceState} from "@/services/api-client/remote-resource.ts";
import {computed} from "vue"; import {computed, onMounted, reactive, ref, type UnwrapNestedRefs, watch} from "vue";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
const props = defineProps<{ const props = defineProps<{
resource: RemoteResource<T> resource: RemoteResource<T> | (UnwrapNestedRefs<RemoteResource<T>> & {})
}>() }>()
const resource = reactive(props.resource as RemoteResource<T>);
const { t } = useI18n(); const { t } = useI18n();
const isLoading = computed(() => props.resource.state.type === 'loading'); const isLoading = computed(() => resource.state.type === 'loading');
const isError = computed(() => props.resource.state.type === 'error'); const isError = computed(() => resource.state.type === 'error');
// `data` will be correctly inferred as `T` // `data` will be correctly inferred as `T`
const data = computed(() => props.resource.data); const data = computed(() => resource.data);
const error = computed(() => props.resource.state.type === "error" ? props.resource.state : null); const error = computed(() => resource.state.type === "error" ? resource.state as ErrorState : null);
const errorMessage = computed(() => const errorMessage = computed(() =>
error.value?.message ? error.value.message : JSON.stringify(error.value?.error) error.value?.message ? error.value.message : JSON.stringify(error.value?.error)
); );
watch(data, (newValue, _) => {
if (!newValue && resource.state.type !== "loading") {
(resource as RemoteResource<T>).startRequestInBackground();
}
}, {immediate: true});
</script> </script>
<template> <template>
<div v-if="isLoading"> <div class="loading-div" v-if="isLoading">
<v-progress-circular indeterminate></v-progress-circular> <v-progress-circular indeterminate></v-progress-circular>
</div> </div>
<div v-if="isError"> <div v-if="isError">
<v-empty-state <v-empty-state
icon="mdi-alert-circle-outline" icon="mdi-alert-circle-outline"
text="errorMessage" :text="errorMessage"
:title=t("error_title") :title="t('error_title')"
></v-empty-state> ></v-empty-state>
</div> </div>
<slot v-if="data" :data="data!"></slot>
</template> </template>
<style scoped> <style scoped>
.loading-div {
text-align: center;
margin: 10px;
}
</style> </style>

View file

@ -14,6 +14,7 @@ import UserClasses from "@/views/classes/UserClasses.vue";
import UserAssignments from "@/views/classes/UserAssignments.vue"; import UserAssignments from "@/views/classes/UserAssignments.vue";
import authState from "@/services/auth/auth-service.ts"; import authState from "@/services/auth/auth-service.ts";
import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue"; import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue";
import path from "path";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -104,7 +105,15 @@ const router = createRouter({
path: "/learningPath/:hruid/:language", path: "/learningPath/:hruid/:language",
name: "LearningPath", name: "LearningPath",
component: LearningPathPage, component: LearningPathPage,
meta: { requiresAuth: false } props: true,
meta: { requiresAuth: false },
children: [
{
path: ":learningObjectHruid",
component: LearningPathPage,
props: true,
}
]
}, },
{ {
path: "/:catchAll(.*)", path: "/:catchAll(.*)",

View file

@ -8,3 +8,15 @@ export class HttpErrorStatusException extends Error {
this.statusCode = response.status; this.statusCode = response.status;
} }
} }
export class NotFoundException extends Error {
constructor(message: string) {
super(message);
}
}
export class InvalidResponseException extends Error {
constructor(message: string) {
super(message);
}
}

View file

@ -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<PP extends Params, QP extends Params> extends RestEndpoint<PP, QP, undefined, Document> {
readonly method: "GET" | "POST" | "PUT" | "DELETE" = "GET";
public get(pathParams: PP, queryParams: QP): RemoteResource<Document> {
return super.request(pathParams, queryParams, undefined, "document");
}
}

View file

@ -1,23 +1,28 @@
import {RemoteResource} from "@/services/api-client/remote-resource.ts"; import {RemoteResource} from "@/services/api-client/remote-resource.ts";
import apiClient from "@/services/api-client/api-client.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<PP extends Params, QP extends Params, B, R> { export abstract class RestEndpoint<PP extends Params, QP extends Params, B, R> {
public abstract readonly method: "GET" | "POST" | "PUT" | "DELETE"; public abstract readonly method: "GET" | "POST" | "PUT" | "DELETE";
constructor(public readonly url: string) { constructor(public readonly url: string) {
} }
protected request(pathParams: PP, queryParams: QP, body: B): RemoteResource<R> { protected request(pathParams: PP, queryParams: QP, body: B, responseType?: ResponseType): RemoteResource<R> {
let urlFilledIn = this.url; let urlFilledIn = this.url.replace(/:(\w+)(\/|$)/g, (_, key, after) =>
urlFilledIn.replace(/:(\w+)([/$])/g, (_, key, after) => (pathParams[key] ? encodeURIComponent(pathParams[key]) : `:${key}`) + after
(key in pathParams ? 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 () => { return new RemoteResource(async () => {
const response = await apiClient.request<R>({ const response = await apiClient.request<R>({
url: urlFilledIn, url: urlFilledIn,
method: this.method, method: this.method,
params: queryParams, params: queryParams,
data: body, data: body,
responseType: responseType || 'json'
}); });
if (response.status / 100 !== 2) { if (response.status / 100 !== 2) {
throw new HttpErrorStatusException(response); throw new HttpErrorStatusException(response);
@ -27,4 +32,4 @@ export abstract class RestEndpoint<PP extends Params, QP extends Params, B, R> {
} }
} }
export type Params = {[key: string]: string | number | boolean}; export type Params = {[key: string]: string | number | boolean | undefined};

View file

@ -2,22 +2,40 @@ export class RemoteResource<T> {
static NOT_LOADED: NotLoadedState = {type: "notLoaded"}; static NOT_LOADED: NotLoadedState = {type: "notLoaded"};
static LOADING: LoadingState = {type: "loading"}; static LOADING: LoadingState = {type: "loading"};
private state: NotLoadedState | LoadingState | ErrorState | SuccessState<T> = RemoteResource.NOT_LOADED; private _state: RemoteResourceState<T> = RemoteResource.NOT_LOADED;
constructor(private readonly requestFn: () => Promise<T>) { constructor(private readonly requestFn: () => Promise<T>) {
} }
public static join<T>(resources: RemoteResource<T>[]): RemoteResource<T[]> {
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<T | undefined> { public async request(): Promise<T | undefined> {
this.state = RemoteResource.LOADING; this._state = RemoteResource.LOADING;
try { try {
let resource = await this.requestFn(); let resource = await this.requestFn();
this.state = { this._state = {
type: "success", type: "success",
data: resource data: resource
}; };
return resource; return resource;
} catch (e: any) { } catch (e: any) {
this.state = { this._state = {
type: "error", type: "error",
errorCode: e.statusCode, errorCode: e.statusCode,
message: e.message, message: e.message,
@ -31,19 +49,23 @@ export class RemoteResource<T> {
return this; return this;
} }
public get state(): RemoteResourceState<T> {
return this._state;
}
public get data(): T | undefined { public get data(): T | undefined {
if (this.state.type === "success") { if (this._state.type === "success") {
return this.state.data; return this._state.data;
} }
} }
public map<U>(mappingFn: (content: T) => U): RemoteResource<U> { public map<U>(mappingFn: (content: T) => U): RemoteResource<U> {
return new RemoteResource<U>(async () => { return new RemoteResource<U>(async () => {
await this.request(); await this.request();
if (this.state.type === "success") { if (this._state.type === "success") {
return mappingFn(this.state.data); return mappingFn(this._state.data);
} else if (this.state.type === "error") { } else if (this._state.type === "error") {
throw this.state.error; throw this._state.error;
} else { } else {
throw new Error("Fetched resource, but afterwards, it was neither in a success nor in an error state. " + throw new Error("Fetched resource, but afterwards, it was neither in a success nor in an error state. " +
"This should never happen."); "This should never happen.");
@ -52,19 +74,20 @@ export class RemoteResource<T> {
} }
} }
type NotLoadedState = { export type NotLoadedState = {
type: "notLoaded" type: "notLoaded"
}; };
type LoadingState = { export type LoadingState = {
type: "loading" type: "loading"
}; };
type ErrorState = { export type ErrorState = {
type: "error", type: "error",
errorCode?: number, errorCode?: number,
message?: string, message?: string,
error: any error: any
}; };
type SuccessState<T> = { export type SuccessState<T> = {
type: "success", type: "success",
data: T data: T
} };
export type RemoteResourceState<T> = NotLoadedState | LoadingState | ErrorState | SuccessState<T>;

View file

@ -1,13 +1,23 @@
import {GetEndpoint} from "@/services/api-client/endpoints/get-endpoint.ts"; import {GetEndpoint} from "@/services/api-client/endpoints/get-endpoint.ts";
import {LearningPath, type LearningPathDTO} from "@/services/learning-content/learning-path.ts"; import {LearningPath, type LearningPathDTO} from "@/services/learning-content/learning-path.ts";
import type {RemoteResource} from "@/services/api-client/remote-resource.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[]>( const learningPathEndpoint = new GetEndpoint<{}, {search?: string, hruid?: string, language?: Language}, LearningPathDTO[]>(
"/learningObjects/:query" "/learningPath"
); );
export function searchLearningPaths(query: string): RemoteResource<LearningPath[]> { export function searchLearningPaths(query: string): RemoteResource<LearningPath[]> {
return searchLearningPathsEndpoint return learningPathEndpoint
.get({}, {query: query}) .get({}, {search: query})
.map(dtos => dtos.map(dto => LearningPath.fromDTO(dto))); .map(dtos => dtos.map(dto => LearningPath.fromDTO(dto)));
} }
export function getLearningPath(hruid: string, language: Language): RemoteResource<LearningPath> {
console.log({hruid, language})
return learningPathEndpoint
.get({}, {hruid, language})
.map(it => {console.log(it); return it;})
.map(dtos => LearningPath.fromDTO(single(dtos)));
}

View file

@ -1,5 +1,5 @@
import type {Language} from "@/services/learning-content/language.ts"; 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 type {LearningObject} from "@/services/learning-content/learning-object.ts";
import {getLearningObjectMetadata} from "@/services/learning-content/learning-object-service.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<LearningObject[]> {
return RemoteResource.join(this.nodesAsList.map(node => node.learningObject));
}
static fromDTO(dto: LearningPathDTO): LearningPath { static fromDTO(dto: LearningPathDTO): LearningPath {
let startNodeDto = dto.nodes.filter(it => it.start_node); let startNodeDto = dto.nodes.filter(it => it.start_node);
if (startNodeDto.length !== 1) { if (startNodeDto.length !== 1) {

View file

@ -0,0 +1,11 @@
import {InvalidResponseException, NotFoundException} from "@/services/api-client/api-exceptions.ts";
export function single<T>(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.`);
}
}

View file

@ -1,18 +1,59 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref} from "vue"; import {Language} from "@/services/learning-content/language.ts";
const learningObjects = ref([ 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 {LearningObject} from "@/services/learning-content/learning-object.ts";
import {useRouter} from "vue-router";
import type {SuccessState} from "@/services/api-client/remote-resource.ts";
const router = useRouter();
const props = defineProps<{hruid: string, language: Language, learningObjectHruid?: string}>()
const learningPathResource = reactive(getLearningPath(props.hruid, props.language));
if (!props.learningObjectHruid) {
watch(() => learningPathResource.state, (newValue) => {
console.log("state changed!!");
if (newValue.type === "success") {
router.push(router.currentRoute.value.path
+ "/" + (newValue as SuccessState<LearningPath>).data.startNode.learningobjectHruid);
}
});
}
])
</script> </script>
<template> <template>
<using-remote-resource :resource="learningPathResource" v-slot="learningPath: {data: LearningPath}">
<v-navigation-drawer> <v-navigation-drawer>
<v-list-item title="My Application" subtitle="Vuetify"></v-list-item> <v-list-item
:title="learningPath.data.title"
:subtitle="learningPath.data.description"
></v-list-item>
<v-divider></v-divider> <v-divider></v-divider>
<v-list-item link title="List Item 1"></v-list-item>
<v-list-item link title="List Item 2"></v-list-item> <div v-if="props.learningObjectHruid">
<v-list-item link title="List Item 3"></v-list-item> <using-remote-resource
:resource="learningPath.data.learningObjectsAsList"
v-slot="learningObjects: {data: LearningObject[]}"
>
<v-list-item
link
:to="node.key"
:title="node.title"
:active="node.key === props.learningObjectHruid"
v-for="node in learningObjects.data"
>
<template v-slot:append>
{{ node.estimatedTime }}'
</template>
</v-list-item>
</using-remote-resource>
</div>
</v-navigation-drawer> </v-navigation-drawer>
</using-remote-resource>
</template> </template>
<style scoped> <style scoped>