Merging origin/dev into feat/assignment-page correctie
This commit is contained in:
		
						commit
						baea0051e6
					
				
					 249 changed files with 6754 additions and 3612 deletions
				
			
		|  | @ -21,7 +21,7 @@ const vueConfig = defineConfigWithVueTs( | |||
| 
 | ||||
|     { | ||||
|         name: "app/files-to-ignore", | ||||
|         ignores: ["**/dist/**", "**/dist-ssr/**", "**/coverage/**"], | ||||
|         ignores: ["**/dist/**", "**/dist-ssr/**", "**/coverage/**", "prettier.config.js"], | ||||
|     }, | ||||
| 
 | ||||
|     pluginVue.configs["flat/essential"], | ||||
|  |  | |||
|  | @ -5,13 +5,15 @@ | |||
|     import { computed } from "vue"; | ||||
| 
 | ||||
|     const route = useRoute(); | ||||
|     auth.loadUser(); | ||||
| 
 | ||||
|     interface RouteMeta { | ||||
|         requiresAuth?: boolean; | ||||
|     } | ||||
| 
 | ||||
|     const showMenuBar = computed(() => (route.meta as RouteMeta).requiresAuth && auth.authState.user); | ||||
| 
 | ||||
|     auth.loadUser().catch((_error) => { | ||||
|         // TODO Could not load user! | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
|  | @ -1,9 +1,10 @@ | |||
| <script setup lang="ts"> | ||||
|     import ThemeCard from "@/components/ThemeCard.vue"; | ||||
|     import { ref, watchEffect, computed } from "vue"; | ||||
|     import { ref, watchEffect, computed, type Ref } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; | ||||
|     import { useThemeQuery } from "@/queries/themes.ts"; | ||||
|     import type { Theme } from "@/data-objects/theme.ts"; | ||||
| 
 | ||||
|     const props = defineProps({ | ||||
|         selectedTheme: { type: String, required: true }, | ||||
|  | @ -15,11 +16,11 @@ | |||
| 
 | ||||
|     const { data: allThemes, isLoading, error } = useThemeQuery(language); | ||||
| 
 | ||||
|     const allCards = ref([]); | ||||
|     const cards = ref([]); | ||||
|     const allCards: Ref<Theme[]> = ref([]); | ||||
|     const cards: Ref<Theme[]> = ref([]); | ||||
| 
 | ||||
|     watchEffect(() => { | ||||
|         const themes = allThemes.value ?? []; | ||||
|         const themes: Theme[] = allThemes.value ?? []; | ||||
|         allCards.value = themes; | ||||
| 
 | ||||
|         if (props.selectedTheme) { | ||||
|  |  | |||
|  | @ -1,7 +0,0 @@ | |||
| <script setup lang="ts"></script> | ||||
| 
 | ||||
| <template> | ||||
|     <main></main> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
							
								
								
									
										34
									
								
								frontend/src/components/LearningPathSearchField.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								frontend/src/components/LearningPathSearchField.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useRoute, useRouter } from "vue-router"; | ||||
|     import { computed, ref } from "vue"; | ||||
|     const route = useRoute(); | ||||
|     const router = useRouter(); | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const SEARCH_PATH = "/learningPath/search"; | ||||
| 
 | ||||
|     const query = computed({ | ||||
|         get: () => route.query.query as string | null, | ||||
|         set: async (newValue) => router.push({ path: SEARCH_PATH, query: { query: newValue } }), | ||||
|     }); | ||||
| 
 | ||||
|     const queryInput = ref(query.value); | ||||
| 
 | ||||
|     function search(): void { | ||||
|         query.value = queryInput.value; | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <v-text-field | ||||
|         class="search-field" | ||||
|         :label="t('search')" | ||||
|         append-inner-icon="mdi-magnify" | ||||
|         v-model="queryInput" | ||||
|         @keyup.enter="search()" | ||||
|         @click:append-inner="search()" | ||||
|     ></v-text-field> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
							
								
								
									
										62
									
								
								frontend/src/components/LearningPathsGrid.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								frontend/src/components/LearningPathsGrid.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,62 @@ | |||
| <script setup lang="ts"> | ||||
|     import { convertBase64ToImageSrc } from "@/utils/base64ToImage.ts"; | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
|     const props = defineProps<{ learningPaths: LearningPath[] }>(); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <div | ||||
|         class="results-grid" | ||||
|         v-if="props.learningPaths.length > 0" | ||||
|     > | ||||
|         <v-card | ||||
|             class="learning-path-card" | ||||
|             link | ||||
|             :to="`/learningPath/${learningPath.hruid}/${learningPath.language}/${learningPath.startNode.learningobjectHruid}`" | ||||
|             :key="`${learningPath.hruid}/${learningPath.language}`" | ||||
|             v-for="learningPath in props.learningPaths" | ||||
|         > | ||||
|             <v-img | ||||
|                 height="300px" | ||||
|                 :src="convertBase64ToImageSrc(learningPath.image)" | ||||
|                 cover | ||||
|                 v-if="learningPath.image" | ||||
|             ></v-img> | ||||
|             <v-card-title class="learning-path-title">{{ learningPath.title }}</v-card-title> | ||||
|             <v-card-subtitle> | ||||
|                 <v-icon icon="mdi-human-male-boy"></v-icon> | ||||
|                 <span>{{ learningPath.targetAges.min }} - {{ learningPath.targetAges.max }} {{ t("yearsAge") }}</span> | ||||
|             </v-card-subtitle> | ||||
|             <v-card-text>{{ learningPath.description }}</v-card-text> | ||||
|         </v-card> | ||||
|     </div> | ||||
|     <div | ||||
|         content="empty-state-container" | ||||
|         v-else | ||||
|     > | ||||
|         <v-empty-state | ||||
|             icon="mdi-emoticon-sad-outline" | ||||
|             :title="t('noLearningPathsFound')" | ||||
|             :text="t('noLearningPathsFoundDescription')" | ||||
|         ></v-empty-state> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .learning-path-card { | ||||
|         width: 300px; | ||||
|     } | ||||
|     .learning-path-title { | ||||
|         white-space: normal; | ||||
|     } | ||||
|     .results-grid { | ||||
|         margin: 20px; | ||||
|         display: flex; | ||||
|         align-items: stretch; | ||||
|         gap: 20px; | ||||
|         flex-wrap: wrap; | ||||
|     } | ||||
| </style> | ||||
|  | @ -26,20 +26,19 @@ | |||
|     ]); | ||||
| 
 | ||||
|     // Logic to change the language of the website to the selected language | ||||
|     const changeLanguage = (langCode: string) => { | ||||
|     function changeLanguage(langCode: string): void { | ||||
|         locale.value = langCode; | ||||
|         localStorage.setItem("user-lang", langCode); | ||||
|     }; | ||||
|     } | ||||
| 
 | ||||
|     // Contains functionality to let the collapsed menu appear and disappear | ||||
|     // When the screen size varies | ||||
|     // Contains functionality to let the collapsed menu appear and disappear when the screen size varies | ||||
|     const drawer = ref(false); | ||||
| 
 | ||||
|     // When the user wants to logout, a popup is shown to verify this | ||||
|     // If verified, the user should be logged out | ||||
|     const performLogout = () => { | ||||
|         auth.logout(); | ||||
|     }; | ||||
|     async function performLogout(): Promise<void> { | ||||
|         await auth.logout(); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
							
								
								
									
										45
									
								
								frontend/src/components/UsingQueryResult.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								frontend/src/components/UsingQueryResult.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,45 @@ | |||
| <script setup lang="ts" generic="T"> | ||||
|     import { computed } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import type { UseQueryReturnType } from "@tanstack/vue-query"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         queryResult: UseQueryReturnType<T, Error>; | ||||
|     }>(); | ||||
| 
 | ||||
|     const { isLoading, isError, isSuccess, data, error } = props.queryResult; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const errorMessage = computed(() => { | ||||
|         const errorWithMessage = (error.value as { message: string }) || null; | ||||
|         return errorWithMessage?.message || JSON.stringify(errorWithMessage); | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <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')" | ||||
|         ></v-empty-state> | ||||
|     </div> | ||||
|     <slot | ||||
|         v-if="isSuccess && data" | ||||
|         :data="data" | ||||
|     ></slot> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .loading-div { | ||||
|         padding: 20px; | ||||
|         text-align: center; | ||||
|     } | ||||
| </style> | ||||
							
								
								
									
										47
									
								
								frontend/src/controllers/assignments.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								frontend/src/controllers/assignments.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,47 @@ | |||
| import { BaseController } from "./base-controller"; | ||||
| import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; | ||||
| import type { SubmissionsResponse } from "./submissions"; | ||||
| import type { QuestionsResponse } from "./questions"; | ||||
| import type { GroupsResponse } from "./groups"; | ||||
| 
 | ||||
| export interface AssignmentsResponse { | ||||
|     assignments: AssignmentDTO[] | string[]; | ||||
| } | ||||
| 
 | ||||
| export interface AssignmentResponse { | ||||
|     assignment: AssignmentDTO; | ||||
| } | ||||
| 
 | ||||
| export class AssignmentController extends BaseController { | ||||
|     constructor(classid: string) { | ||||
|         super(`class/${classid}/assignments`); | ||||
|     } | ||||
| 
 | ||||
|     async getAll(full = true): Promise<AssignmentsResponse> { | ||||
|         return this.get<AssignmentsResponse>(`/`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getByNumber(num: number): Promise<AssignmentResponse> { | ||||
|         return this.get<AssignmentResponse>(`/${num}`); | ||||
|     } | ||||
| 
 | ||||
|     async createAssignment(data: AssignmentDTO): Promise<AssignmentResponse> { | ||||
|         return this.post<AssignmentResponse>(`/`, data); | ||||
|     } | ||||
| 
 | ||||
|     async deleteAssignment(num: number): Promise<AssignmentResponse> { | ||||
|         return this.delete<AssignmentResponse>(`/${num}`); | ||||
|     } | ||||
| 
 | ||||
|     async getSubmissions(assignmentNumber: number, full = true): Promise<SubmissionsResponse> { | ||||
|         return this.get<SubmissionsResponse>(`/${assignmentNumber}/submissions`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getQuestions(assignmentNumber: number, full = true): Promise<QuestionsResponse> { | ||||
|         return this.get<QuestionsResponse>(`/${assignmentNumber}/questions`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getGroups(assignmentNumber: number, full = true): Promise<GroupsResponse> { | ||||
|         return this.get<GroupsResponse>(`/${assignmentNumber}/groups`, { full }); | ||||
|     } | ||||
| } | ||||
|  | @ -1,73 +1,47 @@ | |||
| import { apiConfig } from "@/config.ts"; | ||||
| import apiClient from "@/services/api-client/api-client.ts"; | ||||
| import type { AxiosResponse, ResponseType } from "axios"; | ||||
| import { HttpErrorResponseException } from "@/exception/http-error-response-exception.ts"; | ||||
| 
 | ||||
| export class BaseController { | ||||
|     protected baseUrl: string; | ||||
| export abstract class BaseController { | ||||
|     protected basePath: string; | ||||
| 
 | ||||
|     constructor(basePath: string) { | ||||
|         this.baseUrl = `${apiConfig.baseUrl}/${basePath}`; | ||||
|     protected constructor(basePath: string) { | ||||
|         this.basePath = basePath; | ||||
|     } | ||||
| 
 | ||||
|     protected async get<T>(path: string, queryParams?: Record<string, any>): Promise<T> { | ||||
|         let url = `${this.baseUrl}${path}`; | ||||
|         if (queryParams) { | ||||
|             const query = new URLSearchParams(); | ||||
|             Object.entries(queryParams).forEach(([key, value]) => { | ||||
|                 if (value !== undefined && value !== null) { | ||||
|                     query.append(key, value.toString()); | ||||
|                 } | ||||
|             }); | ||||
|             url += `?${query.toString()}`; | ||||
|     private static assertSuccessResponse(response: AxiosResponse<unknown, unknown>): void { | ||||
|         if (response.status / 100 !== 2) { | ||||
|             throw new HttpErrorResponseException(response); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|         const res = await fetch(url); | ||||
|         if (!res.ok) { | ||||
|             const errorData = await res.json().catch(() => ({})); | ||||
|             throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); | ||||
|         } | ||||
| 
 | ||||
|         return res.json(); | ||||
|     protected async get<T>(path: string, queryParams?: QueryParams, responseType?: ResponseType): Promise<T> { | ||||
|         const response = await apiClient.get<T>(this.absolutePathFor(path), { params: queryParams, responseType }); | ||||
|         BaseController.assertSuccessResponse(response); | ||||
|         return response.data; | ||||
|     } | ||||
| 
 | ||||
|     protected async post<T>(path: string, body: unknown): Promise<T> { | ||||
|         const res = await fetch(`${this.baseUrl}${path}`, { | ||||
|             method: "POST", | ||||
|             headers: { "Content-Type": "application/json" }, | ||||
|             body: JSON.stringify(body), | ||||
|         }); | ||||
| 
 | ||||
|         if (!res.ok) { | ||||
|             const errorData = await res.json().catch(() => ({})); | ||||
|             throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); | ||||
|         } | ||||
| 
 | ||||
|         return res.json(); | ||||
|         const response = await apiClient.post<T>(this.absolutePathFor(path), body); | ||||
|         BaseController.assertSuccessResponse(response); | ||||
|         return response.data; | ||||
|     } | ||||
| 
 | ||||
|     protected async delete<T>(path: string): Promise<T> { | ||||
|         const res = await fetch(`${this.baseUrl}${path}`, { | ||||
|             method: "DELETE", | ||||
|         }); | ||||
| 
 | ||||
|         if (!res.ok) { | ||||
|             const errorData = await res.json().catch(() => ({})); | ||||
|             throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); | ||||
|         } | ||||
| 
 | ||||
|         return res.json(); | ||||
|         const response = await apiClient.delete<T>(this.absolutePathFor(path)); | ||||
|         BaseController.assertSuccessResponse(response); | ||||
|         return response.data; | ||||
|     } | ||||
| 
 | ||||
|     protected async put<T>(path: string, body: unknown): Promise<T> { | ||||
|         const res = await fetch(`${this.baseUrl}${path}`, { | ||||
|             method: "PUT", | ||||
|             headers: { "Content-Type": "application/json" }, | ||||
|             body: JSON.stringify(body), | ||||
|         }); | ||||
|         const response = await apiClient.put<T>(this.absolutePathFor(path), body); | ||||
|         BaseController.assertSuccessResponse(response); | ||||
|         return response.data; | ||||
|     } | ||||
| 
 | ||||
|         if (!res.ok) { | ||||
|             const errorData = await res.json().catch(() => ({})); | ||||
|             throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); | ||||
|         } | ||||
| 
 | ||||
|         return res.json(); | ||||
|     private absolutePathFor(path: string): string { | ||||
|         return "/" + this.basePath + path; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| type QueryParams = Record<string, string | number | boolean | undefined>; | ||||
|  |  | |||
							
								
								
									
										55
									
								
								frontend/src/controllers/classes.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								frontend/src/controllers/classes.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,55 @@ | |||
| import { BaseController } from "./base-controller"; | ||||
| import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; | ||||
| import type { StudentsResponse } from "./students"; | ||||
| import type { AssignmentsResponse } from "./assignments"; | ||||
| import type { TeacherInvitationDTO } from "@dwengo-1/common/interfaces/teacher-invitation"; | ||||
| 
 | ||||
| export interface ClassesResponse { | ||||
|     classes: ClassDTO[] | string[]; | ||||
| } | ||||
| 
 | ||||
| export interface ClassResponse { | ||||
|     class: ClassDTO; | ||||
| } | ||||
| 
 | ||||
| export interface TeacherInvitationsResponse { | ||||
|     invites: TeacherInvitationDTO[]; | ||||
| } | ||||
| 
 | ||||
| export interface TeacherInvitationResponse { | ||||
|     invite: TeacherInvitationDTO; | ||||
| } | ||||
| 
 | ||||
| export class ClassController extends BaseController { | ||||
|     constructor() { | ||||
|         super("class"); | ||||
|     } | ||||
| 
 | ||||
|     async getAll(full = true): Promise<ClassesResponse> { | ||||
|         return this.get<ClassesResponse>(`/`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getById(id: string): Promise<ClassResponse> { | ||||
|         return this.get<ClassResponse>(`/${id}`); | ||||
|     } | ||||
| 
 | ||||
|     async createClass(data: ClassDTO): Promise<ClassResponse> { | ||||
|         return this.post<ClassResponse>(`/`, data); | ||||
|     } | ||||
| 
 | ||||
|     async deleteClass(id: string): Promise<ClassResponse> { | ||||
|         return this.delete<ClassResponse>(`/${id}`); | ||||
|     } | ||||
| 
 | ||||
|     async getStudents(id: string, full = true): Promise<StudentsResponse> { | ||||
|         return this.get<StudentsResponse>(`/${id}/students`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getTeacherInvitations(id: string, full = true): Promise<TeacherInvitationsResponse> { | ||||
|         return this.get<TeacherInvitationsResponse>(`/${id}/teacher-invitations`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getAssignments(id: string, full = true): Promise<AssignmentsResponse> { | ||||
|         return this.get<AssignmentsResponse>(`/${id}/assignments`, { full }); | ||||
|     } | ||||
| } | ||||
|  | @ -1,14 +1,18 @@ | |||
| import { ThemeController } from "@/controllers/themes.ts"; | ||||
| import { LearningObjectController } from "@/controllers/learning-objects.ts"; | ||||
| import { LearningPathController } from "@/controllers/learning-paths.ts"; | ||||
| 
 | ||||
| export function controllerGetter<T>(Factory: new () => T): () => T { | ||||
| export function controllerGetter<T>(factory: new () => T): () => T { | ||||
|     let instance: T | undefined; | ||||
| 
 | ||||
|     return (): T => { | ||||
|         if (!instance) { | ||||
|             instance = new Factory(); | ||||
|             instance = new factory(); | ||||
|         } | ||||
|         return instance; | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export const getThemeController = controllerGetter(ThemeController); | ||||
| export const getLearningObjectController = controllerGetter(LearningObjectController); | ||||
| export const getLearningPathController = controllerGetter(LearningPathController); | ||||
|  |  | |||
							
								
								
									
										42
									
								
								frontend/src/controllers/groups.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								frontend/src/controllers/groups.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | |||
| import { BaseController } from "./base-controller"; | ||||
| import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; | ||||
| import type { SubmissionsResponse } from "./submissions"; | ||||
| import type { QuestionsResponse } from "./questions"; | ||||
| 
 | ||||
| export interface GroupsResponse { | ||||
|     groups: GroupDTO[]; | ||||
| } | ||||
| 
 | ||||
| export interface GroupResponse { | ||||
|     group: GroupDTO; | ||||
| } | ||||
| 
 | ||||
| export class GroupController extends BaseController { | ||||
|     constructor(classid: string, assignmentNumber: number) { | ||||
|         super(`class/${classid}/assignments/${assignmentNumber}/groups`); | ||||
|     } | ||||
| 
 | ||||
|     async getAll(full = true): Promise<GroupsResponse> { | ||||
|         return this.get<GroupsResponse>(`/`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getByNumber(num: number): Promise<GroupResponse> { | ||||
|         return this.get<GroupResponse>(`/${num}`); | ||||
|     } | ||||
| 
 | ||||
|     async createGroup(data: GroupDTO): Promise<GroupResponse> { | ||||
|         return this.post<GroupResponse>(`/`, data); | ||||
|     } | ||||
| 
 | ||||
|     async deleteGroup(num: number): Promise<GroupResponse> { | ||||
|         return this.delete<GroupResponse>(`/${num}`); | ||||
|     } | ||||
| 
 | ||||
|     async getSubmissions(groupNumber: number, full = true): Promise<SubmissionsResponse> { | ||||
|         return this.get<SubmissionsResponse>(`/${groupNumber}/submissions`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getQuestions(groupNumber: number, full = true): Promise<QuestionsResponse> { | ||||
|         return this.get<QuestionsResponse>(`/${groupNumber}/questions`, { full }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										17
									
								
								frontend/src/controllers/learning-objects.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/src/controllers/learning-objects.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | |||
| import { BaseController } from "@/controllers/base-controller.ts"; | ||||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; | ||||
| 
 | ||||
| export class LearningObjectController extends BaseController { | ||||
|     constructor() { | ||||
|         super("learningObject"); | ||||
|     } | ||||
| 
 | ||||
|     async getMetadata(hruid: string, language: Language, version: number): Promise<LearningObject> { | ||||
|         return this.get<LearningObject>(`/${hruid}`, { language, version }); | ||||
|     } | ||||
| 
 | ||||
|     async getHTML(hruid: string, language: Language, version: number): Promise<Document> { | ||||
|         return this.get<Document>(`/${hruid}/html`, { language, version }, "document"); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										32
									
								
								frontend/src/controllers/learning-paths.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								frontend/src/controllers/learning-paths.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | |||
| import { BaseController } from "@/controllers/base-controller.ts"; | ||||
| import { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import { single } from "@/utils/response-assertions.ts"; | ||||
| import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; | ||||
| 
 | ||||
| export class LearningPathController extends BaseController { | ||||
|     constructor() { | ||||
|         super("learningPath"); | ||||
|     } | ||||
|     async search(query: string): Promise<LearningPath[]> { | ||||
|         const dtos = await this.get<LearningPathDTO[]>("/", { search: query }); | ||||
|         return dtos.map((dto) => LearningPath.fromDTO(dto)); | ||||
|     } | ||||
|     async getBy( | ||||
|         hruid: string, | ||||
|         language: Language, | ||||
|         options?: { forGroup?: string; forStudent?: string }, | ||||
|     ): Promise<LearningPath> { | ||||
|         const dtos = await this.get<LearningPathDTO[]>("/", { | ||||
|             hruid, | ||||
|             language, | ||||
|             forGroup: options?.forGroup, | ||||
|             forStudent: options?.forStudent, | ||||
|         }); | ||||
|         return LearningPath.fromDTO(single(dtos)); | ||||
|     } | ||||
|     async getAllByTheme(theme: string): Promise<LearningPath[]> { | ||||
|         const dtos = await this.get<LearningPathDTO[]>("/", { theme }); | ||||
|         return dtos.map((dto) => LearningPath.fromDTO(dto)); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										5
									
								
								frontend/src/controllers/questions.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/controllers/questions.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| import type { QuestionDTO, QuestionId } from "@dwengo-1/common/interfaces/question"; | ||||
| 
 | ||||
| export interface QuestionsResponse { | ||||
|     questions: QuestionDTO[] | QuestionId[]; | ||||
| } | ||||
							
								
								
									
										79
									
								
								frontend/src/controllers/students.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								frontend/src/controllers/students.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,79 @@ | |||
| import { BaseController } from "@/controllers/base-controller.ts"; | ||||
| import type { ClassesResponse } from "@/controllers/classes.ts"; | ||||
| import type { AssignmentsResponse } from "@/controllers/assignments.ts"; | ||||
| import type { GroupsResponse } from "@/controllers/groups.ts"; | ||||
| import type { SubmissionsResponse } from "@/controllers/submissions.ts"; | ||||
| import type { QuestionsResponse } from "@/controllers/questions.ts"; | ||||
| import type { StudentDTO } from "@dwengo-1/common/interfaces/student"; | ||||
| import type { ClassJoinRequestDTO } from "@dwengo-1/common/interfaces/class-join-request"; | ||||
| 
 | ||||
| export interface StudentsResponse { | ||||
|     students: StudentDTO[] | string[]; | ||||
| } | ||||
| export interface StudentResponse { | ||||
|     student: StudentDTO; | ||||
| } | ||||
| export interface JoinRequestsResponse { | ||||
|     requests: ClassJoinRequestDTO[]; | ||||
| } | ||||
| export interface JoinRequestResponse { | ||||
|     request: ClassJoinRequestDTO; | ||||
| } | ||||
| 
 | ||||
| export class StudentController extends BaseController { | ||||
|     constructor() { | ||||
|         super("student"); | ||||
|     } | ||||
| 
 | ||||
|     async getAll(full = true): Promise<StudentsResponse> { | ||||
|         return this.get<StudentsResponse>("/", { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getByUsername(username: string): Promise<StudentResponse> { | ||||
|         return this.get<StudentResponse>(`/${username}`); | ||||
|     } | ||||
| 
 | ||||
|     async createStudent(data: StudentDTO): Promise<StudentResponse> { | ||||
|         return this.post<StudentResponse>("/", data); | ||||
|     } | ||||
| 
 | ||||
|     async deleteStudent(username: string): Promise<StudentResponse> { | ||||
|         return this.delete<StudentResponse>(`/${username}`); | ||||
|     } | ||||
| 
 | ||||
|     async getClasses(username: string, full = true): Promise<ClassesResponse> { | ||||
|         return this.get<ClassesResponse>(`/${username}/classes`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getAssignments(username: string, full = true): Promise<AssignmentsResponse> { | ||||
|         return this.get<AssignmentsResponse>(`/${username}/assignments`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getGroups(username: string, full = true): Promise<GroupsResponse> { | ||||
|         return this.get<GroupsResponse>(`/${username}/groups`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getSubmissions(username: string): Promise<SubmissionsResponse> { | ||||
|         return this.get<SubmissionsResponse>(`/${username}/submissions`); | ||||
|     } | ||||
| 
 | ||||
|     async getQuestions(username: string, full = true): Promise<QuestionsResponse> { | ||||
|         return this.get<QuestionsResponse>(`/${username}/questions`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getJoinRequests(username: string): Promise<JoinRequestsResponse> { | ||||
|         return this.get<JoinRequestsResponse>(`/${username}/joinRequests`); | ||||
|     } | ||||
| 
 | ||||
|     async getJoinRequest(username: string, classId: string): Promise<JoinRequestResponse> { | ||||
|         return this.get<JoinRequestResponse>(`/${username}/joinRequests/${classId}`); | ||||
|     } | ||||
| 
 | ||||
|     async createJoinRequest(username: string, classId: string): Promise<JoinRequestResponse> { | ||||
|         return this.post<JoinRequestResponse>(`/${username}/joinRequests}`, classId); | ||||
|     } | ||||
| 
 | ||||
|     async deleteJoinRequest(username: string, classId: string): Promise<JoinRequestResponse> { | ||||
|         return this.delete<JoinRequestResponse>(`/${username}/joinRequests/${classId}`); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										32
									
								
								frontend/src/controllers/submissions.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								frontend/src/controllers/submissions.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | |||
| import { BaseController } from "./base-controller"; | ||||
| import type { SubmissionDTO, SubmissionDTOId } from "@dwengo-1/common/interfaces/submission"; | ||||
| 
 | ||||
| export interface SubmissionsResponse { | ||||
|     submissions: SubmissionDTO[] | SubmissionDTOId[]; | ||||
| } | ||||
| 
 | ||||
| export interface SubmissionResponse { | ||||
|     submission: SubmissionDTO; | ||||
| } | ||||
| 
 | ||||
| export class SubmissionController extends BaseController { | ||||
|     constructor(classid: string, assignmentNumber: number, groupNumber: number) { | ||||
|         super(`class/${classid}/assignments/${assignmentNumber}/groups/${groupNumber}`); | ||||
|     } | ||||
| 
 | ||||
|     async getAll(full = true): Promise<SubmissionsResponse> { | ||||
|         return this.get<SubmissionsResponse>(`/`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getByNumber(submissionNumber: number): Promise<SubmissionResponse> { | ||||
|         return this.get<SubmissionResponse>(`/${submissionNumber}`); | ||||
|     } | ||||
| 
 | ||||
|     async createSubmission(data: unknown): Promise<SubmissionResponse> { | ||||
|         return this.post<SubmissionResponse>(`/`, data); | ||||
|     } | ||||
| 
 | ||||
|     async deleteSubmission(submissionNumber: number): Promise<SubmissionResponse> { | ||||
|         return this.delete<SubmissionResponse>(`/${submissionNumber}`); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										64
									
								
								frontend/src/controllers/teachers.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								frontend/src/controllers/teachers.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,64 @@ | |||
| import { BaseController } from "@/controllers/base-controller.ts"; | ||||
| import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; | ||||
| import type { QuestionsResponse } from "@/controllers/questions.ts"; | ||||
| import type { ClassesResponse } from "@/controllers/classes.ts"; | ||||
| import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; | ||||
| 
 | ||||
| export interface TeachersResponse { | ||||
|     teachers: TeacherDTO[] | string[]; | ||||
| } | ||||
| export interface TeacherResponse { | ||||
|     teacher: TeacherDTO; | ||||
| } | ||||
| 
 | ||||
| export class TeacherController extends BaseController { | ||||
|     constructor() { | ||||
|         super("teacher"); | ||||
|     } | ||||
| 
 | ||||
|     async getAll(full = false): Promise<TeachersResponse> { | ||||
|         return this.get<TeachersResponse>("/", { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getByUsername(username: string): Promise<TeacherResponse> { | ||||
|         return this.get<TeacherResponse>(`/${username}`); | ||||
|     } | ||||
| 
 | ||||
|     async createTeacher(data: TeacherDTO): Promise<TeacherResponse> { | ||||
|         return this.post<TeacherResponse>("/", data); | ||||
|     } | ||||
| 
 | ||||
|     async deleteTeacher(username: string): Promise<TeacherResponse> { | ||||
|         return this.delete<TeacherResponse>(`/${username}`); | ||||
|     } | ||||
| 
 | ||||
|     async getClasses(username: string, full = false): Promise<ClassesResponse> { | ||||
|         return this.get<ClassesResponse>(`/${username}/classes`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getStudents(username: string, full = false): Promise<StudentsResponse> { | ||||
|         return this.get<StudentsResponse>(`/${username}/students`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getQuestions(username: string, full = false): Promise<QuestionsResponse> { | ||||
|         return this.get<QuestionsResponse>(`/${username}/questions`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getStudentJoinRequests(username: string, classId: string): Promise<JoinRequestsResponse> { | ||||
|         return this.get<JoinRequestsResponse>(`/${username}/joinRequests/${classId}`); | ||||
|     } | ||||
| 
 | ||||
|     async updateStudentJoinRequest( | ||||
|         teacherUsername: string, | ||||
|         classId: string, | ||||
|         studentUsername: string, | ||||
|         accepted: boolean, | ||||
|     ): Promise<JoinRequestResponse> { | ||||
|         return this.put<JoinRequestResponse>( | ||||
|             `/${teacherUsername}/joinRequests/${classId}/${studentUsername}`, | ||||
|             accepted, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     // GetInvitations(id: string) {return this.get<{ invitations: string[] }>(`/${id}/invitations`);}
 | ||||
| } | ||||
|  | @ -1,16 +1,17 @@ | |||
| import { BaseController } from "@/controllers/base-controller.ts"; | ||||
| import type { Theme } from "@dwengo-1/common/interfaces/theme"; | ||||
| 
 | ||||
| export class ThemeController extends BaseController { | ||||
|     constructor() { | ||||
|         super("theme"); | ||||
|     } | ||||
| 
 | ||||
|     getAll(language: string | null = null) { | ||||
|     async getAll(language: string | null = null): Promise<Theme[]> { | ||||
|         const query = language ? { language } : undefined; | ||||
|         return this.get<any[]>("/", query); | ||||
|         return this.get("/", query); | ||||
|     } | ||||
| 
 | ||||
|     getHruidsByKey(themeKey: string) { | ||||
|     async getHruidsByKey(themeKey: string): Promise<string[]> { | ||||
|         return this.get<string[]>(`/${encodeURIComponent(themeKey)}`); | ||||
|     } | ||||
| } | ||||
|  |  | |||
							
								
								
									
										186
									
								
								frontend/src/data-objects/language.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										186
									
								
								frontend/src/data-objects/language.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,186 @@ | |||
| export enum Language { | ||||
|     Afar = "aa", | ||||
|     Abkhazian = "ab", | ||||
|     Afrikaans = "af", | ||||
|     Akan = "ak", | ||||
|     Albanian = "sq", | ||||
|     Amharic = "am", | ||||
|     Arabic = "ar", | ||||
|     Aragonese = "an", | ||||
|     Armenian = "hy", | ||||
|     Assamese = "as", | ||||
|     Avaric = "av", | ||||
|     Avestan = "ae", | ||||
|     Aymara = "ay", | ||||
|     Azerbaijani = "az", | ||||
|     Bashkir = "ba", | ||||
|     Bambara = "bm", | ||||
|     Basque = "eu", | ||||
|     Belarusian = "be", | ||||
|     Bengali = "bn", | ||||
|     Bihari = "bh", | ||||
|     Bislama = "bi", | ||||
|     Bosnian = "bs", | ||||
|     Breton = "br", | ||||
|     Bulgarian = "bg", | ||||
|     Burmese = "my", | ||||
|     Catalan = "ca", | ||||
|     Chamorro = "ch", | ||||
|     Chechen = "ce", | ||||
|     Chinese = "zh", | ||||
|     ChurchSlavic = "cu", | ||||
|     Chuvash = "cv", | ||||
|     Cornish = "kw", | ||||
|     Corsican = "co", | ||||
|     Cree = "cr", | ||||
|     Czech = "cs", | ||||
|     Danish = "da", | ||||
|     Divehi = "dv", | ||||
|     Dutch = "nl", | ||||
|     Dzongkha = "dz", | ||||
|     English = "en", | ||||
|     Esperanto = "eo", | ||||
|     Estonian = "et", | ||||
|     Ewe = "ee", | ||||
|     Faroese = "fo", | ||||
|     Fijian = "fj", | ||||
|     Finnish = "fi", | ||||
|     French = "fr", | ||||
|     Frisian = "fy", | ||||
|     Fulah = "ff", | ||||
|     Georgian = "ka", | ||||
|     German = "de", | ||||
|     Gaelic = "gd", | ||||
|     Irish = "ga", | ||||
|     Galician = "gl", | ||||
|     Manx = "gv", | ||||
|     Greek = "el", | ||||
|     Guarani = "gn", | ||||
|     Gujarati = "gu", | ||||
|     Haitian = "ht", | ||||
|     Hausa = "ha", | ||||
|     Hebrew = "he", | ||||
|     Herero = "hz", | ||||
|     Hindi = "hi", | ||||
|     HiriMotu = "ho", | ||||
|     Croatian = "hr", | ||||
|     Hungarian = "hu", | ||||
|     Igbo = "ig", | ||||
|     Icelandic = "is", | ||||
|     Ido = "io", | ||||
|     SichuanYi = "ii", | ||||
|     Inuktitut = "iu", | ||||
|     Interlingue = "ie", | ||||
|     Interlingua = "ia", | ||||
|     Indonesian = "id", | ||||
|     Inupiaq = "ik", | ||||
|     Italian = "it", | ||||
|     Javanese = "jv", | ||||
|     Japanese = "ja", | ||||
|     Kalaallisut = "kl", | ||||
|     Kannada = "kn", | ||||
|     Kashmiri = "ks", | ||||
|     Kanuri = "kr", | ||||
|     Kazakh = "kk", | ||||
|     Khmer = "km", | ||||
|     Kikuyu = "ki", | ||||
|     Kinyarwanda = "rw", | ||||
|     Kirghiz = "ky", | ||||
|     Komi = "kv", | ||||
|     Kongo = "kg", | ||||
|     Korean = "ko", | ||||
|     Kuanyama = "kj", | ||||
|     Kurdish = "ku", | ||||
|     Lao = "lo", | ||||
|     Latin = "la", | ||||
|     Latvian = "lv", | ||||
|     Limburgan = "li", | ||||
|     Lingala = "ln", | ||||
|     Lithuanian = "lt", | ||||
|     Luxembourgish = "lb", | ||||
|     LubaKatanga = "lu", | ||||
|     Ganda = "lg", | ||||
|     Macedonian = "mk", | ||||
|     Marshallese = "mh", | ||||
|     Malayalam = "ml", | ||||
|     Maori = "mi", | ||||
|     Marathi = "mr", | ||||
|     Malay = "ms", | ||||
|     Malagasy = "mg", | ||||
|     Maltese = "mt", | ||||
|     Mongolian = "mn", | ||||
|     Nauru = "na", | ||||
|     Navajo = "nv", | ||||
|     SouthNdebele = "nr", | ||||
|     NorthNdebele = "nd", | ||||
|     Ndonga = "ng", | ||||
|     Nepali = "ne", | ||||
|     NorwegianNynorsk = "nn", | ||||
|     NorwegianBokmal = "nb", | ||||
|     Norwegian = "no", | ||||
|     Chichewa = "ny", | ||||
|     Occitan = "oc", | ||||
|     Ojibwa = "oj", | ||||
|     Oriya = "or", | ||||
|     Oromo = "om", | ||||
|     Ossetian = "os", | ||||
|     Punjabi = "pa", | ||||
|     Persian = "fa", | ||||
|     Pali = "pi", | ||||
|     Polish = "pl", | ||||
|     Portuguese = "pt", | ||||
|     Pashto = "ps", | ||||
|     Quechua = "qu", | ||||
|     Romansh = "rm", | ||||
|     Romanian = "ro", | ||||
|     Rundi = "rn", | ||||
|     Russian = "ru", | ||||
|     Sango = "sg", | ||||
|     Sanskrit = "sa", | ||||
|     Sinhala = "si", | ||||
|     Slovak = "sk", | ||||
|     Slovenian = "sl", | ||||
|     NorthernSami = "se", | ||||
|     Samoan = "sm", | ||||
|     Shona = "sn", | ||||
|     Sindhi = "sd", | ||||
|     Somali = "so", | ||||
|     Sotho = "st", | ||||
|     Spanish = "es", | ||||
|     Sardinian = "sc", | ||||
|     Serbian = "sr", | ||||
|     Swati = "ss", | ||||
|     Sundanese = "su", | ||||
|     Swahili = "sw", | ||||
|     Swedish = "sv", | ||||
|     Tahitian = "ty", | ||||
|     Tamil = "ta", | ||||
|     Tatar = "tt", | ||||
|     Telugu = "te", | ||||
|     Tajik = "tg", | ||||
|     Tagalog = "tl", | ||||
|     Thai = "th", | ||||
|     Tibetan = "bo", | ||||
|     Tigrinya = "ti", | ||||
|     Tonga = "to", | ||||
|     Tswana = "tn", | ||||
|     Tsonga = "ts", | ||||
|     Turkmen = "tk", | ||||
|     Turkish = "tr", | ||||
|     Twi = "tw", | ||||
|     Uighur = "ug", | ||||
|     Ukrainian = "uk", | ||||
|     Urdu = "ur", | ||||
|     Uzbek = "uz", | ||||
|     Venda = "ve", | ||||
|     Vietnamese = "vi", | ||||
|     Volapuk = "vo", | ||||
|     Welsh = "cy", | ||||
|     Walloon = "wa", | ||||
|     Wolof = "wo", | ||||
|     Xhosa = "xh", | ||||
|     Yiddish = "yi", | ||||
|     Yoruba = "yo", | ||||
|     Zhuang = "za", | ||||
|     Zulu = "zu", | ||||
| } | ||||
|  | @ -0,0 +1,4 @@ | |||
| export interface EducationalGoal { | ||||
|     source: string; | ||||
|     id: string; | ||||
| } | ||||
|  | @ -0,0 +1,25 @@ | |||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import type { ReturnValue } from "@/data-objects/learning-objects/return-value.ts"; | ||||
| import type { EducationalGoal } from "@/data-objects/learning-objects/educational-goal.ts"; | ||||
| 
 | ||||
| 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,4 @@ | |||
| export interface ReturnValue { | ||||
|     callback_url: string; | ||||
|     callback_schema: Record<string, unknown>; | ||||
| } | ||||
|  | @ -0,0 +1,17 @@ | |||
| import type { LearningPathNodeDTO } from "@/data-objects/learning-paths/learning-path.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; | ||||
| } | ||||
|  | @ -0,0 +1,58 @@ | |||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import type { LearningPathNodeDTO } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| 
 | ||||
| export class LearningPathNode { | ||||
|     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; | ||||
|     public readonly done: boolean; | ||||
| 
 | ||||
|     constructor(options: { | ||||
|         learningobjectHruid: string; | ||||
|         version: number; | ||||
|         language: Language; | ||||
|         transitions: { next: LearningPathNode; default: boolean }[]; | ||||
|         createdAt: Date; | ||||
|         updatedAt: Date; | ||||
|         done?: boolean; | ||||
|     }) { | ||||
|         this.learningobjectHruid = options.learningobjectHruid; | ||||
|         this.version = options.version; | ||||
|         this.language = options.language; | ||||
|         this.transitions = options.transitions; | ||||
|         this.createdAt = options.createdAt; | ||||
|         this.updatedAt = options.updatedAt; | ||||
|         this.done = options.done || false; | ||||
|     } | ||||
| 
 | ||||
|     static fromDTOAndOtherNodes(dto: LearningPathNodeDTO, otherNodes: LearningPathNodeDTO[]): LearningPathNode { | ||||
|         return new LearningPathNode({ | ||||
|             learningobjectHruid: dto.learningobject_hruid, | ||||
|             version: dto.version, | ||||
|             language: dto.language, | ||||
|             transitions: dto.transitions | ||||
|                 .map((transDto) => { | ||||
|                     const nextNodeDto = otherNodes.find( | ||||
|                         (it) => | ||||
|                             it.learningobject_hruid === transDto.next.hruid && | ||||
|                             it.language === transDto.next.language && | ||||
|                             it.version === transDto.next.version, | ||||
|                     ); | ||||
|                     if (nextNodeDto) { | ||||
|                         return { | ||||
|                             next: LearningPathNode.fromDTOAndOtherNodes(nextNodeDto, otherNodes), | ||||
|                             default: transDto.default, | ||||
|                         }; | ||||
|                     } | ||||
|                     return undefined; | ||||
|                 }) | ||||
|                 .filter((it) => it !== undefined), | ||||
|             createdAt: new Date(dto.created_at), | ||||
|             updatedAt: new Date(dto.updatedAt), | ||||
|             done: dto.done, | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										97
									
								
								frontend/src/data-objects/learning-paths/learning-path.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								frontend/src/data-objects/learning-paths/learning-path.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,97 @@ | |||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; | ||||
| import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; | ||||
| 
 | ||||
| export 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.
 | ||||
| } | ||||
| 
 | ||||
| export interface LearningPathTransitionDTO { | ||||
|     default: boolean; | ||||
|     _id: string; | ||||
|     next: { | ||||
|         _id: string; | ||||
|         hruid: string; | ||||
|         version: number; | ||||
|         language: string; | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export class LearningPath { | ||||
|     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
 | ||||
| 
 | ||||
|     constructor(options: { | ||||
|         language: string; | ||||
|         hruid: string; | ||||
|         title: string; | ||||
|         description: string; | ||||
|         amountOfNodes: number; | ||||
|         amountOfNodesLeft: number; | ||||
|         keywords: string[]; | ||||
|         targetAges: { min: number; max: number }; | ||||
|         startNode: LearningPathNode; | ||||
|         image?: string; // Image might be missing, so it's optional
 | ||||
|     }) { | ||||
|         this.language = options.language; | ||||
|         this.hruid = options.hruid; | ||||
|         this.title = options.title; | ||||
|         this.description = options.description; | ||||
|         this.amountOfNodes = options.amountOfNodes; | ||||
|         this.amountOfNodesLeft = options.amountOfNodesLeft; | ||||
|         this.keywords = options.keywords; | ||||
|         this.targetAges = options.targetAges; | ||||
|         this.startNode = options.startNode; | ||||
|         this.image = options.image; | ||||
|     } | ||||
| 
 | ||||
|     public get nodesAsList(): LearningPathNode[] { | ||||
|         const list: LearningPathNode[] = []; | ||||
|         let currentNode = this.startNode; | ||||
|         while (currentNode) { | ||||
|             list.push(currentNode); | ||||
|             currentNode = currentNode.transitions.find((it) => it.default)?.next || currentNode.transitions[0]?.next; | ||||
|         } | ||||
|         return list; | ||||
|     } | ||||
| 
 | ||||
|     static fromDTO(dto: LearningPathDTO): LearningPath { | ||||
|         return new LearningPath({ | ||||
|             language: dto.language, | ||||
|             hruid: dto.hruid, | ||||
|             title: dto.title, | ||||
|             description: dto.description, | ||||
|             amountOfNodes: dto.num_nodes, | ||||
|             amountOfNodesLeft: dto.num_nodes_left, | ||||
|             keywords: dto.keywords.split(" "), | ||||
|             targetAges: { min: dto.min_age, max: dto.max_age }, | ||||
|             startNode: LearningPathNode.fromDTOAndOtherNodes(LearningPath.getStartNode(dto), dto.nodes), | ||||
|             image: dto.image, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     static getStartNode(dto: LearningPathDTO): LearningPathNodeDTO { | ||||
|         const startNodeDtos = dto.nodes.filter((it) => it.start_node === true); | ||||
|         if (startNodeDtos.length < 1) { | ||||
|             // The learning path has no starting node -> use the first node.
 | ||||
|             return dto.nodes[0]; | ||||
|         } // The learning path has 1 or more starting nodes -> use the first start node.
 | ||||
|         return startNodeDtos[0]; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										8
									
								
								frontend/src/data-objects/theme.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/src/data-objects/theme.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | |||
| export interface Theme { | ||||
|     key: string; | ||||
|     title: string; | ||||
|     description: string; | ||||
| 
 | ||||
|     // URL of the image
 | ||||
|     image: string; | ||||
| } | ||||
							
								
								
									
										9
									
								
								frontend/src/exception/http-error-response-exception.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								frontend/src/exception/http-error-response-exception.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | |||
| import type { AxiosResponse } from "axios"; | ||||
| 
 | ||||
| export class HttpErrorResponseException extends Error { | ||||
|     public statusCode: number; | ||||
|     constructor(public response: AxiosResponse<unknown, unknown>) { | ||||
|         super((response.data as { message: string })?.message || JSON.stringify(response.data)); | ||||
|         this.statusCode = response.status; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										5
									
								
								frontend/src/exception/invalid-response-exception.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/exception/invalid-response-exception.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| export class InvalidResponseException extends Error { | ||||
|     constructor(message: string) { | ||||
|         super(message); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										5
									
								
								frontend/src/exception/not-found-exception.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/exception/not-found-exception.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| export class NotFoundException extends Error { | ||||
|     constructor(message: string) { | ||||
|         super(message); | ||||
|     } | ||||
| } | ||||
|  | @ -40,18 +40,16 @@ | |||
|         "older": "18 und älter" | ||||
|     }, | ||||
|     "read-more": "Mehr lesen", | ||||
|     "new-assignment": "Neue Aufgabe", | ||||
|     "edit-assignment": "Zuordnung bearbeiten", | ||||
|     "next": "nächste", | ||||
|     "previous": "vorherige", | ||||
|     "groups": "Gruppen", | ||||
|     "learning-path": "Lernpfad", | ||||
|     "choose-lp": "Einen lernpfad auswählen", | ||||
|     "choose-classes": "Klassen wählen", | ||||
|     "create-groups": "Gruppen erstellen", | ||||
|     "title": "Titel", | ||||
|     "pick-class": "Wählen Sie eine klasse", | ||||
|     "choose-students": "Studenten auswählen", | ||||
|     "create-group": "Gruppe erstellen", | ||||
|     "class": "klasse" | ||||
|     "error_title": "Fehler", | ||||
|     "previous": "Zurück", | ||||
|     "next": "Weiter", | ||||
|     "search": "Suchen...", | ||||
|     "yearsAge": "Jahre", | ||||
|     "enterSearchTerm": "Lernpfade suchen", | ||||
|     "enterSearchTermDescription": "Bitte geben Sie einen Suchbegriff ein.", | ||||
|     "noLearningPathsFound": "Nichts gefunden!", | ||||
|     "noLearningPathsFoundDescription": "Es gibt keine Lernpfade, die zu Ihrem Suchbegriff passen.", | ||||
|     "legendNotCompletedYet": "Noch nicht fertig", | ||||
|     "legendCompleted": "Fertig", | ||||
|     "legendTeacherExclusive": "Information für Lehrkräfte" | ||||
| } | ||||
|  |  | |||
|  | @ -2,10 +2,22 @@ | |||
|     "welcome": "Welcome", | ||||
|     "student": "student", | ||||
|     "teacher": "teacher", | ||||
|     "assignments": "Assignments", | ||||
|     "classes": "Classes", | ||||
|     "discussions": "Discussions", | ||||
|     "assignments": "assignments", | ||||
|     "classes": "classes", | ||||
|     "discussions": "discussions", | ||||
|     "logout": "log out", | ||||
|     "error_title": "Error", | ||||
|     "previous": "Previous", | ||||
|     "next": "Next", | ||||
|     "search": "Search...", | ||||
|     "yearsAge": "years", | ||||
|     "enterSearchTerm": "Search learning paths", | ||||
|     "enterSearchTermDescription": "Please enter a search term.", | ||||
|     "noLearningPathsFound": "Nothing found!", | ||||
|     "noLearningPathsFoundDescription": "There are no learning paths matching your search term.", | ||||
|     "legendNotCompletedYet": "Not completed yet", | ||||
|     "legendCompleted": "Completed", | ||||
|     "legendTeacherExclusive": "Information for teachers", | ||||
|     "cancel": "cancel", | ||||
|     "logoutVerification": "Are you sure you want to log out?", | ||||
|     "homeTitle": "Our strengths", | ||||
|  |  | |||
|  | @ -1,5 +1,17 @@ | |||
| { | ||||
|     "welcome": "Bienvenue", | ||||
|     "error_title": "Erreur", | ||||
|     "previous": "Précédente", | ||||
|     "next": "Suivante", | ||||
|     "search": "Réchercher...", | ||||
|     "yearsAge": "ans", | ||||
|     "enterSearchTerm": "Rechercher des parcours d'apprentissage", | ||||
|     "enterSearchTermDescription": "Saisissez un terme de recherche pour commencer.", | ||||
|     "noLearningPathsFound": "Rien trouvé !", | ||||
|     "noLearningPathsFoundDescription": "Aucun parcours d'apprentissage ne correspond à votre recherche.", | ||||
|     "legendNotCompletedYet": "Pas encore achevé", | ||||
|     "legendCompleted": "Achevé", | ||||
|     "legendTeacherExclusive": "Informations pour les enseignants", | ||||
|     "student": "élève", | ||||
|     "teacher": "enseignant", | ||||
|     "assignments": "Travails", | ||||
|  |  | |||
|  | @ -2,10 +2,22 @@ | |||
|     "welcome": "Welkom", | ||||
|     "student": "leerling", | ||||
|     "teacher": "leerkracht", | ||||
|     "assignments": "Opdrachten", | ||||
|     "classes": "Klassen", | ||||
|     "discussions": "Discussies", | ||||
|     "assignments": "opdrachten", | ||||
|     "classes": "klassen", | ||||
|     "discussions": "discussies", | ||||
|     "logout": "log uit", | ||||
|     "error_title": "Fout", | ||||
|     "previous": "Vorige", | ||||
|     "next": "Volgende", | ||||
|     "search": "Zoeken...", | ||||
|     "yearsAge": "jaar", | ||||
|     "enterSearchTerm": "Zoek naar leerpaden", | ||||
|     "enterSearchTermDescription": "Gelieve een zoekterm in te voeren.", | ||||
|     "noLearningPathsFound": "Niets gevonden!", | ||||
|     "noLearningPathsFoundDescription": "Er zijn geen leerpaden die overeenkomen met je zoekterm.", | ||||
|     "legendNotCompletedYet": "Nog niet afgewerkt", | ||||
|     "legendCompleted": "Afgewerkt", | ||||
|     "legendTeacherExclusive": "Informatie voor leerkrachten", | ||||
|     "cancel": "annuleren", | ||||
|     "logoutVerification": "Bent u zeker dat u wilt uitloggen?", | ||||
|     "homeTitle": "Onze sterke punten", | ||||
|  |  | |||
|  | @ -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"; | ||||
| import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; | ||||
| 
 | ||||
| const app = createApp(App); | ||||
|  | @ -24,6 +25,13 @@ document.head.appendChild(link); | |||
| const vuetify = createVuetify({ | ||||
|     components, | ||||
|     directives, | ||||
|     icons: { | ||||
|         defaultSet: "mdi", | ||||
|         aliases, | ||||
|         sets: { | ||||
|             mdi, | ||||
|         }, | ||||
|     }, | ||||
| }); | ||||
| 
 | ||||
| const queryClient = new QueryClient({ | ||||
|  |  | |||
							
								
								
									
										57
									
								
								frontend/src/queries/learning-objects.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								frontend/src/queries/learning-objects.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | |||
| import { type MaybeRefOrGetter, toValue } from "vue"; | ||||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import { useQuery, 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"; | ||||
| 
 | ||||
| const LEARNING_OBJECT_KEY = "learningObject"; | ||||
| const learningObjectController = getLearningObjectController(); | ||||
| 
 | ||||
| export function useLearningObjectMetadataQuery( | ||||
|     hruid: MaybeRefOrGetter<string>, | ||||
|     language: MaybeRefOrGetter<Language>, | ||||
|     version: MaybeRefOrGetter<number>, | ||||
| ): UseQueryReturnType<LearningObject, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_OBJECT_KEY, "metadata", hruid, language, version], | ||||
|         queryFn: async () => { | ||||
|             const [hruidVal, languageVal, versionVal] = [toValue(hruid), toValue(language), toValue(version)]; | ||||
|             return learningObjectController.getMetadata(hruidVal, languageVal, versionVal); | ||||
|         }, | ||||
|         enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useLearningObjectHTMLQuery( | ||||
|     hruid: MaybeRefOrGetter<string>, | ||||
|     language: MaybeRefOrGetter<Language>, | ||||
|     version: MaybeRefOrGetter<number>, | ||||
| ): 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); | ||||
|         }, | ||||
|         enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useLearningObjectListForPathQuery( | ||||
|     learningPath: MaybeRefOrGetter<LearningPath | undefined>, | ||||
| ): UseQueryReturnType<LearningObject[], Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_OBJECT_KEY, "onPath", learningPath], | ||||
|         queryFn: async () => { | ||||
|             const learningObjects: Promise<LearningObject>[] = []; | ||||
|             for (const node of toValue(learningPath)!.nodesAsList) { | ||||
|                 learningObjects.push( | ||||
|                     learningObjectController.getMetadata(node.learningobjectHruid, node.language, node.version), | ||||
|                 ); | ||||
|             } | ||||
|             return Promise.all(learningObjects); | ||||
|         }, | ||||
|         enabled: () => Boolean(toValue(learningPath)), | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										46
									
								
								frontend/src/queries/learning-paths.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								frontend/src/queries/learning-paths.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | |||
| import { type MaybeRefOrGetter, toValue } from "vue"; | ||||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; | ||||
| import { getLearningPathController } from "@/controllers/controllers"; | ||||
| import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| 
 | ||||
| const LEARNING_PATH_KEY = "learningPath"; | ||||
| const learningPathController = getLearningPathController(); | ||||
| 
 | ||||
| export function useGetLearningPathQuery( | ||||
|     hruid: MaybeRefOrGetter<string>, | ||||
|     language: MaybeRefOrGetter<Language>, | ||||
|     options?: MaybeRefOrGetter<{ forGroup?: string; forStudent?: string }>, | ||||
| ): UseQueryReturnType<LearningPath, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_PATH_KEY, "get", hruid, language, options], | ||||
|         queryFn: async () => { | ||||
|             const [hruidVal, languageVal, optionsVal] = [toValue(hruid), toValue(language), toValue(options)]; | ||||
|             return learningPathController.getBy(hruidVal, languageVal, optionsVal); | ||||
|         }, | ||||
|         enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useGetAllLearningPathsByThemeQuery( | ||||
|     theme: MaybeRefOrGetter<string>, | ||||
| ): UseQueryReturnType<LearningPath[], Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_PATH_KEY, "getAllByTheme", theme], | ||||
|         queryFn: async () => learningPathController.getAllByTheme(toValue(theme)), | ||||
|         enabled: () => Boolean(toValue(theme)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useSearchLearningPathQuery( | ||||
|     query: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<LearningPath[], Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_PATH_KEY, "search", query], | ||||
|         queryFn: async () => { | ||||
|             const queryVal = toValue(query)!; | ||||
|             return learningPathController.search(queryVal); | ||||
|         }, | ||||
|         enabled: () => Boolean(toValue(query)), | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										205
									
								
								frontend/src/queries/students.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										205
									
								
								frontend/src/queries/students.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,205 @@ | |||
| import { computed, toValue } from "vue"; | ||||
| import type { MaybeRefOrGetter } from "vue"; | ||||
| import { | ||||
|     useMutation, | ||||
|     type UseMutationReturnType, | ||||
|     useQuery, | ||||
|     useQueryClient, | ||||
|     type UseQueryReturnType, | ||||
| } from "@tanstack/vue-query"; | ||||
| import { | ||||
|     type JoinRequestResponse, | ||||
|     type JoinRequestsResponse, | ||||
|     StudentController, | ||||
|     type StudentResponse, | ||||
|     type StudentsResponse, | ||||
| } from "@/controllers/students.ts"; | ||||
| import type { ClassesResponse } from "@/controllers/classes.ts"; | ||||
| import type { AssignmentsResponse } from "@/controllers/assignments.ts"; | ||||
| import type { GroupsResponse } from "@/controllers/groups.ts"; | ||||
| import type { SubmissionsResponse } from "@/controllers/submissions.ts"; | ||||
| import type { QuestionsResponse } from "@/controllers/questions.ts"; | ||||
| import type { StudentDTO } from "@dwengo-1/interfaces/student"; | ||||
| 
 | ||||
| const studentController = new StudentController(); | ||||
| 
 | ||||
| /** 🔑 Query keys */ | ||||
| function studentsQueryKey(full: boolean): [string, boolean] { | ||||
|     return ["students", full]; | ||||
| } | ||||
| function studentQueryKey(username: string): [string, string] { | ||||
|     return ["student", username]; | ||||
| } | ||||
| function studentClassesQueryKey(username: string, full: boolean): [string, string, boolean] { | ||||
|     return ["student-classes", username, full]; | ||||
| } | ||||
| function studentAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] { | ||||
|     return ["student-assignments", username, full]; | ||||
| } | ||||
| function studentGroupsQueryKeys(username: string, full: boolean): [string, string, boolean] { | ||||
|     return ["student-groups", username, full]; | ||||
| } | ||||
| function studentSubmissionsQueryKey(username: string): [string, string] { | ||||
|     return ["student-submissions", username]; | ||||
| } | ||||
| function studentQuestionsQueryKey(username: string, full: boolean): [string, string, boolean] { | ||||
|     return ["student-questions", username, full]; | ||||
| } | ||||
| export function studentJoinRequestsQueryKey(username: string): [string, string] { | ||||
|     return ["student-join-requests", username]; | ||||
| } | ||||
| export function studentJoinRequestQueryKey(username: string, classId: string): [string, string, string] { | ||||
|     return ["student-join-request", username, classId]; | ||||
| } | ||||
| 
 | ||||
| export function useStudentsQuery(full: MaybeRefOrGetter<boolean> = true): UseQueryReturnType<StudentsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => studentsQueryKey(toValue(full))), | ||||
|         queryFn: async () => studentController.getAll(toValue(full)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useStudentQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<StudentResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => studentQueryKey(toValue(username)!)), | ||||
|         queryFn: async () => studentController.getByUsername(toValue(username)!), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useStudentClassesQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<ClassesResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => studentClassesQueryKey(toValue(username)!, toValue(full))), | ||||
|         queryFn: async () => studentController.getClasses(toValue(username)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useStudentAssignmentsQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<AssignmentsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => studentAssignmentsQueryKey(toValue(username)!, toValue(full))), | ||||
|         queryFn: async () => studentController.getAssignments(toValue(username)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useStudentGroupsQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<GroupsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => studentGroupsQueryKeys(toValue(username)!, toValue(full))), | ||||
|         queryFn: async () => studentController.getGroups(toValue(username)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useStudentSubmissionsQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<SubmissionsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => studentSubmissionsQueryKey(toValue(username)!)), | ||||
|         queryFn: async () => studentController.getSubmissions(toValue(username)!), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useStudentQuestionsQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<QuestionsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => studentQuestionsQueryKey(toValue(username)!, toValue(full))), | ||||
|         queryFn: async () => studentController.getQuestions(toValue(username)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useStudentJoinRequestsQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<JoinRequestsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => studentJoinRequestsQueryKey(toValue(username)!)), | ||||
|         queryFn: async () => studentController.getJoinRequests(toValue(username)!), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useStudentJoinRequestQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
|     classId: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<JoinRequestResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => studentJoinRequestQueryKey(toValue(username)!, toValue(classId)!)), | ||||
|         queryFn: async () => studentController.getJoinRequest(toValue(username)!, toValue(classId)!), | ||||
|         enabled: () => Boolean(toValue(username)) && Boolean(toValue(classId)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useCreateStudentMutation(): UseMutationReturnType<StudentResponse, Error, StudentDTO, unknown> { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (data) => studentController.createStudent(data), | ||||
|         onSuccess: async () => { | ||||
|             await queryClient.invalidateQueries({ queryKey: ["students"] }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useDeleteStudentMutation(): UseMutationReturnType<StudentResponse, Error, string, unknown> { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (username) => studentController.deleteStudent(username), | ||||
|         onSuccess: async (deletedUser) => { | ||||
|             await queryClient.invalidateQueries({ queryKey: ["students"] }); | ||||
|             await queryClient.invalidateQueries({ queryKey: studentQueryKey(deletedUser.student.username) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useCreateJoinRequestMutation(): UseMutationReturnType< | ||||
|     JoinRequestResponse, | ||||
|     Error, | ||||
|     { username: string; classId: string }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ username, classId }) => studentController.createJoinRequest(username, classId), | ||||
|         onSuccess: async (newJoinRequest) => { | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: studentJoinRequestsQueryKey(newJoinRequest.request.requester), | ||||
|             }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useDeleteJoinRequestMutation(): UseMutationReturnType< | ||||
|     JoinRequestResponse, | ||||
|     Error, | ||||
|     { username: string; classId: string }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ username, classId }) => studentController.deleteJoinRequest(username, classId), | ||||
|         onSuccess: async (deletedJoinRequest) => { | ||||
|             const username = deletedJoinRequest.request.requester; | ||||
|             const classId = deletedJoinRequest.request.class; | ||||
|             await queryClient.invalidateQueries({ queryKey: studentJoinRequestsQueryKey(username) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: studentJoinRequestQueryKey(username, classId) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										136
									
								
								frontend/src/queries/teachers.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								frontend/src/queries/teachers.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,136 @@ | |||
| import { computed, toValue } from "vue"; | ||||
| import type { MaybeRefOrGetter } from "vue"; | ||||
| import { useMutation, useQuery, useQueryClient, UseMutationReturnType, UseQueryReturnType } from "@tanstack/vue-query"; | ||||
| import { TeacherController, type TeacherResponse, type TeachersResponse } from "@/controllers/teachers.ts"; | ||||
| import type { ClassesResponse } from "@/controllers/classes.ts"; | ||||
| import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; | ||||
| import type { QuestionsResponse } from "@/controllers/questions.ts"; | ||||
| import type { TeacherDTO } from "@dwengo-1/interfaces/teacher"; | ||||
| import { studentJoinRequestQueryKey, studentJoinRequestsQueryKey } from "@/queries/students.ts"; | ||||
| 
 | ||||
| const teacherController = new TeacherController(); | ||||
| 
 | ||||
| /** 🔑 Query keys */ | ||||
| function teachersQueryKey(full: boolean): [string, boolean] { | ||||
|     return ["teachers", full]; | ||||
| } | ||||
| 
 | ||||
| function teacherQueryKey(username: string): [string, string] { | ||||
|     return ["teacher", username]; | ||||
| } | ||||
| 
 | ||||
| function teacherClassesQueryKey(username: string, full: boolean): [string, string, boolean] { | ||||
|     return ["teacher-classes", username, full]; | ||||
| } | ||||
| 
 | ||||
| function teacherStudentsQueryKey(username: string, full: boolean): [string, string, boolean] { | ||||
|     return ["teacher-students", username, full]; | ||||
| } | ||||
| 
 | ||||
| function teacherQuestionsQueryKey(username: string, full: boolean): [string, string, boolean] { | ||||
|     return ["teacher-questions", username, full]; | ||||
| } | ||||
| 
 | ||||
| export function useTeachersQuery(full: MaybeRefOrGetter<boolean> = false): UseQueryReturnType<TeachersResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => teachersQueryKey(toValue(full))), | ||||
|         queryFn: async () => teacherController.getAll(toValue(full)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useTeacherQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<TeacherResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => teacherQueryKey(toValue(username)!)), | ||||
|         queryFn: async () => teacherController.getByUsername(toValue(username)!), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useTeacherClassesQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = false, | ||||
| ): UseQueryReturnType<ClassesResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => teacherClassesQueryKey(toValue(username)!, toValue(full))), | ||||
|         queryFn: async () => teacherController.getClasses(toValue(username)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useTeacherStudentsQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = false, | ||||
| ): UseQueryReturnType<StudentsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => teacherStudentsQueryKey(toValue(username)!, toValue(full))), | ||||
|         queryFn: async () => teacherController.getStudents(toValue(username)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useTeacherQuestionsQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = false, | ||||
| ): UseQueryReturnType<QuestionsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => teacherQuestionsQueryKey(toValue(username)!, toValue(full))), | ||||
|         queryFn: async () => teacherController.getQuestions(toValue(username)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useTeacherJoinRequestsQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
|     classId: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<JoinRequestsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => JOIN_REQUESTS_QUERY_KEY(toValue(username)!, toValue(classId)!)), | ||||
|         queryFn: async () => teacherController.getStudentJoinRequests(toValue(username)!, toValue(classId)!), | ||||
|         enabled: () => Boolean(toValue(username)) && Boolean(toValue(classId)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useCreateTeacherMutation(): UseMutationReturnType<TeacherResponse, Error, TeacherDTO, unknown> { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (data: TeacherDTO) => teacherController.createTeacher(data), | ||||
|         onSuccess: async () => { | ||||
|             await queryClient.invalidateQueries({ queryKey: ["teachers"] }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useDeleteTeacherMutation(): UseMutationReturnType<TeacherResponse, Error, string, unknown> { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (username: string) => teacherController.deleteTeacher(username), | ||||
|         onSuccess: async (deletedTeacher) => { | ||||
|             await queryClient.invalidateQueries({ queryKey: ["teachers"] }); | ||||
|             await queryClient.invalidateQueries({ queryKey: teacherQueryKey(deletedTeacher.teacher.username) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useUpdateJoinRequestMutation(): UseMutationReturnType< | ||||
|     JoinRequestResponse, | ||||
|     Error, | ||||
|     { teacherUsername: string; classId: string; studentUsername: string; accepted: boolean }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ teacherUsername, classId, studentUsername, accepted }) => | ||||
|             teacherController.updateStudentJoinRequest(teacherUsername, classId, studentUsername, accepted), | ||||
|         onSuccess: async (deletedJoinRequest) => { | ||||
|             const username = deletedJoinRequest.request.requester; | ||||
|             const classId = deletedJoinRequest.request.class; | ||||
|             await queryClient.invalidateQueries({ queryKey: studentJoinRequestsQueryKey(username) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: studentJoinRequestQueryKey(username, classId) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
|  | @ -1,22 +1,27 @@ | |||
| import { useQuery } from "@tanstack/vue-query"; | ||||
| import { getThemeController } from "@/controllers/controllers"; | ||||
| import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; | ||||
| import { type MaybeRefOrGetter, toValue } from "vue"; | ||||
| import type { Theme } from "@dwengo-1/interfaces/theme"; | ||||
| import { getThemeController } from "@/controllers/controllers.ts"; | ||||
| 
 | ||||
| const themeController = getThemeController(); | ||||
| 
 | ||||
| export const useThemeQuery = (language: MaybeRefOrGetter<string>) => | ||||
|     useQuery({ | ||||
| export function useThemeQuery(language: MaybeRefOrGetter<string | undefined>): UseQueryReturnType<Theme[], Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: ["themes", language], | ||||
|         queryFn: () => { | ||||
|         queryFn: async () => { | ||||
|             const lang = toValue(language); | ||||
|             return themeController.getAll(lang); | ||||
|         }, | ||||
|         enabled: () => Boolean(toValue(language)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export const useThemeHruidsQuery = (themeKey: string | null) => | ||||
|     useQuery({ | ||||
| export function useThemeHruidsQuery( | ||||
|     themeKey: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<string[], Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: ["theme-hruids", themeKey], | ||||
|         queryFn: () => themeController.getHruidsByKey(themeKey!), | ||||
|         queryFn: async () => themeController.getHruidsByKey(toValue(themeKey)!), | ||||
|         enabled: Boolean(themeKey), | ||||
|     }); | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,4 @@ | |||
| import {createRouter, createWebHistory} from "vue-router"; | ||||
| import MenuBar from "@/components/MenuBar.vue"; | ||||
| import { createRouter, createWebHistory } from "vue-router"; | ||||
| import SingleAssignment from "@/views/assignments/SingleAssignment.vue"; | ||||
| import SingleClass from "@/views/classes/SingleClass.vue"; | ||||
| import SingleDiscussion from "@/views/discussions/SingleDiscussion.vue"; | ||||
|  | @ -10,10 +9,13 @@ import CreateDiscussion from "@/views/discussions/CreateDiscussion.vue"; | |||
| import CallbackPage from "@/views/CallbackPage.vue"; | ||||
| import UserDiscussions from "@/views/discussions/UserDiscussions.vue"; | ||||
| import UserClasses from "@/views/classes/UserClasses.vue"; | ||||
| import UserAssignments from "@/views/assignments/UserAssignments.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 LearningPathSearchPage from "@/views/learning-paths/LearningPathSearchPage.vue"; | ||||
| import UserHomePage from "@/views/homepage/UserHomePage.vue"; | ||||
| import SingleTheme from "@/views/SingleTheme.vue"; | ||||
| import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; | ||||
| 
 | ||||
| const router = createRouter({ | ||||
|     history: createWebHistory(import.meta.env.BASE_URL), | ||||
|  | @ -21,25 +23,24 @@ const router = createRouter({ | |||
|         { | ||||
|             path: "/", | ||||
|             name: "home", | ||||
|             component: () => import("../views/HomePage.vue"), | ||||
|             meta: {requiresAuth: false}, | ||||
|             component: async (): Promise<unknown> => import("../views/HomePage.vue"), | ||||
|             meta: { requiresAuth: false }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/login", | ||||
|             name: "LoginPage", | ||||
|             component: () => import("../views/LoginPage.vue"), | ||||
|             meta: {requiresAuth: false}, | ||||
|             component: async (): Promise<unknown> => import("../views/LoginPage.vue"), | ||||
|             meta: { requiresAuth: false }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/callback", | ||||
|             component: CallbackPage, | ||||
|             meta: {requiresAuth: false}, | ||||
|             meta: { requiresAuth: false }, | ||||
|         }, | ||||
| 
 | ||||
|         { | ||||
|             path: "/user", | ||||
|             component: MenuBar, | ||||
|             meta: {requiresAuth: true}, | ||||
|             meta: { requiresAuth: true }, | ||||
|             children: [ | ||||
|                 { | ||||
|                     path: "", | ||||
|  | @ -65,63 +66,83 @@ const router = createRouter({ | |||
|         }, | ||||
| 
 | ||||
|         { | ||||
|             path: "/theme/:id", | ||||
|             path: "/theme/:theme", | ||||
|             name: "Theme", | ||||
|             component: SingleTheme, | ||||
|             meta: {requiresAuth: true}, | ||||
|             props: true, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/assignment", | ||||
|             component: MenuBar, | ||||
|             meta: {requiresAuth: true}, | ||||
|             children: [ | ||||
|                 { | ||||
|                     path: "create", | ||||
|                     name: "CreateAssigment", | ||||
|                     component: CreateAssignment, | ||||
|                 }, | ||||
|                 { | ||||
|                     path: ":id", | ||||
|                     name: "SingleAssigment", | ||||
|                     component: SingleAssignment, | ||||
|                 }, | ||||
|             ] | ||||
|             path: "/assignment/create", | ||||
|             name: "CreateAssigment", | ||||
|             component: CreateAssignment, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/assignment/:id", | ||||
|             name: "SingleAssigment", | ||||
|             component: SingleAssignment, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
| 
 | ||||
|         { | ||||
|             path: "/class/create", | ||||
|             name: "CreateClass", | ||||
|             component: CreateClass, | ||||
|             meta: {requiresAuth: true}, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/class/:id", | ||||
|             name: "SingleClass", | ||||
|             component: SingleClass, | ||||
|             meta: {requiresAuth: true}, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/discussion/create", | ||||
|             name: "CreateDiscussion", | ||||
|             component: CreateDiscussion, | ||||
|             meta: {requiresAuth: true}, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/discussion/:id", | ||||
|             name: "SingleDiscussion", | ||||
|             component: SingleDiscussion, | ||||
|             meta: {requiresAuth: true}, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/learningPath", | ||||
|             children: [ | ||||
|                 { | ||||
|                     path: "search", | ||||
|                     name: "LearningPathSearchPage", | ||||
|                     component: LearningPathSearchPage, | ||||
|                     meta: { requiresAuth: true }, | ||||
|                 }, | ||||
|                 { | ||||
|                     path: ":hruid/:language/:learningObjectHruid", | ||||
|                     name: "LearningPath", | ||||
|                     component: LearningPathPage, | ||||
|                     props: true, | ||||
|                     meta: { requiresAuth: true }, | ||||
|                 }, | ||||
|             ], | ||||
|         }, | ||||
|         { | ||||
|             path: "/learningObject/:hruid/:language/:version/raw", | ||||
|             name: "LearningObjectView", | ||||
|             component: LearningObjectView, | ||||
|             props: true, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/:catchAll(.*)", | ||||
|             name: "NotFound", | ||||
|             component: NotFound, | ||||
|             meta: {requiresAuth: false}, | ||||
|             meta: { requiresAuth: false }, | ||||
|         }, | ||||
|     ], | ||||
| }); | ||||
| 
 | ||||
| router.beforeEach(async (to, from, next) => { | ||||
| router.beforeEach(async (to, _from, next) => { | ||||
|     // Verify if user is logged in before accessing certain routes
 | ||||
|     if (to.meta.requiresAuth) { | ||||
|         if (!authState.isLoggedIn.value) { | ||||
|  |  | |||
|  | @ -1,12 +1,13 @@ | |||
| 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"; | ||||
| import type { UserManagerSettings } from "oidc-client-ts"; | ||||
| 
 | ||||
| export const AUTH_CONFIG_ENDPOINT = "auth/config"; | ||||
| 
 | ||||
| /** | ||||
|  * Fetch the authentication configuration from the backend. | ||||
|  */ | ||||
| export async function loadAuthConfig() { | ||||
| export async function loadAuthConfig(): Promise<Record<string, UserManagerSettings>> { | ||||
|     const authConfigResponse = await apiClient.get<FrontendAuthConfig>(AUTH_CONFIG_ENDPOINT); | ||||
|     const authConfig = authConfigResponse.data; | ||||
|     return { | ||||
|  |  | |||
|  | @ -8,7 +8,7 @@ import { User, UserManager } from "oidc-client-ts"; | |||
| import { AUTH_CONFIG_ENDPOINT, 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"; | ||||
| 
 | ||||
|  | @ -49,7 +49,7 @@ const isLoggedIn = computed(() => authState.user !== null); | |||
| /** | ||||
|  * Redirect the user to the login page where he/she can choose whether to log in as a student or teacher. | ||||
|  */ | ||||
| async function initiateLogin() { | ||||
| async function initiateLogin(): Promise<void> { | ||||
|     await router.push(loginRoute); | ||||
| } | ||||
| 
 | ||||
|  | @ -77,20 +77,20 @@ async function handleLoginCallback(): Promise<void> { | |||
| /** | ||||
|  * Refresh an expired authorization token. | ||||
|  */ | ||||
| async function renewToken() { | ||||
| async function renewToken(): Promise<User | null> { | ||||
|     const activeRole = authStorage.getActiveRole(); | ||||
|     if (!activeRole) { | ||||
|         console.log("Can't renew the token: Not logged in!"); | ||||
|         // FIXME console.log("Can't renew the token: Not logged in!");
 | ||||
|         await initiateLogin(); | ||||
|         return; | ||||
|         return null; | ||||
|     } | ||||
|     try { | ||||
|         return await (await getUserManagers())[activeRole].signinSilent(); | ||||
|     } catch (error) { | ||||
|         console.log("Can't renew the token:"); | ||||
|         console.log(error); | ||||
|     } catch (_error) { | ||||
|         // FIXME console.log("Can't renew the token: " + error);
 | ||||
|         await initiateLogin(); | ||||
|     } | ||||
|     return null; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | @ -113,7 +113,7 @@ apiClient.interceptors.request.use( | |||
|         } | ||||
|         return reqConfig; | ||||
|     }, | ||||
|     (error) => Promise.reject(error), | ||||
|     async (error) => Promise.reject(error), | ||||
| ); | ||||
| 
 | ||||
| // Registering interceptor to refresh the token when a request failed because it was expired.
 | ||||
|  | @ -121,8 +121,8 @@ apiClient.interceptors.response.use( | |||
|     (response) => response, | ||||
|     async (error: AxiosError<{ message?: string }>) => { | ||||
|         if (error.response?.status === 401) { | ||||
|             if (error.response!.data.message === "token_expired") { | ||||
|                 console.log("Access token expired, trying to refresh..."); | ||||
|             if (error.response.data.message === "token_expired") { | ||||
|                 // FIXME console.log("Access token expired, trying to refresh...");
 | ||||
|                 await renewToken(); | ||||
|                 return apiClient(error.config!); // Retry the request
 | ||||
|             } // Apparently, the user got a 401 because he was not logged in yet at all. Redirect him to login.
 | ||||
|  |  | |||
|  | @ -12,7 +12,7 @@ export default { | |||
|      * Set the role the user is currently logged in as from the local persistent storage. | ||||
|      * This should happen when the user logs in with another account. | ||||
|      */ | ||||
|     setActiveRole(role: Role) { | ||||
|     setActiveRole(role: Role): void { | ||||
|         localStorage.setItem("activeRole", role); | ||||
|     }, | ||||
| 
 | ||||
|  | @ -20,7 +20,7 @@ export default { | |||
|      * Remove the saved current role from the local persistent storage. | ||||
|      * This should happen when the user is logged out. | ||||
|      */ | ||||
|     deleteActiveRole() { | ||||
|     deleteActiveRole(): void { | ||||
|         localStorage.removeItem("activeRole"); | ||||
|     }, | ||||
| }; | ||||
|  |  | |||
							
								
								
									
										17
									
								
								frontend/src/services/auth/auth.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										17
									
								
								frontend/src/services/auth/auth.d.ts
									
										
									
									
										vendored
									
									
								
							|  | @ -1,22 +1,25 @@ | |||
| import { type User, UserManager } from "oidc-client-ts"; | ||||
| 
 | ||||
| export type AuthState = { | ||||
| export interface AuthState { | ||||
|     user: User | null; | ||||
|     accessToken: string | null; | ||||
|     activeRole: Role | null; | ||||
| }; | ||||
| } | ||||
| 
 | ||||
| export type FrontendAuthConfig = { | ||||
| export interface FrontendAuthConfig { | ||||
|     student: FrontendIdpConfig; | ||||
|     teacher: FrontendIdpConfig; | ||||
| }; | ||||
| } | ||||
| 
 | ||||
| export type FrontendIdpConfig = { | ||||
| export interface FrontendIdpConfig { | ||||
|     authority: string; | ||||
|     clientId: string; | ||||
|     scope: string; | ||||
|     responseType: string; | ||||
| }; | ||||
| } | ||||
| 
 | ||||
| export type Role = "student" | "teacher"; | ||||
| export type UserManagersForRoles = { student: UserManager; teacher: UserManager }; | ||||
| export interface UserManagersForRoles { | ||||
|     student: UserManager; | ||||
|     teacher: UserManager; | ||||
| } | ||||
|  |  | |||
							
								
								
									
										14
									
								
								frontend/src/utils/response-assertions.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								frontend/src/utils/response-assertions.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| import { NotFoundException } from "@/exception/not-found-exception.ts"; | ||||
| import { InvalidResponseException } from "@/exception/invalid-response-exception.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,22 +1,25 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useRouter } from "vue-router"; | ||||
|     import { onMounted } from "vue"; | ||||
|     import { onMounted, ref, type Ref } from "vue"; | ||||
|     import auth from "../services/auth/auth-service.ts"; | ||||
| 
 | ||||
|     const router = useRouter(); | ||||
| 
 | ||||
|     const errorMessage: Ref<string | null> = ref(null); | ||||
| 
 | ||||
|     onMounted(async () => { | ||||
|         try { | ||||
|             await auth.handleLoginCallback(); | ||||
|             await router.replace("/user"); // Redirect to theme page | ||||
|         } catch (error) { | ||||
|             console.error("OIDC callback error:", error); | ||||
|             errorMessage.value = `OIDC callback error: ${error}`; | ||||
|         } | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <p>Logging you in...</p> | ||||
|     <p v-if="!errorMessage">Logging you in...</p> | ||||
|     <p v-else>{{ errorMessage }}</p> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
|  |  | |||
|  | @ -13,10 +13,10 @@ | |||
|     ]); | ||||
| 
 | ||||
|     // Logic to change the language of the website to the selected language | ||||
|     const changeLanguage = (langCode: string) => { | ||||
|     function changeLanguage(langCode: string): void { | ||||
|         locale.value = langCode; | ||||
|         localStorage.setItem("user-lang", langCode); | ||||
|     }; | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
|  | @ -2,16 +2,16 @@ | |||
|     import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; | ||||
|     import auth from "@/services/auth/auth-service.ts"; | ||||
| 
 | ||||
|     function loginAsStudent() { | ||||
|         auth.loginAs("student"); | ||||
|     async function loginAsStudent(): Promise<void> { | ||||
|         await auth.loginAs("student"); | ||||
|     } | ||||
| 
 | ||||
|     function loginAsTeacher() { | ||||
|         auth.loginAs("teacher"); | ||||
|     async function loginAsTeacher(): Promise<void> { | ||||
|         await auth.loginAs("teacher"); | ||||
|     } | ||||
| 
 | ||||
|     function performLogout() { | ||||
|         auth.logout(); | ||||
|     async function performLogout(): Promise<void> { | ||||
|         await auth.logout(); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,7 +1,68 @@ | |||
| <script setup lang="ts"></script> | ||||
| <script setup lang="ts"> | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
|     import LearningPathsGrid from "@/components/LearningPathsGrid.vue"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { useGetAllLearningPathsByThemeQuery } from "@/queries/learning-paths.ts"; | ||||
|     import { computed, ref } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useThemeQuery } from "@/queries/themes.ts"; | ||||
| 
 | ||||
|     const props = defineProps<{ theme: string }>(); | ||||
| 
 | ||||
|     const { locale } = useI18n(); | ||||
|     const language = computed(() => locale.value); | ||||
| 
 | ||||
|     const themeQueryResult = useThemeQuery(language); | ||||
| 
 | ||||
|     const currentThemeInfo = computed(() => themeQueryResult.data.value?.find((it) => it.key === props.theme)); | ||||
| 
 | ||||
|     const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeQuery(() => props.theme); | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
|     const searchFilter = ref(""); | ||||
| 
 | ||||
|     function filterLearningPaths(learningPaths: LearningPath[]): LearningPath[] { | ||||
|         return learningPaths.filter( | ||||
|             (it) => | ||||
|                 it.title.toLowerCase().includes(searchFilter.value.toLowerCase()) || | ||||
|                 it.description.toLowerCase().includes(searchFilter.value.toLowerCase()), | ||||
|         ); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <main></main> | ||||
|     <div class="container"> | ||||
|         <using-query-result :query-result="themeQueryResult"> | ||||
|             <h1>{{ currentThemeInfo!!.title }}</h1> | ||||
|             <p>{{ currentThemeInfo!!.description }}</p> | ||||
|             <div class="search-field-container"> | ||||
|                 <v-text-field | ||||
|                     class="search-field" | ||||
|                     :label="t('search')" | ||||
|                     append-inner-icon="mdi-magnify" | ||||
|                     v-model="searchFilter" | ||||
|                 ></v-text-field> | ||||
|             </div> | ||||
| 
 | ||||
|             <using-query-result | ||||
|                 :query-result="learningPathsForThemeQueryResult" | ||||
|                 v-slot="{ data }: { data: LearningPath[] }" | ||||
|             > | ||||
|                 <learning-paths-grid :learning-paths="filterLearningPaths(data)"></learning-paths-grid> | ||||
|             </using-query-result> | ||||
|         </using-query-result> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
| <style scoped> | ||||
|     .search-field-container { | ||||
|         display: block; | ||||
|         margin: 20px; | ||||
|     } | ||||
|     .search-field { | ||||
|         max-width: 300px; | ||||
|     } | ||||
|     .container { | ||||
|         padding: 20px; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
							
								
								
									
										51
									
								
								frontend/src/views/learning-paths/LearningObjectView.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								frontend/src/views/learning-paths/LearningObjectView.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | |||
| <script setup lang="ts"> | ||||
|     import { Language } from "@/data-objects/language.ts"; | ||||
|     import type { UseQueryReturnType } from "@tanstack/vue-query"; | ||||
|     import { useLearningObjectHTMLQuery } from "@/queries/learning-objects.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
| 
 | ||||
|     const props = defineProps<{ hruid: string; language: Language; version: number }>(); | ||||
| 
 | ||||
|     const learningObjectHtmlQueryResult: UseQueryReturnType<Document, Error> = useLearningObjectHTMLQuery( | ||||
|         () => props.hruid, | ||||
|         () => props.language, | ||||
|         () => props.version, | ||||
|     ); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <using-query-result | ||||
|         :query-result="learningObjectHtmlQueryResult as UseQueryReturnType<Document, Error>" | ||||
|         v-slot="learningPathHtml: { data: Document }" | ||||
|     > | ||||
|         <div | ||||
|             class="learning-object-container" | ||||
|             v-html="learningPathHtml.data.body.innerHTML" | ||||
|         ></div> | ||||
|     </using-query-result> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .learning-object-container { | ||||
|         padding: 20px; | ||||
|     } | ||||
|     :deep(hr) { | ||||
|         margin-top: 10px; | ||||
|         margin-bottom: 10px; | ||||
|     } | ||||
|     :deep(li) { | ||||
|         margin-left: 30px; | ||||
|         margin-top: 5px; | ||||
|         margin-bottom: 5px; | ||||
|     } | ||||
|     :deep(img) { | ||||
|         max-width: 80%; | ||||
|     } | ||||
|     :deep(h2), | ||||
|     :deep(h3), | ||||
|     :deep(h4), | ||||
|     :deep(h5), | ||||
|     :deep(h6) { | ||||
|         margin-top: 10px; | ||||
|     } | ||||
| </style> | ||||
							
								
								
									
										229
									
								
								frontend/src/views/learning-paths/LearningPathPage.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								frontend/src/views/learning-paths/LearningPathPage.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,229 @@ | |||
| <script setup lang="ts"> | ||||
|     import { Language } from "@/data-objects/language.ts"; | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
|     import { computed, type ComputedRef, ref } from "vue"; | ||||
|     import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; | ||||
|     import { useRoute } from "vue-router"; | ||||
|     import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import LearningPathSearchField from "@/components/LearningPathSearchField.vue"; | ||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import { useLearningObjectListForPathQuery } from "@/queries/learning-objects.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import authService from "@/services/auth/auth-service.ts"; | ||||
|     import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; | ||||
| 
 | ||||
|     const route = useRoute(); | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const props = defineProps<{ hruid: string; language: Language; learningObjectHruid?: string }>(); | ||||
| 
 | ||||
|     interface Personalization { | ||||
|         forStudent?: string; | ||||
|         forGroup?: string; | ||||
|     } | ||||
| 
 | ||||
|     const personalization = computed(() => { | ||||
|         if (route.query.forStudent || route.query.forGroup) { | ||||
|             return { | ||||
|                 forStudent: route.query.forStudent, | ||||
|                 forGroup: route.query.forGroup, | ||||
|             } as Personalization; | ||||
|         } | ||||
|         return { | ||||
|             forStudent: authService.authState.user?.profile?.preferred_username, | ||||
|         } as Personalization; | ||||
|     }); | ||||
| 
 | ||||
|     const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, personalization); | ||||
| 
 | ||||
|     const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data); | ||||
| 
 | ||||
|     const nodesList: ComputedRef<LearningPathNode[] | null> = computed( | ||||
|         () => learningPathQueryResult.data.value?.nodesAsList ?? null, | ||||
|     ); | ||||
| 
 | ||||
|     const currentNode = computed(() => { | ||||
|         const currentHruid = props.learningObjectHruid; | ||||
|         return nodesList.value?.find((it) => it.learningobjectHruid === currentHruid); | ||||
|     }); | ||||
| 
 | ||||
|     const nextNode = computed(() => { | ||||
|         if (!currentNode.value || !nodesList.value) return undefined; | ||||
|         const currentIndex = nodesList.value?.indexOf(currentNode.value); | ||||
|         return currentIndex < nodesList.value?.length ? nodesList.value?.[currentIndex + 1] : undefined; | ||||
|     }); | ||||
| 
 | ||||
|     const previousNode = computed(() => { | ||||
|         if (!currentNode.value || !nodesList.value) return undefined; | ||||
|         const currentIndex = nodesList.value?.indexOf(currentNode.value); | ||||
|         return currentIndex < nodesList.value?.length ? nodesList.value?.[currentIndex - 1] : undefined; | ||||
|     }); | ||||
| 
 | ||||
|     const navigationDrawerShown = ref(true); | ||||
| 
 | ||||
|     function isLearningObjectCompleted(learningObject: LearningObject): boolean { | ||||
|         if (learningObjectListQueryResult.isSuccess) { | ||||
|             return ( | ||||
|                 learningPathQueryResult.data.value?.nodesAsList?.find( | ||||
|                     (it) => | ||||
|                         it.learningobjectHruid === learningObject.key && | ||||
|                         it.version === learningObject.version && | ||||
|                         it.language === learningObject.language, | ||||
|                 )?.done ?? false | ||||
|             ); | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     type NavItemState = "teacherExclusive" | "completed" | "notCompleted"; | ||||
| 
 | ||||
|     const ICONS: Record<NavItemState, string> = { | ||||
|         teacherExclusive: "mdi-information", | ||||
|         completed: "mdi-checkbox-marked-circle-outline", | ||||
|         notCompleted: "mdi-checkbox-blank-circle-outline", | ||||
|     }; | ||||
| 
 | ||||
|     const COLORS: Record<NavItemState, string | undefined> = { | ||||
|         teacherExclusive: "info", | ||||
|         completed: "success", | ||||
|         notCompleted: undefined, | ||||
|     }; | ||||
| 
 | ||||
|     function getNavItemState(learningObject: LearningObject): NavItemState { | ||||
|         if (learningObject.teacherExclusive) { | ||||
|             return "teacherExclusive"; | ||||
|         } else if (isLearningObjectCompleted(learningObject)) { | ||||
|             return "completed"; | ||||
|         } | ||||
|         return "notCompleted"; | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <using-query-result | ||||
|         :query-result="learningPathQueryResult" | ||||
|         v-slot="learningPath: { data: LearningPath }" | ||||
|     > | ||||
|         <v-navigation-drawer | ||||
|             v-model="navigationDrawerShown" | ||||
|             :width="350" | ||||
|         > | ||||
|             <v-list-item> | ||||
|                 <template v-slot:title> | ||||
|                     <div class="learning-path-title">{{ learningPath.data.title }}</div> | ||||
|                 </template> | ||||
|                 <template v-slot:subtitle> | ||||
|                     <div>{{ learningPath.data.description }}</div> | ||||
|                 </template> | ||||
|             </v-list-item> | ||||
|             <v-list-item> | ||||
|                 <template v-slot:subtitle> | ||||
|                     <p> | ||||
|                         <v-icon | ||||
|                             :color="COLORS.notCompleted" | ||||
|                             :icon="ICONS.notCompleted" | ||||
|                         ></v-icon> | ||||
|                         {{ t("legendNotCompletedYet") }} | ||||
|                     </p> | ||||
|                     <p> | ||||
|                         <v-icon | ||||
|                             :color="COLORS.completed" | ||||
|                             :icon="ICONS.completed" | ||||
|                         ></v-icon> | ||||
|                         {{ t("legendCompleted") }} | ||||
|                     </p> | ||||
|                     <p> | ||||
|                         <v-icon | ||||
|                             :color="COLORS.teacherExclusive" | ||||
|                             :icon="ICONS.teacherExclusive" | ||||
|                         ></v-icon> | ||||
|                         {{ t("legendTeacherExclusive") }} | ||||
|                     </p> | ||||
|                 </template> | ||||
|             </v-list-item> | ||||
|             <v-divider></v-divider> | ||||
|             <div v-if="props.learningObjectHruid"> | ||||
|                 <using-query-result | ||||
|                     :query-result="learningObjectListQueryResult" | ||||
|                     v-slot="learningObjects: { data: LearningObject[] }" | ||||
|                 > | ||||
|                     <template v-for="node in learningObjects.data"> | ||||
|                         <v-list-item | ||||
|                             link | ||||
|                             :to="{ path: node.key, query: route.query }" | ||||
|                             :title="node.title" | ||||
|                             :active="node.key === props.learningObjectHruid" | ||||
|                             :key="node.key" | ||||
|                             v-if="!node.teacherExclusive || authService.authState.activeRole === 'teacher'" | ||||
|                         > | ||||
|                             <template v-slot:prepend> | ||||
|                                 <v-icon | ||||
|                                     :color="COLORS[getNavItemState(node)]" | ||||
|                                     :icon="ICONS[getNavItemState(node)]" | ||||
|                                 ></v-icon> | ||||
|                             </template> | ||||
|                             <template v-slot:append> {{ node.estimatedTime }}' </template> | ||||
|                         </v-list-item> | ||||
|                     </template> | ||||
|                 </using-query-result> | ||||
|             </div> | ||||
|         </v-navigation-drawer> | ||||
|         <div class="control-bar-above-content"> | ||||
|             <v-btn | ||||
|                 :icon="navigationDrawerShown ? 'mdi-menu-open' : 'mdi-menu'" | ||||
|                 class="navigation-drawer-toggle-button" | ||||
|                 variant="plain" | ||||
|                 @click="navigationDrawerShown = !navigationDrawerShown" | ||||
|             ></v-btn> | ||||
|             <div class="search-field-container"> | ||||
|                 <learning-path-search-field></learning-path-search-field> | ||||
|             </div> | ||||
|         </div> | ||||
|         <learning-object-view | ||||
|             :hruid="currentNode.learningobjectHruid" | ||||
|             :language="currentNode.language" | ||||
|             :version="currentNode.version" | ||||
|             v-if="currentNode" | ||||
|         ></learning-object-view> | ||||
|         <div class="navigation-buttons-container"> | ||||
|             <v-btn | ||||
|                 prepend-icon="mdi-chevron-left" | ||||
|                 variant="text" | ||||
|                 :disabled="!previousNode" | ||||
|                 :to="previousNode ? { path: previousNode.learningobjectHruid, query: route.query } : undefined" | ||||
|             > | ||||
|                 {{ t("previous") }} | ||||
|             </v-btn> | ||||
|             <v-btn | ||||
|                 append-icon="mdi-chevron-right" | ||||
|                 variant="text" | ||||
|                 :disabled="!nextNode" | ||||
|                 :to="nextNode ? { path: nextNode.learningobjectHruid, query: route.query } : undefined" | ||||
|             > | ||||
|                 {{ t("next") }} | ||||
|             </v-btn> | ||||
|         </div> | ||||
|     </using-query-result> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .learning-path-title { | ||||
|         white-space: normal; | ||||
|     } | ||||
|     .search-field-container { | ||||
|         min-width: 250px; | ||||
|     } | ||||
|     .control-bar-above-content { | ||||
|         margin-left: 5px; | ||||
|         margin-right: 5px; | ||||
|         margin-bottom: -30px; | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|     } | ||||
|     .navigation-buttons-container { | ||||
|         padding: 20px; | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|     } | ||||
| </style> | ||||
							
								
								
									
										48
									
								
								frontend/src/views/learning-paths/LearningPathSearchPage.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								frontend/src/views/learning-paths/LearningPathSearchPage.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,48 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
|     import { useRoute } from "vue-router"; | ||||
|     import { computed } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import LearningPathSearchField from "@/components/LearningPathSearchField.vue"; | ||||
|     import { useSearchLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import LearningPathsGrid from "@/components/LearningPathsGrid.vue"; | ||||
| 
 | ||||
|     const route = useRoute(); | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const query = computed(() => route.query.query as string | undefined); | ||||
| 
 | ||||
|     const searchQueryResults = useSearchLearningPathQuery(query); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <div class="search-field-container"> | ||||
|         <learning-path-search-field class="search-field"></learning-path-search-field> | ||||
|     </div> | ||||
| 
 | ||||
|     <using-query-result | ||||
|         :query-result="searchQueryResults" | ||||
|         v-slot="{ data }: { data: LearningPath[] }" | ||||
|     > | ||||
|         <learning-paths-grid :learning-paths="data"></learning-paths-grid> | ||||
|     </using-query-result> | ||||
|     <div content="empty-state-container"> | ||||
|         <v-empty-state | ||||
|             v-if="!query" | ||||
|             icon="mdi-magnify" | ||||
|             :title="t('enterSearchTerm')" | ||||
|             :text="t('enterSearchTermDescription')" | ||||
|         ></v-empty-state> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .search-field-container { | ||||
|         display: block; | ||||
|         margin: 20px; | ||||
|     } | ||||
|     .search-field { | ||||
|         max-width: 300px; | ||||
|     } | ||||
| </style> | ||||
|  | @ -12,6 +12,7 @@ | |||
|         } | ||||
|     ], | ||||
|     "compilerOptions": { | ||||
|         "composite": true, | ||||
|         "resolveJsonModule": true | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -2,7 +2,6 @@ import { fileURLToPath, URL } from "node:url"; | |||
| 
 | ||||
| import { defineConfig } from "vite"; | ||||
| import vue from "@vitejs/plugin-vue"; | ||||
| import vueDevTools from "vite-plugin-vue-devtools"; | ||||
| 
 | ||||
| // https://vite.dev/config/
 | ||||
| export default defineConfig({ | ||||
|  | @ -12,4 +11,7 @@ export default defineConfig({ | |||
|             "@": fileURLToPath(new URL("./src", import.meta.url)), | ||||
|         }, | ||||
|     }, | ||||
|     build: { | ||||
|         target: "esnext", //Browsers can handle the latest ES features
 | ||||
|     }, | ||||
| }); | ||||
|  |  | |||
		Reference in a new issue
	
	 Joyelle Ndagijimana
						Joyelle Ndagijimana