feat(frontend): Functionaliteit om leerobjecten te tonen toegevoegd.
Hiervoor ook de state management geherstructureerd.
This commit is contained in:
		
							parent
							
								
									07340de2e3
								
							
						
					
					
						commit
						728b04c9d8
					
				
					 12 changed files with 141 additions and 150 deletions
				
			
		|  | @ -1,32 +1,22 @@ | |||
| <script setup lang="ts" generic="T"> | ||||
| import {type ErrorState, RemoteResource, type RemoteResourceState} from "@/services/api-client/remote-resource.ts"; | ||||
| import {computed, onMounted, reactive, ref, type UnwrapNestedRefs, watch} from "vue"; | ||||
|     import {RemoteResource} from "@/services/api-client/remote-resource.ts"; | ||||
|     import {computed} from "vue"; | ||||
|     import {useI18n} from "vue-i18n"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         resource: RemoteResource<T> | (UnwrapNestedRefs<RemoteResource<T>> & {}) | ||||
|         resource: RemoteResource<T> | ||||
|     }>() | ||||
| 
 | ||||
|     const resource = reactive(props.resource as RemoteResource<T>); | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const isLoading = computed(() => resource.state.type === 'loading'); | ||||
|     const isError = computed(() => resource.state.type === 'error'); | ||||
|     const isLoading = computed(() => props.resource.state.type === 'loading'); | ||||
|     const isError = computed(() => props.resource.state.type === 'error'); | ||||
|     const data = computed(() => props.resource.state.type === 'success' ? props.resource.state.data : null); | ||||
| 
 | ||||
|     // `data` will be correctly inferred as `T` | ||||
|     const data = computed(() => resource.data); | ||||
| 
 | ||||
|     const error = computed(() => resource.state.type === "error" ? resource.state as ErrorState : null); | ||||
|     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) | ||||
|         error.value?.error.message ? error.value.error.message : JSON.stringify(error.value?.error) | ||||
|     ); | ||||
| 
 | ||||
