Merge branch '43-overzicht-vragen-bij-opdracht-leerpad' into feat/leerpad-vragen
This commit is contained in:
		
						commit
						1edbe219a6
					
				
					 179 changed files with 5197 additions and 3023 deletions
				
			
		|  | @ -10,10 +10,6 @@ | |||
|     } | ||||
| 
 | ||||
|     const showMenuBar = computed(() => (route.meta as RouteMeta).requiresAuth && auth.authState.user); | ||||
| 
 | ||||
|     auth.loadUser().catch((_error) => { | ||||
|         // TODO Could not load user! | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| <script setup lang="ts"> | ||||
|     import { ref } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useRouter } from "vue-router"; | ||||
| 
 | ||||
|     import auth from "@/services/auth/auth-service.ts"; | ||||
| 
 | ||||
|  | @ -10,6 +11,7 @@ | |||
|     const { t, locale } = useI18n(); | ||||
| 
 | ||||
|     const role = auth.authState.activeRole; | ||||
|     const router = useRouter(); | ||||
| 
 | ||||
|     const name: string = auth.authState.user!.profile.name!; | ||||
|     const initials: string = name | ||||
|  | @ -80,13 +82,14 @@ | |||
|             > | ||||
|                 {{ t("classes") }} | ||||
|             </v-btn> | ||||
|             <v-btn | ||||
|                 class="menu_item" | ||||
|                 variant="text" | ||||
|                 to="/user/discussion" | ||||
|             > | ||||
|                 {{ t("discussions") }} | ||||
|             </v-btn> | ||||
|             <!-- TODO Re-enable this button when the discussion page is ready --> | ||||
|             <!--            <v-btn--> | ||||
|             <!--                class="menu_item"--> | ||||
|             <!--                variant="text"--> | ||||
|             <!--                to="/user/discussion"--> | ||||
|             <!--            >--> | ||||
|             <!--                {{ t("discussions") }}--> | ||||
|             <!--            </v-btn>--> | ||||
|             <v-menu open-on-hover> | ||||
|                 <template v-slot:activator="{ props }"> | ||||
|                     <v-btn | ||||
|  |  | |||
							
								
								
									
										22
									
								
								frontend/src/components/QandA.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								frontend/src/components/QandA.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { QuestionDTO } from "@dwengo-1/common/interfaces/question"; | ||||
|     import SingleQuestion from "./SingleQuestion.vue"; | ||||
| 
 | ||||
|     defineProps<{ | ||||
|         questions: QuestionDTO[]; | ||||
|     }>(); | ||||
| </script> | ||||
| <template> | ||||
|     <div class="space-y-4"> | ||||
|         <div | ||||
|             v-for="(question) in questions" | ||||
|             :key="(question.sequenceNumber, question.content)" | ||||
|             class="border rounded-2xl p-4 shadow-sm bg-white" | ||||
|         > | ||||
|             <SingleQuestion :question="question"></SingleQuestion> | ||||
| 
 | ||||
|          | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
| <style scoped></style> | ||||
							
								
								
									
										108
									
								
								frontend/src/components/SingleQuestion.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								frontend/src/components/SingleQuestion.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,108 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useAnswersQuery } from "@/queries/answers"; | ||||
|     import type { QuestionDTO, QuestionId } from "@dwengo-1/common/interfaces/question"; | ||||
|     import { computed, ref } from "vue"; | ||||
|     import UsingQueryResult from "./UsingQueryResult.vue"; | ||||
|     import type { AnswersResponse } from "@/controllers/answers"; | ||||
|     import type { AnswerDTO } from "@dwengo-1/common/interfaces/answer"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         question: QuestionDTO; | ||||
|     }>(); | ||||
| 
 | ||||
|     const expanded = ref(false); | ||||
| 
 | ||||
|     const toggle = () => { | ||||
|         expanded.value = !expanded.value; | ||||
|     }; | ||||
| 
 | ||||
|     const formatDate = (timestamp: string | Date): string => { | ||||
|         return new Date(timestamp).toLocaleString(); | ||||
|     }; | ||||
| 
 | ||||
|     const answersQuery = useAnswersQuery( | ||||
|         computed( | ||||
|             () => | ||||
|                 ({ | ||||
|                     learningObjectIdentifier: props.question.learningObjectIdentifier, | ||||
|                     sequenceNumber: props.question.sequenceNumber, | ||||
|                 }) as QuestionId, | ||||
|         ), | ||||
|     ); | ||||
| </script> | ||||
| <template> | ||||
|     <div class="space-y-4"> | ||||
|         <div | ||||
|             class="flex justify-between items-center mb-2" | ||||
|             style=" | ||||
|                 margin-right: 5px; | ||||
|                 margin-left: 5px; | ||||
|                 font-weight: bold; | ||||
|                 display: flex; | ||||
|                 flex-direction: row; | ||||
|                 justify-content: space-between; | ||||
|             " | ||||
|         > | ||||
|             <span class="font-semibold text-lg text-gray-800">{{ | ||||
|                 question.author.firstName + " " + question.author.lastName | ||||
|             }}</span> | ||||
|             <span class="text-sm text-gray-500">{{ formatDate(question.timestamp) }}</span> | ||||
|         </div> | ||||
| 
 | ||||
|         <div | ||||
|             class="text-gray-700 mb-3" | ||||
|             style="margin-left: 10px" | ||||
|         > | ||||
|             {{ question.content }} | ||||
|         </div> | ||||
| 
 | ||||
|         <using-query-result | ||||
|             :query-result="answersQuery" | ||||
|             v-slot="answersResponse: { data: AnswersResponse }" | ||||
|         > | ||||
|             <button | ||||
|                 v-if="answersResponse.data.answers && answersResponse.data.answers.length > 0" | ||||
|                 @click="toggle()" | ||||
|                 class="text-blue-600 hover:underline text-sm" | ||||
|             > | ||||
|                 {{ expanded ? "Hide Answers" : "Show Answers" }} | ||||
|             </button> | ||||
| 
 | ||||
|             <div | ||||
|                 v-if="expanded" | ||||
|                 class="mt-3 pl-4 border-l-2 border-blue-200 space-y-2" | ||||
|             > | ||||
|                 <div | ||||
|                     v-for="(answer, answerIndex) in answersResponse.data.answers as AnswerDTO[]" | ||||
|                     :key="answerIndex" | ||||
|                     class="text-gray-600" | ||||
|                 > | ||||
|                     <div | ||||
|                         class="flex justify-between items-center mb-2" | ||||
|                         style=" | ||||
|                             margin-right: 5px; | ||||
|                             margin-left: 5px; | ||||
|                             font-weight: bold; | ||||
|                             display: flex; | ||||
|                             flex-direction: row; | ||||
|                             justify-content: space-between; | ||||
|                         " | ||||
|                     > | ||||
|                         <span class="font-semibold text-lg text-gray-800">{{ | ||||
|                             answer.author.username | ||||
|                         }}</span> | ||||
|                         <span class="text-sm text-gray-500">{{ formatDate(answer.timestamp) }}</span> | ||||
|                     </div> | ||||
| 
 | ||||
|                     <div | ||||
|                         class="text-gray-700 mb-3" | ||||
|                         style="margin-left: 10px" | ||||
|                     > | ||||
|                         {{ answer.content }} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </using-query-result> | ||||
|     </div> | ||||
| </template> | ||||
| <style scoped></style> | ||||
|  | @ -1,6 +1,7 @@ | |||
| import type { AnswerData, AnswerDTO, AnswerId } from "@dwengo-1/common/interfaces/answer"; | ||||
| import { BaseController } from "@/controllers/base-controller.ts"; | ||||
| import type { QuestionId } from "@dwengo-1/common/interfaces/question"; | ||||
| import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content"; | ||||
| 
 | ||||
