Merge branch '43-overzicht-vragen-bij-opdracht-leerpad' into feat/leerpad-vragen

This commit is contained in:
Timo De Meyst 2025-04-23 20:33:40 +02:00
commit 1edbe219a6
179 changed files with 5197 additions and 3023 deletions

View file

@ -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>

View file

@ -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>

View file

@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<main></main>
</template>
<style scoped></style>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -3,8 +3,8 @@
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import { computed, type ComputedRef, ref } from "vue";
import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts";
import { useRoute } from "vue-router";
import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue";
import { useRoute, useRouter } from "vue-router";
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
import { useI18n } from "vue-i18n";
import LearningPathSearchField from "@/components/LearningPathSearchField.vue";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
@ -12,33 +12,47 @@
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import authService from "@/services/auth/auth-service.ts";
import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts";
import { useStudentAssignmentsQuery } from "@/queries/students";
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import { watch } from "vue";
import LearningPathGroupSelector from "@/views/learning-paths/LearningPathGroupSelector.vue";
import { useQuestionsQuery } from "@/queries/questions";
import type { QuestionsResponse } from "@/controllers/questions";
import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content";
import QandA from "@/components/QandA.vue";
import type { QuestionDTO } from "@dwengo-1/common/interfaces/question";
import { useStudentAssignmentsQuery } from "@/queries/students";
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import { watch } from "vue";
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const props = defineProps<{ hruid: string; language: Language; learningObjectHruid?: string }>();
const props = defineProps<{
hruid: string;
language: Language;
learningObjectHruid?: string;
}>();
interface Personalization {
forStudent?: string;
interface LearningPathPageQuery {
forGroup?: string;
assignmentNo?: string;
classId?: string;
}
const personalization = computed(() => {
if (route.query.forStudent || route.query.forGroup) {
const query = computed(() => route.query as LearningPathPageQuery);
const forGroup = computed(() => {
if (query.value.forGroup && query.value.assignmentNo && query.value.classId) {
return {
forStudent: route.query.forStudent,
forGroup: route.query.forGroup,
} as Personalization;
forGroup: parseInt(query.value.forGroup),
assignmentNo: parseInt(query.value.assignmentNo),
classId: query.value.classId,
};
}
return {
forStudent: authService.authState.user?.profile?.preferred_username,
} as Personalization;
return undefined;
});
const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, personalization);
const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, forGroup);
const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data);
@ -63,6 +77,17 @@ import { watch } from "vue";
return currentIndex < nodesList.value?.length ? nodesList.value?.[currentIndex - 1] : undefined;
});
const getQuestionsQuery = useQuestionsQuery(
computed(
() =>
({
language: currentNode.value?.language,
hruid: currentNode.value?.learningobjectHruid,
version: currentNode.value?.version,
}) as LearningObjectIdentifierDTO,
),
);
const navigationDrawerShown = ref(true);
function isLearningObjectCompleted(learningObject: LearningObject): boolean {
@ -102,6 +127,25 @@ import { watch } from "vue";
return "notCompleted";
}
const forGroupQueryParam = computed<number | undefined>({
get: () => route.query.forGroup,
set: async (value: number | undefined) => {
const query = structuredClone(route.query);
query.forGroup = value;
await router.push({ query });
},
});
async function assign(): Promise<void> {
await router.push({
path: "/assignment/create",
query: {
hruid: props.hruid,
language: props.language,
},
});
}
//TODO: berekenen of het een assignment is voor de student werkt nog niet zoals het hoort...
const studentAssignmentsQuery = useStudentAssignmentsQuery(authService.authState.user?.profile?.preferred_username);
@ -154,64 +198,87 @@ import { watch } from "vue";
v-model="navigationDrawerShown"
:width="350"
>
<v-list-item>
<template v-slot:title>
<div class="learning-path-title">{{ learningPath.data.title }}</div>
</template>
<template v-slot:subtitle>
<div>{{ learningPath.data.description }}</div>
</template>
</v-list-item>
<v-list-item>
<template v-slot:subtitle>
<p>
<v-icon
:color="COLORS.notCompleted"
:icon="ICONS.notCompleted"
></v-icon>
{{ t("legendNotCompletedYet") }}
</p>
<p>
<v-icon
:color="COLORS.completed"
:icon="ICONS.completed"
></v-icon>
{{ t("legendCompleted") }}
</p>
<p>
<v-icon
:color="COLORS.teacherExclusive"
:icon="ICONS.teacherExclusive"
></v-icon>
{{ t("legendTeacherExclusive") }}
</p>
</template>
</v-list-item>
<v-divider></v-divider>
<div v-if="props.learningObjectHruid">
<using-query-result
:query-result="learningObjectListQueryResult"
v-slot="learningObjects: { data: LearningObject[] }"
>
<template v-for="node in learningObjects.data">
<v-list-item
link
:to="{ path: node.key, query: route.query }"
:title="node.title"
:active="node.key === props.learningObjectHruid"
:key="node.key"
v-if="!node.teacherExclusive || authService.authState.activeRole === 'teacher'"
>
<template v-slot:prepend>
<v-icon
:color="COLORS[getNavItemState(node)]"
:icon="ICONS[getNavItemState(node)]"
></v-icon>
</template>
<template v-slot:append> {{ node.estimatedTime }}' </template>
</v-list-item>
<div class="d-flex flex-column h-100">
<v-list-item>
<template v-slot:title>
<div class="learning-path-title">{{ learningPath.data.title }}</div>
</template>
</using-query-result>
<template v-slot:subtitle>
<div>{{ learningPath.data.description }}</div>
</template>
</v-list-item>
<v-list-item>
<template v-slot:subtitle>
<p>
<v-icon
:color="COLORS.notCompleted"
:icon="ICONS.notCompleted"
></v-icon>
{{ t("legendNotCompletedYet") }}
</p>
<p>
<v-icon
:color="COLORS.completed"
:icon="ICONS.completed"
></v-icon>
{{ t("legendCompleted") }}
</p>
<p>
<v-icon
:color="COLORS.teacherExclusive"
:icon="ICONS.teacherExclusive"
></v-icon>
{{ t("legendTeacherExclusive") }}
</p>
</template>
</v-list-item>
<v-list-item
v-if="query.classId && query.assignmentNo && authService.authState.activeRole === 'teacher'"
>
<template v-slot:default>
<learning-path-group-selector
:class-id="query.classId"
:assignment-number="parseInt(query.assignmentNo)"
v-model="forGroupQueryParam"
/>
</template>
</v-list-item>
<v-divider></v-divider>
<div v-if="props.learningObjectHruid">
<using-query-result
:query-result="learningObjectListQueryResult"
v-slot="learningObjects: { data: LearningObject[] }"
>
<template v-for="node in learningObjects.data">
<v-list-item
link
:to="{ path: node.key, query: route.query }"
:title="node.title"
:active="node.key === props.learningObjectHruid"
:key="node.key"
v-if="!node.teacherExclusive || authService.authState.activeRole === 'teacher'"
>
<template v-slot:prepend>
<v-icon
:color="COLORS[getNavItemState(node)]"
:icon="ICONS[getNavItemState(node)]"
></v-icon>
</template>
<template v-slot:append> {{ node.estimatedTime }}' </template>
</v-list-item>
</template>
</using-query-result>
</div>
<v-spacer></v-spacer>
<v-list-item v-if="authService.authState.activeRole === 'teacher'">
<template v-slot:default>
<v-btn
class="button-in-nav"
@click="assign()"
>{{ t("assignLearningPath") }}</v-btn
>
</template>
</v-list-item>
</div>
<v-divider></v-divider>
<div v-if="true" class="assignment-indicator">
@ -229,12 +296,15 @@ import { watch } from "vue";
<learning-path-search-field></learning-path-search-field>
</div>
</div>
<learning-object-view
:hruid="currentNode.learningobjectHruid"
:language="currentNode.language"
:version="currentNode.version"
v-if="currentNode"
></learning-object-view>
<div class="learning-object-view-container">
<learning-object-view
:hruid="currentNode.learningobjectHruid"
:language="currentNode.language"
:version="currentNode.version"
:group="forGroup"
v-if="currentNode"
></learning-object-view>
</div>
<div class="question-box">
<div class="input-wrapper">
<input
@ -267,6 +337,12 @@ import { watch } from "vue";
{{ t("next") }}
</v-btn>
</div>
<using-query-result
:query-result="getQuestionsQuery"
v-slot="questionsResponse: { data: QuestionsResponse }"
>
<QandA :questions="questionsResponse.data.questions as QuestionDTO[] ?? []" />
</using-query-result>
</using-query-result>
</template>
@ -284,6 +360,11 @@ import { watch } from "vue";
display: flex;
justify-content: space-between;
}
.learning-object-view-container {
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
}
.navigation-buttons-container {
padding: 20px;
display: flex;

View file

@ -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>

View file

@ -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);
},
};

View 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;
}

View file

@ -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);
}

View file

@ -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);
});
},
};

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1 @@
export type SubmissionData = (string | number | object | null)[];

View file

@ -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>

View file

@ -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>

View file

@ -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>