diff --git a/frontend/src/components/subject/createSubjectView/body/CreateSubjectBody.vue b/frontend/src/components/subject/createSubjectView/body/CreateSubjectBody.vue new file mode 100644 index 00000000..16f3e330 --- /dev/null +++ b/frontend/src/components/subject/createSubjectView/body/CreateSubjectBody.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/frontend/src/components/subject/createSubjectView/body/SubjectInstructorsCard.vue b/frontend/src/components/subject/createSubjectView/body/SubjectInstructorsCard.vue new file mode 100644 index 00000000..b4bdcf64 --- /dev/null +++ b/frontend/src/components/subject/createSubjectView/body/SubjectInstructorsCard.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/frontend/src/components/subject/createSubjectView/body/UserSearchCard.vue b/frontend/src/components/subject/createSubjectView/body/UserSearchCard.vue new file mode 100644 index 00000000..3a4bce23 --- /dev/null +++ b/frontend/src/components/subject/createSubjectView/body/UserSearchCard.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/src/components/subject/createSubjectView/body/UserSearchList.vue b/frontend/src/components/subject/createSubjectView/body/UserSearchList.vue new file mode 100644 index 00000000..0a75b6d7 --- /dev/null +++ b/frontend/src/components/subject/createSubjectView/body/UserSearchList.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/frontend/src/components/subject/createSubjectView/header/CreateSubjectHeaderCard.vue b/frontend/src/components/subject/createSubjectView/header/CreateSubjectHeaderCard.vue new file mode 100644 index 00000000..0e7a2676 --- /dev/null +++ b/frontend/src/components/subject/createSubjectView/header/CreateSubjectHeaderCard.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/frontend/src/components/subject/createSubjectView/header/CreateSubjectHeaderContainer.vue b/frontend/src/components/subject/createSubjectView/header/CreateSubjectHeaderContainer.vue new file mode 100644 index 00000000..d149c68b --- /dev/null +++ b/frontend/src/components/subject/createSubjectView/header/CreateSubjectHeaderContainer.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/frontend/src/components/subject/createSubjectView/header/CreateSubjectHeaderImage.vue b/frontend/src/components/subject/createSubjectView/header/CreateSubjectHeaderImage.vue new file mode 100644 index 00000000..53f4dad8 --- /dev/null +++ b/frontend/src/components/subject/createSubjectView/header/CreateSubjectHeaderImage.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/components/subject/subjectsview/SubjectCard.vue b/frontend/src/components/subject/subjectsview/body/SubjectCard.vue similarity index 93% rename from frontend/src/components/subject/subjectsview/SubjectCard.vue rename to frontend/src/components/subject/subjectsview/body/SubjectCard.vue index 8701fce1..60834f0b 100644 --- a/frontend/src/components/subject/subjectsview/SubjectCard.vue +++ b/frontend/src/components/subject/subjectsview/body/SubjectCard.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index b28fd7d0..c5c0f4ab 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -9,6 +9,12 @@ export default { }, no: "no", to: "to ", + no_capital: "No", + yes_capital: "Yes", + cancel: "Cancel", + confirm: "Confirm", + search: "Search", + add: "Add", }, logo: "ghent-university-logo-white.png", logout: "logout", @@ -110,6 +116,7 @@ export default { search: "Search", userTable: { name: "Name", + surname: "Surname", uid: "UGent ID", email: "Email", isTeacher: "Is Teacher", @@ -154,6 +161,25 @@ export default { student_subjects: "Show student subjects", no_subjects: "No subjects found.", }, + + create_subject: { + error_snackbar: "There needs to be at least one teacher amongst the subject instructors.", + cancel_dialog: "Cancel subject creation?", + new_subject: "New subject", + title: "Title", + enter_title: "Enter title", + enter_title_hint: "Enter a valid title for the new subject", + assign_self: "Assign myself as instructor", + field_required: "This field is required", + field_length: "Title must be at least 3 characters long", + subject_instructors: "Subject instructors", + search_for_instructors: "Search for instructors", + email: "Subject email", + enter_email: "Enter email", + email_hint: "Field is optional", + email_invalid: "Provided email is invalid", + }, + group: { not_found: "Group not found", not_found2: "No groups found", diff --git a/frontend/src/i18n/locales/nl.ts b/frontend/src/i18n/locales/nl.ts index 872bb494..c203f6ed 100644 --- a/frontend/src/i18n/locales/nl.ts +++ b/frontend/src/i18n/locales/nl.ts @@ -8,6 +8,12 @@ export default { }, no: "geen", to: "naar ", + no_capital: "Neen", + yes_capital: "Ja", + cancel: "Annuleren", + confirm: "Bevestigen", + search: "Zoeken", + add: "Toevoegen", }, logo: "universiteit-gent-logo-white.png", logout: "uitloggen", @@ -112,6 +118,7 @@ export default { search: "Zoeken", userTable: { name: "Naam", + surname: "Achternaam", uid: "UGent ID", email: "Email", isTeacher: "Is Lesgever", @@ -157,6 +164,26 @@ export default { student_subjects: "Toon student vakken", no_subjects: "Geen vakken gevonden.", }, + + create_subject: { + error_snackbar: + "Er moet minstens één lesgever bij de geselecteerde vakverantwoordelijken aanwezig zijn.", + cancel_dialog: "Vak aanmaken annuleren?", + new_subject: "Nieuw vak", + title: "Titel", + enter_title: "Voer titel in", + enter_title_hint: "Voer een geldige titel in voor het nieuwe vak", + assign_self: "Wijs mezelf toe als lesgever", + field_required: "Dit veld is verplicht", + field_length: "Titel moet minstens 3 tekens lang zijn", + subject_instructors: "Vak verantwoordelijken", + search_for_instructors: "Zoek naar vak verantwoordelijken", + email: "Vak email", + enter_email: "Voer email in", + email_hint: "Veld is optioneel", + email_invalid: "Ingevoerde email is ongeldig", + }, + group: { not_found: "Groep niet gevonden", not_found2: "Geen groepen teruggevonden", diff --git a/frontend/src/models/Subject.ts b/frontend/src/models/Subject.ts index 9a391ec4..e965ed4c 100644 --- a/frontend/src/models/Subject.ts +++ b/frontend/src/models/Subject.ts @@ -26,3 +26,9 @@ export interface SubjectDetails { subjectData: Subject; role: SubjectRole; } + +export default interface SubjectForm { + name: string; + email: string; + academic_year: number; +} diff --git a/frontend/src/queries/Subject.ts b/frontend/src/queries/Subject.ts index 261a2cca..dad0cd48 100644 --- a/frontend/src/queries/Subject.ts +++ b/frontend/src/queries/Subject.ts @@ -10,12 +10,15 @@ import { getSubjectByUuid, registerToSubject, getSubjectUuid, + createSubject, + createSubjectInstructor, } from "@/services/subject"; import { getSubjectProjects } from "@/services/project"; import type User from "@/models/User"; import type Subject from "@/models/Subject"; import type { UserSubjectList } from "@/models/Subject"; import type Project from "@/models/Project"; +import type SubjectForm from "@/models/Subject"; function SUBJECT_QUERY_KEY(subjectId: number | string): (string | number)[] { return ["subject", subjectId]; @@ -147,9 +150,67 @@ export function useRegisterToSubjectMutation(): UseMutationReturnType< onSettled: () => { queryClient.invalidateQueries({ queryKey: SUBJECTS_QUERY_KEY() }); }, - onError: (error) => { - console.error(error); + onError: () => { alert("Failed to register to subject"); }, }); } + +/** + * Mutation composable for creating a subject + */ +export function useCreateSubjectMutation(): UseMutationReturnType< + number, + Error, + SubjectForm, + void +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createSubject, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: SUBJECTS_QUERY_KEY() }); + }, + onError: () => { + alert("Could not create subject. Please try again."); + }, + }); +} + +/** + * Mutation composable for creating subject instructor + */ + +export function useCreateSubjectInstructorMutation(): UseMutationReturnType< + void, + Error, + { user: User; subjectId: number }, + { previousUsers: User[] } +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ user, subjectId }) => createSubjectInstructor(subjectId, user.uid), + onMutate: ({ subjectId, user }) => { + const previousUsers = queryClient.getQueryData( + SUBJECT_INSTRUCTORS_QUERY_KEY(subjectId) + ); + queryClient.cancelQueries({ queryKey: SUBJECT_INSTRUCTORS_QUERY_KEY(subjectId) }); + const newUsers = previousUsers ? [...previousUsers] : []; + newUsers.push(user); + queryClient.setQueryData(SUBJECT_INSTRUCTORS_QUERY_KEY(subjectId), newUsers); + return { previousUsers: previousUsers || [] }; + }, + onSuccess: (_, { subjectId }) => { + queryClient.invalidateQueries({ + queryKey: SUBJECT_INSTRUCTORS_QUERY_KEY(subjectId), + }); + }, + onError: (_, { subjectId }, ctx) => { + queryClient.setQueryData( + SUBJECT_INSTRUCTORS_QUERY_KEY(subjectId), + () => ctx!.previousUsers! + ); + alert("Could not create subject instructor. Please try again."); + }, + }); +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index f332d648..5cfedfde 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -119,6 +119,11 @@ const router = createRouter({ name: "subjects", component: () => import("../views/subject/SubjectsView.vue"), }, + { + path: "/subjects/create", + name: "create-subject", + component: () => import("../views/subject/CreateSubjectView.vue"), + }, { path: "/subjects/:subjectId(\\d+)", name: "subject", diff --git a/frontend/src/services/subject.ts b/frontend/src/services/subject.ts index 328c8e79..601b5864 100644 --- a/frontend/src/services/subject.ts +++ b/frontend/src/services/subject.ts @@ -2,6 +2,7 @@ import type User from "@/models/User"; import type Subject from "@/models/Subject"; import type { UserSubjectList } from "@/models/Subject"; import { authorized_fetch } from "@/services"; +import type SubjectForm from "@/models/Subject"; /** * Fetches the subject with the given ID. @@ -53,3 +54,20 @@ export async function getSubjectUuid(subjectId: number): Promise { const result = await authorized_fetch(`/api/subjects/${subjectId}/uuid`, { method: "GET" }); return result.subject_uuid; } + +/** + * Creates a new project. + */ +export async function createSubject(projectData: SubjectForm): Promise { + const response = await authorized_fetch(`/api/subjects/`, { + method: "POST", + body: JSON.stringify(projectData), + }); + return response.id; +} + +export async function createSubjectInstructor(subjectId: number, uid: string): Promise { + return authorized_fetch(`/api/subjects/${subjectId}/instructors?user_id=${uid}`, { + method: "POST", + }); +} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index db352b29..0efa3729 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -12,3 +12,33 @@ export async function download_file(url: string, filename: string) { document.body.appendChild(link); link.click(); } + +type ThrottledFunction any> = (...args: Parameters) => void; + +export function throttle any>( + func: T, + limit: number +): ThrottledFunction { + let lastFunc: ReturnType; + let lastRan: number; + + return function (this: any, ...args: Parameters) { + const context = this; + + if (!lastRan) { + func.apply(context, args); + lastRan = Date.now(); + } else { + clearTimeout(lastFunc); + lastFunc = setTimeout( + function () { + if (Date.now() - lastRan >= limit) { + func.apply(context, args); + lastRan = Date.now(); + } + }, + limit - (Date.now() - lastRan) + ); + } + }; +} diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 59295e3a..53a746f1 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -3,129 +3,40 @@ - - - - - - + diff --git a/frontend/src/views/subject/SubjectView.vue b/frontend/src/views/subject/SubjectView.vue index 2e05df13..fb7cffeb 100644 --- a/frontend/src/views/subject/SubjectView.vue +++ b/frontend/src/views/subject/SubjectView.vue @@ -28,7 +28,7 @@ { const isStudent = computed(() => { return [...(students.value || [])].some((student) => student?.uid === user.value?.uid); }); + +const sortedInstructors = computed(() => { + return [...(instructors.value || [])].sort((a, b) => { + if (a?.is_teacher && !b?.is_teacher) { + return -1; + } else if (!a?.is_teacher && b?.is_teacher) { + return 1; + } else { + return a?.surname.localeCompare(b?.surname); + } + }); +}); + const { isAdmin } = useIsAdmin(); const { isTeacher } = useIsTeacher(); diff --git a/frontend/src/views/subject/SubjectsView.vue b/frontend/src/views/subject/SubjectsView.vue index a5aec499..e15e46ee 100644 --- a/frontend/src/views/subject/SubjectsView.vue +++ b/frontend/src/views/subject/SubjectsView.vue @@ -38,7 +38,7 @@
- + {{ $t("subjects.create_subject") }} @@ -52,8 +52,8 @@