| export interface AnswersResponse { | ||||
|     answers: AnswerDTO[] | AnswerId[]; | ||||
|  | @ -11,29 +12,34 @@ export interface AnswerResponse { | |||
| } | ||||
| 
 | ||||
| export class AnswerController extends BaseController { | ||||
| 
 | ||||
|     loId: LearningObjectIdentifierDTO; | ||||
|     sequenceNumber: number; | ||||
| 
 | ||||
| 
 | ||||
|     constructor(questionId: QuestionId) { | ||||
|         super(`learningObject/${questionId.learningObjectIdentifier.hruid}/:${questionId.learningObjectIdentifier.version}/questions/${questionId.sequenceNumber}/answers`); | ||||
|         this.loId = questionId.learningObjectIdentifier; | ||||
|         this.sequenceNumber = questionId.sequenceNumber; | ||||
|         super(`learningObject/${loId.hruid}/:${loId.version}/questions/${this.sequenceNumber}/answers`); | ||||
|     } | ||||
| 
 | ||||
|     async getAll(full = true): Promise<AnswersResponse> { | ||||
|         return this.get<AnswersResponse>("/", { lang: this.loId.lang, full }); | ||||
|         return this.get<AnswersResponse>("/", { lang: this.loId.language, full }); | ||||
|     } | ||||
| 
 | ||||
|     async getBy(seq: number): Promise<AnswerResponse> { | ||||
|         return this.get<AnswerResponse>(`/${seq}`, { lang: this.loId.lang }); | ||||
|         return this.get<AnswerResponse>(`/${seq}`, { lang: this.loId.language }); | ||||
|     } | ||||
| 
 | ||||
|     async create(answerData: AnswerData): Promise<AnswerResponse> { | ||||
|         return this.post<AnswerResponse>("/", answerData, { lang: this.loId.lang }); | ||||
|         return this.post<AnswerResponse>("/", answerData, { lang: this.loId.language }); | ||||
|     } | ||||
| 
 | ||||
|     async remove(seq: number): Promise<AnswerResponse> { | ||||
|         return this.delete<AnswerResponse>(`/${seq}`, { lang: this.loId.lang }); | ||||
|         return this.delete<AnswerResponse>(`/${seq}`, { lang: this.loId.language }); | ||||
|     } | ||||
| 
 | ||||
|     async update(seq: number, answerData: AnswerData): Promise<AnswerResponse> { | ||||
|         return this.put<AnswerResponse>(`/${seq}`, answerData, { lang: this.loId.lang }); | ||||
|         return this.put<AnswerResponse>(`/${seq}`, answerData, { lang: this.loId.language }); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,11 +1,11 @@ | |||
| import { BaseController } from "./base-controller"; | ||||
| import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; | ||||
| import type { AssignmentDTO, AssignmentDTOId } 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[]; | ||||
|     assignments: AssignmentDTO[] | AssignmentDTOId[]; | ||||
| } | ||||
| 
 | ||||
| export interface AssignmentResponse { | ||||
|  |  | |||
|  | @ -2,8 +2,8 @@ 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"; | ||||
| import type { TeachersResponse } from "@/controllers/teachers.ts"; | ||||
| import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations.ts"; | ||||
| 
 | ||||
| export interface ClassesResponse { | ||||
|     classes: ClassDTO[] | string[]; | ||||
|  | @ -13,14 +13,6 @@ export interface ClassResponse { | |||
|     class: ClassDTO; | ||||
| } | ||||
| 
 | ||||
| export interface TeacherInvitationsResponse { | ||||
|     invites: TeacherInvitationDTO[]; | ||||
| } | ||||
| 
 | ||||
| export interface TeacherInvitationResponse { | ||||
|     invite: TeacherInvitationDTO; | ||||
| } | ||||
| 
 | ||||
| export class ClassController extends BaseController { | ||||
|     constructor() { | ||||
|         super("class"); | ||||
|  |  | |||
|  | @ -1,10 +1,10 @@ | |||
| import { BaseController } from "./base-controller"; | ||||
| import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; | ||||
| import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group"; | ||||
| import type { SubmissionsResponse } from "./submissions"; | ||||
| import type { QuestionsResponse } from "./questions"; | ||||
| 
 | ||||
| export interface GroupsResponse { | ||||
|     groups: GroupDTO[]; | ||||
|     groups: GroupDTO[] | GroupDTOId[]; | ||||
| } | ||||
| 
 | ||||
| export interface GroupResponse { | ||||
|  | @ -36,11 +36,11 @@ export class GroupController extends BaseController { | |||
|         return this.put<GroupResponse>(`/${num}`, data); | ||||
|     } | ||||
| 
 | ||||
|     async getSubmissions(groupNumber: number, full = true): Promise<SubmissionsResponse> { | ||||
|         return this.get<SubmissionsResponse>(`/${groupNumber}/submissions`, { full }); | ||||
|     async getSubmissions(num: number, full = true): Promise<SubmissionsResponse> { | ||||
|         return this.get<SubmissionsResponse>(`/${num}/submissions`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getQuestions(groupNumber: number, full = true): Promise<QuestionsResponse> { | ||||
|         return this.get<QuestionsResponse>(`/${groupNumber}/questions`, { full }); | ||||
|     async getQuestions(num: number, full = true): Promise<QuestionsResponse> { | ||||
|         return this.get<QuestionsResponse>(`/${num}/questions`, { full }); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -8,20 +8,21 @@ export class LearningPathController extends BaseController { | |||
|     constructor() { | ||||
|         super("learningPath"); | ||||
|     } | ||||
|     async search(query: string): Promise<LearningPath[]> { | ||||
|         const dtos = await this.get<LearningPathDTO[]>("/", { search: query }); | ||||
|     async search(query: string, language: string): Promise<LearningPath[]> { | ||||
|         const dtos = await this.get<LearningPathDTO[]>("/", { search: query, language }); | ||||
|         return dtos.map((dto) => LearningPath.fromDTO(dto)); | ||||
|     } | ||||
|     async getBy( | ||||
|         hruid: string, | ||||
|         language: Language, | ||||
|         options?: { forGroup?: string; forStudent?: string }, | ||||
|         forGroup?: { forGroup: number; assignmentNo: number; classId: string }, | ||||
|     ): Promise<LearningPath> { | ||||
|         const dtos = await this.get<LearningPathDTO[]>("/", { | ||||
|             hruid, | ||||
|             language, | ||||
|             forGroup: options?.forGroup, | ||||
|             forStudent: options?.forStudent, | ||||
|             forGroup: forGroup?.forGroup, | ||||
|             assignmentNo: forGroup?.assignmentNo, | ||||
|             classId: forGroup?.classId, | ||||
|         }); | ||||
|         return LearningPath.fromDTO(single(dtos)); | ||||
|     } | ||||
|  |  | |||
|  | @ -11,28 +11,30 @@ export interface QuestionResponse { | |||
| } | ||||
| 
 | ||||
| export class QuestionController extends BaseController { | ||||
|     loId: LearningObjectIdentifierDTO; | ||||
| 
 | ||||
|     constructor(loId: LearningObjectIdentifierDTO) { | ||||
|         this.loId = loId; | ||||
|         super(`learningObject/${loId.hruid}/:${loId.version}/questions`); | ||||
|         this.loId = loId; | ||||
|     } | ||||
| 
 | ||||
|     async getAll(full = true): Promise<QuestionsResponse> { | ||||
|         return this.get<QuestionsResponse>("/", { lang: this.loId.lang, full }); | ||||
|         return this.get<QuestionsResponse>("/", { lang: this.loId.language, full }); | ||||
|     } | ||||
| 
 | ||||
|     async getBy(sequenceNumber: number): Promise<QuestionResponse> { | ||||
|         return this.get<QuestionResponse>(`/${sequenceNumber}`, { lang: this.loId.lang }); | ||||
|         return this.get<QuestionResponse>(`/${sequenceNumber}`, { lang: this.loId.language }); | ||||
|     } | ||||
| 
 | ||||
|     async create(questionData: QuestionData): Promise<QuestionResponse> { | ||||
|         return this.post<QuestionResponse>("/", questionData, { lang: this.loId.lang }); | ||||
|         return this.post<QuestionResponse>("/", questionData, { lang: this.loId.language }); | ||||
|     } | ||||
| 
 | ||||
|     async remove(sequenceNumber: number): Promise<QuestionResponse> { | ||||
|         return this.delete<QuestionResponse>(`/${sequenceNumber}`, { lang: this.loId.lang }); | ||||
|         return this.delete<QuestionResponse>(`/${sequenceNumber}`, { lang: this.loId.language }); | ||||
|     } | ||||
| 
 | ||||
|     async update(sequenceNumber: number, questionData: QuestionData): Promise<QuestionResponse> { | ||||
|         return this.put<QuestionResponse>(`/${sequenceNumber}`, questionData, { lang: this.loId.lang }); | ||||
|         return this.put<QuestionResponse>(`/${sequenceNumber}`, questionData, { lang: this.loId.language }); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ export interface StudentResponse { | |||
|     student: StudentDTO; | ||||
| } | ||||
| export interface JoinRequestsResponse { | ||||
|     requests: ClassJoinRequestDTO[]; | ||||
|     joinRequests: ClassJoinRequestDTO[]; | ||||
| } | ||||
| export interface JoinRequestResponse { | ||||
|     request: ClassJoinRequestDTO; | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import { BaseController } from "./base-controller"; | ||||
| import type { SubmissionDTO, SubmissionDTOId } from "@dwengo-1/common/interfaces/submission"; | ||||
| import type { Language } from "@dwengo-1/common/util/language"; | ||||
| 
 | ||||
| export interface SubmissionsResponse { | ||||
|     submissions: SubmissionDTO[] | SubmissionDTOId[]; | ||||
|  | @ -10,19 +11,39 @@ export interface SubmissionResponse { | |||
| } | ||||
| 
 | ||||
| export class SubmissionController extends BaseController { | ||||
|     constructor(classid: string, assignmentNumber: number, groupNumber: number) { | ||||
|         super(`class/${classid}/assignments/${assignmentNumber}/groups/${groupNumber}`); | ||||
|     constructor(hruid: string) { | ||||
|         super(`learningObject/${hruid}/submissions`); | ||||
|     } | ||||
| 
 | ||||
|     async getAll(full = true): Promise<SubmissionsResponse> { | ||||
|         return this.get<SubmissionsResponse>(`/`, { full }); | ||||
|     async getAll( | ||||
|         language: Language, | ||||
|         version: number, | ||||
|         classId: string, | ||||
|         assignmentId: number, | ||||
|         groupId?: number, | ||||
|         full = true, | ||||
|     ): Promise<SubmissionsResponse> { | ||||
|         return this.get<SubmissionsResponse>(`/`, { language, version, classId, assignmentId, groupId, full }); | ||||
|     } | ||||
| 
 | ||||
|     async getByNumber(submissionNumber: number): Promise<SubmissionResponse> { | ||||
|         return this.get<SubmissionResponse>(`/${submissionNumber}`); | ||||
|     async getByNumber( | ||||
|         language: Language, | ||||
|         version: number, | ||||
|         classId: string, | ||||
|         assignmentId: number, | ||||
|         groupId: number, | ||||
|         submissionNumber: number, | ||||
|     ): Promise<SubmissionResponse> { | ||||
|         return this.get<SubmissionResponse>(`/${submissionNumber}`, { | ||||
|             language, | ||||
|             version, | ||||
|             classId, | ||||
|             assignmentId, | ||||
|             groupId, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     async createSubmission(data: unknown): Promise<SubmissionResponse> { | ||||
|     async createSubmission(data: SubmissionDTO): Promise<SubmissionResponse> { | ||||
|         return this.post<SubmissionResponse>(`/`, data); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										37
									
								
								frontend/src/controllers/teacher-invitations.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								frontend/src/controllers/teacher-invitations.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | |||
| import { BaseController } from "@/controllers/base-controller.ts"; | ||||
| import type { TeacherInvitationData, TeacherInvitationDTO } from "@dwengo-1/common/interfaces/teacher-invitation"; | ||||
| 
 | ||||
| export interface TeacherInvitationsResponse { | ||||
|     invitations: TeacherInvitationDTO[]; | ||||
| } | ||||
| 
 | ||||
| export interface TeacherInvitationResponse { | ||||
|     invitation: TeacherInvitationDTO; | ||||
| } | ||||
| 
 | ||||
| export class TeacherInvitationController extends BaseController { | ||||
|     constructor() { | ||||
|         super("teacher/invitations"); | ||||
|     } | ||||
| 
 | ||||
|     async getAll(username: string, s: boolean): Promise<TeacherInvitationsResponse> { | ||||
|         const sent = s.toString(); | ||||
|         return this.get<TeacherInvitationsResponse>(`/${username}`, { sent }); | ||||
|     } | ||||
| 
 | ||||
|     async getBy(data: TeacherInvitationData): Promise<TeacherInvitationResponse> { | ||||
|         return this.get<TeacherInvitationResponse>(`/${data.sender}/${data.receiver}/${data.class}`); | ||||
|     } | ||||
| 
 | ||||
|     async create(data: TeacherInvitationData): Promise<TeacherInvitationResponse> { | ||||
|         return this.post<TeacherInvitationResponse>("/", data); | ||||
|     } | ||||
| 
 | ||||
|     async remove(data: TeacherInvitationData): Promise<TeacherInvitationResponse> { | ||||
|         return this.delete<TeacherInvitationResponse>(`/${data.sender}/${data.receiver}/${data.class}`); | ||||
|     } | ||||
| 
 | ||||
|     async respond(data: TeacherInvitationData): Promise<TeacherInvitationResponse> { | ||||
|         return this.put<TeacherInvitationResponse>("/", data); | ||||
|     } | ||||
| } | ||||
|  | @ -54,10 +54,9 @@ export class TeacherController extends BaseController { | |||
|         studentUsername: string, | ||||
|         accepted: boolean, | ||||
|     ): Promise<JoinRequestResponse> { | ||||
|         return this.put<JoinRequestResponse>( | ||||
|             `/${teacherUsername}/joinRequests/${classId}/${studentUsername}`, | ||||
|         return this.put<JoinRequestResponse>(`/${teacherUsername}/joinRequests/${classId}/${studentUsername}`, { | ||||
|             accepted, | ||||
|         ); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     // GetInvitations(id: string) {return this.get<{ invitations: string[] }>(`/${id}/invitations`);}
 | ||||
|  |  | |||
|  | @ -1,13 +1,13 @@ | |||
| { | ||||
|     "welcome": "Willkommen", | ||||
|     "student": "schüler", | ||||
|     "teacher": "lehrer", | ||||
|     "student": "Schüler", | ||||
|     "teacher": "Lehrer", | ||||
|     "assignments": "Aufgaben", | ||||
|     "classes": "Klasses", | ||||
|     "classes": "Klassen", | ||||
|     "discussions": "Diskussionen", | ||||
|     "login": "einloggen", | ||||
|     "logout": "ausloggen", | ||||
|     "cancel": "kündigen", | ||||
|     "cancel": "abbrechen", | ||||
|     "logoutVerification": "Sind Sie sicher, dass Sie sich abmelden wollen?", | ||||
|     "homeTitle": "Unsere Stärken", | ||||
|     "homeIntroduction1": "Wir entwickeln innovative Workshops und Bildungsressourcen, die wir in Zusammenarbeit mit Lehrern und Freiwilligen Schülern auf der ganzen Welt zur Verfügung stellen. Unsere Train-the-Trainer-Sitzungen ermöglichen es ihnen, unsere praktischen Workshops an die Schüler weiterzugeben.", | ||||
|  | @ -23,10 +23,10 @@ | |||
|     "submitCode": "senden", | ||||
|     "members": "Mitglieder", | ||||
|     "themes": "Themen", | ||||
|     "choose-theme": "Wähle ein thema", | ||||
|     "choose-theme": "Wählen Sie ein Thema", | ||||
|     "choose-age": "Alter auswählen", | ||||
|     "theme-options": { | ||||
|         "all": "Alle themen", | ||||
|         "all": "Alle Themen", | ||||
|         "culture": "Kultur", | ||||
|         "electricity-and-mechanics": "Elektrizität und Mechanik", | ||||
|         "nature-and-climate": "Natur und Klima", | ||||
|  | @ -37,11 +37,11 @@ | |||
|         "algorithms": "Algorithmisches Denken" | ||||
|     }, | ||||
|     "age-options": { | ||||
|         "all": "Alle altersgruppen", | ||||
|         "all": "Alle Altersgruppen", | ||||
|         "primary-school": "Grundschule", | ||||
|         "lower-secondary": "12-14 jahre alt", | ||||
|         "upper-secondary": "14-16 jahre alt", | ||||
|         "high-school": "16-18 jahre alt", | ||||
|         "lower-secondary": "12-14 Jahre alt", | ||||
|         "upper-secondary": "14-16 Jahre alt", | ||||
|         "high-school": "16-18 Jahre alt", | ||||
|         "older": "18 und älter" | ||||
|     }, | ||||
|     "read-more": "Mehr lesen", | ||||
|  | @ -73,7 +73,32 @@ | |||
|     "accept": "akzeptieren", | ||||
|     "deny": "ablehnen", | ||||
|     "sent": "sent", | ||||
|     "failed": "gescheitert", | ||||
|     "failed": "fehlgeschlagen", | ||||
|     "wrong": "etwas ist schief gelaufen", | ||||
|     "created": "erstellt" | ||||
|     "created": "erstellt", | ||||
|     "callbackLoading": "Sie werden angemeldet...", | ||||
|     "loginUnexpectedError": "Anmeldung fehlgeschlagen", | ||||
|     "submitSolution": "Lösung einreichen", | ||||
|     "submitNewSolution": "Neue Lösung einreichen", | ||||
|     "markAsDone": "Als fertig markieren", | ||||
|     "groupSubmissions": "Einreichungen dieser Gruppe", | ||||
|     "taskCompleted": "Aufgabe erledigt.", | ||||
|     "submittedBy": "Eingereicht von", | ||||
|     "timestamp": "Zeitpunkt", | ||||
|     "loadSubmission": "Einladen", | ||||
|     "noSubmissionsYet": "Noch keine Lösungen eingereicht.", | ||||
|     "viewAsGroup": "Fortschritt ansehen von Gruppe...", | ||||
|     "assignLearningPath": "Als Aufgabe geben", | ||||
|     "remove": "entfernen", | ||||
|     "students": "Studenten", | ||||
|     "classJoinRequests": "Beitrittsanfragen", | ||||
|     "reject": "ablehnen", | ||||
|     "areusure": "Sind Sie sicher?", | ||||
|     "yes": "ja", | ||||
|     "teachers": "Lehrer", | ||||
|     "rejected": "abgelehnt", | ||||
|     "accepted": "akzeptiert", | ||||
|     "enterUsername": "Geben Sie den Benutzernamen der Lehrkraft ein, die Sie einladen möchten", | ||||
|     "username": "Nutzername", | ||||
|     "invite": "einladen" | ||||
| } | ||||
|  |  | |||
|  | @ -75,5 +75,30 @@ | |||
|     "sent": "sent", | ||||
|     "failed": "failed", | ||||
|     "wrong": "something went wrong", | ||||
|     "created": "created" | ||||
|     "created": "created", | ||||
|     "callbackLoading": "You are being logged in...", | ||||
|     "loginUnexpectedError": "Login failed", | ||||
|     "submitSolution": "Submit solution", | ||||
|     "submitNewSolution": "Submit new solution", | ||||
|     "markAsDone": "Mark as completed", | ||||
|     "groupSubmissions": "This group's submissions", | ||||
|     "taskCompleted": "Task completed.", | ||||
|     "submittedBy": "Submitted by", | ||||
|     "timestamp": "Timestamp", | ||||
|     "loadSubmission": "Load", | ||||
|     "noSubmissionsYet": "No submissions yet.", | ||||
|     "viewAsGroup": "View progress of group...", | ||||
|     "assignLearningPath": "assign", | ||||
|     "remove": "remove", | ||||
|     "students": "students", | ||||
|     "classJoinRequests": "join requests", | ||||
|     "reject": "reject", | ||||
|     "areusure": "Are you sure?", | ||||
|     "yes": "yes", | ||||
|     "teachers": "teachers", | ||||
|     "accepted": "accepted", | ||||
|     "rejected": "rejected", | ||||
|     "enterUsername": "enter the username of the teacher you would like to invite", | ||||
|     "username": "username", | ||||
|     "invite": "invite" | ||||
| } | ||||
|  |  | |||
|  | @ -75,5 +75,30 @@ | |||
|     "sent": "envoyé", | ||||
|     "failed": "échoué", | ||||
|     "wrong": "quelque chose n'a pas fonctionné", | ||||
|     "created": "créé" | ||||
|     "created": "créé", | ||||
|     "callbackLoading": "Vous serez connecté...", | ||||
|     "loginUnexpectedError": "La connexion a échoué", | ||||
|     "submitSolution": "Soumettre la solution", | ||||
|     "submitNewSolution": "Soumettre une nouvelle solution", | ||||
|     "markAsDone": "Marquer comme terminé", | ||||
|     "groupSubmissions": "Soumissions de ce groupe", | ||||
|     "taskCompleted": "Tâche terminée.", | ||||
|     "submittedBy": "Soumis par", | ||||
|     "timestamp": "Horodatage", | ||||
|     "loadSubmission": "Charger", | ||||
|     "noSubmissionsYet": "Pas encore de soumissions.", | ||||
|     "viewAsGroup": "Voir la progression du groupe...", | ||||
|     "assignLearningPath": "donner comme tâche", | ||||
|     "remove": "supprimer", | ||||
|     "students": "étudiants", | ||||
|     "classJoinRequests": "demandes d'adhésion", | ||||
|     "reject": "rejeter", | ||||
|     "areusure": "Êtes-vous sûr?", | ||||
|     "yes": "oui", | ||||
|     "teachers": "enseignants", | ||||
|     "accepted": "acceptée", | ||||
|     "rejected": "rejetée", | ||||
|     "enterUsername": "entrez le nom d'utilisateur de l'enseignant que vous souhaitez inviter", | ||||
|     "username": "Nom d'utilisateur", | ||||
|     "invite": "inviter" | ||||
| } | ||||
|  |  | |||
|  | @ -75,5 +75,30 @@ | |||
|     "sent": "verzonden", | ||||
|     "failed": "mislukt", | ||||
|     "wrong": "er ging iets verkeerd", | ||||
|     "created": "gecreëerd" | ||||
|     "created": "gecreëerd", | ||||
|     "callbackLoading": "Je wordt ingelogd...", | ||||
|     "loginUnexpectedError": "Inloggen mislukt", | ||||
|     "submitSolution": "Oplossing indienen", | ||||
|     "submitNewSolution": "Nieuwe oplossing indienen", | ||||
|     "markAsDone": "Markeren als afgewerkt", | ||||
|     "groupSubmissions": "Indieningen van deze groep", | ||||
|     "taskCompleted": "Taak afgewerkt.", | ||||
|     "submittedBy": "Ingediend door", | ||||
|     "timestamp": "Tijdstip", | ||||
|     "loadSubmission": "Inladen", | ||||
|     "noSubmissionsYet": "Nog geen indieningen.", | ||||
|     "viewAsGroup": "Vooruitgang bekijken van groep...", | ||||
|     "assignLearningPath": "Als opdracht geven", | ||||
|     "remove": "verwijder", | ||||
|     "students": "studenten", | ||||
|     "classJoinRequests": "deelname verzoeken", | ||||
|     "reject": "weiger", | ||||
|     "areusure": "Bent u zeker?", | ||||
|     "yes": "ja", | ||||
|     "teachers": "leerkrachten", | ||||
|     "accepted": "geaccepteerd", | ||||
|     "rejected": "geweigerd", | ||||
|     "enterUsername": "vul de gebruikersnaam van de leerkracht die je wilt uitnodigen in", | ||||
|     "username": "gebruikersnaam", | ||||
|     "invite": "uitnodigen" | ||||
| } | ||||
|  |  | |||
|  | @ -12,9 +12,11 @@ import App from "./App.vue"; | |||
| import router from "./router"; | ||||
| import { aliases, mdi } from "vuetify/iconsets/mdi"; | ||||
| import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; | ||||
| import authService from "./services/auth/auth-service.ts"; | ||||
| 
 | ||||
| const app = createApp(App); | ||||
| 
 | ||||
| await authService.loadUser(); | ||||
| app.use(router); | ||||
| 
 | ||||
| const link = document.createElement("link"); | ||||
|  |  | |||
|  | @ -1,16 +1,37 @@ | |||
| import type { QuestionId } from "@dwengo-1/common/dist/interfaces/question.ts"; | ||||
| import { type MaybeRefOrGetter, toValue } from "vue"; | ||||
| import { useMutation, type UseMutationReturnType, useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; | ||||
| import { computed, type MaybeRefOrGetter, toValue } from "vue"; | ||||
| import { | ||||
|     useMutation, | ||||
|     type UseMutationReturnType, | ||||
|     useQuery, | ||||
|     type UseQueryReturnType, | ||||
|     useQueryClient, | ||||
| } from "@tanstack/vue-query"; | ||||
| import { AnswerController, type AnswerResponse, type AnswersResponse } from "@/controllers/answers.ts"; | ||||
| import type { AnswerData } from "@dwengo-1/common/dist/interfaces/answer.ts"; | ||||
| import type { AnswerData } from "@dwengo-1/common/interfaces/answer"; | ||||
| import type { QuestionId } from "@dwengo-1/common/interfaces/question"; | ||||
| 
 | ||||
| // TODO caching
 | ||||
| /** 🔑 Query keys */ | ||||
| export function answersQueryKey( | ||||
|     questionId: QuestionId, | ||||
|     full: boolean, | ||||
| ): [string, string, number, string, number, boolean] { | ||||
|     const loId = questionId.learningObjectIdentifier; | ||||
|     return ["answers", loId.hruid, loId.version!, loId.language, questionId.sequenceNumber, full]; | ||||
| } | ||||
| export function answerQueryKey( | ||||
|     questionId: QuestionId, | ||||
|     sequenceNumber: number, | ||||
| ): [string, string, number, string, number, number] { | ||||
|     const loId = questionId.learningObjectIdentifier; | ||||
|     return ["answer", loId.hruid, loId.version!, loId.language, questionId.sequenceNumber, sequenceNumber]; | ||||
| } | ||||
| 
 | ||||
| export function useAnswersQuery( | ||||
|     questionId: MaybeRefOrGetter<QuestionId>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<AnswersResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => answersQueryKey(toValue(questionId), toValue(full))), | ||||
|         queryFn: async () => new AnswerController(toValue(questionId)).getAll(toValue(full)), | ||||
|         enabled: () => Boolean(toValue(questionId)), | ||||
|     }); | ||||
|  | @ -21,31 +42,68 @@ export function useAnswerQuery( | |||
|     sequenceNumber: MaybeRefOrGetter<number>, | ||||
| ): UseQueryReturnType<AnswerResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => answerQueryKey(toValue(questionId), toValue(sequenceNumber))), | ||||
|         queryFn: async () => new AnswerController(toValue(questionId)).getBy(toValue(sequenceNumber)), | ||||
|         enabled: () => Boolean(toValue(questionId)), | ||||
|         enabled: () => Boolean(toValue(questionId)) && Boolean(toValue(sequenceNumber)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useCreateAnswerMutation( | ||||
|     questionId: MaybeRefOrGetter<QuestionId>, | ||||
| ): UseMutationReturnType<AnswerResponse, Error, AnswerData, unknown> { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (data) => new AnswerController(toValue(questionId)).create(data), | ||||
|         onSuccess: async () => { | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: answersQueryKey(toValue(questionId), true), | ||||
|             }); | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: answersQueryKey(toValue(questionId), false), | ||||
|             }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useDeleteAnswerMutation( | ||||
|     questionId: MaybeRefOrGetter<QuestionId>, | ||||
| ): UseMutationReturnType<AnswerResponse, Error, number, unknown> { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (seq) => new AnswerController(toValue(questionId)).remove(seq), | ||||
|         onSuccess: async () => { | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: answersQueryKey(toValue(questionId), true), | ||||
|             }); | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: answersQueryKey(toValue(questionId), false), | ||||
|             }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useUpdateAnswerMutation( | ||||
|     questionId: MaybeRefOrGetter<QuestionId>, | ||||
| ): UseMutationReturnType<AnswerResponse, Error, { answerData: AnswerData; seq: number }, unknown> { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (data, seq) => new AnswerController(toValue(questionId)).update(seq, data), | ||||
|         mutationFn: async ({ answerData, seq }) => new AnswerController(toValue(questionId)).update(seq, answerData), | ||||
|         onSuccess: async (_, { seq }) => { | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: answerQueryKey(toValue(questionId), seq), | ||||
|             }); | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: answersQueryKey(toValue(questionId), true), | ||||
|             }); | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: answersQueryKey(toValue(questionId), true), | ||||
|             }); | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: answersQueryKey(toValue(questionId), false), | ||||
|             }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										217
									
								
								frontend/src/queries/assignments.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								frontend/src/queries/assignments.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,217 @@ | |||
| import { AssignmentController, type AssignmentResponse, type AssignmentsResponse } from "@/controllers/assignments"; | ||||
| import type { QuestionsResponse } from "@/controllers/questions"; | ||||
| import type { SubmissionsResponse } from "@/controllers/submissions"; | ||||
| import { | ||||
|     useMutation, | ||||
|     useQuery, | ||||
|     useQueryClient, | ||||
|     type UseMutationReturnType, | ||||
|     type UseQueryReturnType, | ||||
| } from "@tanstack/vue-query"; | ||||
| import { computed, toValue, type MaybeRefOrGetter } from "vue"; | ||||
| import { groupsQueryKey, invalidateAllGroupKeys } from "./groups"; | ||||
| import type { GroupsResponse } from "@/controllers/groups"; | ||||
| import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; | ||||
| import type { QueryClient } from "@tanstack/react-query"; | ||||
| import { invalidateAllSubmissionKeys } from "./submissions"; | ||||
| 
 | ||||
| type AssignmentsQueryKey = ["assignments", string, boolean]; | ||||
| 
 | ||||
| function assignmentsQueryKey(classid: string, full: boolean): AssignmentsQueryKey { | ||||
|     return ["assignments", classid, full]; | ||||
| } | ||||
| 
 | ||||
| type AssignmentQueryKey = ["assignment", string, number]; | ||||
| 
 | ||||
| function assignmentQueryKey(classid: string, assignmentNumber: number): AssignmentQueryKey { | ||||
|     return ["assignment", classid, assignmentNumber]; | ||||
| } | ||||
| 
 | ||||
| type AssignmentSubmissionsQueryKey = ["assignment-submissions", string, number, boolean]; | ||||
| 
 | ||||
| function assignmentSubmissionsQueryKey( | ||||
|     classid: string, | ||||
|     assignmentNumber: number, | ||||
|     full: boolean, | ||||
| ): AssignmentSubmissionsQueryKey { | ||||
|     return ["assignment-submissions", classid, assignmentNumber, full]; | ||||
| } | ||||
| 
 | ||||
| type AssignmentQuestionsQueryKey = ["assignment-questions", string, number, boolean]; | ||||
| 
 | ||||
| function assignmentQuestionsQueryKey( | ||||
|     classid: string, | ||||
|     assignmentNumber: number, | ||||
|     full: boolean, | ||||
| ): AssignmentQuestionsQueryKey { | ||||
|     return ["assignment-questions", classid, assignmentNumber, full]; | ||||
| } | ||||
| 
 | ||||
| export async function invalidateAllAssignmentKeys( | ||||
|     queryClient: QueryClient, | ||||
|     classid?: string, | ||||
|     assignmentNumber?: number, | ||||
| ): Promise<void> { | ||||
|     const keys = ["assignment", "assignment-submissions", "assignment-questions"]; | ||||
| 
 | ||||
|     await Promise.all( | ||||
|         keys.map(async (key) => { | ||||
|             const queryKey = [key, classid, assignmentNumber].filter((arg) => arg !== undefined); | ||||
|             return queryClient.invalidateQueries({ queryKey: queryKey }); | ||||
|         }), | ||||
|     ); | ||||
| 
 | ||||
|     await queryClient.invalidateQueries({ queryKey: ["assignments", classid].filter((arg) => arg !== undefined) }); | ||||
| } | ||||
| 
 | ||||
| function checkEnabled( | ||||
|     classid: string | undefined, | ||||
|     assignmentNumber: number | undefined, | ||||
|     groupNumber: number | undefined, | ||||
| ): boolean { | ||||
|     return Boolean(classid) && !isNaN(Number(groupNumber)) && !isNaN(Number(assignmentNumber)); | ||||
| } | ||||
| 
 | ||||
| interface Values { | ||||
|     cid: string | undefined; | ||||
|     an: number | undefined; | ||||
|     gn: number | undefined; | ||||
|     f: boolean; | ||||
| } | ||||
| 
 | ||||
| function toValues( | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     groupNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean>, | ||||
| ): Values { | ||||
|     return { cid: toValue(classid), an: toValue(assignmentNumber), gn: toValue(groupNumber), f: toValue(full) }; | ||||
| } | ||||
| 
 | ||||
| export function useAssignmentsQuery( | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<AssignmentsResponse, Error> { | ||||
|     const { cid, f } = toValues(classid, 1, 1, full); | ||||
| 
 | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => assignmentsQueryKey(cid!, f)), | ||||
|         queryFn: async () => new AssignmentController(cid!).getAll(f), | ||||
|         enabled: () => checkEnabled(cid, 1, 1), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useAssignmentQuery( | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
| ): UseQueryReturnType<AssignmentsResponse, Error> { | ||||
|     const { cid, an } = toValues(classid, assignmentNumber, 1, true); | ||||
| 
 | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => assignmentQueryKey(cid!, an!)), | ||||
|         queryFn: async () => new AssignmentController(cid!).getByNumber(an!), | ||||
|         enabled: () => checkEnabled(cid, an, 1), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useCreateAssignmentMutation(): UseMutationReturnType< | ||||
|     AssignmentResponse, | ||||
|     Error, | ||||
|     { cid: string; data: AssignmentDTO }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ cid, data }) => new AssignmentController(cid).createAssignment(data), | ||||
|         onSuccess: async (_) => { | ||||
|             await queryClient.invalidateQueries({ queryKey: ["assignments"] }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useDeleteAssignmentMutation(): UseMutationReturnType< | ||||
|     AssignmentResponse, | ||||
|     Error, | ||||
|     { cid: string; an: number }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ cid, an }) => new AssignmentController(cid).deleteAssignment(an), | ||||
|         onSuccess: async (response) => { | ||||
|             const cid = response.assignment.within; | ||||
|             const an = response.assignment.id; | ||||
| 
 | ||||
|             await invalidateAllAssignmentKeys(queryClient, cid, an); | ||||
|             await invalidateAllGroupKeys(queryClient, cid, an); | ||||
|             await invalidateAllSubmissionKeys(queryClient, cid, an); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useUpdateAssignmentMutation(): UseMutationReturnType< | ||||
|     AssignmentResponse, | ||||
|     Error, | ||||
|     { cid: string; an: number; data: Partial<AssignmentDTO> }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ cid, an, data }) => new AssignmentController(cid).updateAssignment(an, data), | ||||
|         onSuccess: async (response) => { | ||||
|             const cid = response.assignment.within; | ||||
|             const an = response.assignment.id; | ||||
| 
 | ||||
|             await invalidateAllGroupKeys(queryClient, cid, an); | ||||
|             await queryClient.invalidateQueries({ queryKey: ["assignments"] }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useAssignmentSubmissionsQuery( | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     groupNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<SubmissionsResponse, Error> { | ||||
|     const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full); | ||||
| 
 | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => assignmentSubmissionsQueryKey(cid!, an!, f)), | ||||
|         queryFn: async () => new AssignmentController(cid!).getSubmissions(gn!, f), | ||||
|         enabled: () => checkEnabled(cid, an, gn), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useAssignmentQuestionsQuery( | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     groupNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<QuestionsResponse, Error> { | ||||
|     const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full); | ||||
| 
 | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => assignmentQuestionsQueryKey(cid!, an!, f)), | ||||
|         queryFn: async () => new AssignmentController(cid!).getQuestions(gn!, f), | ||||
|         enabled: () => checkEnabled(cid, an, gn), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useAssignmentGroupsQuery( | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     groupNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<GroupsResponse, Error> { | ||||
|     const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full); | ||||
| 
 | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => groupsQueryKey(cid!, an!, f)), | ||||
|         queryFn: async () => new AssignmentController(cid!).getQuestions(gn!, f), | ||||
|         enabled: () => checkEnabled(cid, an, gn), | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										245
									
								
								frontend/src/queries/classes.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								frontend/src/queries/classes.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,245 @@ | |||
| import { ClassController, type ClassesResponse, type ClassResponse } from "@/controllers/classes"; | ||||
| import type { StudentsResponse } from "@/controllers/students"; | ||||
| import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; | ||||
| import { | ||||
|     QueryClient, | ||||
|     useMutation, | ||||
|     useQuery, | ||||
|     useQueryClient, | ||||
|     type UseMutationReturnType, | ||||
|     type UseQueryReturnType, | ||||
| } from "@tanstack/vue-query"; | ||||
| import { computed, toValue, type MaybeRefOrGetter } from "vue"; | ||||
| import { invalidateAllAssignmentKeys } from "./assignments"; | ||||
| import { invalidateAllGroupKeys } from "./groups"; | ||||
| import { invalidateAllSubmissionKeys } from "./submissions"; | ||||
| import type { TeachersResponse } from "@/controllers/teachers"; | ||||
| import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations"; | ||||
| 
 | ||||
| const classController = new ClassController(); | ||||
| 
 | ||||
| /* Query cache keys */ | ||||
| type ClassesQueryKey = ["classes", boolean]; | ||||
| 
 | ||||
| function classesQueryKey(full: boolean): ClassesQueryKey { | ||||
|     return ["classes", full]; | ||||
| } | ||||
| 
 | ||||
| type ClassQueryKey = ["class", string]; | ||||
| 
 | ||||
| function classQueryKey(classid: string): ClassQueryKey { | ||||
|     return ["class", classid]; | ||||
| } | ||||
| 
 | ||||
| type ClassStudentsKey = ["class-students", string, boolean]; | ||||
| 
 | ||||
| function classStudentsKey(classid: string, full: boolean): ClassStudentsKey { | ||||
|     return ["class-students", classid, full]; | ||||
| } | ||||
| 
 | ||||
| type ClassTeachersKey = ["class-teachers", string, boolean]; | ||||
| 
 | ||||
| function classTeachersKey(classid: string, full: boolean): ClassTeachersKey { | ||||
|     return ["class-teachers", classid, full]; | ||||
| } | ||||
| 
 | ||||
| type ClassTeacherInvitationsKey = ["class-teacher-invitations", string, boolean]; | ||||
| 
 | ||||
| function classTeacherInvitationsKey(classid: string, full: boolean): ClassTeacherInvitationsKey { | ||||
|     return ["class-teacher-invitations", classid, full]; | ||||
| } | ||||
| 
 | ||||
| type ClassAssignmentsKey = ["class-assignments", string, boolean]; | ||||
| 
 | ||||
| function classAssignmentsKey(classid: string, full: boolean): ClassAssignmentsKey { | ||||
|     return ["class-assignments", classid, full]; | ||||
| } | ||||
| 
 | ||||
| export async function invalidateAllClassKeys(queryClient: QueryClient, classid?: string): Promise<void> { | ||||
|     const keys = ["class", "class-students", "class-teachers", "class-teacher-invitations", "class-assignments"]; | ||||
| 
 | ||||
|     await Promise.all( | ||||
|         keys.map(async (key) => { | ||||
|             const queryKey = [key, classid].filter((arg) => arg !== undefined); | ||||
|             return queryClient.invalidateQueries({ queryKey: queryKey }); | ||||
|         }), | ||||
|     ); | ||||
| 
 | ||||
|     await queryClient.invalidateQueries({ queryKey: ["classes"] }); | ||||
| } | ||||
| 
 | ||||
| /* Queries */ | ||||
| export function useClassesQuery(full: MaybeRefOrGetter<boolean> = true): UseQueryReturnType<ClassesResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => classesQueryKey(toValue(full))), | ||||
|         queryFn: async () => classController.getAll(toValue(full)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useClassQuery(id: MaybeRefOrGetter<string | undefined>): UseQueryReturnType<ClassResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => classQueryKey(toValue(id)!)), | ||||
|         queryFn: async () => classController.getById(toValue(id)!), | ||||
|         enabled: () => Boolean(toValue(id)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useCreateClassMutation(): UseMutationReturnType<ClassResponse, Error, ClassDTO, unknown> { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (data) => classController.createClass(data), | ||||
|         onSuccess: async () => { | ||||
|             await queryClient.invalidateQueries({ queryKey: ["classes"] }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useDeleteClassMutation(): UseMutationReturnType<ClassResponse, Error, string, unknown> { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (id) => classController.deleteClass(id), | ||||
|         onSuccess: async (data) => { | ||||
|             await invalidateAllClassKeys(queryClient, data.class.id); | ||||
|             await invalidateAllAssignmentKeys(queryClient, data.class.id); | ||||
|             await invalidateAllGroupKeys(queryClient, data.class.id); | ||||
|             await invalidateAllSubmissionKeys(queryClient, data.class.id); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useUpdateClassMutation(): UseMutationReturnType< | ||||
|     ClassResponse, | ||||
|     Error, | ||||
|     { cid: string; data: Partial<ClassDTO> }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ cid, data }) => classController.updateClass(cid, data), | ||||
|         onSuccess: async (data) => { | ||||
|             await invalidateAllClassKeys(queryClient, data.class.id); | ||||
|             await invalidateAllAssignmentKeys(queryClient, data.class.id); | ||||
|             await invalidateAllGroupKeys(queryClient, data.class.id); | ||||
|             await invalidateAllSubmissionKeys(queryClient, data.class.id); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useClassStudentsQuery( | ||||
|     id: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<StudentsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => classStudentsKey(toValue(id)!, toValue(full))), | ||||
|         queryFn: async () => classController.getStudents(toValue(id)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(id)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useClassAddStudentMutation(): UseMutationReturnType< | ||||
|     ClassResponse, | ||||
|     Error, | ||||
|     { id: string; username: string }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ id, username }) => classController.addStudent(id, username), | ||||
|         onSuccess: async (data) => { | ||||
|             await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, true) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useClassDeleteStudentMutation(): UseMutationReturnType< | ||||
|     ClassResponse, | ||||
|     Error, | ||||
|     { id: string; username: string }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ id, username }) => classController.deleteStudent(id, username), | ||||
|         onSuccess: async (data) => { | ||||
|             await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, true) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useClassTeachersQuery( | ||||
|     id: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<TeachersResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => classTeachersKey(toValue(id)!, toValue(full))), | ||||
|         queryFn: async () => classController.getTeachers(toValue(id)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(id)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useClassAddTeacherMutation(): UseMutationReturnType< | ||||
|     ClassResponse, | ||||
|     Error, | ||||
|     { id: string; username: string }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ id, username }) => classController.addTeacher(id, username), | ||||
|         onSuccess: async (data) => { | ||||
|             await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, true) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, false) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useClassDeleteTeacherMutation(): UseMutationReturnType< | ||||
|     ClassResponse, | ||||
|     Error, | ||||
|     { id: string; username: string }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ id, username }) => classController.deleteTeacher(id, username), | ||||
|         onSuccess: async (data) => { | ||||
|             await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, true) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, false) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useClassTeacherInvitationsQuery( | ||||
|     id: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<TeacherInvitationsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => classTeacherInvitationsKey(toValue(id)!, toValue(full))), | ||||
|         queryFn: async () => classController.getTeacherInvitations(toValue(id)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(id)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useClassAssignmentsQuery( | ||||
|     id: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<StudentsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => classAssignmentsKey(toValue(id)!, toValue(full))), | ||||
|         queryFn: async () => classController.getAssignments(toValue(id)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(id)), | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										219
									
								
								frontend/src/queries/groups.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								frontend/src/queries/groups.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,219 @@ | |||
| import { GroupController, type GroupResponse, type GroupsResponse } from "@/controllers/groups"; | ||||
| import type { QuestionsResponse } from "@/controllers/questions"; | ||||
| import type { SubmissionsResponse } from "@/controllers/submissions"; | ||||
| import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; | ||||
| import { | ||||
|     QueryClient, | ||||
|     useMutation, | ||||
|     type UseMutationReturnType, | ||||
|     useQuery, | ||||
|     useQueryClient, | ||||
|     type UseQueryReturnType, | ||||
| } from "@tanstack/vue-query"; | ||||
| import { computed, toValue, type MaybeRefOrGetter } from "vue"; | ||||
| import { invalidateAllSubmissionKeys } from "./submissions"; | ||||
| 
 | ||||
| type GroupsQueryKey = ["groups", string, number, boolean]; | ||||
| 
 | ||||
| export function groupsQueryKey(classid: string, assignmentNumber: number, full: boolean): GroupsQueryKey { | ||||
|     return ["groups", classid, assignmentNumber, full]; | ||||
| } | ||||
| 
 | ||||
| type GroupQueryKey = ["group", string, number, number]; | ||||
| 
 | ||||
| function groupQueryKey(classid: string, assignmentNumber: number, groupNumber: number): GroupQueryKey { | ||||
|     return ["group", classid, assignmentNumber, groupNumber]; | ||||
| } | ||||
| 
 | ||||
| type GroupSubmissionsQueryKey = ["group-submissions", string, number, number, boolean]; | ||||
| 
 | ||||
| function groupSubmissionsQueryKey( | ||||
|     classid: string, | ||||
|     assignmentNumber: number, | ||||
|     groupNumber: number, | ||||
|     full: boolean, | ||||
| ): GroupSubmissionsQueryKey { | ||||
|     return ["group-submissions", classid, assignmentNumber, groupNumber, full]; | ||||
| } | ||||
| 
 | ||||
| type GroupQuestionsQueryKey = ["group-questions", string, number, number, boolean]; | ||||
| 
 | ||||
| function groupQuestionsQueryKey( | ||||
|     classid: string, | ||||
|     assignmentNumber: number, | ||||
|     groupNumber: number, | ||||
|     full: boolean, | ||||
| ): GroupQuestionsQueryKey { | ||||
|     return ["group-questions", classid, assignmentNumber, groupNumber, full]; | ||||
| } | ||||
| 
 | ||||
| export async function invalidateAllGroupKeys( | ||||
|     queryClient: QueryClient, | ||||
|     classid?: string, | ||||
|     assignmentNumber?: number, | ||||
|     groupNumber?: number, | ||||
| ): Promise<void> { | ||||
|     const keys = ["group", "group-submissions", "group-questions"]; | ||||
|     await Promise.all( | ||||
|         keys.map(async (key) => { | ||||
|             const queryKey = [key, classid, assignmentNumber, groupNumber].filter((arg) => arg !== undefined); | ||||
|             return queryClient.invalidateQueries({ queryKey: queryKey }); | ||||
|         }), | ||||
|     ); | ||||
| 
 | ||||
|     await queryClient.invalidateQueries({ | ||||
|         queryKey: ["groups", classid, assignmentNumber].filter((arg) => arg !== undefined), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| function checkEnabled( | ||||
|     classid: string | undefined, | ||||
|     assignmentNumber: number | undefined, | ||||
|     groupNumber: number | undefined, | ||||
| ): boolean { | ||||
|     return Boolean(classid) && !isNaN(Number(groupNumber)) && !isNaN(Number(assignmentNumber)); | ||||
| } | ||||
| 
 | ||||
| interface Values { | ||||
|     cid: string | undefined; | ||||
|     an: number | undefined; | ||||
|     gn: number | undefined; | ||||
|     f: boolean; | ||||
| } | ||||
| 
 | ||||
| function toValues( | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     groupNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean>, | ||||
| ): Values { | ||||
|     return { cid: toValue(classid), an: toValue(assignmentNumber), gn: toValue(groupNumber), f: toValue(full) }; | ||||
| } | ||||
| 
 | ||||
| export function useGroupsQuery( | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<GroupsResponse, Error> { | ||||
|     const { cid, an, f } = toValues(classid, assignmentNumber, 1, full); | ||||
| 
 | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => groupsQueryKey(cid!, an!, f)), | ||||
|         queryFn: async () => new GroupController(cid!, an!).getAll(f), | ||||
|         enabled: () => checkEnabled(cid, an, 1), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useGroupQuery( | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     groupNumber: MaybeRefOrGetter<number | undefined>, | ||||
| ): UseQueryReturnType<GroupResponse, Error> { | ||||
|     const { cid, an, gn } = toValues(classid, assignmentNumber, groupNumber, true); | ||||
| 
 | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => groupQueryKey(cid!, an!, gn!)), | ||||
|         queryFn: async () => new GroupController(cid!, an!).getByNumber(gn!), | ||||
|         enabled: () => checkEnabled(cid, an, gn), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useCreateGroupMutation(): UseMutationReturnType< | ||||
|     GroupResponse, | ||||
|     Error, | ||||
|     { cid: string; an: number; data: GroupDTO }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ cid, an, data }) => new GroupController(cid, an).createGroup(data), | ||||
|         onSuccess: async (response) => { | ||||
|             const cid = typeof response.group.class === "string" ? response.group.class : response.group.class.id; | ||||
|             const an = | ||||
|                 typeof response.group.assignment === "number" | ||||
|                     ? response.group.assignment | ||||
|                     : response.group.assignment.id; | ||||
| 
 | ||||
|             await queryClient.invalidateQueries({ queryKey: groupsQueryKey(cid, an, true) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: groupsQueryKey(cid, an, false) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useDeleteGroupMutation(): UseMutationReturnType< | ||||
|     GroupResponse, | ||||
|     Error, | ||||
|     { cid: string; an: number; gn: number }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ cid, an, gn }) => new GroupController(cid, an).deleteGroup(gn), | ||||
|         onSuccess: async (response) => { | ||||
|             const cid = typeof response.group.class === "string" ? response.group.class : response.group.class.id; | ||||
|             const an = | ||||
|                 typeof response.group.assignment === "number" | ||||
|                     ? response.group.assignment | ||||
|                     : response.group.assignment.id; | ||||
|             const gn = response.group.groupNumber; | ||||
| 
 | ||||
|             await invalidateAllGroupKeys(queryClient, cid, an, gn); | ||||
|             await invalidateAllSubmissionKeys(queryClient, undefined, undefined, undefined, cid, an, gn); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useUpdateGroupMutation(): UseMutationReturnType< | ||||
|     GroupResponse, | ||||
|     Error, | ||||
|     { cid: string; an: number; gn: number; data: Partial<GroupDTO> }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ cid, an, gn, data }) => new GroupController(cid, an).updateGroup(gn, data), | ||||
|         onSuccess: async (response) => { | ||||
|             const cid = typeof response.group.class === "string" ? response.group.class : response.group.class.id; | ||||
|             const an = | ||||
|                 typeof response.group.assignment === "number" | ||||
|                     ? response.group.assignment | ||||
|                     : response.group.assignment.id; | ||||
|             const gn = response.group.groupNumber; | ||||
| 
 | ||||
|             await invalidateAllGroupKeys(queryClient, cid, an, gn); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useGroupSubmissionsQuery( | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     groupNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<SubmissionsResponse, Error> { | ||||
|     const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full); | ||||
| 
 | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => groupSubmissionsQueryKey(cid!, an!, gn!, f)), | ||||
|         queryFn: async () => new GroupController(cid!, an!).getSubmissions(gn!, f), | ||||
|         enabled: () => checkEnabled(cid, an, gn), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useGroupQuestionsQuery( | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     groupNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<QuestionsResponse, Error> { | ||||
|     const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full); | ||||
| 
 | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => groupQuestionsQueryKey(cid!, an!, gn!, f)), | ||||
|         queryFn: async () => new GroupController(cid!, an!).getSubmissions(gn!, f), | ||||
|         enabled: () => checkEnabled(cid, an, gn), | ||||
|     }); | ||||
| } | ||||
|  | @ -5,7 +5,7 @@ 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"; | ||||
| export const LEARNING_OBJECT_KEY = "learningObject"; | ||||
| const learningObjectController = getLearningObjectController(); | ||||
| 
 | ||||
| export function useLearningObjectMetadataQuery( | ||||
|  |  | |||
|  | @ -4,19 +4,19 @@ 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"; | ||||
| export const LEARNING_PATH_KEY = "learningPath"; | ||||
| const learningPathController = getLearningPathController(); | ||||
| 
 | ||||
| export function useGetLearningPathQuery( | ||||
|     hruid: MaybeRefOrGetter<string>, | ||||
|     language: MaybeRefOrGetter<Language>, | ||||
|     options?: MaybeRefOrGetter<{ forGroup?: string; forStudent?: string }>, | ||||
|     forGroup?: MaybeRefOrGetter<{ forGroup: number; assignmentNo: number; classId: string } | undefined>, | ||||
| ): UseQueryReturnType<LearningPath, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_PATH_KEY, "get", hruid, language, options], | ||||
|         queryKey: [LEARNING_PATH_KEY, "get", hruid, language, forGroup], | ||||
|         queryFn: async () => { | ||||
|             const [hruidVal, languageVal, optionsVal] = [toValue(hruid), toValue(language), toValue(options)]; | ||||
|             return learningPathController.getBy(hruidVal, languageVal, optionsVal); | ||||
|             const [hruidVal, languageVal, forGroupVal] = [toValue(hruid), toValue(language), toValue(forGroup)]; | ||||
|             return learningPathController.getBy(hruidVal, languageVal, forGroupVal); | ||||
|         }, | ||||
|         enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)), | ||||
|     }); | ||||
|  | @ -34,12 +34,14 @@ export function useGetAllLearningPathsByThemeQuery( | |||
| 
 | ||||
| export function useSearchLearningPathQuery( | ||||
|     query: MaybeRefOrGetter<string | undefined>, | ||||
|     language: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<LearningPath[], Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_PATH_KEY, "search", query], | ||||
|         queryKey: [LEARNING_PATH_KEY, "search", query, language], | ||||
|         queryFn: async () => { | ||||
|             const queryVal = toValue(query)!; | ||||
|             return learningPathController.search(queryVal); | ||||
|             const languageVal = toValue(language)!; | ||||
|             return learningPathController.search(queryVal, languageVal); | ||||
|         }, | ||||
|         enabled: () => Boolean(toValue(query)), | ||||
|     }); | ||||
|  |  | |||
|  | @ -14,12 +14,12 @@ export function questionsQueryKey( | |||
|     loId: LearningObjectIdentifierDTO, | ||||
|     full: boolean, | ||||
| ): [string, string, number, string, boolean] { | ||||
|     return ["questions", loId.hruid, loId.version, loId.language, full]; | ||||
|     return ["questions", loId.hruid, loId.version!, loId.language, full]; | ||||
| } | ||||
| 
 | ||||
| export function questionQueryKey(questionId: QuestionId): [string, string, number, string, number] { | ||||
|     const loId = questionId.learningObjectIdentifier; | ||||
|     return ["question", loId.hruid, loId.version, loId.language, questionId.sequenceNumber]; | ||||
|     return ["question", loId.hruid, loId.version!, loId.language, questionId.sequenceNumber]; | ||||
| } | ||||
| 
 | ||||
| export function useQuestionsQuery( | ||||
|  | @ -39,7 +39,7 @@ export function useQuestionQuery( | |||
|     const loId = toValue(questionId).learningObjectIdentifier; | ||||
|     const sequenceNumber = toValue(questionId).sequenceNumber; | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => questionQueryKey(loId, sequenceNumber)), | ||||
|         queryKey: computed(() => questionQueryKey(toValue(questionId))), | ||||
|         queryFn: async () => new QuestionController(loId).getBy(sequenceNumber), | ||||
|         enabled: () => Boolean(toValue(questionId)), | ||||
|     }); | ||||
|  | @ -55,6 +55,7 @@ export function useCreateQuestionMutation( | |||
|         onSuccess: async () => { | ||||
|             await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), true) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), false) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: ["answers"] }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
|  | @ -88,6 +89,8 @@ export function useDeleteQuestionMutation( | |||
|             await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), true) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), false) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: questionQueryKey(toValue(questionId)) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: ["answers"] }); | ||||
|             await queryClient.invalidateQueries({ queryKey: ["answer"] }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
|  |  | |||
|  | @ -20,6 +20,7 @@ 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 { teacherClassJoinRequests } from "@/queries/teachers.ts"; | ||||
| 
 | ||||
| const studentController = new StudentController(); | ||||
| 
 | ||||
|  | @ -174,13 +175,13 @@ export function useCreateJoinRequestMutation(): UseMutationReturnType< | |||
|     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.username), | ||||
|             }); | ||||
|             await queryClient.invalidateQueries({ queryKey: teacherClassJoinRequests(newJoinRequest.request.class) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
|  | @ -200,6 +201,7 @@ export function useDeleteJoinRequestMutation(): UseMutationReturnType< | |||
|             const classId = deletedJoinRequest.request.class; | ||||
|             await queryClient.invalidateQueries({ queryKey: studentJoinRequestsQueryKey(username) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: studentJoinRequestQueryKey(username, classId) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: teacherClassJoinRequests(classId) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										225
									
								
								frontend/src/queries/submissions.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								frontend/src/queries/submissions.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,225 @@ | |||
| import { SubmissionController, type SubmissionResponse } from "@/controllers/submissions"; | ||||
| import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission"; | ||||
| import { | ||||
|     QueryClient, | ||||
|     useMutation, | ||||
|     type UseMutationReturnType, | ||||
|     useQuery, | ||||
|     useQueryClient, | ||||
|     type UseQueryReturnType, | ||||
| } from "@tanstack/vue-query"; | ||||
| import { computed, type MaybeRefOrGetter, toValue } from "vue"; | ||||
| import { LEARNING_PATH_KEY } from "@/queries/learning-paths.ts"; | ||||
| import { LEARNING_OBJECT_KEY } from "@/queries/learning-objects.ts"; | ||||
| import type { Language } from "@dwengo-1/common/util/language"; | ||||
| 
 | ||||
| export const SUBMISSION_KEY = "submissions"; | ||||
| 
 | ||||
| type SubmissionQueryKey = ["submission", string, Language | undefined, number, string, number, number, number]; | ||||
| 
 | ||||
| function submissionQueryKey( | ||||
|     hruid: string, | ||||
|     language: Language, | ||||
|     version: number, | ||||
|     classid: string, | ||||
|     assignmentNumber: number, | ||||
|     groupNumber: number, | ||||
|     submissionNumber: number, | ||||
| ): SubmissionQueryKey { | ||||
|     return ["submission", hruid, language, version, classid, assignmentNumber, groupNumber, submissionNumber]; | ||||
| } | ||||
| 
 | ||||
| export async function invalidateAllSubmissionKeys( | ||||
|     queryClient: QueryClient, | ||||
|     hruid?: string, | ||||
|     language?: Language, | ||||
|     version?: number, | ||||
|     classid?: string, | ||||
|     assignmentNumber?: number, | ||||
|     groupNumber?: number, | ||||
|     submissionNumber?: number, | ||||
| ): Promise<void> { | ||||
|     const keys = ["submission"]; | ||||
| 
 | ||||
|     await Promise.all( | ||||
|         keys.map(async (key) => { | ||||
|             const queryKey = [ | ||||
|                 key, | ||||
|                 hruid, | ||||
|                 language, | ||||
|                 version, | ||||
|                 classid, | ||||
|                 assignmentNumber, | ||||
|                 groupNumber, | ||||
|                 submissionNumber, | ||||
|             ].filter((arg) => arg !== undefined); | ||||
|             return queryClient.invalidateQueries({ queryKey: queryKey }); | ||||
|         }), | ||||
|     ); | ||||
| 
 | ||||
|     await queryClient.invalidateQueries({ | ||||
|         queryKey: ["submissions", hruid, language, version, classid, assignmentNumber, groupNumber].filter( | ||||
|             (arg) => arg !== undefined, | ||||
|         ), | ||||
|     }); | ||||
|     await queryClient.invalidateQueries({ | ||||
|         queryKey: ["group-submissions", hruid, language, version, classid, assignmentNumber, groupNumber].filter( | ||||
|             (arg) => arg !== undefined, | ||||
|         ), | ||||
|     }); | ||||
|     await queryClient.invalidateQueries({ | ||||
|         queryKey: ["assignment-submissions", hruid, language, version, classid, assignmentNumber].filter( | ||||
|             (arg) => arg !== undefined, | ||||
|         ), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| function checkEnabled(properties: MaybeRefOrGetter<unknown>[]): boolean { | ||||
|     return properties.every((prop) => Boolean(toValue(prop))); | ||||
| } | ||||
| 
 | ||||
| export function useSubmissionsQuery( | ||||
|     hruid: MaybeRefOrGetter<string | undefined>, | ||||
|     language: MaybeRefOrGetter<Language | undefined>, | ||||
|     version: MaybeRefOrGetter<number | undefined>, | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     groupNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = true, | ||||
| ): UseQueryReturnType<SubmissionDTO[], Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: ["submissions", hruid, language, version, classid, assignmentNumber, groupNumber, full], | ||||
|         queryFn: async () => { | ||||
|             const hruidVal = toValue(hruid); | ||||
|             const languageVal = toValue(language); | ||||
|             const versionVal = toValue(version); | ||||
|             const classIdVal = toValue(classid); | ||||
|             const assignmentNumberVal = toValue(assignmentNumber); | ||||
|             const groupNumberVal = toValue(groupNumber); | ||||
|             const fullVal = toValue(full); | ||||
| 
 | ||||
|             const response = await new SubmissionController(hruidVal!).getAll( | ||||
|                 languageVal, | ||||
|                 versionVal!, | ||||
|                 classIdVal!, | ||||
|                 assignmentNumberVal!, | ||||
|                 groupNumberVal, | ||||
|                 fullVal, | ||||
|             ); | ||||
|             return response ? (response.submissions as SubmissionDTO[]) : undefined; | ||||
|         }, | ||||
|         enabled: () => checkEnabled([hruid, language, version, classid, assignmentNumber]), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useSubmissionQuery( | ||||
|     hruid: MaybeRefOrGetter<string | undefined>, | ||||
|     language: MaybeRefOrGetter<Language | undefined>, | ||||
|     version: MaybeRefOrGetter<number | undefined>, | ||||
|     classid: MaybeRefOrGetter<string | undefined>, | ||||
|     assignmentNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     groupNumber: MaybeRefOrGetter<number | undefined>, | ||||
|     submissionNumber: MaybeRefOrGetter<number | undefined>, | ||||
| ): UseQueryReturnType<SubmissionResponse, Error> { | ||||
|     const hruidVal = toValue(hruid); | ||||
|     const languageVal = toValue(language); | ||||
|     const versionVal = toValue(version); | ||||
|     const classIdVal = toValue(classid); | ||||
|     const assignmentNumberVal = toValue(assignmentNumber); | ||||
|     const groupNumberVal = toValue(groupNumber); | ||||
|     const submissionNumberVal = toValue(submissionNumber); | ||||
| 
 | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => | ||||
|             submissionQueryKey( | ||||
|                 hruidVal!, | ||||
|                 languageVal, | ||||
|                 versionVal!, | ||||
|                 classIdVal!, | ||||
|                 assignmentNumberVal!, | ||||
|                 groupNumberVal!, | ||||
|                 submissionNumberVal!, | ||||
|             ), | ||||
|         ), | ||||
|         queryFn: async () => | ||||
|             new SubmissionController(hruidVal!).getByNumber( | ||||
|                 languageVal, | ||||
|                 versionVal!, | ||||
|                 classIdVal!, | ||||
|                 assignmentNumberVal!, | ||||
|                 groupNumberVal!, | ||||
|                 submissionNumberVal!, | ||||
|             ), | ||||
|         enabled: () => | ||||
|             Boolean(hruidVal) && | ||||
|             Boolean(languageVal) && | ||||
|             Boolean(versionVal) && | ||||
|             Boolean(classIdVal) && | ||||
|             Boolean(assignmentNumberVal) && | ||||
|             Boolean(submissionNumber), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useCreateSubmissionMutation(): UseMutationReturnType< | ||||
|     SubmissionResponse, | ||||
|     Error, | ||||
|     { data: SubmissionDTO }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ data }) => | ||||
|             new SubmissionController(data.learningObjectIdentifier.hruid).createSubmission(data), | ||||
|         onSuccess: async (response) => { | ||||
|             if (!response.submission.group) { | ||||
|                 await invalidateAllSubmissionKeys(queryClient); | ||||
|             } else { | ||||
|                 const cls = response.submission.group.class; | ||||
|                 const assignment = response.submission.group.assignment; | ||||
| 
 | ||||
|                 const cid = typeof cls === "string" ? cls : cls.id; | ||||
|                 const an = typeof assignment === "number" ? assignment : assignment.id; | ||||
|                 const gn = response.submission.group.groupNumber; | ||||
| 
 | ||||
|                 const { hruid, language, version } = response.submission.learningObjectIdentifier; | ||||
|                 await invalidateAllSubmissionKeys(queryClient, hruid, language, version, cid, an, gn); | ||||
| 
 | ||||
|                 await queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY, "get"] }); | ||||
| 
 | ||||
|                 await queryClient.invalidateQueries({ | ||||
|                     queryKey: [LEARNING_OBJECT_KEY, "metadata", hruid, language, version], | ||||
|                 }); | ||||
|             } | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useDeleteSubmissionMutation(): UseMutationReturnType< | ||||
|     SubmissionResponse, | ||||
|     Error, | ||||
|     { cid: string; an: number; gn: number; sn: number }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ cid, sn }) => new SubmissionController(cid).deleteSubmission(sn), | ||||
|         onSuccess: async (response) => { | ||||
|             if (!response.submission.group) { | ||||
|                 await invalidateAllSubmissionKeys(queryClient); | ||||
|             } else { | ||||
|                 const cls = response.submission.group.class; | ||||
|                 const assignment = response.submission.group.assignment; | ||||
| 
 | ||||
|                 const cid = typeof cls === "string" ? cls : cls.id; | ||||
|                 const an = typeof assignment === "number" ? assignment : assignment.id; | ||||
|                 const gn = response.submission.group.groupNumber; | ||||
| 
 | ||||
|                 const { hruid, language, version } = response.submission.learningObjectIdentifier; | ||||
| 
 | ||||
|                 await invalidateAllSubmissionKeys(queryClient, hruid, language, version, cid, an, gn); | ||||
|             } | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										135
									
								
								frontend/src/queries/teacher-invitations.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								frontend/src/queries/teacher-invitations.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,135 @@ | |||
| import { | ||||
|     useMutation, | ||||
|     useQuery, | ||||
|     useQueryClient, | ||||
|     type UseMutationReturnType, | ||||
|     type UseQueryReturnType, | ||||
| } from "@tanstack/vue-query"; | ||||
| import { toValue } from "vue"; | ||||
| import type { MaybeRefOrGetter } from "vue"; | ||||
| import { | ||||
|     TeacherInvitationController, | ||||
|     type TeacherInvitationResponse, | ||||
|     type TeacherInvitationsResponse, | ||||
| } from "@/controllers/teacher-invitations"; | ||||
| import type { TeacherInvitationData } from "@dwengo-1/common/interfaces/teacher-invitation"; | ||||
| 
 | ||||
| const controller = new TeacherInvitationController(); | ||||
| 
 | ||||
| /** 🔑 Query keys */ | ||||
| export function teacherInvitationsSentQueryKey(username: string): [string, string, string] { | ||||
|     return ["teacher-invitations", "sent", username]; | ||||
| } | ||||
| 
 | ||||
| export function teacherInvitationsReceivedQueryKey(username: string): [string, string, string] { | ||||
|     return ["teacher-invitations", "received", username]; | ||||
| } | ||||
| 
 | ||||
| export function teacherInvitationQueryKey(data: TeacherInvitationData): [string, string, string, string] { | ||||
|     return ["teacher-invitation", data.sender, data.receiver, data.class]; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * All the invitations the teacher sent | ||||
|  */ | ||||
| export function useTeacherInvitationsSentQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<TeacherInvitationsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: teacherInvitationsSentQueryKey(toValue(username)!), | ||||
|         queryFn: async () => controller.getAll(toValue(username)!, true), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * All the pending invitations sent to this teacher | ||||
|  */ | ||||
| export function useTeacherInvitationsReceivedQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<TeacherInvitationsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: teacherInvitationsReceivedQueryKey(toValue(username)!), | ||||
|         queryFn: async () => controller.getAll(toValue(username)!, false), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useTeacherInvitationQuery( | ||||
|     data: MaybeRefOrGetter<TeacherInvitationData | undefined>, | ||||
| ): UseQueryReturnType<TeacherInvitationResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: teacherInvitationQueryKey(toValue(data)), | ||||
|         queryFn: async () => controller.getBy(toValue(data)), | ||||
|         enabled: () => Boolean(toValue(data)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useCreateTeacherInvitationMutation(): UseMutationReturnType< | ||||
|     TeacherInvitationResponse, | ||||
|     Error, | ||||
|     TeacherInvitationData, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (data) => controller.create(data), | ||||
|         onSuccess: async (_, data) => { | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: teacherInvitationsSentQueryKey(data.sender), | ||||
|             }); | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: teacherInvitationsReceivedQueryKey(data.receiver), | ||||
|             }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useRespondTeacherInvitationMutation(): UseMutationReturnType< | ||||
|     TeacherInvitationResponse, | ||||
|     Error, | ||||
|     TeacherInvitationData, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (data) => controller.respond(data), | ||||
|         onSuccess: async (_, data) => { | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: teacherInvitationsSentQueryKey(data.sender), | ||||
|             }); | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: teacherInvitationsReceivedQueryKey(data.receiver), | ||||
|             }); | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: teacherInvitationQueryKey(data), | ||||
|             }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useDeleteTeacherInvitationMutation(): UseMutationReturnType< | ||||
|     TeacherInvitationResponse, | ||||
|     Error, | ||||
|     TeacherInvitationData, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async (data) => controller.remove(data), | ||||
|         onSuccess: async (_, data) => { | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: teacherInvitationsSentQueryKey(data.sender), | ||||
|             }); | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: teacherInvitationsReceivedQueryKey(data.receiver), | ||||
|             }); | ||||
|             await queryClient.invalidateQueries({ | ||||
|                 queryKey: teacherInvitationQueryKey(data), | ||||
|             }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
|  | @ -37,6 +37,10 @@ function teacherQuestionsQueryKey(username: string, full: boolean): [string, str | |||
|     return ["teacher-questions", username, full]; | ||||
| } | ||||
| 
 | ||||
| export function teacherClassJoinRequests(classId: string): [string, string] { | ||||
|     return ["teacher-class-join-requests", classId]; | ||||
| } | ||||
| 
 | ||||
| export function useTeachersQuery(full: MaybeRefOrGetter<boolean> = false): UseQueryReturnType<TeachersResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => teachersQueryKey(toValue(full))), | ||||
|  | @ -92,7 +96,7 @@ export function useTeacherJoinRequestsQuery( | |||
|     classId: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<JoinRequestsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => JOIN_REQUESTS_QUERY_KEY(toValue(username)!, toValue(classId)!)), | ||||
|         queryKey: computed(() => teacherClassJoinRequests(toValue(classId)!)), | ||||
|         queryFn: async () => teacherController.getStudentJoinRequests(toValue(username)!, toValue(classId)!), | ||||
|         enabled: () => Boolean(toValue(username)) && Boolean(toValue(classId)), | ||||
|     }); | ||||
|  | @ -133,10 +137,11 @@ export function useUpdateJoinRequestMutation(): UseMutationReturnType< | |||
|         mutationFn: async ({ teacherUsername, classId, studentUsername, accepted }) => | ||||
|             teacherController.updateStudentJoinRequest(teacherUsername, classId, studentUsername, accepted), | ||||
|         onSuccess: async (deletedJoinRequest) => { | ||||
|             const username = deletedJoinRequest.request.requester; | ||||
|             const username = deletedJoinRequest.request.requester.username; | ||||
|             const classId = deletedJoinRequest.request.class; | ||||
|             await queryClient.invalidateQueries({ queryKey: studentJoinRequestsQueryKey(username) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: studentJoinRequestQueryKey(username, classId) }); | ||||
|             await queryClient.invalidateQueries({ queryKey: teacherClassJoinRequests(classId) }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| 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"; | ||||
| import type { Theme } from "@dwengo-1/common/interfaces/theme"; | ||||
| 
 | ||||
| const themeController = getThemeController(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,19 +3,17 @@ import SingleAssignment from "@/views/assignments/SingleAssignment.vue"; | |||
| import SingleClass from "@/views/classes/SingleClass.vue"; | ||||
| import SingleDiscussion from "@/views/discussions/SingleDiscussion.vue"; | ||||
| import NotFound from "@/components/errors/NotFound.vue"; | ||||
| import CreateClass from "@/views/classes/CreateClass.vue"; | ||||
| import CreateAssignment from "@/views/assignments/CreateAssignment.vue"; | ||||
| 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/classes/UserAssignments.vue"; | ||||
| import authState from "@/services/auth/auth-service.ts"; | ||||
| import authService 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"; | ||||
| import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; | ||||
| 
 | ||||
| const router = createRouter({ | ||||
|     history: createWebHistory(import.meta.env.BASE_URL), | ||||
|  | @ -57,11 +55,12 @@ const router = createRouter({ | |||
|                     name: "UserClasses", | ||||
|                     component: UserClasses, | ||||
|                 }, | ||||
|                 { | ||||
|                     path: "discussion", | ||||
|                     name: "UserDiscussions", | ||||
|                     component: UserDiscussions, | ||||
|                 }, | ||||
|                 // TODO Re-enable this route when the discussion page is ready
 | ||||
|                 // {
 | ||||
|                 //     Path: "discussion",
 | ||||
|                 //     Name: "UserDiscussions",
 | ||||
|                 //     Component: UserDiscussions,
 | ||||
|                 // },
 | ||||
|             ], | ||||
|         }, | ||||
| 
 | ||||
|  | @ -84,12 +83,6 @@ const router = createRouter({ | |||
|             component: SingleAssignment, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/class/create", | ||||
|             name: "CreateClass", | ||||
|             component: CreateClass, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/class/:id", | ||||
|             name: "SingleClass", | ||||
|  | @ -145,9 +138,8 @@ const router = createRouter({ | |||
| router.beforeEach(async (to, _from, next) => { | ||||
|     // Verify if user is logged in before accessing certain routes
 | ||||
|     if (to.meta.requiresAuth) { | ||||
|         if (!authState.isLoggedIn.value) { | ||||
|             //Next("/login");
 | ||||
|             next(); | ||||
|         if (!authService.isLoggedIn.value) { | ||||
|             next("/login"); | ||||
|         } else { | ||||
|             next(); | ||||
|         } | ||||
|  |  | |||
|  | @ -26,7 +26,7 @@ async function getUserManagers(): Promise<UserManagersForRoles> { | |||
| const authState = reactive<AuthState>({ | ||||
|     user: null, | ||||
|     accessToken: null, | ||||
|     activeRole: authStorage.getActiveRole() || null, | ||||
|     activeRole: authStorage.getActiveRole() ?? null, | ||||
| }); | ||||
| 
 | ||||
| /** | ||||
|  | @ -38,18 +38,38 @@ async function loadUser(): Promise<User | null> { | |||
|         return null; | ||||
|     } | ||||
|     const user = await (await getUserManagers())[activeRole].getUser(); | ||||
|     authState.user = user; | ||||
|     authState.accessToken = user?.access_token || null; | ||||
|     authState.activeRole = activeRole || null; | ||||
|     setUserAuthInfo(user); | ||||
|     authState.activeRole = activeRole ?? null; | ||||
|     return user; | ||||
| } | ||||
| 
 | ||||
| const isLoggedIn = computed(() => authState.user !== null); | ||||
| 
 | ||||
| /** | ||||
|  * Clears all the cached information about the current authentication. | ||||
|  */ | ||||
| function clearAuthState(): void { | ||||
|     authStorage.deleteActiveRole(); | ||||
|     authState.accessToken = null; | ||||
|     authState.user = null; | ||||
|     authState.activeRole = null; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Sets the information about the currently logged-in user in the cache. | ||||
|  */ | ||||
| function setUserAuthInfo(newUser: User | null): void { | ||||
|     authState.user = newUser; | ||||
|     authState.accessToken = newUser?.access_token ?? 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(): Promise<void> { | ||||
|     if (isLoggedIn.value) { | ||||
|         clearAuthState(); | ||||
|     } | ||||
|     await router.push(loginRoute); | ||||
| } | ||||
| 
 | ||||
|  | @ -72,6 +92,7 @@ async function handleLoginCallback(): Promise<void> { | |||
|         throw new Error("Login callback received, but the user is not logging in!"); | ||||
|     } | ||||
|     authState.user = (await (await getUserManagers())[activeRole].signinCallback()) || null; | ||||
|     await apiClient.post("/auth/hello"); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | @ -80,14 +101,14 @@ async function handleLoginCallback(): Promise<void> { | |||
| async function renewToken(): Promise<User | null> { | ||||
|     const activeRole = authStorage.getActiveRole(); | ||||
|     if (!activeRole) { | ||||
|         // FIXME console.log("Can't renew the token: Not logged in!");
 | ||||
|         await initiateLogin(); | ||||
|         return null; | ||||
|     } | ||||
|     try { | ||||
|         return await (await getUserManagers())[activeRole].signinSilent(); | ||||
|     } catch (_error) { | ||||
|         // FIXME console.log("Can't renew the token: " + error);
 | ||||
|         const userManagerForRole = (await getUserManagers())[activeRole]; | ||||
|         const user = await userManagerForRole.signinSilent(); | ||||
|         setUserAuthInfo(user); | ||||
|     } catch (_error: unknown) { | ||||
|         await initiateLogin(); | ||||
|     } | ||||
|     return null; | ||||
|  | @ -101,6 +122,7 @@ async function logout(): Promise<void> { | |||
|     if (activeRole) { | ||||
|         await (await getUserManagers())[activeRole].signoutRedirect(); | ||||
|         authStorage.deleteActiveRole(); | ||||
|         clearAuthState(); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | @ -119,13 +141,15 @@ apiClient.interceptors.request.use( | |||
| // Registering interceptor to refresh the token when a request failed because it was expired.
 | ||||
| apiClient.interceptors.response.use( | ||||
|     (response) => response, | ||||
|     async (error: AxiosError<{ message?: string }>) => { | ||||
|     async (error: AxiosError<{ message?: string; inner?: { message?: string } }>) => { | ||||
|         if (error.response?.status === 401) { | ||||
|             if (error.response.data.message === "token_expired") { | ||||
|                 // FIXME console.log("Access token expired, trying to refresh...");
 | ||||
|             // If the user should already be logged in, his token is probably just expired.
 | ||||
|             if (isLoggedIn.value) { | ||||
|                 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.
 | ||||
|             } | ||||
| 
 | ||||
|             // Apparently, the user got a 401 because he was not logged in yet at all. Redirect him to login.
 | ||||
|             await initiateLogin(); | ||||
|         } | ||||
|         return Promise.reject(error); | ||||
|  |  | |||
							
								
								
									
										5
									
								
								frontend/src/utils/array-utils.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/utils/array-utils.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| export function copyArrayWith<T>(index: number, newValue: T, array: T[]): T[] { | ||||
|     const copy = [...array]; | ||||
|     copy[index] = newValue; | ||||
|     return copy; | ||||
| } | ||||
							
								
								
									
										29
									
								
								frontend/src/utils/deep-equals.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								frontend/src/utils/deep-equals.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| export function deepEquals<T>(a: T, b: T): boolean { | ||||
|     if (a === b) { | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     if (Array.isArray(a) !== Array.isArray(b)) { | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     if (Array.isArray(a) && Array.isArray(b)) { | ||||
|         if (a.length !== b.length) { | ||||
|             return false; | ||||
|         } | ||||
|         return a.every((val, i) => deepEquals(val, b[i])); | ||||
|     } | ||||
| 
 | ||||
|     const keysA = Object.keys(a) as (keyof T)[]; | ||||
|     const keysB = Object.keys(b) as (keyof T)[]; | ||||
| 
 | ||||
|     if (keysA.length !== keysB.length) { | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     return keysA.every((key) => deepEquals(a[key], b[key])); | ||||
| } | ||||
|  | @ -1,8 +1,11 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useRouter } from "vue-router"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { onMounted, ref, type Ref } from "vue"; | ||||
|     import auth from "../services/auth/auth-service.ts"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const router = useRouter(); | ||||
| 
 | ||||
|     const errorMessage: Ref<string | null> = ref(null); | ||||
|  | @ -12,14 +15,34 @@ | |||
|             await auth.handleLoginCallback(); | ||||
|             await router.replace("/user"); // Redirect to theme page | ||||
|         } catch (error) { | ||||
|             errorMessage.value = `OIDC callback error: ${error}`; | ||||
|             errorMessage.value = `${t("loginUnexpectedError")}: ${error}`; | ||||
|         } | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <p v-if="!errorMessage">Logging you in...</p> | ||||
|     <p v-else>{{ errorMessage }}</p> | ||||
|     <div class="callback"> | ||||
|         <div | ||||
|             class="callback-loading" | ||||
|             v-if="!errorMessage" | ||||
|         > | ||||
|             <v-progress-circular indeterminate></v-progress-circular> | ||||
|             <p>{{ t("callbackLoading") }}</p> | ||||
|         </div> | ||||
|         <v-alert | ||||
|             icon="mdi-alert-circle" | ||||
|             type="error" | ||||
|             variant="elevated" | ||||
|             v-if="errorMessage" | ||||
|         > | ||||
|             {{ errorMessage }} | ||||
|         </v-alert> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
| <style scoped> | ||||
|     .callback { | ||||
|         text-align: center; | ||||
|         margin: 20px; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -1,7 +1,14 @@ | |||
| <script setup lang="ts"></script> | ||||
| <script setup lang="ts"> | ||||
|     import { useRoute } from "vue-router"; | ||||
| 
 | ||||
|     const route = useRoute(); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <main></main> | ||||
|     <main> | ||||
|         Hier zou de pagina staan om een assignment aan te maken voor de leerpad met hruid {{ route.query.hruid }} en | ||||
|         language {{ route.query.language }}. (Overschrijf dit) | ||||
|     </main> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
|  |  | |||
|  | @ -1,7 +0,0 @@ | |||
| <script setup lang="ts"></script> | ||||
| 
 | ||||
| <template> | ||||
|     <main></main> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
|  | @ -1,135 +1,358 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import authState from "@/services/auth/auth-service.ts"; | ||||
|     import { onMounted, ref } from "vue"; | ||||
|     import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; | ||||
|     import { onMounted, ref, watchEffect } from "vue"; | ||||
|     import { useRoute } from "vue-router"; | ||||
|     import { ClassController, type ClassResponse } from "@/controllers/classes"; | ||||
|     import type { StudentsResponse } from "@/controllers/students"; | ||||
|     import type { ClassResponse } from "@/controllers/classes"; | ||||
|     import type { JoinRequestsResponse, StudentsResponse } from "@/controllers/students"; | ||||
|     import type { StudentDTO } from "@dwengo-1/common/interfaces/student"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { useTeacherJoinRequestsQuery, useUpdateJoinRequestMutation } from "@/queries/teachers"; | ||||
|     import type { ClassJoinRequestDTO } from "@dwengo-1/common/interfaces/class-join-request"; | ||||
|     import { useClassDeleteStudentMutation, useClassQuery, useClassStudentsQuery } from "@/queries/classes"; | ||||
|     import { useCreateTeacherInvitationMutation } from "@/queries/teacher-invitations"; | ||||
|     import type { TeacherInvitationData } from "@dwengo-1/common/interfaces/teacher-invitation"; | ||||
|     import { useDisplay } from "vuetify"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     // Username of logged in teacher | ||||
|     const username = ref<string | undefined>(undefined); | ||||
|     const classController: ClassController = new ClassController(); | ||||
| 
 | ||||
|     // Find class id from route | ||||
|     const route = useRoute(); | ||||
|     const classId: string = route.params.id as string; | ||||
|     const username = ref<string | undefined>(undefined); | ||||
|     const isLoading = ref(false); | ||||
|     const isError = ref(false); | ||||
|     const errorMessage = ref<string>(""); | ||||
|     const usernameTeacher = ref<string | undefined>(undefined); | ||||
| 
 | ||||
|     const isLoading = ref(true); | ||||
|     const currentClass = ref<ClassDTO | undefined>(undefined); | ||||
|     const students = ref<StudentDTO[]>([]); | ||||
|     // Queries used to access the backend and catch loading or errors | ||||
| 
 | ||||
|     // Find the username of the logged in user so it can be used to fetch other information | ||||
|     // When loading the page | ||||
|     // Gets the class a teacher wants to manage | ||||
|     const getClass = useClassQuery(classId); | ||||
|     // Get all students part of the class | ||||
|     const getStudents = useClassStudentsQuery(classId); | ||||
|     // Get all join requests for this class | ||||
|     const joinRequestsQuery = useTeacherJoinRequestsQuery(username, classId); | ||||
|     // Handle accepting or rejecting join requests | ||||
|     const { mutate } = useUpdateJoinRequestMutation(); | ||||
|     // Handle deletion of a student from the class | ||||
|     const { mutate: deleteStudentMutation } = useClassDeleteStudentMutation(); | ||||
|     // Handle creation of teacher invites | ||||
|     const { mutate: sentInviteMutation } = useCreateTeacherInvitationMutation(); | ||||
| 
 | ||||
|     // Load current user before rendering the page | ||||
|     onMounted(async () => { | ||||
|         const userObject = await authState.loadUser(); | ||||
|         username.value = userObject?.profile?.preferred_username ?? undefined; | ||||
| 
 | ||||
|         // Get class of which information should be shown | ||||
|         const classResponse: ClassResponse = await classController.getById(classId); | ||||
|         if (classResponse && classResponse.class) { | ||||
|             currentClass.value = classResponse.class; | ||||
|         isLoading.value = true; | ||||
|         try { | ||||
|             const userObject = await authState.loadUser(); | ||||
|             username.value = userObject!.profile.preferred_username; | ||||
|         } catch (error) { | ||||
|             isError.value = true; | ||||
|             errorMessage.value = error instanceof Error ? error.message : String(error); | ||||
|         } finally { | ||||
|             isLoading.value = false; | ||||
|         } | ||||
| 
 | ||||
|         // Fetch all students of the class | ||||
|         const studentsResponse: StudentsResponse = await classController.getStudents(classId); | ||||
|         if (studentsResponse && studentsResponse.students) students.value = studentsResponse.students as StudentDTO[]; | ||||
|     }); | ||||
| 
 | ||||
|     // TODO: Boolean that handles visibility for dialogs | ||||
|     // Popup to verify removing student | ||||
|     // Used to set the visibility of the dialog | ||||
|     const dialog = ref(false); | ||||
|     // Student selected for deletion | ||||
|     const selectedStudent = ref<StudentDTO | null>(null); | ||||
| 
 | ||||
|     // Let the teacher verify deletion of a student | ||||
|     function showPopup(s: StudentDTO): void { | ||||
|         selectedStudent.value = s; | ||||
|         dialog.value = true; | ||||
|     } | ||||
| 
 | ||||
|     // Remove student from class | ||||
|     function removeStudentFromclass(): void { | ||||
|         dialog.value = false; | ||||
|     async function removeStudentFromclass(): Promise<void> { | ||||
|         // Delete student from class | ||||
|         deleteStudentMutation( | ||||
|             { id: classId, username: selectedStudent.value!.username }, | ||||
|             { | ||||
|                 onSuccess: async () => { | ||||
|                     dialog.value = false; | ||||
|                     await getStudents.refetch(); | ||||
|                     showSnackbar(t("success"), "success"); | ||||
|                 }, | ||||
|                 onError: (e) => { | ||||
|                     dialog.value = false; | ||||
|                     showSnackbar(t("failed") + ": " + e.message, "error"); | ||||
|                 }, | ||||
|             }, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     function handleJoinRequest(c: ClassJoinRequestDTO, accepted: boolean): void { | ||||
|         // Handle acception or rejection of a join request | ||||
|         mutate( | ||||
|             { | ||||
|                 teacherUsername: username.value!, | ||||
|                 studentUsername: c.requester.username, | ||||
|                 classId: c.class, | ||||
|                 accepted: accepted, | ||||
|             }, | ||||
|             { | ||||
|                 onSuccess: async () => { | ||||
|                     if (accepted) { | ||||
|                         await joinRequestsQuery.refetch(); | ||||
|                         await getStudents.refetch(); | ||||
| 
 | ||||
|                         showSnackbar(t("accepted"), "success"); | ||||
|                     } else { | ||||
|                         await joinRequestsQuery.refetch(); | ||||
|                         showSnackbar(t("rejected"), "success"); | ||||
|                     } | ||||
|                 }, | ||||
|                 onError: (e) => { | ||||
|                     showSnackbar(t("failed") + ": " + e.message, "error"); | ||||
|                 }, | ||||
|             }, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     function sentInvite(): void { | ||||
|         if (!usernameTeacher.value) { | ||||
|             showSnackbar(t("please enter a valid username"), "error"); | ||||
|             return; | ||||
|         } | ||||
|         const data: TeacherInvitationData = { | ||||
|             sender: username.value!, | ||||
|             receiver: usernameTeacher.value, | ||||
|             class: classId, | ||||
|         }; | ||||
|         sentInviteMutation(data, { | ||||
|             onSuccess: () => { | ||||
|                 usernameTeacher.value = ""; | ||||
|             }, | ||||
|             onError: (e) => { | ||||
|                 showSnackbar(t("failed") + ": " + e.message, "error"); | ||||
|             }, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     // Default of snackbar values | ||||
|     const snackbar = ref({ | ||||
|         visible: false, | ||||
|         message: "", | ||||
|         color: "success", | ||||
|     }); | ||||
| 
 | ||||
|     // Function to show snackbar on success or failure | ||||
|     function showSnackbar(message: string, color: string): void { | ||||
|         snackbar.value.message = message; | ||||
|         snackbar.value.color = color; | ||||
|         snackbar.value.visible = true; | ||||
|     } | ||||
| 
 | ||||
|     // Custom breakpoints | ||||
|     const customBreakpoints = { | ||||
|         xs: 0, | ||||
|         sm: 500, | ||||
|         md: 1370, | ||||
|         lg: 1400, | ||||
|         xl: 1600, | ||||
|     }; | ||||
| 
 | ||||
|     // Logic for small screens | ||||
|     const display = useDisplay(); | ||||
| 
 | ||||
|     // Reactive variables to hold custom logic based on breakpoints | ||||
|     const isSmAndDown = ref(false); | ||||
|     const isMdAndDown = ref(false); | ||||
| 
 | ||||
|     watchEffect(() => { | ||||
|         // Custom breakpoint logic | ||||
|         isSmAndDown.value = display.width.value < customBreakpoints.sm; | ||||
|         isMdAndDown.value = display.width.value < customBreakpoints.md; | ||||
|     }); | ||||
| </script> | ||||
| <template> | ||||
|     <main> | ||||
|         <div | ||||
|             class="loading-div" | ||||
|             v-if="isLoading" | ||||
|             class="text-center py-10" | ||||
|         > | ||||
|             <v-progress-circular | ||||
|                 indeterminate | ||||
|                 color="primary" | ||||
|             /> | ||||
|             <p>Loading...</p> | ||||
|             <v-progress-circular indeterminate></v-progress-circular> | ||||
|         </div> | ||||
|         <div v-else> | ||||
|             <h1 class="title">{{ currentClass!.displayName }}</h1> | ||||
|             <v-container | ||||
|                 fluid | ||||
|                 class="ma-4" | ||||
|             > | ||||
|                 <v-row | ||||
|                     no-gutters | ||||
|                     fluid | ||||
|         <div v-if="isError"> | ||||
|             <v-empty-state | ||||
|                 icon="mdi-alert-circle-outline" | ||||
|                 :text="errorMessage" | ||||
|                 :title="t('error_title')" | ||||
|             ></v-empty-state> | ||||
|         </div> | ||||
|         <using-query-result | ||||
|             :query-result="getClass" | ||||
|             v-slot="classResponse: { data: ClassResponse }" | ||||
|         > | ||||
|             <div> | ||||
|                 <h1 class="title">{{ classResponse.data.class.displayName }}</h1> | ||||
|                 <using-query-result | ||||
|                     :query-result="getStudents" | ||||
|                     v-slot="studentsResponse: { data: StudentsResponse }" | ||||
|                 > | ||||
|                     <v-col | ||||
|                         cols="12" | ||||
|                         sm="6" | ||||
|                         md="6" | ||||
|                     <v-container | ||||
|                         fluid | ||||
|                         class="ma-4" | ||||
|                     > | ||||
|                         <v-table class="table"> | ||||
|                             <thead> | ||||
|                                 <tr> | ||||
|                                     <th class="header">{{ t("students") }}</th> | ||||
|                                     <th class="header"></th> | ||||
|                                 </tr> | ||||
|                             </thead> | ||||
|                             <tbody> | ||||
|                                 <tr | ||||
|                                     v-for="s in students" | ||||
|                                     :key="s.id" | ||||
|                         <v-row | ||||
|                             no-gutters | ||||
|                             fluid | ||||
|                         > | ||||
|                             <v-col | ||||
|                                 cols="12" | ||||
|                                 sm="6" | ||||
|                                 md="6" | ||||
|                             > | ||||
|                                 <v-table class="table"> | ||||
|                                     <thead> | ||||
|                                         <tr> | ||||
|                                             <th class="header">{{ t("students") }}</th> | ||||
|                                             <th class="header"></th> | ||||
|                                         </tr> | ||||
|                                     </thead> | ||||
|                                     <tbody> | ||||
|                                         <tr | ||||
|                                             v-for="s in studentsResponse.data.students as StudentDTO[]" | ||||
|                                             :key="s.id" | ||||
|                                         > | ||||
|                                             <td> | ||||
|                                                 {{ s.firstName + " " + s.lastName }} | ||||
|                                             </td> | ||||
|                                             <td> | ||||
|                                                 <v-btn @click="showPopup(s)"> {{ t("remove") }} </v-btn> | ||||
|                                             </td> | ||||
|                                         </tr> | ||||
|                                     </tbody> | ||||
|                                 </v-table> | ||||
|                             </v-col> | ||||
|                             <using-query-result | ||||
|                                 :query-result="joinRequestsQuery" | ||||
|                                 v-slot="joinRequests: { data: JoinRequestsResponse }" | ||||
|                             > | ||||
|                                 <v-col | ||||
|                                     cols="12" | ||||
|                                     sm="6" | ||||
|                                     md="6" | ||||
|                                 > | ||||
|                                     <td> | ||||
|                                         {{ s.firstName + " " + s.lastName }} | ||||
|                                     </td> | ||||
|                                     <td> | ||||
|                                         <v-btn @click="showPopup"> {{ t("remove") }} </v-btn> | ||||
|                                     </td> | ||||
|                                 </tr> | ||||
|                             </tbody> | ||||
|                         </v-table> | ||||
|                     </v-col> | ||||
|                 </v-row> | ||||
|             </v-container> | ||||
|         </div> | ||||
|         <v-dialog | ||||
|             v-model="dialog" | ||||
|             max-width="400px" | ||||
|         > | ||||
|             <v-card> | ||||
|                 <v-card-title class="headline">{{ t("areusure") }}</v-card-title> | ||||
|                                     <v-table class="table"> | ||||
|                                         <thead> | ||||
|                                             <tr> | ||||
|                                                 <th class="header">{{ t("classJoinRequests") }}</th> | ||||
|                                                 <th class="header">{{ t("accept") + "/" + t("reject") }}</th> | ||||
|                                             </tr> | ||||
|                                         </thead> | ||||
|                                         <tbody> | ||||
|                                             <tr | ||||
|                                                 v-for="jr in joinRequests.data.joinRequests as ClassJoinRequestDTO[]" | ||||
|                                                 :key="(jr.class, jr.requester, jr.status)" | ||||
|                                             > | ||||
|                                                 <td> | ||||
|                                                     {{ jr.requester.firstName + " " + jr.requester.lastName }} | ||||
|                                                 </td> | ||||
|                                                 <td> | ||||
|                                                     <span v-if="!isSmAndDown && !isMdAndDown"> | ||||
|                                                         <v-btn | ||||
|                                                             @click="handleJoinRequest(jr, true)" | ||||
|                                                             class="mr-2" | ||||
|                                                             color="green" | ||||
|                                                         > | ||||
|                                                             {{ t("accept") }}</v-btn | ||||
|                                                         > | ||||
| 
 | ||||
|                 <v-card-actions> | ||||
|                     <v-spacer></v-spacer> | ||||
|                     <v-btn | ||||
|                         text | ||||
|                         @click="dialog = false" | ||||
|                                                         <v-btn | ||||
|                                                             @click="handleJoinRequest(jr, false)" | ||||
|                                                             class="mr-2" | ||||
|                                                             color="red" | ||||
|                                                         > | ||||
|                                                             {{ t("reject") }} | ||||
|                                                         </v-btn> | ||||
|                                                     </span> | ||||
|                                                     <span v-else> | ||||
|                                                         <v-btn | ||||
|                                                             @click="handleJoinRequest(jr, true)" | ||||
|                                                             icon="mdi-check-circle" | ||||
|                                                             class="mr-2" | ||||
|                                                             color="green" | ||||
|                                                             variant="text" | ||||
|                                                         ></v-btn> | ||||
|                                                         <v-btn | ||||
|                                                             @click="handleJoinRequest(jr, false)" | ||||
|                                                             icon="mdi-close-circle" | ||||
|                                                             class="mr-2" | ||||
|                                                             color="red" | ||||
|                                                             variant="text" | ||||
|                                                         ></v-btn> | ||||
|                                                     </span> | ||||
|                                                 </td> | ||||
|                                             </tr> | ||||
|                                         </tbody> | ||||
|                                     </v-table> | ||||
|                                 </v-col> | ||||
|                             </using-query-result> | ||||
|                         </v-row> | ||||
|                     </v-container> | ||||
|                 </using-query-result> | ||||
|             </div> | ||||
|             <div> | ||||
|                 <div class="join"> | ||||
|                     <h2>{{ t("invitations") }}</h2> | ||||
|                     <p>{{ t("enterUsername") }}</p> | ||||
| 
 | ||||
|                     <v-sheet | ||||
|                         class="pa-4 sheet" | ||||
|                         max-width="400" | ||||
|                     > | ||||
|                         {{ t("cancel") }} | ||||
|                     </v-btn> | ||||
|                     <v-btn | ||||
|                         text | ||||
|                         @click="removeStudentFromclass" | ||||
|                         >{{ t("yes") }}</v-btn | ||||
|                     > | ||||
|                 </v-card-actions> | ||||
|             </v-card> | ||||
|         </v-dialog> | ||||
|                         <v-form @submit.prevent> | ||||
|                             <v-text-field | ||||
|                                 :label="`${t('username')}`" | ||||
|                                 v-model="usernameTeacher" | ||||
|                                 :placeholder="`${t('username')}`" | ||||
|                                 variant="outlined" | ||||
|                             ></v-text-field> | ||||
|                             <v-btn | ||||
|                                 class="mt-4" | ||||
|                                 color="#f6faf2" | ||||
|                                 type="submit" | ||||
|                                 @click="sentInvite" | ||||
|                                 block | ||||
|                                 >{{ t("invite") }}</v-btn | ||||
|                             > | ||||
|                         </v-form> | ||||
|                     </v-sheet> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <v-dialog | ||||
|                 v-model="dialog" | ||||
|                 max-width="400px" | ||||
|             > | ||||
|                 <v-card> | ||||
|                     <v-card-title class="headline">{{ t("areusure") }}</v-card-title> | ||||
| 
 | ||||
|                     <v-card-actions> | ||||
|                         <v-spacer></v-spacer> | ||||
|                         <v-btn | ||||
|                             text | ||||
|                             @click="dialog = false" | ||||
|                         > | ||||
|                             {{ t("cancel") }} | ||||
|                         </v-btn> | ||||
|                         <v-btn | ||||
|                             text | ||||
|                             @click="removeStudentFromclass" | ||||
|                             >{{ t("yes") }}</v-btn | ||||
|                         > | ||||
|                     </v-card-actions> | ||||
|                 </v-card> | ||||
|             </v-dialog> | ||||
|             <v-snackbar | ||||
|                 v-model="snackbar.visible" | ||||
|                 :color="snackbar.color" | ||||
|                 timeout="3000" | ||||
|             > | ||||
|                 {{ snackbar.message }} | ||||
|             </v-snackbar> | ||||
|         </using-query-result> | ||||
|     </main> | ||||
| </template> | ||||
| <style scoped> | ||||
|  |  | |||
|  | @ -1,51 +1,51 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import authState from "@/services/auth/auth-service.ts"; | ||||
|     import { computed, onMounted, ref, type ComputedRef } from "vue"; | ||||
|     import { computed, onMounted, ref } from "vue"; | ||||
|     import { validate, version } from "uuid"; | ||||
|     import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; | ||||
|     import { useCreateJoinRequestMutation, useStudentClassesQuery } from "@/queries/students"; | ||||
|     import type { StudentDTO } from "@dwengo-1/common/interfaces/student"; | ||||
|     import { StudentController } from "@/controllers/students"; | ||||
|     import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; | ||||
|     import { TeacherController } from "@/controllers/teachers"; | ||||
|     import type { ClassesResponse } from "@/controllers/classes"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { useClassStudentsQuery, useClassTeachersQuery } from "@/queries/classes"; | ||||
|     import type { StudentsResponse } from "@/controllers/students"; | ||||
|     import type { TeachersResponse } from "@/controllers/teachers"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
|     const studentController: StudentController = new StudentController(); | ||||
|     const teacherController: TeacherController = new TeacherController(); | ||||
| 
 | ||||
|     // Username of logged in student | ||||
|     const username = ref<string | undefined>(undefined); | ||||
| 
 | ||||
|     // Find the username of the logged in user so it can be used to fetch other information | ||||
|     // When loading the page | ||||
|     onMounted(async () => { | ||||
|         const userObject = await authState.loadUser(); | ||||
|         username.value = userObject?.profile?.preferred_username ?? undefined; | ||||
|     }); | ||||
| 
 | ||||
|     // Fetch all classes of the logged in student | ||||
|     const { data: classesResponse, isLoading, error } = useStudentClassesQuery(username); | ||||
| 
 | ||||
|     // Empty list when classes are not yet loaded, else the list of classes of the user | ||||
|     const classes: ComputedRef<ClassDTO[]> = computed(() => { | ||||
|         // The classes are not yet fetched | ||||
|         if (!classesResponse.value) { | ||||
|             return []; | ||||
|         } | ||||
|         // The user has no classes | ||||
|         if (classesResponse.value.classes.length === 0) { | ||||
|             return []; | ||||
|         } | ||||
|         return classesResponse.value.classes as ClassDTO[]; | ||||
|     }); | ||||
|     const isLoading = ref(false); | ||||
|     const isError = ref(false); | ||||
|     const errorMessage = ref<string>(""); | ||||
| 
 | ||||
|     // Students of selected class are shown when logged in student presses on the member count | ||||
|     const selectedClass = ref<ClassDTO | null>(null); | ||||
|     const students = ref<StudentDTO[]>([]); | ||||
|     const teachers = ref<TeacherDTO[]>([]); | ||||
|     const getStudents = ref(false); | ||||
| 
 | ||||
|     // Load current user before rendering the page | ||||
|     onMounted(async () => { | ||||
|         isLoading.value = true; | ||||
|         try { | ||||
|             const userObject = await authState.loadUser(); | ||||
|             username.value = userObject!.profile.preferred_username; | ||||
|         } catch (error) { | ||||
|             isError.value = true; | ||||
|             errorMessage.value = error instanceof Error ? error.message : String(error); | ||||
|         } finally { | ||||
|             isLoading.value = false; | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     // Fetch all classes of the logged in student | ||||
|     const classesQuery = useStudentClassesQuery(username); | ||||
|     // Fetch all students of the class | ||||
|     const getStudentsQuery = useClassStudentsQuery(computed(() => selectedClass.value?.id)); | ||||
|     // Fetch all teachers of the class | ||||
|     const getTeachersQuery = useClassTeachersQuery(computed(() => selectedClass.value?.id)); | ||||
| 
 | ||||
|     // Boolean that handles visibility for dialogs | ||||
|     // Clicking on membercount will show a dialog with all members | ||||
|     const dialog = ref(false); | ||||
|  | @ -54,48 +54,19 @@ | |||
|     async function openStudentDialog(c: ClassDTO): Promise<void> { | ||||
|         selectedClass.value = c; | ||||
| 
 | ||||
|         // Clear previous value | ||||
|         // Let the component know it should show the students in a class | ||||
|         getStudents.value = true; | ||||
|         students.value = []; | ||||
|         await getStudentsQuery.refetch(); | ||||
|         dialog.value = true; | ||||
| 
 | ||||
|         // Fetch students from their usernames to display their full names | ||||
|         const studentDTOs: (StudentDTO | null)[] = await Promise.all( | ||||
|             c.students.map(async (uid) => { | ||||
|                 try { | ||||
|                     const res = await studentController.getByUsername(uid); | ||||
|                     return res.student; | ||||
|                 } catch (_) { | ||||
|                     return null; | ||||
|                 } | ||||
|             }), | ||||
|         ); | ||||
| 
 | ||||
|         // Only show students that are not fetched ass *null* | ||||
|         students.value = studentDTOs.filter(Boolean) as StudentDTO[]; | ||||
|     } | ||||
| 
 | ||||
|     async function openTeacherDialog(c: ClassDTO): Promise<void> { | ||||
|         selectedClass.value = c; | ||||
| 
 | ||||
|         // Clear previous value | ||||
|         // Let the component know it should show teachers of a class | ||||
|         getStudents.value = false; | ||||
|         teachers.value = []; | ||||
|         await getTeachersQuery.refetch(); | ||||
|         dialog.value = true; | ||||
| 
 | ||||
|         // Fetch names of teachers | ||||
|         const teacherDTOs: (TeacherDTO | null)[] = await Promise.all( | ||||
|             c.teachers.map(async (uid) => { | ||||
|                 try { | ||||
|                     const res = await teacherController.getByUsername(uid); | ||||
|                     return res.teacher; | ||||
|                 } catch (_) { | ||||
|                     return null; | ||||
|                 } | ||||
|             }), | ||||
|         ); | ||||
| 
 | ||||
|         teachers.value = teacherDTOs.filter(Boolean) as TeacherDTO[]; | ||||
|     } | ||||
| 
 | ||||
|     // Hold the code a student gives in to join a class | ||||
|  | @ -151,100 +122,111 @@ | |||
| <template> | ||||
|     <main> | ||||
|         <div | ||||
|             class="loading-div" | ||||
|             v-if="isLoading" | ||||
|             class="text-center py-10" | ||||
|         > | ||||
|             <v-progress-circular | ||||
|                 indeterminate | ||||
|                 color="primary" | ||||
|             /> | ||||
|             <p>Loading...</p> | ||||
|             <v-progress-circular indeterminate></v-progress-circular> | ||||
|         </div> | ||||
| 
 | ||||
|         <div | ||||
|             v-else-if="error" | ||||
|             class="text-center py-10 text-error" | ||||
|         > | ||||
|             <v-icon large>mdi-alert-circle</v-icon> | ||||
|             <p>Error loading: {{ error.message }}</p> | ||||
|         <div v-if="isError"> | ||||
|             <v-empty-state | ||||
|                 icon="mdi-alert-circle-outline" | ||||
|                 :text="errorMessage" | ||||
|                 :title="t('error_title')" | ||||
|             ></v-empty-state> | ||||
|         </div> | ||||
|         <div v-else> | ||||
|             <h1 class="title">{{ t("classes") }}</h1> | ||||
|             <v-container | ||||
|                 fluid | ||||
|                 class="ma-4" | ||||
|             <using-query-result | ||||
|                 :query-result="classesQuery" | ||||
|                 v-slot="classResponse: { data: ClassesResponse }" | ||||
|             > | ||||
|                 <v-row | ||||
|                     no-gutters | ||||
|                 <v-container | ||||
|                     fluid | ||||
|                     class="ma-4" | ||||
|                 > | ||||
|                     <v-col | ||||
|                         cols="12" | ||||
|                         sm="6" | ||||
|                         md="6" | ||||
|                     <v-row | ||||
|                         no-gutters | ||||
|                         fluid | ||||
|                     > | ||||
|                         <v-table class="table"> | ||||
|                             <thead> | ||||
|                                 <tr> | ||||
|                                     <th class="header">{{ t("classes") }}</th> | ||||
|                                     <th class="header">{{ t("teachers") }}</th> | ||||
|                                     <th class="header">{{ t("members") }}</th> | ||||
|                                 </tr> | ||||
|                             </thead> | ||||
|                             <tbody> | ||||
|                                 <tr | ||||
|                                     v-for="c in classes" | ||||
|                                     :key="c.id" | ||||
|                                 > | ||||
|                                     <td>{{ c.displayName }}</td> | ||||
|                                     <td | ||||
|                                         class="link" | ||||
|                                         @click="openTeacherDialog(c)" | ||||
|                         <v-col | ||||
|                             cols="12" | ||||
|                             sm="6" | ||||
|                             md="6" | ||||
|                         > | ||||
|                             <v-table class="table"> | ||||
|                                 <thead> | ||||
|                                     <tr> | ||||
|                                         <th class="header">{{ t("classes") }}</th> | ||||
|                                         <th class="header">{{ t("teachers") }}</th> | ||||
|                                         <th class="header">{{ t("members") }}</th> | ||||
|                                     </tr> | ||||
|                                 </thead> | ||||
|                                 <tbody> | ||||
|                                     <tr | ||||
|                                         v-for="c in classResponse.data.classes as ClassDTO[]" | ||||
|                                         :key="c.id" | ||||
|                                     > | ||||
|                                         {{ c.teachers.length }} | ||||
|                                     </td> | ||||
|                                     <td | ||||
|                                         class="link" | ||||
|                                         @click="openStudentDialog(c)" | ||||
|                                     > | ||||
|                                         {{ c.students.length }} | ||||
|                                     </td> | ||||
|                                 </tr> | ||||
|                             </tbody> | ||||
|                         </v-table> | ||||
|                     </v-col> | ||||
|                 </v-row> | ||||
|             </v-container> | ||||
|                                         <td>{{ c.displayName }}</td> | ||||
|                                         <td | ||||
|                                             class="link" | ||||
|                                             @click="openTeacherDialog(c)" | ||||
|                                         > | ||||
|                                             {{ c.teachers.length }} | ||||
|                                         </td> | ||||
|                                         <td | ||||
|                                             class="link" | ||||
|                                             @click="openStudentDialog(c)" | ||||
|                                         > | ||||
|                                             {{ c.students.length }} | ||||
|                                         </td> | ||||
|                                     </tr> | ||||
|                                 </tbody> | ||||
|                             </v-table> | ||||
|                         </v-col> | ||||
|                     </v-row> | ||||
|                 </v-container> | ||||
|             </using-query-result> | ||||
| 
 | ||||
|             <v-dialog | ||||
|                 v-if="selectedClass" | ||||
|                 v-model="dialog" | ||||
|                 width="400" | ||||
|             > | ||||
|                 <v-card> | ||||
|                     <v-card-title> {{ selectedClass?.displayName }} </v-card-title> | ||||
|                     <v-card-title> {{ selectedClass!.displayName }} </v-card-title> | ||||
|                     <v-card-text> | ||||
|                         <ul v-if="getStudents"> | ||||
|                             <li | ||||
|                                 v-for="student in students" | ||||
|                                 :key="student.username" | ||||
|                             <using-query-result | ||||
|                                 :query-result="getStudentsQuery" | ||||
|                                 v-slot="studentsResponse: { data: StudentsResponse }" | ||||
|                             > | ||||
|                                 {{ student.firstName + " " + student.lastName }} | ||||
|                             </li> | ||||
|                                 <li | ||||
|                                     v-for="student in studentsResponse.data.students as StudentDTO[]" | ||||
|                                     :key="student.username" | ||||
|                                 > | ||||
|                                     {{ student.firstName + " " + student.lastName }} | ||||
|                                 </li> | ||||
|                             </using-query-result> | ||||
|                         </ul> | ||||
|                         <ul v-else> | ||||
|                             <li | ||||
|                                 v-for="teacher in teachers" | ||||
|                                 :key="teacher.username" | ||||
|                             <using-query-result | ||||
|                                 :query-result="getTeachersQuery" | ||||
|                                 v-slot="teachersResponse: { data: TeachersResponse }" | ||||
|                             > | ||||
|                                 {{ teacher.firstName + " " + teacher.lastName }} | ||||
|                             </li> | ||||
|                                 <li | ||||
|                                     v-for="teacher in teachersResponse.data.teachers as TeacherDTO[]" | ||||
|                                     :key="teacher.username" | ||||
|                                 > | ||||
|                                     {{ teacher.firstName + " " + teacher.lastName }} | ||||
|                                 </li> | ||||
|                             </using-query-result> | ||||
|                         </ul> | ||||
|                     </v-card-text> | ||||
|                     <v-card-actions> | ||||
|                         <v-btn | ||||
|                             color="primary" | ||||
|                             @click="dialog = false" | ||||
|                             >Close</v-btn | ||||
|                             >{{ t("close") }}</v-btn | ||||
|                         > | ||||
|                     </v-card-actions> | ||||
|                 </v-card> | ||||
|  |  | |||
|  | @ -1,41 +1,49 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import authState from "@/services/auth/auth-service.ts"; | ||||
|     import { computed, onMounted, ref, type ComputedRef } from "vue"; | ||||
|     import { onMounted, ref, watchEffect } from "vue"; | ||||
|     import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; | ||||
|     import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; | ||||
|     import type { TeacherInvitationDTO } from "@dwengo-1/common/interfaces/teacher-invitation"; | ||||
|     import type { TeacherInvitationData, TeacherInvitationDTO } from "@dwengo-1/common/interfaces/teacher-invitation"; | ||||
|     import { useTeacherClassesQuery } from "@/queries/teachers"; | ||||
|     import { ClassController, type ClassResponse } from "@/controllers/classes"; | ||||
|     import type { ClassesResponse } from "@/controllers/classes"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { useClassesQuery, useCreateClassMutation } from "@/queries/classes"; | ||||
|     import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations"; | ||||
|     import { | ||||
|         useRespondTeacherInvitationMutation, | ||||
|         useTeacherInvitationsReceivedQuery, | ||||
|     } from "@/queries/teacher-invitations"; | ||||
|     import { useDisplay } from "vuetify"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
|     const classController = new ClassController(); | ||||
| 
 | ||||
|     // Username of logged in teacher | ||||
|     const username = ref<string | undefined>(undefined); | ||||
|     const isLoading = ref(false); | ||||
|     const isError = ref(false); | ||||
|     const errorMessage = ref<string>(""); | ||||
| 
 | ||||
|     // Find the username of the logged in user so it can be used to fetch other information | ||||
|     // When loading the page | ||||
|     // Load current user before rendering the page | ||||
|     onMounted(async () => { | ||||
|         const userObject = await authState.loadUser(); | ||||
|         username.value = userObject?.profile?.preferred_username ?? undefined; | ||||
|         isLoading.value = true; | ||||
|         try { | ||||
|             const userObject = await authState.loadUser(); | ||||
|             username.value = userObject!.profile.preferred_username; | ||||
|         } catch (error) { | ||||
|             isError.value = true; | ||||
|             errorMessage.value = error instanceof Error ? error.message : String(error); | ||||
|         } finally { | ||||
|             isLoading.value = false; | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     // Fetch all classes of the logged in teacher | ||||
|     const { data: classesResponse, isLoading, error, refetch } = useTeacherClassesQuery(username, true); | ||||
| 
 | ||||
|     // Empty list when classes are not yet loaded, else the list of classes of the user | ||||
|     const classes: ComputedRef<ClassDTO[]> = computed(() => { | ||||
|         // The classes are not yet fetched | ||||
|         if (!classesResponse.value) { | ||||
|             return []; | ||||
|         } | ||||
|         // The user has no classes | ||||
|         if (classesResponse.value.classes.length === 0) { | ||||
|             return []; | ||||
|         } | ||||
|         return classesResponse.value.classes as ClassDTO[]; | ||||
|     }); | ||||
|     const classesQuery = useTeacherClassesQuery(username, true); | ||||
|     const allClassesQuery = useClassesQuery(); | ||||
|     const { mutate } = useCreateClassMutation(); | ||||
|     const getInvitationsQuery = useTeacherInvitationsReceivedQuery(username); | ||||
|     const { mutate: respondToInvitation } = useRespondTeacherInvitationMutation(); | ||||
| 
 | ||||
|     // Boolean that handles visibility for dialogs | ||||
|     // Creating a class will generate a popup with the generated code | ||||
|  | @ -44,19 +52,26 @@ | |||
|     // Code generated when new class was created | ||||
|     const code = ref<string>(""); | ||||
| 
 | ||||
|     // TODO: waiting on frontend controllers | ||||
|     const invitations = ref<TeacherInvitationDTO[]>([]); | ||||
|     // Function to handle an invitation request | ||||
|     function handleInvitation(ti: TeacherInvitationDTO, accepted: boolean): void { | ||||
|         const data: TeacherInvitationData = { | ||||
|             sender: (ti.sender as TeacherDTO).id, | ||||
|             receiver: (ti.receiver as TeacherDTO).id, | ||||
|             class: ti.classId, | ||||
|             accepted: accepted, | ||||
|         }; | ||||
|         respondToInvitation(data, { | ||||
|             onSuccess: async () => { | ||||
|                 if (accepted) { | ||||
|                     await classesQuery.refetch(); | ||||
|                 } | ||||
| 
 | ||||
|     // Function to handle a accepted invitation request | ||||
|     function acceptRequest(): void { | ||||
|         //TODO: avoid linting issues when merging by filling the function | ||||
|         invitations.value = []; | ||||
|     } | ||||
| 
 | ||||
|     // Function to handle a denied invitation request | ||||
|     function denyRequest(): void { | ||||
|         //TODO: avoid linting issues when merging by filling the function | ||||
|         invitations.value = []; | ||||
|                 await getInvitationsQuery.refetch(); | ||||
|             }, | ||||
|             onError: (e) => { | ||||
|                 showSnackbar(t("failed") + ": " + e.message, "error"); | ||||
|             }, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     // Teacher should be able to set a displayname when making a class | ||||
|  | @ -76,25 +91,25 @@ | |||
|     async function createClass(): Promise<void> { | ||||
|         // Check if the class name is valid | ||||
|         if (className.value && className.value.length > 0 && /^[a-zA-Z0-9_-]+$/.test(className.value)) { | ||||
|             try { | ||||
|                 const classDto: ClassDTO = { | ||||
|                     id: "", | ||||
|                     displayName: className.value, | ||||
|                     teachers: [username.value!], | ||||
|                     students: [], | ||||
|                     joinRequests: [], | ||||
|                 }; | ||||
|                 const classResponse: ClassResponse = await classController.createClass(classDto); | ||||
|                 const createdClass: ClassDTO = classResponse.class; | ||||
|                 code.value = createdClass.id; | ||||
|                 dialog.value = true; | ||||
|                 showSnackbar(t("created"), "success"); | ||||
|             const classDto: ClassDTO = { | ||||
|                 id: "", | ||||
|                 displayName: className.value, | ||||
|                 teachers: [username.value!], | ||||
|                 students: [], | ||||
|             }; | ||||
| 
 | ||||
|                 // Reload the table with classes so the new class appears | ||||
|                 await refetch(); | ||||
|             } catch (_) { | ||||
|                 showSnackbar(t("wrong"), "error"); | ||||
|             } | ||||
|             mutate(classDto, { | ||||
|                 onSuccess: async (classResponse) => { | ||||
|                     showSnackbar(t("classCreated"), "success"); | ||||
|                     const createdClass: ClassDTO = classResponse.class; | ||||
|                     code.value = createdClass.id; | ||||
|                     await classesQuery.refetch(); | ||||
|                 }, | ||||
|                 onError: (err) => { | ||||
|                     showSnackbar(t("creationFailed") + ": " + err.message, "error"); | ||||
|                 }, | ||||
|             }); | ||||
|             dialog.value = true; | ||||
|         } | ||||
|         if (!className.value || className.value === "") { | ||||
|             showSnackbar(t("name is mandatory"), "error"); | ||||
|  | @ -121,187 +136,272 @@ | |||
|         await navigator.clipboard.writeText(code.value); | ||||
|         copied.value = true; | ||||
|     } | ||||
| 
 | ||||
|     // Custom breakpoints | ||||
|     const customBreakpoints = { | ||||
|         xs: 0, | ||||
|         sm: 500, | ||||
|         md: 1370, | ||||
|         lg: 1400, | ||||
|         xl: 1600, | ||||
|     }; | ||||
| 
 | ||||
|     // Logic for small screens | ||||
|     const display = useDisplay(); | ||||
| 
 | ||||
|     // Reactive variables to hold custom logic based on breakpoints | ||||
|     const isMdAndDown = ref(false); | ||||
|     const isSmAndDown = ref(false); | ||||
| 
 | ||||
|     watchEffect(() => { | ||||
|         // Custom breakpoint logic | ||||
|         isMdAndDown.value = display.width.value < customBreakpoints.md; | ||||
|         isSmAndDown.value = display.width.value < customBreakpoints.sm; | ||||
|     }); | ||||
| 
 | ||||
|     // Code display dialog logic | ||||
|     const viewCodeDialog = ref(false); | ||||
|     const selectedCode = ref(""); | ||||
|     function openCodeDialog(codeToView: string): void { | ||||
|         selectedCode.value = codeToView; | ||||
|         viewCodeDialog.value = true; | ||||
|     } | ||||
| </script> | ||||
| <template> | ||||
|     <main> | ||||
|         <div | ||||
|             class="loading-div" | ||||
|             v-if="isLoading" | ||||
|             class="text-center py-10" | ||||
|         > | ||||
|             <v-progress-circular | ||||
|                 indeterminate | ||||
|                 color="primary" | ||||
|             /> | ||||
|             <p>Loading...</p> | ||||
|             <v-progress-circular indeterminate></v-progress-circular> | ||||
|         </div> | ||||
| 
 | ||||
|         <div | ||||
|             v-else-if="error" | ||||
|             class="text-center py-10 text-error" | ||||
|         > | ||||
|             <v-icon large>mdi-alert-circle</v-icon> | ||||
|             <p>Error loading: {{ error.message }}</p> | ||||
|         <div v-if="isError"> | ||||
|             <v-empty-state | ||||
|                 icon="mdi-alert-circle-outline" | ||||
|                 :text="errorMessage" | ||||
|                 :title="t('error_title')" | ||||
|             ></v-empty-state> | ||||
|         </div> | ||||
|         <div v-else> | ||||
|             <h1 class="title">{{ t("classes") }}</h1> | ||||
|             <v-container | ||||
|                 fluid | ||||
|                 class="ma-4" | ||||
|             <using-query-result | ||||
|                 :query-result="classesQuery" | ||||
|                 v-slot="classesResponse: { data: ClassesResponse }" | ||||
|             > | ||||
|                 <v-row | ||||
|                     no-gutters | ||||
|                 <v-container | ||||
|                     fluid | ||||
|                     class="ma-4" | ||||
|                 > | ||||
|                     <v-col | ||||
|                         cols="12" | ||||
|                         sm="6" | ||||
|                         md="6" | ||||
|                     <v-row | ||||
|                         no-gutters | ||||
|                         class="custom-breakpoint" | ||||
|                     > | ||||
|                         <v-table class="table"> | ||||
|                             <thead> | ||||
|                                 <tr> | ||||
|                                     <th class="header">{{ t("classes") }}</th> | ||||
|                                     <th class="header"> | ||||
|                                         {{ t("code") }} | ||||
|                                     </th> | ||||
|                                     <th class="header">{{ t("members") }}</th> | ||||
|                                 </tr> | ||||
|                             </thead> | ||||
|                             <tbody> | ||||
|                                 <tr | ||||
|                                     v-for="c in classes" | ||||
|                                     :key="c.id" | ||||
|                                 > | ||||
|                                     <td> | ||||
|                                         <v-btn | ||||
|                                             :to="`/class/${c.id}`" | ||||
|                                             variant="text" | ||||
|                                         > | ||||
|                                             {{ c.displayName }} | ||||
|                                             <v-icon end> mdi-menu-right </v-icon> | ||||
|                                         </v-btn> | ||||
|                                     </td> | ||||
|                                     <td>{{ c.id }}</td> | ||||
|                                     <td>{{ c.students.length }}</td> | ||||
|                                 </tr> | ||||
|                             </tbody> | ||||
|                         </v-table> | ||||
|                     </v-col> | ||||
|                     <v-col | ||||
|                         cols="12" | ||||
|                         sm="6" | ||||
|                         md="6" | ||||
|                     > | ||||
|                         <div> | ||||
|                             <h2>{{ t("createClass") }}</h2> | ||||
| 
 | ||||
|                             <v-sheet | ||||
|                                 class="pa-4 sheet" | ||||
|                                 max-width="600px" | ||||
|                             > | ||||
|                                 <p>{{ t("createClassInstructions") }}</p> | ||||
|                                 <v-form @submit.prevent> | ||||
|                                     <v-text-field | ||||
|                                         class="mt-4" | ||||
|                                         :label="`${t('classname')}`" | ||||
|                                         v-model="className" | ||||
|                                         :placeholder="`${t('EnterNameOfClass')}`" | ||||
|                                         :rules="nameRules" | ||||
|                                         variant="outlined" | ||||
|                                     ></v-text-field> | ||||
|                                     <v-btn | ||||
|                                         class="mt-4" | ||||
|                                         color="#f6faf2" | ||||
|                                         type="submit" | ||||
|                                         @click="createClass" | ||||
|                                         block | ||||
|                                         >{{ t("create") }}</v-btn | ||||
|                         <v-col | ||||
|                             cols="12" | ||||
|                             sm="6" | ||||
|                             md="6" | ||||
|                             class="responsive-col" | ||||
|                         > | ||||
|                             <v-table class="table"> | ||||
|                                 <thead> | ||||
|                                     <tr> | ||||
|                                         <th class="header">{{ t("classes") }}</th> | ||||
|                                         <th class="header"> | ||||
|                                             {{ t("code") }} | ||||
|                                         </th> | ||||
|                                         <th class="header">{{ t("members") }}</th> | ||||
|                                     </tr> | ||||
|                                 </thead> | ||||
|                                 <tbody> | ||||
|                                     <tr | ||||
|                                         v-for="c in classesResponse.data.classes as ClassDTO[]" | ||||
|                                         :key="c.id" | ||||
|                                     > | ||||
|                                 </v-form> | ||||
|                             </v-sheet> | ||||
|                             <v-container> | ||||
|                                 <v-dialog | ||||
|                                     v-model="dialog" | ||||
|                                     max-width="400px" | ||||
|                                 > | ||||
|                                     <v-card> | ||||
|                                         <v-card-title class="headline">code</v-card-title> | ||||
|                                         <v-card-text> | ||||
|                                             <v-text-field | ||||
|                                                 v-model="code" | ||||
|                                                 readonly | ||||
|                                                 append-inner-icon="mdi-content-copy" | ||||
|                                                 @click:append-inner="copyToClipboard" | ||||
|                                             ></v-text-field> | ||||
|                                             <v-slide-y-transition> | ||||
|                                                 <div | ||||
|                                                     v-if="copied" | ||||
|                                                     class="text-center mt-2" | ||||
|                                                 > | ||||
|                                                     {{ t("copied") }} | ||||
|                                                 </div> | ||||
|                                             </v-slide-y-transition> | ||||
|                                         </v-card-text> | ||||
|                                         <v-card-actions> | ||||
|                                             <v-spacer></v-spacer> | ||||
|                                         <td> | ||||
|                                             <v-btn | ||||
|                                                 text | ||||
|                                                 @click=" | ||||
|                                                     dialog = false; | ||||
|                                                     copied = false; | ||||
|                                                 " | ||||
|                                                 :to="`/class/${c.id}`" | ||||
|                                                 variant="text" | ||||
|                                             > | ||||
|                                                 {{ t("close") }} | ||||
|                                                 {{ c.displayName }} | ||||
|                                                 <v-icon end> mdi-menu-right </v-icon> | ||||
|                                             </v-btn> | ||||
|                                         </v-card-actions> | ||||
|                                     </v-card> | ||||
|                                 </v-dialog> | ||||
|                             </v-container> | ||||
|                         </div> | ||||
|                     </v-col> | ||||
|                 </v-row> | ||||
|             </v-container> | ||||
|                                         </td> | ||||
|                                         <td> | ||||
|                                             <span v-if="!isMdAndDown">{{ c.id }}</span> | ||||
|                                             <span | ||||
|                                                 v-else | ||||
|                                                 style="cursor: pointer" | ||||
|                                                 @click="openCodeDialog(c.id)" | ||||
|                                                 ><v-icon icon="mdi-eye"></v-icon | ||||
|                                             ></span> | ||||
|                                         </td> | ||||
| 
 | ||||
|                                         <td>{{ c.students.length }}</td> | ||||
|                                     </tr> | ||||
|                                 </tbody> | ||||
|                             </v-table> | ||||
|                         </v-col> | ||||
|                         <v-col | ||||
|                             cols="12" | ||||
|                             sm="6" | ||||
|                             md="6" | ||||
|                             class="responsive-col" | ||||
|                         > | ||||
|                             <div> | ||||
|                                 <h2>{{ t("createClass") }}</h2> | ||||
| 
 | ||||
|                                 <v-sheet | ||||
|                                     class="pa-4 sheet" | ||||
|                                     max-width="600px" | ||||
|                                 > | ||||
|                                     <p>{{ t("createClassInstructions") }}</p> | ||||
|                                     <v-form @submit.prevent> | ||||
|                                         <v-text-field | ||||
|                                             class="mt-4" | ||||
|                                             :label="`${t('classname')}`" | ||||
|                                             v-model="className" | ||||
|                                             :placeholder="`${t('EnterNameOfClass')}`" | ||||
|                                             :rules="nameRules" | ||||
|                                             variant="outlined" | ||||
|                                         ></v-text-field> | ||||
|                                         <v-btn | ||||
|                                             class="mt-4" | ||||
|                                             color="#f6faf2" | ||||
|                                             type="submit" | ||||
|                                             @click="createClass" | ||||
|                                             block | ||||
|                                             >{{ t("create") }}</v-btn | ||||
|                                         > | ||||
|                                     </v-form> | ||||
|                                 </v-sheet> | ||||
|                                 <v-container> | ||||
|                                     <v-dialog | ||||
|                                         v-model="dialog" | ||||
|                                         max-width="400px" | ||||
|                                     > | ||||
|                                         <v-card> | ||||
|                                             <v-card-title class="headline">code</v-card-title> | ||||
|                                             <v-card-text> | ||||
|                                                 <v-text-field | ||||
|                                                     v-model="code" | ||||
|                                                     readonly | ||||
|                                                     append-inner-icon="mdi-content-copy" | ||||
|                                                     @click:append-inner="copyToClipboard" | ||||
|                                                 ></v-text-field> | ||||
|                                                 <v-slide-y-transition> | ||||
|                                                     <div | ||||
|                                                         v-if="copied" | ||||
|                                                         class="text-center mt-2" | ||||
|                                                     > | ||||
|                                                         {{ t("copied") }} | ||||
|                                                     </div> | ||||
|                                                 </v-slide-y-transition> | ||||
|                                             </v-card-text> | ||||
|                                             <v-card-actions> | ||||
|                                                 <v-spacer></v-spacer> | ||||
|                                                 <v-btn | ||||
|                                                     text | ||||
|                                                     @click=" | ||||
|                                                         dialog = false; | ||||
|                                                         copied = false; | ||||
|                                                     " | ||||
|                                                 > | ||||
|                                                     {{ t("close") }} | ||||
|                                                 </v-btn> | ||||
|                                             </v-card-actions> | ||||
|                                         </v-card> | ||||
|                                     </v-dialog> | ||||
|                                 </v-container> | ||||
|                             </div> | ||||
|                         </v-col> | ||||
|                     </v-row> | ||||
|                 </v-container> | ||||
|             </using-query-result> | ||||
| 
 | ||||
|             <h1 class="title"> | ||||
|                 {{ t("invitations") }} | ||||
|             </h1> | ||||
|             <v-table class="table"> | ||||
|                 <thead> | ||||
|                     <tr> | ||||
|                         <th class="header">{{ t("class") }}</th> | ||||
|                         <th class="header">{{ t("sender") }}</th> | ||||
|                         <th class="header"></th> | ||||
|                     </tr> | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
|                     <tr | ||||
|                         v-for="i in invitations" | ||||
|                         :key="(i.class as ClassDTO).id" | ||||
|                     > | ||||
|                         <td> | ||||
|                             {{ (i.class as ClassDTO).displayName }} | ||||
|                         </td> | ||||
|                         <td>{{ (i.sender as TeacherDTO).firstName + " " + (i.sender as TeacherDTO).lastName }}</td> | ||||
|                         <td class="text-right"> | ||||
|                             <div> | ||||
|                                 <v-btn | ||||
|                                     color="green" | ||||
|                                     @click="acceptRequest" | ||||
|                                     class="mr-2" | ||||
|             <v-container | ||||
|                 fluid | ||||
|                 class="ma-4" | ||||
|             > | ||||
|                 <v-table class="table"> | ||||
|                     <thead> | ||||
|                         <tr> | ||||
|                             <th class="header">{{ t("class") }}</th> | ||||
|                             <th class="header">{{ t("sender") }}</th> | ||||
|                             <th class="header">{{ t("accept") + "/" + t("reject") }}</th> | ||||
|                         </tr> | ||||
|                     </thead> | ||||
|                     <tbody> | ||||
|                         <using-query-result | ||||
|                             :query-result="getInvitationsQuery" | ||||
|                             v-slot="invitationsResponse: { data: TeacherInvitationsResponse }" | ||||
|                         > | ||||
|                             <using-query-result | ||||
|                                 :query-result="allClassesQuery" | ||||
|                                 v-slot="classesResponse: { data: ClassesResponse }" | ||||
|                             > | ||||
|                                 <tr | ||||
|                                     v-for="i in invitationsResponse.data.invitations as TeacherInvitationDTO[]" | ||||
|                                     :key="i.classId" | ||||
|                                 > | ||||
|                                     {{ t("accept") }} | ||||
|                                 </v-btn> | ||||
|                                 <v-btn | ||||
|                                     color="red" | ||||
|                                     @click="denyRequest" | ||||
|                                 > | ||||
|                                     {{ t("deny") }} | ||||
|                                 </v-btn> | ||||
|                             </div> | ||||
|                         </td> | ||||
|                     </tr> | ||||
|                 </tbody> | ||||
|             </v-table> | ||||
|                                     <td> | ||||
|                                         {{ | ||||
|                                             (classesResponse.data.classes as ClassDTO[]).filter( | ||||
|                                                 (c) => c.id == i.classId, | ||||
|                                             )[0].displayName | ||||
|                                         }} | ||||
|                                     </td> | ||||
|                                     <td> | ||||
|                                         {{ | ||||
|                                             (i.sender as TeacherDTO).firstName + " " + (i.sender as TeacherDTO).lastName | ||||
|                                         }} | ||||
|                                     </td> | ||||
|                                     <td class="text-right"> | ||||
|                                         <span v-if="!isSmAndDown"> | ||||
|                                             <div> | ||||
|                                                 <v-btn | ||||
|                                                     color="green" | ||||
|                                                     @click="handleInvitation(i, true)" | ||||
|                                                     class="mr-2" | ||||
|                                                 > | ||||
|                                                     {{ t("accept") }} | ||||
|                                                 </v-btn> | ||||
|                                                 <v-btn | ||||
|                                                     color="red" | ||||
|                                                     @click="handleInvitation(i, false)" | ||||
|                                                 > | ||||
|                                                     {{ t("deny") }} | ||||
|                                                 </v-btn> | ||||
|                                             </div> | ||||
|                                         </span> | ||||
|                                         <span v-else> | ||||
|                                             <div> | ||||
|                                                 <v-btn | ||||
|                                                     @click="handleInvitation(i, true)" | ||||
|                                                     class="mr-2" | ||||
|                                                     icon="mdi-check-circle" | ||||
|                                                     color="green" | ||||
|                                                     variant="text" | ||||
|                                                 > | ||||
|                                                 </v-btn> | ||||
|                                                 <v-btn | ||||
|                                                     @click="handleInvitation(i, false)" | ||||
|                                                     class="mr-2" | ||||
|                                                     icon="mdi-close-circle" | ||||
|                                                     color="red" | ||||
|                                                     variant="text" | ||||
|                                                 > | ||||
|                                                 </v-btn></div | ||||
|                                         ></span> | ||||
|                                     </td> | ||||
|                                 </tr> | ||||
|                             </using-query-result> | ||||
|                         </using-query-result> | ||||
|                     </tbody> | ||||
|                 </v-table> | ||||
|             </v-container> | ||||
|         </div> | ||||
|         <v-snackbar | ||||
|             v-model="snackbar.visible" | ||||
|  | @ -310,6 +410,42 @@ | |||
|         > | ||||
|             {{ snackbar.message }} | ||||
|         </v-snackbar> | ||||
|         <v-dialog | ||||
|             v-model="viewCodeDialog" | ||||
|             max-width="400px" | ||||
|         > | ||||
|             <v-card> | ||||
|                 <v-card-title class="headline">{{ t("code") }}</v-card-title> | ||||
|                 <v-card-text> | ||||
|                     <v-text-field | ||||
|                         v-model="selectedCode" | ||||
|                         readonly | ||||
|                         append-inner-icon="mdi-content-copy" | ||||
|                         @click:append-inner="copyToClipboard" | ||||
|                     ></v-text-field> | ||||
|                     <v-slide-y-transition> | ||||
|                         <div | ||||
|                             v-if="copied" | ||||
|                             class="text-center mt-2" | ||||
|                         > | ||||
|                             {{ t("copied") }} | ||||
|                         </div> | ||||
|                     </v-slide-y-transition> | ||||
|                 </v-card-text> | ||||
|                 <v-card-actions> | ||||
|                     <v-spacer></v-spacer> | ||||
|                     <v-btn | ||||
|                         text | ||||
|                         @click=" | ||||
|                             viewCodeDialog = false; | ||||
|                             copied = false; | ||||
|                         " | ||||
|                     > | ||||
|                         {{ t("close") }} | ||||
|                     </v-btn> | ||||
|                 </v-card-actions> | ||||
|             </v-card> | ||||
|         </v-dialog> | ||||
|     </main> | ||||
| </template> | ||||
| <style scoped> | ||||
|  | @ -377,7 +513,7 @@ | |||
|         margin-left: 30px; | ||||
|     } | ||||
| 
 | ||||
|     @media screen and (max-width: 800px) { | ||||
|     @media screen and (max-width: 850px) { | ||||
|         h1 { | ||||
|             text-align: center; | ||||
|             padding-left: 0; | ||||
|  | @ -400,5 +536,18 @@ | |||
|             justify-content: center; | ||||
|             margin: 5px; | ||||
|         } | ||||
| 
 | ||||
|         .custom-breakpoint { | ||||
|             flex-direction: column !important; | ||||
|         } | ||||
| 
 | ||||
|         .table { | ||||
|             width: 100%; | ||||
|         } | ||||
| 
 | ||||
|         .responsive-col { | ||||
|             max-width: 100% !important; | ||||
|             flex-basis: 100% !important; | ||||
|         } | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -1,51 +0,0 @@ | |||
| <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> | ||||
|  | @ -0,0 +1,55 @@ | |||
| <script setup lang="ts"> | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { useGroupsQuery } from "@/queries/groups.ts"; | ||||
|     import type { GroupsResponse } from "@/controllers/groups.ts"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         classId: string; | ||||
|         assignmentNumber: number; | ||||
|     }>(); | ||||
| 
 | ||||
|     const model = defineModel<number | undefined>({ default: undefined }); | ||||
| 
 | ||||
|     const groupsQuery = useGroupsQuery(props.classId, props.assignmentNumber, true); | ||||
| 
 | ||||
|     interface GroupSelectorOption { | ||||
|         groupNumber: number | undefined; | ||||
|         label: string; | ||||
|     } | ||||
| 
 | ||||
|     function groupOptions(groups: GroupDTO[]): GroupSelectorOption[] { | ||||
|         return [...groups] | ||||
|             .sort((a, b) => a.groupNumber - b.groupNumber) | ||||
|             .map((group, index) => ({ | ||||
|                 groupNumber: group.groupNumber, | ||||
|                 label: `${index + 1}`, | ||||
|             })); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <using-query-result | ||||
|         :query-result="groupsQuery" | ||||
|         v-slot="{ data }: { data: GroupsResponse }" | ||||
|     > | ||||
|         <v-select | ||||
|             :label="t('viewAsGroup')" | ||||
|             :items="groupOptions(data.groups)" | ||||
|             v-model="model" | ||||
|             item-title="label" | ||||
|             class="group-selector-cb" | ||||
|             variant="outlined" | ||||
|             clearable | ||||
|         ></v-select> | ||||
|     </using-query-result> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .group-selector-cb { | ||||
|         margin-top: 10px; | ||||
|     } | ||||
| </style> | ||||
|  | @ -3,8 +3,8 @@ | |||
|     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 { useRoute, useRouter } from "vue-router"; | ||||
|     import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import LearningPathSearchField from "@/components/LearningPathSearchField.vue"; | ||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|  | @ -12,33 +12,47 @@ | |||
|     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"; | ||||
| import { useStudentAssignmentsQuery } from "@/queries/students"; | ||||
| import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; | ||||
| import { watch } from "vue"; | ||||
|     import LearningPathGroupSelector from "@/views/learning-paths/LearningPathGroupSelector.vue"; | ||||
|     import { useQuestionsQuery } from "@/queries/questions"; | ||||
|     import type { QuestionsResponse } from "@/controllers/questions"; | ||||
|     import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content"; | ||||
|     import QandA from "@/components/QandA.vue"; | ||||
|     import type { QuestionDTO } from "@dwengo-1/common/interfaces/question"; | ||||
| 
 | ||||
|     import { useStudentAssignmentsQuery } from "@/queries/students"; | ||||
|     import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; | ||||
|     import { watch } from "vue"; | ||||
| 
 | ||||
|     const router = useRouter(); | ||||
|     const route = useRoute(); | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const props = defineProps<{ hruid: string; language: Language; learningObjectHruid?: string }>(); | ||||
|     const props = defineProps<{ | ||||
|         hruid: string; | ||||
|         language: Language; | ||||
|         learningObjectHruid?: string; | ||||
|     }>(); | ||||
| 
 | ||||
|     interface Personalization { | ||||
|         forStudent?: string; | ||||
|     interface LearningPathPageQuery { | ||||
|         forGroup?: string; | ||||
|         assignmentNo?: string; | ||||
|         classId?: string; | ||||
|     } | ||||
| 
 | ||||
|     const personalization = computed(() => { | ||||
|         if (route.query.forStudent || route.query.forGroup) { | ||||
|     const query = computed(() => route.query as LearningPathPageQuery); | ||||
| 
 | ||||
|     const forGroup = computed(() => { | ||||
|         if (query.value.forGroup && query.value.assignmentNo && query.value.classId) { | ||||
|             return { | ||||
|                 forStudent: route.query.forStudent, | ||||
|                 forGroup: route.query.forGroup, | ||||
|             } as Personalization; | ||||
|                 forGroup: parseInt(query.value.forGroup), | ||||
|                 assignmentNo: parseInt(query.value.assignmentNo), | ||||
|                 classId: query.value.classId, | ||||
|             }; | ||||
|         } | ||||
|         return { | ||||
|             forStudent: authService.authState.user?.profile?.preferred_username, | ||||
|         } as Personalization; | ||||
|         return undefined; | ||||
|     }); | ||||
| 
 | ||||
|     const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, personalization); | ||||
|     const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, forGroup); | ||||
| 
 | ||||
|     const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data); | ||||
| 
 | ||||
|  | @ -63,6 +77,17 @@ import { watch } from "vue"; | |||
|         return currentIndex < nodesList.value?.length ? nodesList.value?.[currentIndex - 1] : undefined; | ||||
|     }); | ||||
| 
 | ||||
|     const getQuestionsQuery = useQuestionsQuery( | ||||
|         computed( | ||||
|             () => | ||||
|                 ({ | ||||
|                     language: currentNode.value?.language, | ||||
|                     hruid: currentNode.value?.learningobjectHruid, | ||||
|                     version: currentNode.value?.version, | ||||
|                 }) as LearningObjectIdentifierDTO, | ||||
|         ), | ||||
|     ); | ||||
| 
 | ||||
|     const navigationDrawerShown = ref(true); | ||||
| 
 | ||||
|     function isLearningObjectCompleted(learningObject: LearningObject): boolean { | ||||
|  | @ -102,6 +127,25 @@ import { watch } from "vue"; | |||
|         return "notCompleted"; | ||||
|     } | ||||
| 
 | ||||
|     const forGroupQueryParam = computed<number | undefined>({ | ||||
|         get: () => route.query.forGroup, | ||||
|         set: async (value: number | undefined) => { | ||||
|             const query = structuredClone(route.query); | ||||
|             query.forGroup = value; | ||||
|             await router.push({ query }); | ||||
|         }, | ||||
|     }); | ||||
| 
 | ||||
|     async function assign(): Promise<void> { | ||||
|         await router.push({ | ||||
|             path: "/assignment/create", | ||||
|             query: { | ||||
|                 hruid: props.hruid, | ||||
|                 language: props.language, | ||||
|             }, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     //TODO: berekenen of het een assignment is voor de student werkt nog niet zoals het hoort... | ||||
| 
 | ||||
|     const studentAssignmentsQuery = useStudentAssignmentsQuery(authService.authState.user?.profile?.preferred_username); | ||||
|  | @ -154,64 +198,87 @@ import { watch } from "vue"; | |||
|             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> | ||||
|             <div class="d-flex flex-column h-100"> | ||||
|                 <v-list-item> | ||||
|                     <template v-slot:title> | ||||
|                         <div class="learning-path-title">{{ learningPath.data.title }}</div> | ||||
|                     </template> | ||||
|                 </using-query-result> | ||||
|                     <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-list-item | ||||
|                     v-if="query.classId && query.assignmentNo && authService.authState.activeRole === 'teacher'" | ||||
|                 > | ||||
|                     <template v-slot:default> | ||||
|                         <learning-path-group-selector | ||||
|                             :class-id="query.classId" | ||||
|                             :assignment-number="parseInt(query.assignmentNo)" | ||||
|                             v-model="forGroupQueryParam" | ||||
|                         /> | ||||
|                     </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-spacer></v-spacer> | ||||
|                 <v-list-item v-if="authService.authState.activeRole === 'teacher'"> | ||||
|                     <template v-slot:default> | ||||
|                         <v-btn | ||||
|                             class="button-in-nav" | ||||
|                             @click="assign()" | ||||
|                             >{{ t("assignLearningPath") }}</v-btn | ||||
|                         > | ||||
|                     </template> | ||||
|                 </v-list-item> | ||||
|             </div> | ||||
|             <v-divider></v-divider> | ||||
|             <div v-if="true" class="assignment-indicator">   | ||||
|  | @ -229,12 +296,15 @@ import { watch } from "vue"; | |||
|                 <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="learning-object-view-container"> | ||||
|             <learning-object-view | ||||
|                 :hruid="currentNode.learningobjectHruid" | ||||
|                 :language="currentNode.language" | ||||
|                 :version="currentNode.version" | ||||
|                 :group="forGroup" | ||||
|                 v-if="currentNode" | ||||
|             ></learning-object-view> | ||||
|         </div> | ||||
|         <div class="question-box"> | ||||
|             <div class="input-wrapper"> | ||||
|               <input | ||||
|  | @ -267,6 +337,12 @@ import { watch } from "vue"; | |||
|                 {{ t("next") }} | ||||
|             </v-btn> | ||||
|         </div> | ||||
|         <using-query-result | ||||
|             :query-result="getQuestionsQuery" | ||||
|             v-slot="questionsResponse: { data: QuestionsResponse }" | ||||
|         > | ||||
|             <QandA :questions="questionsResponse.data.questions as QuestionDTO[] ?? []" /> | ||||
|         </using-query-result> | ||||
|     </using-query-result> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -284,6 +360,11 @@ import { watch } from "vue"; | |||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|     } | ||||
|     .learning-object-view-container { | ||||
|         padding-left: 20px; | ||||
|         padding-right: 20px; | ||||
|         padding-bottom: 20px; | ||||
|     } | ||||
|     .navigation-buttons-container { | ||||
|         padding: 20px; | ||||
|         display: flex; | ||||
|  |  | |||
|  | @ -9,11 +9,11 @@ | |||
|     import LearningPathsGrid from "@/components/LearningPathsGrid.vue"; | ||||
| 
 | ||||
|     const route = useRoute(); | ||||
|     const { t } = useI18n(); | ||||
|     const { t, locale } = useI18n(); | ||||
| 
 | ||||
|     const query = computed(() => route.query.query as string | undefined); | ||||
| 
 | ||||
|     const searchQueryResults = useSearchLearningPathQuery(query); | ||||
|     const searchQueryResults = useSearchLearningPathQuery(query, locale); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
|  | @ -0,0 +1,18 @@ | |||
| export const essayQuestionAdapter: GiftAdapter = { | ||||
|     questionType: "Essay", | ||||
| 
 | ||||
|     installListener( | ||||
|         questionElement: Element, | ||||
|         answerUpdateCallback: (newAnswer: string | number | object) => void, | ||||
|     ): void { | ||||
|         const textArea = questionElement.querySelector("textarea")!; | ||||
|         textArea.addEventListener("input", () => { | ||||
|             answerUpdateCallback(textArea.value); | ||||
|         }); | ||||
|     }, | ||||
| 
 | ||||
|     setAnswer(questionElement: Element, answer: string | number | object): void { | ||||
|         const textArea = questionElement.querySelector("textarea")!; | ||||
|         textArea.value = String(answer); | ||||
|     }, | ||||
| }; | ||||
							
								
								
									
										8
									
								
								frontend/src/views/learning-paths/gift-adapters/gift-adapter.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/src/views/learning-paths/gift-adapters/gift-adapter.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | |||
| interface GiftAdapter { | ||||
|     questionType: string; | ||||
|     installListener( | ||||
|         questionElement: Element, | ||||
|         answerUpdateCallback: (newAnswer: string | number | object) => void, | ||||
|     ): void; | ||||
|     setAnswer(questionElement: Element, answer: string | number | object): void; | ||||
| } | ||||
|  | @ -0,0 +1,8 @@ | |||
| import { multipleChoiceQuestionAdapter } from "@/views/learning-paths/gift-adapters/multiple-choice-question-adapter.ts"; | ||||
| import { essayQuestionAdapter } from "@/views/learning-paths/gift-adapters/essay-question-adapter.ts"; | ||||
| 
 | ||||
| export const giftAdapters = [multipleChoiceQuestionAdapter, essayQuestionAdapter]; | ||||
| 
 | ||||
| export function getGiftAdapterForType(questionType: string): GiftAdapter | undefined { | ||||
|     return giftAdapters.find((it) => it.questionType === questionType); | ||||
| } | ||||
|  | @ -0,0 +1,27 @@ | |||
| export const multipleChoiceQuestionAdapter: GiftAdapter = { | ||||
|     questionType: "MC", | ||||
| 
 | ||||
|     installListener( | ||||
|         questionElement: Element, | ||||
|         answerUpdateCallback: (newAnswer: string | number | object) => void, | ||||
|     ): void { | ||||
|         questionElement.querySelectorAll("input[type=radio]").forEach((element) => { | ||||
|             const input = element as HTMLInputElement; | ||||
| 
 | ||||
|             input.addEventListener("change", () => { | ||||
|                 answerUpdateCallback(parseInt(input.value)); | ||||
|             }); | ||||
|             // Optional: initialize value if already selected
 | ||||
|             if (input.checked) { | ||||
|                 answerUpdateCallback(parseInt(input.value)); | ||||
|             } | ||||
|         }); | ||||
|     }, | ||||
| 
 | ||||
|     setAnswer(questionElement: Element, answer: string | number | object): void { | ||||
|         questionElement.querySelectorAll("input[type=radio]").forEach((element) => { | ||||
|             const input = element as HTMLInputElement; | ||||
|             input.checked = String(answer) === String(input.value); | ||||
|         }); | ||||
|     }, | ||||
| }; | ||||
|  | @ -0,0 +1,73 @@ | |||
| <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"; | ||||
|     import { computed, ref } from "vue"; | ||||
|     import authService from "@/services/auth/auth-service.ts"; | ||||
|     import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data"; | ||||
|     import LearningObjectContentView from "@/views/learning-paths/learning-object/content/LearningObjectContentView.vue"; | ||||
|     import LearningObjectSubmissionsView from "@/views/learning-paths/learning-object/submissions/LearningObjectSubmissionsView.vue"; | ||||
| 
 | ||||
|     const _isStudent = computed(() => authService.authState.activeRole === "student"); | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         hruid: string; | ||||
|         language: Language; | ||||
|         version: number; | ||||
|         group?: { forGroup: number; assignmentNo: number; classId: string }; | ||||
|     }>(); | ||||
| 
 | ||||
|     const learningObjectHtmlQueryResult: UseQueryReturnType<Document, Error> = useLearningObjectHTMLQuery( | ||||
|         () => props.hruid, | ||||
|         () => props.language, | ||||
|         () => props.version, | ||||
|     ); | ||||
|     const currentSubmission = ref<SubmissionData>([]); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <using-query-result | ||||
|         :query-result="learningObjectHtmlQueryResult as UseQueryReturnType<Document, Error>" | ||||
|         v-slot="learningPathHtml: { data: Document }" | ||||
|     > | ||||
|         <learning-object-content-view | ||||
|             :learning-object-content="learningPathHtml.data" | ||||
|             v-model:submission-data="currentSubmission" | ||||
|         /> | ||||
|         <div class="content-submissions-spacer" /> | ||||
|         <learning-object-submissions-view | ||||
|             v-if="props.group" | ||||
|             :group="props.group" | ||||
|             :hruid="props.hruid" | ||||
|             :language="props.language" | ||||
|             :version="props.version" | ||||
|             v-model:submission-data="currentSubmission" | ||||
|         /> | ||||
|     </using-query-result> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     :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; | ||||
|     } | ||||
|     .content-submissions-spacer { | ||||
|         height: 20px; | ||||
|     } | ||||
| </style> | ||||
|  | @ -0,0 +1,90 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data"; | ||||
|     import { getGiftAdapterForType } from "@/views/learning-paths/gift-adapters/gift-adapters.ts"; | ||||
|     import { computed, nextTick, onMounted, watch } from "vue"; | ||||
|     import { copyArrayWith } from "@/utils/array-utils.ts"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         learningObjectContent: Document; | ||||
|         submissionData?: SubmissionData; | ||||
|     }>(); | ||||
| 
 | ||||
|     const emit = defineEmits<(e: "update:submissionData", value: SubmissionData) => void>(); | ||||
| 
 | ||||
|     const submissionData = computed<SubmissionData | undefined>({ | ||||
|         get: () => props.submissionData, | ||||
|         set: (v?: SubmissionData): void => { | ||||
|             if (v) emit("update:submissionData", v); | ||||
|         }, | ||||
|     }); | ||||
| 
 | ||||
|     function forEachQuestion( | ||||
|         doAction: (questionIndex: number, questionName: string, questionType: string, questionElement: Element) => void, | ||||
|     ): void { | ||||
|         const questions = document.querySelectorAll(".gift-question"); | ||||
|         questions.forEach((question) => { | ||||
|             const name = question.id.match(/gift-q(\d+)/)?.[1]; | ||||
|             const questionType = question.className | ||||
|                 .split(" ") | ||||
|                 .find((it) => it.startsWith("gift-question-type")) | ||||
|                 ?.match(/gift-question-type-([^ ]*)/)?.[1]; | ||||
| 
 | ||||
|             if (!name || isNaN(parseInt(name)) || !questionType) return; | ||||
| 
 | ||||
|             const index = parseInt(name) - 1; | ||||
| 
 | ||||
|             doAction(index, name, questionType, question); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     function attachQuestionListeners(): void { | ||||
|         forEachQuestion((index, _name, type, element) => { | ||||
|             getGiftAdapterForType(type)?.installListener(element, (newAnswer) => { | ||||
|                 submissionData.value = copyArrayWith(index, newAnswer, submissionData.value ?? []); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     function setAnswers(answers: SubmissionData): void { | ||||
|         forEachQuestion((index, _name, type, element) => { | ||||
|             const answer = answers[index]; | ||||
|             if (answer !== null && answer !== undefined) { | ||||
|                 getGiftAdapterForType(type)?.setAnswer(element, answer); | ||||
|             } else if (answer === undefined) { | ||||
|                 answers[index] = null; | ||||
|             } | ||||
|         }); | ||||
|         submissionData.value = answers; | ||||
|     } | ||||
| 
 | ||||
|     onMounted(async () => | ||||
|         nextTick(() => { | ||||
|             attachQuestionListeners(); | ||||
|             setAnswers(props.submissionData ?? []); | ||||
|         }), | ||||
|     ); | ||||
| 
 | ||||
|     watch( | ||||
|         () => props.learningObjectContent, | ||||
|         async () => { | ||||
|             await nextTick(); | ||||
|             attachQuestionListeners(); | ||||
|         }, | ||||
|     ); | ||||
|     watch( | ||||
|         () => props.submissionData, | ||||
|         async () => { | ||||
|             await nextTick(); | ||||
|             setAnswers(props.submissionData ?? []); | ||||
|         }, | ||||
|     ); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <div | ||||
|         class="learning-object-container" | ||||
|         v-html="learningObjectContent.body.innerHTML" | ||||
|     ></div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
							
								
								
									
										1
									
								
								frontend/src/views/learning-paths/learning-object/submission-data.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/views/learning-paths/learning-object/submission-data.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| export type SubmissionData = (string | number | object | null)[]; | ||||
|  | @ -0,0 +1,61 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission"; | ||||
|     import { computed } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         allSubmissions: SubmissionDTO[]; | ||||
|     }>(); | ||||
|     const emit = defineEmits<(e: "submission-selected", submission: SubmissionDTO) => void>(); | ||||
| 
 | ||||
|     const headers = computed(() => [ | ||||
|         { title: "#", value: "submissionNo", width: "50px" }, | ||||
|         { title: t("submittedBy"), value: "submittedBy" }, | ||||
|         { title: t("timestamp"), value: "timestamp" }, | ||||
|         { title: "", key: "action", width: "70px", sortable: false }, | ||||
|     ]); | ||||
| 
 | ||||
|     const data = computed(() => | ||||
|         [...props.allSubmissions] | ||||
|             .sort((a, b) => (a.submissionNumber ?? 0) - (b.submissionNumber ?? 0)) | ||||
|             .map((submission, index) => ({ | ||||
|                 submissionNo: index + 1, | ||||
|                 submittedBy: `${submission.submitter.firstName} ${submission.submitter.lastName}`, | ||||
|                 timestamp: submission.time ? new Date(submission.time).toLocaleString() : "-", | ||||
|                 dto: submission, | ||||
|             })), | ||||
|     ); | ||||
| 
 | ||||
|     function selectSubmission(submission: SubmissionDTO): void { | ||||
|         emit("submission-selected", submission); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <v-card> | ||||
|         <v-card-title>{{ t("groupSubmissions") }}</v-card-title> | ||||
|         <v-card-text> | ||||
|             <v-data-table | ||||
|                 :headers="headers" | ||||
|                 :items="data" | ||||
|                 density="compact" | ||||
|                 hide-default-footer | ||||
|                 :no-data-text="t('noSubmissionsYet')" | ||||
|             > | ||||
|                 <template v-slot:[`item.action`]="{ item }"> | ||||
|                     <v-btn | ||||
|                         density="compact" | ||||
|                         variant="plain" | ||||
|                         @click="selectSubmission(item.dto)" | ||||
|                     > | ||||
|                         {{ t("loadSubmission") }} | ||||
|                     </v-btn> | ||||
|                 </template> | ||||
|             </v-data-table> | ||||
|         </v-card-text> | ||||
|     </v-card> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
|  | @ -0,0 +1,97 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data"; | ||||
|     import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission"; | ||||
|     import { Language } from "@/data-objects/language.ts"; | ||||
|     import { useSubmissionsQuery } from "@/queries/submissions.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import SubmitButton from "@/views/learning-paths/learning-object/submissions/SubmitButton.vue"; | ||||
|     import { computed, watch } from "vue"; | ||||
|     import LearningObjectSubmissionsTable from "@/views/learning-paths/learning-object/submissions/LearningObjectSubmissionsTable.vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         submissionData?: SubmissionData; | ||||
|         hruid: string; | ||||
|         language: Language; | ||||
|         version: number; | ||||
|         group: { forGroup: number; assignmentNo: number; classId: string }; | ||||
|     }>(); | ||||
|     const emit = defineEmits<(e: "update:submissionData", value: SubmissionData) => void>(); | ||||
| 
 | ||||
|     const submissionQuery = useSubmissionsQuery( | ||||
|         () => props.hruid, | ||||
|         () => props.language, | ||||
|         () => props.version, | ||||
|         () => props.group.classId, | ||||
|         () => props.group.assignmentNo, | ||||
|         () => props.group.forGroup, | ||||
|         () => true, | ||||
|     ); | ||||
| 
 | ||||
|     function emitSubmissionData(submissionData: SubmissionData): void { | ||||
|         emit("update:submissionData", submissionData); | ||||
|     } | ||||
| 
 | ||||
|     function emitSubmission(submission: SubmissionDTO): void { | ||||
|         emitSubmissionData(JSON.parse(submission.content)); | ||||
|     } | ||||
| 
 | ||||
|     watch(submissionQuery.data, () => { | ||||
|         const submissions = submissionQuery.data.value; | ||||
|         if (submissions && submissions.length > 0) { | ||||
|             emitSubmission(submissions[submissions.length - 1]); | ||||
|         } else { | ||||
|             emitSubmissionData([]); | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     const lastSubmission = computed<SubmissionData>(() => { | ||||
|         const submissions = submissionQuery.data.value; | ||||
|         if (!submissions || submissions.length === 0) { | ||||
|             return undefined; | ||||
|         } | ||||
|         return JSON.parse(submissions[submissions.length - 1].content); | ||||
|     }); | ||||
| 
 | ||||
|     const showSubmissionTable = computed(() => props.submissionData !== undefined && props.submissionData.length > 0); | ||||
| 
 | ||||
|     const showIsDoneMessage = computed(() => lastSubmission.value !== undefined && lastSubmission.value.length === 0); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <using-query-result | ||||
|         :query-result="submissionQuery" | ||||
|         v-slot="submissions: { data: SubmissionDTO[] }" | ||||
|     > | ||||
|         <submit-button | ||||
|             :hruid="props.hruid" | ||||
|             :language="props.language" | ||||
|             :version="props.version" | ||||
|             :group="props.group" | ||||
|             :submission-data="props.submissionData" | ||||
|             :submissions="submissions.data" | ||||
|         /> | ||||
|         <div class="submit-submissions-spacer"></div> | ||||
|         <v-alert | ||||
|             icon="mdi-check" | ||||
|             :text="t('taskCompleted')" | ||||
|             type="success" | ||||
|             variant="tonal" | ||||
|             density="compact" | ||||
|             v-if="showIsDoneMessage" | ||||
|         ></v-alert> | ||||
|         <learning-object-submissions-table | ||||
|             v-if="submissionQuery.data && showSubmissionTable" | ||||
|             :all-submissions="submissions.data" | ||||
|             @submission-selected="emitSubmission" | ||||
|         /> | ||||
|     </using-query-result> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .submit-submissions-spacer { | ||||
|         height: 20px; | ||||
|     } | ||||
| </style> | ||||
|  | @ -0,0 +1,98 @@ | |||
| <script setup lang="ts"> | ||||
|     import { computed } from "vue"; | ||||
|     import authService from "@/services/auth/auth-service.ts"; | ||||
|     import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data"; | ||||
|     import { Language } from "@/data-objects/language.ts"; | ||||
|     import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission"; | ||||
|     import { useCreateSubmissionMutation } from "@/queries/submissions.ts"; | ||||
|     import { deepEquals } from "@/utils/deep-equals.ts"; | ||||
|     import type { UserProfile } from "oidc-client-ts"; | ||||
|     import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content"; | ||||
|     import type { StudentDTO } from "@dwengo-1/common/interfaces/student"; | ||||
|     import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         submissionData?: SubmissionData; | ||||
|         submissions: SubmissionDTO[]; | ||||
|         hruid: string; | ||||
|         language: Language; | ||||
|         version: number; | ||||
|         group: { forGroup: number; assignmentNo: number; classId: string }; | ||||
|     }>(); | ||||
| 
 | ||||
|     const { | ||||
|         isPending: submissionIsPending, | ||||
|         // - isError: submissionFailed, | ||||
|         // - error: submissionError, | ||||
|         // - isSuccess: submissionSuccess, | ||||
|         mutate: submitSolution, | ||||
|     } = useCreateSubmissionMutation(); | ||||
| 
 | ||||
|     const isStudent = computed(() => authService.authState.activeRole === "student"); | ||||
| 
 | ||||
|     const isSubmitDisabled = computed(() => { | ||||
|         if (!props.submissionData || props.submissions === undefined) { | ||||
|             return true; | ||||
|         } | ||||
|         if (props.submissionData.some((answer) => answer === null)) { | ||||
|             return false; | ||||
|         } | ||||
|         if (props.submissions.length === 0) { | ||||
|             return false; | ||||
|         } | ||||
|         return deepEquals(JSON.parse(props.submissions[props.submissions.length - 1].content), props.submissionData); | ||||
|     }); | ||||
| 
 | ||||
|     function submitCurrentAnswer(): void { | ||||
|         const { forGroup, assignmentNo, classId } = props.group; | ||||
|         const currentUser: UserProfile = authService.authState.user!.profile; | ||||
|         const learningObjectIdentifier: LearningObjectIdentifierDTO = { | ||||
|             hruid: props.hruid, | ||||
|             language: props.language, | ||||
|             version: props.version, | ||||
|         }; | ||||
|         const submitter: StudentDTO = { | ||||
|             id: currentUser.preferred_username!, | ||||
|             username: currentUser.preferred_username!, | ||||
|             firstName: currentUser.given_name!, | ||||
|             lastName: currentUser.family_name!, | ||||
|         }; | ||||
|         const group: GroupDTO = { | ||||
|             class: classId, | ||||
|             assignment: assignmentNo, | ||||
|             groupNumber: forGroup, | ||||
|         }; | ||||
|         const submission: SubmissionDTO = { | ||||
|             learningObjectIdentifier, | ||||
|             submitter, | ||||
|             group, | ||||
|             content: JSON.stringify(props.submissionData), | ||||
|         }; | ||||
|         submitSolution({ data: submission }); | ||||
|     } | ||||
| 
 | ||||
|     const buttonText = computed(() => { | ||||
|         if (props.submissionData && props.submissionData.length === 0) { | ||||
|             return t("markAsDone"); | ||||
|         } | ||||
|         return t(props.submissions.length > 0 ? "submitNewSolution" : "submitSolution"); | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <v-btn | ||||
|         v-if="isStudent && !isSubmitDisabled" | ||||
|         prepend-icon="mdi-check" | ||||
|         variant="elevated" | ||||
|         :loading="submissionIsPending" | ||||
|         :disabled="isSubmitDisabled" | ||||
|         @click="submitCurrentAnswer()" | ||||
|     > | ||||
|         {{ buttonText }} | ||||
|     </v-btn> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
		Reference in a new issue
	
	 Timo De Meyst
						Timo De Meyst