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