|     watch(data, (newValue, _) => { | ||||
|         if (!newValue && resource.state.type !== "loading") { | ||||
|             (resource as RemoteResource<T>).startRequestInBackground(); | ||||
|         } | ||||
|     }, {immediate: true}); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  | @ -46,6 +36,5 @@ import {computed, onMounted, reactive, ref, type UnwrapNestedRefs, watch} from " | |||
| <style scoped> | ||||
|   .loading-div { | ||||
|       text-align: center; | ||||
|       margin: 10px; | ||||
|   } | ||||
| </style> | ||||
|  |  | |||
|  | @ -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<PP extends Params, QP extends Params, R> extends RestEndpoint<PP, QP, undefined, R> { | ||||
|     readonly method = "GET"; | ||||
| 
 | ||||
|     public delete(pathParams: PP, queryParams: QP): RemoteResource<R> { | ||||
|     public delete(pathParams: PP, queryParams: QP): Promise<R> { | ||||
|         return super.request(pathParams, queryParams, undefined); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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<PP extends Params, QP extends Params, R> extends RestEndpoint<PP, QP, undefined, R> { | ||||
|     readonly method = "GET"; | ||||
| 
 | ||||
|     public get(pathParams: PP, queryParams: QP): RemoteResource<R> { | ||||
|     public get(pathParams: PP, queryParams: QP): Promise<R> { | ||||
|         return super.request(pathParams, queryParams, undefined); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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<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> { | ||||
|     public get(pathParams: PP, queryParams: QP): Promise<Document> { | ||||
|         return super.request(pathParams, queryParams, undefined, "document"); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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<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> { | ||||
|     public post(pathParams: PP, queryParams: QP, body: B): Promise<R> { | ||||
|         return super.request(pathParams, queryParams, body); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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<PP extends Params, QP extends Params, B, R> { | |||
|     constructor(public readonly url: string) { | ||||
|     } | ||||
| 
 | ||||
|     protected request(pathParams: PP, queryParams: QP, body: B, responseType?: ResponseType): RemoteResource<R> { | ||||
|     protected async request(pathParams: PP, queryParams: QP, body: B, responseType?: ResponseType): Promise<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); | ||||
|             } | ||||
|             return response.data; | ||||
|         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); | ||||
|         } | ||||
|         return response.data; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,78 +1,4 @@ | |||
| export class RemoteResource<T> { | ||||
|     static NOT_LOADED: NotLoadedState = {type: "notLoaded"}; | ||||
|     static LOADING: LoadingState = {type: "loading"}; | ||||
| 
 | ||||
|     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; | ||||
|         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 state(): RemoteResourceState<T> { | ||||
|         return this._state; | ||||
|     } | ||||
| 
 | ||||
|     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."); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| } | ||||
| 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<T> = { | ||||
|  | @ -91,3 +15,23 @@ export type SuccessState<T> = { | |||
|     data: T | ||||
| }; | ||||
| export type RemoteResourceState<T> = NotLoadedState | LoadingState | ErrorState | SuccessState<T>; | ||||
| 
 | ||||
| export type RemoteResource<T> = ShallowReactive<{ | ||||
|     state: RemoteResourceState<T> | ||||
| }>; | ||||
| 
 | ||||
| export function remoteResource<T>(): RemoteResource<T> { | ||||
|     return shallowReactive({ | ||||
|         state: { | ||||
|             type: "notLoaded" | ||||
|         } | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function loadResource<T>(resource: RemoteResource<T>, promise: Promise<T>): void { | ||||
|     resource.state = { type: "loading" } | ||||
|     promise.then( | ||||
|         data => resource.state = { type: "success", data }, | ||||
|         error => resource.state = { type: "error", error } | ||||
|     ); | ||||
| } | ||||
|  |  | |||
|  | @ -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<LearningObject> { | ||||
|     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<LearningObject> { | ||||
|     return getLearningObjectMetadataEndpoint.get({hruid}, {language, version}); | ||||
| } | ||||
| 
 | ||||
| export function getLearningObjectHTML( | ||||
|     hruid: string, | ||||
|     language: Language, | ||||
|     version: number | ||||
| ): Promise<Document> { | ||||
|     return getLearningObjectHtmlEndpoint.get({hruid}, {language, version}); | ||||
| } | ||||
|  |  | |||
|  | @ -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<LearningPath[]> { | ||||
|     return learningPathEndpoint | ||||
|         .get({}, {search: query}) | ||||
|         .map(dtos => dtos.map(dto => LearningPath.fromDTO(dto))); | ||||
| export async function searchLearningPaths(query: string): Promise<LearningPath[]> { | ||||
|     let dtos = await learningPathEndpoint.get({}, {search: query}) | ||||
|     return 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))); | ||||
| export async function getLearningPath(hruid: string, language: Language): Promise<LearningPath> { | ||||
|     let dtos = await learningPathEndpoint.get({}, {hruid, language}); | ||||
|     return LearningPath.fromDTO(single(dtos)); | ||||
| } | ||||
|  |  | |||
|  | @ -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<LearningObject> | ||||
| 
 | ||||
|     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<LearningObject> { | ||||
|         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<LearningObject[]> { | ||||
|         return RemoteResource.join(this.nodesAsList.map(node => node.learningObject)); | ||||
|     public get learningObjectsAsList(): Promise<LearningObject[]> { | ||||
|         return Promise.all(this.nodesAsList.map(node => node.learningObject)); | ||||
|     } | ||||
| 
 | ||||
|     static fromDTO(dto: LearningPathDTO): LearningPath { | ||||
|  |  | |||
							
								
								
									
										31
									
								
								frontend/src/views/learning-paths/LearningObjectView.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								frontend/src/views/learning-paths/LearningObjectView.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,31 @@ | |||
| <script setup lang="ts"> | ||||
| import {Language} from "@/services/learning-content/language.ts"; | ||||
| import {watch} from "vue"; | ||||
| import {getLearningObjectHTML} from "@/services/learning-content/learning-object-service.ts"; | ||||
| import UsingRemoteResource from "@/components/UsingRemoteResource.vue"; | ||||
| import {loadResource, remoteResource} from "@/services/api-client/remote-resource.ts"; | ||||
| 
 | ||||
| const props = defineProps<{hruid: string, language: Language, version: number}>() | ||||
| 
 | ||||
| const learningPathHtmlResource = remoteResource<Document>(); | ||||
| watch(props, () => { | ||||
|     loadResource(learningPathHtmlResource, getLearningObjectHTML(props.hruid, props.language, props.version)) | ||||
| }, {immediate: true}); | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <using-remote-resource :resource="learningPathHtmlResource" v-slot="learningPathHtml : {data: Document}"> | ||||
|         <div class="learning-object-container" v-html="learningPathHtml.data.body.innerHTML"></div> | ||||
|     </using-remote-resource> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .learning-object-container { | ||||
|         margin: 20px; | ||||
|     } | ||||
|     :deep(hr) { | ||||
|         margin-top: 10px; | ||||
|         margin-bottom: 10px; | ||||
|     } | ||||
| </style> | ||||
|  | @ -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<LearningPath>(); | ||||
|     watchEffect(() => { | ||||
|         loadResource(learningPathResource, getLearningPath(props.hruid, props.language)); | ||||
|     }); | ||||
| 
 | ||||
|     const learningObjectListResource = remoteResource<LearningObject[]>(); | ||||
|     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<LearningPath>).data.startNode.learningobjectHruid); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <using-remote-resource :resource="learningPathResource" v-slot="learningPath: {data: LearningPath}"> | ||||
|     <using-remote-resource | ||||
|         :resource="learningPathResource" | ||||
|         v-slot="learningPath: {data: LearningPath}" | ||||
|     > | ||||
|         <v-navigation-drawer> | ||||
|             <v-list-item | ||||
|                 :title="learningPath.data.title" | ||||
|  | @ -36,7 +57,7 @@ | |||
| 
 | ||||
|             <div v-if="props.learningObjectHruid"> | ||||
|                 <using-remote-resource | ||||
|                     :resource="learningPath.data.learningObjectsAsList" | ||||
|                     :resource="learningObjectListResource" | ||||
|                     v-slot="learningObjects: {data: LearningObject[]}" | ||||
|                 > | ||||
|                     <v-list-item | ||||
|  | @ -53,6 +74,12 @@ | |||
|                 </using-remote-resource> | ||||
|             </div> | ||||
|         </v-navigation-drawer> | ||||
|         <learning-object-view | ||||
|             :hruid="currentNode.learningobjectHruid" | ||||
|             :language="currentNode.language" | ||||
|             :version="currentNode.version" | ||||
|             v-if="currentNode" | ||||
|         ></learning-object-view> | ||||
|     </using-remote-resource> | ||||
| </template> | ||||
| 
 | ||||
|  |  | |||
		Reference in a new issue
	
	 Gerald Schmittinger
						Gerald Schmittinger