Merge pull request #280 from SELab-2/feat/232-assignments-pagina-ui-ux

feat: Assignments UI/UX
This commit is contained in:
Joyelle Ndagijimana 2025-05-19 19:54:36 +02:00 committed by GitHub
commit a649713e15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 2241 additions and 767 deletions

View file

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

View file

@ -7,6 +7,7 @@ import {
getJoinRequestsByClass, getJoinRequestsByClass,
getStudentsByTeacher, getStudentsByTeacher,
getTeacher, getTeacher,
getTeacherAssignments,
updateClassJoinRequestStatus, updateClassJoinRequestStatus,
} from '../services/teachers.js'; } from '../services/teachers.js';
import { requireFields } from './error-helper.js'; import { requireFields } from './error-helper.js';
@ -59,6 +60,16 @@ export async function getTeacherClassHandler(req: Request, res: Response): Promi
res.json({ classes }); 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> { export async function getTeacherStudentHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username; const username = req.params.username;
const full = req.query.full === 'true'; 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'] }); return this.findOne({ within: within, id: id }, { populate: ['groups', 'groups.members'] });
} }
public async findByClassIdAndAssignmentId(withinClass: string, id: number): Promise<Assignment | null> { 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[]> { public async findAllByResponsibleTeacher(teacherUsername: string): Promise<Assignment[]> {
return this.findAll({ return this.findAll({
@ -20,6 +20,7 @@ export class AssignmentRepository extends DwengoEntityRepository<Assignment> {
}, },
}, },
}, },
populate: ['groups', 'groups.members'],
}); });
} }
public async findAllAssignmentsInClass(within: Class): Promise<Assignment[]> { public async findAllAssignmentsInClass(within: Class): Promise<Assignment[]> {

View file

@ -28,4 +28,9 @@ export class GroupRepository extends DwengoEntityRepository<Group> {
groupNumber: groupNumber, 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(); 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, description: assignment.description,
learningPath: assignment.learningPathHruid, learningPath: assignment.learningPathHruid,
language: assignment.learningPathLanguage, language: assignment.learningPathLanguage,
deadline: assignment.deadline ?? new Date(), deadline: assignment.deadline ?? null,
groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)),
}; };
} }

View file

@ -10,10 +10,13 @@ import { AccountType } from '@dwengo-1/common/util/account-types';
import { fetchClass } from '../../../services/classes.js'; import { fetchClass } from '../../../services/classes.js';
import { fetchGroup } from '../../../services/groups.js'; import { fetchGroup } from '../../../services/groups.js';
import { requireFields } from '../../../controllers/error-helper.js'; import { requireFields } from '../../../controllers/error-helper.js';
import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission';
export const onlyAllowSubmitter = authorize( export const onlyAllowSubmitter = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => {
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { submitter: string }).submitter === auth.username const submittedFor = (req.body as SubmissionDTO).submitter.username;
); const submittedBy = auth.username;
return submittedFor === submittedBy;
});
export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { hruid: lohruid, id: submissionNumber } = req.params; const { hruid: lohruid, id: submissionNumber } = req.params;

View file

