Merge branch 'dev' into feat/discussions
This commit is contained in:
		
						commit
						e28a57754f
					
				
					 44 changed files with 2270 additions and 767 deletions
				
			
		|  | @ -18,10 +18,19 @@ | |||
|     font-size: 1.1rem; | ||||
| } | ||||
| 
 | ||||
| .top-right-btn { | ||||
|     position: absolute; | ||||
|     right: 2%; | ||||
|     color: red; | ||||
| .top-buttons-wrapper { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     padding: 1rem; | ||||
|     position: relative; | ||||
| } | ||||
| 
 | ||||
| .right-buttons { | ||||
|     display: flex; | ||||
|     gap: 0.5rem; | ||||
|     align-items: center; | ||||
|     color: #0e6942; | ||||
| } | ||||
| 
 | ||||
| .group-section { | ||||
|  |  | |||
							
								
								
									
										52
									
								
								frontend/src/components/DwengoTable.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								frontend/src/components/DwengoTable.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,52 @@ | |||
| <template> | ||||
|     <v-table class="table"> | ||||
|         <thead> | ||||
|             <tr | ||||
|                 v-for="name in columns" | ||||
|                 :key="column" | ||||
|             > | ||||
|                 <th class="header">{{ name }}</th> | ||||
|             </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|             <tr | ||||
|                 v-for="([item1, item2, item3], index) in listItems" | ||||
|                 :key="index" | ||||
|             > | ||||
|                 <td></td> | ||||
|                 <td> | ||||
|                     <v-btn | ||||
|                         :to="`/class/${c.id}`" | ||||
|                         variant="text" | ||||
|                     > | ||||
|                         {{ c.displayName }} | ||||
|                         <v-icon end> mdi-menu-right </v-icon> | ||||
|                     </v-btn> | ||||
|                 </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> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
|     export default { | ||||
|         name: "columnList", | ||||
|         props: { | ||||
|             items: { | ||||
|                 type: Array, | ||||
|                 required: true, | ||||
|             }, | ||||
|         }, | ||||
|     }; | ||||
| </script> | ||||
							
								
								
									
										47
									
								
								frontend/src/components/GroupProgressRow.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								frontend/src/components/GroupProgressRow.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,47 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import { computed } from "vue"; | ||||
|     import type { Language } from "@/data-objects/language.ts"; | ||||
|     import { calculateProgress } from "@/utils/assignment-utils.ts"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         groupNumber: number; | ||||
|         learningPath: string; | ||||
|         language: Language; | ||||
|         assignmentId: number; | ||||
|         classId: string; | ||||
|     }>(); | ||||
| 
 | ||||
|     const query = useGetLearningPathQuery( | ||||
|         () => props.learningPath, | ||||
|         () => props.language, | ||||
|         () => ({ | ||||
|             forGroup: props.groupNumber, | ||||
|             assignmentNo: props.assignmentId, | ||||
|             classId: props.classId, | ||||
|         }), | ||||
|     ); | ||||
| 
 | ||||
|     const progress = computed(() => { | ||||
|         if (!query.data.value) return 0; | ||||
|         return calculateProgress(query.data.value); | ||||
|     }); | ||||
| 
 | ||||
|     const progressColor = computed(() => { | ||||
|         if (progress.value < 50) return "error"; | ||||
|         if (progress.value < 80) return "warning"; | ||||
|         return "success"; | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <v-progress-linear | ||||
|         :model-value="progress" | ||||
|         :color="progressColor" | ||||
|         height="25" | ||||
|     > | ||||
|         <template v-slot:default="{ value }"> | ||||
|             <strong>{{ Math.ceil(value) }}%</strong> | ||||
|         </template> | ||||
|     </v-progress-linear> | ||||
| </template> | ||||
							
								
								
									
										50
									
								
								frontend/src/components/GroupSubmissionStatus.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								frontend/src/components/GroupSubmissionStatus.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,50 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { useAssignmentSubmissionsQuery } from "@/queries/assignments.ts"; | ||||
|     import type { SubmissionsResponse } from "@/controllers/submissions.ts"; | ||||
|     import { watch } from "vue"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         group: object; | ||||
|         assignmentId: number; | ||||
|         classId: string; | ||||
|         goToGroupSubmissionLink: (groupNo: number) => void; | ||||
|     }>(); | ||||
| 
 | ||||
|     const emit = defineEmits<(e: "update:hasSubmission", hasSubmission: boolean) => void>(); | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
|     const submissionsQuery = useAssignmentSubmissionsQuery( | ||||
|         () => props.classId, | ||||
|         () => props.assignmentId, | ||||
|         () => props.group.originalGroupNo, | ||||
|         () => true, | ||||
|     ); | ||||
| 
 | ||||
|     watch( | ||||
|         () => submissionsQuery.data.value, | ||||
|         (data) => { | ||||
|             if (data) { | ||||
|                 emit("update:hasSubmission", data.submissions.length > 0); | ||||
|             } | ||||
|         }, | ||||
|         { immediate: true }, | ||||
|     ); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <using-query-result | ||||
|         :query-result="submissionsQuery" | ||||
|         v-slot="{ data }: { data: SubmissionsResponse }" | ||||
|     > | ||||
|         <v-btn | ||||
|             :color="data?.submissions?.length > 0 ? 'green' : 'red'" | ||||
|             variant="text" | ||||
|             :to="data.submissions.length > 0 ? goToGroupSubmissionLink(props.group.groupNo) : undefined" | ||||
|             :disabled="data.submissions.length === 0" | ||||
|         > | ||||
|             {{ data.submissions.length > 0 ? t("submission") : t("noSubmissionsYet") }} | ||||
|         </v-btn> | ||||
|     </using-query-result> | ||||
| </template> | ||||
|  | @ -148,7 +148,8 @@ | |||
|             </template> | ||||
| 
 | ||||
|             <template v-slot:default="{ isActive }"> | ||||
|                 <v-card :title="t('logoutVerification')"> | ||||
|                 <v-card> | ||||
|                     <v-card-title class="logout-verification-title">{{ t("logoutVerification") }}</v-card-title> | ||||
|                     <v-card-actions> | ||||
|                         <v-spacer></v-spacer> | ||||
| 
 | ||||
|  | @ -297,6 +298,13 @@ | |||
|         margin-left: 10px; | ||||
|     } | ||||
| 
 | ||||
|     .logout-verification-title { | ||||
|         word-wrap: break-word; | ||||
|         overflow-wrap: break-word; | ||||
|         white-space: normal; | ||||
|         text-overflow: unset; | ||||
|     } | ||||
| 
 | ||||
|     @media (max-width: 700px) { | ||||
|         .menu { | ||||
|             display: none; | ||||
|  |  | |||
							
								
								
									
										0
									
								
								frontend/src/components/assignments/AssignmentCard.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								frontend/src/components/assignments/AssignmentCard.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -1,18 +1,42 @@ | |||
| <script setup lang="ts"> | ||||
|     import { ref, watch } from "vue"; | ||||
|     import { deadlineRules } from "@/utils/assignment-rules.ts"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
| 
 | ||||
|     const emit = defineEmits<(e: "update:deadline", value: Date) => void>(); | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const emit = defineEmits<(e: "update:deadline", value: Date | null) => void>(); | ||||
|     const props = defineProps<{ deadline: Date | null }>(); | ||||
| 
 | ||||
|     const datetime = ref(""); | ||||
| 
 | ||||
|     datetime.value = props.deadline ? new Date(props.deadline).toISOString().slice(0, 16) : ""; | ||||
| 
 | ||||
|     // Watch the datetime value and emit the update | ||||
|     watch(datetime, (val) => { | ||||
|         const newDate = new Date(val); | ||||
|         if (!isNaN(newDate.getTime())) { | ||||
|             emit("update:deadline", newDate); | ||||
|         } else { | ||||
|             emit("update:deadline", null); | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     const deadlineRules = [ | ||||
|         (value: string): string | boolean => { | ||||
|             const selectedDateTime = new Date(value); | ||||
|             const now = new Date(); | ||||
| 
 | ||||
|             if (isNaN(selectedDateTime.getTime())) { | ||||
|                 return t("deadline-invalid"); | ||||
|             } | ||||
| 
 | ||||
|             if (selectedDateTime <= now) { | ||||
|                 return t("deadline-past"); | ||||
|             } | ||||
| 
 | ||||
|             return true; | ||||
|         }, | ||||
|     ]; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
|  | @ -1,75 +1,680 @@ | |||
| <script setup lang="ts"> | ||||
|     import { ref } from "vue"; | ||||
|     import { computed, ref, watch } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import type { StudentsResponse } from "@/controllers/students.ts"; | ||||
|     import { useClassStudentsQuery } from "@/queries/classes.ts"; | ||||
|     import { useClassStudentsQuery } from "@/queries/classes"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         classId: string | undefined; | ||||
|         groups: string[][]; | ||||
|         groups: object[]; | ||||
|     }>(); | ||||
|     const emit = defineEmits(["groupCreated"]); | ||||
|     const emit = defineEmits(["close", "groupsUpdated", "done"]); | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const selectedStudents = ref([]); | ||||
| 
 | ||||
|     const studentQueryResult = useClassStudentsQuery(() => props.classId, true); | ||||
| 
 | ||||
|     function filterStudents(data: StudentsResponse): { title: string; value: string }[] { | ||||
|         const students = data.students; | ||||
|         const studentsInGroups = props.groups.flat(); | ||||
| 
 | ||||
|         return students | ||||
|             ?.map((st) => ({ | ||||
|                 title: `${st.firstName} ${st.lastName}`, | ||||
|                 value: st.username, | ||||
|             })) | ||||
|             .filter((student) => !studentsInGroups.includes(student.value)); | ||||
|     interface StudentItem { | ||||
|         username: string; | ||||
|         fullName: string; | ||||
|     } | ||||
| 
 | ||||
|     function createGroup(): void { | ||||
|         if (selectedStudents.value.length) { | ||||
|             // Extract only usernames (student.value) | ||||
|             const usernames = selectedStudents.value.map((student) => student.value); | ||||
|             emit("groupCreated", usernames); | ||||
|             selectedStudents.value = []; // Reset selection after creating group | ||||
|     const { data: studentsData } = useClassStudentsQuery(() => props.classId, true); | ||||
| 
 | ||||
|     // Dialog states for group editing | ||||
|     const activeDialog = ref<"random" | "dragdrop" | null>(null); | ||||
| 
 | ||||
|     // Drag state for the drag and drop | ||||
|     const draggedItem = ref<{ groupIndex: number; studentIndex: number } | null>(null); | ||||
| 
 | ||||
|     const currentGroups = ref<StudentItem[][]>([]); | ||||
|     const unassignedStudents = ref<StudentItem[]>([]); | ||||
|     const allStudents = ref<StudentItem[]>([]); | ||||
| 
 | ||||
|     // Random groups state | ||||
|     const groupSize = ref(1); | ||||
|     const randomGroupsPreview = ref<StudentItem[][]>([]); | ||||
| 
 | ||||
|     // Initialize data | ||||
|     watch( | ||||
|         () => [studentsData.value, props.groups], | ||||
|         ([studentsVal, existingGroups]) => { | ||||
|             if (!studentsVal) return; | ||||
| 
 | ||||
|             // Initialize all students | ||||
|             allStudents.value = studentsVal.students.map((s) => ({ | ||||
|                 username: s.username, | ||||
|                 fullName: `${s.firstName} ${s.lastName}`, | ||||
|             })); | ||||
| 
 | ||||
|             // Initialize groups if they exist | ||||
|             if (existingGroups && existingGroups.length > 0) { | ||||
|                 currentGroups.value = existingGroups.map((group) => | ||||
|                     group.members.map((member) => ({ | ||||
|                         username: member.username, | ||||
|                         fullName: `${member.firstName} ${member.lastName}`, | ||||
|                     })), | ||||
|                 ); | ||||
|                 const assignedUsernames = new Set( | ||||
|                     existingGroups.flatMap((g) => g.members.map((m: StudentItem) => m.username)), | ||||
|                 ); | ||||
|                 unassignedStudents.value = allStudents.value.filter((s) => !assignedUsernames.has(s.username)); | ||||
|             } else { | ||||
|                 currentGroups.value = []; | ||||
|                 unassignedStudents.value = [...allStudents.value]; | ||||
|             } | ||||
| 
 | ||||
|             randomGroupsPreview.value = [...currentGroups.value]; | ||||
|         }, | ||||
|         { immediate: true }, | ||||
|     ); | ||||
| 
 | ||||
|     /** Random groups functions */ | ||||
|     function generateRandomGroups(): void { | ||||
|         if (groupSize.value < 1) return; | ||||
| 
 | ||||
|         // Shuffle students | ||||
|         const shuffled = [...allStudents.value].sort(() => Math.random() - 0.5); | ||||
| 
 | ||||
|         // Create new groups | ||||
|         const newGroups: StudentItem[][] = []; | ||||
|         const groupCount = Math.ceil(shuffled.length / groupSize.value); | ||||
| 
 | ||||
|         for (let i = 0; i < groupCount; i++) { | ||||
|             newGroups.push([]); | ||||
|         } | ||||
| 
 | ||||
|         // Distribute students | ||||
|         shuffled.forEach((student, index) => { | ||||
|             const groupIndex = index % groupCount; | ||||
|             newGroups[groupIndex].push(student); | ||||
|         }); | ||||
| 
 | ||||
|         randomGroupsPreview.value = newGroups; | ||||
|     } | ||||
| 
 | ||||
|     function saveRandomGroups(): void { | ||||
|         emit( | ||||
|             "groupsUpdated", | ||||
|             randomGroupsPreview.value.map((g) => g.map((s) => s.username)), | ||||
|         ); | ||||
|         activeDialog.value = null; | ||||
|         emit("done"); | ||||
|         emit("close"); | ||||
|     } | ||||
| 
 | ||||
|     function addNewGroup(): void { | ||||
|         currentGroups.value.push([]); | ||||
|     } | ||||
| 
 | ||||
|     function removeGroup(index: number): void { | ||||
|         // Move students back to unassigned | ||||
|         unassignedStudents.value.push(...currentGroups.value[index]); | ||||
|         currentGroups.value.splice(index, 1); | ||||
|     } | ||||
| 
 | ||||
|     /** Drag and drop functions */ | ||||
| 
 | ||||
|     // Touch state interface | ||||
|     interface TouchState { | ||||
|         isDragging: boolean; | ||||
|         startX: number; | ||||
|         startY: number; | ||||
|         currentGroupIndex: number; | ||||
|         currentStudentIndex: number; | ||||
|         element: HTMLElement | null; | ||||
|         clone: HTMLElement | null; | ||||
|         originalRect: DOMRect | null; | ||||
|         hasMoved: boolean; | ||||
|     } | ||||
| 
 | ||||
|     const touchState = ref<TouchState>({ | ||||
|         isDragging: false, | ||||
|         startX: 0, | ||||
|         startY: 0, | ||||
|         currentGroupIndex: -1, | ||||
|         currentStudentIndex: -1, | ||||
|         element: null, | ||||
|         clone: null, | ||||
|         originalRect: null, | ||||
|         hasMoved: false, | ||||
|     }); | ||||
| 
 | ||||
|     function handleTouchStart(event: TouchEvent, groupIndex: number, studentIndex: number): void { | ||||
|         if (event.touches.length > 1) return; | ||||
| 
 | ||||
|         const touch = event.touches[0]; | ||||
|         const target = event.target as HTMLElement; | ||||
|         // Target the chip directly instead of the draggable container | ||||
|         const chip = target.closest(".v-chip") as HTMLElement; | ||||
| 
 | ||||
|         if (!chip) return; | ||||
| 
 | ||||
|         // Get the chip's position relative to the viewport | ||||
|         const rect = chip.getBoundingClientRect(); | ||||
| 
 | ||||
|         touchState.value = { | ||||
|             isDragging: true, | ||||
|             startX: touch.clientX, | ||||
|             startY: touch.clientY, | ||||
|             currentGroupIndex: groupIndex, | ||||
|             currentStudentIndex: studentIndex, | ||||
|             element: chip, | ||||
|             clone: null, | ||||
|             originalRect: rect, | ||||
|             hasMoved: false, | ||||
|         }; | ||||
| 
 | ||||
|         // Clone only the chip | ||||
|         const clone = chip.cloneNode(true) as HTMLElement; | ||||
|         clone.classList.add("drag-clone"); | ||||
|         clone.style.position = "fixed"; | ||||
|         clone.style.zIndex = "10000"; | ||||
|         clone.style.opacity = "0.9"; | ||||
|         clone.style.pointerEvents = "none"; | ||||
|         clone.style.width = `${rect.width}px`; | ||||
|         clone.style.height = `${rect.height}px`; | ||||
|         clone.style.left = `${rect.left}px`; | ||||
|         clone.style.top = `${rect.top}px`; | ||||
|         clone.style.transform = "scale(1.05)"; | ||||
|         clone.style.boxShadow = "0 4px 8px rgba(0,0,0,0.3)"; | ||||
|         clone.style.transition = "transform 0.1s"; | ||||
| 
 | ||||
|         // Ensure the clone has the same chip styling | ||||
|         clone.style.backgroundColor = getComputedStyle(chip).backgroundColor; | ||||
|         clone.style.color = getComputedStyle(chip).color; | ||||
|         clone.style.borderRadius = getComputedStyle(chip).borderRadius; | ||||
|         clone.style.padding = getComputedStyle(chip).padding; | ||||
|         clone.style.margin = "0"; // Remove any margin | ||||
| 
 | ||||
|         document.body.appendChild(clone); | ||||
|         touchState.value.clone = clone; | ||||
|         chip.style.visibility = "hidden"; | ||||
| 
 | ||||
|         event.preventDefault(); | ||||
|         event.stopPropagation(); | ||||
|     } | ||||
| 
 | ||||
|     function handleTouchMove(event: TouchEvent): void { | ||||
|         if (!touchState.value.isDragging || !touchState.value.clone || event.touches.length > 1) return; | ||||
| 
 | ||||
|         const touch = event.touches[0]; | ||||
|         const clone = touchState.value.clone; | ||||
| 
 | ||||
|         const dx = Math.abs(touch.clientX - touchState.value.startX); | ||||
|         const dy = Math.abs(touch.clientY - touchState.value.startY); | ||||
| 
 | ||||
|         if (dx > 5 || dy > 5) { | ||||
|             touchState.value.hasMoved = true; | ||||
|         } | ||||
| 
 | ||||
|         clone.style.left = `${touch.clientX - clone.offsetWidth / 2}px`; | ||||
|         clone.style.top = `${touch.clientY - clone.offsetHeight / 2}px`; | ||||
| 
 | ||||
|         document.querySelectorAll(".group-box").forEach((el) => { | ||||
|             el.classList.remove("highlight"); | ||||
|         }); | ||||
| 
 | ||||
|         const elements = document.elementsFromPoint(touch.clientX, touch.clientY); | ||||
|         const dropTarget = elements.find((el) => el.classList.contains("group-box")); | ||||
| 
 | ||||
|         if (dropTarget) { | ||||
|             dropTarget.classList.add("highlight"); | ||||
|         } | ||||
| 
 | ||||
|         event.preventDefault(); | ||||
|         event.stopPropagation(); | ||||
|     } | ||||
| 
 | ||||
|     function handleTouchEnd(event: TouchEvent): void { | ||||
|         if (!touchState.value.isDragging) return; | ||||
| 
 | ||||
|         const { currentGroupIndex, currentStudentIndex, clone, element, hasMoved } = touchState.value; | ||||
| 
 | ||||
|         document.querySelectorAll(".group-box").forEach((el) => { | ||||
|             el.classList.remove("highlight"); | ||||
|         }); | ||||
| 
 | ||||
|         if (clone?.parentNode) { | ||||
|             clone.parentNode.removeChild(clone); | ||||
|         } | ||||
| 
 | ||||
|         if (element) { | ||||
|             element.style.visibility = "visible"; | ||||
|         } | ||||
| 
 | ||||
|         if (hasMoved && event.changedTouches.length > 0) { | ||||
|             const touch = event.changedTouches[0]; | ||||
|             const elements = document.elementsFromPoint(touch.clientX, touch.clientY); | ||||
|             const dropTarget = elements.find((el) => el.classList.contains("group-box")); | ||||
| 
 | ||||
|             if (dropTarget) { | ||||
|                 const groupBoxes = document.querySelectorAll(".group-box"); | ||||
|                 const targetGroupIndex = Array.from(groupBoxes).indexOf(dropTarget); | ||||
| 
 | ||||
|                 if (targetGroupIndex !== currentGroupIndex) { | ||||
|                     const sourceArray = | ||||
|                         currentGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[currentGroupIndex]; | ||||
|                     const targetArray = | ||||
|                         targetGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[targetGroupIndex]; | ||||
| 
 | ||||
|                     if (sourceArray && targetArray) { | ||||
|                         const [movedStudent] = sourceArray.splice(currentStudentIndex, 1); | ||||
|                         targetArray.push(movedStudent); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         touchState.value = { | ||||
|             isDragging: false, | ||||
|             startX: 0, | ||||
|             startY: 0, | ||||
|             currentGroupIndex: -1, | ||||
|             currentStudentIndex: -1, | ||||
|             element: null, | ||||
|             clone: null, | ||||
|             originalRect: null, | ||||
|             hasMoved: false, | ||||
|         }; | ||||
| 
 | ||||
|         event.preventDefault(); | ||||
|         event.stopPropagation(); | ||||
|     } | ||||
| 
 | ||||
|     function handleDragStart(event: DragEvent, groupIndex: number, studentIndex: number): void { | ||||
|         draggedItem.value = { groupIndex, studentIndex }; | ||||
|         if (event.dataTransfer) { | ||||
|             event.dataTransfer.effectAllowed = "move"; | ||||
|             event.dataTransfer.setData("text/plain", ""); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     function handleDragOver(e: DragEvent, _: number): void { | ||||
|         e.preventDefault(); | ||||
|         if (e.dataTransfer) { | ||||
|             e.dataTransfer.dropEffect = "move"; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     function handleDrop(e: DragEvent, targetGroupIndex: number, targetStudentIndex?: number): void { | ||||
|         e.preventDefault(); | ||||
|         if (!draggedItem.value) return; | ||||
| 
 | ||||
|         const { groupIndex: sourceGroupIndex, studentIndex: sourceStudentIndex } = draggedItem.value; | ||||
|         const sourceArray = sourceGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[sourceGroupIndex]; | ||||
|         const targetArray = targetGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[targetGroupIndex]; | ||||
| 
 | ||||
|         const [movedStudent] = sourceArray.splice(sourceStudentIndex, 1); | ||||
|         if (targetStudentIndex !== undefined) { | ||||
|             targetArray.splice(targetStudentIndex, 0, movedStudent); | ||||
|         } else { | ||||
|             targetArray.push(movedStudent); | ||||
|         } | ||||
| 
 | ||||
|         draggedItem.value = null; | ||||
|     } | ||||
| 
 | ||||
|     function saveDragDrop(): void { | ||||
|         emit( | ||||
|             "groupsUpdated", | ||||
|             currentGroups.value | ||||
|                 .filter((g) => g.length > 0) // Filter out empty groups | ||||
|                 .map((g) => g.map((s) => s.username)), | ||||
|         ); | ||||
|         activeDialog.value = null; | ||||
|         emit("done"); | ||||
|         emit("close"); | ||||
|     } | ||||
| 
 | ||||
|     const showGroupsPreview = computed(() => currentGroups.value.length > 0 || unassignedStudents.value.length > 0); | ||||
| 
 | ||||
|     function removeStudent(groupIndex: number, student: StudentItem): void { | ||||
|         const group = currentGroups.value[groupIndex]; | ||||
|         currentGroups.value[groupIndex] = group.filter((s) => s.username !== student.username); | ||||
|         unassignedStudents.value.push(student); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <using-query-result | ||||
|         :query-result="studentQueryResult" | ||||
|         v-slot="{ data }: { data: StudentsResponse }" | ||||
|     > | ||||
|         <h3>{{ t("create-groups") }}</h3> | ||||
|         <v-card-text> | ||||
|             <v-combobox | ||||
|                 v-model="selectedStudents" | ||||
|                 :items="filterStudents(data)" | ||||
|                 item-title="title" | ||||
|                 item-value="value" | ||||
|                 :label="t('choose-students')" | ||||
|                 variant="outlined" | ||||
|                 clearable | ||||
|                 multiple | ||||
|                 hide-details | ||||
|                 density="compact" | ||||
|                 chips | ||||
|                 append-inner-icon="mdi-magnify" | ||||
|             ></v-combobox> | ||||
|     <v-card class="pa-4 minimal-card"> | ||||
|         <!-- Current groups and unassigned students Preview --> | ||||
|         <div | ||||
|             v-if="showGroupsPreview" | ||||
|             class="mb-6" | ||||
|         > | ||||
|             <h3 class="mb-2">{{ t("current-groups") }}</h3> | ||||
|             <div> | ||||
|                 <div class="d-flex flex-wrap"> | ||||
|                     <label>{{ currentGroups.length }}</label> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <v-row | ||||
|             justify="center" | ||||
|             class="mb-4" | ||||
|         > | ||||
|             <v-btn | ||||
|                 @click="createGroup" | ||||
|                 color="primary" | ||||
|                 class="mt-2" | ||||
|                 size="small" | ||||
|                 @click="activeDialog = 'random'" | ||||
|                 prepend-icon="mdi-shuffle" | ||||
|             > | ||||
|                 {{ t("create-group") }} | ||||
|                 {{ t("random-grouping") }} | ||||
|             </v-btn> | ||||
|         </v-card-text> | ||||
|     </using-query-result> | ||||
|             <v-btn | ||||
|                 color="secondary" | ||||
|                 class="ml-4" | ||||
|                 @click="activeDialog = 'dragdrop'" | ||||
|                 prepend-icon="mdi-drag" | ||||
|             > | ||||
|                 {{ t("drag-and-drop") }} | ||||
|             </v-btn> | ||||
|         </v-row> | ||||
| 
 | ||||
|         <!-- Random Groups selection Dialog --> | ||||
|         <v-dialog | ||||
|             :model-value="activeDialog === 'random'" | ||||
|             @update:model-value="(val) => (val ? (activeDialog = 'random') : (activeDialog = null))" | ||||
|             max-width="600" | ||||
|         > | ||||
|             <v-card class="custom-dialog"> | ||||
|                 <v-card-title class="dialog-title">{{ t("auto-generate-groups") }}</v-card-title> | ||||
|                 <v-card-text> | ||||
|                     <v-row align="center"> | ||||
|                         <v-col cols="6"> | ||||
|                             <v-text-field | ||||
|                                 v-model.number="groupSize" | ||||
|                                 type="number" | ||||
|                                 min="1" | ||||
|                                 :max="allStudents.length" | ||||
|                                 :label="t('group-size-label')" | ||||
|                                 dense | ||||
|                             /> | ||||
|                         </v-col> | ||||
|                         <v-col cols="6"> | ||||
|                             <v-btn | ||||
|                                 color="primary" | ||||
|                                 @click="generateRandomGroups" | ||||
|                                 :disabled="groupSize < 1 || groupSize > allStudents.length" | ||||
|                                 block | ||||
|                             > | ||||
|                                 {{ t("generate-groups") }} | ||||
|                             </v-btn> | ||||
|                         </v-col> | ||||
|                     </v-row> | ||||
| 
 | ||||
|                     <div class="mt-4"> | ||||
|                         <div class="d-flex justify-space-between align-center mb-2"> | ||||
|                             <strong>{{ t("preview") }}</strong> | ||||
|                             <span class="text-caption"> {{ randomGroupsPreview.length }} {{ t("groups") }} </span> | ||||
|                         </div> | ||||
| 
 | ||||
|                         <v-expansion-panels> | ||||
|                             <v-expansion-panel | ||||
|                                 v-for="(group, index) in randomGroupsPreview" | ||||
|                                 :key="'random-preview-' + index" | ||||
|                             > | ||||
|                                 <v-expansion-panel-title> | ||||
|                                     {{ t("group") }} {{ index + 1 }} ({{ group.length }} {{ t("members") }}) | ||||
|                                 </v-expansion-panel-title> | ||||
|                                 <v-expansion-panel-text> | ||||
|                                     <v-chip | ||||
|                                         v-for="student in group" | ||||
|                                         :key="student.username" | ||||
|                                         class="ma-1" | ||||
|                                     > | ||||
|                                         {{ student.fullName }} | ||||
|                                     </v-chip> | ||||
|                                 </v-expansion-panel-text> | ||||
|                             </v-expansion-panel> | ||||
|                         </v-expansion-panels> | ||||
|                     </div> | ||||
|                 </v-card-text> | ||||
| 
 | ||||
|                 <v-card-actions class="dialog-actions"> | ||||
|                     <v-spacer /> | ||||
|                     <v-btn | ||||
|                         text | ||||
|                         @click="activeDialog = null" | ||||
|                         >{{ t("cancel") }}</v-btn | ||||
|                     > | ||||
|                     <v-btn | ||||
|                         color="success" | ||||
|                         @click="saveRandomGroups" | ||||
|                         :disabled="randomGroupsPreview.length === 0" | ||||
|                     > | ||||
|                         {{ t("save") }} | ||||
|                     </v-btn> | ||||
|                 </v-card-actions> | ||||
|             </v-card> | ||||
|         </v-dialog> | ||||
| 
 | ||||
|         <!-- Drag and Drop Dialog --> | ||||
|         <v-dialog | ||||
|             :model-value="activeDialog === 'dragdrop'" | ||||
|             @update:model-value="(val) => (val ? (activeDialog = 'dragdrop') : (activeDialog = null))" | ||||
|             max-width="900" | ||||
|         > | ||||
|             <v-card class="custom-dialog"> | ||||
|                 <v-card-title class="dialog-title d-flex justify-space-between align-center"> | ||||
|                     <div>{{ t("drag-and-drop") }}</div> | ||||
|                     <v-btn | ||||
|                         color="primary" | ||||
|                         small | ||||
|                         @click="addNewGroup" | ||||
|                         >+</v-btn | ||||
|                     > | ||||
|                 </v-card-title> | ||||
| 
 | ||||
|                 <v-card-text> | ||||
|                     <v-row> | ||||
|                         <!-- Groups Column --> | ||||
|                         <v-col | ||||
|                             cols="12" | ||||
|                             md="8" | ||||
|                         > | ||||
|                             <div | ||||
|                                 v-if="currentGroups.length === 0" | ||||
|                                 class="text-center py-4" | ||||
|                             > | ||||
|                                 <div> | ||||
|                                     <v-icon | ||||
|                                         icon="mdi-information-outline" | ||||
|                                         size="small" | ||||
|                                     /> | ||||
|                                     {{ t("currently-no-groups") }} | ||||
|                                 </div> | ||||
|                             </div> | ||||
| 
 | ||||
|                             <template v-else> | ||||
|                                 <div | ||||
|                                     v-for="(group, groupIndex) in currentGroups" | ||||
|                                     :key="groupIndex" | ||||
|                                     class="mb-4" | ||||
|                                     @dragover.prevent="handleDragOver($event, groupIndex)" | ||||
|                                     @drop="handleDrop($event, groupIndex)" | ||||
|                                 > | ||||
|                                     <div class="d-flex justify-space-between align-center mb-2"> | ||||
|                                         <strong>{{ t("group") }} {{ groupIndex + 1 }}</strong> | ||||
|                                         <v-btn | ||||
|                                             icon | ||||
|                                             small | ||||
|                                             color="error" | ||||
|                                             @click="removeGroup(groupIndex)" | ||||
|                                         > | ||||
|                                             <v-icon>mdi-delete</v-icon> | ||||
|                                         </v-btn> | ||||
|                                     </div> | ||||
| 
 | ||||
|                                     <div class="group-box pa-2"> | ||||
|                                         <div | ||||
|                                             v-for="(student, studentIndex) in group" | ||||
|                                             :key="student.username" | ||||
|                                             class="draggable-item ma-1" | ||||
|                                             draggable="true" | ||||
|                                             @touchstart="handleTouchStart($event, groupIndex, studentIndex)" | ||||
|                                             @touchmove="handleTouchMove($event)" | ||||
|                                             @touchend="handleTouchEnd($event)" | ||||
|                                             @dragstart="handleDragStart($event, groupIndex, studentIndex)" | ||||
|                                             @dragover.prevent="handleDragOver($event, groupIndex)" | ||||
|                                             @drop="handleDrop($event, groupIndex, studentIndex)" | ||||
|                                         > | ||||
|                                             <v-chip | ||||
|                                                 close | ||||
|                                                 @click:close="removeStudent(groupIndex, student)" | ||||
|                                             > | ||||
|                                                 {{ student.fullName }} | ||||
|                                             </v-chip> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </template> | ||||
|                         </v-col> | ||||
| 
 | ||||
|                         <!-- Unassigned Students Column --> | ||||
|                         <v-col | ||||
|                             cols="12" | ||||
|                             md="4" | ||||
|                             @dragover.prevent="handleDragOver($event, -1)" | ||||
|                             @drop="handleDrop($event, -1)" | ||||
|                         > | ||||
|                             <div class="mb-2"> | ||||
|                                 <strong>{{ t("unassigned") }}</strong> | ||||
|                                 <span class="text-caption ml-2">({{ unassignedStudents.length }})</span> | ||||
|                             </div> | ||||
| 
 | ||||
|                             <div class="group-box pa-2"> | ||||
|                                 <div | ||||
|                                     v-for="(student, studentIndex) in unassignedStudents" | ||||
|                                     :key="student.username" | ||||
|                                     class="draggable-item ma-1" | ||||
|                                     draggable="true" | ||||
|                                     @touchstart="handleTouchStart($event, -1, studentIndex)" | ||||
|                                     @touchmove="handleTouchMove($event)" | ||||
|                                     @touchend="handleTouchEnd($event)" | ||||
|                                     @dragstart="handleDragStart($event, -1, studentIndex)" | ||||
|                                     @dragover.prevent="handleDragOver($event, -1)" | ||||
|                                     @drop="handleDrop($event, -1, studentIndex)" | ||||
|                                 > | ||||
|                                     <v-chip>{{ student.fullName }}</v-chip> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </v-col> | ||||
|                     </v-row> | ||||
|                 </v-card-text> | ||||
| 
 | ||||
|                 <v-card-actions> | ||||
|                     <v-spacer /> | ||||
|                     <v-btn | ||||
|                         text | ||||
|                         @click="activeDialog = null" | ||||
|                         >{{ t("cancel") }}</v-btn | ||||
|                     > | ||||
|                     <v-btn | ||||
|                         color="primary" | ||||
|                         @click="saveDragDrop" | ||||
|                     > | ||||
|                         {{ t("save") }} | ||||
|                     </v-btn> | ||||
|                 </v-card-actions> | ||||
|             </v-card> | ||||
|         </v-dialog> | ||||
|     </v-card> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
| <style scoped> | ||||
|     .group-box { | ||||
|         min-height: 100px; | ||||
|         max-height: 200px; | ||||
|         overflow-y: auto; | ||||
|         background-color: #fafafa; | ||||
|         border-radius: 4px; | ||||
|         transition: all 0.2s; | ||||
|     } | ||||
| 
 | ||||
|     .group-box.highlight { | ||||
|         background-color: #e3f2fd; | ||||
|         border: 2px dashed #2196f3; | ||||
|     } | ||||
| 
 | ||||
|     .v-expansion-panel-text { | ||||
|         max-height: 200px; | ||||
|         overflow-y: auto; | ||||
|     } | ||||
| 
 | ||||
|     .drag-clone { | ||||
|         z-index: 10000; | ||||
|         transform: scale(1.05); | ||||
|         box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); | ||||
|         transition: transform 0.1s; | ||||
|         will-change: transform; | ||||
|         pointer-events: none; | ||||
|         display: inline-flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         border-radius: 16px; | ||||
|         background-color: inherit; | ||||
|     } | ||||
| 
 | ||||
|     .draggable-item { | ||||
|         display: inline-block; | ||||
|     } | ||||
| 
 | ||||
|     .draggable-item .v-chip[style*="hidden"] { | ||||
|         visibility: hidden; | ||||
|         display: inline-block; | ||||
|     } | ||||
| 
 | ||||
|     .custom-dialog { | ||||
|         border-radius: 16px; | ||||
|         padding: 24px; | ||||
|         box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); | ||||
|     } | ||||
| 
 | ||||
|     .dialog-title { | ||||
|         color: #00796b; /* teal-like green */ | ||||
|         font-weight: bold; | ||||
|         font-size: 1.25rem; | ||||
|         margin-bottom: 16px; | ||||
|     } | ||||
| 
 | ||||
|     .dialog-actions { | ||||
|         display: flex; | ||||
|         justify-content: flex-end; | ||||
|         gap: 12px; | ||||
|         margin-top: 24px; | ||||
|     } | ||||
| 
 | ||||
|     .v-btn.custom-green { | ||||
|         background-color: #43a047; | ||||
|         color: white; | ||||
|     } | ||||
| 
 | ||||
|     .v-btn.custom-green:hover { | ||||
|         background-color: #388e3c; | ||||
|     } | ||||
| 
 | ||||
|     .v-btn.custom-blue { | ||||
|         background-color: #1e88e5; | ||||
|         color: white; | ||||
|     } | ||||
| 
 | ||||
|     .v-btn.custom-blue:hover { | ||||
|         background-color: #1565c0; | ||||
|     } | ||||
| 
 | ||||
|     .v-btn.cancel-button { | ||||
|         background-color: #e0f2f1; | ||||
|         color: #00695c; | ||||
|     } | ||||
| 
 | ||||
|     .minimal-card { | ||||
|         box-shadow: none; /* remove card shadow */ | ||||
|         border: none; /* remove border */ | ||||
|         background-color: transparent; /* make background transparent */ | ||||
|         padding: 0; /* reduce padding */ | ||||
|         margin-bottom: 1rem; /* keep some spacing below */ | ||||
|     } | ||||
| 
 | ||||
|     /* Optionally, keep some padding only around buttons */ | ||||
|     .minimal-card > .v-row { | ||||
|         padding: 1rem 0; /* give vertical padding around buttons */ | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -25,7 +25,7 @@ export class AssignmentController extends BaseController { | |||
|         return this.get<AssignmentResponse>(`/${num}`); | ||||
|     } | ||||
| 
 | ||||
|     async createAssignment(data: AssignmentDTO): Promise<AssignmentResponse> { | ||||
|     async createAssignment(data: Partial<AssignmentDTO>): Promise<AssignmentResponse> { | ||||
|         return this.post<AssignmentResponse>(`/`, data); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import { BaseController } from "@/controllers/base-controller.ts"; | |||
| import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; | ||||
| import type { ClassesResponse } from "@/controllers/classes.ts"; | ||||
| import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; | ||||
| import type { AssignmentsResponse } from "./assignments"; | ||||
| 
 | ||||
| export interface TeachersResponse { | ||||
|     teachers: TeacherDTO[] | string[]; | ||||
|  | @ -35,6 +36,10 @@ export class TeacherController extends BaseController { | |||
|         return this.get<ClassesResponse>(`/${username}/classes`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getAssignments(username: string, full = true): Promise<AssignmentsResponse> { | ||||
|         return this.get<AssignmentsResponse>(`/${username}/assignments`, { full }); | ||||
|     } | ||||
| 
 | ||||
|     async getStudents(username: string, full = false): Promise<StudentsResponse> { | ||||
|         return this.get<StudentsResponse>(`/${username}/students`, { full }); | ||||
|     } | ||||
|  |  | |||
|  | @ -105,7 +105,6 @@ | |||
|     "assignLearningPath": "Als Aufgabe geben", | ||||
|     "group": "Gruppe", | ||||
|     "description": "Beschreibung", | ||||
|     "no-submission": "keine vorlage", | ||||
|     "submission": "Einreichung", | ||||
|     "progress": "Fortschritte", | ||||
|     "remove": "entfernen", | ||||
|  | @ -167,6 +166,22 @@ | |||
|     "targetAgesMandatory": "Zielalter müssen angegeben werden.", | ||||
|     "hintRemoveIfUnconditionalTransition": "(entfernen, wenn dies ein bedingungsloser Übergang sein soll)", | ||||
|     "hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt", | ||||
|     "title-required": "Titel darf nicht leer sein.", | ||||
|     "class-required": "Du musst eine Klasse auswählen.", | ||||
|     "deadline-invalid": "Ungültiges Datum oder Uhrzeit.", | ||||
|     "deadline-past": "Die Frist muss in der Zukunft liegen.", | ||||
|     "lp-required": "Du musst einen Lernpfad auswählen.", | ||||
|     "lp-invalid": "Der ausgewählte Lernpfad existiert nicht.", | ||||
|     "currently-no-groups": "Es gibt keine Gruppen für diese Aufgabe.", | ||||
|     "random-grouping": "Gruppen zufällig erstellen", | ||||
|     "drag-and-drop": "Gruppen manuell erstellen", | ||||
|     "generate-groups": "erzeugen", | ||||
|     "auto-generate-groups": "Gruppen gleicher Größe erstellen", | ||||
|     "preview": "Vorschau", | ||||
|     "current-groups": "Aktuelle Gruppen", | ||||
|     "group-size-label": "Gruppengröße", | ||||
|     "save": "Speichern", | ||||
|     "unassigned": "Nicht zugewiesen", | ||||
|     "questions": "Fragen", | ||||
|     "view-questions": "Fragen anzeigen auf ", | ||||
|     "question-input-placeholder": "Ihre Frage...", | ||||
|  |  | |||
|  | @ -104,7 +104,6 @@ | |||
|     "assignLearningPath": "assign", | ||||
|     "group": "Group", | ||||
|     "description": "Description", | ||||
|     "no-submission": "no submission", | ||||
|     "submission": "Submission", | ||||
|     "progress": "Progress", | ||||
|     "created": "created", | ||||
|  | @ -122,6 +121,7 @@ | |||
|     "invite": "invite", | ||||
|     "assignmentIndicator": "ASSIGNMENT", | ||||
|     "searchAllLearningPathsTitle": "Search all learning paths", | ||||
|     "not-in-group-message": "You are not part of a group yet", | ||||
|     "searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths.", | ||||
|     "no-students-found": "This class has no students.", | ||||
|     "no-invitations-found": "You have no pending invitations.", | ||||
|  | @ -167,6 +167,22 @@ | |||
|     "targetAgesMandatory": "Target ages must be specified.", | ||||
|     "hintRemoveIfUnconditionalTransition": "(remove this if this should be an unconditional transition)", | ||||
|     "hintKeywordsSeparatedBySpaces": "Keywords separated by spaces", | ||||
|     "title-required": "Title cannot be empty.", | ||||
|     "class-required": "You must select a class.", | ||||
|     "deadline-invalid": "Invalid date or time.", | ||||
|     "deadline-past": "The deadline must be in the future.", | ||||
|     "lp-required": "You must select a learning path.", | ||||
|     "lp-invalid": "The selected learning path doesn't exist.", | ||||
|     "currently-no-groups": "There are no groups for this assignment.", | ||||
|     "random-grouping": "Randomly create groups", | ||||
|     "drag-and-drop": "Manually create groups", | ||||
|     "generate-groups": "generate", | ||||
|     "auto-generate-groups": "Create groups of equal size", | ||||
|     "preview": "Preview", | ||||
|     "current-groups": "Current groups", | ||||
|     "group-size-label": "Group size", | ||||
|     "save": "Save", | ||||
|     "unassigned": "Unassigned", | ||||
|     "questions": "questions", | ||||
|     "view-questions": "View questions in ", | ||||
|     "question-input-placeholder": "Your question...", | ||||
|  |  | |||
|  | @ -88,7 +88,7 @@ | |||
|     "deny": "refuser", | ||||
|     "sent": "envoyé", | ||||
|     "failed": "échoué", | ||||
|     "wrong": "quelque chose n'a pas fonctionné", | ||||
|     "wrong": "Il y a une erreur", | ||||
|     "created": "créé", | ||||
|     "callbackLoading": "Vous serez connecté...", | ||||
|     "loginUnexpectedError": "La connexion a échoué", | ||||
|  | @ -98,14 +98,13 @@ | |||
|     "groupSubmissions": "Soumissions de ce groupe", | ||||
|     "taskCompleted": "Tâche terminée.", | ||||
|     "submittedBy": "Soumis par", | ||||
|     "timestamp": "Horodatage", | ||||
|     "timestamp": "Date et heure", | ||||
|     "loadSubmission": "Charger", | ||||
|     "noSubmissionsYet": "Pas encore de soumissions.", | ||||
|     "viewAsGroup": "Voir la progression du groupe...", | ||||
|     "assignLearningPath": "donner comme tâche", | ||||
|     "group": "Groupe", | ||||
|     "description": "Description", | ||||
|     "no-submission": "aucune soumission", | ||||
|     "submission": "Soumission", | ||||
|     "progress": "Progrès", | ||||
|     "remove": "supprimer", | ||||
|  | @ -168,6 +167,22 @@ | |||
|     "targetAgesMandatory": "Les âges cibles doivent être spécifiés.", | ||||
|     "hintRemoveIfUnconditionalTransition": "(supprimer ceci s’il s’agit d’une transition inconditionnelle)", | ||||
|     "hintKeywordsSeparatedBySpaces": "Mots-clés séparés par des espaces", | ||||
|     "title-required": "Le titre ne peut pas être vide.", | ||||
|     "class-required": "Vous devez sélectionner une classe.", | ||||
|     "deadline-invalid": "Date ou heure invalide.", | ||||
|     "deadline-past": "La date limite doit être dans le futur.", | ||||
|     "lp-required": "Vous devez sélectionner un parcours d'apprentissage.", | ||||
|     "lp-invalid": "Le parcours d'apprentissage sélectionné n'existe pas.", | ||||
|     "currently-no-groups": "Il n’y a pas de groupes pour cette tâche.", | ||||
|     "random-grouping": "Créer des groupes aléatoirement", | ||||
|     "drag-and-drop": "Créer des groupes manuellement", | ||||
|     "generate-groups": "générer", | ||||
|     "auto-generate-groups": "Créer des groupes de taille égale", | ||||
|     "preview": "Aperçu", | ||||
|     "current-groups": "Groupes actuels", | ||||
|     "group-size-label": "Taille des groupes", | ||||
|     "save": "Enregistrer", | ||||
|     "unassigned": "Non assigné", | ||||
|     "questions": "Questions", | ||||
|     "view-questions": "Voir les questions dans ", | ||||
|     "question-input-placeholder": "Votre question...", | ||||
|  |  | |||
|  | @ -105,7 +105,6 @@ | |||
|     "assignLearningPath": "Als opdracht geven", | ||||
|     "group": "Groep", | ||||
|     "description": "Beschrijving", | ||||
|     "no-submission": "geen indiening", | ||||
|     "submission": "Indiening", | ||||
|     "progress": "Vooruitgang", | ||||
|     "remove": "verwijder", | ||||
|  | @ -167,6 +166,22 @@ | |||
|     "targetAgesMandatory": "Doelleeftijden moeten worden opgegeven.", | ||||
|     "hintRemoveIfUnconditionalTransition": "(verwijder dit voor onvoorwaardelijke overgangen)", | ||||
|     "hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties", | ||||
|     "title-required": "Titel mag niet leeg zijn.", | ||||
|     "class-required": "Je moet een klas selecteren.", | ||||
|     "deadline-invalid": "Ongeldige datum of tijd.", | ||||
|     "deadline-past": "De deadline moet in de toekomst liggen.", | ||||
|     "lp-required": "Je moet een leerpad selecteren.", | ||||
|     "lp-invalid": "Het geselecteerde leerpad bestaat niet.", | ||||
|     "currently-no-groups": "Er zijn geen groepen voor deze opdracht.", | ||||
|     "random-grouping": "Groepeer willekeurig", | ||||
|     "drag-and-drop": "Stel groepen handmatig samen", | ||||
|     "generate-groups": "genereren", | ||||
|     "auto-generate-groups": "Maak groepen van gelijke grootte", | ||||
|     "preview": "Voorbeeld", | ||||
|     "current-groups": "Huidige groepen", | ||||
|     "group-size-label": "Grootte van groepen", | ||||
|     "save": "Opslaan", | ||||
|     "unassigned": "Niet toegewezen", | ||||
|     "questions": "vragen", | ||||
|     "view-questions": "Bekijk vragen in ", | ||||
|     "question-input-placeholder": "Uw vraag...", | ||||
|  |  | |||
|  | @ -117,7 +117,7 @@ export function useAssignmentQuery( | |||
| export function useCreateAssignmentMutation(): UseMutationReturnType< | ||||
|     AssignmentResponse, | ||||
|     Error, | ||||
|     { cid: string; data: AssignmentDTO }, | ||||
|     { cid: string; data: Partial<AssignmentDTO> }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
|  | @ -181,7 +181,7 @@ export function useAssignmentSubmissionsQuery( | |||
| 
 | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => assignmentSubmissionsQueryKey(cid!, an!, f)), | ||||
|         queryFn: async () => new AssignmentController(cid!).getSubmissions(gn!, f), | ||||
|         queryFn: async () => new AssignmentController(cid!).getSubmissions(an!, f), | ||||
|         enabled: () => checkEnabled(cid, an, gn), | ||||
|     }); | ||||
| } | ||||
|  |  | |||
|  | @ -12,6 +12,7 @@ import type { ClassesResponse } from "@/controllers/classes.ts"; | |||
| import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; | ||||
| import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; | ||||
| import { studentJoinRequestQueryKey, studentJoinRequestsQueryKey } from "@/queries/students.ts"; | ||||
| import type { AssignmentsResponse } from "@/controllers/assignments"; | ||||
| 
 | ||||
| const teacherController = new TeacherController(); | ||||
| 
 | ||||
|  | @ -28,6 +29,10 @@ function teacherClassesQueryKey(username: string, full: boolean): [string, strin | |||
|     return ["teacher-classes", username, full]; | ||||
| } | ||||
| 
 | ||||
| function teacherAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] { | ||||
|     return ["teacher-assignments", username, full]; | ||||
| } | ||||
| 
 | ||||
| function teacherStudentsQueryKey(username: string, full: boolean): [string, string, boolean] { | ||||
|     return ["teacher-students", username, full]; | ||||
| } | ||||
|  | @ -64,6 +69,17 @@ export function useTeacherClassesQuery( | |||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useTeacherAssignmentsQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = false, | ||||
| ): UseQueryReturnType<AssignmentsResponse, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: computed(() => teacherAssignmentsQueryKey(toValue(username)!, toValue(full))), | ||||
|         queryFn: async () => teacherController.getAssignments(toValue(username)!, toValue(full)), | ||||
|         enabled: () => Boolean(toValue(username)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useTeacherStudentsQuery( | ||||
|     username: MaybeRefOrGetter<string | undefined>, | ||||
|     full: MaybeRefOrGetter<boolean> = false, | ||||
|  |  | |||
|  | @ -1,76 +0,0 @@ | |||
| /** | ||||
|  * Validation rule for the assignment title. | ||||
|  * | ||||
|  * Ensures that the title is not empty. | ||||
|  */ | ||||
| export const assignmentTitleRules = [ | ||||
|     (value: string): string | boolean => { | ||||
|         if (value?.length >= 1) { | ||||
|             return true; | ||||
|         } // Title must not be empty
 | ||||
|         return "Title cannot be empty."; | ||||
|     }, | ||||
| ]; | ||||
| 
 | ||||
| /** | ||||
|  * Validation rule for the learning path selection. | ||||
|  * | ||||
|  * Ensures that a valid learning path is selected. | ||||
|  */ | ||||
| export const learningPathRules = [ | ||||
|     (value: { hruid: string; title: string }): string | boolean => { | ||||
|         if (value && value.hruid) { | ||||
|             return true; // Valid if hruid is present
 | ||||
|         } | ||||
|         return "You must select a learning path."; | ||||
|     }, | ||||
| ]; | ||||
| 
 | ||||
| /** | ||||
|  * Validation rule for the classes selection. | ||||
|  * | ||||
|  * Ensures that at least one class is selected. | ||||
|  */ | ||||
| export const classRules = [ | ||||
|     (value: string): string | boolean => { | ||||
|         if (value) { | ||||
|             return true; | ||||
|         } | ||||
|         return "You must select at least one class."; | ||||
|     }, | ||||
| ]; | ||||
| 
 | ||||
| /** | ||||
|  * Validation rule for the deadline field. | ||||
|  * | ||||
|  * Ensures that a valid deadline is selected and is in the future. | ||||
|  */ | ||||
| export const deadlineRules = [ | ||||
|     (value: string): string | boolean => { | ||||
|         if (!value) { | ||||
|             return "You must set a deadline."; | ||||
|         } | ||||
| 
 | ||||
|         const selectedDateTime = new Date(value); | ||||
|         const now = new Date(); | ||||
| 
 | ||||
|         if (isNaN(selectedDateTime.getTime())) { | ||||
|             return "Invalid date or time."; | ||||
|         } | ||||
| 
 | ||||
|         if (selectedDateTime <= now) { | ||||
|             return "The deadline must be in the future."; | ||||
|         } | ||||
| 
 | ||||
|         return true; | ||||
|     }, | ||||
| ]; | ||||
| 
 | ||||
| export const descriptionRules = [ | ||||
|     (value: string): string | boolean => { | ||||
|         if (!value || value.trim() === "") { | ||||
|             return "Description cannot be empty."; | ||||
|         } | ||||
|         return true; | ||||
|     }, | ||||
| ]; | ||||
							
								
								
									
										5
									
								
								frontend/src/utils/assignment-utils.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/utils/assignment-utils.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| 
 | ||||
| export function calculateProgress(lp: LearningPath): number { | ||||
|     return ((lp.amountOfNodes - lp.amountOfNodesLeft) / lp.amountOfNodes) * 100; | ||||
| } | ||||
|  | @ -1,19 +1,15 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { computed, onMounted, ref, watch } from "vue"; | ||||
|     import GroupSelector from "@/components/assignments/GroupSelector.vue"; | ||||
|     import { assignmentTitleRules, classRules, descriptionRules, learningPathRules } from "@/utils/assignment-rules.ts"; | ||||
|     import DeadlineSelector from "@/components/assignments/DeadlineSelector.vue"; | ||||
|     import auth from "@/services/auth/auth-service.ts"; | ||||
|     import { useTeacherClassesQuery } from "@/queries/teachers.ts"; | ||||
|     import { useRouter } from "vue-router"; | ||||
|     import { useRouter, useRoute } from "vue-router"; | ||||
|     import { useGetAllLearningPaths } from "@/queries/learning-paths.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
|     import type { ClassesResponse } from "@/controllers/classes.ts"; | ||||
|     import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; | ||||
|     import { useCreateAssignmentMutation } from "@/queries/assignments.ts"; | ||||
|     import { useRoute } from "vue-router"; | ||||
|     import { AccountType } from "@dwengo-1/common/util/account-types"; | ||||
| 
 | ||||
|     const route = useRoute(); | ||||
|  | @ -23,12 +19,9 @@ | |||
|     const username = ref<string>(""); | ||||
| 
 | ||||
|     onMounted(async () => { | ||||
|         // Redirect student | ||||
|         if (role.value === AccountType.Student) { | ||||
|             await router.push("/user"); | ||||
|         } | ||||
| 
 | ||||
|         // Get the user's username | ||||
|         const user = await auth.loadUser(); | ||||
|         username.value = user?.profile?.preferred_username ?? ""; | ||||
|     }); | ||||
|  | @ -36,32 +29,25 @@ | |||
|     const language = computed(() => locale.value); | ||||
|     const form = ref(); | ||||
| 
 | ||||
|     //Fetch all learning paths | ||||
|     const learningPathsQueryResults = useGetAllLearningPaths(language); | ||||
| 
 | ||||
|     // Fetch and store all the teacher's classes | ||||
|     const classesQueryResults = useTeacherClassesQuery(username, true); | ||||
| 
 | ||||
|     const selectedClass = ref(undefined); | ||||
| 
 | ||||
|     const assignmentTitle = ref(""); | ||||
|     const selectedLearningPath = ref(route.query.hruid || undefined); | ||||
| 
 | ||||
|     // Disable combobox when learningPath prop is passed | ||||
|     const lpIsSelected = route.query.hruid !== undefined; | ||||
|     const deadline = ref(new Date()); | ||||
|     const description = ref(""); | ||||
|     const groups = ref<string[][]>([]); | ||||
|     const selectedLearningPath = ref<LearningPath | undefined>(undefined); | ||||
|     const lpIsSelected = ref(false); | ||||
| 
 | ||||
|     // New group is added to the list | ||||
|     function addGroupToList(students: string[]): void { | ||||
|         if (students.length) { | ||||
|             groups.value = [...groups.value, students]; | ||||
|     watch(learningPathsQueryResults.data, (data) => { | ||||
|         const hruidFromRoute = route.query.hruid?.toString(); | ||||
|         if (!hruidFromRoute || !data) return; | ||||
| 
 | ||||
|         // Verify if the hruid given in the url is valid before accepting it | ||||
|         const matchedLP = data.find((lp) => lp.hruid === hruidFromRoute); | ||||
|         if (matchedLP) { | ||||
|             selectedLearningPath.value = matchedLP; | ||||
|             lpIsSelected.value = true; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     watch(selectedClass, () => { | ||||
|         groups.value = []; | ||||
|     }); | ||||
| 
 | ||||
|     const { mutate, data, isSuccess } = useCreateAssignmentMutation(); | ||||
|  | @ -76,134 +62,144 @@ | |||
|         const { valid } = await form.value.validate(); | ||||
|         if (!valid) return; | ||||
| 
 | ||||
|         let lp = selectedLearningPath.value; | ||||
|         if (!lpIsSelected) { | ||||
|             lp = selectedLearningPath.value?.hruid; | ||||
|         const lp = lpIsSelected.value ? route.query.hruid?.toString() : selectedLearningPath.value?.hruid; | ||||
|         if (!lp) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const assignmentDTO: AssignmentDTO = { | ||||
|             id: 0, | ||||
|             within: selectedClass.value?.id || "", | ||||
|             title: assignmentTitle.value, | ||||
|             description: description.value, | ||||
|             learningPath: lp || "", | ||||
|             deadline: deadline.value, | ||||
|             description: "", | ||||
|             learningPath: lp, | ||||
|             language: language.value, | ||||
|             groups: groups.value, | ||||
|             deadline: null, | ||||
|             groups: [], | ||||
|         }; | ||||
| 
 | ||||
|         mutate({ cid: assignmentDTO.within, data: assignmentDTO }); | ||||
|     } | ||||
| 
 | ||||
|     const learningPathRules = [ | ||||
|         (value: LearningPath): string | boolean => { | ||||
|             if (lpIsSelected.value) return true; | ||||
| 
 | ||||
|             if (!value) return t("lp-required"); | ||||
| 
 | ||||
|             const allLPs = learningPathsQueryResults.data.value ?? []; | ||||
|             const valid = allLPs.some((lp) => lp.hruid === value?.hruid); | ||||
|             return valid || t("lp-invalid"); | ||||
|         }, | ||||
|     ]; | ||||
| 
 | ||||
|     const assignmentTitleRules = [ | ||||
|         (value: string): string | boolean => { | ||||
|             if (value?.length >= 1) { | ||||
|                 return true; | ||||
|             } // Title must not be empty | ||||
|             return t("title-required"); | ||||
|         }, | ||||
|     ]; | ||||
| 
 | ||||
|     const classRules = [ | ||||
|         (value: string): string | boolean => { | ||||
|             if (value) { | ||||
|                 return true; | ||||
|             } | ||||
|             return t("class-required"); | ||||
|         }, | ||||
|     ]; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <div class="main-container"> | ||||
|         <h1 class="h1">{{ t("new-assignment") }}</h1> | ||||
|         <v-card class="form-card"> | ||||
| 
 | ||||
|         <v-card class="form-card elevation-2 pa-6"> | ||||
|             <v-form | ||||
|                 ref="form" | ||||
|                 class="form-container" | ||||
|                 validate-on="submit lazy" | ||||
|                 @submit.prevent="submitFormHandler" | ||||
|             > | ||||
|                 <v-container class="step-container"> | ||||
|                     <v-card-text> | ||||
|                         <v-text-field | ||||
|                             v-model="assignmentTitle" | ||||
|                             :label="t('title')" | ||||
|                             :rules="assignmentTitleRules" | ||||
|                             density="compact" | ||||
|                             variant="outlined" | ||||
|                             clearable | ||||
|                             required | ||||
|                         ></v-text-field> | ||||
|                     </v-card-text> | ||||
|                 <v-container class="step-container pa-0"> | ||||
|                     <!-- Title field --> | ||||
|                     <v-text-field | ||||
|                         v-model="assignmentTitle" | ||||
|                         :label="t('title')" | ||||
|                         :rules="assignmentTitleRules" | ||||
|                         density="comfortable" | ||||
|                         variant="solo-filled" | ||||
|                         prepend-inner-icon="mdi-format-title" | ||||
|                         clearable | ||||
|                         required | ||||
|                     /> | ||||
| 
 | ||||
|                     <!-- Learning Path keuze --> | ||||
|                     <using-query-result | ||||
|                         :query-result="learningPathsQueryResults" | ||||
|                         v-slot="{ data }: { data: LearningPath[] }" | ||||
|                     > | ||||
|                         <v-card-text> | ||||
|                             <v-combobox | ||||
|                                 v-model="selectedLearningPath" | ||||
|                                 :items="data" | ||||
|                                 :label="t('choose-lp')" | ||||
|                                 :rules="learningPathRules" | ||||
|                                 variant="outlined" | ||||
|                                 clearable | ||||
|                                 hide-details | ||||
|                                 density="compact" | ||||
|                                 append-inner-icon="mdi-magnify" | ||||
|                                 item-title="title" | ||||
|                                 item-value="hruid" | ||||
|                                 required | ||||
|                                 :disabled="lpIsSelected" | ||||
|                                 :filter=" | ||||
|                                     (item, query: string) => item.title.toLowerCase().includes(query.toLowerCase()) | ||||
|                                 " | ||||
|                             ></v-combobox> | ||||
|                         </v-card-text> | ||||
|                         <v-combobox | ||||
|                             v-model="selectedLearningPath" | ||||
|                             :items="data" | ||||
|                             :label="t('choose-lp')" | ||||
|                             :rules="lpIsSelected ? [] : learningPathRules" | ||||
|                             variant="solo-filled" | ||||
|                             clearable | ||||
|                             item-title="title" | ||||
|                             :disabled="lpIsSelected" | ||||
|                             return-object | ||||
|                         /> | ||||
|                     </using-query-result> | ||||
| 
 | ||||
|                     <!-- Klas keuze --> | ||||
|                     <using-query-result | ||||
|                         :query-result="classesQueryResults" | ||||
|                         v-slot="{ data }: { data: ClassesResponse }" | ||||
|                     > | ||||
|                         <v-card-text> | ||||
|                             <v-combobox | ||||
|                                 v-model="selectedClass" | ||||
|                                 :items="data?.classes ?? []" | ||||
|                                 :label="t('pick-class')" | ||||
|                                 :rules="classRules" | ||||
|                                 variant="outlined" | ||||
|                                 clearable | ||||
|                                 hide-details | ||||
|                                 density="compact" | ||||
|                                 append-inner-icon="mdi-magnify" | ||||
|                                 item-title="displayName" | ||||
|                                 item-value="id" | ||||
|                                 required | ||||
|                             ></v-combobox> | ||||
|                         </v-card-text> | ||||
|                         <v-combobox | ||||
|                             v-model="selectedClass" | ||||
|                             :items="data?.classes ?? []" | ||||
|                             :label="t('pick-class')" | ||||
|                             :rules="classRules" | ||||
|                             variant="solo-filled" | ||||
|                             clearable | ||||
|                             density="comfortable" | ||||
|                             chips | ||||
|                             hide-no-data | ||||
|                             hide-selected | ||||
|                             item-title="displayName" | ||||
|                             item-value="id" | ||||
|                             prepend-inner-icon="mdi-account-multiple" | ||||
|                         /> | ||||
|                     </using-query-result> | ||||
| 
 | ||||
|                     <GroupSelector | ||||
|                         :classId="selectedClass?.id" | ||||
|                         :groups="groups" | ||||
|                         @groupCreated="addGroupToList" | ||||
|                     /> | ||||
|                     <!-- Submit & Cancel --> | ||||
|                     <v-divider class="my-6" /> | ||||
| 
 | ||||
|                     <!-- Counter for created groups --> | ||||
|                     <v-card-text v-if="groups.length"> | ||||
|                         <strong>Created Groups: {{ groups.length }}</strong> | ||||
|                     </v-card-text> | ||||
|                     <DeadlineSelector v-model:deadline="deadline" /> | ||||
|                     <v-card-text> | ||||
|                         <v-textarea | ||||
|                             v-model="description" | ||||
|                             :label="t('description')" | ||||
|                             variant="outlined" | ||||
|                             density="compact" | ||||
|                             auto-grow | ||||
|                             rows="3" | ||||
|                             :rules="descriptionRules" | ||||
|                         ></v-textarea> | ||||
|                     </v-card-text> | ||||
|                     <v-card-text> | ||||
|                     <div class="d-flex justify-end ga-2"> | ||||
|                         <v-btn | ||||
|                             class="mt-2" | ||||
|                             color="secondary" | ||||
|                             color="primary" | ||||
|                             type="submit" | ||||
|                             block | ||||
|                             >{{ t("submit") }} | ||||
|                             size="small" | ||||
|                             prepend-icon="mdi-check-circle" | ||||
|                             elevation="1" | ||||
|                         > | ||||
|                             {{ t("submit") }} | ||||
|                         </v-btn> | ||||
| 
 | ||||
|                         <v-btn | ||||
|                             to="/user/assignment" | ||||
|                             color="grey" | ||||
|                             block | ||||
|                             >{{ t("cancel") }} | ||||
|                             size="small" | ||||
|                             variant="text" | ||||
|                             prepend-icon="mdi-close-circle" | ||||
|                         > | ||||
|                             {{ t("cancel") }} | ||||
|                         </v-btn> | ||||
|                     </v-card-text> | ||||
|                     </div> | ||||
|                 </v-container> | ||||
|             </v-form> | ||||
|         </v-card> | ||||
|  | @ -215,46 +211,55 @@ | |||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         justify-content: start; | ||||
|         padding-top: 32px; | ||||
|         text-align: center; | ||||
|     } | ||||
| 
 | ||||
|     .form-card { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         width: 55%; | ||||
|         /*padding: 1%;*/ | ||||
|         width: 100%; | ||||
|         max-width: 720px; | ||||
|         border-radius: 16px; | ||||
|     } | ||||
| 
 | ||||
|     .form-container { | ||||
|         width: 100%; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 24px; | ||||
|         width: 100%; | ||||
|     } | ||||
| 
 | ||||
|     .step-container { | ||||
|         display: flex; | ||||
|         justify-content: center; | ||||
|         flex-direction: column; | ||||
|         min-height: 200px; | ||||
|         gap: 24px; | ||||
|     } | ||||
| 
 | ||||
|     @media (max-width: 1000px) { | ||||
|         .form-card { | ||||
|             width: 70%; | ||||
|             width: 85%; | ||||
|             padding: 1%; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|         .step-container { | ||||
|             min-height: 300px; | ||||
|     @media (max-width: 600px) { | ||||
|         h1 { | ||||
|             font-size: 32px; | ||||
|             text-align: center; | ||||
|             margin-left: 0; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @media (max-width: 650px) { | ||||
|         .form-card { | ||||
|             width: 95%; | ||||
|     @media (max-width: 400px) { | ||||
|         h1 { | ||||
|             font-size: 24px; | ||||
|             text-align: center; | ||||
|             margin-left: 0; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     .v-card { | ||||
|         border: 2px solid #0e6942; | ||||
|         border-radius: 12px; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -1,13 +1,9 @@ | |||
| <script setup lang="ts"> | ||||
|     import auth from "@/services/auth/auth-service.ts"; | ||||
|     import { computed, type Ref, ref, watchEffect } from "vue"; | ||||
|     import { computed, ref } from "vue"; | ||||
|     import StudentAssignment from "@/views/assignments/StudentAssignment.vue"; | ||||
|     import TeacherAssignment from "@/views/assignments/TeacherAssignment.vue"; | ||||
|     import { useRoute } from "vue-router"; | ||||
|     import type { Language } from "@/data-objects/language.ts"; | ||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
|     import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; | ||||
|     import { AccountType } from "@dwengo-1/common/util/account-types"; | ||||
| 
 | ||||
|     const role = auth.authState.activeRole; | ||||
|  | @ -16,58 +12,18 @@ | |||
|     const route = useRoute(); | ||||
|     const classId = ref<string>(route.params.classId as string); | ||||
|     const assignmentId = ref(Number(route.params.id)); | ||||
| 
 | ||||
|     function useGroupsWithProgress( | ||||
|         groups: Ref<GroupDTO[]>, | ||||
|         hruid: Ref<string>, | ||||
|         language: Ref<string>, | ||||
|     ): { groupProgressMap: Map<number, number> } { | ||||
|         const groupProgressMap: Map<number, number> = new Map<number, number>(); | ||||
| 
 | ||||
|         watchEffect(() => { | ||||
|             // Clear existing entries to avoid stale data | ||||
|             groupProgressMap.clear(); | ||||
| 
 | ||||
|             const lang = ref(language.value as Language); | ||||
| 
 | ||||
|             groups.value.forEach((group) => { | ||||
|                 const groupKey = group.groupNumber; | ||||
|                 const forGroup = ref({ | ||||
|                     forGroup: groupKey, | ||||
|                     assignmentNo: assignmentId, | ||||
|                     classId: classId, | ||||
|                 }); | ||||
| 
 | ||||
|                 const query = useGetLearningPathQuery(hruid.value, lang, forGroup); | ||||
| 
 | ||||
|                 const data = query.data.value; | ||||
| 
 | ||||
|                 groupProgressMap.set(groupKey, data ? calculateProgress(data) : 0); | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         return { | ||||
|             groupProgressMap, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     function calculateProgress(lp: LearningPath): number { | ||||
|         return ((lp.amountOfNodes - lp.amountOfNodesLeft) / lp.amountOfNodes) * 100; | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <TeacherAssignment | ||||
|         :class-id="classId" | ||||
|         :assignment-id="assignmentId" | ||||
|         :use-groups-with-progress="useGroupsWithProgress" | ||||
|         v-if="isTeacher" | ||||
|     > | ||||
|     </TeacherAssignment> | ||||
|     <StudentAssignment | ||||
|         :class-id="classId" | ||||
|         :assignment-id="assignmentId" | ||||
|         :use-groups-with-progress="useGroupsWithProgress" | ||||
|         v-else | ||||
|     > | ||||
|     </StudentAssignment> | ||||
|  |  | |||
|  | @ -1,28 +1,26 @@ | |||
| <script setup lang="ts"> | ||||
|     import { ref, computed, type Ref } from "vue"; | ||||
|     import { ref, computed, watchEffect } from "vue"; | ||||
|     import auth from "@/services/auth/auth-service.ts"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useAssignmentQuery } from "@/queries/assignments.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import type { AssignmentResponse } from "@/controllers/assignments.ts"; | ||||
|     import { asyncComputed } from "@vueuse/core"; | ||||
|     import { useStudentsByUsernamesQuery } from "@/queries/students.ts"; | ||||
|     import { useGroupsQuery } from "@/queries/groups.ts"; | ||||
|     import { | ||||
|         useStudentAssignmentsQuery, | ||||
|         useStudentGroupsQuery, | ||||
|         useStudentsByUsernamesQuery, | ||||
|     } from "@/queries/students.ts"; | ||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import type { Language } from "@/data-objects/language.ts"; | ||||
|     import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; | ||||
|     import { calculateProgress } from "@/utils/assignment-utils.ts"; | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         classId: string; | ||||
|         assignmentId: number; | ||||
|         useGroupsWithProgress: ( | ||||
|             groups: Ref<GroupDTO[]>, | ||||
|             hruid: Ref<string>, | ||||
|             language: Ref<Language>, | ||||
|         ) => { groupProgressMap: Map<number, number> }; | ||||
|     }>(); | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
|     const lang = ref(); | ||||
|     const learningPath = ref(); | ||||
|     // Get the user's username/id | ||||
|     const username = asyncComputed(async () => { | ||||
|  | @ -30,45 +28,70 @@ | |||
|         return user?.profile?.preferred_username ?? undefined; | ||||
|     }); | ||||
| 
 | ||||
|     const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId); | ||||
|     learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath; | ||||
|     const assignmentsQueryResult = useStudentAssignmentsQuery(username, true); | ||||
| 
 | ||||
|     const submitted = ref(false); //TODO: update by fetching submissions and check if group submitted | ||||
|     const assignment = computed(() => { | ||||
|         const assignments = assignmentsQueryResult.data.value?.assignments; | ||||
|         if (!assignments) return undefined; | ||||
| 
 | ||||
|         return assignments.find((a) => a.id === props.assignmentId && a.within === props.classId); | ||||
|     }); | ||||
| 
 | ||||
|     learningPath.value = assignment.value?.learningPath; | ||||
| 
 | ||||
|     const groupsQueryResult = useStudentGroupsQuery(username, true); | ||||
|     const group = computed(() => { | ||||
|         const groups = groupsQueryResult.data.value?.groups as GroupDTO[]; | ||||
| 
 | ||||
|         if (!groups) return undefined; | ||||
| 
 | ||||
|         // Sort by original groupNumber | ||||
|         const sortedGroups = [...groups].sort((a, b) => a.groupNumber - b.groupNumber); | ||||
| 
 | ||||
|         return sortedGroups | ||||
|             .map((group, index) => ({ | ||||
|                 ...group, | ||||
|                 groupNo: index + 1, // Renumbered index | ||||
|             })) | ||||
|             .find((group) => group.members?.some((m) => m.username === username.value)); | ||||
|     }); | ||||
| 
 | ||||
|     watchEffect(() => { | ||||
|         learningPath.value = assignment.value?.learningPath; | ||||
|         lang.value = assignment.value?.language as Language; | ||||
|     }); | ||||
| 
 | ||||
|     const learningPathParams = computed(() => { | ||||
|         if (!group.value || !learningPath.value || !lang.value) return undefined; | ||||
| 
 | ||||
|         return { | ||||
|             forGroup: group.value.groupNumber, | ||||
|             assignmentNo: props.assignmentId, | ||||
|             classId: props.classId, | ||||
|         }; | ||||
|     }); | ||||
| 
 | ||||
|     const lpQueryResult = useGetLearningPathQuery( | ||||
|         computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""), | ||||
|         computed(() => assignmentQueryResult.data.value?.assignment.language as Language), | ||||
|         () => learningPath.value, | ||||
|         () => lang.value, | ||||
|         () => learningPathParams.value, | ||||
|     ); | ||||
| 
 | ||||
|     const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); | ||||
|     const group = computed(() => | ||||
|         groupsQueryResult?.data.value?.groups.find((group) => | ||||
|             group.members?.some((m) => m.username === username.value), | ||||
|         ), | ||||
|     ); | ||||
|     const progressColor = computed(() => { | ||||
|         const progress = calculateProgress(lpQueryResult.data.value as LearningPath); | ||||
|         if (progress >= 100) return "success"; | ||||
|         if (progress >= 50) return "warning"; | ||||
|         return "error"; | ||||
|     }); | ||||
| 
 | ||||
|     const _groupArray = computed(() => (group.value ? [group.value] : [])); | ||||
|     const progressValue = ref(0); | ||||
|     /* Crashes right now cause api data has inexistent hruid TODO: uncomment later and use it in progress bar | ||||
| Const {groupProgressMap} = props.useGroupsWithProgress( | ||||
| groupArray, | ||||
| learningPath, | ||||
| language | ||||
| ); | ||||
| */ | ||||
| 
 | ||||
|     // Assuming group.value.members is a list of usernames TODO: case when it's StudentDTO's | ||||
|     const studentQueries = useStudentsByUsernamesQuery(() => group.value?.members as string[]); | ||||
|     const studentQueries = useStudentsByUsernamesQuery(() => (group.value?.members as string[]) ?? undefined); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <div class="container"> | ||||
|         <using-query-result | ||||
|             :query-result="assignmentQueryResult" | ||||
|             v-slot="{ data }: { data: AssignmentResponse }" | ||||
|         > | ||||
|         <using-query-result :query-result="assignmentsQueryResult"> | ||||
|             <v-card | ||||
|                 v-if="data" | ||||
|                 v-if="assignment" | ||||
|                 class="assignment-card" | ||||
|             > | ||||
|                 <div class="top-buttons"> | ||||
|  | @ -80,17 +103,8 @@ language | |||
|                     > | ||||
|                         <v-icon>mdi-arrow-left</v-icon> | ||||
|                     </v-btn> | ||||
| 
 | ||||
|                     <v-chip | ||||
|                         v-if="submitted" | ||||
|                         class="ma-2 top-right-btn" | ||||
|                         label | ||||
|                         color="success" | ||||
|                     > | ||||
|                         {{ t("submitted") }} | ||||
|                     </v-chip> | ||||
|                 </div> | ||||
|                 <v-card-title class="text-h4 assignmentTopTitle">{{ data.assignment.title }}</v-card-title> | ||||
|                 <v-card-title class="text-h4 assignmentTopTitle">{{ assignment.title }} </v-card-title> | ||||
| 
 | ||||
|                 <v-card-subtitle class="subtitle-section"> | ||||
|                     <using-query-result | ||||
|  | @ -99,7 +113,12 @@ language | |||
|                     > | ||||
|                         <v-btn | ||||
|                             v-if="lpData" | ||||
|                             :to="`/learningPath/${lpData.hruid}/${assignmentQueryResult.data.value?.assignment.language}/${lpData.startNode.learningobjectHruid}?forGroup=${group?.groupNumber}&assignmentNo=${assignmentId}&classId=${classId}`" | ||||
|                             :to=" | ||||
|                                 group | ||||
|                                     ? `/learningPath/${lpData.hruid}/${assignment.language}/${lpData.startNode.learningobjectHruid}?forGroup=${group.groupNumber}&assignmentNo=${assignment.id}&classId=${assignment.within}` | ||||
|                                     : undefined | ||||
|                             " | ||||
|                             :disabled="!group" | ||||
|                             variant="tonal" | ||||
|                             color="primary" | ||||
|                         > | ||||
|  | @ -109,20 +128,19 @@ language | |||
|                 </v-card-subtitle> | ||||
| 
 | ||||
|                 <v-card-text class="description"> | ||||
|                     {{ data.assignment.description }} | ||||
|                     {{ assignment.description }} | ||||
|                 </v-card-text> | ||||
|                 <v-card-text> | ||||
|                     <v-row | ||||
|                         align="center" | ||||
|                         no-gutters | ||||
|                     > | ||||
|                         <v-col cols="auto"> | ||||
|                             <span class="progress-label">{{ t("progress") + ": " }}</span> | ||||
|                         </v-col> | ||||
|                         <v-col> | ||||
|                     <v-card-text> | ||||
|                         <h3 class="mb-2">{{ t("progress") }}</h3> | ||||
|                         <using-query-result | ||||
|                             :query-result="lpQueryResult" | ||||
|                             v-slot="{ data: learningPData }" | ||||
|                         > | ||||
|                             <v-progress-linear | ||||
|                                 :model-value="progressValue" | ||||
|                                 color="primary" | ||||
|                                 v-if="group" | ||||
|                                 :model-value="calculateProgress(learningPData)" | ||||
|                                 :color="progressColor" | ||||
|                                 height="20" | ||||
|                                 class="progress-bar" | ||||
|                             > | ||||
|  | @ -130,16 +148,20 @@ language | |||
|                                     <strong>{{ Math.ceil(value) }}%</strong> | ||||
|                                 </template> | ||||
|                             </v-progress-linear> | ||||
|                         </v-col> | ||||
|                     </v-row> | ||||
|                         </using-query-result> | ||||
|                     </v-card-text> | ||||
|                 </v-card-text> | ||||
| 
 | ||||
|                 <v-card-text class="group-section"> | ||||
|                     <h3>{{ t("group") }}</h3> | ||||
|                     <div v-if="studentQueries"> | ||||
|                 <v-card-text | ||||
|                     class="group-section" | ||||
|                     v-if="group && studentQueries" | ||||
|                 > | ||||
|                     <h3>{{ `${t("group")} ${group.groupNo}` }}</h3> | ||||
| 
 | ||||
|                     <div> | ||||
|                         <ul> | ||||
|                             <li | ||||
|                                 v-for="student in group?.members" | ||||
|                                 v-for="student in group.members" | ||||
|                                 :key="student.username" | ||||
|                             > | ||||
|                                 {{ student.firstName + " " + student.lastName }} | ||||
|  | @ -147,6 +169,21 @@ language | |||
|                         </ul> | ||||
|                     </div> | ||||
|                 </v-card-text> | ||||
|                 <v-card-text | ||||
|                     class="group-section" | ||||
|                     v-else | ||||
|                 > | ||||
|                     <h3>{{ t("group") }}</h3> | ||||
|                     <div> | ||||
|                         <v-alert class="empty-message"> | ||||
|                             <v-icon | ||||
|                                 icon="mdi-information-outline" | ||||
|                                 size="small" | ||||
|                             /> | ||||
|                             {{ t("currently-no-groups") }} | ||||
|                         </v-alert> | ||||
|                     </div> | ||||
|                 </v-card-text> | ||||
|             </v-card> | ||||
|         </using-query-result> | ||||
|     </div> | ||||
|  | @ -155,11 +192,6 @@ language | |||
| <style scoped> | ||||
|     @import "@/assets/assignment.css"; | ||||
| 
 | ||||
|     .progress-label { | ||||
|         font-weight: bold; | ||||
|         margin-right: 5px; | ||||
|     } | ||||
| 
 | ||||
|     .progress-bar { | ||||
|         width: 40%; | ||||
|     } | ||||
|  |  | |||
|  | @ -1,224 +1,485 @@ | |||
| <script setup lang="ts"> | ||||
|     import { computed, type Ref, ref } from "vue"; | ||||
|     import { computed, ref, watch, watchEffect } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useAssignmentQuery, useDeleteAssignmentMutation } from "@/queries/assignments.ts"; | ||||
|     import { | ||||
|         useAssignmentQuery, | ||||
|         useDeleteAssignmentMutation, | ||||
|         useUpdateAssignmentMutation, | ||||
|     } from "@/queries/assignments.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { useGroupsQuery } from "@/queries/groups.ts"; | ||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import type { Language } from "@/data-objects/language.ts"; | ||||
|     import type { AssignmentResponse } from "@/controllers/assignments.ts"; | ||||
|     import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; | ||||
|     import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group"; | ||||
|     import GroupSubmissionStatus from "@/components/GroupSubmissionStatus.vue"; | ||||
|     import GroupProgressRow from "@/components/GroupProgressRow.vue"; | ||||
|     import type { AssignmentDTO } from "@dwengo-1/common/dist/interfaces/assignment.ts"; | ||||
|     import GroupSelector from "@/components/assignments/GroupSelector.vue"; | ||||
|     import DeadlineSelector from "@/components/assignments/DeadlineSelector.vue"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         classId: string; | ||||
|         assignmentId: number; | ||||
|         useGroupsWithProgress: ( | ||||
|             groups: Ref<GroupDTO[]>, | ||||
|             hruid: Ref<string>, | ||||
|             language: Ref<Language>, | ||||
|         ) => { groupProgressMap: Map<number, number> }; | ||||
|     }>(); | ||||
| 
 | ||||
|     const isEditing = ref(false); | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
|     const groups = ref(); | ||||
|     const lang = ref(); | ||||
|     const groups = ref<GroupDTO[] | GroupDTOId[]>([]); | ||||
|     const learningPath = ref(); | ||||
|     const form = ref(); | ||||
| 
 | ||||
|     const editingLearningPath = ref(learningPath); | ||||
|     const description = ref(""); | ||||
|     const deadline = ref<Date | null>(null); | ||||
|     const editGroups = ref(false); | ||||
| 
 | ||||
|     const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId); | ||||
|     learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath; | ||||
|     // Get learning path object | ||||
|     const lpQueryResult = useGetLearningPathQuery( | ||||
|         computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""), | ||||
|         computed(() => assignmentQueryResult.data.value?.assignment.language as Language), | ||||
|         computed(() => assignmentQueryResult.data.value?.assignment?.language as Language), | ||||
|     ); | ||||
| 
 | ||||
|     // Get all the groups withing the assignment | ||||
|     const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); | ||||
|     groups.value = groupsQueryResult.data.value?.groups; | ||||
|     groups.value = groupsQueryResult.data.value?.groups ?? []; | ||||
| 
 | ||||
|     /* Crashes right now cause api data has inexistent hruid TODO: uncomment later and use it in progress bar | ||||
| Const {groupProgressMap} = props.useGroupsWithProgress( | ||||
| groups, | ||||
| learningPath, | ||||
| language | ||||
| ); | ||||
| */ | ||||
|     watchEffect(() => { | ||||
|         const assignment = assignmentQueryResult.data.value?.assignment; | ||||
|         if (assignment) { | ||||
|             learningPath.value = assignment.learningPath; | ||||
|             lang.value = assignment.language as Language; | ||||
|             deadline.value = assignment.deadline ? new Date(assignment.deadline) : null; | ||||
| 
 | ||||
|             if (lpQueryResult.data.value) { | ||||
|                 editingLearningPath.value = lpQueryResult.data.value; | ||||
|             } | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     const hasSubmissions = ref<boolean>(false); | ||||
| 
 | ||||
|     const allGroups = computed(() => { | ||||
|         const groups = groupsQueryResult.data.value?.groups; | ||||
| 
 | ||||
|         if (!groups) return []; | ||||
| 
 | ||||
|         return groups.map((group) => ({ | ||||
|             name: `${t("group")} ${group.groupNumber}`, | ||||
|             progress: 0, //GroupProgressMap[group.groupNumber], | ||||
|         // Sort by original groupNumber | ||||
|         const sortedGroups = [...groups].sort((a, b) => a.groupNumber - b.groupNumber); | ||||
| 
 | ||||
|         // Assign new sequential numbers starting from 1 | ||||
|         return sortedGroups.map((group, index) => ({ | ||||
|             groupNo: index + 1, // New group number that will be used | ||||
|             name: `${t("group")} ${index + 1}`, | ||||
|             members: group.members, | ||||
|             submitted: false, //TODO: fetch from submission | ||||
|             originalGroupNo: group.groupNumber, | ||||
|         })); | ||||
|     }); | ||||
| 
 | ||||
|     const dialog = ref(false); | ||||
|     const selectedGroup = ref({}); | ||||
| 
 | ||||
|     function openGroupDetails(group): void { | ||||
|     function openGroupDetails(group: object): void { | ||||
|         selectedGroup.value = group; | ||||
|         dialog.value = true; | ||||
|     } | ||||
| 
 | ||||
|     const headers = computed(() => [ | ||||
|         { title: t("group"), align: "start", key: "name" }, | ||||
|         { title: t("progress"), align: "center", key: "progress" }, | ||||
|         { title: t("submission"), align: "center", key: "submission" }, | ||||
|     ]); | ||||
|     const snackbar = ref({ | ||||
|         visible: false, | ||||
|         message: "", | ||||
|         color: "success", | ||||
|     }); | ||||
| 
 | ||||
|     const { mutate } = useDeleteAssignmentMutation(); | ||||
|     function showSnackbar(message: string, color: string): void { | ||||
|         snackbar.value.message = message; | ||||
|         snackbar.value.color = color; | ||||
|         snackbar.value.visible = true; | ||||
|     } | ||||
| 
 | ||||
|     const deleteAssignmentMutation = useDeleteAssignmentMutation(); | ||||
|     async function deleteAssignment(num: number, clsId: string): Promise<void> { | ||||
|         mutate( | ||||
|         deleteAssignmentMutation.mutate( | ||||
|             { cid: clsId, an: num }, | ||||
|             { | ||||
|                 onSuccess: () => { | ||||
|                     window.location.href = "/user/assignment"; | ||||
|                 }, | ||||
|                 onError: (e) => { | ||||
|                     showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error"); | ||||
|                 }, | ||||
|             }, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     function goToLearningPathLink(): string | undefined { | ||||
|         const assignment = assignmentQueryResult.data.value?.assignment; | ||||
|         const lp = lpQueryResult.data.value; | ||||
| 
 | ||||
|         if (!assignment || !lp) return undefined; | ||||
| 
 | ||||
|         return `/learningPath/${lp.hruid}/${assignment.language}/${lp.startNode.learningobjectHruid}?assignmentNo=${props.assignmentId}&classId=${props.classId}`; | ||||
|     } | ||||
| 
 | ||||
|     function goToGroupSubmissionLink(groupNo: number): string | undefined { | ||||
|         const lp = lpQueryResult.data.value; | ||||
|         if (!lp) return undefined; | ||||
| 
 | ||||
|         return `/learningPath/${lp.hruid}/${lp.language}/${lp.startNode.learningobjectHruid}?forGroup=${groupNo}&assignmentNo=${props.assignmentId}&classId=${props.classId}`; | ||||
|     } | ||||
| 
 | ||||
|     const { mutate, data, isSuccess } = useUpdateAssignmentMutation(); | ||||
| 
 | ||||
|     watch([isSuccess, data], async ([success, newData]) => { | ||||
|         if (success && newData?.assignment) { | ||||
|             await assignmentQueryResult.refetch(); | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     async function saveChanges(): Promise<void> { | ||||
|         const { valid } = await form.value.validate(); | ||||
|         if (!valid) return; | ||||
| 
 | ||||
|         isEditing.value = false; | ||||
| 
 | ||||
|         const assignmentDTO: AssignmentDTO = { | ||||
|             description: description.value, | ||||
|             deadline: deadline.value ?? null, | ||||
|         }; | ||||
| 
 | ||||
|         mutate({ | ||||
|             cid: assignmentQueryResult.data.value?.assignment.within, | ||||
|             an: assignmentQueryResult.data.value?.assignment.id, | ||||
|             data: assignmentDTO, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     async function handleGroupsUpdated(updatedGroups: string[][]): Promise<void> { | ||||
|         const assignmentDTO: AssignmentDTO = { | ||||
|             groups: updatedGroups, | ||||
|         }; | ||||
|         mutate({ | ||||
|             cid: assignmentQueryResult.data.value?.assignment.within, | ||||
|             an: assignmentQueryResult.data.value?.assignment.id, | ||||
|             data: assignmentDTO, | ||||
|         }); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <div class="container"> | ||||
|         <using-query-result | ||||
|             :query-result="assignmentQueryResult" | ||||
|             v-slot="{ data }: { data: AssignmentResponse }" | ||||
|             v-slot="assignmentResponse: { data: AssignmentResponse }" | ||||
|         > | ||||
|             <v-card | ||||
|                 v-if="data" | ||||
|                 class="assignment-card" | ||||
|             <v-container | ||||
|                 fluid | ||||
|                 class="ma-4" | ||||
|             > | ||||
|                 <div class="top-buttons"> | ||||
|                     <v-btn | ||||
|                         icon | ||||
|                         variant="text" | ||||
|                         class="back-btn" | ||||
|                         to="/user/assignment" | ||||
|                 <v-row | ||||
|                     no-gutters | ||||
|                     class="custom-breakpoint" | ||||
|                 > | ||||
|                     <v-col | ||||
|                         cols="12" | ||||
|                         sm="6" | ||||
|                         md="6" | ||||
|                         class="responsive-col" | ||||
|                     > | ||||
|                         <v-icon>mdi-arrow-left</v-icon> | ||||
|                     </v-btn> | ||||
| 
 | ||||
|                     <v-btn | ||||
|                         icon | ||||
|                         variant="text" | ||||
|                         class="top-right-btn" | ||||
|                         @click="deleteAssignment(data.assignment.id, data.assignment.within)" | ||||
|                     > | ||||
|                         <v-icon>mdi-delete</v-icon> | ||||
|                     </v-btn> | ||||
|                 </div> | ||||
|                 <v-card-title class="text-h4 assignmentTopTitle">{{ data.assignment.title }}</v-card-title> | ||||
|                 <v-card-subtitle class="subtitle-section"> | ||||
|                     <using-query-result | ||||
|                         :query-result="lpQueryResult" | ||||
|                         v-slot="{ data: lpData }" | ||||
|                     > | ||||
|                         <v-btn | ||||
|                             v-if="lpData" | ||||
|                             :to="`/learningPath/${lpData.hruid}/${assignmentQueryResult.data.value?.assignment.language}/${lpData.startNode.learningobjectHruid}?assignmentNo=${assignmentId}&classId=${classId}`" | ||||
|                             variant="tonal" | ||||
|                             color="primary" | ||||
|                         <v-form | ||||
|                             ref="form" | ||||
|                             validate-on="submit lazy" | ||||
|                             @submit.prevent="saveChanges" | ||||
|                         > | ||||
|                             {{ t("learning-path") }} | ||||
|                         </v-btn> | ||||
|                     </using-query-result> | ||||
|                 </v-card-subtitle> | ||||
|                             <v-card | ||||
|                                 v-if="assignmentResponse" | ||||
|                                 class="assignment-card-teacher" | ||||
|                             > | ||||
|                                 <div class="top-buttons"> | ||||
|                                     <div class="top-buttons-wrapper"> | ||||
|                                         <v-btn | ||||
|                                             icon | ||||
|                                             variant="text" | ||||
|                                             class="back-btn" | ||||
|                                             to="/user/assignment" | ||||
|                                         > | ||||
|                                             <v-icon>mdi-arrow-left</v-icon> | ||||
|                                         </v-btn> | ||||
|                                         <div class="right-buttons"> | ||||
|                                             <v-btn | ||||
|                                                 v-if="!isEditing" | ||||
|                                                 icon | ||||
|                                                 variant="text" | ||||
|                                                 class="top_next_to_right_button" | ||||
|                                                 @click=" | ||||
|                                                     () => { | ||||
|                                                         isEditing = true; | ||||
|                                                         description = assignmentResponse.data.assignment.description; | ||||
|                                                     } | ||||
|                                                 " | ||||
|                                             > | ||||
|                                                 <v-icon>mdi-pencil</v-icon> | ||||
|                                             </v-btn> | ||||
|                                             <v-btn | ||||
|                                                 v-else | ||||
|                                                 variant="text" | ||||
|                                                 class="top-right-btn" | ||||
|                                                 @click=" | ||||
|                                                     () => { | ||||
|                                                         isEditing = false; | ||||
|                                                         editingLearningPath = learningPath; | ||||
|                                                     } | ||||
|                                                 " | ||||
|                                                 >{{ t("cancel") }} | ||||
|                                             </v-btn> | ||||
| 
 | ||||
|                 <v-card-text class="description"> | ||||
|                     {{ data.assignment.description }} | ||||
|                 </v-card-text> | ||||
|                                             <v-btn | ||||
|                                                 v-if="!isEditing" | ||||
|                                                 icon | ||||
|                                                 variant="text" | ||||
|                                                 class="top-right-btn" | ||||
|                                                 @click=" | ||||
|                                                     deleteAssignment( | ||||
|                                                         assignmentResponse.data.assignment.id, | ||||
|                                                         assignmentResponse.data.assignment.within, | ||||
|                                                     ) | ||||
|                                                 " | ||||
|                                             > | ||||
|                                                 <v-icon>mdi-delete</v-icon> | ||||
|                                             </v-btn> | ||||
|                                             <v-btn | ||||
|                                                 v-else | ||||
|                                                 icon | ||||
|                                                 variant="text" | ||||
|                                                 class="top_next_to_right_button" | ||||
|                                                 @click="saveChanges" | ||||
|                                             > | ||||
|                                                 <v-icon>mdi-content-save-edit-outline</v-icon> | ||||
|                                             </v-btn> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <v-card-title class="text-h4 assignmentTopTitle" | ||||
|                                     >{{ assignmentResponse.data.assignment.title }} | ||||
|                                 </v-card-title> | ||||
|                                 <v-card-subtitle class="subtitle-section"> | ||||
|                                     <using-query-result | ||||
|                                         :query-result="lpQueryResult" | ||||
|                                         v-slot="{ data: lpData }" | ||||
|                                     > | ||||
|                                         <v-btn | ||||
|                                             v-if="lpData" | ||||
|                                             :to="goToLearningPathLink()" | ||||
|                                             variant="tonal" | ||||
|                                             color="primary" | ||||
|                                             :disabled="isEditing" | ||||
|                                         > | ||||
|                                             {{ t("learning-path") }} | ||||
|                                         </v-btn> | ||||
|                                         <v-alert | ||||
|                                             v-else | ||||
|                                             type="info" | ||||
|                                         > | ||||
|                                             {{ t("no-learning-path-selected") }} | ||||
|                                         </v-alert> | ||||
|                                     </using-query-result> | ||||
|                                 </v-card-subtitle> | ||||
|                                 <v-card-text v-if="isEditing"> | ||||
|                                     <deadline-selector v-model:deadline="deadline" /> | ||||
|                                 </v-card-text> | ||||
|                                 <v-card-text | ||||
|                                     v-if="!isEditing" | ||||
|                                     class="description" | ||||
|                                 > | ||||
|                                     {{ assignmentResponse.data.assignment.description }} | ||||
|                                 </v-card-text> | ||||
|                                 <v-card-text v-else> | ||||
|                                     <v-textarea | ||||
|                                         v-model="description" | ||||
|                                         :label="t('description')" | ||||
|                                         variant="outlined" | ||||
|                                         density="compact" | ||||
|                                         auto-grow | ||||
|                                         rows="3" | ||||
|                                     ></v-textarea> | ||||
|                                 </v-card-text> | ||||
|                             </v-card> | ||||
|                         </v-form> | ||||
| 
 | ||||
|                 <v-card-text class="group-section"> | ||||
|                     <h3>{{ t("groups") }}</h3> | ||||
|                     <div class="table-scroll"> | ||||
|                         <v-data-table | ||||
|                             :headers="headers" | ||||
|                             :items="allGroups" | ||||
|                             item-key="id" | ||||
|                             class="elevation-1" | ||||
|                         <!-- A pop up to show group members --> | ||||
|                         <v-dialog | ||||
|                             v-model="dialog" | ||||
|                             max-width="600" | ||||
|                             persistent | ||||
|                         > | ||||
|                             <template #[`item.name`]="{ item }"> | ||||
|                                 <v-btn | ||||
|                                     @click="openGroupDetails(item)" | ||||
|                                     variant="text" | ||||
|                                     color="primary" | ||||
|                                 > | ||||
|                                     {{ item.name }} | ||||
|                                 </v-btn> | ||||
|                             </template> | ||||
|                             <v-card class="pa-4 rounded-xl elevation-6 group-members-dialog"> | ||||
|                                 <v-card-title class="text-h6 font-weight-bold"> | ||||
|                                     {{ t("members") }} | ||||
|                                 </v-card-title> | ||||
| 
 | ||||
|                             <template #[`item.progress`]="{ item }"> | ||||
|                                 <v-progress-linear | ||||
|                                     :model-value="item.progress" | ||||
|                                     color="blue-grey" | ||||
|                                     height="25" | ||||
|                                 > | ||||
|                                     <template v-slot:default="{ value }"> | ||||
|                                         <strong>{{ Math.ceil(value) }}%</strong> | ||||
|                                     </template> | ||||
|                                 </v-progress-linear> | ||||
|                             </template> | ||||
|                                 <v-divider class="my-2" /> | ||||
| 
 | ||||
|                             <template #[`item.submission`]="{ item }"> | ||||
|                                 <v-btn | ||||
|                                     :to="item.submitted ? `${props.assignmentId}/submissions/` : undefined" | ||||
|                                     :color="item.submitted ? 'green' : 'red'" | ||||
|                                     variant="text" | ||||
|                                     class="text-capitalize" | ||||
|                                 > | ||||
|                                     {{ item.submitted ? t("see-submission") : t("no-submission") }} | ||||
|                                 </v-btn> | ||||
|                             </template> | ||||
|                         </v-data-table> | ||||
|                     </div> | ||||
|                 </v-card-text> | ||||
|                                 <v-card-text> | ||||
|                                     <v-list> | ||||
|                                         <v-list-item | ||||
|                                             v-for="(member, index) in selectedGroup.members" | ||||
|                                             :key="index" | ||||
|                                             class="py-2" | ||||
|                                         > | ||||
|                                             <v-list-item-content> | ||||
|                                                 <v-list-item-title class="text-body-1"> | ||||
|                                                     {{ member.firstName }} {{ member.lastName }} | ||||
|                                                 </v-list-item-title> | ||||
|                                             </v-list-item-content> | ||||
|                                         </v-list-item> | ||||
|                                     </v-list> | ||||
|                                 </v-card-text> | ||||
| 
 | ||||
|                                 <v-divider class="my-2" /> | ||||
| 
 | ||||
|                                 <v-card-actions class="justify-end"> | ||||
|                                     <v-btn | ||||
|                                         color="primary" | ||||
|                                         variant="outlined" | ||||
|                                         @click="dialog = false" | ||||
|                                         prepend-icon="mdi-close-circle" | ||||
|                                     > | ||||
|                                         {{ t("close") }} | ||||
|                                     </v-btn> | ||||
|                                 </v-card-actions> | ||||
|                             </v-card> | ||||
|                         </v-dialog> | ||||
|                     </v-col> | ||||
| 
 | ||||
|                     <!-- The second column of the screen --> | ||||
|                     <v-col | ||||
|                         cols="12" | ||||
|                         sm="6" | ||||
|                         md="6" | ||||
|                         class="responsive-col" | ||||
|                     > | ||||
|                         <div class="table-container"> | ||||
|                             <v-table class="table"> | ||||
|                                 <thead> | ||||
|                                     <tr> | ||||
|                                         <th class="header">{{ t("group") }}</th> | ||||
|                                         <th class="header">{{ t("progress") }}</th> | ||||
|                                         <th class="header">{{ t("submission") }}</th> | ||||
|                                         <th class="header"> | ||||
|                                             <v-btn | ||||
|                                                 @click="editGroups = true" | ||||
|                                                 variant="text" | ||||
|                                                 :disabled="hasSubmissions" | ||||
|                                             > | ||||
|                                                 <v-icon>mdi-pencil</v-icon> | ||||
|                                             </v-btn> | ||||
|                                         </th> | ||||
|                                     </tr> | ||||
|                                 </thead> | ||||
|                                 <tbody v-if="allGroups.length > 0"> | ||||
|                                     <tr | ||||
|                                         v-for="g in allGroups" | ||||
|                                         :key="g.originalGroupNo" | ||||
|                                     > | ||||
|                                         <td> | ||||
|                                             <v-btn variant="text"> | ||||
|                                                 {{ g.name }} | ||||
|                                             </v-btn> | ||||
|                                         </td> | ||||
| 
 | ||||
|                                         <td> | ||||
|                                             <GroupProgressRow | ||||
|                                                 :group-number="g.originalGroupNo" | ||||
|                                                 :learning-path="learningPath.hruid" | ||||
|                                                 :language="lang" | ||||
|                                                 :assignment-id="assignmentId" | ||||
|                                                 :class-id="classId" | ||||
|                                             /> | ||||
|                                         </td> | ||||
| 
 | ||||
|                                         <td> | ||||
|                                             <GroupSubmissionStatus | ||||
|                                                 :group="g" | ||||
|                                                 :assignment-id="assignmentId" | ||||
|                                                 :class-id="classId" | ||||
|                                                 :language="lang" | ||||
|                                                 :go-to-group-submission-link="goToGroupSubmissionLink" | ||||
|                                                 @update:hasSubmission=" | ||||
|                                                     (hasSubmission) => (hasSubmissions = hasSubmission) | ||||
|                                                 " | ||||
|                                             /> | ||||
|                                         </td> | ||||
| 
 | ||||
|                                         <!-- Edit icon --> | ||||
|                                         <td> | ||||
|                                             <v-btn | ||||
|                                                 @click="openGroupDetails(g)" | ||||
|                                                 variant="text" | ||||
|                                             > | ||||
|                                                 <v-icon>mdi-eye</v-icon> | ||||
|                                             </v-btn> | ||||
|                                         </td> | ||||
|                                     </tr> | ||||
|                                 </tbody> | ||||
|                                 <template v-else> | ||||
|                                     <tbody> | ||||
|                                         <tr> | ||||
|                                             <td | ||||
|                                                 colspan="4" | ||||
|                                                 class="empty-message" | ||||
|                                             > | ||||
|                                                 <v-icon | ||||
|                                                     icon="mdi-information-outline" | ||||
|                                                     size="small" | ||||
|                                                 /> | ||||
|                                                 {{ t("currently-no-groups") }} | ||||
|                                             </td> | ||||
|                                         </tr> | ||||
|                                     </tbody> | ||||
|                                 </template> | ||||
|                             </v-table> | ||||
|                         </div> | ||||
|                     </v-col> | ||||
|                 </v-row> | ||||
|                 <v-dialog | ||||
|                     v-model="dialog" | ||||
|                     max-width="50%" | ||||
|                     v-model="editGroups" | ||||
|                     max-width="800" | ||||
|                     persistent | ||||
|                 > | ||||
|                     <v-card> | ||||
|                         <v-card-title class="headline">{{ t("members") }}</v-card-title> | ||||
|                         <v-card-text> | ||||
|                             <v-list> | ||||
|                                 <v-list-item | ||||
|                                     v-for="(member, index) in selectedGroup.members" | ||||
|                                     :key="index" | ||||
|                                 > | ||||
|                                     <v-list-item-content> | ||||
|                                         <v-list-item-title | ||||
|                                             >{{ member.firstName + " " + member.lastName }} | ||||
|                                         </v-list-item-title> | ||||
|                                     </v-list-item-content> | ||||
|                                 </v-list-item> | ||||
|                             </v-list> | ||||
|                             <GroupSelector | ||||
|                                 :groups="allGroups" | ||||
|                                 :class-id="props.classId" | ||||
|                                 :assignment-id="props.assignmentId" | ||||
|                                 @groupsUpdated="handleGroupsUpdated" | ||||
|                                 @close="editGroups = false" | ||||
|                             /> | ||||
|                         </v-card-text> | ||||
| 
 | ||||
|                         <v-divider></v-divider> | ||||
| 
 | ||||
|                         <v-card-actions> | ||||
|                             <v-spacer></v-spacer> | ||||
|                             <v-btn | ||||
|                                 color="primary" | ||||
|                                 @click="dialog = false" | ||||
|                                 >Close | ||||
|                                 text | ||||
|                                 @click="editGroups = false" | ||||
|                             > | ||||
|                                 {{ t("cancel") }} | ||||
|                             </v-btn> | ||||
|                         </v-card-actions> | ||||
|                     </v-card> | ||||
|                 </v-dialog> | ||||
|                 <!-- | ||||
|                 <v-card-actions class="justify-end"> | ||||
|                     <v-btn | ||||
|                         size="large" | ||||
|                         color="success" | ||||
|                         variant="text" | ||||
|                     > | ||||
|                         {{ t("view-submissions") }} | ||||
|                     </v-btn> | ||||
|                 </v-card-actions> | ||||
|                 --> | ||||
|             </v-card> | ||||
|             </v-container> | ||||
|             <v-snackbar | ||||
|                 v-model="snackbar.visible" | ||||
|                 :color="snackbar.color" | ||||
|                 timeout="3000" | ||||
|             > | ||||
|                 {{ snackbar.message }} | ||||
|             </v-snackbar> | ||||
|         </using-query-result> | ||||
|     </div> | ||||
| </template> | ||||
|  | @ -226,8 +487,130 @@ language | |||
| <style scoped> | ||||
|     @import "@/assets/assignment.css"; | ||||
| 
 | ||||
|     .assignment-card-teacher { | ||||
|         width: 80%; | ||||
|         padding: 2%; | ||||
|         border-radius: 12px; | ||||
|     } | ||||
| 
 | ||||
|     .table-scroll { | ||||
|         overflow-x: auto; | ||||
|         -webkit-overflow-scrolling: touch; | ||||
|     } | ||||
| 
 | ||||
|     .header { | ||||
|         font-weight: bold; | ||||
|         background-color: #0e6942; | ||||
|         color: white; | ||||
|         padding: 5px; | ||||
|     } | ||||
| 
 | ||||
|     table thead th:first-child { | ||||
|         border-top-left-radius: 10px; | ||||
|     } | ||||
| 
 | ||||
|     .table thead th:last-child { | ||||
|         border-top-right-radius: 10px; | ||||
|     } | ||||
| 
 | ||||
|     .table tbody tr:nth-child(odd) { | ||||
|         background-color: white; | ||||
|     } | ||||
| 
 | ||||
|     .table tbody tr:nth-child(even) { | ||||
|         background-color: #f6faf2; | ||||
|     } | ||||
| 
 | ||||
|     td, | ||||
|     th { | ||||
|         border-bottom: 1px solid #0e6942; | ||||
|         border-top: 1px solid #0e6942; | ||||
|     } | ||||
| 
 | ||||
|     h1 { | ||||
|         color: #0e6942; | ||||
|         text-transform: uppercase; | ||||
|         font-weight: bolder; | ||||
|         padding-top: 2%; | ||||
|         font-size: 50px; | ||||
|     } | ||||
| 
 | ||||
|     h2 { | ||||
|         color: #0e6942; | ||||
|         font-size: 30px; | ||||
|     } | ||||
| 
 | ||||
|     .link { | ||||
|         color: #0b75bb; | ||||
|         text-decoration: underline; | ||||
|     } | ||||
| 
 | ||||
|     main { | ||||
|         margin-left: 30px; | ||||
|     } | ||||
| 
 | ||||
|     .table-container { | ||||
|         width: 100%; | ||||
|         overflow-x: visible; | ||||
|     } | ||||
| 
 | ||||
|     .table { | ||||
|         width: 100%; | ||||
|         min-width: auto; | ||||
|         table-layout: auto; | ||||
|     } | ||||
| 
 | ||||
|     @media screen and (max-width: 1200px) { | ||||
|         h1 { | ||||
|             text-align: center; | ||||
|             padding-left: 0; | ||||
|         } | ||||
| 
 | ||||
|         .join { | ||||
|             text-align: center; | ||||
|             align-items: center; | ||||
|             margin-left: 0; | ||||
|         } | ||||
| 
 | ||||
|         .sheet { | ||||
|             width: 90%; | ||||
|         } | ||||
| 
 | ||||
|         main { | ||||
|             display: flex; | ||||
|             flex-direction: column; | ||||
|             align-items: center; | ||||
|             justify-content: center; | ||||
|             margin: 5px; | ||||
|         } | ||||
| 
 | ||||
|         .custom-breakpoint { | ||||
|             flex-direction: column !important; | ||||
|         } | ||||
| 
 | ||||
|         .table { | ||||
|             width: 100%; | ||||
|             display: block; | ||||
|             overflow-x: auto; | ||||
|         } | ||||
| 
 | ||||
|         .table-container { | ||||
|             overflow-x: auto; | ||||
|         } | ||||
| 
 | ||||
|         .responsive-col { | ||||
|             max-width: 100% !important; | ||||
|             flex-basis: 100% !important; | ||||
|         } | ||||
| 
 | ||||
|         .assignment-card-teacher { | ||||
|             width: 100%; | ||||
|             border-radius: 12px; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     .group-members-dialog { | ||||
|         max-height: 80vh; | ||||
|         overflow-y: auto; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -2,74 +2,78 @@ | |||
|     import { ref, computed, onMounted, watch } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useRouter } from "vue-router"; | ||||
|     import authState from "@/services/auth/auth-service.ts"; | ||||
|     import auth from "@/services/auth/auth-service.ts"; | ||||
|     import { useTeacherClassesQuery } from "@/queries/teachers.ts"; | ||||
|     import { useStudentClassesQuery } from "@/queries/students.ts"; | ||||
|     import { ClassController } from "@/controllers/classes.ts"; | ||||
|     import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; | ||||
|     import { asyncComputed } from "@vueuse/core"; | ||||
|     import { useTeacherAssignmentsQuery, useTeacherClassesQuery } from "@/queries/teachers.ts"; | ||||
|     import { useStudentAssignmentsQuery, useStudentClassesQuery } from "@/queries/students.ts"; | ||||
|     import { useDeleteAssignmentMutation } from "@/queries/assignments.ts"; | ||||
|     import { AccountType } from "@dwengo-1/common/util/account-types"; | ||||
|     import "../../assets/common.css"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
| 
 | ||||
|     const { t, locale } = useI18n(); | ||||
|     const router = useRouter(); | ||||
| 
 | ||||
|     const role = ref(auth.authState.activeRole); | ||||
|     const username = ref<string>(""); | ||||
|     const isTeacher = computed(() => role.value === "teacher"); | ||||
|     const username = ref<string | undefined>(undefined); | ||||
|     const isLoading = ref(false); | ||||
|     const isError = ref(false); | ||||
|     const errorMessage = ref<string>(""); | ||||
| 
 | ||||
|     const isTeacher = computed(() => role.value === AccountType.Teacher); | ||||
|     // 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 and store all the teacher's classes | ||||
|     let classesQueryResults = undefined; | ||||
|     const classesQueryResult = isTeacher.value | ||||
|         ? useTeacherClassesQuery(username, true) | ||||
|         : useStudentClassesQuery(username, true); | ||||
| 
 | ||||
|     if (isTeacher.value) { | ||||
|         classesQueryResults = useTeacherClassesQuery(username, true); | ||||
|     } else { | ||||
|         classesQueryResults = useStudentClassesQuery(username, true); | ||||
|     } | ||||
|     const assignmentsQueryResult = isTeacher.value | ||||
|         ? useTeacherAssignmentsQuery(username, true) | ||||
|         : useStudentAssignmentsQuery(username, true); | ||||
| 
 | ||||
|     const classController = new ClassController(); | ||||
|     const allAssignments = computed(() => { | ||||
|         const assignments = assignmentsQueryResult.data.value?.assignments; | ||||
|         if (!assignments) return []; | ||||
| 
 | ||||
|     const assignments = asyncComputed( | ||||
|         async () => { | ||||
|             const classes = classesQueryResults?.data?.value?.classes; | ||||
|             if (!classes) return []; | ||||
|         const classes = classesQueryResult.data.value?.classes; | ||||
|         if (!classes) return []; | ||||
| 
 | ||||
|             const result = await Promise.all( | ||||
|                 (classes as ClassDTO[]).map(async (cls) => { | ||||
|                     const { assignments } = await classController.getAssignments(cls.id); | ||||
|                     return assignments.map((a) => ({ | ||||
|                         id: a.id, | ||||
|                         class: cls, | ||||
|                         title: a.title, | ||||
|                         description: a.description, | ||||
|                         learningPath: a.learningPath, | ||||
|                         language: a.language, | ||||
|                         deadline: a.deadline, | ||||
|                         groups: a.groups, | ||||
|                     })); | ||||
|                 }), | ||||
|             ); | ||||
|         const result = assignments.map((a) => ({ | ||||
|             id: a.id, | ||||
|             class: classes.find((cls) => cls?.id === a.within) ?? undefined, | ||||
|             title: a.title, | ||||
|             description: a.description, | ||||
|             learningPath: a.learningPath, | ||||
|             language: a.language, | ||||
|             deadline: a.deadline, | ||||
|             groups: a.groups, | ||||
|         })); | ||||
| 
 | ||||
|             // Order the assignments by deadline | ||||
|             return result.flat().sort((a, b) => { | ||||
|                 const now = Date.now(); | ||||
|                 const aTime = new Date(a.deadline).getTime(); | ||||
|                 const bTime = new Date(b.deadline).getTime(); | ||||
|         // Order the assignments by deadline | ||||
|         return result.flat().sort((a, b) => { | ||||
|             const now = Date.now(); | ||||
|             const aTime = new Date(a.deadline).getTime(); | ||||
|             const bTime = new Date(b.deadline).getTime(); | ||||
| 
 | ||||
|                 const aIsPast = aTime < now; | ||||
|                 const bIsPast = bTime < now; | ||||
|             const aIsPast = aTime < now; | ||||
|             const bIsPast = bTime < now; | ||||
| 
 | ||||
|                 if (aIsPast && !bIsPast) return 1; | ||||
|                 if (!aIsPast && bIsPast) return -1; | ||||
|             if (aIsPast && !bIsPast) return 1; | ||||
|             if (!aIsPast && bIsPast) return -1; | ||||
| 
 | ||||
|                 return aTime - bTime; | ||||
|             }); | ||||
|         }, | ||||
|         [], | ||||
|         { evaluating: true }, | ||||
|     ); | ||||
|             return aTime - bTime; | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     async function goToCreateAssignment(): Promise<void> { | ||||
|         await router.push("/assignment/create"); | ||||
|  | @ -79,16 +83,35 @@ | |||
|         await router.push(`/assignment/${clsId}/${id}`); | ||||
|     } | ||||
| 
 | ||||
|     const { mutate, data, isSuccess } = useDeleteAssignmentMutation(); | ||||
| 
 | ||||
|     watch([isSuccess, data], async ([success, oldData]) => { | ||||
|         if (success && oldData?.assignment) { | ||||
|             window.location.reload(); | ||||
|         } | ||||
|     const snackbar = ref({ | ||||
|         visible: false, | ||||
|         message: "", | ||||
|         color: "success", | ||||
|     }); | ||||
| 
 | ||||
|     function showSnackbar(message: string, color: string): void { | ||||
|         snackbar.value.message = message; | ||||
|         snackbar.value.color = color; | ||||
|         snackbar.value.visible = true; | ||||
|     } | ||||
| 
 | ||||
|     const deleteAssignmentMutation = useDeleteAssignmentMutation(); | ||||
| 
 | ||||
|     async function goToDeleteAssignment(num: number, clsId: string): Promise<void> { | ||||
|         mutate({ cid: clsId, an: num }); | ||||
|         deleteAssignmentMutation.mutate( | ||||
|             { cid: clsId, an: num }, | ||||
|             { | ||||
|                 onSuccess: (data) => { | ||||
|                     if (data?.assignment) { | ||||
|                         window.location.reload(); | ||||
|                     } | ||||
|                     showSnackbar(t("success"), "success"); | ||||
|                 }, | ||||
|                 onError: (e) => { | ||||
|                     showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error"); | ||||
|                 }, | ||||
|             }, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     function formatDate(date?: string | Date): string { | ||||
|  | @ -124,6 +147,11 @@ | |||
|         const user = await auth.loadUser(); | ||||
|         username.value = user?.profile?.preferred_username ?? ""; | ||||
|     }); | ||||
| 
 | ||||
|     onMounted(async () => { | ||||
|         const user = await auth.loadUser(); | ||||
|         username.value = user?.profile?.preferred_username ?? ""; | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  | @ -132,68 +160,84 @@ | |||
| 
 | ||||
|         <v-btn | ||||
|             v-if="isTeacher" | ||||
|             color="primary" | ||||
|             :style="{ backgroundColor: '#0E6942' }" | ||||
|             class="mb-4 center-btn" | ||||
|             @click="goToCreateAssignment" | ||||
|         > | ||||
|             {{ t("new-assignment") }} | ||||
|         </v-btn> | ||||
| 
 | ||||
|         <v-container> | ||||
|             <v-row> | ||||
|                 <v-col | ||||
|                     v-for="assignment in assignments" | ||||
|                     :key="assignment.id" | ||||
|                     cols="12" | ||||
|                 > | ||||
|                     <v-card class="assignment-card"> | ||||
|                         <div class="top-content"> | ||||
|                             <div class="assignment-title">{{ assignment.title }}</div> | ||||
|                             <div class="assignment-class"> | ||||
|                                 {{ t("class") }}: | ||||
|                                 <span class="class-name"> | ||||
|                                     {{ assignment.class.displayName }} | ||||
|                                 </span> | ||||
|         <using-query-result :query-result="assignmentsQueryResult"> | ||||
|             <v-container> | ||||
|                 <v-row> | ||||
|                     <v-col | ||||
|                         v-for="assignment in allAssignments" | ||||
|                         :key="assignment.id" | ||||
|                         cols="12" | ||||
|                     > | ||||
|                         <v-card class="assignment-card"> | ||||
|                             <div class="top-content"> | ||||
|                                 <div class="assignment-title">{{ assignment.title }}</div> | ||||
|                                 <div class="assignment-class"> | ||||
|                                     {{ t("class") }}: | ||||
|                                     <a | ||||
|                                         :href="`/class/${assignment?.class?.id}`" | ||||
|                                         class="class-name" | ||||
|                                     > | ||||
|                                         {{ assignment?.class?.displayName }} | ||||
|                                     </a> | ||||
|                                 </div> | ||||
|                                 <div | ||||
|                                     class="assignment-deadline" | ||||
|                                     :class="getDeadlineClass(assignment.deadline)" | ||||
|                                 > | ||||
|                                     {{ t("deadline") }}: | ||||
|                                     <span>{{ formatDate(assignment.deadline) }}</span> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                             <div | ||||
|                                 class="assignment-deadline" | ||||
|                                 :class="getDeadlineClass(assignment.deadline)" | ||||
|                             > | ||||
|                                 {{ t("deadline") }}: | ||||
|                                 <span>{{ formatDate(assignment.deadline) }}</span> | ||||
| 
 | ||||
|                             <div class="spacer"></div> | ||||
| 
 | ||||
|                             <div class="button-row"> | ||||
|                                 <v-btn | ||||
|                                     color="primary" | ||||
|                                     variant="text" | ||||
|                                     @click="goToAssignmentDetails(assignment.id, assignment?.class?.id)" | ||||
|                                 > | ||||
|                                     {{ t("view-assignment") }} | ||||
|                                 </v-btn> | ||||
|                                 <v-btn | ||||
|                                     v-if="isTeacher" | ||||
|                                     color="red" | ||||
|                                     variant="text" | ||||
|                                     @click="goToDeleteAssignment(assignment.id, assignment?.class?.id)" | ||||
|                                 > | ||||
|                                     {{ t("delete") }} | ||||
|                                 </v-btn> | ||||
|                             </div> | ||||
|                         </v-card> | ||||
|                     </v-col> | ||||
|                 </v-row> | ||||
|                 <v-row v-if="allAssignments.length === 0"> | ||||
|                     <v-col cols="12"> | ||||
|                         <div class="no-assignments"> | ||||
|                             <v-icon | ||||
|                                 icon="mdi-information-outline" | ||||
|                                 size="small" | ||||
|                             /> | ||||
|                             {{ t("no-assignments") }} | ||||
|                         </div> | ||||
| 
 | ||||
|                         <div class="spacer"></div> | ||||
| 
 | ||||
|                         <div class="button-row"> | ||||
|                             <v-btn | ||||
|                                 color="primary" | ||||
|                                 variant="text" | ||||
|                                 @click="goToAssignmentDetails(assignment.id, assignment.class.id)" | ||||
|                             > | ||||
|                                 {{ t("view-assignment") }} | ||||
|                             </v-btn> | ||||
|                             <v-btn | ||||
|                                 v-if="isTeacher" | ||||
|                                 color="red" | ||||
|                                 variant="text" | ||||
|                                 @click="goToDeleteAssignment(assignment.id, assignment.class.id)" | ||||
|                             > | ||||
|                                 {{ t("delete") }} | ||||
|                             </v-btn> | ||||
|                         </div> | ||||
|                     </v-card> | ||||
|                 </v-col> | ||||
|             </v-row> | ||||
|             <v-row v-if="assignments.length === 0"> | ||||
|                 <v-col cols="12"> | ||||
|                     <div class="no-assignments"> | ||||
|                         {{ t("no-assignments") }} | ||||
|                     </div> | ||||
|                 </v-col> | ||||
|             </v-row> | ||||
|         </v-container> | ||||
|                     </v-col> | ||||
|                 </v-row> | ||||
|             </v-container> | ||||
|             <v-snackbar | ||||
|                 v-model="snackbar.visible" | ||||
|                 :color="snackbar.color" | ||||
|                 timeout="3000" | ||||
|             > | ||||
|                 {{ snackbar.message }} | ||||
|             </v-snackbar> | ||||
|         </using-query-result> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -212,6 +256,7 @@ | |||
|         color: white; | ||||
|         transition: background-color 0.2s; | ||||
|     } | ||||
| 
 | ||||
|     .center-btn:hover { | ||||
|         background-color: #0e6942; | ||||
|     } | ||||
|  | @ -225,6 +270,7 @@ | |||
|             transform 0.2s, | ||||
|             box-shadow 0.2s; | ||||
|     } | ||||
| 
 | ||||
|     .assignment-card:hover { | ||||
|         box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); | ||||
|     } | ||||
|  | @ -248,6 +294,10 @@ | |||
|         margin-bottom: 0.2rem; | ||||
|     } | ||||
| 
 | ||||
|     .assignment-class a { | ||||
|         text-decoration: none; | ||||
|     } | ||||
| 
 | ||||
|     .class-name { | ||||
|         font-weight: 600; | ||||
|         color: #097180; | ||||
|  |  | |||
|  | @ -1,10 +1,58 @@ | |||
| import { describe, expect, it } from "vitest"; | ||||
| import { describe, it, expect, beforeEach } from "vitest"; | ||||
| import { ClassController } from "../../src/controllers/classes"; | ||||
| 
 | ||||
| describe("Test controller classes", () => { | ||||
|     it("Get classes", async () => { | ||||
|         const controller = new ClassController(); | ||||
|         const data = await controller.getAll(true); | ||||
|         expect(data.classes).to.have.length.greaterThan(0); | ||||
| describe("ClassController Tests", () => { | ||||
|     let controller: ClassController; | ||||
|     const testClassId = "X2J9QT"; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|         controller = new ClassController(); | ||||
|     }); | ||||
| 
 | ||||
|     it("should fetch all classes", async () => { | ||||
|         const result = await controller.getAll(true); | ||||
|         expect(result).toHaveProperty("classes"); | ||||
|         expect(Array.isArray(result.classes)).toBe(true); | ||||
|         expect(result.classes.length).toBeGreaterThan(0); | ||||
|     }); | ||||
| 
 | ||||
|     it("should fetch a class by ID", async () => { | ||||
|         const result = await controller.getById(testClassId); | ||||
|         expect(result).toHaveProperty("class"); | ||||
|         expect(result.class).toHaveProperty("id", testClassId); | ||||
|     }); | ||||
| 
 | ||||
|     it("should fetch students for a class", async () => { | ||||
|         const result = await controller.getStudents(testClassId, true); | ||||
|         expect(result).toHaveProperty("students"); | ||||
|         expect(Array.isArray(result.students)).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it("should fetch teachers for a class", async () => { | ||||
|         const result = await controller.getTeachers(testClassId, true); | ||||
|         expect(result).toHaveProperty("teachers"); | ||||
|         expect(Array.isArray(result.teachers)).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it("should fetch teacher invitations for a class", async () => { | ||||
|         const result = await controller.getTeacherInvitations(testClassId, true); | ||||
|         expect(result).toHaveProperty("invitations"); | ||||
|         expect(Array.isArray(result.invitations)).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it("should fetch assignments for a class", async () => { | ||||
|         const result = await controller.getAssignments(testClassId, true); | ||||
|         expect(result).toHaveProperty("assignments"); | ||||
|         expect(Array.isArray(result.assignments)).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it("should handle fetching a non-existent class", async () => { | ||||
|         const nonExistentId = "NON_EXISTENT_ID"; | ||||
|         await expect(controller.getById(nonExistentId)).rejects.toThrow(); | ||||
|     }); | ||||
| 
 | ||||
|     it("should handle deleting a non-existent class", async () => { | ||||
|         const nonExistentId = "NON_EXISTENT_ID"; | ||||
|         await expect(controller.deleteClass(nonExistentId)).rejects.toThrow(); | ||||
|     }); | ||||
| }); | ||||
|  |  | |||
							
								
								
									
										49
									
								
								frontend/tests/utils/array-utils.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								frontend/tests/utils/array-utils.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,49 @@ | |||
| import { copyArrayWith } from "../../src/utils/array-utils"; | ||||
| import { describe, it, expect } from "vitest"; | ||||
| 
 | ||||
| describe("copyArrayWith", () => { | ||||
|     it("should replace the element at the specified index", () => { | ||||
|         const original = [1, 2, 3, 4]; | ||||
|         const result = copyArrayWith(2, 99, original); | ||||
|         expect(result).toEqual([1, 2, 99, 4]); | ||||
|     }); | ||||
| 
 | ||||
|     it("should not modify the original array", () => { | ||||
|         const original = ["a", "b", "c"]; | ||||
|         const result = copyArrayWith(1, "x", original); | ||||
|         expect(original).toEqual(["a", "b", "c"]); // Original remains unchanged
 | ||||
|         expect(result).toEqual(["a", "x", "c"]); | ||||
|     }); | ||||
| 
 | ||||
|     it("should handle replacing the first element", () => { | ||||
|         const original = [true, false, true]; | ||||
|         const result = copyArrayWith(0, false, original); | ||||
|         expect(result).toEqual([false, false, true]); | ||||
|     }); | ||||
| 
 | ||||
|     it("should handle replacing the last element", () => { | ||||
|         const original = ["apple", "banana", "cherry"]; | ||||
|         const result = copyArrayWith(2, "date", original); | ||||
|         expect(result).toEqual(["apple", "banana", "date"]); | ||||
|     }); | ||||
| 
 | ||||
|     it("should work with complex objects", () => { | ||||
|         const original = [{ id: 1 }, { id: 2 }, { id: 3 }]; | ||||
|         const newValue = { id: 99 }; | ||||
|         const result = copyArrayWith(1, newValue, original); | ||||
|         expect(result).toEqual([{ id: 1 }, { id: 99 }, { id: 3 }]); | ||||
|         expect(original[1].id).toBe(2); // Original remains unchanged
 | ||||
|     }); | ||||
| 
 | ||||
|     it("should allow setting to undefined", () => { | ||||
|         const original = [1, 2, 3]; | ||||
|         const result = copyArrayWith(1, undefined, original); | ||||
|         expect(result).toEqual([1, undefined, 3]); | ||||
|     }); | ||||
| 
 | ||||
|     it("should allow setting to null", () => { | ||||
|         const original = [1, 2, 3]; | ||||
|         const result = copyArrayWith(1, null, original); | ||||
|         expect(result).toEqual([1, null, 3]); | ||||
|     }); | ||||
| }); | ||||
							
								
								
									
										86
									
								
								frontend/tests/utils/assignment-utils.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								frontend/tests/utils/assignment-utils.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,86 @@ | |||
| import { LearningPathNode } from "@dwengo-1/backend/dist/entities/content/learning-path-node.entity"; | ||||
| import { calculateProgress } from "../../src/utils/assignment-utils"; | ||||
| import { LearningPath } from "../../src/data-objects/learning-paths/learning-path"; | ||||
| import { describe, it, expect } from "vitest"; | ||||
| 
 | ||||
| describe("calculateProgress", () => { | ||||
|     it("should return 0 when no nodes are completed", () => { | ||||
|         const lp = new LearningPath({ | ||||
|             language: "en", | ||||
|             hruid: "test-path", | ||||
|             title: "Test Path", | ||||
|             description: "Test Description", | ||||
|             amountOfNodes: 10, | ||||
|             amountOfNodesLeft: 10, | ||||
|             keywords: ["test"], | ||||
|             targetAges: { min: 10, max: 15 }, | ||||
|             startNode: {} as LearningPathNode, | ||||
|         }); | ||||
| 
 | ||||
|         expect(calculateProgress(lp)).toBe(0); | ||||
|     }); | ||||
| 
 | ||||
|     it("should return 100 when all nodes are completed", () => { | ||||
|         const lp = new LearningPath({ | ||||
|             language: "en", | ||||
|             hruid: "test-path", | ||||
|             title: "Test Path", | ||||
|             description: "Test Description", | ||||
|             amountOfNodes: 10, | ||||
|             amountOfNodesLeft: 0, | ||||
|             keywords: ["test"], | ||||
|             targetAges: { min: 10, max: 15 }, | ||||
|             startNode: {} as LearningPathNode, | ||||
|         }); | ||||
| 
 | ||||
|         expect(calculateProgress(lp)).toBe(100); | ||||
|     }); | ||||
| 
 | ||||
|     it("should return 50 when half of the nodes are completed", () => { | ||||
|         const lp = new LearningPath({ | ||||
|             language: "en", | ||||
|             hruid: "test-path", | ||||
|             title: "Test Path", | ||||
|             description: "Test Description", | ||||
|             amountOfNodes: 10, | ||||
|             amountOfNodesLeft: 5, | ||||
|             keywords: ["test"], | ||||
|             targetAges: { min: 10, max: 15 }, | ||||
|             startNode: {} as LearningPathNode, | ||||
|         }); | ||||
| 
 | ||||
|         expect(calculateProgress(lp)).toBe(50); | ||||
|     }); | ||||
| 
 | ||||
|     it("should handle floating point progress correctly", () => { | ||||
|         const lp = new LearningPath({ | ||||
|             language: "en", | ||||
|             hruid: "test-path", | ||||
|             title: "Test Path", | ||||
|             description: "Test Description", | ||||
|             amountOfNodes: 3, | ||||
|             amountOfNodesLeft: 1, | ||||
|             keywords: ["test"], | ||||
|             targetAges: { min: 10, max: 15 }, | ||||
|             startNode: {} as LearningPathNode, | ||||
|         }); | ||||
| 
 | ||||
|         expect(calculateProgress(lp)).toBeCloseTo(66.666, 2); | ||||
|     }); | ||||
| 
 | ||||
|     it("should handle edge case where amountOfNodesLeft is negative", () => { | ||||
|         const lp = new LearningPath({ | ||||
|             language: "en", | ||||
|             hruid: "test-path", | ||||
|             title: "Test Path", | ||||
|             description: "Test Description", | ||||
|             amountOfNodes: 10, | ||||
|             amountOfNodesLeft: -5, | ||||
|             keywords: ["test"], | ||||
|             targetAges: { min: 10, max: 15 }, | ||||
|             startNode: {} as LearningPathNode, | ||||
|         }); | ||||
| 
 | ||||
|         expect(calculateProgress(lp)).toBe(150); | ||||
|     }); | ||||
| }); | ||||
|  | @ -1,82 +0,0 @@ | |||
| import { describe, expect, it } from "vitest"; | ||||
| import { | ||||
|     assignmentTitleRules, | ||||
|     classRules, | ||||
|     deadlineRules, | ||||
|     descriptionRules, | ||||
|     learningPathRules, | ||||
| } from "../../src/utils/assignment-rules"; | ||||
| 
 | ||||
| describe("Validation Rules", () => { | ||||
|     describe("assignmentTitleRules", () => { | ||||
|         it("should return true for a valid title", () => { | ||||
|             const result = assignmentTitleRules[0]("Valid Title"); | ||||
|             expect(result).toBe(true); | ||||
|         }); | ||||
| 
 | ||||
|         it("should return an error message for an empty title", () => { | ||||
|             const result = assignmentTitleRules[0](""); | ||||
|             expect(result).toBe("Title cannot be empty."); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe("learningPathRules", () => { | ||||
|         it("should return true for a valid learning path", () => { | ||||
|             const result = learningPathRules[0]({ hruid: "123", title: "Path Title" }); | ||||
|             expect(result).toBe(true); | ||||
|         }); | ||||
| 
 | ||||
|         it("should return an error message for an invalid learning path", () => { | ||||
|             const result = learningPathRules[0]({ hruid: "", title: "" }); | ||||
|             expect(result).toBe("You must select a learning path."); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe("classRules", () => { | ||||
|         it("should return true for a valid class", () => { | ||||
|             const result = classRules[0]("Class 1"); | ||||
|             expect(result).toBe(true); | ||||
|         }); | ||||
| 
 | ||||
|         it("should return an error message for an empty class", () => { | ||||
|             const result = classRules[0](""); | ||||
|             expect(result).toBe("You must select at least one class."); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe("deadlineRules", () => { | ||||
|         it("should return true for a valid future deadline", () => { | ||||
|             const futureDate = new Date(Date.now() + 1000 * 60 * 60).toISOString(); | ||||
|             const result = deadlineRules[0](futureDate); | ||||
|             expect(result).toBe(true); | ||||
|         }); | ||||
| 
 | ||||
|         it("should return an error message for a past deadline", () => { | ||||
|             const pastDate = new Date(Date.now() - 1000 * 60 * 60).toISOString(); | ||||
|             const result = deadlineRules[0](pastDate); | ||||
|             expect(result).toBe("The deadline must be in the future."); | ||||
|         }); | ||||
| 
 | ||||
|         it("should return an error message for an invalid date", () => { | ||||
|             const result = deadlineRules[0]("invalid-date"); | ||||
|             expect(result).toBe("Invalid date or time."); | ||||
|         }); | ||||
| 
 | ||||
|         it("should return an error message for an empty deadline", () => { | ||||
|             const result = deadlineRules[0](""); | ||||
|             expect(result).toBe("You must set a deadline."); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe("descriptionRules", () => { | ||||
|         it("should return true for a valid description", () => { | ||||
|             const result = descriptionRules[0]("This is a valid description."); | ||||
|             expect(result).toBe(true); | ||||
|         }); | ||||
| 
 | ||||
|         it("should return an error message for an empty description", () => { | ||||
|             const result = descriptionRules[0](""); | ||||
|             expect(result).toBe("Description cannot be empty."); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
		Reference in a new issue