feat(frontend): LearningObjectService en LearningPathService geïmplementeerd.
This commit is contained in:
parent
8b0fc4263f
commit
3c3fddb7d0
24 changed files with 375 additions and 84 deletions
39
frontend/src/components/UsingRemoteResource.vue
Normal file
39
frontend/src/components/UsingRemoteResource.vue
Normal 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>
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"welcome": "Willkommen"
|
||||
"welcome": "Willkommen",
|
||||
"error_title": "Fehler"
|
||||
}
|
||||
|
|
|
@ -5,5 +5,6 @@
|
|||
"assignments": "assignments",
|
||||
"classes": "classes",
|
||||
"discussions": "discussions",
|
||||
"logout": "log out"
|
||||
"logout": "log out",
|
||||
"error_title": "Error"
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"welcome": "Bienvenue"
|
||||
"welcome": "Bienvenue",
|
||||
"error_title": "Erreur"
|
||||
}
|
||||
|
|
|
@ -5,5 +5,6 @@
|
|||
"assignments": "opdrachten",
|
||||
"classes": "klassen",
|
||||
"discussions": "discussies",
|
||||
"logout": "log uit"
|
||||
"logout": "log uit",
|
||||
"error_title": "Fout"
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
10
frontend/src/services/api-client/api-exceptions.d.ts
vendored
Normal file
10
frontend/src/services/api-client/api-exceptions.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
10
frontend/src/services/api-client/endpoints/get-endpoint.ts
Normal file
10
frontend/src/services/api-client/endpoints/get-endpoint.ts
Normal 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);
|
||||
}
|
||||
}
|
10
frontend/src/services/api-client/endpoints/post-endpoint.ts
Normal file
10
frontend/src/services/api-client/endpoints/post-endpoint.ts
Normal 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);
|
||||
}
|
||||
}
|
30
frontend/src/services/api-client/endpoints/rest-endpoint.ts
Normal file
30
frontend/src/services/api-client/endpoints/rest-endpoint.ts
Normal 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};
|
70
frontend/src/services/api-client/remote-resource.ts
Normal file
70
frontend/src/services/api-client/remote-resource.ts
Normal 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
|
||||
}
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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});
|
||||
}
|
33
frontend/src/services/learning-content/learning-object.ts
Normal file
33
frontend/src/services/learning-content/learning-object.ts
Normal 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;
|
||||
}
|
|
@ -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)));
|
||||
}
|
118
frontend/src/services/learning-content/learning-path.ts
Normal file
118
frontend/src/services/learning-content/learning-path.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue