Merge branch 'dev' into test/e2e

This commit is contained in:
Tibo De Peuter 2025-05-16 14:30:00 +02:00
commit e12c057ebb
Signed by: tdpeuter
GPG key ID: 38297DE43F75FFE2
112 changed files with 5304 additions and 1422 deletions

View file

@ -17,6 +17,7 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@dwengo-1/common": "^0.2.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/vue-query": "^5.69.0",
"@vueuse/core": "^13.1.0",

View file

@ -0,0 +1,54 @@
.h1 {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
font-size: 50px;
padding-left: 1%;
}
.empty-message {
text-align: center;
font-size: 18px;
}
.header {
font-weight: bold !important;
background-color: #0e6942;
color: white;
padding: 10px;
}
.table thead th:first-child {
border-top-left-radius: 10px;
}
.table thead th:last-child {
border-top-right-radius: 10px;
}
.table tbody tr:nth-child(odd) {
background-color: white;
}
.table tbody tr:nth-child(even) {
background-color: #f6faf2;
}
.table td,
.table th {
border-bottom: 1px solid #0e6942;
border-top: 1px solid #0e6942;
}
.table {
width: 90%;
padding-top: 10px;
border-collapse: collapse;
}
@media screen and (max-width: 850px) {
.h1 {
text-align: center;
padding-left: 0;
}
}

View file

@ -57,6 +57,22 @@
</div>
<v-row v-else>
<v-col
cols="12"
sm="6"
md="4"
lg="4"
class="d-flex"
>
<ThemeCard
path="/learningPath/search"
:is-absolute-path="true"
:title="t('searchAllLearningPathsTitle')"
:description="t('searchAllLearningPathsDescription')"
icon="mdi-magnify"
class="fill-height grey-bg-card"
/>
</v-col>
<v-col
v-for="card in cards"
:key="card.key"
@ -74,24 +90,13 @@
class="fill-height"
/>
</v-col>
<v-col
cols="12"
sm="6"
md="4"
lg="4"
class="d-flex"
>
<ThemeCard
path="/learningPath/search"
:is-absolute-path="true"
:title="t('searchAllLearningPathsTitle')"
:description="t('searchAllLearningPathsDescription')"
icon="mdi-magnify"
class="fill-height"
/>
</v-col>
</v-row>
</v-container>
</template>
<style scoped></style>
<style scoped>
.grey-bg-card {
background-color: #f6faf2;
border: 2px solid #0e6942;
}
</style>

View file

@ -31,4 +31,9 @@
></v-text-field>
</template>
<style scoped></style>
<style scoped>
.search-field {
width: 25%;
min-width: 300px;
}
</style>

View file

@ -53,9 +53,9 @@
white-space: normal;
}
.results-grid {
margin: 20px;
margin: 20px auto;
display: flex;
align-items: stretch;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
}

View file

@ -14,6 +14,8 @@
const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable
const name: string = auth.authState.user!.profile.name!;
const username = auth.authState.user!.profile.preferred_username!;
const email = auth.authState.user!.profile.email;
const initials: string = name
.split(" ")
.map((n) => n[0])
@ -90,31 +92,34 @@
<!-- >-->
<!-- {{ t("discussions") }}-->
<!-- </v-btn>-->
<v-menu open-on-hover>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
variant="text"
>
<v-icon
icon="mdi-translate"
size="small"
color="#0e6942"
></v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(language, index) in languages"
:key="index"
@click="changeLanguage(language.code)"
>
<v-list-item-title>{{ language.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar-items>
<v-menu
open-on-hover
open-on-click
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
variant="text"
>
<v-icon
icon="mdi-translate"
size="small"
color="#0e6942"
></v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(language, index) in languages"
:key="index"
@click="changeLanguage(language.code)"
>
<v-list-item-title>{{ language.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-dialog max-width="500">
<template v-slot:activator="{ props: activatorProps }">
@ -158,12 +163,48 @@
</v-card>
</template>
</v-dialog>
<v-avatar
size="large"
color="#0e6942"
class="user-button"
>{{ initials }}</v-avatar
>
<v-menu min-width="200px">
<template v-slot:activator="{ props }">
<v-btn
icon
v-bind="props"
>
<v-avatar
color="#0e6942"
size="large"
class="user-button"
>
<span>{{ initials }}</span>
</v-avatar>
</v-btn>
</template>
<v-card>
<v-card-text>
<div class="mx-auto text-center">
<v-avatar
color="#0e6942"
size="large"
class="user-button mb-3"
>
<span>{{ initials }}</span>
</v-avatar>
<h3>{{ name }}</h3>
<p class="text-caption mt-1">{{ username }}</p>
<p class="text-caption mt-1">{{ email }}</p>
<v-divider class="my-3"></v-divider>
<v-btn
variant="text"
rounded
append-icon="mdi-logout"
@click="performLogout"
to="/login"
>{{ t("logout") }}</v-btn
>
<v-divider class="my-3"></v-divider>
</div>
</v-card-text>
</v-card>
</v-menu>
</v-app-bar>
<v-navigation-drawer
v-model="drawer"
@ -248,6 +289,12 @@
text-transform: none;
}
.translate-button {
z-index: 1;
position: relative;
margin-left: 10px;
}
@media (max-width: 700px) {
.menu {
display: none;

View file

@ -6,6 +6,7 @@
import type { AnswersResponse } from "@/controllers/answers";
import type { AnswerData, AnswerDTO } from "@dwengo-1/common/interfaces/answer";
import authService from "@/services/auth/auth-service";
import { AccountType } from "@dwengo-1/common/util/account-types";
const props = defineProps<{
question: QuestionDTO;
@ -80,7 +81,7 @@
{{ question.content }}
</div>
<div
v-if="authService.authState.activeRole === 'teacher'"
v-if="authService.authState.activeRole === AccountType.Teacher"
class="answer-input-container"
>
<input

View file

@ -1,49 +1,30 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref, watch } from "vue";
import { deadlineRules } from "@/utils/assignment-rules.ts";
const date = ref("");
const time = ref("23:59");
const emit = defineEmits(["update:deadline"]);
const emit = defineEmits<(e: "update:deadline", value: Date) => void>();
const formattedDeadline = computed(() => {
if (!date.value || !time.value) return "";
return `${date.value} ${time.value}`;
});
const datetime = ref("");
function updateDeadline(): void {
if (date.value && time.value) {
emit("update:deadline", formattedDeadline.value);
// Watch the datetime value and emit the update
watch(datetime, (val) => {
const newDate = new Date(val);
if (!isNaN(newDate.getTime())) {
emit("update:deadline", newDate);
}
}
});
</script>
<template>
<div>
<v-card-text>
<v-text-field
v-model="date"
label="Select Deadline Date"
type="date"
variant="outlined"
density="compact"
:rules="deadlineRules"
required
@update:modelValue="updateDeadline"
></v-text-field>
</v-card-text>
<v-card-text>
<v-text-field
v-model="time"
label="Select Deadline Time"
type="time"
variant="outlined"
density="compact"
@update:modelValue="updateDeadline"
></v-text-field>
</v-card-text>
</div>
<v-card-text>
<v-text-field
v-model="datetime"
type="datetime-local"
label="Select Deadline"
variant="outlined"
density="compact"
:rules="deadlineRules"
required
/>
</v-card-text>
</template>
<style scoped></style>

View file

@ -26,8 +26,8 @@ export class LearningPathController extends BaseController {
});
return LearningPath.fromDTO(single(dtos));
}
async getAllByTheme(theme: string): Promise<LearningPath[]> {
const dtos = await this.get<LearningPathDTO[]>("/", { theme });
async getAllByThemeAndLanguage(theme: string, language: Language): Promise<LearningPath[]> {
const dtos = await this.get<LearningPathDTO[]>("/", { theme, language });
return dtos.map((dto) => LearningPath.fromDTO(dto));
}

View file

@ -1,6 +1,5 @@
import { BaseController } from "@/controllers/base-controller.ts";
import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts";
import type { QuestionsResponse } from "@/controllers/questions.ts";
import type { ClassesResponse } from "@/controllers/classes.ts";
import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher";
@ -40,10 +39,6 @@ export class TeacherController extends BaseController {
return this.get<StudentsResponse>(`/${username}/students`, { full });
}
async getQuestions(username: string, full = false): Promise<QuestionsResponse> {
return this.get<QuestionsResponse>(`/${username}/questions`, { full });
}
async getStudentJoinRequests(username: string, classId: string): Promise<JoinRequestsResponse> {
return this.get<JoinRequestsResponse>(`/${username}/joinRequests/${classId}`);
}

View file

@ -21,6 +21,7 @@
"JoinClassExplanation": "Geben Sie den Code ein, den Ihnen die Lehrkraft mitgeteilt hat, um der Klasse beizutreten.",
"invalidFormat": "Ungültiges Format",
"submitCode": "senden",
"submit": "senden",
"members": "Mitglieder",
"themes": "Themen",
"choose-theme": "Wählen Sie ein Thema",
@ -68,10 +69,10 @@
"pick-class": "Wählen Sie eine klasse",
"choose-students": "Studenten auswählen",
"create-group": "Gruppe erstellen",
"class": "klasse",
"class": "Klasse",
"delete": "löschen",
"view-assignment": "Auftrag anzeigen",
"code": "code",
"code": "Code",
"invitations": "Einladungen",
"createClass": "Klasse erstellen",
"createClassInstructions": "Geben Sie einen Namen für Ihre Klasse ein und klicken Sie auf „Erstellen“. Es erscheint ein Fenster mit einem Code, den Sie kopieren können. Geben Sie diesen Code an Ihre Schüler weiter und sie können Ihrer Klasse beitreten.",
@ -83,7 +84,7 @@
"onlyUse": "nur Buchstaben, Zahlen, Bindestriche (-) und Unterstriche (_) verwenden",
"close": "schließen",
"copied": "kopiert!",
"accept": "akzeptieren",
"accept": "Akzeptieren",
"deny": "ablehnen",
"sent": "sent",
"failed": "fehlgeschlagen",
@ -110,7 +111,7 @@
"remove": "entfernen",
"students": "Studenten",
"classJoinRequests": "Beitrittsanfragen",
"reject": "ablehnen",
"reject": "Ablehnen",
"areusure": "Sind Sie sicher?",
"yes": "ja",
"teachers": "Lehrer",
@ -121,5 +122,18 @@
"invite": "einladen",
"assignmentIndicator": "AUFGABE",
"searchAllLearningPathsTitle": "Alle Lernpfade durchsuchen",
"searchAllLearningPathsDescription": "Nicht gefunden, was Sie gesucht haben? Klicken Sie hier, um unsere gesamte Lernpfad-Datenbank zu durchsuchen."
"searchAllLearningPathsDescription": "Nicht gefunden, was Sie gesucht haben? Klicken Sie hier, um unsere gesamte Lernpfad-Datenbank zu durchsuchen.",
"no-students-found": "Diese Klasse hat keine Schüler.",
"no-invitations-found": "Sie haben keine ausstehenden Einladungen.",
"no-join-requests-found": "Es gibt keine ausstehenden Beitrittsanfragen für diese Klasse.",
"no-classes-found": "Sie sind noch keinem Kurs beigetreten.",
"classCreated": "Klasse erstellt!",
"success": "Erfolg",
"submitted": "eingereicht",
"see-submission": "Einsendung anzeigen",
"view-submissions": "Einsendungen anzeigen",
"valid-username": "Bitte geben Sie einen gültigen Benutzernamen ein",
"creationFailed": "Erstellung fehlgeschlagen, bitte versuchen Sie es erneut",
"no-assignments": "Derzeit gibt es keine Zuweisungen.",
"deadline": "deadline"
}

View file

@ -33,6 +33,7 @@
"JoinClassExplanation": "Enter the code the teacher has given you to join the class.",
"invalidFormat": "Invalid format.",
"submitCode": "submit",
"submit": "submit",
"members": "Members",
"themes": "Themes",
"choose-theme": "Select a theme",
@ -68,21 +69,21 @@
"pick-class": "Pick a class",
"choose-students": "Select students",
"create-group": "Create group",
"class": "class",
"class": "Class",
"delete": "delete",
"view-assignment": "View assignment",
"code": "code",
"invitations": "invitations",
"createClass": "create class",
"code": "Code",
"invitations": "Invitations",
"createClass": "Create class",
"classname": "classname",
"EnterNameOfClass": "Enter a classname.",
"create": "create",
"sender": "sender",
"sender": "Sender",
"nameIsMandatory": "classname is mandatory",
"onlyUse": "only use letters, numbers, dashes (-) and underscores (_)",
"close": "close",
"copied": "copied!",
"accept": "accept",
"accept": "Accept",
"deny": "deny",
"createClassInstructions": "Enter a name for your class and click on create. A window will appear with a code that you can copy. Give this code to your students and they will be able to join.",
"sent": "sent",
@ -108,12 +109,12 @@
"progress": "Progress",
"created": "created",
"remove": "remove",
"students": "students",
"classJoinRequests": "join requests",
"reject": "reject",
"students": "Students",
"classJoinRequests": "Join requests",
"reject": "Reject",
"areusure": "Are you sure?",
"yes": "yes",
"teachers": "teachers",
"teachers": "Teachers",
"accepted": "accepted",
"rejected": "rejected",
"enterUsername": "enter the username of the teacher you would like to invite",
@ -121,5 +122,18 @@
"invite": "invite",
"assignmentIndicator": "ASSIGNMENT",
"searchAllLearningPathsTitle": "Search all learning paths",
"searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths."
"searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths.",
"no-students-found": "This class has no students.",
"no-invitations-found": "You have no pending invitations.",
"no-join-requests-found": "There are no pending join requests for this class.",
"no-classes-found": "You are not yet part of a class.",
"classCreated": "class created!",
"success": "success",
"submitted": "submitted",
"see-submission": "view submission",
"view-submissions": "view submissions",
"valid-username": "please enter a valid username",
"creationFailed": "creation failed, please try again",
"no-assignments": "There are currently no assignments.",
"deadline": "deadline"
}

View file

@ -33,6 +33,7 @@
"JoinClassExplanation": "Entrez le code que l'enseignant vous a donné pour rejoindre la classe.",
"invalidFormat": "Format non valide.",
"submitCode": "envoyer",
"submit": "envoyer",
"members": "Membres",
"themes": "Thèmes",
"choose-theme": "Choisis un thème",
@ -68,22 +69,22 @@
"pick-class": "Choisissez une classe",
"choose-students": "Sélectionnez des élèves",
"create-group": "Créer un groupe",
"class": "classe",
"class": "Classe",
"delete": "supprimer",
"view-assignment": "Voir le travail",
"code": "code",
"invitations": "invitations",
"createClass": "créer une classe",
"code": "Code",
"invitations": "Invitations",
"createClass": "Créer une classe",
"createClassInstructions": "Entrez un nom pour votre classe et cliquez sur créer. Une fenêtre apparaît avec un code que vous pouvez copier. Donnez ce code à vos élèves et ils pourront rejoindre votre classe.",
"classname": "nom de classe",
"EnterNameOfClass": "saisir un nom de classe.",
"create": "créer",
"sender": "expéditeur",
"sender": "Expéditeur",
"nameIsMandatory": "le nom de classe est obligatoire",
"onlyUse": "n'utiliser que des lettres, des chiffres, des tirets (-) et des traits de soulignement (_)",
"close": "fermer",
"copied": "copié!",
"accept": "accepter",
"accept": "Accepter",
"deny": "refuser",
"sent": "envoyé",
"failed": "échoué",
@ -108,12 +109,13 @@
"submission": "Soumission",
"progress": "Progrès",
"remove": "supprimer",
"students": "étudiants",
"classJoinRequests": "demandes d'adhésion",
"reject": "rejeter",
"students": "Étudiants",
"classJoinRequests": "Demandes d'adhésion",
"reject": "Rejeter",
"areusure": "Êtes-vous sûr?",
"yes": "oui",
"teachers": "enseignants",
"teachers": "Enseignants",
"accepted": "acceptée",
"rejected": "rejetée",
"enterUsername": "entrez le nom d'utilisateur de l'enseignant que vous souhaitez inviter",
@ -121,5 +123,18 @@
"invite": "inviter",
"assignmentIndicator": "DEVOIR",
"searchAllLearningPathsTitle": "Rechercher tous les parcours d'apprentissage",
"searchAllLearningPathsDescription": "Vous n'avez pas trouvé ce que vous cherchiez ? Cliquez ici pour rechercher dans toute notre base de données de parcours d'apprentissage disponibles."
"searchAllLearningPathsDescription": "Vous n'avez pas trouvé ce que vous cherchiez ? Cliquez ici pour rechercher dans toute notre base de données de parcours d'apprentissage disponibles.",
"no-students-found": "Cette classe n'a pas d'élèves.",
"no-invitations-found": "Vous n'avez aucune invitation en attente.",
"no-join-requests-found": "Il n'y a aucune demande d'adhésion en attente pour cette classe.",
"no-classes-found": "Vous ne faites pas encore partie d'une classe.",
"classCreated": "Classe créée !",
"success": "succès",
"submitted": "soumis",
"see-submission": "voir la soumission",
"view-submissions": "voir les soumissions",
"valid-username": "veuillez entrer un nom d'utilisateur valide",
"creationFailed": "échec de la création, veuillez réessayer",
"no-assignments": "Il n'y a actuellement aucun travail.",
"deadline": "délai"
}

View file

@ -33,6 +33,7 @@
"JoinClassExplanation": "Voer de code in die je van de docent hebt gekregen om lid te worden van de klas.",
"invalidFormat": "Ongeldig formaat.",
"submitCode": "verzenden",
"submit": "verzenden",
"members": "Leden",
"themes": "Lesthema's",
"choose-theme": "Kies een thema",
@ -68,22 +69,22 @@
"pick-class": "Kies een klas",
"choose-students": "Studenten selecteren",
"create-group": "Groep aanmaken",
"class": "klas",
"class": "Klas",
"delete": "verwijderen",
"view-assignment": "Opdracht bekijken",
"code": "code",
"invitations": "uitnodigingen",
"createClass": "klas aanmaken",
"code": "Code",
"invitations": "Uitnodigingen",
"createClass": "Klas aanmaken",
"createClassInstructions": "Voer een naam in voor je klas en klik op create. Er verschijnt een venster met een code die je kunt kopiëren. Geef deze code aan je leerlingen en ze kunnen deelnemen aan je klas.",
"classname": "klasnaam",
"EnterNameOfClass": "Geef een klasnaam op.",
"create": "aanmaken",
"sender": "afzender",
"sender": "Afzender",
"nameIsMandatory": "klasnaam is verplicht",
"onlyUse": "gebruik enkel letters, cijfers, dashes (-) en underscores (_)",
"close": "sluiten",
"copied": "gekopieerd!",
"accept": "accepteren",
"accept": "Accepteren",
"deny": "weigeren",
"sent": "verzonden",
"failed": "mislukt",
@ -108,12 +109,12 @@
"submission": "Indiening",
"progress": "Vooruitgang",
"remove": "verwijder",
"students": "studenten",
"classJoinRequests": "deelname verzoeken",
"reject": "weiger",
"students": "Studenten",
"classJoinRequests": "Deelname verzoeken",
"reject": "Weiger",
"areusure": "Bent u zeker?",
"yes": "ja",
"teachers": "leerkrachten",
"teachers": "Leerkrachten",
"accepted": "geaccepteerd",
"rejected": "geweigerd",
"enterUsername": "vul de gebruikersnaam van de leerkracht die je wilt uitnodigen in",
@ -121,5 +122,18 @@
"invite": "uitnodigen",
"assignmentIndicator": "OPDRACHT",
"searchAllLearningPathsTitle": "Alle leerpaden doorzoeken",
"searchAllLearningPathsDescription": "Niet gevonden waar je naar op zoek was? Klik hier om onze volledige databank van beschikbare leerpaden te doorzoeken."
"searchAllLearningPathsDescription": "Niet gevonden waar je naar op zoek was? Klik hier om onze volledige databank van beschikbare leerpaden te doorzoeken.",
"no-students-found": "Deze klas heeft geen leerlingen.",
"no-invitations-found": "U heeft geen openstaande uitnodigingen.",
"no-join-requests-found": "Er zijn geen openstaande verzoeken om lid te worden van deze klas.",
"no-classes-found": "U maakt nog geen deel uit van een klas.",
"classCreated": "Klas aangemaakt!",
"success": "succes",
"submitted": "ingediend",
"see-submission": "inzending bekijken",
"view-submissions": "inzendingen bekijken",
"valid-username": "voer een geldige gebruikersnaam in",
"creationFailed": "aanmaak mislukt, probeer het opnieuw",
"no-assignments": "Er zijn momenteel geen opdrachten.",
"deadline": "deadline"
}

View file

@ -15,6 +15,7 @@ import { invalidateAllGroupKeys } from "./groups";
import { invalidateAllSubmissionKeys } from "./submissions";
import type { TeachersResponse } from "@/controllers/teachers";
import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations";
import { studentClassesQueryKey } from "@/queries/students.ts";
const classController = new ClassController();
@ -171,6 +172,8 @@ export function useClassDeleteStudentMutation(): UseMutationReturnType<
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) });
await queryClient.invalidateQueries({ queryKey: studentClassesQueryKey(data.class.id, false) });
await queryClient.invalidateQueries({ queryKey: studentClassesQueryKey(data.class.id, true) });
},
});
}

