Merge branch 'dev' into feat/discussions

This commit is contained in:
Tibo De Peuter 2025-05-19 19:59:10 +02:00
commit e28a57754f
Signed by: tdpeuter
GPG key ID: 38297DE43F75FFE2
44 changed files with 2270 additions and 767 deletions

View file

@ -11,8 +11,7 @@ import {
import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment';
import { requireFields } from './error-helper.js';
import { BadRequestException } from '../exceptions/bad-request-exception.js';
import { Assignment } from '../entities/assignments/assignment.entity.js';
import { EntityDTO } from '@mikro-orm/core';
import { FALLBACK_LANG } from '../config.js';
function getAssignmentParams(req: Request): { classid: string; assignmentNumber: number; full: boolean } {
const classid = req.params.classid;
@ -38,14 +37,19 @@ export async function getAllAssignmentsHandler(req: Request, res: Response): Pro
export async function createAssignmentHandler(req: Request, res: Response): Promise<void> {
const classid = req.params.classid;
const description = req.body.description;
const language = req.body.language;
const learningPath = req.body.learningPath;
const description = req.body.description || '';
const language = req.body.language || FALLBACK_LANG;
const learningPath = req.body.learningPath || '';
const title = req.body.title;
requireFields({ description, language, learningPath, title });
requireFields({ title });
const assignmentData = req.body as AssignmentDTO;
const assignmentData = {
description: description,
language: language,
learningPath: learningPath,
title: title,
} as AssignmentDTO;
const assignment = await createAssignment(classid, assignmentData);
res.json({ assignment });
@ -62,7 +66,7 @@ export async function getAssignmentHandler(req: Request, res: Response): Promise
export async function putAssignmentHandler(req: Request, res: Response): Promise<void> {
const { classid, assignmentNumber } = getAssignmentParams(req);
const assignmentData = req.body as Partial<EntityDTO<Assignment>>;
const assignmentData = req.body as Partial<AssignmentDTO>;
const assignment = await putAssignment(classid, assignmentNumber, assignmentData);
res.json({ assignment });

View file

@ -7,6 +7,7 @@ import {
getJoinRequestsByClass,
getStudentsByTeacher,
getTeacher,
getTeacherAssignments,
updateClassJoinRequestStatus,
} from '../services/teachers.js';
import { requireFields } from './error-helper.js';
@ -59,6 +60,16 @@ export async function getTeacherClassHandler(req: Request, res: Response): Promi
res.json({ classes });
}
export async function getTeacherAssignmentsHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username;
const full = req.query.full === 'true';
requireFields({ username });
const assignments = await getTeacherAssignments(username, full);
res.json({ assignments });
}
export async function getTeacherStudentHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username;
const full = req.query.full === 'true';

View file

@ -7,7 +7,7 @@ export class AssignmentRepository extends DwengoEntityRepository<Assignment> {
return this.findOne({ within: within, id: id }, { populate: ['groups', 'groups.members'] });
}
public async findByClassIdAndAssignmentId(withinClass: string, id: number): Promise<Assignment | null> {
return this.findOne({ within: { classId: withinClass }, id: id });
return this.findOne({ within: { classId: withinClass }, id: id }, { populate: ['groups', 'groups.members'] });
}
public async findAllByResponsibleTeacher(teacherUsername: string): Promise<Assignment[]> {
return this.findAll({
@ -20,6 +20,7 @@ export class AssignmentRepository extends DwengoEntityRepository<Assignment> {
},
},
},
populate: ['groups', 'groups.members'],
});
}
public async findAllAssignmentsInClass(within: Class): Promise<Assignment[]> {

View file

@ -28,4 +28,9 @@ export class GroupRepository extends DwengoEntityRepository<Group> {
groupNumber: groupNumber,
});
}
public async deleteAllByAssignment(assignment: Assignment): Promise<void> {
return this.deleteAllWhere({
assignment: assignment,
});
}
}

View file

@ -16,4 +16,13 @@ export abstract class DwengoEntityRepository<T extends object> extends EntityRep
await em.flush();
}
}
public async deleteAllWhere(query: FilterQuery<T>): Promise<void> {
const toDelete = await this.find(query);
const em = this.getEntityManager();
if (toDelete) {
em.remove(toDelete);
await em.flush();
}
}
}

View file

@ -20,7 +20,7 @@ export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO {
description: assignment.description,
learningPath: assignment.learningPathHruid,
language: assignment.learningPathLanguage,
deadline: assignment.deadline ?? new Date(),
deadline: assignment.deadline ?? null,
groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)),
};
}

View file

@ -7,10 +7,16 @@ import { authorize } from './auth-checks.js';
import { FALLBACK_LANG } from '../../../config.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
import { fetchClass } from '../../../services/classes.js';
import { fetchGroup } from '../../../services/groups.js';
import { requireFields } from '../../../controllers/error-helper.js';
import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission';
export const onlyAllowSubmitter = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { submitter: string }).submitter === auth.username
);
export const onlyAllowSubmitter = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const submittedFor = (req.body as SubmissionDTO).submitter.username;
const submittedBy = auth.username;
return submittedFor === submittedBy;
});
export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { hruid: lohruid, id: submissionNumber } = req.params;
@ -26,3 +32,17 @@ export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: Authentic
return submission.onBehalfOf.members.map(mapToUsername).includes(auth.username);
});
export const onlyAllowIfHasAccessToSubmissionFromParams = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { classId, assignmentId, groupId } = req.query;
requireFields({ classId, assignmentId, groupId });
if (auth.accountType === AccountType.Teacher) {
const cls = await fetchClass(classId as string);
return cls.teachers.map(mapToUsername).includes(auth.username);
}
const group = await fetchGroup(classId as string, Number(assignmentId as string), Number(groupId as string));
return group.members.map(mapToUsername).includes(auth.username);
});

View file

@ -1,10 +1,14 @@
import express from 'express';
import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js';
import { onlyAllowIfHasAccessToSubmission, onlyAllowSubmitter } from '../middleware/auth/checks/submission-checks.js';
import { adminOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js';
import {
onlyAllowIfHasAccessToSubmission,
onlyAllowIfHasAccessToSubmissionFromParams,
onlyAllowSubmitter,
} from '../middleware/auth/checks/submission-checks.js';
import { studentsOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router({ mergeParams: true });
router.get('/', adminOnly, getSubmissionsHandler);
router.get('/', onlyAllowIfHasAccessToSubmissionFromParams, getSubmissionsHandler);
router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler);

View file

@ -4,6 +4,7 @@ import {
deleteTeacherHandler,
getAllTeachersHandler,
getStudentJoinRequestHandler,
getTeacherAssignmentsHandler,
getTeacherClassHandler,
getTeacherHandler,
getTeacherStudentHandler,
@ -28,6 +29,8 @@ router.get('/:username/classes', preventImpersonation, getTeacherClassHandler);
router.get('/:username/students', preventImpersonation, getTeacherStudentHandler);
router.get(`/:username/assignments`, getTeacherAssignmentsHandler);
router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler);
router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler);

View file

@ -14,10 +14,13 @@ import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submissi
import { fetchClass } from './classes.js';
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission';
import { EntityDTO } from '@mikro-orm/core';
import { EntityDTO, ForeignKeyConstraintViolationException } from '@mikro-orm/core';
import { putObject } from './service-helper.js';
import { fetchStudents } from './students.js';
import { ServerErrorException } from '../exceptions/server-error-exception.js';
import { BadRequestException } from '../exceptions/bad-request-exception.js';
import { ConflictException } from '../exceptions/conflict-exception.js';
import { PostgreSqlExceptionConverter } from '@mikro-orm/postgresql';
export async function fetchAssignment(classid: string, assignmentNumber: number): Promise<Assignment> {
const classRepository = getClassRepository();
@ -59,7 +62,7 @@ export async function createAssignment(classid: string, assignmentData: Assignme
if (assignmentData.groups) {
/*
For some reason when trying to add groups, it does not work when using the original assignment variable.
For some reason when trying to add groups, it does not work when using the original assignment variable.
The assignment needs to be refetched in order for it to work.
*/
@ -93,10 +96,36 @@ export async function getAssignment(classid: string, id: number): Promise<Assign
return mapToAssignmentDTO(assignment);
}
export async function putAssignment(classid: string, id: number, assignmentData: Partial<EntityDTO<Assignment>>): Promise<AssignmentDTO> {
function hasDuplicates(arr: string[]): boolean {
return new Set(arr).size !== arr.length;
}
export async function putAssignment(classid: string, id: number, assignmentData: Partial<AssignmentDTO>): Promise<AssignmentDTO> {
const assignment = await fetchAssignment(classid, id);
await putObject<Assignment>(assignment, assignmentData, getAssignmentRepository());
if (assignmentData.groups) {
if (hasDuplicates(assignmentData.groups.flat() as string[])) {
throw new BadRequestException('Student can only be in one group');
}
const studentLists = await Promise.all((assignmentData.groups as string[][]).map(async (group) => await fetchStudents(group)));
const groupRepository = getGroupRepository();
await groupRepository.deleteAllByAssignment(assignment);
await Promise.all(
studentLists.map(async (students) => {
const newGroup = groupRepository.create({
assignment: assignment,
members: students,
});
await groupRepository.save(newGroup);
})
);
delete assignmentData.groups;
}
await putObject<Assignment>(assignment, assignmentData as Partial<EntityDTO<Assignment>>, getAssignmentRepository());
return mapToAssignmentDTO(assignment);
}
@ -106,7 +135,16 @@ export async function deleteAssignment(classid: string, id: number): Promise<Ass
const cls = await fetchClass(classid);
const assignmentRepository = getAssignmentRepository();
await assignmentRepository.deleteByClassAndId(cls, id);
try {
await assignmentRepository.deleteByClassAndId(cls, id);
} catch (e: unknown) {
if (e instanceof ForeignKeyConstraintViolationException || e instanceof PostgreSqlExceptionConverter) {
throw new ConflictException('Cannot delete assigment with questions or submissions');
} else {
throw e;
}
}
return mapToAssignmentDTO(assignment);
}

View file

@ -70,6 +70,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
// Convert the learning object notes as retrieved from the database into the expected response format-
const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor);
const nodesActuallyOnPath = traverseLearningPath(convertedNodes);
return {
_id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
__order: order,
@ -79,8 +81,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
image: image,
title: learningPath.title,
nodes: convertedNodes,
num_nodes: learningPath.nodes.length,
num_nodes_left: convertedNodes.filter((it) => !it.done).length,
num_nodes: nodesActuallyOnPath.length,
num_nodes_left: nodesActuallyOnPath.filter((it) => !it.done).length,
keywords: keywords.join(' '),
target_ages: targetAges,
max_age: Math.max(...targetAges),
@ -180,7 +182,6 @@ function convertTransition(
return {
_id: String(index), // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
default: false, // We don't work with default transitions but retain this for backwards compatibility.
condition: transition.condition,
next: {
_id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility.
hruid: transition.next.learningObjectHruid,
@ -191,6 +192,29 @@ function convertTransition(
}
}
/**
* Start from the start node and then always take the first transition until there are no transitions anymore.
* Returns the traversed nodes as an array. (This effectively filters outs nodes that cannot be reached.)
*/
function traverseLearningPath(nodes: LearningObjectNode[]): LearningObjectNode[] {
const traversedNodes: LearningObjectNode[] = [];
let currentNode = nodes.find((it) => it.start_node);
while (currentNode) {
traversedNodes.push(currentNode);
const next = currentNode.transitions[0]?.next;
if (next) {
currentNode = nodes.find((it) => it.learningobject_hruid === next.hruid && it.language === next.language && it.version === next.version);
} else {
currentNode = undefined;
}
}
return traversedNodes;
}
/**
* Service providing access to data about learning paths from the database.
*/

View file

@ -10,7 +10,7 @@ import { mapToClassDTO } from '../interfaces/class.js';
import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js';
import { mapToStudent, mapToStudentDTO } from '../interfaces/student.js';
import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js';
import { getAllAssignments } from './assignments.js';
import { fetchAssignment } from './assignments.js';
import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js';
import { mapToStudentRequest, mapToStudentRequestDTO } from '../interfaces/student-request.js';
import { Student } from '../entities/users/student.entity.js';
@ -26,6 +26,7 @@ import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-requ
import { ConflictException } from '../exceptions/conflict-exception.js';
import { Submission } from '../entities/assignments/submission.entity.js';
import { mapToUsername } from '../interfaces/user.js';
import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js';
export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> {
const studentRepository = getStudentRepository();
@ -50,8 +51,7 @@ export async function fetchStudent(username: string): Promise<Student> {
}
export async function fetchStudents(usernames: string[]): Promise<Student[]> {
const members = await Promise.all(usernames.map(async (username) => await fetchStudent(username)));
return members;
return await Promise.all(usernames.map(async (username) => await fetchStudent(username)));
}
export async function getStudent(username: string): Promise<StudentDTO> {
@ -102,10 +102,14 @@ export async function getStudentClasses(username: string, full: boolean): Promis
export async function getStudentAssignments(username: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> {
const student = await fetchStudent(username);
const classRepository = getClassRepository();
const classes = await classRepository.findByStudent(student);
const groupRepository = getGroupRepository();
const groups = await groupRepository.findAllGroupsWithStudent(student);
const assignments = await Promise.all(groups.map(async (group) => await fetchAssignment(group.assignment.within.classId!, group.assignment.id!)));
return (await Promise.all(classes.map(async (cls) => await getAllAssignments(cls.classId!, full)))).flat();
if (full) {
return assignments.map(mapToAssignmentDTO);
}
return assignments.map(mapToAssignmentDTOId);
}
export async function getStudentGroups(username: string, full: boolean): Promise<GroupDTO[] | GroupDTOId[]> {

View file

@ -1,4 +1,4 @@
import { getClassJoinRequestRepository, getClassRepository, getTeacherRepository } from '../data/repositories.js';
import { getAssignmentRepository, getClassJoinRequestRepository, getClassRepository, getTeacherRepository } from '../data/repositories.js';
import { mapToClassDTO } from '../interfaces/class.js';
import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js';
import { Teacher } from '../entities/users/teacher.entity.js';
@ -18,6 +18,8 @@ import { StudentDTO } from '@dwengo-1/common/interfaces/student';
import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request';
import { ClassStatus } from '@dwengo-1/common/util/class-join-request';
import { ConflictException } from '../exceptions/conflict-exception.js';
import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment';
import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js';
import { mapToUsername } from '../interfaces/user.js';
export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> {
@ -91,6 +93,17 @@ export async function getClassesByTeacher(username: string, full: boolean): Prom
return classes.map((cls) => cls.id);
}
export async function getTeacherAssignments(username: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> {
const assignmentRepository = getAssignmentRepository();
const assignments = await assignmentRepository.findAllByResponsibleTeacher(username);
if (full) {
return assignments.map(mapToAssignmentDTO);
}
return assignments.map(mapToAssignmentDTOId);
}
export async function getStudentsByTeacher(username: string, full: boolean): Promise<StudentDTO[] | string[]> {
const classes: ClassDTO[] = await fetchClassesByTeacher(username);

View file

@ -14,6 +14,7 @@ import {
getStudentRequestsHandler,
deleteClassJoinRequestHandler,
getStudentRequestHandler,
getStudentAssignmentsHandler,
} from '../../src/controllers/students.js';
import { getDireStraits, getNoordkaap, getTheDoors, TEST_STUDENTS } from '../test_assets/users/students.testdata.js';
import { NotFoundException } from '../../src/exceptions/not-found-exception.js';
@ -150,6 +151,19 @@ describe('Student controllers', () => {
expect(result.groups).to.have.length.greaterThan(0);
});
it('Student assignments', async () => {
const group = getTestGroup01();
const member = group.members[0];
req = { params: { username: member.username }, query: {} };
await getStudentAssignmentsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignments: expect.anything() }));
const result = jsonMock.mock.lastCall?.[0];
expect(result.assignments).to.have.length.greaterThan(0);
});
it('Student submissions', async () => {
const submission = getSubmission01();
req = { params: { username: submission.submitter.username }, query: { full: 'true' } };

View file

@ -51,7 +51,7 @@ export function makeTestGroups(em: EntityManager): Group[] {
*/
group05 = em.create(Group, {
assignment: getAssignment04(),
groupNumber: 21001,
groupNumber: 21006,
members: [getNoordkaap(), getDireStraits()],
});

View file

@ -5,8 +5,22 @@ import { makeTestTeachers } from '../tests/test_assets/users/teachers.testdata';
import { makeTestLearningObjects } from '../tests/test_assets/content/learning-objects.testdata';
import { makeTestLearningPaths } from '../tests/test_assets/content/learning-paths.testdata';
import { makeTestClasses } from '../tests/test_assets/classes/classes.testdata';
import { makeTestAssignemnts } from '../tests/test_assets/assignments/assignments.testdata';
import { getTestGroup01, getTestGroup02, getTestGroup03, getTestGroup04, makeTestGroups } from '../tests/test_assets/assignments/groups.testdata';
import {
getAssignment01,
getAssignment02,
getAssignment04,
getConditionalPathAssignment,
makeTestAssignemnts,
} from '../tests/test_assets/assignments/assignments.testdata';
import {
getGroup1ConditionalLearningPath,
getTestGroup01,
getTestGroup02,
getTestGroup03,
getTestGroup04,
getTestGroup05,
makeTestGroups,
} from '../tests/test_assets/assignments/groups.testdata';
import { Group } from '../src/entities/assignments/group.entity';
import { makeTestTeacherInvitations } from '../tests/test_assets/classes/teacher-invitations.testdata';
import { makeTestClassJoinRequests } from '../tests/test_assets/classes/class-join-requests.testdata';
@ -36,8 +50,14 @@ export async function seedORM(orm: MikroORM): Promise<void> {
const groups = makeTestGroups(em);
assignments[0].groups = new Collection<Group>([getTestGroup01(), getTestGroup02(), getTestGroup03()]);
assignments[1].groups = new Collection<Group>([getTestGroup04()]);
let assignment = getAssignment01();
assignment.groups = new Collection<Group>([getTestGroup01(), getTestGroup02(), getTestGroup03()]);
assignment = getAssignment02();
assignment.groups = new Collection<Group>([getTestGroup04()]);
assignment = getAssignment04();
assignment.groups = new Collection<Group>([getTestGroup05()]);
assignment = getConditionalPathAssignment();
assignment.groups = new Collection<Group>([getGroup1ConditionalLearningPath()]);
const teacherInvitations = makeTestTeacherInvitations(em);
const classJoinRequests = makeTestClassJoinRequests(em);

View file

@ -7,7 +7,7 @@ export interface AssignmentDTO {
description: string;
learningPath: string;
language: string;
deadline: Date;
deadline: Date | null;
groups: GroupDTO[] | string[][];
}

View file

@ -18,10 +18,19 @@
font-size: 1.1rem;
}
.top-right-btn {
position: absolute;
right: 2%;
color: red;
.top-buttons-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
position: relative;
}
.right-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
color: #0e6942;
}
.group-section {

View file

@ -0,0 +1,52 @@
<template>
<v-table class="table">
<thead>
<tr
v-for="name in columns"
:key="column"
>
<th class="header">{{ name }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="([item1, item2, item3], index) in listItems"
:key="index"
>
<td></td>
<td>
<v-btn
:to="`/class/${c.id}`"
variant="text"
>
{{ c.displayName }}
<v-icon end> mdi-menu-right </v-icon>
</v-btn>
</td>
<td>
<span v-if="!isMdAndDown">{{ c.id }}</span>
<span
v-else
style="cursor: pointer"
@click="openCodeDialog(c.id)"
><v-icon icon="mdi-eye"></v-icon
></span>
</td>
<td>{{ c.students.length }}</td>
</tr>
</tbody>
</v-table>
</template>
<script>
export default {
name: "columnList",
props: {
items: {
type: Array,
required: true,
},
},
};
</script>

View file

@ -0,0 +1,47 @@
<script setup lang="ts">
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import { computed } from "vue";
import type { Language } from "@/data-objects/language.ts";
import { calculateProgress } from "@/utils/assignment-utils.ts";
const props = defineProps<{
groupNumber: number;
learningPath: string;
language: Language;
assignmentId: number;
classId: string;
}>();
const query = useGetLearningPathQuery(
() => props.learningPath,
() => props.language,
() => ({
forGroup: props.groupNumber,
assignmentNo: props.assignmentId,
classId: props.classId,
}),
);
const progress = computed(() => {
if (!query.data.value) return 0;
return calculateProgress(query.data.value);
});
const progressColor = computed(() => {
if (progress.value < 50) return "error";
if (progress.value < 80) return "warning";
return "success";
});
</script>
<template>
<v-progress-linear
:model-value="progress"
:color="progressColor"
height="25"
>
<template v-slot:default="{ value }">
<strong>{{ Math.ceil(value) }}%</strong>
</template>
</v-progress-linear>
</template>

View file

@ -0,0 +1,50 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { useAssignmentSubmissionsQuery } from "@/queries/assignments.ts";
import type { SubmissionsResponse } from "@/controllers/submissions.ts";
import { watch } from "vue";
const props = defineProps<{
group: object;
assignmentId: number;
classId: string;
goToGroupSubmissionLink: (groupNo: number) => void;
}>();
const emit = defineEmits<(e: "update:hasSubmission", hasSubmission: boolean) => void>();
const { t } = useI18n();
const submissionsQuery = useAssignmentSubmissionsQuery(
() => props.classId,
() => props.assignmentId,
() => props.group.originalGroupNo,
() => true,
);
watch(
() => submissionsQuery.data.value,
(data) => {
if (data) {
emit("update:hasSubmission", data.submissions.length > 0);
}
},
{ immediate: true },
);
</script>
<template>
<using-query-result
:query-result="submissionsQuery"
v-slot="{ data }: { data: SubmissionsResponse }"
>
<v-btn
:color="data?.submissions?.length > 0 ? 'green' : 'red'"
variant="text"
:to="data.submissions.length > 0 ? goToGroupSubmissionLink(props.group.groupNo) : undefined"
:disabled="data.submissions.length === 0"
>
{{ data.submissions.length > 0 ? t("submission") : t("noSubmissionsYet") }}
</v-btn>
</using-query-result>
</template>

View file

@ -148,7 +148,8 @@
</template>
<template v-slot:default="{ isActive }">
<v-card :title="t('logoutVerification')">
<v-card>
<v-card-title class="logout-verification-title">{{ t("logoutVerification") }}</v-card-title>
<v-card-actions>
<v-spacer></v-spacer>
@ -297,6 +298,13 @@
margin-left: 10px;
}
.logout-verification-title {
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
text-overflow: unset;
}
@media (max-width: 700px) {
.menu {
display: none;

View file

@ -1,18 +1,42 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { deadlineRules } from "@/utils/assignment-rules.ts";
import { useI18n } from "vue-i18n";
const emit = defineEmits<(e: "update:deadline", value: Date) => void>();
const { t } = useI18n();
const emit = defineEmits<(e: "update:deadline", value: Date | null) => void>();
const props = defineProps<{ deadline: Date | null }>();
const datetime = ref("");
datetime.value = props.deadline ? new Date(props.deadline).toISOString().slice(0, 16) : "";
// Watch the datetime value and emit the update
watch(datetime, (val) => {
const newDate = new Date(val);
if (!isNaN(newDate.getTime())) {
emit("update:deadline", newDate);
} else {
emit("update:deadline", null);
}
});
const deadlineRules = [
(value: string): string | boolean => {
const selectedDateTime = new Date(value);
const now = new Date();
if (isNaN(selectedDateTime.getTime())) {
return t("deadline-invalid");
}
if (selectedDateTime <= now) {
return t("deadline-past");
}
return true;
},
];
</script>
<template>

View file

@ -1,75 +1,680 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import type { StudentsResponse } from "@/controllers/students.ts";
import { useClassStudentsQuery } from "@/queries/classes.ts";
import { useClassStudentsQuery } from "@/queries/classes";
const props = defineProps<{
classId: string | undefined;
groups: string[][];
groups: object[];
}>();
const emit = defineEmits(["groupCreated"]);
const emit = defineEmits(["close", "groupsUpdated", "done"]);
const { t } = useI18n();
const selectedStudents = ref([]);
const studentQueryResult = useClassStudentsQuery(() => props.classId, true);
function filterStudents(data: StudentsResponse): { title: string; value: string }[] {
const students = data.students;
const studentsInGroups = props.groups.flat();
return students
?.map((st) => ({
title: `${st.firstName} ${st.lastName}`,
value: st.username,
}))
.filter((student) => !studentsInGroups.includes(student.value));
interface StudentItem {
username: string;
fullName: string;
}
function createGroup(): void {
if (selectedStudents.value.length) {
// Extract only usernames (student.value)
const usernames = selectedStudents.value.map((student) => student.value);
emit("groupCreated", usernames);
selectedStudents.value = []; // Reset selection after creating group
const { data: studentsData } = useClassStudentsQuery(() => props.classId, true);
// Dialog states for group editing
const activeDialog = ref<"random" | "dragdrop" | null>(null);
// Drag state for the drag and drop
const draggedItem = ref<{ groupIndex: number; studentIndex: number } | null>(null);
const currentGroups = ref<StudentItem[][]>([]);
const unassignedStudents = ref<StudentItem[]>([]);
const allStudents = ref<StudentItem[]>([]);
// Random groups state
const groupSize = ref(1);
const randomGroupsPreview = ref<StudentItem[][]>([]);
// Initialize data
watch(
() => [studentsData.value, props.groups],
([studentsVal, existingGroups]) => {
if (!studentsVal) return;
// Initialize all students
allStudents.value = studentsVal.students.map((s) => ({
username: s.username,
fullName: `${s.firstName} ${s.lastName}`,
}));
// Initialize groups if they exist
if (existingGroups && existingGroups.length > 0) {
currentGroups.value = existingGroups.map((group) =>
group.members.map((member) => ({
username: member.username,
fullName: `${member.firstName} ${member.lastName}`,
})),
);
const assignedUsernames = new Set(
existingGroups.flatMap((g) => g.members.map((m: StudentItem) => m.username)),
);
unassignedStudents.value = allStudents.value.filter((s) => !assignedUsernames.has(s.username));
} else {
currentGroups.value = [];
unassignedStudents.value = [...allStudents.value];
}
randomGroupsPreview.value = [...currentGroups.value];
},
{ immediate: true },
);
/** Random groups functions */
function generateRandomGroups(): void {
if (groupSize.value < 1) return;
// Shuffle students
const shuffled = [...allStudents.value].sort(() => Math.random() - 0.5);
// Create new groups
const newGroups: StudentItem[][] = [];
const groupCount = Math.ceil(shuffled.length / groupSize.value);
for (let i = 0; i < groupCount; i++) {
newGroups.push([]);
}
// Distribute students
shuffled.forEach((student, index) => {
const groupIndex = index % groupCount;
newGroups[groupIndex].push(student);
});
randomGroupsPreview.value = newGroups;
}
function saveRandomGroups(): void {
emit(
"groupsUpdated",
randomGroupsPreview.value.map((g) => g.map((s) => s.username)),
);
activeDialog.value = null;
emit("done");
emit("close");
}
function addNewGroup(): void {
currentGroups.value.push([]);
}
function removeGroup(index: number): void {
// Move students back to unassigned
unassignedStudents.value.push(...currentGroups.value[index]);
currentGroups.value.splice(index, 1);
}
/** Drag and drop functions */
// Touch state interface
interface TouchState {
isDragging: boolean;
startX: number;
startY: number;
currentGroupIndex: number;
currentStudentIndex: number;
element: HTMLElement | null;
clone: HTMLElement | null;
originalRect: DOMRect | null;
hasMoved: boolean;
}
const touchState = ref<TouchState>({
isDragging: false,
startX: 0,
startY: 0,
currentGroupIndex: -1,
currentStudentIndex: -1,
element: null,
clone: null,
originalRect: null,
hasMoved: false,
});
function handleTouchStart(event: TouchEvent, groupIndex: number, studentIndex: number): void {
if (event.touches.length > 1) return;
const touch = event.touches[0];
const target = event.target as HTMLElement;
// Target the chip directly instead of the draggable container
const chip = target.closest(".v-chip") as HTMLElement;
if (!chip) return;
// Get the chip's position relative to the viewport
const rect = chip.getBoundingClientRect();
touchState.value = {
isDragging: true,
startX: touch.clientX,
startY: touch.clientY,
currentGroupIndex: groupIndex,
currentStudentIndex: studentIndex,
element: chip,
clone: null,
originalRect: rect,
hasMoved: false,
};
// Clone only the chip
const clone = chip.cloneNode(true) as HTMLElement;
clone.classList.add("drag-clone");
clone.style.position = "fixed";
clone.style.zIndex = "10000";
clone.style.opacity = "0.9";
clone.style.pointerEvents = "none";
clone.style.width = `${rect.width}px`;
clone.style.height = `${rect.height}px`;
clone.style.left = `${rect.left}px`;
clone.style.top = `${rect.top}px`;
clone.style.transform = "scale(1.05)";
clone.style.boxShadow = "0 4px 8px rgba(0,0,0,0.3)";
clone.style.transition = "transform 0.1s";
// Ensure the clone has the same chip styling
clone.style.backgroundColor = getComputedStyle(chip).backgroundColor;
clone.style.color = getComputedStyle(chip).color;
clone.style.borderRadius = getComputedStyle(chip).borderRadius;
clone.style.padding = getComputedStyle(chip).padding;
clone.style.margin = "0"; // Remove any margin
document.body.appendChild(clone);
touchState.value.clone = clone;
chip.style.visibility = "hidden";
event.preventDefault();
event.stopPropagation();
}
function handleTouchMove(event: TouchEvent): void {
if (!touchState.value.isDragging || !touchState.value.clone || event.touches.length > 1) return;
const touch = event.touches[0];
const clone = touchState.value.clone;
const dx = Math.abs(touch.clientX - touchState.value.startX);
const dy = Math.abs(touch.clientY - touchState.value.startY);
if (dx > 5 || dy > 5) {
touchState.value.hasMoved = true;
}
clone.style.left = `${touch.clientX - clone.offsetWidth / 2}px`;
clone.style.top = `${touch.clientY - clone.offsetHeight / 2}px`;
document.querySelectorAll(".group-box").forEach((el) => {
el.classList.remove("highlight");
});
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
const dropTarget = elements.find((el) => el.classList.contains("group-box"));
if (dropTarget) {
dropTarget.classList.add("highlight");
}
event.preventDefault();
event.stopPropagation();
}
function handleTouchEnd(event: TouchEvent): void {
if (!touchState.value.isDragging) return;
const { currentGroupIndex, currentStudentIndex, clone, element, hasMoved } = touchState.value;
document.querySelectorAll(".group-box").forEach((el) => {
el.classList.remove("highlight");
});
if (clone?.parentNode) {
clone.parentNode.removeChild(clone);
}
if (element) {
element.style.visibility = "visible";
}
if (hasMoved && event.changedTouches.length > 0) {
const touch = event.changedTouches[0];
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
const dropTarget = elements.find((el) => el.classList.contains("group-box"));
if (dropTarget) {
const groupBoxes = document.querySelectorAll(".group-box");
const targetGroupIndex = Array.from(groupBoxes).indexOf(dropTarget);
if (targetGroupIndex !== currentGroupIndex) {
const sourceArray =
currentGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[currentGroupIndex];
const targetArray =
targetGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[targetGroupIndex];
if (sourceArray && targetArray) {
const [movedStudent] = sourceArray.splice(currentStudentIndex, 1);
targetArray.push(movedStudent);
}
}
}
}
touchState.value = {
isDragging: false,
startX: 0,
startY: 0,
currentGroupIndex: -1,
currentStudentIndex: -1,
element: null,
clone: null,
originalRect: null,
hasMoved: false,
};
event.preventDefault();
event.stopPropagation();
}
function handleDragStart(event: DragEvent, groupIndex: number, studentIndex: number): void {
draggedItem.value = { groupIndex, studentIndex };
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", "");
}
}
function handleDragOver(e: DragEvent, _: number): void {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "move";
}
}
function handleDrop(e: DragEvent, targetGroupIndex: number, targetStudentIndex?: number): void {
e.preventDefault();
if (!draggedItem.value) return;
const { groupIndex: sourceGroupIndex, studentIndex: sourceStudentIndex } = draggedItem.value;
const sourceArray = sourceGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[sourceGroupIndex];
const targetArray = targetGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[targetGroupIndex];
const [movedStudent] = sourceArray.splice(sourceStudentIndex, 1);
if (targetStudentIndex !== undefined) {
targetArray.splice(targetStudentIndex, 0, movedStudent);
} else {
targetArray.push(movedStudent);
}
draggedItem.value = null;
}
function saveDragDrop(): void {
emit(
"groupsUpdated",
currentGroups.value
.filter((g) => g.length > 0) // Filter out empty groups
.map((g) => g.map((s) => s.username)),
);
activeDialog.value = null;
emit("done");
emit("close");
}
const showGroupsPreview = computed(() => currentGroups.value.length > 0 || unassignedStudents.value.length > 0);
function removeStudent(groupIndex: number, student: StudentItem): void {
const group = currentGroups.value[groupIndex];
currentGroups.value[groupIndex] = group.filter((s) => s.username !== student.username);
unassignedStudents.value.push(student);
}
</script>
<template>
<using-query-result
:query-result="studentQueryResult"
v-slot="{ data }: { data: StudentsResponse }"
>
<h3>{{ t("create-groups") }}</h3>
<v-card-text>
<v-combobox
v-model="selectedStudents"
:items="filterStudents(data)"
item-title="title"
item-value="value"
:label="t('choose-students')"
variant="outlined"
clearable
multiple
hide-details
density="compact"
chips
append-inner-icon="mdi-magnify"
></v-combobox>
<v-card class="pa-4 minimal-card">
<!-- Current groups and unassigned students Preview -->
<div
v-if="showGroupsPreview"
class="mb-6"
>
<h3 class="mb-2">{{ t("current-groups") }}</h3>
<div>
<div class="d-flex flex-wrap">
<label>{{ currentGroups.length }}</label>
</div>
</div>
</div>
<v-row
justify="center"
class="mb-4"
>
<v-btn
@click="createGroup"
color="primary"
class="mt-2"
size="small"
@click="activeDialog = 'random'"
prepend-icon="mdi-shuffle"
>
{{ t("create-group") }}
{{ t("random-grouping") }}
</v-btn>
</v-card-text>
</using-query-result>
<v-btn
color="secondary"
class="ml-4"
@click="activeDialog = 'dragdrop'"
prepend-icon="mdi-drag"
>
{{ t("drag-and-drop") }}
</v-btn>
</v-row>
<!-- Random Groups selection Dialog -->
<v-dialog
:model-value="activeDialog === 'random'"
@update:model-value="(val) => (val ? (activeDialog = 'random') : (activeDialog = null))"
max-width="600"
>
<v-card class="custom-dialog">
<v-card-title class="dialog-title">{{ t("auto-generate-groups") }}</v-card-title>
<v-card-text>
<v-row align="center">
<v-col cols="6">
<v-text-field
v-model.number="groupSize"
type="number"
min="1"
:max="allStudents.length"
:label="t('group-size-label')"
dense
/>
</v-col>
<v-col cols="6">
<v-btn
color="primary"
@click="generateRandomGroups"
:disabled="groupSize < 1 || groupSize > allStudents.length"
block
>
{{ t("generate-groups") }}
</v-btn>
</v-col>
</v-row>
<div class="mt-4">
<div class="d-flex justify-space-between align-center mb-2">
<strong>{{ t("preview") }}</strong>
<span class="text-caption"> {{ randomGroupsPreview.length }} {{ t("groups") }} </span>
</div>
<v-expansion-panels>
<v-expansion-panel
v-for="(group, index) in randomGroupsPreview"
:key="'random-preview-' + index"
>
<v-expansion-panel-title>
{{ t("group") }} {{ index + 1 }} ({{ group.length }} {{ t("members") }})
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-chip
v-for="student in group"
:key="student.username"
class="ma-1"
>
{{ student.fullName }}
</v-chip>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-card-text>
<v-card-actions class="dialog-actions">
<v-spacer />
<v-btn
text
@click="activeDialog = null"
>{{ t("cancel") }}</v-btn
>
<v-btn
color="success"
@click="saveRandomGroups"
:disabled="randomGroupsPreview.length === 0"
>
{{ t("save") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Drag and Drop Dialog -->
<v-dialog
:model-value="activeDialog === 'dragdrop'"
@update:model-value="(val) => (val ? (activeDialog = 'dragdrop') : (activeDialog = null))"
max-width="900"
>
<v-card class="custom-dialog">
<v-card-title class="dialog-title d-flex justify-space-between align-center">
<div>{{ t("drag-and-drop") }}</div>
<v-btn
color="primary"
small
@click="addNewGroup"
>+</v-btn
>
</v-card-title>
<v-card-text>
<v-row>
<!-- Groups Column -->
<v-col
cols="12"
md="8"
>
<div
v-if="currentGroups.length === 0"
class="text-center py-4"
>
<div>
<v-icon
icon="mdi-information-outline"
size="small"
/>
{{ t("currently-no-groups") }}
</div>
</div>
<template v-else>
<div
v-for="(group, groupIndex) in currentGroups"
:key="groupIndex"
class="mb-4"
@dragover.prevent="handleDragOver($event, groupIndex)"
@drop="handleDrop($event, groupIndex)"
>
<div class="d-flex justify-space-between align-center mb-2">
<strong>{{ t("group") }} {{ groupIndex + 1 }}</strong>
<v-btn
icon
small
color="error"
@click="removeGroup(groupIndex)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</div>
<div class="group-box pa-2">
<div
v-for="(student, studentIndex) in group"
:key="student.username"
class="draggable-item ma-1"
draggable="true"
@touchstart="handleTouchStart($event, groupIndex, studentIndex)"
@touchmove="handleTouchMove($event)"
@touchend="handleTouchEnd($event)"
@dragstart="handleDragStart($event, groupIndex, studentIndex)"
@dragover.prevent="handleDragOver($event, groupIndex)"
@drop="handleDrop($event, groupIndex, studentIndex)"
>
<v-chip
close
@click:close="removeStudent(groupIndex, student)"
>
{{ student.fullName }}
</v-chip>
</div>
</div>
</div>
</template>
</v-col>
<!-- Unassigned Students Column -->
<v-col
cols="12"
md="4"
@dragover.prevent="handleDragOver($event, -1)"
@drop="handleDrop($event, -1)"
>
<div class="mb-2">
<strong>{{ t("unassigned") }}</strong>
<span class="text-caption ml-2">({{ unassignedStudents.length }})</span>
</div>
<div class="group-box pa-2">
<div
v-for="(student, studentIndex) in unassignedStudents"
:key="student.username"
class="draggable-item ma-1"
draggable="true"
@touchstart="handleTouchStart($event, -1, studentIndex)"
@touchmove="handleTouchMove($event)"
@touchend="handleTouchEnd($event)"
@dragstart="handleDragStart($event, -1, studentIndex)"
@dragover.prevent="handleDragOver($event, -1)"
@drop="handleDrop($event, -1, studentIndex)"
>
<v-chip>{{ student.fullName }}</v-chip>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
@click="activeDialog = null"
>{{ t("cancel") }}</v-btn
>
<v-btn
color="primary"
@click="saveDragDrop"
>
{{ t("save") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<style scoped></style>
<style scoped>
.group-box {
min-height: 100px;
max-height: 200px;
overflow-y: auto;
background-color: #fafafa;
border-radius: 4px;
transition: all 0.2s;
}
.group-box.highlight {
background-color: #e3f2fd;
border: 2px dashed #2196f3;
}
.v-expansion-panel-text {
max-height: 200px;
overflow-y: auto;
}
.drag-clone {
z-index: 10000;
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
transition: transform 0.1s;
will-change: transform;
pointer-events: none;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background-color: inherit;
}
.draggable-item {
display: inline-block;
}
.draggable-item .v-chip[style*="hidden"] {
visibility: hidden;
display: inline-block;
}
.custom-dialog {
border-radius: 16px;
padding: 24px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.dialog-title {
color: #00796b; /* teal-like green */
font-weight: bold;
font-size: 1.25rem;
margin-bottom: 16px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.v-btn.custom-green {
background-color: #43a047;
color: white;
}
.v-btn.custom-green:hover {
background-color: #388e3c;
}
.v-btn.custom-blue {
background-color: #1e88e5;
color: white;
}
.v-btn.custom-blue:hover {
background-color: #1565c0;
}
.v-btn.cancel-button {
background-color: #e0f2f1;
color: #00695c;
}
.minimal-card {
box-shadow: none; /* remove card shadow */
border: none; /* remove border */
background-color: transparent; /* make background transparent */
padding: 0; /* reduce padding */
margin-bottom: 1rem; /* keep some spacing below */
}
/* Optionally, keep some padding only around buttons */
.minimal-card > .v-row {
padding: 1rem 0; /* give vertical padding around buttons */
}
</style>

View file

@ -25,7 +25,7 @@ export class AssignmentController extends BaseController {
return this.get<AssignmentResponse>(`/${num}`);
}
async createAssignment(data: AssignmentDTO): Promise<AssignmentResponse> {
async createAssignment(data: Partial<AssignmentDTO>): Promise<AssignmentResponse> {
return this.post<AssignmentResponse>(`/`, data);
}

View file

@ -2,6 +2,7 @@ import { BaseController } from "@/controllers/base-controller.ts";
import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts";
import type { ClassesResponse } from "@/controllers/classes.ts";
import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher";
import type { AssignmentsResponse } from "./assignments";
export interface TeachersResponse {
teachers: TeacherDTO[] | string[];
@ -35,6 +36,10 @@ export class TeacherController extends BaseController {
return this.get<ClassesResponse>(`/${username}/classes`, { full });
}
async getAssignments(username: string, full = true): Promise<AssignmentsResponse> {
return this.get<AssignmentsResponse>(`/${username}/assignments`, { full });
}
async getStudents(username: string, full = false): Promise<StudentsResponse> {
return this.get<StudentsResponse>(`/${username}/students`, { full });
}

View file

@ -105,7 +105,6 @@
"assignLearningPath": "Als Aufgabe geben",
"group": "Gruppe",
"description": "Beschreibung",
"no-submission": "keine vorlage",
"submission": "Einreichung",
"progress": "Fortschritte",
"remove": "entfernen",
@ -167,6 +166,22 @@
"targetAgesMandatory": "Zielalter müssen angegeben werden.",
"hintRemoveIfUnconditionalTransition": "(entfernen, wenn dies ein bedingungsloser Übergang sein soll)",
"hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt",
"title-required": "Titel darf nicht leer sein.",
"class-required": "Du musst eine Klasse auswählen.",
"deadline-invalid": "Ungültiges Datum oder Uhrzeit.",
"deadline-past": "Die Frist muss in der Zukunft liegen.",
"lp-required": "Du musst einen Lernpfad auswählen.",
"lp-invalid": "Der ausgewählte Lernpfad existiert nicht.",
"currently-no-groups": "Es gibt keine Gruppen für diese Aufgabe.",
"random-grouping": "Gruppen zufällig erstellen",
"drag-and-drop": "Gruppen manuell erstellen",
"generate-groups": "erzeugen",
"auto-generate-groups": "Gruppen gleicher Größe erstellen",
"preview": "Vorschau",
"current-groups": "Aktuelle Gruppen",
"group-size-label": "Gruppengröße",
"save": "Speichern",
"unassigned": "Nicht zugewiesen",
"questions": "Fragen",
"view-questions": "Fragen anzeigen auf ",
"question-input-placeholder": "Ihre Frage...",

View file

@ -104,7 +104,6 @@
"assignLearningPath": "assign",
"group": "Group",
"description": "Description",
"no-submission": "no submission",
"submission": "Submission",
"progress": "Progress",
"created": "created",
@ -122,6 +121,7 @@
"invite": "invite",
"assignmentIndicator": "ASSIGNMENT",
"searchAllLearningPathsTitle": "Search all learning paths",
"not-in-group-message": "You are not part of a group yet",
"searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths.",
"no-students-found": "This class has no students.",
"no-invitations-found": "You have no pending invitations.",
@ -167,6 +167,22 @@
"targetAgesMandatory": "Target ages must be specified.",
"hintRemoveIfUnconditionalTransition": "(remove this if this should be an unconditional transition)",
"hintKeywordsSeparatedBySpaces": "Keywords separated by spaces",
"title-required": "Title cannot be empty.",
"class-required": "You must select a class.",
"deadline-invalid": "Invalid date or time.",
"deadline-past": "The deadline must be in the future.",
"lp-required": "You must select a learning path.",
"lp-invalid": "The selected learning path doesn't exist.",
"currently-no-groups": "There are no groups for this assignment.",
"random-grouping": "Randomly create groups",
"drag-and-drop": "Manually create groups",
"generate-groups": "generate",
"auto-generate-groups": "Create groups of equal size",
"preview": "Preview",
"current-groups": "Current groups",
"group-size-label": "Group size",
"save": "Save",
"unassigned": "Unassigned",
"questions": "questions",
"view-questions": "View questions in ",
"question-input-placeholder": "Your question...",

View file

@ -88,7 +88,7 @@
"deny": "refuser",
"sent": "envoyé",
"failed": "échoué",
"wrong": "quelque chose n'a pas fonctionné",
"wrong": "Il y a une erreur",
"created": "créé",
"callbackLoading": "Vous serez connecté...",
"loginUnexpectedError": "La connexion a échoué",
@ -98,14 +98,13 @@
"groupSubmissions": "Soumissions de ce groupe",
"taskCompleted": "Tâche terminée.",
"submittedBy": "Soumis par",
"timestamp": "Horodatage",
"timestamp": "Date et heure",
"loadSubmission": "Charger",
"noSubmissionsYet": "Pas encore de soumissions.",
"viewAsGroup": "Voir la progression du groupe...",
"assignLearningPath": "donner comme tâche",
"group": "Groupe",
"description": "Description",
"no-submission": "aucune soumission",
"submission": "Soumission",
"progress": "Progrès",
"remove": "supprimer",
@ -168,6 +167,22 @@
"targetAgesMandatory": "Les âges cibles doivent être spécifiés.",
"hintRemoveIfUnconditionalTransition": "(supprimer ceci sil sagit dune transition inconditionnelle)",
"hintKeywordsSeparatedBySpaces": "Mots-clés séparés par des espaces",
"title-required": "Le titre ne peut pas être vide.",
"class-required": "Vous devez sélectionner une classe.",
"deadline-invalid": "Date ou heure invalide.",
"deadline-past": "La date limite doit être dans le futur.",
"lp-required": "Vous devez sélectionner un parcours d'apprentissage.",
"lp-invalid": "Le parcours d'apprentissage sélectionné n'existe pas.",
"currently-no-groups": "Il ny a pas de groupes pour cette tâche.",
"random-grouping": "Créer des groupes aléatoirement",
"drag-and-drop": "Créer des groupes manuellement",
"generate-groups": "générer",
"auto-generate-groups": "Créer des groupes de taille égale",
"preview": "Aperçu",
"current-groups": "Groupes actuels",
"group-size-label": "Taille des groupes",
"save": "Enregistrer",
"unassigned": "Non assigné",
"questions": "Questions",
"view-questions": "Voir les questions dans ",
"question-input-placeholder": "Votre question...",

View file

@ -105,7 +105,6 @@
"assignLearningPath": "Als opdracht geven",
"group": "Groep",
"description": "Beschrijving",
"no-submission": "geen indiening",
"submission": "Indiening",
"progress": "Vooruitgang",
"remove": "verwijder",
@ -167,6 +166,22 @@
"targetAgesMandatory": "Doelleeftijden moeten worden opgegeven.",
"hintRemoveIfUnconditionalTransition": "(verwijder dit voor onvoorwaardelijke overgangen)",
"hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties",
"title-required": "Titel mag niet leeg zijn.",
"class-required": "Je moet een klas selecteren.",
"deadline-invalid": "Ongeldige datum of tijd.",
"deadline-past": "De deadline moet in de toekomst liggen.",
"lp-required": "Je moet een leerpad selecteren.",
"lp-invalid": "Het geselecteerde leerpad bestaat niet.",
"currently-no-groups": "Er zijn geen groepen voor deze opdracht.",
"random-grouping": "Groepeer willekeurig",
"drag-and-drop": "Stel groepen handmatig samen",
"generate-groups": "genereren",
"auto-generate-groups": "Maak groepen van gelijke grootte",
"preview": "Voorbeeld",
"current-groups": "Huidige groepen",
"group-size-label": "Grootte van groepen",
"save": "Opslaan",
"unassigned": "Niet toegewezen",
"questions": "vragen",
"view-questions": "Bekijk vragen in ",
"question-input-placeholder": "Uw vraag...",

View file

@ -117,7 +117,7 @@ export function useAssignmentQuery(
export function useCreateAssignmentMutation(): UseMutationReturnType<
AssignmentResponse,
Error,
{ cid: string; data: AssignmentDTO },
{ cid: string; data: Partial<AssignmentDTO> },
unknown
> {
const queryClient = useQueryClient();
@ -181,7 +181,7 @@ export function useAssignmentSubmissionsQuery(
return useQuery({
queryKey: computed(() => assignmentSubmissionsQueryKey(cid!, an!, f)),
queryFn: async () => new AssignmentController(cid!).getSubmissions(gn!, f),
queryFn: async () => new AssignmentController(cid!).getSubmissions(an!, f),
enabled: () => checkEnabled(cid, an, gn),
});
}

View file

@ -12,6 +12,7 @@ import type { ClassesResponse } from "@/controllers/classes.ts";
import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts";
import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher";
import { studentJoinRequestQueryKey, studentJoinRequestsQueryKey } from "@/queries/students.ts";
import type { AssignmentsResponse } from "@/controllers/assignments";
const teacherController = new TeacherController();
@ -28,6 +29,10 @@ function teacherClassesQueryKey(username: string, full: boolean): [string, strin
return ["teacher-classes", username, full];
}
function teacherAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] {
return ["teacher-assignments", username, full];
}
function teacherStudentsQueryKey(username: string, full: boolean): [string, string, boolean] {
return ["teacher-students", username, full];
}
@ -64,6 +69,17 @@ export function useTeacherClassesQuery(
});
}
export function useTeacherAssignmentsQuery(
username: MaybeRefOrGetter<string | undefined>,
full: MaybeRefOrGetter<boolean> = false,
): UseQueryReturnType<AssignmentsResponse, Error> {
return useQuery({
queryKey: computed(() => teacherAssignmentsQueryKey(toValue(username)!, toValue(full))),
queryFn: async () => teacherController.getAssignments(toValue(username)!, toValue(full)),
enabled: () => Boolean(toValue(username)),
});
}
export function useTeacherStudentsQuery(
username: MaybeRefOrGetter<string | undefined>,
full: MaybeRefOrGetter<boolean> = false,

View file

@ -1,76 +0,0 @@
/**
* Validation rule for the assignment title.
*
* Ensures that the title is not empty.
*/
export const assignmentTitleRules = [
(value: string): string | boolean => {
if (value?.length >= 1) {
return true;
} // Title must not be empty
return "Title cannot be empty.";
},
];
/**
* Validation rule for the learning path selection.
*
* Ensures that a valid learning path is selected.
*/
export const learningPathRules = [
(value: { hruid: string; title: string }): string | boolean => {
if (value && value.hruid) {
return true; // Valid if hruid is present
}
return "You must select a learning path.";
},
];
/**
* Validation rule for the classes selection.
*
* Ensures that at least one class is selected.
*/
export const classRules = [
(value: string): string | boolean => {
if (value) {
return true;
}
return "You must select at least one class.";
},
];
/**
* Validation rule for the deadline field.
*
* Ensures that a valid deadline is selected and is in the future.
*/
export const deadlineRules = [
(value: string): string | boolean => {
if (!value) {
return "You must set a deadline.";
}
const selectedDateTime = new Date(value);
const now = new Date();
if (isNaN(selectedDateTime.getTime())) {
return "Invalid date or time.";
}
if (selectedDateTime <= now) {
return "The deadline must be in the future.";
}
return true;
},
];
export const descriptionRules = [
(value: string): string | boolean => {
if (!value || value.trim() === "") {
return "Description cannot be empty.";
}
return true;
},
];

View file

@ -0,0 +1,5 @@
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
export function calculateProgress(lp: LearningPath): number {
return ((lp.amountOfNodes - lp.amountOfNodesLeft) / lp.amountOfNodes) * 100;
}

View file

@ -1,19 +1,15 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { computed, onMounted, ref, watch } from "vue";
import GroupSelector from "@/components/assignments/GroupSelector.vue";
import { assignmentTitleRules, classRules, descriptionRules, learningPathRules } from "@/utils/assignment-rules.ts";
import DeadlineSelector from "@/components/assignments/DeadlineSelector.vue";
import auth from "@/services/auth/auth-service.ts";
import { useTeacherClassesQuery } from "@/queries/teachers.ts";
import { useRouter } from "vue-router";
import { useRouter, useRoute } from "vue-router";
import { useGetAllLearningPaths } from "@/queries/learning-paths.ts";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import type { ClassesResponse } from "@/controllers/classes.ts";
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import { useCreateAssignmentMutation } from "@/queries/assignments.ts";
import { useRoute } from "vue-router";
import { AccountType } from "@dwengo-1/common/util/account-types";
const route = useRoute();
@ -23,12 +19,9 @@
const username = ref<string>("");
onMounted(async () => {
// Redirect student
if (role.value === AccountType.Student) {
await router.push("/user");
}
// Get the user's username
const user = await auth.loadUser();
username.value = user?.profile?.preferred_username ?? "";
});
@ -36,32 +29,25 @@
const language = computed(() => locale.value);
const form = ref();
//Fetch all learning paths
const learningPathsQueryResults = useGetAllLearningPaths(language);
// Fetch and store all the teacher's classes
const classesQueryResults = useTeacherClassesQuery(username, true);
const selectedClass = ref(undefined);
const assignmentTitle = ref("");
const selectedLearningPath = ref(route.query.hruid || undefined);
// Disable combobox when learningPath prop is passed
const lpIsSelected = route.query.hruid !== undefined;
const deadline = ref(new Date());
const description = ref("");
const groups = ref<string[][]>([]);
const selectedLearningPath = ref<LearningPath | undefined>(undefined);
const lpIsSelected = ref(false);
// New group is added to the list
function addGroupToList(students: string[]): void {
if (students.length) {
groups.value = [...groups.value, students];
watch(learningPathsQueryResults.data, (data) => {
const hruidFromRoute = route.query.hruid?.toString();
if (!hruidFromRoute || !data) return;
// Verify if the hruid given in the url is valid before accepting it
const matchedLP = data.find((lp) => lp.hruid === hruidFromRoute);
if (matchedLP) {
selectedLearningPath.value = matchedLP;
lpIsSelected.value = true;
}
}
watch(selectedClass, () => {
groups.value = [];
});
const { mutate, data, isSuccess } = useCreateAssignmentMutation();
@ -76,134 +62,144 @@
const { valid } = await form.value.validate();
if (!valid) return;
let lp = selectedLearningPath.value;
if (!lpIsSelected) {
lp = selectedLearningPath.value?.hruid;
const lp = lpIsSelected.value ? route.query.hruid?.toString() : selectedLearningPath.value?.hruid;
if (!lp) {
return;
}
const assignmentDTO: AssignmentDTO = {
id: 0,
within: selectedClass.value?.id || "",
title: assignmentTitle.value,
description: description.value,
learningPath: lp || "",
deadline: deadline.value,
description: "",
learningPath: lp,
language: language.value,
groups: groups.value,
deadline: null,
groups: [],
};
mutate({ cid: assignmentDTO.within, data: assignmentDTO });
}
const learningPathRules = [
(value: LearningPath): string | boolean => {
if (lpIsSelected.value) return true;
if (!value) return t("lp-required");
const allLPs = learningPathsQueryResults.data.value ?? [];
const valid = allLPs.some((lp) => lp.hruid === value?.hruid);
return valid || t("lp-invalid");
},
];
const assignmentTitleRules = [
(value: string): string | boolean => {
if (value?.length >= 1) {
return true;
} // Title must not be empty
return t("title-required");
},
];
const classRules = [
(value: string): string | boolean => {
if (value) {
return true;
}
return t("class-required");
},
];
</script>
<template>
<div class="main-container">
<h1 class="h1">{{ t("new-assignment") }}</h1>
<v-card class="form-card">
<v-card class="form-card elevation-2 pa-6">
<v-form
ref="form"
class="form-container"
validate-on="submit lazy"
@submit.prevent="submitFormHandler"
>
<v-container class="step-container">
<v-card-text>
<v-text-field
v-model="assignmentTitle"
:label="t('title')"
:rules="assignmentTitleRules"
density="compact"
variant="outlined"
clearable
required
></v-text-field>
</v-card-text>
<v-container class="step-container pa-0">
<!-- Title field -->
<v-text-field
v-model="assignmentTitle"
:label="t('title')"
:rules="assignmentTitleRules"
density="comfortable"
variant="solo-filled"
prepend-inner-icon="mdi-format-title"
clearable
required
/>
<!-- Learning Path keuze -->
<using-query-result
:query-result="learningPathsQueryResults"
v-slot="{ data }: { data: LearningPath[] }"
>
<v-card-text>
<v-combobox
v-model="selectedLearningPath"
:items="data"
:label="t('choose-lp')"
:rules="learningPathRules"
variant="outlined"
clearable
hide-details
density="compact"
append-inner-icon="mdi-magnify"
item-title="title"
item-value="hruid"
required
:disabled="lpIsSelected"
:filter="
(item, query: string) => item.title.toLowerCase().includes(query.toLowerCase())
"
></v-combobox>
</v-card-text>
<v-combobox
v-model="selectedLearningPath"
:items="data"
:label="t('choose-lp')"
:rules="lpIsSelected ? [] : learningPathRules"
variant="solo-filled"
clearable
item-title="title"
:disabled="lpIsSelected"
return-object
/>
</using-query-result>
<!-- Klas keuze -->
<using-query-result
:query-result="classesQueryResults"
v-slot="{ data }: { data: ClassesResponse }"
>
<v-card-text>
<v-combobox
v-model="selectedClass"
:items="data?.classes ?? []"
:label="t('pick-class')"
:rules="classRules"
variant="outlined"
clearable
hide-details
density="compact"
append-inner-icon="mdi-magnify"
item-title="displayName"
item-value="id"
required
></v-combobox>
</v-card-text>
<v-combobox
v-model="selectedClass"
:items="data?.classes ?? []"
:label="t('pick-class')"
:rules="classRules"
variant="solo-filled"
clearable
density="comfortable"
chips
hide-no-data
hide-selected
item-title="displayName"
item-value="id"
prepend-inner-icon="mdi-account-multiple"
/>
</using-query-result>
<GroupSelector
:classId="selectedClass?.id"
:groups="groups"
@groupCreated="addGroupToList"
/>
<!-- Submit & Cancel -->
<v-divider class="my-6" />
<!-- Counter for created groups -->
<v-card-text v-if="groups.length">
<strong>Created Groups: {{ groups.length }}</strong>
</v-card-text>
<DeadlineSelector v-model:deadline="deadline" />
<v-card-text>
<v-textarea
v-model="description"
:label="t('description')"
variant="outlined"
density="compact"
auto-grow
rows="3"
:rules="descriptionRules"
></v-textarea>
</v-card-text>
<v-card-text>
<div class="d-flex justify-end ga-2">
<v-btn
class="mt-2"
color="secondary"
color="primary"
type="submit"
block
>{{ t("submit") }}
size="small"
prepend-icon="mdi-check-circle"
elevation="1"
>
{{ t("submit") }}
</v-btn>
<v-btn
to="/user/assignment"
color="grey"
block
>{{ t("cancel") }}
size="small"
variant="text"
prepend-icon="mdi-close-circle"
>
{{ t("cancel") }}
</v-btn>
</v-card-text>
</div>
</v-container>
</v-form>
</v-card>
@ -215,46 +211,55 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: start;
padding-top: 32px;
text-align: center;
}
.form-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 55%;
/*padding: 1%;*/
width: 100%;
max-width: 720px;
border-radius: 16px;
}
.form-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
}
.step-container {
display: flex;
justify-content: center;
flex-direction: column;
min-height: 200px;
gap: 24px;
}
@media (max-width: 1000px) {
.form-card {
width: 70%;
width: 85%;
padding: 1%;
}
}
.step-container {
min-height: 300px;
@media (max-width: 600px) {
h1 {
font-size: 32px;
text-align: center;
margin-left: 0;
}
}
@media (max-width: 650px) {
.form-card {
width: 95%;
@media (max-width: 400px) {
h1 {
font-size: 24px;
text-align: center;
margin-left: 0;
}
}
.v-card {
border: 2px solid #0e6942;
border-radius: 12px;
}
</style>

View file

@ -1,13 +1,9 @@
<script setup lang="ts">
import auth from "@/services/auth/auth-service.ts";
import { computed, type Ref, ref, watchEffect } from "vue";
import { computed, ref } from "vue";
import StudentAssignment from "@/views/assignments/StudentAssignment.vue";
import TeacherAssignment from "@/views/assignments/TeacherAssignment.vue";
import { useRoute } from "vue-router";
import type { Language } from "@/data-objects/language.ts";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import { AccountType } from "@dwengo-1/common/util/account-types";
const role = auth.authState.activeRole;
@ -16,58 +12,18 @@
const route = useRoute();
const classId = ref<string>(route.params.classId as string);
const assignmentId = ref(Number(route.params.id));
function useGroupsWithProgress(
groups: Ref<GroupDTO[]>,
hruid: Ref<string>,
language: Ref<string>,
): { groupProgressMap: Map<number, number> } {
const groupProgressMap: Map<number, number> = new Map<number, number>();
watchEffect(() => {
// Clear existing entries to avoid stale data
groupProgressMap.clear();
const lang = ref(language.value as Language);
groups.value.forEach((group) => {
const groupKey = group.groupNumber;
const forGroup = ref({
forGroup: groupKey,
assignmentNo: assignmentId,
classId: classId,
});
const query = useGetLearningPathQuery(hruid.value, lang, forGroup);
const data = query.data.value;
groupProgressMap.set(groupKey, data ? calculateProgress(data) : 0);
});
});
return {
groupProgressMap,
};
}
function calculateProgress(lp: LearningPath): number {
return ((lp.amountOfNodes - lp.amountOfNodesLeft) / lp.amountOfNodes) * 100;
}
</script>
<template>
<TeacherAssignment
:class-id="classId"
:assignment-id="assignmentId"
:use-groups-with-progress="useGroupsWithProgress"
v-if="isTeacher"
>
</TeacherAssignment>
<StudentAssignment
:class-id="classId"
:assignment-id="assignmentId"
:use-groups-with-progress="useGroupsWithProgress"
v-else
>
</StudentAssignment>

View file

@ -1,28 +1,26 @@
<script setup lang="ts">
import { ref, computed, type Ref } from "vue";
import { ref, computed, watchEffect } from "vue";
import auth from "@/services/auth/auth-service.ts";
import { useI18n } from "vue-i18n";
import { useAssignmentQuery } from "@/queries/assignments.ts";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import type { AssignmentResponse } from "@/controllers/assignments.ts";
import { asyncComputed } from "@vueuse/core";
import { useStudentsByUsernamesQuery } from "@/queries/students.ts";
import { useGroupsQuery } from "@/queries/groups.ts";
import {
useStudentAssignmentsQuery,
useStudentGroupsQuery,
useStudentsByUsernamesQuery,
} from "@/queries/students.ts";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import type { Language } from "@/data-objects/language.ts";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import { calculateProgress } from "@/utils/assignment-utils.ts";
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
const props = defineProps<{
classId: string;
assignmentId: number;
useGroupsWithProgress: (
groups: Ref<GroupDTO[]>,
hruid: Ref<string>,
language: Ref<Language>,
) => { groupProgressMap: Map<number, number> };
}>();
const { t } = useI18n();
const lang = ref();
const learningPath = ref();
// Get the user's username/id
const username = asyncComputed(async () => {
@ -30,45 +28,70 @@
return user?.profile?.preferred_username ?? undefined;
});
const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId);
learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath;
const assignmentsQueryResult = useStudentAssignmentsQuery(username, true);
const submitted = ref(false); //TODO: update by fetching submissions and check if group submitted
const assignment = computed(() => {
const assignments = assignmentsQueryResult.data.value?.assignments;
if (!assignments) return undefined;
return assignments.find((a) => a.id === props.assignmentId && a.within === props.classId);
});
learningPath.value = assignment.value?.learningPath;
const groupsQueryResult = useStudentGroupsQuery(username, true);
const group = computed(() => {
const groups = groupsQueryResult.data.value?.groups as GroupDTO[];
if (!groups) return undefined;
// Sort by original groupNumber
const sortedGroups = [...groups].sort((a, b) => a.groupNumber - b.groupNumber);
return sortedGroups
.map((group, index) => ({
...group,
groupNo: index + 1, // Renumbered index
}))
.find((group) => group.members?.some((m) => m.username === username.value));
});
watchEffect(() => {
learningPath.value = assignment.value?.learningPath;
lang.value = assignment.value?.language as Language;
});
const learningPathParams = computed(() => {
if (!group.value || !learningPath.value || !lang.value) return undefined;
return {
forGroup: group.value.groupNumber,
assignmentNo: props.assignmentId,
classId: props.classId,
};
});
const lpQueryResult = useGetLearningPathQuery(
computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""),
computed(() => assignmentQueryResult.data.value?.assignment.language as Language),
() => learningPath.value,
() => lang.value,
() => learningPathParams.value,
);
const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true);
const group = computed(() =>
groupsQueryResult?.data.value?.groups.find((group) =>
group.members?.some((m) => m.username === username.value),
),
);
const progressColor = computed(() => {
const progress = calculateProgress(lpQueryResult.data.value as LearningPath);
if (progress >= 100) return "success";
if (progress >= 50) return "warning";
return "error";
});
const _groupArray = computed(() => (group.value ? [group.value] : []));
const progressValue = ref(0);
/* Crashes right now cause api data has inexistent hruid TODO: uncomment later and use it in progress bar
Const {groupProgressMap} = props.useGroupsWithProgress(
groupArray,
learningPath,
language
);
*/
// Assuming group.value.members is a list of usernames TODO: case when it's StudentDTO's
const studentQueries = useStudentsByUsernamesQuery(() => group.value?.members as string[]);
const studentQueries = useStudentsByUsernamesQuery(() => (group.value?.members as string[]) ?? undefined);
</script>
<template>
<div class="container">
<using-query-result
:query-result="assignmentQueryResult"
v-slot="{ data }: { data: AssignmentResponse }"
>
<using-query-result :query-result="assignmentsQueryResult">
<v-card
v-if="data"
v-if="assignment"
class="assignment-card"
>
<div class="top-buttons">
@ -80,17 +103,8 @@ language
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-chip
v-if="submitted"
class="ma-2 top-right-btn"
label
color="success"
>
{{ t("submitted") }}
</v-chip>
</div>
<v-card-title class="text-h4 assignmentTopTitle">{{ data.assignment.title }}</v-card-title>
<v-card-title class="text-h4 assignmentTopTitle">{{ assignment.title }} </v-card-title>
<v-card-subtitle class="subtitle-section">
<using-query-result
@ -99,7 +113,12 @@ language
>
<v-btn
v-if="lpData"
:to="`/learningPath/${lpData.hruid}/${assignmentQueryResult.data.value?.assignment.language}/${lpData.startNode.learningobjectHruid}?forGroup=${group?.groupNumber}&assignmentNo=${assignmentId}&classId=${classId}`"
:to="
group
? `/learningPath/${lpData.hruid}/${assignment.language}/${lpData.startNode.learningobjectHruid}?forGroup=${group.groupNumber}&assignmentNo=${assignment.id}&classId=${assignment.within}`
: undefined
"
:disabled="!group"
variant="tonal"
color="primary"
>
@ -109,20 +128,19 @@ language
</v-card-subtitle>
<v-card-text class="description">
{{ data.assignment.description }}
{{ assignment.description }}
</v-card-text>
<v-card-text>
<v-row
align="center"
no-gutters
>
<v-col cols="auto">
<span class="progress-label">{{ t("progress") + ": " }}</span>
</v-col>
<v-col>
<v-card-text>
<h3 class="mb-2">{{ t("progress") }}</h3>
<using-query-result
:query-result="lpQueryResult"
v-slot="{ data: learningPData }"
>
<v-progress-linear
:model-value="progressValue"
color="primary"
v-if="group"
:model-value="calculateProgress(learningPData)"
:color="progressColor"
height="20"
class="progress-bar"
>
@ -130,16 +148,20 @@ language
<strong>{{ Math.ceil(value) }}%</strong>
</template>
</v-progress-linear>
</v-col>
</v-row>
</using-query-result>
</v-card-text>
</v-card-text>
<v-card-text class="group-section">
<h3>{{ t("group") }}</h3>
<div v-if="studentQueries">
<v-card-text
class="group-section"
v-if="group && studentQueries"
>
<h3>{{ `${t("group")} ${group.groupNo}` }}</h3>
<div>
<ul>
<li
v-for="student in group?.members"
v-for="student in group.members"
:key="student.username"
>
{{ student.firstName + " " + student.lastName }}
@ -147,6 +169,21 @@ language
</ul>
</div>
</v-card-text>
<v-card-text
class="group-section"
v-else
>
<h3>{{ t("group") }}</h3>
<div>
<v-alert class="empty-message">
<v-icon
icon="mdi-information-outline"
size="small"
/>
{{ t("currently-no-groups") }}
</v-alert>
</div>
</v-card-text>
</v-card>
</using-query-result>
</div>
@ -155,11 +192,6 @@ language
<style scoped>
@import "@/assets/assignment.css";
.progress-label {
font-weight: bold;
margin-right: 5px;
}
.progress-bar {
width: 40%;
}

View file

@ -1,224 +1,485 @@
<script setup lang="ts">
import { computed, type Ref, ref } from "vue";
import { computed, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { useAssignmentQuery, useDeleteAssignmentMutation } from "@/queries/assignments.ts";
import {
useAssignmentQuery,
useDeleteAssignmentMutation,
useUpdateAssignmentMutation,
} from "@/queries/assignments.ts";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { useGroupsQuery } from "@/queries/groups.ts";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import type { Language } from "@/data-objects/language.ts";
import type { AssignmentResponse } from "@/controllers/assignments.ts";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group";
import GroupSubmissionStatus from "@/components/GroupSubmissionStatus.vue";
import GroupProgressRow from "@/components/GroupProgressRow.vue";
import type { AssignmentDTO } from "@dwengo-1/common/dist/interfaces/assignment.ts";
import GroupSelector from "@/components/assignments/GroupSelector.vue";
import DeadlineSelector from "@/components/assignments/DeadlineSelector.vue";
const props = defineProps<{
classId: string;
assignmentId: number;
useGroupsWithProgress: (
groups: Ref<GroupDTO[]>,
hruid: Ref<string>,
language: Ref<Language>,
) => { groupProgressMap: Map<number, number> };
}>();
const isEditing = ref(false);
const { t } = useI18n();
const groups = ref();
const lang = ref();
const groups = ref<GroupDTO[] | GroupDTOId[]>([]);
const learningPath = ref();
const form = ref();
const editingLearningPath = ref(learningPath);
const description = ref("");
const deadline = ref<Date | null>(null);
const editGroups = ref(false);
const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId);
learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath;
// Get learning path object
const lpQueryResult = useGetLearningPathQuery(
computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""),
computed(() => assignmentQueryResult.data.value?.assignment.language as Language),
computed(() => assignmentQueryResult.data.value?.assignment?.language as Language),
);
// Get all the groups withing the assignment
const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true);
groups.value = groupsQueryResult.data.value?.groups;
groups.value = groupsQueryResult.data.value?.groups ?? [];
/* Crashes right now cause api data has inexistent hruid TODO: uncomment later and use it in progress bar
Const {groupProgressMap} = props.useGroupsWithProgress(
groups,
learningPath,
language
);
*/
watchEffect(() => {
const assignment = assignmentQueryResult.data.value?.assignment;
if (assignment) {
learningPath.value = assignment.learningPath;
lang.value = assignment.language as Language;
deadline.value = assignment.deadline ? new Date(assignment.deadline) : null;
if (lpQueryResult.data.value) {
editingLearningPath.value = lpQueryResult.data.value;
}
}
});
const hasSubmissions = ref<boolean>(false);
const allGroups = computed(() => {
const groups = groupsQueryResult.data.value?.groups;
if (!groups) return [];
return groups.map((group) => ({
name: `${t("group")} ${group.groupNumber}`,
progress: 0, //GroupProgressMap[group.groupNumber],
// Sort by original groupNumber
const sortedGroups = [...groups].sort((a, b) => a.groupNumber - b.groupNumber);
// Assign new sequential numbers starting from 1
return sortedGroups.map((group, index) => ({
groupNo: index + 1, // New group number that will be used
name: `${t("group")} ${index + 1}`,
members: group.members,
submitted: false, //TODO: fetch from submission
originalGroupNo: group.groupNumber,
}));
});
const dialog = ref(false);
const selectedGroup = ref({});
function openGroupDetails(group): void {
function openGroupDetails(group: object): void {
selectedGroup.value = group;
dialog.value = true;
}
const headers = computed(() => [
{ title: t("group"), align: "start", key: "name" },
{ title: t("progress"), align: "center", key: "progress" },
{ title: t("submission"), align: "center", key: "submission" },
]);
const snackbar = ref({
visible: false,
message: "",
color: "success",
});
const { mutate } = useDeleteAssignmentMutation();
function showSnackbar(message: string, color: string): void {
snackbar.value.message = message;
snackbar.value.color = color;
snackbar.value.visible = true;
}
const deleteAssignmentMutation = useDeleteAssignmentMutation();
async function deleteAssignment(num: number, clsId: string): Promise<void> {
mutate(
deleteAssignmentMutation.mutate(
{ cid: clsId, an: num },
{
onSuccess: () => {
window.location.href = "/user/assignment";
},
onError: (e) => {
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
},
);
}
function goToLearningPathLink(): string | undefined {
const assignment = assignmentQueryResult.data.value?.assignment;
const lp = lpQueryResult.data.value;
if (!assignment || !lp) return undefined;
return `/learningPath/${lp.hruid}/${assignment.language}/${lp.startNode.learningobjectHruid}?assignmentNo=${props.assignmentId}&classId=${props.classId}`;
}
function goToGroupSubmissionLink(groupNo: number): string | undefined {
const lp = lpQueryResult.data.value;
if (!lp) return undefined;
return `/learningPath/${lp.hruid}/${lp.language}/${lp.startNode.learningobjectHruid}?forGroup=${groupNo}&assignmentNo=${props.assignmentId}&classId=${props.classId}`;
}
const { mutate, data, isSuccess } = useUpdateAssignmentMutation();
watch([isSuccess, data], async ([success, newData]) => {
if (success && newData?.assignment) {
await assignmentQueryResult.refetch();
}
});
async function saveChanges(): Promise<void> {
const { valid } = await form.value.validate();
if (!valid) return;
isEditing.value = false;
const assignmentDTO: AssignmentDTO = {
description: description.value,
deadline: deadline.value ?? null,
};
mutate({
cid: assignmentQueryResult.data.value?.assignment.within,
an: assignmentQueryResult.data.value?.assignment.id,
data: assignmentDTO,
});
}
async function handleGroupsUpdated(updatedGroups: string[][]): Promise<void> {
const assignmentDTO: AssignmentDTO = {
groups: updatedGroups,
};
mutate({
cid: assignmentQueryResult.data.value?.assignment.within,
an: assignmentQueryResult.data.value?.assignment.id,
data: assignmentDTO,
});
}
</script>
<template>
<div class="container">
<using-query-result
:query-result="assignmentQueryResult"
v-slot="{ data }: { data: AssignmentResponse }"
v-slot="assignmentResponse: { data: AssignmentResponse }"
>
<v-card
v-if="data"
class="assignment-card"
<v-container
fluid
class="ma-4"
>
<div class="top-buttons">
<v-btn
icon
variant="text"
class="back-btn"
to="/user/assignment"
<v-row
no-gutters
class="custom-breakpoint"
>
<v-col
cols="12"
sm="6"
md="6"
class="responsive-col"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-btn
icon
variant="text"
class="top-right-btn"
@click="deleteAssignment(data.assignment.id, data.assignment.within)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</div>
<v-card-title class="text-h4 assignmentTopTitle">{{ data.assignment.title }}</v-card-title>
<v-card-subtitle class="subtitle-section">
<using-query-result
:query-result="lpQueryResult"
v-slot="{ data: lpData }"
>
<v-btn
v-if="lpData"
:to="`/learningPath/${lpData.hruid}/${assignmentQueryResult.data.value?.assignment.language}/${lpData.startNode.learningobjectHruid}?assignmentNo=${assignmentId}&classId=${classId}`"
variant="tonal"
color="primary"
<v-form
ref="form"
validate-on="submit lazy"
@submit.prevent="saveChanges"
>
{{ t("learning-path") }}
</v-btn>
</using-query-result>
</v-card-subtitle>
<v-card
v-if="assignmentResponse"
class="assignment-card-teacher"
>
<div class="top-buttons">
<div class="top-buttons-wrapper">
<v-btn
icon
variant="text"
class="back-btn"
to="/user/assignment"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<div class="right-buttons">
<v-btn
v-if="!isEditing"
icon
variant="text"
class="top_next_to_right_button"
@click="
() => {
isEditing = true;
description = assignmentResponse.data.assignment.description;
}
"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn
v-else
variant="text"
class="top-right-btn"
@click="
() => {
isEditing = false;
editingLearningPath = learningPath;
}
"
>{{ t("cancel") }}
</v-btn>
<v-card-text class="description">
{{ data.assignment.description }}
</v-card-text>
<v-btn
v-if="!isEditing"
icon
variant="text"
class="top-right-btn"
@click="
deleteAssignment(
assignmentResponse.data.assignment.id,
assignmentResponse.data.assignment.within,
)
"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
<v-btn
v-else
icon
variant="text"
class="top_next_to_right_button"
@click="saveChanges"
>
<v-icon>mdi-content-save-edit-outline</v-icon>
</v-btn>
</div>
</div>
</div>
<v-card-title class="text-h4 assignmentTopTitle"
>{{ assignmentResponse.data.assignment.title }}
</v-card-title>
<v-card-subtitle class="subtitle-section">
<using-query-result
:query-result="lpQueryResult"
v-slot="{ data: lpData }"
>
<v-btn
v-if="lpData"
:to="goToLearningPathLink()"
variant="tonal"
color="primary"
:disabled="isEditing"
>
{{ t("learning-path") }}
</v-btn>
<v-alert
v-else
type="info"
>
{{ t("no-learning-path-selected") }}
</v-alert>
</using-query-result>
</v-card-subtitle>
<v-card-text v-if="isEditing">
<deadline-selector v-model:deadline="deadline" />
</v-card-text>
<v-card-text
v-if="!isEditing"
class="description"
>
{{ assignmentResponse.data.assignment.description }}
</v-card-text>
<v-card-text v-else>
<v-textarea
v-model="description"
:label="t('description')"
variant="outlined"
density="compact"
auto-grow
rows="3"
></v-textarea>
</v-card-text>
</v-card>
</v-form>
<v-card-text class="group-section">
<h3>{{ t("groups") }}</h3>
<div class="table-scroll">
<v-data-table
:headers="headers"
:items="allGroups"
item-key="id"
class="elevation-1"
<!-- A pop up to show group members -->
<v-dialog
v-model="dialog"
max-width="600"
persistent
>
<template #[`item.name`]="{ item }">
<v-btn
@click="openGroupDetails(item)"
variant="text"
color="primary"
>
{{ item.name }}
</v-btn>
</template>
<v-card class="pa-4 rounded-xl elevation-6 group-members-dialog">
<v-card-title class="text-h6 font-weight-bold">
{{ t("members") }}
</v-card-title>
<template #[`item.progress`]="{ item }">
<v-progress-linear
:model-value="item.progress"
color="blue-grey"
height="25"
>
<template v-slot:default="{ value }">
<strong>{{ Math.ceil(value) }}%</strong>
</template>
</v-progress-linear>
</template>
<v-divider class="my-2" />
<template #[`item.submission`]="{ item }">
<v-btn
:to="item.submitted ? `${props.assignmentId}/submissions/` : undefined"
:color="item.submitted ? 'green' : 'red'"
variant="text"
class="text-capitalize"
>
{{ item.submitted ? t("see-submission") : t("no-submission") }}
</v-btn>
</template>
</v-data-table>
</div>
</v-card-text>
<v-card-text>
<v-list>
<v-list-item
v-for="(member, index) in selectedGroup.members"
:key="index"
class="py-2"
>
<v-list-item-content>
<v-list-item-title class="text-body-1">
{{ member.firstName }} {{ member.lastName }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card-text>
<v-divider class="my-2" />
<v-card-actions class="justify-end">
<v-btn
color="primary"
variant="outlined"
@click="dialog = false"
prepend-icon="mdi-close-circle"
>
{{ t("close") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-col>
<!-- The second column of the screen -->
<v-col
cols="12"
sm="6"
md="6"
class="responsive-col"
>
<div class="table-container">
<v-table class="table">
<thead>
<tr>
<th class="header">{{ t("group") }}</th>
<th class="header">{{ t("progress") }}</th>
<th class="header">{{ t("submission") }}</th>
<th class="header">
<v-btn
@click="editGroups = true"
variant="text"
:disabled="hasSubmissions"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</th>
</tr>
</thead>
<tbody v-if="allGroups.length > 0">
<tr
v-for="g in allGroups"
:key="g.originalGroupNo"
>
<td>
<v-btn variant="text">
{{ g.name }}
</v-btn>
</td>
<td>
<GroupProgressRow
:group-number="g.originalGroupNo"
:learning-path="learningPath.hruid"
:language="lang"
:assignment-id="assignmentId"
:class-id="classId"
/>
</td>
<td>
<GroupSubmissionStatus
:group="g"
:assignment-id="assignmentId"
:class-id="classId"
:language="lang"
:go-to-group-submission-link="goToGroupSubmissionLink"
@update:hasSubmission="
(hasSubmission) => (hasSubmissions = hasSubmission)
"
/>
</td>
<!-- Edit icon -->
<td>
<v-btn
@click="openGroupDetails(g)"
variant="text"
>
<v-icon>mdi-eye</v-icon>
</v-btn>
</td>
</tr>
</tbody>
<template v-else>
<tbody>
<tr>
<td
colspan="4"
class="empty-message"
>
<v-icon
icon="mdi-information-outline"
size="small"
/>
{{ t("currently-no-groups") }}
</td>
</tr>
</tbody>
</template>
</v-table>
</div>
</v-col>
</v-row>
<v-dialog
v-model="dialog"
max-width="50%"
v-model="editGroups"
max-width="800"
persistent
>
<v-card>
<v-card-title class="headline">{{ t("members") }}</v-card-title>
<v-card-text>
<v-list>
<v-list-item
v-for="(member, index) in selectedGroup.members"
:key="index"
>
<v-list-item-content>
<v-list-item-title
>{{ member.firstName + " " + member.lastName }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
<GroupSelector
:groups="allGroups"
:class-id="props.classId"
:assignment-id="props.assignmentId"
@groupsUpdated="handleGroupsUpdated"
@close="editGroups = false"
/>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="dialog = false"
>Close
text
@click="editGroups = false"
>
{{ t("cancel") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!--
<v-card-actions class="justify-end">
<v-btn
size="large"
color="success"
variant="text"
>
{{ t("view-submissions") }}
</v-btn>
</v-card-actions>
-->
</v-card>
</v-container>
<v-snackbar
v-model="snackbar.visible"
:color="snackbar.color"
timeout="3000"
>
{{ snackbar.message }}
</v-snackbar>
</using-query-result>
</div>
</template>
@ -226,8 +487,130 @@ language
<style scoped>
@import "@/assets/assignment.css";
.assignment-card-teacher {
width: 80%;
padding: 2%;
border-radius: 12px;
}
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.header {
font-weight: bold;
background-color: #0e6942;
color: white;
padding: 5px;
}
table thead th:first-child {
border-top-left-radius: 10px;
}
.table thead th:last-child {
border-top-right-radius: 10px;
}
.table tbody tr:nth-child(odd) {
background-color: white;
}
.table tbody tr:nth-child(even) {
background-color: #f6faf2;
}
td,
th {
border-bottom: 1px solid #0e6942;
border-top: 1px solid #0e6942;
}
h1 {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
padding-top: 2%;
font-size: 50px;
}
h2 {
color: #0e6942;
font-size: 30px;
}
.link {
color: #0b75bb;
text-decoration: underline;
}
main {
margin-left: 30px;
}
.table-container {
width: 100%;
overflow-x: visible;
}
.table {
width: 100%;
min-width: auto;
table-layout: auto;
}
@media screen and (max-width: 1200px) {
h1 {
text-align: center;
padding-left: 0;
}
.join {
text-align: center;
align-items: center;
margin-left: 0;
}
.sheet {
width: 90%;
}
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 5px;
}
.custom-breakpoint {
flex-direction: column !important;
}
.table {
width: 100%;
display: block;
overflow-x: auto;
}
.table-container {
overflow-x: auto;
}
.responsive-col {
max-width: 100% !important;
flex-basis: 100% !important;
}
.assignment-card-teacher {
width: 100%;
border-radius: 12px;
}
}
.group-members-dialog {
max-height: 80vh;
overflow-y: auto;
}
</style>

View file

@ -2,74 +2,78 @@
import { ref, computed, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import authState from "@/services/auth/auth-service.ts";
import auth from "@/services/auth/auth-service.ts";
import { useTeacherClassesQuery } from "@/queries/teachers.ts";
import { useStudentClassesQuery } from "@/queries/students.ts";
import { ClassController } from "@/controllers/classes.ts";
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
import { asyncComputed } from "@vueuse/core";
import { useTeacherAssignmentsQuery, useTeacherClassesQuery } from "@/queries/teachers.ts";
import { useStudentAssignmentsQuery, useStudentClassesQuery } from "@/queries/students.ts";
import { useDeleteAssignmentMutation } from "@/queries/assignments.ts";
import { AccountType } from "@dwengo-1/common/util/account-types";
import "../../assets/common.css";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
const { t, locale } = useI18n();
const router = useRouter();
const role = ref(auth.authState.activeRole);
const username = ref<string>("");
const isTeacher = computed(() => role.value === "teacher");
const username = ref<string | undefined>(undefined);
const isLoading = ref(false);
const isError = ref(false);
const errorMessage = ref<string>("");
const isTeacher = computed(() => role.value === AccountType.Teacher);
// Load current user before rendering the page
onMounted(async () => {
isLoading.value = true;
try {
const userObject = await authState.loadUser();
username.value = userObject!.profile.preferred_username;
} catch (error) {
isError.value = true;
errorMessage.value = error instanceof Error ? error.message : String(error);
} finally {
isLoading.value = false;
}
});
// Fetch and store all the teacher's classes
let classesQueryResults = undefined;
const classesQueryResult = isTeacher.value
? useTeacherClassesQuery(username, true)
: useStudentClassesQuery(username, true);
if (isTeacher.value) {
classesQueryResults = useTeacherClassesQuery(username, true);
} else {
classesQueryResults = useStudentClassesQuery(username, true);
}
const assignmentsQueryResult = isTeacher.value
? useTeacherAssignmentsQuery(username, true)
: useStudentAssignmentsQuery(username, true);
const classController = new ClassController();
const allAssignments = computed(() => {
const assignments = assignmentsQueryResult.data.value?.assignments;
if (!assignments) return [];
const assignments = asyncComputed(
async () => {
const classes = classesQueryResults?.data?.value?.classes;
if (!classes) return [];
const classes = classesQueryResult.data.value?.classes;
if (!classes) return [];
const result = await Promise.all(
(classes as ClassDTO[]).map(async (cls) => {
const { assignments } = await classController.getAssignments(cls.id);
return assignments.map((a) => ({
id: a.id,
class: cls,
title: a.title,
description: a.description,
learningPath: a.learningPath,
language: a.language,
deadline: a.deadline,
groups: a.groups,
}));
}),
);
const result = assignments.map((a) => ({
id: a.id,
class: classes.find((cls) => cls?.id === a.within) ?? undefined,
title: a.title,
description: a.description,
learningPath: a.learningPath,
language: a.language,
deadline: a.deadline,
groups: a.groups,
}));
// Order the assignments by deadline
return result.flat().sort((a, b) => {
const now = Date.now();
const aTime = new Date(a.deadline).getTime();
const bTime = new Date(b.deadline).getTime();
// Order the assignments by deadline
return result.flat().sort((a, b) => {
const now = Date.now();
const aTime = new Date(a.deadline).getTime();
const bTime = new Date(b.deadline).getTime();
const aIsPast = aTime < now;
const bIsPast = bTime < now;
const aIsPast = aTime < now;
const bIsPast = bTime < now;
if (aIsPast && !bIsPast) return 1;
if (!aIsPast && bIsPast) return -1;
if (aIsPast && !bIsPast) return 1;
if (!aIsPast && bIsPast) return -1;
return aTime - bTime;
});
},
[],
{ evaluating: true },
);
return aTime - bTime;
});
});
async function goToCreateAssignment(): Promise<void> {
await router.push("/assignment/create");
@ -79,16 +83,35 @@
await router.push(`/assignment/${clsId}/${id}`);
}
const { mutate, data, isSuccess } = useDeleteAssignmentMutation();
watch([isSuccess, data], async ([success, oldData]) => {
if (success && oldData?.assignment) {
window.location.reload();
}
const snackbar = ref({
visible: false,
message: "",
color: "success",
});
function showSnackbar(message: string, color: string): void {
snackbar.value.message = message;
snackbar.value.color = color;
snackbar.value.visible = true;
}
const deleteAssignmentMutation = useDeleteAssignmentMutation();
async function goToDeleteAssignment(num: number, clsId: string): Promise<void> {
mutate({ cid: clsId, an: num });
deleteAssignmentMutation.mutate(
{ cid: clsId, an: num },
{
onSuccess: (data) => {
if (data?.assignment) {
window.location.reload();
}
showSnackbar(t("success"), "success");
},
onError: (e) => {
showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
},
},
);
}
function formatDate(date?: string | Date): string {
@ -124,6 +147,11 @@
const user = await auth.loadUser();
username.value = user?.profile?.preferred_username ?? "";
});
onMounted(async () => {
const user = await auth.loadUser();
username.value = user?.profile?.preferred_username ?? "";
});
</script>
<template>
@ -132,68 +160,84 @@
<v-btn
v-if="isTeacher"
color="primary"
:style="{ backgroundColor: '#0E6942' }"
class="mb-4 center-btn"
@click="goToCreateAssignment"
>
{{ t("new-assignment") }}
</v-btn>
<v-container>
<v-row>
<v-col
v-for="assignment in assignments"
:key="assignment.id"
cols="12"
>
<v-card class="assignment-card">
<div class="top-content">
<div class="assignment-title">{{ assignment.title }}</div>
<div class="assignment-class">
{{ t("class") }}:
<span class="class-name">
{{ assignment.class.displayName }}
</span>
<using-query-result :query-result="assignmentsQueryResult">
<v-container>
<v-row>
<v-col
v-for="assignment in allAssignments"
:key="assignment.id"
cols="12"
>
<v-card class="assignment-card">
<div class="top-content">
<div class="assignment-title">{{ assignment.title }}</div>
<div class="assignment-class">
{{ t("class") }}:
<a
:href="`/class/${assignment?.class?.id}`"
class="class-name"
>
{{ assignment?.class?.displayName }}
</a>
</div>
<div
class="assignment-deadline"
:class="getDeadlineClass(assignment.deadline)"
>
{{ t("deadline") }}:
<span>{{ formatDate(assignment.deadline) }}</span>
</div>
</div>
<div
class="assignment-deadline"
:class="getDeadlineClass(assignment.deadline)"
>
{{ t("deadline") }}:
<span>{{ formatDate(assignment.deadline) }}</span>
<div class="spacer"></div>
<div class="button-row">
<v-btn
color="primary"
variant="text"
@click="goToAssignmentDetails(assignment.id, assignment?.class?.id)"
>
{{ t("view-assignment") }}
</v-btn>
<v-btn
v-if="isTeacher"
color="red"
variant="text"
@click="goToDeleteAssignment(assignment.id, assignment?.class?.id)"
>
{{ t("delete") }}
</v-btn>
</div>
</v-card>
</v-col>
</v-row>
<v-row v-if="allAssignments.length === 0">
<v-col cols="12">
<div class="no-assignments">
<v-icon
icon="mdi-information-outline"
size="small"
/>
{{ t("no-assignments") }}
</div>
<div class="spacer"></div>
<div class="button-row">
<v-btn
color="primary"
variant="text"
@click="goToAssignmentDetails(assignment.id, assignment.class.id)"
>
{{ t("view-assignment") }}
</v-btn>
<v-btn
v-if="isTeacher"
color="red"
variant="text"
@click="goToDeleteAssignment(assignment.id, assignment.class.id)"
>
{{ t("delete") }}
</v-btn>
</div>
</v-card>
</v-col>
</v-row>
<v-row v-if="assignments.length === 0">
<v-col cols="12">
<div class="no-assignments">
{{ t("no-assignments") }}
</div>
</v-col>
</v-row>
</v-container>
</v-col>
</v-row>
</v-container>
<v-snackbar
v-model="snackbar.visible"
:color="snackbar.color"
timeout="3000"
>
{{ snackbar.message }}
</v-snackbar>
</using-query-result>
</div>
</template>
@ -212,6 +256,7 @@
color: white;
transition: background-color 0.2s;
}
.center-btn:hover {
background-color: #0e6942;
}
@ -225,6 +270,7 @@
transform 0.2s,
box-shadow 0.2s;
}
.assignment-card:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
@ -248,6 +294,10 @@
margin-bottom: 0.2rem;
}
.assignment-class a {
text-decoration: none;
}
.class-name {
font-weight: 600;
color: #097180;

View file

@ -1,10 +1,58 @@
import { describe, expect, it } from "vitest";
import { describe, it, expect, beforeEach } from "vitest";
import { ClassController } from "../../src/controllers/classes";
describe("Test controller classes", () => {
it("Get classes", async () => {
const controller = new ClassController();
const data = await controller.getAll(true);
expect(data.classes).to.have.length.greaterThan(0);
describe("ClassController Tests", () => {
let controller: ClassController;
const testClassId = "X2J9QT";
beforeEach(() => {
controller = new ClassController();
});
it("should fetch all classes", async () => {
const result = await controller.getAll(true);
expect(result).toHaveProperty("classes");
expect(Array.isArray(result.classes)).toBe(true);
expect(result.classes.length).toBeGreaterThan(0);
});
it("should fetch a class by ID", async () => {
const result = await controller.getById(testClassId);
expect(result).toHaveProperty("class");
expect(result.class).toHaveProperty("id", testClassId);
});
it("should fetch students for a class", async () => {
const result = await controller.getStudents(testClassId, true);
expect(result).toHaveProperty("students");
expect(Array.isArray(result.students)).toBe(true);
});
it("should fetch teachers for a class", async () => {
const result = await controller.getTeachers(testClassId, true);
expect(result).toHaveProperty("teachers");
expect(Array.isArray(result.teachers)).toBe(true);
});
it("should fetch teacher invitations for a class", async () => {
const result = await controller.getTeacherInvitations(testClassId, true);
expect(result).toHaveProperty("invitations");
expect(Array.isArray(result.invitations)).toBe(true);
});
it("should fetch assignments for a class", async () => {
const result = await controller.getAssignments(testClassId, true);
expect(result).toHaveProperty("assignments");
expect(Array.isArray(result.assignments)).toBe(true);
});
it("should handle fetching a non-existent class", async () => {
const nonExistentId = "NON_EXISTENT_ID";
await expect(controller.getById(nonExistentId)).rejects.toThrow();
});
it("should handle deleting a non-existent class", async () => {
const nonExistentId = "NON_EXISTENT_ID";
await expect(controller.deleteClass(nonExistentId)).rejects.toThrow();
});
});

View file

@ -0,0 +1,49 @@
import { copyArrayWith } from "../../src/utils/array-utils";
import { describe, it, expect } from "vitest";
describe("copyArrayWith", () => {
it("should replace the element at the specified index", () => {
const original = [1, 2, 3, 4];
const result = copyArrayWith(2, 99, original);
expect(result).toEqual([1, 2, 99, 4]);
});
it("should not modify the original array", () => {
const original = ["a", "b", "c"];
const result = copyArrayWith(1, "x", original);
expect(original).toEqual(["a", "b", "c"]); // Original remains unchanged
expect(result).toEqual(["a", "x", "c"]);
});
it("should handle replacing the first element", () => {
const original = [true, false, true];
const result = copyArrayWith(0, false, original);
expect(result).toEqual([false, false, true]);
});
it("should handle replacing the last element", () => {
const original = ["apple", "banana", "cherry"];
const result = copyArrayWith(2, "date", original);
expect(result).toEqual(["apple", "banana", "date"]);
});
it("should work with complex objects", () => {
const original = [{ id: 1 }, { id: 2 }, { id: 3 }];
const newValue = { id: 99 };
const result = copyArrayWith(1, newValue, original);
expect(result).toEqual([{ id: 1 }, { id: 99 }, { id: 3 }]);
expect(original[1].id).toBe(2); // Original remains unchanged
});
it("should allow setting to undefined", () => {
const original = [1, 2, 3];
const result = copyArrayWith(1, undefined, original);
expect(result).toEqual([1, undefined, 3]);
});
it("should allow setting to null", () => {
const original = [1, 2, 3];
const result = copyArrayWith(1, null, original);
expect(result).toEqual([1, null, 3]);
});
});

View file

@ -0,0 +1,86 @@
import { LearningPathNode } from "@dwengo-1/backend/dist/entities/content/learning-path-node.entity";
import { calculateProgress } from "../../src/utils/assignment-utils";
import { LearningPath } from "../../src/data-objects/learning-paths/learning-path";
import { describe, it, expect } from "vitest";
describe("calculateProgress", () => {
it("should return 0 when no nodes are completed", () => {
const lp = new LearningPath({
language: "en",
hruid: "test-path",
title: "Test Path",
description: "Test Description",
amountOfNodes: 10,
amountOfNodesLeft: 10,
keywords: ["test"],
targetAges: { min: 10, max: 15 },
startNode: {} as LearningPathNode,
});
expect(calculateProgress(lp)).toBe(0);
});
it("should return 100 when all nodes are completed", () => {
const lp = new LearningPath({
language: "en",
hruid: "test-path",
title: "Test Path",
description: "Test Description",
amountOfNodes: 10,
amountOfNodesLeft: 0,
keywords: ["test"],
targetAges: { min: 10, max: 15 },
startNode: {} as LearningPathNode,
});
expect(calculateProgress(lp)).toBe(100);
});
it("should return 50 when half of the nodes are completed", () => {
const lp = new LearningPath({
language: "en",
hruid: "test-path",
title: "Test Path",
description: "Test Description",
amountOfNodes: 10,
amountOfNodesLeft: 5,
keywords: ["test"],
targetAges: { min: 10, max: 15 },
startNode: {} as LearningPathNode,
});
expect(calculateProgress(lp)).toBe(50);
});
it("should handle floating point progress correctly", () => {
const lp = new LearningPath({
language: "en",
hruid: "test-path",
title: "Test Path",
description: "Test Description",
amountOfNodes: 3,
amountOfNodesLeft: 1,
keywords: ["test"],
targetAges: { min: 10, max: 15 },
startNode: {} as LearningPathNode,
});
expect(calculateProgress(lp)).toBeCloseTo(66.666, 2);
});
it("should handle edge case where amountOfNodesLeft is negative", () => {
const lp = new LearningPath({
language: "en",
hruid: "test-path",
title: "Test Path",
description: "Test Description",
amountOfNodes: 10,
amountOfNodesLeft: -5,
keywords: ["test"],
targetAges: { min: 10, max: 15 },
startNode: {} as LearningPathNode,
});
expect(calculateProgress(lp)).toBe(150);
});
});

View file

@ -1,82 +0,0 @@
import { describe, expect, it } from "vitest";
import {
assignmentTitleRules,
classRules,
deadlineRules,
descriptionRules,
learningPathRules,
} from "../../src/utils/assignment-rules";
describe("Validation Rules", () => {
describe("assignmentTitleRules", () => {
it("should return true for a valid title", () => {
const result = assignmentTitleRules[0]("Valid Title");
expect(result).toBe(true);
});
it("should return an error message for an empty title", () => {
const result = assignmentTitleRules[0]("");
expect(result).toBe("Title cannot be empty.");
});
});
describe("learningPathRules", () => {
it("should return true for a valid learning path", () => {
const result = learningPathRules[0]({ hruid: "123", title: "Path Title" });
expect(result).toBe(true);
});
it("should return an error message for an invalid learning path", () => {
const result = learningPathRules[0]({ hruid: "", title: "" });
expect(result).toBe("You must select a learning path.");
});
});
describe("classRules", () => {
it("should return true for a valid class", () => {
const result = classRules[0]("Class 1");
expect(result).toBe(true);
});
it("should return an error message for an empty class", () => {
const result = classRules[0]("");
expect(result).toBe("You must select at least one class.");
});
});
describe("deadlineRules", () => {
it("should return true for a valid future deadline", () => {
const futureDate = new Date(Date.now() + 1000 * 60 * 60).toISOString();
const result = deadlineRules[0](futureDate);
expect(result).toBe(true);
});
it("should return an error message for a past deadline", () => {
const pastDate = new Date(Date.now() - 1000 * 60 * 60).toISOString();
const result = deadlineRules[0](pastDate);
expect(result).toBe("The deadline must be in the future.");
});
it("should return an error message for an invalid date", () => {
const result = deadlineRules[0]("invalid-date");
expect(result).toBe("Invalid date or time.");
});
it("should return an error message for an empty deadline", () => {
const result = deadlineRules[0]("");
expect(result).toBe("You must set a deadline.");
});
});
describe("descriptionRules", () => {
it("should return true for a valid description", () => {
const result = descriptionRules[0]("This is a valid description.");
expect(result).toBe(true);
});
it("should return an error message for an empty description", () => {
const result = descriptionRules[0]("");
expect(result).toBe("Description cannot be empty.");
});
});
});