feat(frontend): LearningObjectService en LearningPathService geïmplementeerd.

This commit is contained in:
Gerald Schmittinger 2025-03-23 08:56:34 +01:00
parent 8b0fc4263f
commit 3c3fddb7d0
24 changed files with 375 additions and 84 deletions

View file

@ -0,0 +1,39 @@
<script setup lang="ts" generic="T">
import {RemoteResource} from "@/services/api-client/remote-resource.ts";
import {computed} from "vue";
import {useI18n} from "vue-i18n";
const props = defineProps<{
resource: RemoteResource<T>
}>()
const { t } = useI18n();
const isLoading = computed(() => props.resource.state.type === 'loading');
const isError = computed(() => props.resource.state.type === 'error');
// `data` will be correctly inferred as `T`
const data = computed(() => props.resource.data);
const error = computed(() => props.resource.state.type === "error" ? props.resource.state : null);
const errorMessage = computed(() =>
error.value?.message ? error.value.message : JSON.stringify(error.value?.error)
);
</script>
<template>
<div v-if="isLoading">
<v-progress-circular indeterminate></v-progress-circular>
</div>
<div v-if="isError">
<v-empty-state
icon="mdi-alert-circle-outline"
text="errorMessage"
:title=t("error_title")
></v-empty-state>
</div>
</template>
<style scoped>
</style>

View file

@ -1,3 +1,4 @@
{
"welcome": "Willkommen"
"welcome": "Willkommen",
"error_title": "Fehler"
}

View file

@ -5,5 +5,6 @@
"assignments": "assignments",
"classes": "classes",
"discussions": "discussions",
"logout": "log out"
"logout": "log out",
"error_title": "Error"
}

View file

@ -1,3 +1,4 @@
{
"welcome": "Bienvenue"
"welcome": "Bienvenue",
"error_title": "Erreur"
}

View file

@ -5,5 +5,6 @@
"assignments": "opdrachten",
"classes": "klassen",
"discussions": "discussies",
"logout": "log uit"
"logout": "log uit",
"error_title": "Fout"
}

View file

@ -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);

View file

@ -0,0 +1,10 @@
import type {AxiosResponse} from "axios";
export class HttpErrorStatusException extends Error {
public readonly statusCode: number;
constructor(response: AxiosResponse<any, any>) {
super(`${response.statusText} (${response.status})`);
this.statusCode = response.status;
}
}

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 DeleteEndpoint<PP extends Params, QP extends Params, R> extends RestEndpoint<PP, QP, undefined, R> {
readonly method = "GET";
public delete(pathParams: PP, queryParams: QP): RemoteResource<R> {
return super.request(pathParams, queryParams, undefined);
}
}

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

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 PostEndpoint<PP extends Params, QP extends Params, B, R> extends RestEndpoint<PP, QP, B, R> {
readonly method = "POST";
public post(pathParams: PP, queryParams: QP, body: B): RemoteResource<R> {
return super.request(pathParams, queryParams, body);
}
}

View file

@ -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<PP extends Params, QP extends Params, B, R> {
public abstract readonly method: "GET" | "POST" | "PUT" | "DELETE";
constructor(public readonly url: string) {
}
protected request(pathParams: PP, queryParams: QP, body: B): RemoteResource<R> {
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<R>({
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};

View file

@ -0,0 +1,70 @@
export class RemoteResource<T> {
static NOT_LOADED: NotLoadedState = {type: "notLoaded"};
static LOADING: LoadingState = {type: "loading"};
private state: NotLoadedState | LoadingState | ErrorState | SuccessState<T> = RemoteResource.NOT_LOADED;
constructor(private readonly requestFn: () => Promise<T>) {
}
public async request(): Promise<T | undefined> {
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<T> {
this.request().then();
return this;
}
public get data(): T | undefined {
if (this.state.type === "success") {
return this.state.data;
}
}
public map<U>(mappingFn: (content: T) => U): RemoteResource<U> {
return new RemoteResource<U>(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<T> = {
type: "success",
data: T
}

View file

@ -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";
/**

View file

@ -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";

View file

@ -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<LearningObject> {
return getLearningObjectMetadataEndpoint
.get({hruid}, {language, version});
}

View file

@ -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<string, any>;
}
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;
}

View file

@ -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<LearningPath[]> {
return searchLearningPathsEndpoint
.get({}, {query: query})
.map(dtos => dtos.map(dto => LearningPath.fromDTO(dto)));
}

View file

@ -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<LearningObject>
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)
)
}
}

View file

@ -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<string, any>;
}
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;
}

View file

@ -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;
};
}

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import auth from "@/services/auth/auth-service.ts";
import apiClient from "@/services/api-client.ts";
import apiClient from "@/services/api-client/api-client.ts";
import { ref } from "vue";
const testResponse = ref(null);