feat(frontend): basisimplementatie leerobject upload-UI
This commit is contained in:
		
							parent
							
								
									6600441b08
								
							
						
					
					
						commit
						be1091544c
					
				
					 11 changed files with 311 additions and 40 deletions
				
			
		|  | @ -37,6 +37,28 @@ export abstract class BaseController { | |||
|         return response.data; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Sends a POST-request with a form-data body with the given file. | ||||
|      * | ||||
|      * @param path Relative path in the api to send the request to. | ||||
|      * @param formFieldName The name of the form field in which the file should be. | ||||
|      * @param file The file to upload. | ||||
|      * @param queryParams The query parameters. | ||||
|      * @returns The response the POST request generated. | ||||
|      */ | ||||
|     protected async postFile<T>(path: string, formFieldName: string, file: File, queryParams?: QueryParams): Promise<T> { | ||||
|         const formData = new FormData(); | ||||
|         formData.append(formFieldName, file); | ||||
|         const response = await apiClient.post<T>(this.absolutePathFor(path), formData, { | ||||
|             params: queryParams, | ||||
|             headers: { | ||||
|                 'Content-Type': 'multipart/form-data' | ||||
|             } | ||||
|         }); | ||||
|         BaseController.assertSuccessResponse(response) | ||||
|         return response.data; | ||||
|     } | ||||
| 
 | ||||
|     protected async delete<T>(path: string, queryParams?: QueryParams): Promise<T> { | ||||
|         const response = await apiClient.delete<T>(this.absolutePathFor(path), { params: queryParams }); | ||||
|         BaseController.assertSuccessResponse(response); | ||||
|  |  | |||
|  | @ -14,4 +14,12 @@ export class LearningObjectController extends BaseController { | |||
|     async getHTML(hruid: string, language: Language, version: number): Promise<Document> { | ||||
|         return this.get<Document>(`/${hruid}/html`, { language, version }, "document"); | ||||
|     } | ||||
| 
 | ||||
|     async getAllAdministratedBy(admin: string): Promise<LearningObject[]> { | ||||
|         return this.get<LearningObject[]>("/", { admin }); | ||||
|     } | ||||
| 
 | ||||
|     async upload(learningObjectZip: File): Promise<LearningObject> { | ||||
|         return this.postFile<LearningObject>("/", "learningObject", learningObjectZip); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,9 +1,10 @@ | |||
| import { type MaybeRefOrGetter, toValue } from "vue"; | ||||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; | ||||
| import { useMutation, useQuery, useQueryClient, type UseMutationReturnType, type UseQueryReturnType } from "@tanstack/vue-query"; | ||||
| import { getLearningObjectController } from "@/controllers/controllers.ts"; | ||||
| import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; | ||||
| import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| import type { AxiosError } from "axios"; | ||||
| 
 | ||||
| export const LEARNING_OBJECT_KEY = "learningObject"; | ||||
| const learningObjectController = getLearningObjectController(); | ||||
|  | @ -24,15 +25,15 @@ export function useLearningObjectMetadataQuery( | |||
| } | ||||
| 
 | ||||
| export function useLearningObjectHTMLQuery( | ||||
|     hruid: MaybeRefOrGetter<string>, | ||||
|     language: MaybeRefOrGetter<Language>, | ||||
|     version: MaybeRefOrGetter<number>, | ||||
|     hruid: MaybeRefOrGetter<string | undefined>, | ||||
|     language: MaybeRefOrGetter<Language | undefined>, | ||||
|     version: MaybeRefOrGetter<number | undefined>, | ||||
| ): UseQueryReturnType<Document, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_OBJECT_KEY, "html", hruid, language, version], | ||||
|         queryFn: async () => { | ||||
|             const [hruidVal, languageVal, versionVal] = [toValue(hruid), toValue(language), toValue(version)]; | ||||
|             return learningObjectController.getHTML(hruidVal, languageVal, versionVal); | ||||
|             return learningObjectController.getHTML(hruidVal!, languageVal!, versionVal!); | ||||
|         }, | ||||
|         enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)), | ||||
|     }); | ||||
|  | @ -55,3 +56,25 @@ export function useLearningObjectListForPathQuery( | |||
|         enabled: () => Boolean(toValue(learningPath)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useLearningObjectListForAdminQuery( | ||||
|     admin: MaybeRefOrGetter<string | undefined> | ||||
| ): UseQueryReturnType<LearningObject[], Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_OBJECT_KEY, "forAdmin", admin], | ||||
|         queryFn: async () => { | ||||
|             const adminVal = toValue(admin); | ||||
|             return await learningObjectController.getAllAdministratedBy(adminVal!); | ||||
|         }, | ||||
|         enabled: () => toValue(admin) !== undefined | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useUploadLearningObjectMutation(): UseMutationReturnType<LearningObject, AxiosError, {learningObjectZip: File}, unknown> { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ learningObjectZip }) => await learningObjectController.upload(learningObjectZip), | ||||
|         onSuccess: async () => { await queryClient.invalidateQueries({queryKey: [LEARNING_OBJECT_KEY, "forAdmin"]}); } | ||||
|     }); | ||||
| } | ||||
|  |  | |||
|  | @ -14,6 +14,7 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue"; | |||
| import SingleTheme from "@/views/SingleTheme.vue"; | ||||
| import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; | ||||
| import authService from "@/services/auth/auth-service"; | ||||
| import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue"; | ||||
| 
 | ||||