@ -4,6 +4,7 @@ import {
deleteTeacherHandler, deleteTeacherHandler,
getAllTeachersHandler, getAllTeachersHandler,
getStudentJoinRequestHandler, getStudentJoinRequestHandler,
getTeacherAssignmentsHandler,
getTeacherClassHandler, getTeacherClassHandler,
getTeacherHandler, getTeacherHandler,
getTeacherStudentHandler, getTeacherStudentHandler,
@ -28,6 +29,8 @@ router.get('/:username/classes', preventImpersonation, getTeacherClassHandler);
router.get('/:username/students', preventImpersonation, getTeacherStudentHandler); router.get('/:username/students', preventImpersonation, getTeacherStudentHandler);
router.get(`/:username/assignments`, getTeacherAssignmentsHandler);
router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler); router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler);
router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler); 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 { fetchClass } from './classes.js';
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; 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 { putObject } from './service-helper.js';
import { fetchStudents } from './students.js'; import { fetchStudents } from './students.js';
import { ServerErrorException } from '../exceptions/server-error-exception.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> { export async function fetchAssignment(classid: string, assignmentNumber: number): Promise<Assignment> {
const classRepository = getClassRepository(); const classRepository = getClassRepository();
@ -59,7 +62,7 @@ export async function createAssignment(classid: string, assignmentData: Assignme
if (assignmentData.groups) { 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. 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); 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); 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); return mapToAssignmentDTO(assignment);
} }
@ -106,7 +135,16 @@ export async function deleteAssignment(classid: string, id: number): Promise<Ass
const cls = await fetchClass(classid); const cls = await fetchClass(classid);
const assignmentRepository = getAssignmentRepository(); 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); 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- // Convert the learning object notes as retrieved from the database into the expected response format-
const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor);
const nodesActuallyOnPath = traverseLearningPath(convertedNodes);
return { return {
_id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API. _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
__order: order, __order: order,
@ -79,8 +81,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
image: image, image: image,
title: learningPath.title, title: learningPath.title,
nodes: convertedNodes, nodes: convertedNodes,
num_nodes: learningPath.nodes.length, num_nodes: nodesActuallyOnPath.length,
num_nodes_left: convertedNodes.filter((it) => !it.done).length, num_nodes_left: nodesActuallyOnPath.filter((it) => !it.done).length,
keywords: keywords.join(' '), keywords: keywords.join(' '),
target_ages: targetAges, target_ages: targetAges,
max_age: Math.max(...targetAges), max_age: Math.max(...targetAges),
@ -180,7 +182,6 @@ function convertTransition(
return { return {
_id: String(index), // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path. _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. default: false, // We don't work with default transitions but retain this for backwards compatibility.
condition: transition.condition,
next: { next: {
_id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility. _id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility.
hruid: transition.next.learningObjectHruid, 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. * 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 { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js';
import { mapToStudent, mapToStudentDTO } from '../interfaces/student.js'; import { mapToStudent, mapToStudentDTO } from '../interfaces/student.js';
import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.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 { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js';
import { mapToStudentRequest, mapToStudentRequestDTO } from '../interfaces/student-request.js'; import { mapToStudentRequest, mapToStudentRequestDTO } from '../interfaces/student-request.js';
import { Student } from '../entities/users/student.entity.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 { ConflictException } from '../exceptions/conflict-exception.js';
import { Submission } from '../entities/assignments/submission.entity.js'; import { Submission } from '../entities/assignments/submission.entity.js';
import { mapToUsername } from '../interfaces/user.js'; import { mapToUsername } from '../interfaces/user.js';
import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js';
export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> { export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> {
const studentRepository = getStudentRepository(); const studentRepository = getStudentRepository();
@ -50,8 +51,7 @@ export async function fetchStudent(username: string): Promise<Student> {
} }
export async function fetchStudents(usernames: string[]): Promise<Student[]> { export async function fetchStudents(usernames: string[]): Promise<Student[]> {
const members = await Promise.all(usernames.map(async (username) => await fetchStudent(username))); return await Promise.all(usernames.map(async (username) => await fetchStudent(username)));
return members;
} }
export async function getStudent(username: string): Promise<StudentDTO> { 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[]> { export async function getStudentAssignments(username: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> {
const student = await fetchStudent(username); const student = await fetchStudent(username);
const classRepository = getClassRepository(); const groupRepository = getGroupRepository();
const classes = await classRepository.findByStudent(student); 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[]> { 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 { mapToClassDTO } from '../interfaces/class.js';
import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js'; import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js';
import { Teacher } from '../entities/users/teacher.entity.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 { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request';
import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; import { ClassStatus } from '@dwengo-1/common/util/class-join-request';
import { ConflictException } from '../exceptions/conflict-exception.js'; 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'; import { mapToUsername } from '../interfaces/user.js';
export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> { 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); 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[]> { export async function getStudentsByTeacher(username: string, full: boolean): Promise<StudentDTO[] | string[]> {
const classes: ClassDTO[] = await fetchClassesByTeacher(username); const classes: ClassDTO[] = await fetchClassesByTeacher(username);

View file

@ -14,6 +14,7 @@ import {
getStudentRequestsHandler, getStudentRequestsHandler,
deleteClassJoinRequestHandler, deleteClassJoinRequestHandler,
getStudentRequestHandler, getStudentRequestHandler,
getStudentAssignmentsHandler,
} from '../../src/controllers/students.js'; } from '../../src/controllers/students.js';
import { getDireStraits, getNoordkaap, getTheDoors, TEST_STUDENTS } from '../test_assets/users/students.testdata.js'; import { getDireStraits, getNoordkaap, getTheDoors, TEST_STUDENTS } from '../test_assets/users/students.testdata.js';
import { NotFoundException } from '../../src/exceptions/not-found-exception.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); 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 () => { it('Student submissions', async () => {
const submission = getSubmission01(); const submission = getSubmission01();
req = { params: { username: submission.submitter.username }, query: { full: 'true' } }; 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, { group05 = em.create(Group, {
assignment: getAssignment04(), assignment: getAssignment04(),
groupNumber: 21001, groupNumber: 21006,
members: [getNoordkaap(), getDireStraits()], 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 { makeTestLearningObjects } from '../tests/test_assets/content/learning-objects.testdata';
import { makeTestLearningPaths } from '../tests/test_assets/content/learning-paths.testdata'; import { makeTestLearningPaths } from '../tests/test_assets/content/learning-paths.testdata';
import { makeTestClasses } from '../tests/test_assets/classes/classes.testdata'; import { makeTestClasses } from '../tests/test_assets/classes/classes.testdata';
import { makeTestAssignemnts } from '../tests/test_assets/assignments/assignments.testdata'; import {
import { getTestGroup01, getTestGroup02, getTestGroup03, getTestGroup04, makeTestGroups } from '../tests/test_assets/assignments/groups.testdata'; 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 { Group } from '../src/entities/assignments/group.entity';
import { makeTestTeacherInvitations } from '../tests/test_assets/classes/teacher-invitations.testdata'; import { makeTestTeacherInvitations } from '../tests/test_assets/classes/teacher-invitations.testdata';
import { makeTestClassJoinRequests } from '../tests/test_assets/classes/class-join-requests.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); const groups = makeTestGroups(em);
assignments[0].groups = new Collection<Group>([getTestGroup01(), getTestGroup02(), getTestGroup03()]); let assignment = getAssignment01();
assignments[1].groups = new Collection<Group>([getTestGroup04()]); 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 teacherInvitations = makeTestTeacherInvitations(em);
const classJoinRequests = makeTestClassJoinRequests(em); const classJoinRequests = makeTestClassJoinRequests(em);

View file

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

View file

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

@ -1,18 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue"; 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(""); const datetime = ref("");
datetime.value = props.deadline ? new Date(props.deadline).toISOString().slice(0, 16) : "";
// Watch the datetime value and emit the update // Watch the datetime value and emit the update
watch(datetime, (val) => { watch(datetime, (val) => {
const newDate = new Date(val); const newDate = new Date(val);
if (!isNaN(newDate.getTime())) { if (!isNaN(newDate.getTime())) {
emit("update:deadline", newDate); 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> </script>
<template> <template>

View file

@ -1,75 +1,680 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import UsingQueryResult from "@/components/UsingQueryResult.vue"; import { useClassStudentsQuery } from "@/queries/classes";
import type { StudentsResponse } from "@/controllers/students.ts";
import { useClassStudentsQuery } from "@/queries/classes.ts";
const props = defineProps<{ const props = defineProps<{
classId: string | undefined; classId: string | undefined;
groups: string[][]; groups: object[];
}>(); }>();
const emit = defineEmits(["groupCreated"]); const emit = defineEmits(["close", "groupsUpdated", "done"]);
const { t } = useI18n(); const { t } = useI18n();
const selectedStudents = ref([]); interface StudentItem {
username: string;
const studentQueryResult = useClassStudentsQuery(() => props.classId, true); fullName: string;
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));
} }
function createGroup(): void { const { data: studentsData } = useClassStudentsQuery(() => props.classId, true);
if (selectedStudents.value.length) {
// Extract only usernames (student.value) // Dialog states for group editing
const usernames = selectedStudents.value.map((student) => student.value); const activeDialog = ref<"random" | "dragdrop" | null>(null);
emit("groupCreated", usernames);
selectedStudents.value = []; // Reset selection after creating group // 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> </script>
<template> <template>
<using-query-result <v-card class="pa-4 minimal-card">
:query-result="studentQueryResult" <!-- Current groups and unassigned students Preview -->
v-slot="{ data }: { data: StudentsResponse }" <div
> v-if="showGroupsPreview"
<h3>{{ t("create-groups") }}</h3> class="mb-6"
<v-card-text> >
<v-combobox <h3 class="mb-2">{{ t("current-groups") }}</h3>
v-model="selectedStudents" <div>
:items="filterStudents(data)" <div class="d-flex flex-wrap">
item-title="title" <label>{{ currentGroups.length }}</label>
item-value="value" </div>
:label="t('choose-students')" </div>
variant="outlined" </div>
clearable
multiple
hide-details
density="compact"
chips
append-inner-icon="mdi-magnify"
></v-combobox>
<v-row
justify="center"
class="mb-4"
>
<v-btn <v-btn
@click="createGroup"
color="primary" color="primary"
class="mt-2" @click="activeDialog = 'random'"
size="small" prepend-icon="mdi-shuffle"
> >
{{ t("create-group") }} {{ t("random-grouping") }}
</v-btn> </v-btn>
</v-card-text> <v-btn
</using-query-result> 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> </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}`); return this.get<AssignmentResponse>(`/${num}`);
} }
async createAssignment(data: AssignmentDTO): Promise<AssignmentResponse> { async createAssignment(data: Partial<AssignmentDTO>): Promise<AssignmentResponse> {
return this.post<AssignmentResponse>(`/`, data); 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 { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts";
import type { ClassesResponse } from "@/controllers/classes.ts"; import type { ClassesResponse } from "@/controllers/classes.ts";
import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher";
import type { AssignmentsResponse } from "./assignments";
export interface TeachersResponse { export interface TeachersResponse {
teachers: TeacherDTO[] | string[]; teachers: TeacherDTO[] | string[];
@ -35,6 +36,10 @@ export class TeacherController extends BaseController {
return this.get<ClassesResponse>(`/${username}/classes`, { full }); 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> { async getStudents(username: string, full = false): Promise<StudentsResponse> {
return this.get<StudentsResponse>(`/${username}/students`, { full }); return this.get<StudentsResponse>(`/${username}/students`, { full });
} }

View file

@ -105,7 +105,6 @@
"assignLearningPath": "Als Aufgabe geben", "assignLearningPath": "Als Aufgabe geben",
"group": "Gruppe", "group": "Gruppe",
"description": "Beschreibung", "description": "Beschreibung",
"no-submission": "keine vorlage",
"submission": "Einreichung", "submission": "Einreichung",
"progress": "Fortschritte", "progress": "Fortschritte",
"remove": "entfernen", "remove": "entfernen",
@ -166,5 +165,21 @@
"pathContainsNonExistingLearningObjects": "Mindestens eines der in diesem Pfad referenzierten Lernobjekte existiert nicht.", "pathContainsNonExistingLearningObjects": "Mindestens eines der in diesem Pfad referenzierten Lernobjekte existiert nicht.",
"targetAgesMandatory": "Zielalter müssen angegeben werden.", "targetAgesMandatory": "Zielalter müssen angegeben werden.",
"hintRemoveIfUnconditionalTransition": "(entfernen, wenn dies ein bedingungsloser Übergang sein soll)", "hintRemoveIfUnconditionalTransition": "(entfernen, wenn dies ein bedingungsloser Übergang sein soll)",
"hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt" "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"
} }

View file

@ -104,7 +104,6 @@
"assignLearningPath": "assign", "assignLearningPath": "assign",
"group": "Group", "group": "Group",
"description": "Description", "description": "Description",
"no-submission": "no submission",
"submission": "Submission", "submission": "Submission",
"progress": "Progress", "progress": "Progress",
"created": "created", "created": "created",
@ -122,6 +121,7 @@
"invite": "invite", "invite": "invite",
"assignmentIndicator": "ASSIGNMENT", "assignmentIndicator": "ASSIGNMENT",
"searchAllLearningPathsTitle": "Search all learning paths", "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.", "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-students-found": "This class has no students.",
"no-invitations-found": "You have no pending invitations.", "no-invitations-found": "You have no pending invitations.",
@ -166,5 +166,21 @@
"pathContainsNonExistingLearningObjects": "At least one of the learning objects referenced in this path does not exist.", "pathContainsNonExistingLearningObjects": "At least one of the learning objects referenced in this path does not exist.",
"targetAgesMandatory": "Target ages must be specified.", "targetAgesMandatory": "Target ages must be specified.",
"hintRemoveIfUnconditionalTransition": "(remove this if this should be an unconditional transition)", "hintRemoveIfUnconditionalTransition": "(remove this if this should be an unconditional transition)",
"hintKeywordsSeparatedBySpaces": "Keywords separated by spaces" "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"
} }

View file

@ -88,7 +88,7 @@
"deny": "refuser", "deny": "refuser",
"sent": "envoyé", "sent": "envoyé",
"failed": "échoué", "failed": "échoué",
"wrong": "quelque chose n'a pas fonctionné", "wrong": "Il y a une erreur",
"created": "créé", "created": "créé",
"callbackLoading": "Vous serez connecté...", "callbackLoading": "Vous serez connecté...",
"loginUnexpectedError": "La connexion a échoué", "loginUnexpectedError": "La connexion a échoué",
@ -98,14 +98,13 @@
"groupSubmissions": "Soumissions de ce groupe", "groupSubmissions": "Soumissions de ce groupe",
"taskCompleted": "Tâche terminée.", "taskCompleted": "Tâche terminée.",
"submittedBy": "Soumis par", "submittedBy": "Soumis par",
"timestamp": "Horodatage", "timestamp": "Date et heure",
"loadSubmission": "Charger", "loadSubmission": "Charger",
"noSubmissionsYet": "Pas encore de soumissions.", "noSubmissionsYet": "Pas encore de soumissions.",
"viewAsGroup": "Voir la progression du groupe...", "viewAsGroup": "Voir la progression du groupe...",
"assignLearningPath": "donner comme tâche", "assignLearningPath": "donner comme tâche",
"group": "Groupe", "group": "Groupe",
"description": "Description", "description": "Description",
"no-submission": "aucune soumission",
"submission": "Soumission", "submission": "Soumission",
"progress": "Progrès", "progress": "Progrès",
"remove": "supprimer", "remove": "supprimer",
@ -167,5 +166,21 @@
"pathContainsNonExistingLearningObjects": "Au moins un des objets dapprentissage référencés dans ce chemin nexiste pas.", "pathContainsNonExistingLearningObjects": "Au moins un des objets dapprentissage référencés dans ce chemin nexiste pas.",
"targetAgesMandatory": "Les âges cibles doivent être spécifiés.", "targetAgesMandatory": "Les âges cibles doivent être spécifiés.",
"hintRemoveIfUnconditionalTransition": "(supprimer ceci sil sagit dune transition inconditionnelle)", "hintRemoveIfUnconditionalTransition": "(supprimer ceci sil sagit dune transition inconditionnelle)",
"hintKeywordsSeparatedBySpaces": "Mots-clés séparés par des espaces" "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é"
} }

View file

@ -105,7 +105,6 @@
"assignLearningPath": "Als opdracht geven", "assignLearningPath": "Als opdracht geven",
"group": "Groep", "group": "Groep",
"description": "Beschrijving", "description": "Beschrijving",
"no-submission": "geen indiening",
"submission": "Indiening", "submission": "Indiening",
"progress": "Vooruitgang", "progress": "Vooruitgang",
"remove": "verwijder", "remove": "verwijder",
@ -166,5 +165,21 @@
"pathContainsNonExistingLearningObjects": "Ten minste één van de leerobjecten in dit pad bestaat niet.", "pathContainsNonExistingLearningObjects": "Ten minste één van de leerobjecten in dit pad bestaat niet.",
"targetAgesMandatory": "Doelleeftijden moeten worden opgegeven.", "targetAgesMandatory": "Doelleeftijden moeten worden opgegeven.",
"hintRemoveIfUnconditionalTransition": "(verwijder dit voor onvoorwaardelijke overgangen)", "hintRemoveIfUnconditionalTransition": "(verwijder dit voor onvoorwaardelijke overgangen)",
"hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties" "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"
} }

View file

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

View file

@ -1,13 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import auth from "@/services/auth/auth-service.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 StudentAssignment from "@/views/assignments/StudentAssignment.vue";
import TeacherAssignment from "@/views/assignments/TeacherAssignment.vue"; import TeacherAssignment from "@/views/assignments/TeacherAssignment.vue";
import { useRoute } from "vue-router"; 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"; import { AccountType } from "@dwengo-1/common/util/account-types";
const role = auth.authState.activeRole; const role = auth.authState.activeRole;
@ -16,58 +12,18 @@
const route = useRoute(); const route = useRoute();
const classId = ref<string>(route.params.classId as string); const classId = ref<string>(route.params.classId as string);
const assignmentId = ref(Number(route.params.id)); 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> </script>
<template> <template>
<TeacherAssignment <TeacherAssignment
:class-id="classId" :class-id="classId"
:assignment-id="assignmentId" :assignment-id="assignmentId"
:use-groups-with-progress="useGroupsWithProgress"
v-if="isTeacher" v-if="isTeacher"
> >
</TeacherAssignment> </TeacherAssignment>
<StudentAssignment <StudentAssignment
:class-id="classId" :class-id="classId"
:assignment-id="assignmentId" :assignment-id="assignmentId"
:use-groups-with-progress="useGroupsWithProgress"
v-else v-else
> >
</StudentAssignment> </StudentAssignment>

View file

@ -1,28 +1,26 @@
<script setup lang="ts"> <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 auth from "@/services/auth/auth-service.ts";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useAssignmentQuery } from "@/queries/assignments.ts";
import UsingQueryResult from "@/components/UsingQueryResult.vue"; import UsingQueryResult from "@/components/UsingQueryResult.vue";
import type { AssignmentResponse } from "@/controllers/assignments.ts";
import { asyncComputed } from "@vueuse/core"; import { asyncComputed } from "@vueuse/core";
import { useStudentsByUsernamesQuery } from "@/queries/students.ts"; import {
import { useGroupsQuery } from "@/queries/groups.ts"; useStudentAssignmentsQuery,
useStudentGroupsQuery,
useStudentsByUsernamesQuery,
} from "@/queries/students.ts";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import type { Language } from "@/data-objects/language.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<{ const props = defineProps<{
classId: string; classId: string;
assignmentId: number; assignmentId: number;
useGroupsWithProgress: (
groups: Ref<GroupDTO[]>,
hruid: Ref<string>,
language: Ref<Language>,
) => { groupProgressMap: Map<number, number> };
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const lang = ref();
const learningPath = ref(); const learningPath = ref();
// Get the user's username/id // Get the user's username/id
const username = asyncComputed(async () => { const username = asyncComputed(async () => {
@ -30,45 +28,70 @@
return user?.profile?.preferred_username ?? undefined; return user?.profile?.preferred_username ?? undefined;
}); });
const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId); const assignmentsQueryResult = useStudentAssignmentsQuery(username, true);
learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath;
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( const lpQueryResult = useGetLearningPathQuery(
computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""), () => learningPath.value,
computed(() => assignmentQueryResult.data.value?.assignment.language as Language), () => lang.value,
() => learningPathParams.value,
); );
const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); const progressColor = computed(() => {
const group = computed(() => const progress = calculateProgress(lpQueryResult.data.value as LearningPath);
groupsQueryResult?.data.value?.groups.find((group) => if (progress >= 100) return "success";
group.members?.some((m) => m.username === username.value), if (progress >= 50) return "warning";
), return "error";
); });
const _groupArray = computed(() => (group.value ? [group.value] : [])); const studentQueries = useStudentsByUsernamesQuery(() => (group.value?.members as string[]) ?? undefined);
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[]);
</script> </script>
<template> <template>
<div class="container"> <div class="container">
<using-query-result <using-query-result :query-result="assignmentsQueryResult">
:query-result="assignmentQueryResult"
v-slot="{ data }: { data: AssignmentResponse }"
>
<v-card <v-card
v-if="data" v-if="assignment"
class="assignment-card" class="assignment-card"
> >
<div class="top-buttons"> <div class="top-buttons">
@ -80,17 +103,8 @@ language
> >
<v-icon>mdi-arrow-left</v-icon> <v-icon>mdi-arrow-left</v-icon>
</v-btn> </v-btn>
<v-chip
v-if="submitted"
class="ma-2 top-right-btn"
label
color="success"
>
{{ t("submitted") }}
</v-chip>
</div> </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"> <v-card-subtitle class="subtitle-section">
<using-query-result <using-query-result
@ -99,7 +113,12 @@ language
> >
<v-btn <v-btn
v-if="lpData" 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" variant="tonal"
color="primary" color="primary"
> >
@ -109,20 +128,19 @@ language
</v-card-subtitle> </v-card-subtitle>
<v-card-text class="description"> <v-card-text class="description">
{{ data.assignment.description }} {{ assignment.description }}
</v-card-text> </v-card-text>
<v-card-text> <v-card-text>
<v-row <v-card-text>
align="center" <h3 class="mb-2">{{ t("progress") }}</h3>
no-gutters <using-query-result
> :query-result="lpQueryResult"
<v-col cols="auto"> v-slot="{ data: learningPData }"
<span class="progress-label">{{ t("progress") + ": " }}</span> >
</v-col>
<v-col>
<v-progress-linear <v-progress-linear
:model-value="progressValue" v-if="group"
color="primary" :model-value="calculateProgress(learningPData)"
:color="progressColor"
height="20" height="20"
class="progress-bar" class="progress-bar"
> >
@ -130,16 +148,20 @@ language
<strong>{{ Math.ceil(value) }}%</strong> <strong>{{ Math.ceil(value) }}%</strong>
</template> </template>
</v-progress-linear> </v-progress-linear>
</v-col> </using-query-result>
</v-row> </v-card-text>
</v-card-text> </v-card-text>
<v-card-text class="group-section"> <v-card-text
<h3>{{ t("group") }}</h3> class="group-section"
<div v-if="studentQueries"> v-if="group && studentQueries"
>
<h3>{{ `${t("group")} ${group.groupNo}` }}</h3>
<div>
<ul> <ul>
<li <li
v-for="student in group?.members" v-for="student in group.members"
:key="student.username" :key="student.username"
> >
{{ student.firstName + " " + student.lastName }} {{ student.firstName + " " + student.lastName }}
@ -147,6 +169,21 @@ language
</ul> </ul>
</div> </div>
</v-card-text> </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> </v-card>
</using-query-result> </using-query-result>
</div> </div>
@ -155,11 +192,6 @@ language
<style scoped> <style scoped>
@import "@/assets/assignment.css"; @import "@/assets/assignment.css";
.progress-label {
font-weight: bold;
margin-right: 5px;
}
.progress-bar { .progress-bar {
width: 40%; width: 40%;
} }

View file

@ -1,224 +1,485 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, type Ref, ref } from "vue"; import { computed, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n"; 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 UsingQueryResult from "@/components/UsingQueryResult.vue";
import { useGroupsQuery } from "@/queries/groups.ts"; import { useGroupsQuery } from "@/queries/groups.ts";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import type { Language } from "@/data-objects/language.ts"; import type { Language } from "@/data-objects/language.ts";
import type { AssignmentResponse } from "@/controllers/assignments.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<{ const props = defineProps<{
classId: string; classId: string;
assignmentId: number; assignmentId: number;
useGroupsWithProgress: (
groups: Ref<GroupDTO[]>,
hruid: Ref<string>,
language: Ref<Language>,
) => { groupProgressMap: Map<number, number> };
}>(); }>();
const isEditing = ref(false);
const { t } = useI18n(); const { t } = useI18n();
const groups = ref(); const lang = ref();
const groups = ref<GroupDTO[] | GroupDTOId[]>([]);
const learningPath = ref(); 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); const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId);
learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath;
// Get learning path object // Get learning path object
const lpQueryResult = useGetLearningPathQuery( const lpQueryResult = useGetLearningPathQuery(
computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""), 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 // Get all the groups withing the assignment
const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); 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 watchEffect(() => {
Const {groupProgressMap} = props.useGroupsWithProgress( const assignment = assignmentQueryResult.data.value?.assignment;
groups, if (assignment) {
learningPath, learningPath.value = assignment.learningPath;
language 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 allGroups = computed(() => {
const groups = groupsQueryResult.data.value?.groups; const groups = groupsQueryResult.data.value?.groups;
if (!groups) return []; if (!groups) return [];
return groups.map((group) => ({ // Sort by original groupNumber
name: `${t("group")} ${group.groupNumber}`, const sortedGroups = [...groups].sort((a, b) => a.groupNumber - b.groupNumber);
progress: 0, //GroupProgressMap[group.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, members: group.members,
submitted: false, //TODO: fetch from submission originalGroupNo: group.groupNumber,
})); }));
}); });
const dialog = ref(false); const dialog = ref(false);
const selectedGroup = ref({}); const selectedGroup = ref({});
function openGroupDetails(group): void { function openGroupDetails(group: object): void {
selectedGroup.value = group; selectedGroup.value = group;
dialog.value = true; dialog.value = true;
} }
const headers = computed(() => [ const snackbar = ref({
{ title: t("group"), align: "start", key: "name" }, visible: false,
{ title: t("progress"), align: "center", key: "progress" }, message: "",
{ title: t("submission"), align: "center", key: "submission" }, 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> { async function deleteAssignment(num: number, clsId: string): Promise<void> {
mutate( deleteAssignmentMutation.mutate(
{ cid: clsId, an: num }, { cid: clsId, an: num },
{ {
onSuccess: () => { onSuccess: () => {
window.location.href = "/user/assignment"; 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> </script>
<template> <template>
<div class="container"> <div class="container">
<using-query-result <using-query-result
:query-result="assignmentQueryResult" :query-result="assignmentQueryResult"
v-slot="{ data }: { data: AssignmentResponse }" v-slot="assignmentResponse: { data: AssignmentResponse }"
> >
<v-card <v-container
v-if="data" fluid
class="assignment-card" class="ma-4"
> >
<div class="top-buttons"> <v-row
<v-btn no-gutters
icon class="custom-breakpoint"
variant="text" >
class="back-btn" <v-col
to="/user/assignment" cols="12"
sm="6"
md="6"
class="responsive-col"
> >
<v-icon>mdi-arrow-left</v-icon> <v-form
</v-btn> ref="form"
validate-on="submit lazy"
<v-btn @submit.prevent="saveChanges"
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"
> >
{{ t("learning-path") }} <v-card
</v-btn> v-if="assignmentResponse"
</using-query-result> class="assignment-card-teacher"
</v-card-subtitle> >
<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"> <v-btn
{{ data.assignment.description }} v-if="!isEditing"
</v-card-text> 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"> <!-- A pop up to show group members -->
<h3>{{ t("groups") }}</h3> <v-dialog
<div class="table-scroll"> v-model="dialog"
<v-data-table max-width="600"
:headers="headers" persistent
:items="allGroups"
item-key="id"
class="elevation-1"
> >
<template #[`item.name`]="{ item }"> <v-card class="pa-4 rounded-xl elevation-6 group-members-dialog">
<v-btn <v-card-title class="text-h6 font-weight-bold">
@click="openGroupDetails(item)" {{ t("members") }}
variant="text" </v-card-title>
color="primary"
>
{{ item.name }}
</v-btn>
</template>
<template #[`item.progress`]="{ item }"> <v-divider class="my-2" />
<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>
<template #[`item.submission`]="{ item }"> <v-card-text>
<v-btn <v-list>
:to="item.submitted ? `${props.assignmentId}/submissions/` : undefined" <v-list-item
:color="item.submitted ? 'green' : 'red'" v-for="(member, index) in selectedGroup.members"
variant="text" :key="index"
class="text-capitalize" class="py-2"
> >
{{ item.submitted ? t("see-submission") : t("no-submission") }} <v-list-item-content>
</v-btn> <v-list-item-title class="text-body-1">
</template> {{ member.firstName }} {{ member.lastName }}
</v-data-table> </v-list-item-title>
</div> </v-list-item-content>
</v-card-text> </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-dialog
v-model="dialog" v-model="editGroups"
max-width="50%" max-width="800"
persistent
> >
<v-card> <v-card>
<v-card-title class="headline">{{ t("members") }}</v-card-title>
<v-card-text> <v-card-text>
<v-list> <GroupSelector
<v-list-item :groups="allGroups"
v-for="(member, index) in selectedGroup.members" :class-id="props.classId"
:key="index" :assignment-id="props.assignmentId"
> @groupsUpdated="handleGroupsUpdated"
<v-list-item-content> @close="editGroups = false"
<v-list-item-title />
>{{ member.firstName + " " + member.lastName }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card-text> </v-card-text>
<v-divider></v-divider>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer>
<v-btn <v-btn
color="primary" text
@click="dialog = false" @click="editGroups = false"
>Close >
{{ t("cancel") }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- </v-container>
<v-card-actions class="justify-end"> <v-snackbar
<v-btn v-model="snackbar.visible"
size="large" :color="snackbar.color"
color="success" timeout="3000"
variant="text" >
> {{ snackbar.message }}
{{ t("view-submissions") }} </v-snackbar>
</v-btn>
</v-card-actions>
-->
</v-card>
</using-query-result> </using-query-result>
</div> </div>
</template> </template>
@ -226,8 +487,130 @@ language
<style scoped> <style scoped>
@import "@/assets/assignment.css"; @import "@/assets/assignment.css";
.assignment-card-teacher {
width: 80%;
padding: 2%;
border-radius: 12px;
}
.table-scroll { .table-scroll {
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -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> </style>

View file

@ -2,74 +2,78 @@
import { ref, computed, onMounted, watch } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import authState from "@/services/auth/auth-service.ts";
import auth from "@/services/auth/auth-service.ts"; import auth from "@/services/auth/auth-service.ts";
import { useTeacherClassesQuery } from "@/queries/teachers.ts"; import { useTeacherAssignmentsQuery, useTeacherClassesQuery } from "@/queries/teachers.ts";
import { useStudentClassesQuery } from "@/queries/students.ts"; import { useStudentAssignmentsQuery, 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 { useDeleteAssignmentMutation } from "@/queries/assignments.ts"; import { useDeleteAssignmentMutation } from "@/queries/assignments.ts";
import { AccountType } from "@dwengo-1/common/util/account-types"; import UsingQueryResult from "@/components/UsingQueryResult.vue";
import "../../assets/common.css";
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
const role = ref(auth.authState.activeRole); 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 const classesQueryResult = isTeacher.value
let classesQueryResults = undefined; ? useTeacherClassesQuery(username, true)
: useStudentClassesQuery(username, true);
if (isTeacher.value) { const assignmentsQueryResult = isTeacher.value
classesQueryResults = useTeacherClassesQuery(username, true); ? useTeacherAssignmentsQuery(username, true)
} else { : useStudentAssignmentsQuery(username, true);
classesQueryResults = useStudentClassesQuery(username, true);
}
const classController = new ClassController(); const allAssignments = computed(() => {
const assignments = assignmentsQueryResult.data.value?.assignments;
if (!assignments) return [];
const assignments = asyncComputed( const classes = classesQueryResult.data.value?.classes;
async () => { if (!classes) return [];
const classes = classesQueryResults?.data?.value?.classes;
if (!classes) return [];
const result = await Promise.all( const result = assignments.map((a) => ({
(classes as ClassDTO[]).map(async (cls) => { id: a.id,
const { assignments } = await classController.getAssignments(cls.id); class: classes.find((cls) => cls?.id === a.within) ?? undefined,
return assignments.map((a) => ({ title: a.title,
id: a.id, description: a.description,
class: cls, learningPath: a.learningPath,
title: a.title, language: a.language,
description: a.description, deadline: a.deadline,
learningPath: a.learningPath, groups: a.groups,
language: a.language, }));
deadline: a.deadline,
groups: a.groups,
}));
}),
);
// Order the assignments by deadline // Order the assignments by deadline
return result.flat().sort((a, b) => { return result.flat().sort((a, b) => {
const now = Date.now(); const now = Date.now();
const aTime = new Date(a.deadline).getTime(); const aTime = new Date(a.deadline).getTime();
const bTime = new Date(b.deadline).getTime(); const bTime = new Date(b.deadline).getTime();
const aIsPast = aTime < now; const aIsPast = aTime < now;
const bIsPast = bTime < 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; return aTime - bTime;
}); });
}, });
[],
{ evaluating: true },
);
async function goToCreateAssignment(): Promise<void> { async function goToCreateAssignment(): Promise<void> {
await router.push("/assignment/create"); await router.push("/assignment/create");
@ -79,16 +83,35 @@
await router.push(`/assignment/${clsId}/${id}`); await router.push(`/assignment/${clsId}/${id}`);
} }
const { mutate, data, isSuccess } = useDeleteAssignmentMutation(); const snackbar = ref({
visible: false,
watch([isSuccess, data], async ([success, oldData]) => { message: "",
if (success && oldData?.assignment) { color: "success",
window.location.reload();
}
}); });
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> { 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 { function formatDate(date?: string | Date): string {
@ -124,6 +147,11 @@
const user = await auth.loadUser(); const user = await auth.loadUser();
username.value = user?.profile?.preferred_username ?? ""; username.value = user?.profile?.preferred_username ?? "";
}); });
onMounted(async () => {
const user = await auth.loadUser();
username.value = user?.profile?.preferred_username ?? "";
});
</script> </script>
<template> <template>
@ -132,68 +160,84 @@
<v-btn <v-btn
v-if="isTeacher" v-if="isTeacher"
color="primary" :style="{ backgroundColor: '#0E6942' }"
class="mb-4 center-btn" class="mb-4 center-btn"
@click="goToCreateAssignment" @click="goToCreateAssignment"
> >
{{ t("new-assignment") }} {{ t("new-assignment") }}
</v-btn> </v-btn>
<v-container> <using-query-result :query-result="assignmentsQueryResult">
<v-row> <v-container>
<v-col <v-row>
v-for="assignment in assignments" <v-col
:key="assignment.id" v-for="assignment in allAssignments"
cols="12" :key="assignment.id"
> cols="12"
<v-card class="assignment-card"> >
<div class="top-content"> <v-card class="assignment-card">
<div class="assignment-title">{{ assignment.title }}</div> <div class="top-content">
<div class="assignment-class"> <div class="assignment-title">{{ assignment.title }}</div>
{{ t("class") }}: <div class="assignment-class">
<span class="class-name"> {{ t("class") }}:
{{ assignment.class.displayName }} <a
</span> :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>
<div
class="assignment-deadline" <div class="spacer"></div>
:class="getDeadlineClass(assignment.deadline)"
> <div class="button-row">
{{ t("deadline") }}: <v-btn
<span>{{ formatDate(assignment.deadline) }}</span> 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> </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>
</v-col>
<div class="spacer"></div> </v-row>
</v-container>
<div class="button-row"> <v-snackbar
<v-btn v-model="snackbar.visible"
color="primary" :color="snackbar.color"
variant="text" timeout="3000"
@click="goToAssignmentDetails(assignment.id, assignment.class.id)" >
> {{ snackbar.message }}
{{ t("view-assignment") }} </v-snackbar>
</v-btn> </using-query-result>
<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>
</div> </div>
</template> </template>
@ -212,6 +256,7 @@
color: white; color: white;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.center-btn:hover { .center-btn:hover {
background-color: #0e6942; background-color: #0e6942;
} }
@ -225,6 +270,7 @@
transform 0.2s, transform 0.2s,
box-shadow 0.2s; box-shadow 0.2s;
} }
.assignment-card:hover { .assignment-card:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
} }
@ -248,6 +294,10 @@
margin-bottom: 0.2rem; margin-bottom: 0.2rem;
} }
.assignment-class a {
text-decoration: none;
}
.class-name { .class-name {
font-weight: 600; font-weight: 600;
color: #097180; 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"; import { ClassController } from "../../src/controllers/classes";
describe("Test controller classes", () => { describe("ClassController Tests", () => {
it("Get classes", async () => { let controller: ClassController;
const controller = new ClassController(); const testClassId = "X2J9QT";
const data = await controller.getAll(true);
expect(data.classes).to.have.length.greaterThan(0); 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.");
});
});
});