feat(frontend): Navigatie voor leerpad geïmplementeerd.
This commit is contained in:
		
							parent
							
								
									3c3fddb7d0
								
							
						
					
					
						commit
						07340de2e3
					
				
					 13 changed files with 216 additions and 54 deletions
				
			
		|  | @ -1,10 +1,16 @@ | |||
| <script setup lang="ts"> | ||||
|     import auth from "@/services/auth/auth-service.ts"; | ||||
|     import MenuBar from "@/components/MenuBar.vue"; | ||||
|     auth.loadUser(); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <router-view /> | ||||
|     <v-app> | ||||
|         <menu-bar></menu-bar> | ||||
|         <v-main> | ||||
|             <router-view /> | ||||
|         </v-main> | ||||
|     </v-app> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
|  |  | |||
|  | @ -37,7 +37,7 @@ | |||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <main> | ||||
|     <v-app-bar> | ||||
|         <nav class="menu"> | ||||
|             <div class="left"> | ||||
|                 <ul> | ||||
|  | @ -133,7 +133,7 @@ | |||
|                 </li> | ||||
|             </div> | ||||
|         </nav> | ||||
|     </main> | ||||
|     </v-app-bar> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|  | @ -188,4 +188,8 @@ | |||
|     nav a.router-link-active { | ||||
|         font-weight: bold; | ||||
|     } | ||||
| 
 | ||||
|     nav { | ||||
|         width: 100%; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -1,39 +1,51 @@ | |||
| <script setup lang="ts" generic="T"> | ||||
|     import {RemoteResource} from "@/services/api-client/remote-resource.ts"; | ||||
|     import {computed} from "vue"; | ||||
| import {type ErrorState, RemoteResource, type RemoteResourceState} from "@/services/api-client/remote-resource.ts"; | ||||
| import {computed, onMounted, reactive, ref, type UnwrapNestedRefs, watch} from "vue"; | ||||
|     import {useI18n} from "vue-i18n"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         resource: RemoteResource<T> | ||||
|         resource: RemoteResource<T> | (UnwrapNestedRefs<RemoteResource<T>> & {}) | ||||
|     }>() | ||||
| 
 | ||||
|     const resource = reactive(props.resource as RemoteResource<T>); | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const isLoading = computed(() => props.resource.state.type === 'loading'); | ||||
|     const isError = computed(() => props.resource.state.type === 'error'); | ||||
|     const isLoading = computed(() => resource.state.type === 'loading'); | ||||
|     const isError = computed(() => resource.state.type === 'error'); | ||||
| 
 | ||||
|     // `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(() => | ||||
|         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> | ||||
| 
 | ||||
| <template> | ||||
|     <div v-if="isLoading"> | ||||
|     <div class="loading-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") | ||||
|             :text="errorMessage" | ||||
|             :title="t('error_title')" | ||||
|         ></v-empty-state> | ||||
|     </div> | ||||
|     <slot v-if="data" :data="data!"></slot> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
| 
 | ||||
|   .loading-div { | ||||
|       text-align: center; | ||||
|       margin: 10px; | ||||
|   } | ||||
| </style> | ||||
|  |  | |||
|  | @ -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(.*)", | ||||
|  |  | |||
|  | @ -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); | ||||
|     } | ||||
| } | ||||
|  | @ -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"); | ||||
|     } | ||||
| } | ||||
|  | @ -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<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 | ||||
|     protected request(pathParams: PP, queryParams: QP, body: B, responseType?: ResponseType): RemoteResource<R> { | ||||
|         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<R>({ | ||||
|                 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<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}; | ||||
|  |  | |||
|  | @ -2,22 +2,40 @@ 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; | ||||
|     private _state: RemoteResourceState<T> = RemoteResource.NOT_LOADED; | ||||
| 
 | ||||
|     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> { | ||||
|         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<T> { | |||
|         return this; | ||||
|     } | ||||
| 
 | ||||
|     public get state(): RemoteResourceState<T> { | ||||
|         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<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; | ||||
|             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<T> { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| 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<T> = { | ||||
| export type SuccessState<T> = { | ||||
|     type: "success", | ||||
|     data: T | ||||
| } | ||||
| }; | ||||
| export type RemoteResourceState<T> = NotLoadedState | LoadingState | ErrorState | SuccessState<T>; | ||||
|  |  | |||
|  | @ -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<LearningPath[]> { | ||||
|     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<LearningPath> { | ||||
|     console.log({hruid, language}) | ||||
|     return learningPathEndpoint | ||||
|         .get({}, {hruid, language}) | ||||
|         .map(it => {console.log(it); return it;}) | ||||
|         .map(dtos => LearningPath.fromDTO(single(dtos))); | ||||
| } | ||||
|  |  | |||
|  | @ -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<LearningObject[]> { | ||||
|         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) { | ||||
|  |  | |||
							
								
								
									
										11
									
								
								frontend/src/utils/response-assertions.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								frontend/src/utils/response-assertions.ts
									
										
									
									
									
										Normal 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.`); | ||||
|     } | ||||
| } | ||||
|  | @ -1,18 +1,59 @@ | |||
| <script setup lang="ts"> | ||||
|     import {ref} from "vue"; | ||||
|     const learningObjects = ref([ | ||||
|     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 {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> | ||||
| 
 | ||||
| <template> | ||||
|     <v-navigation-drawer> | ||||
|         <v-list-item title="My Application" subtitle="Vuetify"></v-list-item> | ||||
|         <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> | ||||
|         <v-list-item link title="List Item 3"></v-list-item> | ||||
|     </v-navigation-drawer> | ||||
|     <using-remote-resource :resource="learningPathResource" v-slot="learningPath: {data: LearningPath}"> | ||||
|         <v-navigation-drawer> | ||||
|             <v-list-item | ||||
|                 :title="learningPath.data.title" | ||||
|                 :subtitle="learningPath.data.description" | ||||
|             ></v-list-item> | ||||
|             <v-divider></v-divider> | ||||
| 
 | ||||
|             <div v-if="props.learningObjectHruid"> | ||||
|                 <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> | ||||
|     </using-remote-resource> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|  |  | |||
		Reference in a new issue
	
	 Gerald Schmittinger
						Gerald Schmittinger