| const router = createRouter({ | ||||
|     history: createWebHistory(import.meta.env.BASE_URL), | ||||
|  | @ -114,6 +115,12 @@ const router = createRouter({ | |||
|                     component: LearningPathSearchPage, | ||||
|                     meta: { requiresAuth: true }, | ||||
|                 }, | ||||
|                 { | ||||
|                     path: "my", | ||||
|                     name: "OwnLearningContentPage", | ||||
|                     component: OwnLearningContentPage, | ||||
|                     meta: { requiresAuth: true } | ||||
|                 }, | ||||
|                 { | ||||
|                     path: ":hruid/:language/:learningObjectHruid", | ||||
|                     name: "LearningPath", | ||||
|  |  | |||
|  | @ -0,0 +1,76 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useUploadLearningObjectMutation } from '@/queries/learning-objects'; | ||||
|     import { ref, watch, type Ref } from 'vue'; | ||||
|     import { useI18n } from 'vue-i18n'; | ||||
|     import { VFileUpload } from 'vuetify/labs/VFileUpload'; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const dialogOpen = ref(false); | ||||
| 
 | ||||
|     interface ContainsErrorString { | ||||
|         error: string; | ||||
|     } | ||||
| 
 | ||||
|     const fileToUpload: Ref<File | undefined> = ref(undefined); | ||||
| 
 | ||||
|     const { isPending, error, isError, isSuccess, mutate } = useUploadLearningObjectMutation(); | ||||
| 
 | ||||
|     watch(isSuccess, (newIsSuccess) => { | ||||
|         if (newIsSuccess) { | ||||
|             dialogOpen.value = false; | ||||
|             fileToUpload.value = undefined; | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     function uploadFile() { | ||||
|         if (fileToUpload.value) { | ||||
|             mutate({learningObjectZip: fileToUpload.value}); | ||||
|         } | ||||
|     } | ||||
| </script> | ||||
| <template> | ||||
|     <v-dialog max-width="500" v-model="dialogOpen"> | ||||
|         <template v-slot:activator="{ props: activatorProps }"> | ||||
|             <v-fab icon="mdi mdi-plus" v-bind="activatorProps"></v-fab> | ||||
|         </template> | ||||
| 
 | ||||
|         <template v-slot:default="{ isActive }"> | ||||
|             <v-card :title="t('learning_object_upload_title')"> | ||||
|                 <v-card-text> | ||||
|                     <v-file-upload | ||||
|                         :browse-text="t('upload_browse')" | ||||
|                         :divider-text="t('upload_divider')" | ||||
|                         icon="mdi-upload" | ||||
|                         :title="t('upload_drag_and_drop')" | ||||
|                         v-model="fileToUpload" | ||||
|                         :disabled="isPending" | ||||
|                     ></v-file-upload> | ||||
|                     <v-alert | ||||
|                         v-if="error" | ||||
|                         icon="mdi mdi-alert-circle" | ||||
|                         type="error" | ||||
|                         :title="t('upload_failed')" | ||||
|                         :text="t((error.response?.data as ContainsErrorString).error ?? error.message)" | ||||
|                     ></v-alert> | ||||
|                 </v-card-text> | ||||
| 
 | ||||
|                 <v-card-actions> | ||||
|                     <v-spacer></v-spacer> | ||||
|                     <v-btn | ||||
|                         :text="t('cancel')" | ||||
|                         @click="isActive.value = false" | ||||
|                     ></v-btn> | ||||
|                     <v-btn | ||||
|                         :text="t('upload')" | ||||
|                         @click="uploadFile()" | ||||
|                         :loading="isPending" | ||||
|                         :disabled="!fileToUpload" | ||||
|                     ></v-btn> | ||||
|                 </v-card-actions> | ||||
|             </v-card> | ||||
|         </template> | ||||
|     </v-dialog> | ||||
| </template> | ||||
| <style scoped> | ||||
| </style> | ||||
|  | @ -1,11 +1,52 @@ | |||
| <script setup lang="ts"> | ||||
|     import {useLearningObjectListForAdminQuery} from "@/queries/learning-objects.ts"; | ||||
|     import OwnLearningObjectsView from "@/views/own-learning-content/OwnLearningObjectsView.vue" | ||||
|     import OwnLearningPathsView from "@/views/own-learning-content/OwnLearningPathsView.vue" | ||||
|     import authService from "@/services/auth/auth-service.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import type { LearningObject } from "@/data-objects/learning-objects/learning-object"; | ||||
|     import { ref, type Ref } from "vue"; | ||||
| import { useI18n } from "vue-i18n"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const learningObjectsQuery = | ||||
|         useLearningObjectListForAdminQuery(authService.authState.user?.profile.preferred_username); | ||||
| 
 | ||||
|     type Tab = "learningObjects" | "learningPaths"; | ||||
|     const tab: Ref<Tab> = ref("learningObjects"); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <div class="tab-pane-container"> | ||||
|         <v-tabs v-model="tab"> | ||||
|         <v-tab value="learningObjects">{{ t('learningObjects') }}</v-tab> | ||||
|         <v-tab value="learningPaths">{{ t('learningPaths') }}</v-tab> | ||||
|         </v-tabs> | ||||
| 
 | ||||
|         <v-tabs-window v-model="tab" class="main-content"> | ||||
|             <v-tabs-window-item value="learningObjects" class="main-content"> | ||||
|                 <using-query-result | ||||
|                     :query-result="learningObjectsQuery" | ||||
|                     v-slot="response: { data: LearningObject[] }" | ||||
|                 > | ||||
|                     <own-learning-objects-view :learningObjects="response.data"></own-learning-objects-view> | ||||
|                 </using-query-result> | ||||
|             </v-tabs-window-item> | ||||
|             <v-tabs-window-item value="learningPaths"> | ||||
|                 <own-learning-paths-view/> | ||||
|             </v-tabs-window-item> | ||||
|         </v-tabs-window> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
| 
 | ||||
|     .tab-pane-container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         height: 100%; | ||||
|     } | ||||
|     .main-content { | ||||
|         flex: 1; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -1,11 +1,80 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { LearningObject } from '@/data-objects/learning-objects/learning-object'; | ||||
|     import LearningObjectUploadButton from '@/views/own-learning-content/LearningObjectUploadButton.vue' | ||||
|     import LearningObjectContentView from '../learning-paths/learning-object/content/LearningObjectContentView.vue'; | ||||
|     import { computed, ref, type Ref } from 'vue'; | ||||
|     import { useI18n } from 'vue-i18n'; | ||||
| import { useLearningObjectHTMLQuery } from '@/queries/learning-objects'; | ||||
| import UsingQueryResult from '@/components/UsingQueryResult.vue'; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
|     const props = defineProps<{ | ||||
|         learningObjects: LearningObject[] | ||||
|     }>(); | ||||
| 
 | ||||
|     const tableHeaders = [ | ||||
|         { title: t("hruid"), width: "250px", key: "key" }, | ||||
|         { title: t("language"), width: "50px", key: "language" }, | ||||
|         { title: t("version"), width: "50px", key: "version" }, | ||||
|         { title: t("title"), key: "title" } | ||||
|     ]; | ||||
| 
 | ||||
|     const selectedLearningObjects: Ref<LearningObject[]> = ref([]) | ||||
| 
 | ||||
|     const selectedLearningObject = computed(() => | ||||
|         selectedLearningObjects.value ? selectedLearningObjects.value[0] : undefined | ||||
|     ) | ||||
| 
 | ||||
|     const learningObjectQueryResult = useLearningObjectHTMLQuery( | ||||
|         () => selectedLearningObject.value?.key, | ||||
|         () => selectedLearningObject.value?.language, | ||||
|         () => selectedLearningObject.value?.version | ||||
|     ); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
| 
 | ||||
|     <div class="root"> | ||||
|         <v-data-table | ||||
|             class="table" | ||||
|             v-model="selectedLearningObjects" | ||||
|             :items="props.learningObjects" | ||||
|             :headers="tableHeaders" | ||||
|             select-strategy="single" | ||||
|             show-select | ||||
|             return-object | ||||
|         /> | ||||
|         <v-card | ||||
|             class="preview" | ||||
|             v-if="selectedLearningObjects.length > 0" | ||||
|             :title="t('preview_for') + selectedLearningObjects[0].title" | ||||
|         > | ||||
|             <template v-slot:text> | ||||
|                 <using-query-result :query-result="learningObjectQueryResult" v-slot="response: { data: Document }"> | ||||
|                     <learning-object-content-view :learning-object-content="response.data"></learning-object-content-view> | ||||
|                 </using-query-result> | ||||
|             </template> | ||||
|         </v-card> | ||||
|     </div> | ||||
|     <div class="fab"> | ||||
|         <learning-object-upload-button/> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
| 
 | ||||
|     .fab { | ||||
|         position: absolute; | ||||
|         right: 20px; | ||||
|         bottom: 20px; | ||||
|     } | ||||
|     .root { | ||||
|         display: flex; | ||||
|         gap: 20px; | ||||
|         padding: 20px; | ||||
|     } | ||||
|     .preview { | ||||
|         flex: 1; | ||||
|     } | ||||
|     .table { | ||||
|         flex: 1; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -0,0 +1,9 @@ | |||
| <script lang="ts"> | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <p>Own learning paths</p> | ||||
| </template> | ||||
| 
 | ||||
| <style> | ||||
| </style> | ||||
		Reference in a new issue
	
	 Gerald Schmittinger
						Gerald Schmittinger