View file

@ -22,12 +22,13 @@ export function useGetLearningPathQuery(
});
}
export function useGetAllLearningPathsByThemeQuery(
export function useGetAllLearningPathsByThemeAndLanguageQuery(
theme: MaybeRefOrGetter<string>,
language: MaybeRefOrGetter<Language>,
): UseQueryReturnType<LearningPath[], Error> {
return useQuery({
queryKey: [LEARNING_PATH_KEY, "getAllByTheme", theme],
queryFn: async () => learningPathController.getAllByTheme(toValue(theme)),
queryKey: [LEARNING_PATH_KEY, "getAllByTheme", theme, language],
queryFn: async () => learningPathController.getAllByThemeAndLanguage(toValue(theme), toValue(language)),
enabled: () => Boolean(toValue(theme)),
});
}

View file

@ -33,7 +33,7 @@ function studentsQueryKey(full: boolean): [string, boolean] {
function studentQueryKey(username: string): [string, string] {
return ["student", username];
}
function studentClassesQueryKey(username: string, full: boolean): [string, string, boolean] {
export function studentClassesQueryKey(username: string, full: boolean): [string, string, boolean] {
return ["student-classes", username, full];
}
function studentAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] {

View file

@ -10,7 +10,6 @@ import {
import { TeacherController, type TeacherResponse, type TeachersResponse } from "@/controllers/teachers.ts";
import type { ClassesResponse } from "@/controllers/classes.ts";
import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts";
import type { QuestionsResponse } from "@/controllers/questions.ts";
import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher";
import { studentJoinRequestQueryKey, studentJoinRequestsQueryKey } from "@/queries/students.ts";
@ -33,10 +32,6 @@ function teacherStudentsQueryKey(username: string, full: boolean): [string, stri
return ["teacher-students", username, full];
}
function teacherQuestionsQueryKey(username: string, full: boolean): [string, string, boolean] {
return ["teacher-questions", username, full];
}
export function teacherClassJoinRequests(classId: string): [string, string] {
return ["teacher-class-join-requests", classId];
}
@ -80,17 +75,6 @@ export function useTeacherStudentsQuery(
});
}
export function useTeacherQuestionsQuery(
username: MaybeRefOrGetter<string | undefined>,
full: MaybeRefOrGetter<boolean> = false,
): UseQueryReturnType<QuestionsResponse, Error> {
return useQuery({
queryKey: computed(() => teacherQuestionsQueryKey(toValue(username)!, toValue(full))),
queryFn: async () => teacherController.getQuestions(toValue(username)!, toValue(full)),
enabled: () => Boolean(toValue(username)),
});
}
export function useTeacherJoinRequestsQuery(
username: MaybeRefOrGetter<string | undefined>,
classId: MaybeRefOrGetter<string | undefined>,

View file

@ -14,6 +14,7 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue";
import SingleTheme from "@/views/SingleTheme.vue";
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
import authService from "@/services/auth/auth-service";
import { allowRedirect, Redirect } from "@/utils/redirect.ts";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -143,7 +144,11 @@ router.beforeEach(async (to, _from, next) => {
// Verify if user is logged in before accessing certain routes
if (to.meta.requiresAuth) {
if (!authService.isLoggedIn.value && !(await authService.loadUser())) {
next("/login");
const path = to.fullPath;
if (allowRedirect(path)) {
localStorage.setItem(Redirect.AFTER_LOGIN_KEY, path);
}
next(Redirect.LOGIN);
} else {
next();
}

View file

@ -0,0 +1,12 @@
export enum Redirect {
AFTER_LOGIN_KEY = "redirectAfterLogin",
HOME = "/user",
LOGIN = "/login",
ROOT = "/",
}
const NOT_ALLOWED_REDIRECTS = new Set<Redirect>([Redirect.HOME, Redirect.ROOT, Redirect.LOGIN]);
export function allowRedirect(path: string): boolean {
return !NOT_ALLOWED_REDIRECTS.has(path as Redirect);
}

View file

@ -3,6 +3,7 @@
import { useI18n } from "vue-i18n";
import { onMounted, ref, type Ref } from "vue";
import auth from "../services/auth/auth-service.ts";
import { Redirect } from "@/utils/redirect.ts";
const { t } = useI18n();
@ -10,10 +11,20 @@
const errorMessage: Ref<string | null> = ref(null);
async function redirectPage(): Promise<void> {
const redirectUrl = localStorage.getItem(Redirect.AFTER_LOGIN_KEY);
if (redirectUrl) {
localStorage.removeItem(Redirect.AFTER_LOGIN_KEY);
await router.replace(redirectUrl);
} else {
await router.replace(Redirect.HOME);
}
}
onMounted(async () => {
try {
await auth.handleLoginCallback();
await router.replace("/user"); // Redirect to theme page
await redirectPage();
} catch (error) {
errorMessage.value = `${t("loginUnexpectedError")}: ${error}`;
}

View file

@ -28,7 +28,7 @@
alt="Dwengo logo"
style="align-self: center"
/>
<h1>{{ t("homeTitle") }}</h1>
<h1 class="h1">{{ t("homeTitle") }}</h1>
<p class="info">
{{ t("homeIntroduction1") }}
</p>
@ -84,7 +84,10 @@
</div>
</div>
<div class="container_right">
<v-menu open-on-hover>
<v-menu
open-on-hover
open-on-click
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"

View file

@ -1,17 +1,28 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
import auth from "@/services/auth/auth-service.ts";
import { watch } from "vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
const router = useRouter();
watch(
() => auth.isLoggedIn.value,
async (newVal) => {
if (newVal) {
await router.push("/user");
}
},
{ immediate: true },
);
async function loginAsStudent(): Promise<void> {
await auth.loginAs("student");
await auth.loginAs(AccountType.Student);
}
async function loginAsTeacher(): Promise<void> {
await auth.loginAs("teacher");
}
async function performLogout(): Promise<void> {
await auth.logout();
await auth.loginAs(AccountType.Teacher);
}
</script>
@ -65,13 +76,6 @@
</div>
</ul>
</div>
<div v-if="auth.isLoggedIn.value">
<p>
You are currently logged in as {{ auth.authState.user!.profile.name }} ({{ auth.authState.activeRole }})
</p>
<v-btn @click="performLogout">Logout</v-btn>
<v-btn to="/user">home</v-btn>
</div>
</main>
</template>

View file

@ -2,10 +2,11 @@
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import LearningPathsGrid from "@/components/LearningPathsGrid.vue";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { useGetAllLearningPathsByThemeQuery } from "@/queries/learning-paths.ts";
import { useGetAllLearningPathsByThemeAndLanguageQuery } from "@/queries/learning-paths.ts";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useThemeQuery } from "@/queries/themes.ts";
import type { Language } from "@/data-objects/language";
const props = defineProps<{ theme: string }>();
@ -16,7 +17,10 @@
const currentThemeInfo = computed(() => themeQueryResult.data.value?.find((it) => it.key === props.theme));
const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeQuery(() => props.theme);
const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeAndLanguageQuery(
() => props.theme,
() => locale.value as Language,
);
const { t } = useI18n();
const searchFilter = ref("");
@ -31,13 +35,14 @@
</script>
<template>
<div class="container">
<div class="container d-flex flex-column align-items-center justify-center">
<using-query-result :query-result="themeQueryResult">
<h1>{{ currentThemeInfo!!.title }}</h1>
<p>{{ currentThemeInfo!!.description }}</p>
<div class="search-field-container">
<br />
<div class="search-field-container mt-sm-6">
<v-text-field
class="search-field"
class="search-field mx-auto"
:label="t('search')"
append-inner-icon="mdi-magnify"
v-model="searchFilter"
@ -56,13 +61,15 @@
<style scoped>
.search-field-container {
display: block;
margin: 20px;
justify-content: center !important;
}
.search-field {
max-width: 300px;
width: 25%;
min-width: 300px;
}
.container {
padding: 20px;
justify-content: center;
justify-items: center;
}
</style>

View file

@ -14,6 +14,7 @@
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import { useCreateAssignmentMutation } from "@/queries/assignments.ts";
import { useRoute } from "vue-router";
import { AccountType } from "@dwengo-1/common/util/account-types";
const route = useRoute();
const router = useRouter();
@ -23,7 +24,7 @@
onMounted(async () => {
// Redirect student
if (role.value === "student") {
if (role.value === AccountType.Student) {
await router.push("/user");
}
@ -48,7 +49,7 @@
// Disable combobox when learningPath prop is passed
const lpIsSelected = route.query.hruid !== undefined;
const deadline = ref(null);
const deadline = ref(new Date());
const description = ref("");
const groups = ref<string[][]>([]);
@ -86,6 +87,7 @@
title: assignmentTitle.value,
description: description.value,
learningPath: lp || "",
deadline: deadline.value,
language: language.value,
groups: groups.value,
};
@ -96,7 +98,7 @@
<template>
<div class="main-container">
<h1 class="title">{{ t("new-assignment") }}</h1>
<h1 class="h1">{{ t("new-assignment") }}</h1>
<v-card class="form-card">
<v-form
ref="form"

View file

@ -8,9 +8,10 @@
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import { AccountType } from "@dwengo-1/common/util/account-types";
const role = auth.authState.activeRole;
const isTeacher = computed(() => role === "teacher");
const isTeacher = computed(() => role === AccountType.Teacher);
const route = useRoute();
const classId = ref<string>(route.params.classId as string);

View file

@ -22,8 +22,7 @@
) => { groupProgressMap: Map<number, number> };
}>();
const { t, locale } = useI18n();
const language = ref<Language>(locale.value as Language);
const { t } = useI18n();
const learningPath = ref();
// Get the user's username/id
const username = asyncComputed(async () => {
@ -38,7 +37,7 @@
const lpQueryResult = useGetLearningPathQuery(
computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""),
computed(() => language.value),
computed(() => assignmentQueryResult.data.value?.assignment.language as Language),
);
const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true);
@ -100,7 +99,7 @@ language
>
<v-btn
v-if="lpData"
:to="`/learningPath/${lpData.hruid}/${language}/${lpData.startNode.learningobjectHruid}?forGroup=${group?.groupNumber}&assignmentNo=${assignmentId}&classId=${classId}`"
:to="`/learningPath/${lpData.hruid}/${assignmentQueryResult.data.value?.assignment.language}/${lpData.startNode.learningobjectHruid}?forGroup=${group?.groupNumber}&assignmentNo=${assignmentId}&classId=${classId}`"
variant="tonal"
color="primary"
>

View file

@ -19,8 +19,7 @@
) => { groupProgressMap: Map<number, number> };
}>();
const { t, locale } = useI18n();
const language = computed(() => locale.value);
const { t } = useI18n();
const groups = ref();
const learningPath = ref();
@ -29,7 +28,7 @@
// Get learning path object
const lpQueryResult = useGetLearningPathQuery(
computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""),
computed(() => language.value as Language),
computed(() => assignmentQueryResult.data.value?.assignment.language as Language),
);
// Get all the groups withing the assignment
@ -38,9 +37,9 @@
/* Crashes right now cause api data has inexistent hruid TODO: uncomment later and use it in progress bar
Const {groupProgressMap} = props.useGroupsWithProgress(
groups,
learningPath,
language
groups,
learningPath,
language
);
*/
@ -121,7 +120,7 @@ Const {groupProgressMap} = props.useGroupsWithProgress(
>
<v-btn
v-if="lpData"
:to="`/learningPath/${lpData.hruid}/${language}/${lpData.startNode.learningobjectHruid}?assignmentNo=${assignmentId}&classId=${classId}`"
:to="`/learningPath/${lpData.hruid}/${assignmentQueryResult.data.value?.assignment.language}/${lpData.startNode.learningobjectHruid}?assignmentNo=${assignmentId}&classId=${classId}`"
variant="tonal"
color="primary"
>
@ -203,8 +202,8 @@ Const {groupProgressMap} = props.useGroupsWithProgress(
<v-btn
color="primary"
@click="dialog = false"
>Close</v-btn
>
>Close
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>

View file

@ -9,14 +9,16 @@
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
import { asyncComputed } from "@vueuse/core";
import { useDeleteAssignmentMutation } from "@/queries/assignments.ts";
import { AccountType } from "@dwengo-1/common/util/account-types";
import "../../assets/common.css";
const { t } = useI18n();
const { t, locale } = useI18n();
const router = useRouter();
const role = ref(auth.authState.activeRole);
const username = ref<string>("");
const isTeacher = computed(() => role.value === "teacher");
const isTeacher = computed(() => role.value === AccountType.Teacher);
// Fetch and store all the teacher's classes
let classesQueryResults = undefined;
@ -27,30 +29,47 @@
classesQueryResults = useStudentClassesQuery(username, true);
}
//TODO: remove later
const classController = new ClassController();
//TODO: replace by query that fetches all user's assignment
const assignments = asyncComputed(async () => {
const classes = classesQueryResults?.data?.value?.classes;
if (!classes) return [];
const result = await Promise.all(
(classes as ClassDTO[]).map(async (cls) => {
const { assignments } = await classController.getAssignments(cls.id);
return assignments.map((a) => ({
id: a.id,
class: cls,
title: a.title,
description: a.description,
learningPath: a.learningPath,
language: a.language,
groups: a.groups,
}));
}),
);
const assignments = asyncComputed(
async () => {
const classes = classesQueryResults?.data?.value?.classes;
if (!classes) return [];
return result.flat();
}, []);
const result = await Promise.all(
(classes as ClassDTO[]).map(async (cls) => {
const { assignments } = await classController.getAssignments(cls.id);
return assignments.map((a) => ({
id: a.id,
class: cls,
title: a.title,
description: a.description,
learningPath: a.learningPath,
language: a.language,
deadline: a.deadline,
groups: a.groups,
}));
}),
);
// Order the assignments by deadline
return result.flat().sort((a, b) => {
const now = Date.now();
const aTime = new Date(a.deadline).getTime();
const bTime = new Date(b.deadline).getTime();
const aIsPast = aTime < now;
const bIsPast = bTime < now;
if (aIsPast && !bIsPast) return 1;
if (!aIsPast && bIsPast) return -1;
return aTime - bTime;
});
},
[],
{ evaluating: true },
);
async function goToCreateAssignment(): Promise<void> {
await router.push("/assignment/create");
@ -72,6 +91,35 @@
mutate({ cid: clsId, an: num });
}
function formatDate(date?: string | Date): string {
if (!date) return "";
const d = new Date(date);
// Choose locale based on selected language
const currentLocale = locale.value;
return d.toLocaleDateString(currentLocale, {
weekday: "short",
day: "2-digit",
month: "long",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function getDeadlineClass(deadline?: string | Date): string {
if (!deadline) return "";
const date = new Date(deadline);
const now = new Date();
const in24Hours = new Date(now.getTime() + 24 * 60 * 60 * 1000);
if (date.getTime() < now.getTime()) return "deadline-passed";
if (date.getTime() <= in24Hours.getTime()) return "deadline-in24hours";
return "deadline-upcoming";
}
onMounted(async () => {
const user = await auth.loadUser();
username.value = user?.profile?.preferred_username ?? "";
@ -80,7 +128,7 @@
<template>
<div class="assignments-container">
<h1>{{ t("assignments") }}</h1>
<h1 class="h1">{{ t("assignments") }}</h1>
<v-btn
v-if="isTeacher"
@ -107,6 +155,13 @@
{{ assignment.class.displayName }}
</span>
</div>
<div
class="assignment-deadline"
:class="getDeadlineClass(assignment.deadline)"
>
{{ t("deadline") }}:
<span>{{ formatDate(assignment.deadline) }}</span>
</div>
</div>
<div class="spacer"></div>
@ -131,6 +186,13 @@
</v-card>
</v-col>
</v-row>
<v-row v-if="assignments.length === 0">
<v-col cols="12">
<div class="no-assignments">
{{ t("no-assignments") }}
</div>
</v-col>
</v-row>
</v-container>
</div>
</template>
@ -139,18 +201,32 @@
.assignments-container {
width: 100%;
margin: 0 auto;
padding: 2% 4%;
box-sizing: border-box;
}
.center-btn {
display: block;
margin-left: auto;
margin-right: auto;
margin: 0 auto 2rem auto;
font-weight: 600;
background-color: #10ad61;
color: white;
transition: background-color 0.2s;
}
.center-btn:hover {
background-color: #0e6942;
}
.assignment-card {
padding: 1rem;
padding: 1.25rem;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background-color: white;
transition:
transform 0.2s,
box-shadow 0.2s;
}
.assignment-card:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.top-content {
@ -158,6 +234,35 @@
word-break: break-word;
}
.assignment-title {
font-weight: 700;
font-size: 1.4rem;
color: #0e6942;
margin-bottom: 0.3rem;
}
.assignment-class,
.assignment-deadline {
font-size: 0.95rem;
color: #444;
margin-bottom: 0.2rem;
}
.class-name {
font-weight: 600;
color: #097180;
}
.assignment-deadline.deadline-passed {
color: #d32f2f;
font-weight: bold;
}
.assignment-deadline.deadline-in24hours {
color: #f57c00;
font-weight: bold;
}
.spacer {
flex: 1;
}
@ -165,24 +270,14 @@
.button-row {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
gap: 0.75rem;
flex-wrap: wrap;
}
.assignment-title {
font-weight: bold;
font-size: 1.5rem;
margin-bottom: 0.1rem;
word-break: break-word;
}
.assignment-class {
color: #666;
font-size: 0.95rem;
}
.class-name {
font-weight: 500;
color: #333;
.no-assignments {
text-align: center;
font-size: 1.2rem;
color: #777;
padding: 3rem 0;
}
</style>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import { useClassQuery } from "@/queries/classes";
import { defineProps } from "vue";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
const props = defineProps({
classId: String,
});
const classQuery = useClassQuery(props.classId);
</script>
<template>
<using-query-result
:query-result="classQuery"
v-slot="{ data: classResponse }"
>
<span>{{ classResponse?.class.displayName }}</span>
</using-query-result>
</template>

View file

@ -13,6 +13,7 @@
import { useCreateTeacherInvitationMutation } from "@/queries/teacher-invitations";
import type { TeacherInvitationData } from "@dwengo-1/common/interfaces/teacher-invitation";
import { useDisplay } from "vuetify";
import "../../assets/common.css";
const { t } = useI18n();
@ -76,7 +77,7 @@
},
onError: (e) => {
dialog.value = false;
showSnackbar(t("failed") + ": " + e.message, "error");
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
},
);
@ -104,7 +105,7 @@
}
},
onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error");
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
},
);
@ -112,7 +113,7 @@
function sentInvite(): void {
if (!usernameTeacher.value) {
showSnackbar(t("please enter a valid username"), "error");
showSnackbar(t("valid-username"), "error");
return;
}
const data: TeacherInvitationData = {
@ -125,7 +126,7 @@
usernameTeacher.value = "";
},
onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error");
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
});
}
@ -186,7 +187,7 @@
v-slot="classResponse: { data: ClassResponse }"
>
<div>
<h1 class="title">{{ classResponse.data.class.displayName }}</h1>
<h1 class="h1">{{ classResponse.data.class.displayName }}</h1>
<using-query-result
:query-result="getStudents"
v-slot="studentsResponse: { data: StudentsResponse }"
@ -211,16 +212,31 @@
<th class="header"></th>
</tr>
</thead>
<tbody>
<tbody v-if="studentsResponse.data.students.length">
<tr
v-for="s in studentsResponse.data.students as StudentDTO[]"
:key="s.id"
>
<td>{{ s.firstName + " " + s.lastName }}</td>
<td>
{{ s.firstName + " " + s.lastName }}
<v-btn @click="showPopup(s)">{{ t("remove") }}</v-btn>
</td>
<td>
<v-btn @click="showPopup(s)"> {{ t("remove") }} </v-btn>
</tr>
</tbody>
<tbody v-else>
<tr>
<td
colspan="2"
class="empty-message"
>
<v-icon
icon="mdi-information-outline"
size="small"
>
</v-icon>
{{ t("no-students-found") }}
</td>
</tr>
</tbody>
@ -242,7 +258,7 @@
<th class="header">{{ t("accept") + "/" + t("reject") }}</th>
</tr>
</thead>
<tbody>
<tbody v-if="joinRequests.data.joinRequests.length">
<tr
v-for="jr in joinRequests.data.joinRequests as ClassJoinRequestDTO[]"
:key="(jr.class, jr.requester, jr.status)"
@ -287,6 +303,21 @@
</td>
</tr>
</tbody>
<tbody v-else>
<tr>
<td
colspan="2"
class="empty-message"
>
<v-icon
icon="mdi-information-outline"
size="small"
>
</v-icon>
{{ t("no-join-requests-found") }}
</td>
</tr>
</tbody>
</v-table>
</v-col>
</using-query-result>
@ -356,49 +387,6 @@
</main>
</template>
<style scoped>
.header {
font-weight: bold !important;
background-color: #0e6942;
color: white;
padding: 10px;
}
table thead th:first-child {
border-top-left-radius: 10px;
}
.table thead th:last-child {
border-top-right-radius: 10px;
}
.table tbody tr:nth-child(odd) {
background-color: white;
}
.table tbody tr:nth-child(even) {
background-color: #f6faf2;
}
td,
th {
border-bottom: 1px solid #0e6942;
border-top: 1px solid #0e6942;
}
.table {
width: 90%;
padding-top: 10px;
border-collapse: collapse;
}
h1 {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
padding-top: 2%;
font-size: 50px;
}
h2 {
color: #0e6942;
font-size: 30px;
@ -407,6 +395,7 @@
.join {
display: flex;
flex-direction: column;
margin-left: 1%;
gap: 20px;
margin-top: 50px;
}
@ -416,16 +405,7 @@
text-decoration: underline;
}
main {
margin-left: 30px;
}
@media screen and (max-width: 800px) {
h1 {
text-align: center;
padding-left: 0;
}
.join {
text-align: center;
align-items: center;

View file

@ -2,7 +2,7 @@
import { useI18n } from "vue-i18n";
import authState from "@/services/auth/auth-service.ts";
import { computed, onMounted, ref } from "vue";
import { validate, version } from "uuid";
import { useRoute } from "vue-router";
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
import { useCreateJoinRequestMutation, useStudentClassesQuery } from "@/queries/students";
import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
@ -12,8 +12,10 @@
import { useClassStudentsQuery, useClassTeachersQuery } from "@/queries/classes";
import type { StudentsResponse } from "@/controllers/students";
import type { TeachersResponse } from "@/controllers/teachers";
import "../../assets/common.css";
const { t } = useI18n();
const route = useRoute();
// Username of logged in student
const username = ref<string | undefined>(undefined);
@ -37,6 +39,11 @@
} finally {
isLoading.value = false;
}
const queryCode = route.query.code as string | undefined;
if (queryCode) {
code.value = queryCode;
}
});
// Fetch all classes of the logged in student
@ -74,11 +81,15 @@
// The code a student sends in to join a class needs to be formatted as v4 to be valid
// These rules are used to display a message to the user if they use a code that has an invalid format
function codeRegex(value: string): boolean {
return /^[a-zA-Z0-9]{6}$/.test(value);
}
const codeRules = [
(value: string | undefined): string | boolean => {
if (value === undefined || value === "") {
return true;
} else if (value !== undefined && validate(value) && version(value) === 4) {
} else if (codeRegex(value)) {
return true;
}
return t("invalidFormat");
@ -91,7 +102,7 @@
// Function called when a student submits a code to join a class
function submitCode(): void {
// Check if the code is valid
if (code.value !== undefined && validate(code.value) && version(code.value) === 4) {
if (code.value !== undefined && codeRegex(code.value)) {
mutate(
{ username: username.value!, classId: code.value },
{
@ -99,7 +110,7 @@
showSnackbar(t("sent"), "success");
},
onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error");
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
},
);
@ -135,7 +146,7 @@
></v-empty-state>
</div>
<div v-else>
<h1 class="title">{{ t("classes") }}</h1>
<h1 class="h1">{{ t("classes") }}</h1>
<using-query-result
:query-result="classesQuery"
v-slot="classResponse: { data: ClassesResponse }"
@ -161,7 +172,7 @@
<th class="header">{{ t("members") }}</th>
</tr>
</thead>
<tbody>
<tbody v-if="classResponse.data.classes.length">
<tr
v-for="c in classResponse.data.classes as ClassDTO[]"
:key="c.id"
@ -181,6 +192,21 @@
</td>
</tr>
</tbody>
<tbody v-else>
<tr>
<td
colspan="3"
class="empty-message"
>
<v-icon
icon="mdi-information-outline"
size="small"
>
</v-icon>
{{ t("no-classes-found") }}
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
@ -244,7 +270,7 @@
<v-text-field
label="CODE"
v-model="code"
placeholder="XXXXXXXX-XXXX-4XXX-XXXX-XXXXXXXXXXXX"
placeholder="XXXXXX"
:rules="codeRules"
variant="outlined"
></v-text-field>
@ -271,49 +297,6 @@
</main>
</template>
<style scoped>
.header {
font-weight: bold !important;
background-color: #0e6942;
color: white;
padding: 10px;
}
table thead th:first-child {
border-top-left-radius: 10px;
}
.table thead th:last-child {
border-top-right-radius: 10px;
}
.table tbody tr:nth-child(odd) {
background-color: white;
}
.table tbody tr:nth-child(even) {
background-color: #f6faf2;
}
td,
th {
border-bottom: 1px solid #0e6942;
border-top: 1px solid #0e6942;
}
.table {
width: 90%;
padding-top: 10px;
border-collapse: collapse;
}
h1 {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
padding-top: 2%;
font-size: 50px;
}
h2 {
color: #0e6942;
font-size: 30px;
@ -321,6 +304,7 @@
.join {
display: flex;
margin-left: 1%;
flex-direction: column;
gap: 20px;
margin-top: 50px;
@ -331,16 +315,7 @@
text-decoration: underline;
}
main {
margin-left: 30px;
}
@media screen and (max-width: 800px) {
h1 {
text-align: center;
padding-left: 0;
}
.join {
text-align: center;
align-items: center;

View file

@ -8,13 +8,15 @@
import { useTeacherClassesQuery } from "@/queries/teachers";
import type { ClassesResponse } from "@/controllers/classes";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { useClassesQuery, useCreateClassMutation } from "@/queries/classes";
import { useCreateClassMutation } from "@/queries/classes";
import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations";
import {
useRespondTeacherInvitationMutation,
useTeacherInvitationsReceivedQuery,
} from "@/queries/teacher-invitations";
import { useDisplay } from "vuetify";
import "../../assets/common.css";
import ClassDisplay from "@/views/classes/ClassDisplay.vue";
const { t } = useI18n();
@ -40,7 +42,6 @@
// Fetch all classes of the logged in teacher
const classesQuery = useTeacherClassesQuery(username, true);
const allClassesQuery = useClassesQuery();
const { mutate } = useCreateClassMutation();
const getInvitationsQuery = useTeacherInvitationsReceivedQuery(username);
const { mutate: respondToInvitation } = useRespondTeacherInvitationMutation();
@ -69,7 +70,7 @@
await getInvitationsQuery.refetch();
},
onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error");
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
});
}
@ -112,7 +113,7 @@
dialog.value = true;
}
if (!className.value || className.value === "") {
showSnackbar(t("name is mandatory"), "error");
showSnackbar(t("nameIsMandatory"), "error");
}
}
@ -131,10 +132,12 @@
// Show the teacher, copying of the code was a successs
const copied = ref(false);
// Copy the generated code to the clipboard
async function copyToClipboard(): Promise<void> {
await navigator.clipboard.writeText(code.value);
copied.value = true;
async function copyToClipboard(code: string, isDialog = false, isLink = false): Promise<void> {
const content = isLink ? `${window.location.origin}/user/class?code=${code}` : code;
await navigator.clipboard.writeText(content);
copied.value = isDialog;
if (!isDialog) showSnackbar(t("copied"), "white");
}
// Custom breakpoints
@ -162,6 +165,7 @@
// Code display dialog logic
const viewCodeDialog = ref(false);
const selectedCode = ref("");
function openCodeDialog(codeToView: string): void {
selectedCode.value = codeToView;
viewCodeDialog.value = true;
@ -183,7 +187,7 @@
></v-empty-state>
</div>
<div v-else>
<h1 class="title">{{ t("classes") }}</h1>
<h1 class="h1">{{ t("classes") }}</h1>
<using-query-result
:query-result="classesQuery"
v-slot="classesResponse: { data: ClassesResponse }"
@ -212,7 +216,7 @@
<th class="header">{{ t("members") }}</th>
</tr>
</thead>
<tbody>
<tbody v-if="classesResponse.data.classes.length">
<tr
v-for="c in classesResponse.data.classes as ClassDTO[]"
:key="c.id"
@ -223,22 +227,58 @@
variant="text"
>
{{ c.displayName }}
<v-icon end> mdi-menu-right </v-icon>
<v-icon end> mdi-menu-right</v-icon>
</v-btn>
</td>
<td>
<span v-if="!isMdAndDown">{{ c.id }}</span>
<v-row
v-if="!isMdAndDown"
dense
align="center"
no-gutters
>
<v-btn
variant="text"
append-icon="mdi-content-copy"
@click="copyToClipboard(c.id)"
>
{{ c.id }}
</v-btn>
<v-btn
icon
variant="text"
@click="copyToClipboard(c.id, false, true)"
>
<v-icon>mdi-link-variant</v-icon>
</v-btn>
</v-row>
<span
v-else
style="cursor: pointer"
@click="openCodeDialog(c.id)"
><v-icon icon="mdi-eye"></v-icon
></span>
>
<v-icon icon="mdi-eye"></v-icon>
</span>
</td>
<td>{{ c.students.length }}</td>
</tr>
</tbody>
<tbody v-else>
<tr>
<td
colspan="3"
class="empty-message"
>
<v-icon
icon="mdi-information-outline"
size="small"
>
</v-icon>
{{ t("no-classes-found") }}
</td>
</tr>
</tbody>
</v-table>
</v-col>
<v-col
@ -270,8 +310,8 @@
type="submit"
@click="createClass"
block
>{{ t("create") }}</v-btn
>
>{{ t("create") }}
</v-btn>
</v-form>
</v-sheet>
<v-container>
@ -280,14 +320,29 @@
max-width="400px"
>
<v-card>
<v-card-title class="headline">code</v-card-title>
<v-card-title class="headline">{{ t("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>
>
<template #append>
<v-btn
icon
variant="text"
@click="copyToClipboard(code, true)"
>
<v-icon>mdi-content-copy</v-icon>
</v-btn>
<v-btn
icon
variant="text"
@click="copyToClipboard(code, true, true)"
>
<v-icon>mdi-link-variant</v-icon>
</v-btn>
</template>
</v-text-field>
<v-slide-y-transition>
<div
v-if="copied"
@ -318,7 +373,7 @@
</v-container>
</using-query-result>
<h1 class="title">
<h1 class="h1">
{{ t("invitations") }}
</h1>
<v-container
@ -338,20 +393,13 @@
:query-result="getInvitationsQuery"
v-slot="invitationsResponse: { data: TeacherInvitationsResponse }"
>
<using-query-result
:query-result="allClassesQuery"
v-slot="classesResponse: { data: ClassesResponse }"
>
<template v-if="invitationsResponse.data.invitations.length">
<tr
v-for="i in invitationsResponse.data.invitations as TeacherInvitationDTO[]"
:key="i.classId"
>
<td>
{{
(classesResponse.data.classes as ClassDTO[]).filter(
(c) => c.id == i.classId,
)[0].displayName
}}
<ClassDisplay :classId="i.classId" />
</td>
<td>
{{
@ -393,11 +441,27 @@
color="red"
variant="text"
>
</v-btn></div
></span>
</v-btn>
</div>
</span>
</td>
</tr>
</using-query-result>
</template>
<template v-else>
<tr>
<td
colspan="3"
class="empty-message"
>
<v-icon
icon="mdi-information-outline"
size="small"
>
</v-icon>
{{ t("no-invitations-found") }}
</td>
</tr>
</template>
</using-query-result>
</tbody>
</v-table>
@ -420,9 +484,24 @@
<v-text-field
v-model="selectedCode"
readonly
append-inner-icon="mdi-content-copy"
@click:append-inner="copyToClipboard"
></v-text-field>
>
<template #append>
<v-btn
icon
variant="text"
@click="copyToClipboard(selectedCode, true)"
>
<v-icon>mdi-content-copy</v-icon>
</v-btn>
<v-btn
icon
variant="text"
@click="copyToClipboard(selectedCode, true, true)"
>
<v-icon>mdi-link-variant</v-icon>
</v-btn>
</template>
</v-text-field>
<v-slide-y-transition>
<div
v-if="copied"
@ -449,49 +528,6 @@
</main>
</template>
<style scoped>
.header {
font-weight: bold !important;
background-color: #0e6942;
color: white;
padding: 10px;
}
table thead th:first-child {
border-top-left-radius: 10px;
}
.table thead th:last-child {
border-top-right-radius: 10px;
}
.table tbody tr:nth-child(odd) {
background-color: white;
}
.table tbody tr:nth-child(even) {
background-color: #f6faf2;
}
td,
th {
border-bottom: 1px solid #0e6942;
border-top: 1px solid #0e6942;
}
.table {
width: 90%;
padding-top: 10px;
border-collapse: collapse;
}
h1 {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
padding-top: 2%;
font-size: 50px;
}
h2 {
color: #0e6942;
font-size: 30px;
@ -509,16 +545,7 @@
text-decoration: underline;
}
main {
margin-left: 30px;
}
@media screen and (max-width: 850px) {
h1 {
text-align: center;
padding-left: 0;
}
.join {
text-align: center;
align-items: center;
@ -541,10 +568,6 @@
flex-direction: column !important;
}
.table {
width: 100%;
}
.responsive-col {
max-width: 100% !important;
flex-basis: 100% !important;

View file

@ -2,6 +2,7 @@
import authState from "@/services/auth/auth-service.ts";
import TeacherClasses from "./TeacherClasses.vue";
import StudentClasses from "./StudentClasses.vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
// Determine if role is student or teacher to render correct view
const role: string = authState.authState.activeRole!;
@ -9,7 +10,7 @@
<template>
<main>
<TeacherClasses v-if="role === 'teacher'"></TeacherClasses>
<TeacherClasses v-if="role === AccountType.Teacher"></TeacherClasses>
<StudentClasses v-else></StudentClasses>
</main>
</template>

View file

@ -3,6 +3,7 @@
import { useI18n } from "vue-i18n";
import { THEMESITEMS, AGE_TO_THEMES } from "@/utils/constants.ts";
import BrowseThemes from "@/components/BrowseThemes.vue";
import "../../assets/common.css";
const { t, locale } = useI18n();
@ -46,7 +47,7 @@
<template>
<div class="main-container">
<h1 class="title">{{ t("themes") }}</h1>
<h1 class="h1">{{ t("themes") }}</h1>
<v-container class="dropdowns">
<v-select
class="v-select"
@ -77,24 +78,6 @@
</template>
<style scoped>
.main-container {
min-height: 100vh;
min-width: 100vw;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
.title {
max-width: 50rem;
margin-left: 1rem;
margin-top: 1rem;
text-align: center;
display: flex;
justify-content: center;
}
.dropdowns {
display: flex;
justify-content: space-between;
@ -107,12 +90,6 @@
min-width: 100px;
}
@media (max-width: 768px) {
.main-container {
padding: 1rem;
}
}
@media (max-width: 700px) {
.dropdowns {
flex-direction: column;

View file

@ -22,6 +22,7 @@
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import QuestionNotification from "@/components/QuestionNotification.vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
const router = useRouter();
const route = useRoute();
@ -235,8 +236,10 @@
</p>
</template>
</v-list-item>
<v-list-item
v-if="query.classId && query.assignmentNo && authService.authState.activeRole === 'teacher'"
<v-list-itemF
v-if="
query.classId && query.assignmentNo && authService.authState.activeRole === AccountType.Teacher
"
>
<template v-slot:default>
<learning-path-group-selector
@ -245,7 +248,7 @@
v-model="forGroupQueryParam"
/>
</template>
</v-list-item>
</v-list-itemF>
<v-divider></v-divider>
<div v-if="props.learningObjectHruid">
<using-query-result
@ -259,7 +262,9 @@
:title="node.title"
:active="node.key === props.learningObjectHruid"
:key="node.key"
v-if="!node.teacherExclusive || authService.authState.activeRole === 'teacher'"
v-if="
!node.teacherExclusive || authService.authState.activeRole === AccountType.Teacher
"
>
<template v-slot:prepend>
<v-icon
@ -283,10 +288,12 @@
</using-query-result>
</div>
<v-spacer></v-spacer>
<v-list-item v-if="authService.authState.activeRole === 'teacher'">
<v-list-item v-if="authService.authState.activeRole === AccountType.Teacher">
<template v-slot:default>
<v-btn
class="button-in-nav"
width="100%"
:color="COLORS.teacherExclusive"
@click="assign()"
>{{ t("assignLearningPath") }}</v-btn
>
@ -294,7 +301,7 @@
</v-list-item>
<v-list-item>
<div
v-if="authService.authState.activeRole === 'student' && pathIsAssignment"
v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment"
class="assignment-indicator"
>
{{ t("assignmentIndicator") }}
@ -323,7 +330,7 @@
></learning-object-view>
</div>
<div
v-if="authService.authState.activeRole === 'student' && pathIsAssignment"
v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment"
class="question-box"
>
<div class="input-wrapper">

View file

@ -17,32 +17,37 @@
</script>
<template>
<div class="search-field-container">
<learning-path-search-field class="search-field"></learning-path-search-field>
</div>
<div class="search-page-container d-flex flex-column align-items-center justify-center">
<div class="search-field-container">
<learning-path-search-field class="mx-auto" />
</div>
<using-query-result
:query-result="searchQueryResults"
v-slot="{ data }: { data: LearningPath[] }"
>
<learning-paths-grid :learning-paths="data"></learning-paths-grid>
</using-query-result>
<div content="empty-state-container">
<v-empty-state
<using-query-result
:query-result="searchQueryResults"
v-slot="{ data }: { data: LearningPath[] }"
>
<learning-paths-grid :learning-paths="data" />
</using-query-result>
<div
v-if="!query"
icon="mdi-magnify"
:title="t('enterSearchTerm')"
:text="t('enterSearchTermDescription')"
></v-empty-state>
class="empty-state-container"
>
<v-empty-state
icon="mdi-magnify"
:title="t('enterSearchTerm')"
:text="t('enterSearchTermDescription')"
/>
</div>
</div>
</template>
<style scoped>
.search-field-container {
display: block;
margin: 20px;
.search-page-container {
padding-top: 40px;
padding-bottom: 40px;
}
.search-field {
max-width: 300px;
.search-field-container {
justify-content: center !important;
}
</style>

View file

@ -8,8 +8,9 @@
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";
import { AccountType } from "@dwengo-1/common/util/account-types";
const _isStudent = computed(() => authService.authState.activeRole === "student");
const _isStudent = computed(() => authService.authState.activeRole === AccountType.Student);
const props = defineProps<{
hruid: string;

View file

@ -11,6 +11,7 @@
import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import { useI18n } from "vue-i18n";
import { AccountType } from "@dwengo-1/common/util/account-types";
const { t } = useI18n();
@ -31,7 +32,7 @@
mutate: submitSolution,
} = useCreateSubmissionMutation();
const isStudent = computed(() => authService.authState.activeRole === "student");
const isStudent = computed(() => authService.authState.activeRole === AccountType.Student);
const isSubmitDisabled = computed(() => {
if (!props.submissionData || props.submissions === undefined) {

View file

@ -5,7 +5,7 @@ describe("AssignmentController Tests", () => {
let controller: AssignmentController;
beforeEach(() => {
controller = new AssignmentController("8764b861-90a6-42e5-9732-c0d9eb2f55f9"); // Example class ID
controller = new AssignmentController("X2J9QT"); // Example class ID (class01)
});
it("should fetch all assignments", async () => {

View file

@ -3,7 +3,7 @@ import { GroupController } from "../../src/controllers/groups";
describe("Test controller groups", () => {
it("Get groups", async () => {
const classId = "8764b861-90a6-42e5-9732-c0d9eb2f55f9";
const classId = "X2J9QT"; // Class01
const assignmentNumber = 21000;
const controller = new GroupController(classId, assignmentNumber);

View file

@ -15,7 +15,7 @@ describe("Test controller learning paths", () => {
});
it("Can get learning path by id", async () => {
const data = await controller.getAllByTheme("kiks");
const data = await controller.getAllByThemeAndLanguage("kiks", Language.Dutch);
expect(data).to.have.length.greaterThan(0);
});
});

View file

@ -5,7 +5,7 @@ import { Language } from "../../src/data-objects/language";
describe("Test controller submissions", () => {
it("Get submission by number", async () => {
const hruid = "id03";
const classId = "8764b861-90a6-42e5-9732-c0d9eb2f55f9";
const classId = "X2J9QT"; // Class01
const controller = new SubmissionController(hruid);
const data = await controller.getByNumber(Language.English, 1, classId, 1, 1, 1);