From c31b4713714670b03636abafcfb70579405c9936 Mon Sep 17 00:00:00 2001 From: Gabriellvl Date: Fri, 21 Mar 2025 22:51:42 +0100 Subject: [PATCH 001/112] feat: questions via student --- backend/src/controllers/students.ts | 108 ++++++++++-------- backend/src/controllers/teachers.ts | 100 ++++++---------- .../src/data/questions/question-repository.ts | 8 ++ backend/src/routes/students.ts | 18 +-- backend/src/routes/teachers.ts | 2 - backend/src/services/students.ts | 33 ++++-- backend/src/services/teachers.ts | 55 +++------ 7 files changed, 153 insertions(+), 171 deletions(-) diff --git a/backend/src/controllers/students.ts b/backend/src/controllers/students.ts index 6c253cff..cde6b5ef 100644 --- a/backend/src/controllers/students.ts +++ b/backend/src/controllers/students.ts @@ -1,53 +1,43 @@ import { Request, Response } from 'express'; import { createStudent, - deleteStudent, + deleteStudent, getAllStudentIds, getAllStudents, getStudent, getStudentAssignments, getStudentClasses, - getStudentGroups, + getStudentGroups, getStudentQuestions, getStudentSubmissions, } from '../services/students.js'; -import { ClassDTO } from '../interfaces/class.js'; -import { getAllAssignments } from '../services/assignments.js'; -import { getUserHandler } from './users.js'; -import { Student } from '../entities/users/student.entity.js'; +import {MISSING_FIELDS_ERROR, MISSING_USERNAME_ERROR, NAME_NOT_FOUND_ERROR} from './users.js'; import { StudentDTO } from '../interfaces/student.js'; -import { getStudentRepository } from '../data/repositories.js'; -import { UserDTO } from '../interfaces/user.js'; -// TODO: accept arguments (full, ...) -// TODO: endpoints + export async function getAllStudentsHandler(req: Request, res: Response): Promise { const full = req.query.full === 'true'; - const studentRepository = getStudentRepository(); - - const students: StudentDTO[] | string[] = full ? await getAllStudents() : await getAllStudents(); + const students: StudentDTO[] | string[] = full ? await getAllStudents() : await getAllStudentIds(); if (!students) { - res.status(404).json({ error: `Student not found.` }); + res.status(404).json({ error: `Students not found.` }); return; } - res.status(201).json(students); + res.json({students}); } export async function getStudentHandler(req: Request, res: Response): Promise { const username = req.params.username; if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); + res.status(400).json(MISSING_USERNAME_ERROR); return; } const user = await getStudent(username); if (!user) { - res.status(404).json({ - error: `User with username '${username}' not found.`, - }); + res.status(404).json(NAME_NOT_FOUND_ERROR(username)); return; } @@ -58,9 +48,7 @@ export async function createStudentHandler(req: Request, res: Response) { const userData = req.body as StudentDTO; if (!userData.username || !userData.firstName || !userData.lastName) { - res.status(400).json({ - error: 'Missing required fields: username, firstName, lastName', - }); + res.status(400).json(MISSING_FIELDS_ERROR); return; } @@ -72,15 +60,13 @@ export async function deleteStudentHandler(req: Request, res: Response) { const username = req.params.username; if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); + res.status(400).json(MISSING_USERNAME_ERROR); return; } const deletedUser = await deleteStudent(username); if (!deletedUser) { - res.status(404).json({ - error: `User with username '${username}' not found.`, - }); + res.status(404).json(NAME_NOT_FOUND_ERROR(username)); return; } @@ -88,25 +74,19 @@ export async function deleteStudentHandler(req: Request, res: Response) { } export async function getStudentClassesHandler(req: Request, res: Response): Promise { - try { - const full = req.query.full === 'true'; - const username = req.params.id; + const full = req.query.full === 'true'; + const username = req.params.username; - const classes = await getStudentClasses(username, full); - - res.json({ - classes: classes, - endpoints: { - self: `${req.baseUrl}/${req.params.id}`, - classes: `${req.baseUrl}/${req.params.id}/invitations`, - questions: `${req.baseUrl}/${req.params.id}/assignments`, - students: `${req.baseUrl}/${req.params.id}/students`, - }, - }); - } catch (error) { - console.error('Error fetching learning objects:', error); - res.status(500).json({ error: 'Internal server error' }); + if (!username) { + res.status(400).json(MISSING_USERNAME_ERROR); + return; } + + const classes = await getStudentClasses(username, full); + + res.json({ + classes, + }); } // TODO @@ -115,32 +95,62 @@ export async function getStudentClassesHandler(req: Request, res: Response): Pro // Have this assignment. export async function getStudentAssignmentsHandler(req: Request, res: Response): Promise { const full = req.query.full === 'true'; - const username = req.params.id; + const username = req.params.username; + + if (!username) { + res.status(400).json(MISSING_USERNAME_ERROR); + return; + } const assignments = getStudentAssignments(username, full); res.json({ - assignments: assignments, + assignments, }); } export async function getStudentGroupsHandler(req: Request, res: Response): Promise { const full = req.query.full === 'true'; - const username = req.params.id; + const username = req.params.username; + + if (!username) { + res.status(400).json(MISSING_USERNAME_ERROR); + return; + } const groups = await getStudentGroups(username, full); res.json({ - groups: groups, + groups, }); } export async function getStudentSubmissionsHandler(req: Request, res: Response): Promise { - const username = req.params.id; + const username = req.params.username; + + if (!username) { + res.status(400).json(MISSING_USERNAME_ERROR); + return; + } const submissions = await getStudentSubmissions(username); res.json({ - submissions: submissions, + submissions, }); } + +export async function getStudentQuestionsHandler(req: Request, res: Response): Promise { + const username = req.params.username; + + if (!username) { + res.status(400).json(MISSING_USERNAME_ERROR); + return; + } + + const questions = await getStudentQuestions(username, full); + + res.json({ + questions, + }) +} diff --git a/backend/src/controllers/teachers.ts b/backend/src/controllers/teachers.ts index 52e5e713..fb4c2a0d 100644 --- a/backend/src/controllers/teachers.ts +++ b/backend/src/controllers/teachers.ts @@ -4,49 +4,40 @@ import { deleteTeacher, getAllTeachers, getClassesByTeacher, - getClassIdsByTeacher, - getQuestionIdsByTeacher, - getQuestionsByTeacher, - getStudentIdsByTeacher, getStudentsByTeacher, getTeacher, } from '../services/teachers.js'; import { ClassDTO } from '../interfaces/class.js'; import { StudentDTO } from '../interfaces/student.js'; import { QuestionDTO, QuestionId } from '../interfaces/question.js'; -import { Teacher } from '../entities/users/teacher.entity.js'; import { TeacherDTO } from '../interfaces/teacher.js'; -import { getTeacherRepository } from '../data/repositories.js'; +import {MISSING_FIELDS_ERROR, MISSING_USERNAME_ERROR, NAME_NOT_FOUND_ERROR} from "./users"; export async function getAllTeachersHandler(req: Request, res: Response): Promise { const full = req.query.full === 'true'; - const teacherRepository = getTeacherRepository(); - - const teachers: TeacherDTO[] | string[] = full ? await getAllTeachers() : await getAllTeachers(); + const teachers: TeacherDTO[] | string[] = await getAllTeachers(full); if (!teachers) { - res.status(404).json({ error: `Teacher not found.` }); + res.status(404).json({ error: `Teachers not found.` }); return; } - res.status(201).json(teachers); + res.json({teachers}); } export async function getTeacherHandler(req: Request, res: Response): Promise { const username = req.params.username; if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); + res.status(400).json(MISSING_USERNAME_ERROR); return; } const user = await getTeacher(username); if (!user) { - res.status(404).json({ - error: `User with username '${username}' not found.`, - }); + res.status(404).json(NAME_NOT_FOUND_ERROR(username)); return; } @@ -57,9 +48,7 @@ export async function createTeacherHandler(req: Request, res: Response) { const userData = req.body as TeacherDTO; if (!userData.username || !userData.firstName || !userData.lastName) { - res.status(400).json({ - error: 'Missing required fields: username, firstName, lastName', - }); + res.status(400).json(MISSING_FIELDS_ERROR); return; } @@ -71,15 +60,13 @@ export async function deleteTeacherHandler(req: Request, res: Response) { const username = req.params.username; if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); + res.status(400).json(MISSING_USERNAME_ERROR); return; } const deletedUser = await deleteTeacher(username); if (!deletedUser) { - res.status(404).json({ - error: `User with username '${username}' not found.`, - }); + res.status(404).json(NAME_NOT_FOUND_ERROR(username)); return; } @@ -87,58 +74,43 @@ export async function deleteTeacherHandler(req: Request, res: Response) { } export async function getTeacherClassHandler(req: Request, res: Response): Promise { - try { - const username = req.params.username as string; - const full = req.query.full === 'true'; + const username = req.params.username as string; + const full = req.query.full === 'true'; - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } - - const classes: ClassDTO[] | string[] = full ? await getClassesByTeacher(username) : await getClassIdsByTeacher(username); - - res.status(201).json(classes); - } catch (error) { - console.error('Error fetching classes by teacher:', error); - res.status(500).json({ error: 'Internal server error' }); + if (!username) { + res.status(400).json(MISSING_USERNAME_ERROR); + return; } + + const classes: ClassDTO[] | string[] = await getClassesByTeacher(username, full); + + res.status(201).json(classes); } export async function getTeacherStudentHandler(req: Request, res: Response): Promise { - try { - const username = req.params.username as string; - const full = req.query.full === 'true'; + const username = req.params.username as string; + const full = req.query.full === 'true'; - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } - - const students: StudentDTO[] | string[] = full ? await getStudentsByTeacher(username) : await getStudentIdsByTeacher(username); - - res.status(201).json(students); - } catch (error) { - console.error('Error fetching students by teacher:', error); - res.status(500).json({ error: 'Internal server error' }); + if (!username) { + res.status(400).json(MISSING_USERNAME_ERROR); + return; } + + const students: StudentDTO[] | string[] = await getStudentsByTeacher(username, full); + + res.json({students}); } export async function getTeacherQuestionHandler(req: Request, res: Response): Promise { - try { - const username = req.params.username as string; - const full = req.query.full === 'true'; + const username = req.params.username as string; + const full = req.query.full === 'true'; - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } - - const questions: QuestionDTO[] | QuestionId[] = full ? await getQuestionsByTeacher(username) : await getQuestionIdsByTeacher(username); - - res.status(201).json(questions); - } catch (error) { - console.error('Error fetching questions by teacher:', error); - res.status(500).json({ error: 'Internal server error' }); + if (!username) { + res.status(400).json(MISSING_USERNAME_ERROR); + return; } + + const questions: QuestionDTO[] | QuestionId[] = await getQuestionsByTeacher(username, full); + + res.json({questions}); } diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index 9207e1dd..8789c2e9 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -54,4 +54,12 @@ export class QuestionRepository extends DwengoEntityRepository { orderBy: { timestamp: 'ASC' }, }); } + + public findAllByAuthor(author: Student): Promise { + return this.findAll({ + where: { author }, + orderBy: { timestamp: 'DESC' }, // new to old + }); + } + } diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts index 7ed7a666..77775b8f 100644 --- a/backend/src/routes/students.ts +++ b/backend/src/routes/students.ts @@ -6,7 +6,7 @@ import { getStudentAssignmentsHandler, getStudentClassesHandler, getStudentGroupsHandler, - getStudentHandler, + getStudentHandler, getStudentQuestionsHandler, getStudentSubmissionsHandler, } from '../controllers/students.js'; import { getStudentGroups } from '../services/students.js'; @@ -17,30 +17,24 @@ router.get('/', getAllStudentsHandler); router.post('/', createStudentHandler); -router.delete('/', deleteStudentHandler); - router.delete('/:username', deleteStudentHandler); // Information about a student's profile router.get('/:username', getStudentHandler); // The list of classes a student is in -router.get('/:id/classes', getStudentClassesHandler); +router.get('/:username/classes', getStudentClassesHandler); // The list of submissions a student has made -router.get('/:id/submissions', getStudentSubmissionsHandler); +router.get('/:username/submissions', getStudentSubmissionsHandler); // The list of assignments a student has -router.get('/:id/assignments', getStudentAssignmentsHandler); +router.get('/:username/assignments', getStudentAssignmentsHandler); // The list of groups a student is in -router.get('/:id/groups', getStudentGroupsHandler); +router.get('/:username/groups', getStudentGroupsHandler); // A list of questions a user has created -router.get('/:id/questions', (req, res) => { - res.json({ - questions: ['0'], - }); -}); +router.get('/:username/questions', getStudentQuestionsHandler); export default router; diff --git a/backend/src/routes/teachers.ts b/backend/src/routes/teachers.ts index c04e1575..8e7f709d 100644 --- a/backend/src/routes/teachers.ts +++ b/backend/src/routes/teachers.ts @@ -15,8 +15,6 @@ router.get('/', getAllTeachersHandler); router.post('/', createTeacherHandler); -router.delete('/', deleteTeacherHandler); - router.get('/:username', getTeacherHandler); router.delete('/:username', deleteTeacherHandler); diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts index 5099a18d..70bed549 100644 --- a/backend/src/services/students.ts +++ b/backend/src/services/students.ts @@ -1,13 +1,17 @@ -import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../data/repositories.js'; -import { Class } from '../entities/classes/class.entity.js'; -import { Student } from '../entities/users/student.entity.js'; +import { + getClassRepository, + getGroupRepository, + getQuestionRepository, + getStudentRepository, + getSubmissionRepository +} from '../data/repositories.js'; import { AssignmentDTO } from '../interfaces/assignment.js'; import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; import { GroupDTO, mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; import { mapToStudent, mapToStudentDTO, StudentDTO } from '../interfaces/student.js'; import { mapToSubmissionDTO, SubmissionDTO } from '../interfaces/submission.js'; import { getAllAssignments } from './assignments.js'; -import { UserService } from './users.js'; +import {mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId} from "../interfaces/question"; export async function getAllStudents(): Promise { const studentRepository = getStudentRepository(); @@ -88,9 +92,7 @@ export async function getStudentAssignments(username: string, full: boolean): Pr const classRepository = getClassRepository(); const classes = await classRepository.findByStudent(student); - const assignments = (await Promise.all(classes.map(async (cls) => await getAllAssignments(cls.classId!, full)))).flat(); - - return assignments; + return (await Promise.all(classes.map(async (cls) => await getAllAssignments(cls.classId!, full)))).flat(); } export async function getStudentGroups(username: string, full: boolean): Promise { @@ -124,3 +126,20 @@ export async function getStudentSubmissions(username: string): Promise { + const studentRepository = getStudentRepository(); + const student = await studentRepository.findByUsername(username); + + if (!student) { + return []; + } + + const questionRepository = getQuestionRepository(); + const questions = await questionRepository.findAllByAuthor(student); + + if (full) + return questions.map(mapToQuestionDTO) + + return questions.map(mapToQuestionId); +} diff --git a/backend/src/services/teachers.ts b/backend/src/services/teachers.ts index f4dbedfe..5b0d8144 100644 --- a/backend/src/services/teachers.ts +++ b/backend/src/services/teachers.ts @@ -5,23 +5,17 @@ import { getStudentRepository, getTeacherRepository, } from '../data/repositories.js'; -import { Teacher } from '../entities/users/teacher.entity.js'; import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; import { getClassStudents } from './class.js'; -import { StudentDTO } from '../interfaces/student.js'; -import { mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId } from '../interfaces/question.js'; -import { UserService } from './users.js'; -import { mapToUser } from '../interfaces/user.js'; +import { mapToQuestionDTO, mapToQuestionId, QuestionDTO } from '../interfaces/question.js'; import { mapToTeacher, mapToTeacherDTO, TeacherDTO } from '../interfaces/teacher.js'; -export async function getAllTeachers(): Promise { +export async function getAllTeachers(full: boolean): Promise { const teacherRepository = getTeacherRepository(); const users = await teacherRepository.findAll(); - return users.map(mapToTeacherDTO); -} -export async function getAllTeacherIds(): Promise { - const users = await getAllTeachers(); + if (full) + return users.map(mapToTeacherDTO); return users.map((user) => user.username); } @@ -64,7 +58,7 @@ export async function deleteTeacher(username: string): Promise { +async function fetchClassesByTeacher(username: string): Promise { const teacherRepository = getTeacherRepository(); const teacher = await teacherRepository.findByUsername(username); if (!teacher) { @@ -76,31 +70,24 @@ export async function fetchClassesByTeacher(username: string): Promise { - return await fetchClassesByTeacher(username); -} - -export async function getClassIdsByTeacher(username: string): Promise { +export async function getClassesByTeacher(username: string, full: boolean): Promise { const classes = await fetchClassesByTeacher(username); + + if (full) + return classes; return classes.map((cls) => cls.id); } -export async function fetchStudentsByTeacher(username: string) { - const classes = await getClassIdsByTeacher(username); +export async function getStudentsByTeacher(username: string, full: boolean) { + const classes = await getClassesByTeacher(username, false); - return (await Promise.all(classes.map(async (id) => getClassStudents(id)))).flat(); -} - -export async function getStudentsByTeacher(username: string): Promise { - return await fetchStudentsByTeacher(username); -} - -export async function getStudentIdsByTeacher(username: string): Promise { - const students = await fetchStudentsByTeacher(username); + const students = (await Promise.all(classes.map(async (id) => getClassStudents(id)))).flat(); + if (full) + return students return students.map((student) => student.username); } -export async function fetchTeacherQuestions(username: string): Promise { +export async function getTeacherQuestions(username: string, full: boolean): Promise { const teacherRepository = getTeacherRepository(); const teacher = await teacherRepository.findByUsername(username); if (!teacher) { @@ -115,15 +102,9 @@ export async function fetchTeacherQuestions(username: string): Promise { - return await fetchTeacherQuestions(username); -} - -export async function getQuestionIdsByTeacher(username: string): Promise { - const questions = await fetchTeacherQuestions(username); + if (full) + return questions.map(mapToQuestionDTO); return questions.map(mapToQuestionId); } + From bfb9598fa11fc9344a9e1de5dcd2a73d3094e2f8 Mon Sep 17 00:00:00 2001 From: Gabriellvl Date: Fri, 21 Mar 2025 22:52:09 +0100 Subject: [PATCH 002/112] feat: gemeenschappelijke errors in aparte file --- backend/src/controllers/users.ts | 92 ++------------------------------ 1 file changed, 4 insertions(+), 88 deletions(-) diff --git a/backend/src/controllers/users.ts b/backend/src/controllers/users.ts index 850c6549..16f0e55d 100644 --- a/backend/src/controllers/users.ts +++ b/backend/src/controllers/users.ts @@ -1,91 +1,7 @@ -import { Request, Response } from 'express'; -import { UserService } from '../services/users.js'; -import { UserDTO } from '../interfaces/user.js'; -import { User } from '../entities/users/user.entity.js'; +export const MISSING_USERNAME_ERROR = { error: 'Missing required field: username' }; -export async function getAllUsersHandler(req: Request, res: Response, service: UserService): Promise { - try { - const full = req.query.full === 'true'; - - const users: UserDTO[] | string[] = full ? await service.getAllUsers() : await service.getAllUserIds(); - - if (!users) { - res.status(404).json({ error: `Users not found.` }); - return; - } - - res.status(201).json(users); - } catch (error) { - console.error('❌ Error fetching users:', error); - res.status(500).json({ error: 'Internal server error' }); - } +export function NAME_NOT_FOUND_ERROR(username: string){ + return {error: `User with username '${username}' not found.`}; } -export async function getUserHandler(req: Request, res: Response, service: UserService): Promise { - try { - const username = req.params.username as string; - - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } - - const user = await service.getUserByUsername(username); - - if (!user) { - res.status(404).json({ - error: `User with username '${username}' not found.`, - }); - return; - } - - res.status(201).json(user); - } catch (error) { - console.error('❌ Error fetching users:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -export async function createUserHandler(req: Request, res: Response, service: UserService, UserClass: new () => T) { - try { - console.log('req', req); - const userData = req.body as UserDTO; - - if (!userData.username || !userData.firstName || !userData.lastName) { - res.status(400).json({ - error: 'Missing required fields: username, firstName, lastName', - }); - return; - } - - const newUser = await service.createUser(userData, UserClass); - res.status(201).json(newUser); - } catch (error) { - console.error('❌ Error creating user:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -export async function deleteUserHandler(req: Request, res: Response, service: UserService) { - try { - const username = req.params.username; - - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } - - const deletedUser = await service.deleteUser(username); - if (!deletedUser) { - res.status(404).json({ - error: `User with username '${username}' not found.`, - }); - return; - } - - res.status(200).json(deletedUser); - } catch (error) { - console.error('❌ Error deleting user:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} +export const MISSING_FIELDS_ERROR = { error: 'Missing required fields: username, firstName, lastName'} From 52364d717c559be58828d9da872dde58225a8f8d Mon Sep 17 00:00:00 2001 From: Gabriellvl Date: Fri, 21 Mar 2025 23:23:33 +0100 Subject: [PATCH 003/112] feat: teacher en student frontend controllers --- backend/src/routes/router.ts | 2 + frontend/config.ts | 1 + frontend/src/controllers/base-controller.ts | 72 +++++++++++++++++++ frontend/src/controllers/controllers.ts | 16 +++++ .../src/controllers/student-controller.ts | 44 ++++++++++++ .../src/controllers/teacher-controller.ts | 37 ++++++++++ 6 files changed, 172 insertions(+) create mode 100644 frontend/config.ts create mode 100644 frontend/src/controllers/base-controller.ts create mode 100644 frontend/src/controllers/controllers.ts create mode 100644 frontend/src/controllers/student-controller.ts create mode 100644 frontend/src/controllers/teacher-controller.ts diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts index 639857a7..e05f9909 100644 --- a/backend/src/routes/router.ts +++ b/backend/src/routes/router.ts @@ -1,5 +1,6 @@ import { Response, Router } from 'express'; import studentRouter from './students.js'; +import teacherRouter from './teachers.js'; import groupRouter from './groups.js'; import assignmentRouter from './assignments.js'; import submissionRouter from './submissions.js'; @@ -22,6 +23,7 @@ router.get('/', (_, res: Response) => { }); router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); +router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */); router.use('/group', groupRouter /* #swagger.tags = ['Group'] */); router.use('/assignment', assignmentRouter /* #swagger.tags = ['Assignment'] */); router.use('/submission', submissionRouter /* #swagger.tags = ['Submission'] */); diff --git a/frontend/config.ts b/frontend/config.ts new file mode 100644 index 00000000..656687fd --- /dev/null +++ b/frontend/config.ts @@ -0,0 +1 @@ +export const API_BASE = "http://localhost:3000/api"; diff --git a/frontend/src/controllers/base-controller.ts b/frontend/src/controllers/base-controller.ts new file mode 100644 index 00000000..8005a32e --- /dev/null +++ b/frontend/src/controllers/base-controller.ts @@ -0,0 +1,72 @@ +import {API_BASE} from "../../config.ts"; + +export class BaseController { + protected baseUrl: string; + + constructor(basePath: string) { + this.baseUrl = `${API_BASE}/${basePath}`; + } + + protected async get(path: string, queryParams?: Record): Promise { + let url = `${this.baseUrl}${path}`; + if (queryParams) { + const query = new URLSearchParams(); + Object.entries(queryParams).forEach(([key, value]) => { + if (value !== undefined && value !== null) query.append(key, value.toString()); + }); + url += `?${query.toString()}`; + } + + const res = await fetch(url); + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); + } + + return res.json(); + } + + protected async post(path: string, body: unknown): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); + } + + return res.json(); + } + + protected async delete(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method: "DELETE", + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); + } + + return res.json(); + } + + protected async put(path: string, body: unknown): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData?.error || `Error ${res.status}: ${res.statusText}`); + } + + return res.json(); + } + +} diff --git a/frontend/src/controllers/controllers.ts b/frontend/src/controllers/controllers.ts new file mode 100644 index 00000000..61bb09d6 --- /dev/null +++ b/frontend/src/controllers/controllers.ts @@ -0,0 +1,16 @@ +import {StudentController} from "@/controllers/student-controller.ts"; +import {TeacherController} from "@/controllers/teacher-controller.ts"; + +export function controllerGetter(Factory: new () => T): () => T { + let instance: T | undefined; + + return (): T => { + if (!instance) { + instance = new Factory(); + } + return instance; + }; +} + +export const getStudentController = controllerGetter(StudentController); +export const getTeacherController = controllerGetter(TeacherController); diff --git a/frontend/src/controllers/student-controller.ts b/frontend/src/controllers/student-controller.ts new file mode 100644 index 00000000..e74bd74c --- /dev/null +++ b/frontend/src/controllers/student-controller.ts @@ -0,0 +1,44 @@ +import {BaseController} from "@/controllers/base-controller.ts"; + +export class StudentController extends BaseController { + constructor() { + super("students"); + } + + getAll(full = true) { + return this.get<{ students: any[] }>("/", { full }); + } + + getByUsername(username: string) { + return this.get(`/${username}`); + } + + createStudent(data: any) { + return this.post("/", data); + } + + deleteStudent(username: string) { + return this.delete(`/${username}`); + } + + getClasses(username: string, full = true) { + return this.get<{ classes: any[] }>(`/${username}/classes`, { full }); + } + + getAssignments(username: string, full = true) { + return this.get<{ assignments: any[] }>(`/${username}/assignments`, { full }); + } + + getGroups(username: string, full = true) { + return this.get<{ groups: any[] }>(`/${username}/groups`, { full }); + } + + getSubmissions(username: string) { + return this.get<{ submissions: any[] }>(`/${username}/submissions`); + } + + getQuestions(username: string, full = true) { + return this.get<{ questions: any[] }>(`/${username}/questions`, { full }); + } + +} diff --git a/frontend/src/controllers/teacher-controller.ts b/frontend/src/controllers/teacher-controller.ts new file mode 100644 index 00000000..d3273ac1 --- /dev/null +++ b/frontend/src/controllers/teacher-controller.ts @@ -0,0 +1,37 @@ +import {BaseController} from "@/controllers/base-controller.ts"; + +export class TeacherController extends BaseController { + constructor() { + super("teachers"); + } + + getAll(full = false) { + return this.get<{ teachers: any[] }>("/", { full }); + } + + getByUsername(username: string) { + return this.get(`/${username}`); + } + + createTeacher(data: any) { + return this.post("/", data); + } + + deleteTeacher(username: string) { + return this.delete(`/${username}`); + } + + getClasses(username: string, full = false) { + return this.get(`/${username}/classes`, { full }); + } + + getStudents(username: string, full = false) { + return this.get<{ students: any[] }>(`/${username}/students`, { full }); + } + + getQuestions(username: string, full = false) { + return this.get<{ questions: any[] }>(`/${username}/questions`, { full }); + } + + // getInvitations(id: string) {return this.get<{ invitations: string[] }>(`/${id}/invitations`);} +} From fc5a40ba40e25b7c08fba2535061a8eb8b0980b2 Mon Sep 17 00:00:00 2001 From: Lint Action Date: Fri, 21 Mar 2025 22:26:24 +0000 Subject: [PATCH 004/112] style: fix linting issues met ESLint --- backend/src/data/questions/question-repository.ts | 2 +- backend/src/services/students.ts | 2 +- backend/src/services/teachers.ts | 8 ++++---- frontend/src/controllers/base-controller.ts | 2 +- frontend/src/controllers/teacher-controller.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index 8789c2e9..78d3a6ef 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -58,7 +58,7 @@ export class QuestionRepository extends DwengoEntityRepository { public findAllByAuthor(author: Student): Promise { return this.findAll({ where: { author }, - orderBy: { timestamp: 'DESC' }, // new to old + orderBy: { timestamp: 'DESC' }, // New to old }); } diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts index 70bed549..f7a7cbcc 100644 --- a/backend/src/services/students.ts +++ b/backend/src/services/students.ts @@ -139,7 +139,7 @@ export async function getStudentQuestions(username: string, full: boolean): Prom const questions = await questionRepository.findAllByAuthor(student); if (full) - return questions.map(mapToQuestionDTO) + {return questions.map(mapToQuestionDTO)} return questions.map(mapToQuestionId); } diff --git a/backend/src/services/teachers.ts b/backend/src/services/teachers.ts index 5b0d8144..5e1ea2cb 100644 --- a/backend/src/services/teachers.ts +++ b/backend/src/services/teachers.ts @@ -15,7 +15,7 @@ export async function getAllTeachers(full: boolean): Promise { const users = await teacherRepository.findAll(); if (full) - return users.map(mapToTeacherDTO); + {return users.map(mapToTeacherDTO);} return users.map((user) => user.username); } @@ -74,7 +74,7 @@ export async function getClassesByTeacher(username: string, full: boolean): Prom const classes = await fetchClassesByTeacher(username); if (full) - return classes; + {return classes;} return classes.map((cls) => cls.id); } @@ -83,7 +83,7 @@ export async function getStudentsByTeacher(username: string, full: boolean) { const students = (await Promise.all(classes.map(async (id) => getClassStudents(id)))).flat(); if (full) - return students + {return students} return students.map((student) => student.username); } @@ -103,7 +103,7 @@ export async function getTeacherQuestions(username: string, full: boolean): Prom const questions = await questionRepository.findAllByLearningObjects(learningObjects); if (full) - return questions.map(mapToQuestionDTO); + {return questions.map(mapToQuestionDTO);} return questions.map(mapToQuestionId); } diff --git a/frontend/src/controllers/base-controller.ts b/frontend/src/controllers/base-controller.ts index 8005a32e..e14dba23 100644 --- a/frontend/src/controllers/base-controller.ts +++ b/frontend/src/controllers/base-controller.ts @@ -12,7 +12,7 @@ export class BaseController { if (queryParams) { const query = new URLSearchParams(); Object.entries(queryParams).forEach(([key, value]) => { - if (value !== undefined && value !== null) query.append(key, value.toString()); + if (value !== undefined && value !== null) {query.append(key, value.toString());} }); url += `?${query.toString()}`; } diff --git a/frontend/src/controllers/teacher-controller.ts b/frontend/src/controllers/teacher-controller.ts index d3273ac1..fea08419 100644 --- a/frontend/src/controllers/teacher-controller.ts +++ b/frontend/src/controllers/teacher-controller.ts @@ -33,5 +33,5 @@ export class TeacherController extends BaseController { return this.get<{ questions: any[] }>(`/${username}/questions`, { full }); } - // getInvitations(id: string) {return this.get<{ invitations: string[] }>(`/${id}/invitations`);} + // GetInvitations(id: string) {return this.get<{ invitations: string[] }>(`/${id}/invitations`);} } From 9b0c0c9889f115730e424ca4a95bcb65ad807f45 Mon Sep 17 00:00:00 2001 From: Lint Action Date: Fri, 21 Mar 2025 22:26:28 +0000 Subject: [PATCH 005/112] style: fix linting issues met Prettier --- backend/src/controllers/students.ts | 13 ++++++----- backend/src/controllers/teachers.ts | 17 ++++---------- backend/src/controllers/users.ts | 6 ++--- .../src/data/questions/question-repository.ts | 1 - backend/src/routes/students.ts | 3 ++- backend/src/services/students.ts | 9 ++++---- backend/src/services/teachers.ts | 23 +++++++++++-------- frontend/src/controllers/base-controller.ts | 7 +++--- frontend/src/controllers/controllers.ts | 4 ++-- .../src/controllers/student-controller.ts | 3 +-- .../src/controllers/teacher-controller.ts | 2 +- 11 files changed, 43 insertions(+), 45 deletions(-) diff --git a/backend/src/controllers/students.ts b/backend/src/controllers/students.ts index cde6b5ef..771aacbe 100644 --- a/backend/src/controllers/students.ts +++ b/backend/src/controllers/students.ts @@ -1,18 +1,19 @@ import { Request, Response } from 'express'; import { createStudent, - deleteStudent, getAllStudentIds, + deleteStudent, + getAllStudentIds, getAllStudents, getStudent, getStudentAssignments, getStudentClasses, - getStudentGroups, getStudentQuestions, + getStudentGroups, + getStudentQuestions, getStudentSubmissions, } from '../services/students.js'; -import {MISSING_FIELDS_ERROR, MISSING_USERNAME_ERROR, NAME_NOT_FOUND_ERROR} from './users.js'; +import { MISSING_FIELDS_ERROR, MISSING_USERNAME_ERROR, NAME_NOT_FOUND_ERROR } from './users.js'; import { StudentDTO } from '../interfaces/student.js'; - export async function getAllStudentsHandler(req: Request, res: Response): Promise { const full = req.query.full === 'true'; @@ -23,7 +24,7 @@ export async function getAllStudentsHandler(req: Request, res: Response): Promis return; } - res.json({students}); + res.json({ students }); } export async function getStudentHandler(req: Request, res: Response): Promise { @@ -152,5 +153,5 @@ export async function getStudentQuestionsHandler(req: Request, res: Response): P res.json({ questions, - }) + }); } diff --git a/backend/src/controllers/teachers.ts b/backend/src/controllers/teachers.ts index fb4c2a0d..f1a557b0 100644 --- a/backend/src/controllers/teachers.ts +++ b/backend/src/controllers/teachers.ts @@ -1,17 +1,10 @@ import { Request, Response } from 'express'; -import { - createTeacher, - deleteTeacher, - getAllTeachers, - getClassesByTeacher, - getStudentsByTeacher, - getTeacher, -} from '../services/teachers.js'; +import { createTeacher, deleteTeacher, getAllTeachers, getClassesByTeacher, getStudentsByTeacher, getTeacher } from '../services/teachers.js'; import { ClassDTO } from '../interfaces/class.js'; import { StudentDTO } from '../interfaces/student.js'; import { QuestionDTO, QuestionId } from '../interfaces/question.js'; import { TeacherDTO } from '../interfaces/teacher.js'; -import {MISSING_FIELDS_ERROR, MISSING_USERNAME_ERROR, NAME_NOT_FOUND_ERROR} from "./users"; +import { MISSING_FIELDS_ERROR, MISSING_USERNAME_ERROR, NAME_NOT_FOUND_ERROR } from './users'; export async function getAllTeachersHandler(req: Request, res: Response): Promise { const full = req.query.full === 'true'; @@ -23,7 +16,7 @@ export async function getAllTeachersHandler(req: Request, res: Response): Promis return; } - res.json({teachers}); + res.json({ teachers }); } export async function getTeacherHandler(req: Request, res: Response): Promise { @@ -98,7 +91,7 @@ export async function getTeacherStudentHandler(req: Request, res: Response): Pro const students: StudentDTO[] | string[] = await getStudentsByTeacher(username, full); - res.json({students}); + res.json({ students }); } export async function getTeacherQuestionHandler(req: Request, res: Response): Promise { @@ -112,5 +105,5 @@ export async function getTeacherQuestionHandler(req: Request, res: Response): Pr const questions: QuestionDTO[] | QuestionId[] = await getQuestionsByTeacher(username, full); - res.json({questions}); + res.json({ questions }); } diff --git a/backend/src/controllers/users.ts b/backend/src/controllers/users.ts index 16f0e55d..d2a6ce7c 100644 --- a/backend/src/controllers/users.ts +++ b/backend/src/controllers/users.ts @@ -1,7 +1,7 @@ export const MISSING_USERNAME_ERROR = { error: 'Missing required field: username' }; -export function NAME_NOT_FOUND_ERROR(username: string){ - return {error: `User with username '${username}' not found.`}; +export function NAME_NOT_FOUND_ERROR(username: string) { + return { error: `User with username '${username}' not found.` }; } -export const MISSING_FIELDS_ERROR = { error: 'Missing required fields: username, firstName, lastName'} +export const MISSING_FIELDS_ERROR = { error: 'Missing required fields: username, firstName, lastName' }; diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index 78d3a6ef..deba7aad 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -61,5 +61,4 @@ export class QuestionRepository extends DwengoEntityRepository { orderBy: { timestamp: 'DESC' }, // New to old }); } - } diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts index 77775b8f..388f51a0 100644 --- a/backend/src/routes/students.ts +++ b/backend/src/routes/students.ts @@ -6,7 +6,8 @@ import { getStudentAssignmentsHandler, getStudentClassesHandler, getStudentGroupsHandler, - getStudentHandler, getStudentQuestionsHandler, + getStudentHandler, + getStudentQuestionsHandler, getStudentSubmissionsHandler, } from '../controllers/students.js'; import { getStudentGroups } from '../services/students.js'; diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts index f7a7cbcc..a3df888c 100644 --- a/backend/src/services/students.ts +++ b/backend/src/services/students.ts @@ -3,7 +3,7 @@ import { getGroupRepository, getQuestionRepository, getStudentRepository, - getSubmissionRepository + getSubmissionRepository, } from '../data/repositories.js'; import { AssignmentDTO } from '../interfaces/assignment.js'; import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; @@ -11,7 +11,7 @@ import { GroupDTO, mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js import { mapToStudent, mapToStudentDTO, StudentDTO } from '../interfaces/student.js'; import { mapToSubmissionDTO, SubmissionDTO } from '../interfaces/submission.js'; import { getAllAssignments } from './assignments.js'; -import {mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId} from "../interfaces/question"; +import { mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId } from '../interfaces/question'; export async function getAllStudents(): Promise { const studentRepository = getStudentRepository(); @@ -138,8 +138,9 @@ export async function getStudentQuestions(username: string, full: boolean): Prom const questionRepository = getQuestionRepository(); const questions = await questionRepository.findAllByAuthor(student); - if (full) - {return questions.map(mapToQuestionDTO)} + if (full) { + return questions.map(mapToQuestionDTO); + } return questions.map(mapToQuestionId); } diff --git a/backend/src/services/teachers.ts b/backend/src/services/teachers.ts index 5e1ea2cb..cfe274c9 100644 --- a/backend/src/services/teachers.ts +++ b/backend/src/services/teachers.ts @@ -14,8 +14,9 @@ export async function getAllTeachers(full: boolean): Promise { const teacherRepository = getTeacherRepository(); const users = await teacherRepository.findAll(); - if (full) - {return users.map(mapToTeacherDTO);} + if (full) { + return users.map(mapToTeacherDTO); + } return users.map((user) => user.username); } @@ -73,17 +74,19 @@ async function fetchClassesByTeacher(username: string): Promise { export async function getClassesByTeacher(username: string, full: boolean): Promise { const classes = await fetchClassesByTeacher(username); - if (full) - {return classes;} + if (full) { + return classes; + } return classes.map((cls) => cls.id); } export async function getStudentsByTeacher(username: string, full: boolean) { const classes = await getClassesByTeacher(username, false); - const students = (await Promise.all(classes.map(async (id) => getClassStudents(id)))).flat(); - if (full) - {return students} + const students = (await Promise.all(classes.map(async (id) => getClassStudents(id)))).flat(); + if (full) { + return students; + } return students.map((student) => student.username); } @@ -102,9 +105,9 @@ export async function getTeacherQuestions(username: string, full: boolean): Prom const questionRepository = getQuestionRepository(); const questions = await questionRepository.findAllByLearningObjects(learningObjects); - if (full) - {return questions.map(mapToQuestionDTO);} + if (full) { + return questions.map(mapToQuestionDTO); + } return questions.map(mapToQuestionId); } - diff --git a/frontend/src/controllers/base-controller.ts b/frontend/src/controllers/base-controller.ts index e14dba23..e422b4be 100644 --- a/frontend/src/controllers/base-controller.ts +++ b/frontend/src/controllers/base-controller.ts @@ -1,4 +1,4 @@ -import {API_BASE} from "../../config.ts"; +import { API_BASE } from "../../config.ts"; export class BaseController { protected baseUrl: string; @@ -12,7 +12,9 @@ export class BaseController { if (queryParams) { const query = new URLSearchParams(); Object.entries(queryParams).forEach(([key, value]) => { - if (value !== undefined && value !== null) {query.append(key, value.toString());} + if (value !== undefined && value !== null) { + query.append(key, value.toString()); + } }); url += `?${query.toString()}`; } @@ -68,5 +70,4 @@ export class BaseController { return res.json(); } - } diff --git a/frontend/src/controllers/controllers.ts b/frontend/src/controllers/controllers.ts index 61bb09d6..9907c745 100644 --- a/frontend/src/controllers/controllers.ts +++ b/frontend/src/controllers/controllers.ts @@ -1,5 +1,5 @@ -import {StudentController} from "@/controllers/student-controller.ts"; -import {TeacherController} from "@/controllers/teacher-controller.ts"; +import { StudentController } from "@/controllers/student-controller.ts"; +import { TeacherController } from "@/controllers/teacher-controller.ts"; export function controllerGetter(Factory: new () => T): () => T { let instance: T | undefined; diff --git a/frontend/src/controllers/student-controller.ts b/frontend/src/controllers/student-controller.ts index e74bd74c..38cfef89 100644 --- a/frontend/src/controllers/student-controller.ts +++ b/frontend/src/controllers/student-controller.ts @@ -1,4 +1,4 @@ -import {BaseController} from "@/controllers/base-controller.ts"; +import { BaseController } from "@/controllers/base-controller.ts"; export class StudentController extends BaseController { constructor() { @@ -40,5 +40,4 @@ export class StudentController extends BaseController { getQuestions(username: string, full = true) { return this.get<{ questions: any[] }>(`/${username}/questions`, { full }); } - } diff --git a/frontend/src/controllers/teacher-controller.ts b/frontend/src/controllers/teacher-controller.ts index fea08419..e4f34027 100644 --- a/frontend/src/controllers/teacher-controller.ts +++ b/frontend/src/controllers/teacher-controller.ts @@ -1,4 +1,4 @@ -import {BaseController} from "@/controllers/base-controller.ts"; +import { BaseController } from "@/controllers/base-controller.ts"; export class TeacherController extends BaseController { constructor() { From 8b0fc4263f3641f45dbfa66ee3464aef4cabbe94 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Sat, 22 Mar 2025 16:16:34 +0100 Subject: [PATCH 006/112] feat(frontend): Skelet voor de implementatie van views & services voor leerpaden aangemaakt. --- frontend/src/components/LearningPath.vue | 7 - frontend/src/router/index.ts | 7 + .../src/services/learning-paths/language.ts | 186 ++++++++++++++++++ .../learning-paths/learning-object.ts | 37 ++++ .../learning-paths/learning-path-service.ts | 0 .../services/learning-paths/learning-path.ts | 40 ++++ .../views/learning-paths/LearningPathPage.vue | 20 ++ 7 files changed, 290 insertions(+), 7 deletions(-) delete mode 100644 frontend/src/components/LearningPath.vue create mode 100644 frontend/src/services/learning-paths/language.ts create mode 100644 frontend/src/services/learning-paths/learning-object.ts create mode 100644 frontend/src/services/learning-paths/learning-path-service.ts create mode 100644 frontend/src/services/learning-paths/learning-path.ts create mode 100644 frontend/src/views/learning-paths/LearningPathPage.vue diff --git a/frontend/src/components/LearningPath.vue b/frontend/src/components/LearningPath.vue deleted file mode 100644 index 1a35a59f..00000000 --- a/frontend/src/components/LearningPath.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 5f2c4624..be11c0df 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -13,6 +13,7 @@ import UserDiscussions from "@/views/discussions/UserDiscussions.vue"; import UserClasses from "@/views/classes/UserClasses.vue"; import UserAssignments from "@/views/classes/UserAssignments.vue"; import authState from "@/services/auth/auth-service.ts"; +import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -99,6 +100,12 @@ const router = createRouter({ component: SingleDiscussion, meta: { requiresAuth: true }, }, + { + path: "/learningPath/:hruid/:language", + name: "LearningPath", + component: LearningPathPage, + meta: { requiresAuth: false } + }, { path: "/:catchAll(.*)", name: "NotFound", diff --git a/frontend/src/services/learning-paths/language.ts b/frontend/src/services/learning-paths/language.ts new file mode 100644 index 00000000..d7687331 --- /dev/null +++ b/frontend/src/services/learning-paths/language.ts @@ -0,0 +1,186 @@ +export enum Language { + Afar = 'aa', + Abkhazian = 'ab', + Afrikaans = 'af', + Akan = 'ak', + Albanian = 'sq', + Amharic = 'am', + Arabic = 'ar', + Aragonese = 'an', + Armenian = 'hy', + Assamese = 'as', + Avaric = 'av', + Avestan = 'ae', + Aymara = 'ay', + Azerbaijani = 'az', + Bashkir = 'ba', + Bambara = 'bm', + Basque = 'eu', + Belarusian = 'be', + Bengali = 'bn', + Bihari = 'bh', + Bislama = 'bi', + Bosnian = 'bs', + Breton = 'br', + Bulgarian = 'bg', + Burmese = 'my', + Catalan = 'ca', + Chamorro = 'ch', + Chechen = 'ce', + Chinese = 'zh', + ChurchSlavic = 'cu', + Chuvash = 'cv', + Cornish = 'kw', + Corsican = 'co', + Cree = 'cr', + Czech = 'cs', + Danish = 'da', + Divehi = 'dv', + Dutch = 'nl', + Dzongkha = 'dz', + English = 'en', + Esperanto = 'eo', + Estonian = 'et', + Ewe = 'ee', + Faroese = 'fo', + Fijian = 'fj', + Finnish = 'fi', + French = 'fr', + Frisian = 'fy', + Fulah = 'ff', + Georgian = 'ka', + German = 'de', + Gaelic = 'gd', + Irish = 'ga', + Galician = 'gl', + Manx = 'gv', + Greek = 'el', + Guarani = 'gn', + Gujarati = 'gu', + Haitian = 'ht', + Hausa = 'ha', + Hebrew = 'he', + Herero = 'hz', + Hindi = 'hi', + HiriMotu = 'ho', + Croatian = 'hr', + Hungarian = 'hu', + Igbo = 'ig', + Icelandic = 'is', + Ido = 'io', + SichuanYi = 'ii', + Inuktitut = 'iu', + Interlingue = 'ie', + Interlingua = 'ia', + Indonesian = 'id', + Inupiaq = 'ik', + Italian = 'it', + Javanese = 'jv', + Japanese = 'ja', + Kalaallisut = 'kl', + Kannada = 'kn', + Kashmiri = 'ks', + Kanuri = 'kr', + Kazakh = 'kk', + Khmer = 'km', + Kikuyu = 'ki', + Kinyarwanda = 'rw', + Kirghiz = 'ky', + Komi = 'kv', + Kongo = 'kg', + Korean = 'ko', + Kuanyama = 'kj', + Kurdish = 'ku', + Lao = 'lo', + Latin = 'la', + Latvian = 'lv', + Limburgan = 'li', + Lingala = 'ln', + Lithuanian = 'lt', + Luxembourgish = 'lb', + LubaKatanga = 'lu', + Ganda = 'lg', + Macedonian = 'mk', + Marshallese = 'mh', + Malayalam = 'ml', + Maori = 'mi', + Marathi = 'mr', + Malay = 'ms', + Malagasy = 'mg', + Maltese = 'mt', + Mongolian = 'mn', + Nauru = 'na', + Navajo = 'nv', + SouthNdebele = 'nr', + NorthNdebele = 'nd', + Ndonga = 'ng', + Nepali = 'ne', + NorwegianNynorsk = 'nn', + NorwegianBokmal = 'nb', + Norwegian = 'no', + Chichewa = 'ny', + Occitan = 'oc', + Ojibwa = 'oj', + Oriya = 'or', + Oromo = 'om', + Ossetian = 'os', + Punjabi = 'pa', + Persian = 'fa', + Pali = 'pi', + Polish = 'pl', + Portuguese = 'pt', + Pashto = 'ps', + Quechua = 'qu', + Romansh = 'rm', + Romanian = 'ro', + Rundi = 'rn', + Russian = 'ru', + Sango = 'sg', + Sanskrit = 'sa', + Sinhala = 'si', + Slovak = 'sk', + Slovenian = 'sl', + NorthernSami = 'se', + Samoan = 'sm', + Shona = 'sn', + Sindhi = 'sd', + Somali = 'so', + Sotho = 'st', + Spanish = 'es', + Sardinian = 'sc', + Serbian = 'sr', + Swati = 'ss', + Sundanese = 'su', + Swahili = 'sw', + Swedish = 'sv', + Tahitian = 'ty', + Tamil = 'ta', + Tatar = 'tt', + Telugu = 'te', + Tajik = 'tg', + Tagalog = 'tl', + Thai = 'th', + Tibetan = 'bo', + Tigrinya = 'ti', + Tonga = 'to', + Tswana = 'tn', + Tsonga = 'ts', + Turkmen = 'tk', + Turkish = 'tr', + Twi = 'tw', + Uighur = 'ug', + Ukrainian = 'uk', + Urdu = 'ur', + Uzbek = 'uz', + Venda = 've', + Vietnamese = 'vi', + Volapuk = 'vo', + Welsh = 'cy', + Walloon = 'wa', + Wolof = 'wo', + Xhosa = 'xh', + Yiddish = 'yi', + Yoruba = 'yo', + Zhuang = 'za', + Zulu = 'zu', +} diff --git a/frontend/src/services/learning-paths/learning-object.ts b/frontend/src/services/learning-paths/learning-object.ts new file mode 100644 index 00000000..cde6915e --- /dev/null +++ b/frontend/src/services/learning-paths/learning-object.ts @@ -0,0 +1,37 @@ +import type {Language} from "@/services/learning-paths/language.ts"; + +export interface LearningPathIdentifier { + hruid: string; + language: Language; +} + +export interface EducationalGoal { + source: string; + id: string; +} + +export interface ReturnValue { + callback_url: string; + callback_schema: Record; +} + +export interface LearningObjectMetadata { + _id: string; + uuid: string; + hruid: string; + version: number; + language: Language; + title: string; + description: string; + difficulty: number; + estimated_time: number; + available: boolean; + teacher_exclusive: boolean; + educational_goals: EducationalGoal[]; + keywords: string[]; + target_ages: number[]; + content_type: string; // Markdown, image, etc. + content_location?: string; + skos_concepts?: string[]; + return_value?: ReturnValue; +} diff --git a/frontend/src/services/learning-paths/learning-path-service.ts b/frontend/src/services/learning-paths/learning-path-service.ts new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/services/learning-paths/learning-path.ts b/frontend/src/services/learning-paths/learning-path.ts new file mode 100644 index 00000000..2347b04e --- /dev/null +++ b/frontend/src/services/learning-paths/learning-path.ts @@ -0,0 +1,40 @@ +import type {Language} from "@/services/learning-paths/language.ts"; + +export interface LearningPath { + language: string; + hruid: string; + title: string; + description: string; + image?: string; // Image might be missing, so it's optional + num_nodes: number; + num_nodes_left: number; + nodes: LearningObjectNode[]; + keywords: string; + target_ages: number[]; + min_age: number; + max_age: number; + __order: number; +} + +export interface LearningObjectNode { + _id: string; + learningobject_hruid: string; + version: number; + language: Language; + start_node?: boolean; + transitions: Transition[]; + created_at: string; + updatedAt: string; + done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. +} + +export interface Transition { + default: boolean; + _id: string; + next: { + _id: string; + hruid: string; + version: number; + language: string; + }; +} diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue new file mode 100644 index 00000000..14e911e2 --- /dev/null +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -0,0 +1,20 @@ + + + + + From 3c3fddb7d016ed4a05ac3363de65431f47e18941 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Sun, 23 Mar 2025 08:56:34 +0100 Subject: [PATCH 007/112] =?UTF-8?q?feat(frontend):=20LearningObjectService?= =?UTF-8?q?=20en=20LearningPathService=20ge=C3=AFmplementeerd.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/UsingRemoteResource.vue | 39 ++++++ frontend/src/i18n/locale/de.json | 3 +- frontend/src/i18n/locale/en.json | 3 +- frontend/src/i18n/locale/fr.json | 3 +- frontend/src/i18n/locale/nl.json | 3 +- frontend/src/main.ts | 8 ++ .../services/{ => api-client}/api-client.ts | 0 .../services/api-client/api-exceptions.d.ts | 10 ++ .../api-client/endpoints/delete-endpoint.ts | 10 ++ .../api-client/endpoints/get-endpoint.ts | 10 ++ .../api-client/endpoints/post-endpoint.ts | 10 ++ .../api-client/endpoints/rest-endpoint.ts | 30 +++++ .../services/api-client/remote-resource.ts | 70 +++++++++++ .../src/services/auth/auth-config-loader.ts | 2 +- frontend/src/services/auth/auth-service.ts | 2 +- .../language.ts | 0 .../learning-object-service.ts | 13 ++ .../learning-content/learning-object.ts | 33 +++++ .../learning-content/learning-path-service.ts | 13 ++ .../learning-content/learning-path.ts | 118 ++++++++++++++++++ .../learning-paths/learning-object.ts | 37 ------ .../learning-paths/learning-path-service.ts | 0 .../services/learning-paths/learning-path.ts | 40 ------ frontend/src/views/HomePage.vue | 2 +- 24 files changed, 375 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/UsingRemoteResource.vue rename frontend/src/services/{ => api-client}/api-client.ts (100%) create mode 100644 frontend/src/services/api-client/api-exceptions.d.ts create mode 100644 frontend/src/services/api-client/endpoints/delete-endpoint.ts create mode 100644 frontend/src/services/api-client/endpoints/get-endpoint.ts create mode 100644 frontend/src/services/api-client/endpoints/post-endpoint.ts create mode 100644 frontend/src/services/api-client/endpoints/rest-endpoint.ts create mode 100644 frontend/src/services/api-client/remote-resource.ts rename frontend/src/services/{learning-paths => learning-content}/language.ts (100%) create mode 100644 frontend/src/services/learning-content/learning-object-service.ts create mode 100644 frontend/src/services/learning-content/learning-object.ts create mode 100644 frontend/src/services/learning-content/learning-path-service.ts create mode 100644 frontend/src/services/learning-content/learning-path.ts delete mode 100644 frontend/src/services/learning-paths/learning-object.ts delete mode 100644 frontend/src/services/learning-paths/learning-path-service.ts delete mode 100644 frontend/src/services/learning-paths/learning-path.ts diff --git a/frontend/src/components/UsingRemoteResource.vue b/frontend/src/components/UsingRemoteResource.vue new file mode 100644 index 00000000..6bc19234 --- /dev/null +++ b/frontend/src/components/UsingRemoteResource.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json index a1a699e5..b9a087fc 100644 --- a/frontend/src/i18n/locale/de.json +++ b/frontend/src/i18n/locale/de.json @@ -1,3 +1,4 @@ { - "welcome": "Willkommen" + "welcome": "Willkommen", + "error_title": "Fehler" } diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index c75bfc5d..a04b3fa5 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -5,5 +5,6 @@ "assignments": "assignments", "classes": "classes", "discussions": "discussions", - "logout": "log out" + "logout": "log out", + "error_title": "Error" } diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json index 86fe964d..1d59c4dd 100644 --- a/frontend/src/i18n/locale/fr.json +++ b/frontend/src/i18n/locale/fr.json @@ -1,3 +1,4 @@ { - "welcome": "Bienvenue" + "welcome": "Bienvenue", + "error_title": "Erreur" } diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json index 97ec9b49..576a95b4 100644 --- a/frontend/src/i18n/locale/nl.json +++ b/frontend/src/i18n/locale/nl.json @@ -5,5 +5,6 @@ "assignments": "opdrachten", "classes": "klassen", "discussions": "discussies", - "logout": "log uit" + "logout": "log uit", + "error_title": "Fout" } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e4843dae..556395c3 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -10,6 +10,7 @@ import i18n from "./i18n/i18n.ts"; // Components import App from "./App.vue"; import router from "./router"; +import {aliases, mdi} from "vuetify/iconsets/mdi"; const app = createApp(App); @@ -23,6 +24,13 @@ document.head.appendChild(link); const vuetify = createVuetify({ components, directives, + icons: { + defaultSet: "mdi", + aliases, + sets: { + mdi + } + } }); app.use(vuetify); app.use(i18n); diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client/api-client.ts similarity index 100% rename from frontend/src/services/api-client.ts rename to frontend/src/services/api-client/api-client.ts diff --git a/frontend/src/services/api-client/api-exceptions.d.ts b/frontend/src/services/api-client/api-exceptions.d.ts new file mode 100644 index 00000000..28df1bc1 --- /dev/null +++ b/frontend/src/services/api-client/api-exceptions.d.ts @@ -0,0 +1,10 @@ +import type {AxiosResponse} from "axios"; + +export class HttpErrorStatusException extends Error { + public readonly statusCode: number; + + constructor(response: AxiosResponse) { + super(`${response.statusText} (${response.status})`); + this.statusCode = response.status; + } +} diff --git a/frontend/src/services/api-client/endpoints/delete-endpoint.ts b/frontend/src/services/api-client/endpoints/delete-endpoint.ts new file mode 100644 index 00000000..554e1855 --- /dev/null +++ b/frontend/src/services/api-client/endpoints/delete-endpoint.ts @@ -0,0 +1,10 @@ +import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts"; +import {RemoteResource} from "@/services/api-client/remote-resource.ts"; + +export class DeleteEndpoint extends RestEndpoint { + readonly method = "GET"; + + public delete(pathParams: PP, queryParams: QP): RemoteResource { + return super.request(pathParams, queryParams, undefined); + } +} diff --git a/frontend/src/services/api-client/endpoints/get-endpoint.ts b/frontend/src/services/api-client/endpoints/get-endpoint.ts new file mode 100644 index 00000000..1d9a086f --- /dev/null +++ b/frontend/src/services/api-client/endpoints/get-endpoint.ts @@ -0,0 +1,10 @@ +import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts"; +import {RemoteResource} from "@/services/api-client/remote-resource.ts"; + +export class GetEndpoint extends RestEndpoint { + readonly method = "GET"; + + public get(pathParams: PP, queryParams: QP): RemoteResource { + return super.request(pathParams, queryParams, undefined); + } +} diff --git a/frontend/src/services/api-client/endpoints/post-endpoint.ts b/frontend/src/services/api-client/endpoints/post-endpoint.ts new file mode 100644 index 00000000..6fde53e8 --- /dev/null +++ b/frontend/src/services/api-client/endpoints/post-endpoint.ts @@ -0,0 +1,10 @@ +import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts"; +import {RemoteResource} from "@/services/api-client/remote-resource.ts"; + +export class PostEndpoint extends RestEndpoint { + readonly method = "POST"; + + public post(pathParams: PP, queryParams: QP, body: B): RemoteResource { + return super.request(pathParams, queryParams, body); + } +} diff --git a/frontend/src/services/api-client/endpoints/rest-endpoint.ts b/frontend/src/services/api-client/endpoints/rest-endpoint.ts new file mode 100644 index 00000000..3f6bb5cf --- /dev/null +++ b/frontend/src/services/api-client/endpoints/rest-endpoint.ts @@ -0,0 +1,30 @@ +import {RemoteResource} from "@/services/api-client/remote-resource.ts"; +import apiClient from "@/services/api-client/api-client.ts"; +import {HttpErrorStatusException} from "@/services/api-client/api-exceptions"; + +export abstract class RestEndpoint { + public abstract readonly method: "GET" | "POST" | "PUT" | "DELETE"; + constructor(public readonly url: string) { + } + + protected request(pathParams: PP, queryParams: QP, body: B): RemoteResource { + let urlFilledIn = this.url; + urlFilledIn.replace(/:(\w+)([/$])/g, (_, key, after) => + (key in pathParams ? encodeURIComponent(pathParams[key]) : `:${key}`) + after + ); + return new RemoteResource(async () => { + const response = await apiClient.request({ + url: urlFilledIn, + method: this.method, + params: queryParams, + data: body, + }); + if (response.status / 100 !== 2) { + throw new HttpErrorStatusException(response); + } + return response.data; + }); + } +} + +export type Params = {[key: string]: string | number | boolean}; diff --git a/frontend/src/services/api-client/remote-resource.ts b/frontend/src/services/api-client/remote-resource.ts new file mode 100644 index 00000000..b1add8b9 --- /dev/null +++ b/frontend/src/services/api-client/remote-resource.ts @@ -0,0 +1,70 @@ +export class RemoteResource { + static NOT_LOADED: NotLoadedState = {type: "notLoaded"}; + static LOADING: LoadingState = {type: "loading"}; + + private state: NotLoadedState | LoadingState | ErrorState | SuccessState = RemoteResource.NOT_LOADED; + + constructor(private readonly requestFn: () => Promise) { + } + + public async request(): Promise { + this.state = RemoteResource.LOADING; + try { + let resource = await this.requestFn(); + this.state = { + type: "success", + data: resource + }; + return resource; + } catch (e: any) { + this.state = { + type: "error", + errorCode: e.statusCode, + message: e.message, + error: e + }; + } + } + + public startRequestInBackground(): RemoteResource { + this.request().then(); + return this; + } + + public get data(): T | undefined { + if (this.state.type === "success") { + return this.state.data; + } + } + + public map(mappingFn: (content: T) => U): RemoteResource { + return new RemoteResource(async () => { + await this.request(); + if (this.state.type === "success") { + return mappingFn(this.state.data); + } else if (this.state.type === "error") { + throw this.state.error; + } else { + throw new Error("Fetched resource, but afterwards, it was neither in a success nor in an error state. " + + "This should never happen."); + } + }); + } +} + +type NotLoadedState = { + type: "notLoaded" +}; +type LoadingState = { + type: "loading" +}; +type ErrorState = { + type: "error", + errorCode?: number, + message?: string, + error: any +}; +type SuccessState = { + type: "success", + data: T +} diff --git a/frontend/src/services/auth/auth-config-loader.ts b/frontend/src/services/auth/auth-config-loader.ts index ce8a33ca..ef5b63c3 100644 --- a/frontend/src/services/auth/auth-config-loader.ts +++ b/frontend/src/services/auth/auth-config-loader.ts @@ -1,4 +1,4 @@ -import apiClient from "@/services/api-client.ts"; +import apiClient from "@/services/api-client/api-client.ts"; import type { FrontendAuthConfig } from "@/services/auth/auth.d.ts"; /** diff --git a/frontend/src/services/auth/auth-service.ts b/frontend/src/services/auth/auth-service.ts index 2b3d2807..2963d8f4 100644 --- a/frontend/src/services/auth/auth-service.ts +++ b/frontend/src/services/auth/auth-service.ts @@ -8,7 +8,7 @@ import { User, UserManager } from "oidc-client-ts"; import { loadAuthConfig } from "@/services/auth/auth-config-loader.ts"; import authStorage from "./auth-storage.ts"; import { loginRoute } from "@/config.ts"; -import apiClient from "@/services/api-client.ts"; +import apiClient from "@/services/api-client/api-client.ts"; import router from "@/router"; import type { AxiosError } from "axios"; diff --git a/frontend/src/services/learning-paths/language.ts b/frontend/src/services/learning-content/language.ts similarity index 100% rename from frontend/src/services/learning-paths/language.ts rename to frontend/src/services/learning-content/language.ts diff --git a/frontend/src/services/learning-content/learning-object-service.ts b/frontend/src/services/learning-content/learning-object-service.ts new file mode 100644 index 00000000..076a662e --- /dev/null +++ b/frontend/src/services/learning-content/learning-object-service.ts @@ -0,0 +1,13 @@ +import {GetEndpoint} from "@/services/api-client/endpoints/get-endpoint.ts"; +import type {LearningObject} from "@/services/learning-content/learning-object.ts"; +import type {Language} from "@/services/learning-content/language.ts"; +import type {RemoteResource} from "@/services/api-client/remote-resource.ts"; + +const getLearningObjectMetadataEndpoint = new GetEndpoint<{hruid: string}, {language: Language, version: number}, LearningObject>( + "/learningObject/:hruid" +); + +export function getLearningObjectMetadata(hruid: string, language: Language, version: number): RemoteResource { + return getLearningObjectMetadataEndpoint + .get({hruid}, {language, version}); +} diff --git a/frontend/src/services/learning-content/learning-object.ts b/frontend/src/services/learning-content/learning-object.ts new file mode 100644 index 00000000..ff660420 --- /dev/null +++ b/frontend/src/services/learning-content/learning-object.ts @@ -0,0 +1,33 @@ +import type {Language} from "@/services/learning-content/language.ts"; + +export interface EducationalGoal { + source: string; + id: string; +} + +export interface ReturnValue { + callback_url: string; + callback_schema: Record; +} + +export interface LearningObject { + key: string; + _id: string; + uuid: string; + version: number; + title: string; + htmlUrl: string; + language: Language; + difficulty: number; + estimatedTime?: number; + available: boolean; + teacherExclusive: boolean; + educationalGoals: EducationalGoal[]; + keywords: string[]; + description: string; + targetAges: number[]; + contentType: string; + contentLocation?: string; + skosConcepts?: string[]; + returnValue?: ReturnValue; +} diff --git a/frontend/src/services/learning-content/learning-path-service.ts b/frontend/src/services/learning-content/learning-path-service.ts new file mode 100644 index 00000000..be9dffd5 --- /dev/null +++ b/frontend/src/services/learning-content/learning-path-service.ts @@ -0,0 +1,13 @@ +import {GetEndpoint} from "@/services/api-client/endpoints/get-endpoint.ts"; +import {LearningPath, type LearningPathDTO} from "@/services/learning-content/learning-path.ts"; +import type {RemoteResource} from "@/services/api-client/remote-resource.ts"; + +const searchLearningPathsEndpoint = new GetEndpoint<{}, {query: string}, LearningPathDTO[]>( + "/learningObjects/:query" +); + +export function searchLearningPaths(query: string): RemoteResource { + return searchLearningPathsEndpoint + .get({}, {query: query}) + .map(dtos => dtos.map(dto => LearningPath.fromDTO(dto))); +} diff --git a/frontend/src/services/learning-content/learning-path.ts b/frontend/src/services/learning-content/learning-path.ts new file mode 100644 index 00000000..7aefa28e --- /dev/null +++ b/frontend/src/services/learning-content/learning-path.ts @@ -0,0 +1,118 @@ +import type {Language} from "@/services/learning-content/language.ts"; +import type {RemoteResource} from "@/services/api-client/remote-resource.ts"; +import type {LearningObject} from "@/services/learning-content/learning-object.ts"; +import {getLearningObjectMetadata} from "@/services/learning-content/learning-object-service.ts"; + +export interface LearningPathDTO { + language: string; + hruid: string; + title: string; + description: string; + image?: string; // Image might be missing, so it's optional + num_nodes: number; + num_nodes_left: number; + nodes: LearningPathNodeDTO[]; + keywords: string; + target_ages: number[]; + min_age: number; + max_age: number; + __order: number; +} + +interface LearningPathNodeDTO { + _id: string; + learningobject_hruid: string; + version: number; + language: Language; + start_node?: boolean; + transitions: LearningPathTransitionDTO[]; + created_at: string; + updatedAt: string; + done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. +} + +interface LearningPathTransitionDTO { + default: boolean; + _id: string; + next: { + _id: string; + hruid: string; + version: number; + language: string; + }; +} + +export class LearningPathNode { + public learningObject: RemoteResource + + constructor( + public readonly learningobjectHruid: string, + public readonly version: number, + public readonly language: Language, + public readonly transitions: {next: LearningPathNode, default: boolean}[], + public readonly createdAt: Date, + public readonly updatedAt: Date + ) { + this.learningObject = getLearningObjectMetadata(learningobjectHruid, language, version); + } + + static fromDTOAndOtherNodes(dto: LearningPathNodeDTO, otherNodes: LearningPathNodeDTO[]): LearningPathNode { + return new LearningPathNode( + dto.learningobject_hruid, + dto.version, + dto.language, + dto.transitions.map(transDto => { + let nextNodeDto = otherNodes.filter(it => + it.learningobject_hruid === transDto.next.hruid + && it.language === transDto.next.language + && it.version === transDto.next.version + ); + if (nextNodeDto.length !== 1) { + throw new Error(`Invalid learning path! There is a transition to node` + + `${transDto.next.hruid}/${transDto.next.language}/${transDto.next.version}, but there are` + + `${nextNodeDto.length} such nodes.`); + } + return { + next: LearningPathNode.fromDTOAndOtherNodes(nextNodeDto[0], otherNodes), + default: transDto.default + } + }), + new Date(dto.created_at), + new Date(dto.updatedAt), + ) + } +} + +export class LearningPath { + constructor( + public readonly language: string, + public readonly hruid: string, + public readonly title: string, + public readonly description: string, + public readonly amountOfNodes: number, + public readonly amountOfNodesLeft: number, + public readonly keywords: string[], + public readonly targetAges: {min: number; max: number}, + public readonly startNode: LearningPathNode, + public readonly image?: string // Image might be missing, so it's optional + ) { + } + + static fromDTO(dto: LearningPathDTO): LearningPath { + let startNodeDto = dto.nodes.filter(it => it.start_node); + if (startNodeDto.length !== 1) { + throw new Error(`Invalid learning path! Expected precisely one start node, but there were ${startNodeDto.length}.`); + } + return new LearningPath( + dto.language, + dto.hruid, + dto.title, + dto.description, + dto.num_nodes, + dto.num_nodes_left, + dto.keywords.split(' '), + {min: dto.min_age, max: dto.max_age}, + LearningPathNode.fromDTOAndOtherNodes(startNodeDto[0], dto.nodes) + ) + } +} diff --git a/frontend/src/services/learning-paths/learning-object.ts b/frontend/src/services/learning-paths/learning-object.ts deleted file mode 100644 index cde6915e..00000000 --- a/frontend/src/services/learning-paths/learning-object.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type {Language} from "@/services/learning-paths/language.ts"; - -export interface LearningPathIdentifier { - hruid: string; - language: Language; -} - -export interface EducationalGoal { - source: string; - id: string; -} - -export interface ReturnValue { - callback_url: string; - callback_schema: Record; -} - -export interface LearningObjectMetadata { - _id: string; - uuid: string; - hruid: string; - version: number; - language: Language; - title: string; - description: string; - difficulty: number; - estimated_time: number; - available: boolean; - teacher_exclusive: boolean; - educational_goals: EducationalGoal[]; - keywords: string[]; - target_ages: number[]; - content_type: string; // Markdown, image, etc. - content_location?: string; - skos_concepts?: string[]; - return_value?: ReturnValue; -} diff --git a/frontend/src/services/learning-paths/learning-path-service.ts b/frontend/src/services/learning-paths/learning-path-service.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/services/learning-paths/learning-path.ts b/frontend/src/services/learning-paths/learning-path.ts deleted file mode 100644 index 2347b04e..00000000 --- a/frontend/src/services/learning-paths/learning-path.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type {Language} from "@/services/learning-paths/language.ts"; - -export interface LearningPath { - language: string; - hruid: string; - title: string; - description: string; - image?: string; // Image might be missing, so it's optional - num_nodes: number; - num_nodes_left: number; - nodes: LearningObjectNode[]; - keywords: string; - target_ages: number[]; - min_age: number; - max_age: number; - __order: number; -} - -export interface LearningObjectNode { - _id: string; - learningobject_hruid: string; - version: number; - language: Language; - start_node?: boolean; - transitions: Transition[]; - created_at: string; - updatedAt: string; - done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. -} - -export interface Transition { - default: boolean; - _id: string; - next: { - _id: string; - hruid: string; - version: number; - language: string; - }; -} diff --git a/frontend/src/views/HomePage.vue b/frontend/src/views/HomePage.vue index e9d53770..c17ca147 100644 --- a/frontend/src/views/HomePage.vue +++ b/frontend/src/views/HomePage.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index f942babc..0e22ec71 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -37,7 +37,7 @@ diff --git a/frontend/src/components/UsingRemoteResource.vue b/frontend/src/components/UsingRemoteResource.vue index 6bc19234..96654ce9 100644 --- a/frontend/src/components/UsingRemoteResource.vue +++ b/frontend/src/components/UsingRemoteResource.vue @@ -1,39 +1,51 @@ diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index be11c0df..f096a0ba 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -14,6 +14,7 @@ import UserClasses from "@/views/classes/UserClasses.vue"; import UserAssignments from "@/views/classes/UserAssignments.vue"; import authState from "@/services/auth/auth-service.ts"; import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue"; +import path from "path"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -104,7 +105,15 @@ const router = createRouter({ path: "/learningPath/:hruid/:language", name: "LearningPath", component: LearningPathPage, - meta: { requiresAuth: false } + props: true, + meta: { requiresAuth: false }, + children: [ + { + path: ":learningObjectHruid", + component: LearningPathPage, + props: true, + } + ] }, { path: "/:catchAll(.*)", diff --git a/frontend/src/services/api-client/api-exceptions.d.ts b/frontend/src/services/api-client/api-exceptions.ts similarity index 56% rename from frontend/src/services/api-client/api-exceptions.d.ts rename to frontend/src/services/api-client/api-exceptions.ts index 28df1bc1..d2fa2ac2 100644 --- a/frontend/src/services/api-client/api-exceptions.d.ts +++ b/frontend/src/services/api-client/api-exceptions.ts @@ -8,3 +8,15 @@ export class HttpErrorStatusException extends Error { this.statusCode = response.status; } } + +export class NotFoundException extends Error { + constructor(message: string) { + super(message); + } +} + +export class InvalidResponseException extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/frontend/src/services/api-client/endpoints/get-html-endpoint.ts b/frontend/src/services/api-client/endpoints/get-html-endpoint.ts new file mode 100644 index 00000000..2b646c16 --- /dev/null +++ b/frontend/src/services/api-client/endpoints/get-html-endpoint.ts @@ -0,0 +1,10 @@ +import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts"; +import {RemoteResource} from "@/services/api-client/remote-resource.ts"; + +export class GetHtmlEndpoint extends RestEndpoint { + readonly method: "GET" | "POST" | "PUT" | "DELETE" = "GET"; + + public get(pathParams: PP, queryParams: QP): RemoteResource { + return super.request(pathParams, queryParams, undefined, "document"); + } +} diff --git a/frontend/src/services/api-client/endpoints/rest-endpoint.ts b/frontend/src/services/api-client/endpoints/rest-endpoint.ts index 3f6bb5cf..cbe18aa0 100644 --- a/frontend/src/services/api-client/endpoints/rest-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/rest-endpoint.ts @@ -1,23 +1,28 @@ import {RemoteResource} from "@/services/api-client/remote-resource.ts"; import apiClient from "@/services/api-client/api-client.ts"; -import {HttpErrorStatusException} from "@/services/api-client/api-exceptions"; +import {HttpErrorStatusException} from "@/services/api-client/api-exceptions.ts"; +import type {ResponseType} from "axios"; export abstract class RestEndpoint { public abstract readonly method: "GET" | "POST" | "PUT" | "DELETE"; constructor(public readonly url: string) { } - protected request(pathParams: PP, queryParams: QP, body: B): RemoteResource { - let urlFilledIn = this.url; - urlFilledIn.replace(/:(\w+)([/$])/g, (_, key, after) => - (key in pathParams ? encodeURIComponent(pathParams[key]) : `:${key}`) + after + protected request(pathParams: PP, queryParams: QP, body: B, responseType?: ResponseType): RemoteResource { + let urlFilledIn = this.url.replace(/:(\w+)(\/|$)/g, (_, key, after) => + (pathParams[key] ? encodeURIComponent(pathParams[key]) : `:${key}`) + after ); + console.log(this.url); + console.log(/:(\w+)(\W|$)/g.test(this.url)) + console.log(pathParams); + console.log("--> filled in: " + urlFilledIn); return new RemoteResource(async () => { const response = await apiClient.request({ url: urlFilledIn, method: this.method, params: queryParams, data: body, + responseType: responseType || 'json' }); if (response.status / 100 !== 2) { throw new HttpErrorStatusException(response); @@ -27,4 +32,4 @@ export abstract class RestEndpoint { } } -export type Params = {[key: string]: string | number | boolean}; +export type Params = {[key: string]: string | number | boolean | undefined}; diff --git a/frontend/src/services/api-client/remote-resource.ts b/frontend/src/services/api-client/remote-resource.ts index b1add8b9..4e3cbdfb 100644 --- a/frontend/src/services/api-client/remote-resource.ts +++ b/frontend/src/services/api-client/remote-resource.ts @@ -2,22 +2,40 @@ export class RemoteResource { static NOT_LOADED: NotLoadedState = {type: "notLoaded"}; static LOADING: LoadingState = {type: "loading"}; - private state: NotLoadedState | LoadingState | ErrorState | SuccessState = RemoteResource.NOT_LOADED; + private _state: RemoteResourceState = RemoteResource.NOT_LOADED; constructor(private readonly requestFn: () => Promise) { } + public static join(resources: RemoteResource[]): RemoteResource { + return new RemoteResource(async () => { + console.log("joined fetch"); + const promises = resources.map(it => it.request()); + const data = await Promise.all(promises); + const failed = resources + .filter(it => it.state.type === "error") + .map(it => it.state as ErrorState); + if (failed.length > 0) { + console.log("joined error!"); + throw failed[0].error; + } + console.log("succ"); + console.log(data); + return data.map(it => it!); + }); + } + public async request(): Promise { - this.state = RemoteResource.LOADING; + this._state = RemoteResource.LOADING; try { let resource = await this.requestFn(); - this.state = { + this._state = { type: "success", data: resource }; return resource; } catch (e: any) { - this.state = { + this._state = { type: "error", errorCode: e.statusCode, message: e.message, @@ -31,19 +49,23 @@ export class RemoteResource { return this; } + public get state(): RemoteResourceState { + return this._state; + } + public get data(): T | undefined { - if (this.state.type === "success") { - return this.state.data; + if (this._state.type === "success") { + return this._state.data; } } public map(mappingFn: (content: T) => U): RemoteResource { return new RemoteResource(async () => { await this.request(); - if (this.state.type === "success") { - return mappingFn(this.state.data); - } else if (this.state.type === "error") { - throw this.state.error; + if (this._state.type === "success") { + return mappingFn(this._state.data); + } else if (this._state.type === "error") { + throw this._state.error; } else { throw new Error("Fetched resource, but afterwards, it was neither in a success nor in an error state. " + "This should never happen."); @@ -52,19 +74,20 @@ export class RemoteResource { } } -type NotLoadedState = { +export type NotLoadedState = { type: "notLoaded" }; -type LoadingState = { +export type LoadingState = { type: "loading" }; -type ErrorState = { +export type ErrorState = { type: "error", errorCode?: number, message?: string, error: any }; -type SuccessState = { +export type SuccessState = { type: "success", data: T -} +}; +export type RemoteResourceState = NotLoadedState | LoadingState | ErrorState | SuccessState; diff --git a/frontend/src/services/learning-content/learning-path-service.ts b/frontend/src/services/learning-content/learning-path-service.ts index be9dffd5..c1af58c9 100644 --- a/frontend/src/services/learning-content/learning-path-service.ts +++ b/frontend/src/services/learning-content/learning-path-service.ts @@ -1,13 +1,23 @@ import {GetEndpoint} from "@/services/api-client/endpoints/get-endpoint.ts"; import {LearningPath, type LearningPathDTO} from "@/services/learning-content/learning-path.ts"; import type {RemoteResource} from "@/services/api-client/remote-resource.ts"; +import type {Language} from "@/services/learning-content/language.ts"; +import {single} from "@/utils/response-assertions.ts"; -const searchLearningPathsEndpoint = new GetEndpoint<{}, {query: string}, LearningPathDTO[]>( - "/learningObjects/:query" +const learningPathEndpoint = new GetEndpoint<{}, {search?: string, hruid?: string, language?: Language}, LearningPathDTO[]>( + "/learningPath" ); export function searchLearningPaths(query: string): RemoteResource { - return searchLearningPathsEndpoint - .get({}, {query: query}) + return learningPathEndpoint + .get({}, {search: query}) .map(dtos => dtos.map(dto => LearningPath.fromDTO(dto))); } + +export function getLearningPath(hruid: string, language: Language): RemoteResource { + console.log({hruid, language}) + return learningPathEndpoint + .get({}, {hruid, language}) + .map(it => {console.log(it); return it;}) + .map(dtos => LearningPath.fromDTO(single(dtos))); +} diff --git a/frontend/src/services/learning-content/learning-path.ts b/frontend/src/services/learning-content/learning-path.ts index 7aefa28e..19a94339 100644 --- a/frontend/src/services/learning-content/learning-path.ts +++ b/frontend/src/services/learning-content/learning-path.ts @@ -1,5 +1,5 @@ import type {Language} from "@/services/learning-content/language.ts"; -import type {RemoteResource} from "@/services/api-client/remote-resource.ts"; +import {RemoteResource} from "@/services/api-client/remote-resource.ts"; import type {LearningObject} from "@/services/learning-content/learning-object.ts"; import {getLearningObjectMetadata} from "@/services/learning-content/learning-object-service.ts"; @@ -98,6 +98,21 @@ export class LearningPath { ) { } + public get nodesAsList(): LearningPathNode[] { + let list: LearningPathNode[] = []; + let currentNode = this.startNode; + while (currentNode) { + list.push(currentNode); + currentNode = currentNode.transitions.filter(it => it.default)[0]?.next + || currentNode.transitions[0]?.next; + } + return list; + } + + public get learningObjectsAsList(): RemoteResource { + return RemoteResource.join(this.nodesAsList.map(node => node.learningObject)); + } + static fromDTO(dto: LearningPathDTO): LearningPath { let startNodeDto = dto.nodes.filter(it => it.start_node); if (startNodeDto.length !== 1) { diff --git a/frontend/src/utils/response-assertions.ts b/frontend/src/utils/response-assertions.ts new file mode 100644 index 00000000..5be421ff --- /dev/null +++ b/frontend/src/utils/response-assertions.ts @@ -0,0 +1,11 @@ +import {InvalidResponseException, NotFoundException} from "@/services/api-client/api-exceptions.ts"; + +export function single(list: T[]): T { + if (list.length === 1) { + return list[0]; + } else if (list.length === 0) { + throw new NotFoundException("Expected list with exactly one element, but got an empty list."); + } else { + throw new InvalidResponseException(`Expected list with exactly one element, but got one with ${list.length} elements.`); + } +} diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue index 14e911e2..541f0fbe 100644 --- a/frontend/src/views/learning-paths/LearningPathPage.vue +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -1,18 +1,59 @@ diff --git a/frontend/src/services/api-client/endpoints/delete-endpoint.ts b/frontend/src/services/api-client/endpoints/delete-endpoint.ts index 554e1855..0c01e55a 100644 --- a/frontend/src/services/api-client/endpoints/delete-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/delete-endpoint.ts @@ -1,10 +1,9 @@ import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts"; -import {RemoteResource} from "@/services/api-client/remote-resource.ts"; export class DeleteEndpoint extends RestEndpoint { readonly method = "GET"; - public delete(pathParams: PP, queryParams: QP): RemoteResource { + public delete(pathParams: PP, queryParams: QP): Promise { return super.request(pathParams, queryParams, undefined); } } diff --git a/frontend/src/services/api-client/endpoints/get-endpoint.ts b/frontend/src/services/api-client/endpoints/get-endpoint.ts index 1d9a086f..4393e288 100644 --- a/frontend/src/services/api-client/endpoints/get-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/get-endpoint.ts @@ -1,10 +1,9 @@ import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts"; -import {RemoteResource} from "@/services/api-client/remote-resource.ts"; export class GetEndpoint extends RestEndpoint { readonly method = "GET"; - public get(pathParams: PP, queryParams: QP): RemoteResource { + public get(pathParams: PP, queryParams: QP): Promise { return super.request(pathParams, queryParams, undefined); } } diff --git a/frontend/src/services/api-client/endpoints/get-html-endpoint.ts b/frontend/src/services/api-client/endpoints/get-html-endpoint.ts index 2b646c16..9bb2e523 100644 --- a/frontend/src/services/api-client/endpoints/get-html-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/get-html-endpoint.ts @@ -1,10 +1,9 @@ import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts"; -import {RemoteResource} from "@/services/api-client/remote-resource.ts"; export class GetHtmlEndpoint extends RestEndpoint { readonly method: "GET" | "POST" | "PUT" | "DELETE" = "GET"; - public get(pathParams: PP, queryParams: QP): RemoteResource { + public get(pathParams: PP, queryParams: QP): Promise { return super.request(pathParams, queryParams, undefined, "document"); } } diff --git a/frontend/src/services/api-client/endpoints/post-endpoint.ts b/frontend/src/services/api-client/endpoints/post-endpoint.ts index 6fde53e8..9b5fd96f 100644 --- a/frontend/src/services/api-client/endpoints/post-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/post-endpoint.ts @@ -1,10 +1,9 @@ import {type Params, RestEndpoint} from "@/services/api-client/endpoints/rest-endpoint.ts"; -import {RemoteResource} from "@/services/api-client/remote-resource.ts"; export class PostEndpoint extends RestEndpoint { readonly method = "POST"; - public post(pathParams: PP, queryParams: QP, body: B): RemoteResource { + public post(pathParams: PP, queryParams: QP, body: B): Promise { return super.request(pathParams, queryParams, body); } } diff --git a/frontend/src/services/api-client/endpoints/rest-endpoint.ts b/frontend/src/services/api-client/endpoints/rest-endpoint.ts index cbe18aa0..4438214f 100644 --- a/frontend/src/services/api-client/endpoints/rest-endpoint.ts +++ b/frontend/src/services/api-client/endpoints/rest-endpoint.ts @@ -1,4 +1,3 @@ -import {RemoteResource} from "@/services/api-client/remote-resource.ts"; import apiClient from "@/services/api-client/api-client.ts"; import {HttpErrorStatusException} from "@/services/api-client/api-exceptions.ts"; import type {ResponseType} from "axios"; @@ -8,27 +7,21 @@ export abstract class RestEndpoint { constructor(public readonly url: string) { } - protected request(pathParams: PP, queryParams: QP, body: B, responseType?: ResponseType): RemoteResource { + protected async request(pathParams: PP, queryParams: QP, body: B, responseType?: ResponseType): Promise { let urlFilledIn = this.url.replace(/:(\w+)(\/|$)/g, (_, key, after) => (pathParams[key] ? encodeURIComponent(pathParams[key]) : `:${key}`) + after ); - console.log(this.url); - console.log(/:(\w+)(\W|$)/g.test(this.url)) - console.log(pathParams); - console.log("--> filled in: " + urlFilledIn); - return new RemoteResource(async () => { - const response = await apiClient.request({ - url: urlFilledIn, - method: this.method, - params: queryParams, - data: body, - responseType: responseType || 'json' - }); - if (response.status / 100 !== 2) { - throw new HttpErrorStatusException(response); - } - return response.data; + const response = await apiClient.request({ + url: urlFilledIn, + method: this.method, + params: queryParams, + data: body, + responseType: responseType || 'json' }); + if (response.status / 100 !== 2) { + throw new HttpErrorStatusException(response); + } + return response.data; } } diff --git a/frontend/src/services/api-client/remote-resource.ts b/frontend/src/services/api-client/remote-resource.ts index 4e3cbdfb..24982135 100644 --- a/frontend/src/services/api-client/remote-resource.ts +++ b/frontend/src/services/api-client/remote-resource.ts @@ -1,78 +1,4 @@ -export class RemoteResource { - static NOT_LOADED: NotLoadedState = {type: "notLoaded"}; - static LOADING: LoadingState = {type: "loading"}; - - private _state: RemoteResourceState = RemoteResource.NOT_LOADED; - - constructor(private readonly requestFn: () => Promise) { - } - - public static join(resources: RemoteResource[]): RemoteResource { - return new RemoteResource(async () => { - console.log("joined fetch"); - const promises = resources.map(it => it.request()); - const data = await Promise.all(promises); - const failed = resources - .filter(it => it.state.type === "error") - .map(it => it.state as ErrorState); - if (failed.length > 0) { - console.log("joined error!"); - throw failed[0].error; - } - console.log("succ"); - console.log(data); - return data.map(it => it!); - }); - } - - public async request(): Promise { - this._state = RemoteResource.LOADING; - try { - let resource = await this.requestFn(); - this._state = { - type: "success", - data: resource - }; - return resource; - } catch (e: any) { - this._state = { - type: "error", - errorCode: e.statusCode, - message: e.message, - error: e - }; - } - } - - public startRequestInBackground(): RemoteResource { - this.request().then(); - return this; - } - - public get state(): RemoteResourceState { - return this._state; - } - - public get data(): T | undefined { - if (this._state.type === "success") { - return this._state.data; - } - } - - public map(mappingFn: (content: T) => U): RemoteResource { - return new RemoteResource(async () => { - await this.request(); - if (this._state.type === "success") { - return mappingFn(this._state.data); - } else if (this._state.type === "error") { - throw this._state.error; - } else { - throw new Error("Fetched resource, but afterwards, it was neither in a success nor in an error state. " + - "This should never happen."); - } - }); - } -} +import {type ShallowReactive, shallowReactive} from "vue"; export type NotLoadedState = { type: "notLoaded" @@ -82,8 +8,6 @@ export type LoadingState = { }; export type ErrorState = { type: "error", - errorCode?: number, - message?: string, error: any }; export type SuccessState = { @@ -91,3 +15,23 @@ export type SuccessState = { data: T }; export type RemoteResourceState = NotLoadedState | LoadingState | ErrorState | SuccessState; + +export type RemoteResource = ShallowReactive<{ + state: RemoteResourceState +}>; + +export function remoteResource(): RemoteResource { + return shallowReactive({ + state: { + type: "notLoaded" + } + }); +} + +export function loadResource(resource: RemoteResource, promise: Promise): void { + resource.state = { type: "loading" } + promise.then( + data => resource.state = { type: "success", data }, + error => resource.state = { type: "error", error } + ); +} diff --git a/frontend/src/services/learning-content/learning-object-service.ts b/frontend/src/services/learning-content/learning-object-service.ts index 076a662e..c6148025 100644 --- a/frontend/src/services/learning-content/learning-object-service.ts +++ b/frontend/src/services/learning-content/learning-object-service.ts @@ -1,13 +1,28 @@ import {GetEndpoint} from "@/services/api-client/endpoints/get-endpoint.ts"; import type {LearningObject} from "@/services/learning-content/learning-object.ts"; import type {Language} from "@/services/learning-content/language.ts"; -import type {RemoteResource} from "@/services/api-client/remote-resource.ts"; +import {GetHtmlEndpoint} from "@/services/api-client/endpoints/get-html-endpoint.ts"; const getLearningObjectMetadataEndpoint = new GetEndpoint<{hruid: string}, {language: Language, version: number}, LearningObject>( "/learningObject/:hruid" ); -export function getLearningObjectMetadata(hruid: string, language: Language, version: number): RemoteResource { - return getLearningObjectMetadataEndpoint - .get({hruid}, {language, version}); +const getLearningObjectHtmlEndpoint = new GetHtmlEndpoint<{hruid: string}, {language: Language, version: number}>( + "/learningObject/:hruid/html" +); + +export function getLearningObjectMetadata( + hruid: string, + language: Language, + version: number +): Promise { + return getLearningObjectMetadataEndpoint.get({hruid}, {language, version}); +} + +export function getLearningObjectHTML( + hruid: string, + language: Language, + version: number +): Promise { + return getLearningObjectHtmlEndpoint.get({hruid}, {language, version}); } diff --git a/frontend/src/services/learning-content/learning-path-service.ts b/frontend/src/services/learning-content/learning-path-service.ts index c1af58c9..6c0043ad 100644 --- a/frontend/src/services/learning-content/learning-path-service.ts +++ b/frontend/src/services/learning-content/learning-path-service.ts @@ -1,6 +1,5 @@ import {GetEndpoint} from "@/services/api-client/endpoints/get-endpoint.ts"; import {LearningPath, type LearningPathDTO} from "@/services/learning-content/learning-path.ts"; -import type {RemoteResource} from "@/services/api-client/remote-resource.ts"; import type {Language} from "@/services/learning-content/language.ts"; import {single} from "@/utils/response-assertions.ts"; @@ -8,16 +7,12 @@ const learningPathEndpoint = new GetEndpoint<{}, {search?: string, hruid?: strin "/learningPath" ); -export function searchLearningPaths(query: string): RemoteResource { - return learningPathEndpoint - .get({}, {search: query}) - .map(dtos => dtos.map(dto => LearningPath.fromDTO(dto))); +export async function searchLearningPaths(query: string): Promise { + let dtos = await learningPathEndpoint.get({}, {search: query}) + return dtos.map(dto => LearningPath.fromDTO(dto)); } -export function getLearningPath(hruid: string, language: Language): RemoteResource { - console.log({hruid, language}) - return learningPathEndpoint - .get({}, {hruid, language}) - .map(it => {console.log(it); return it;}) - .map(dtos => LearningPath.fromDTO(single(dtos))); +export async function getLearningPath(hruid: string, language: Language): Promise { + let dtos = await learningPathEndpoint.get({}, {hruid, language}); + return LearningPath.fromDTO(single(dtos)); } diff --git a/frontend/src/services/learning-content/learning-path.ts b/frontend/src/services/learning-content/learning-path.ts index 19a94339..a07faf9c 100644 --- a/frontend/src/services/learning-content/learning-path.ts +++ b/frontend/src/services/learning-content/learning-path.ts @@ -1,5 +1,4 @@ import type {Language} from "@/services/learning-content/language.ts"; -import {RemoteResource} from "@/services/api-client/remote-resource.ts"; import type {LearningObject} from "@/services/learning-content/learning-object.ts"; import {getLearningObjectMetadata} from "@/services/learning-content/learning-object-service.ts"; @@ -43,7 +42,6 @@ interface LearningPathTransitionDTO { } export class LearningPathNode { - public learningObject: RemoteResource constructor( public readonly learningobjectHruid: string, @@ -53,7 +51,10 @@ export class LearningPathNode { public readonly createdAt: Date, public readonly updatedAt: Date ) { - this.learningObject = getLearningObjectMetadata(learningobjectHruid, language, version); + } + + get learningObject(): Promise { + return getLearningObjectMetadata(this.learningobjectHruid, this.language, this.version); } static fromDTOAndOtherNodes(dto: LearningPathNodeDTO, otherNodes: LearningPathNodeDTO[]): LearningPathNode { @@ -109,8 +110,8 @@ export class LearningPath { return list; } - public get learningObjectsAsList(): RemoteResource { - return RemoteResource.join(this.nodesAsList.map(node => node.learningObject)); + public get learningObjectsAsList(): Promise { + return Promise.all(this.nodesAsList.map(node => node.learningObject)); } static fromDTO(dto: LearningPathDTO): LearningPath { diff --git a/frontend/src/views/learning-paths/LearningObjectView.vue b/frontend/src/views/learning-paths/LearningObjectView.vue new file mode 100644 index 00000000..064965ce --- /dev/null +++ b/frontend/src/views/learning-paths/LearningObjectView.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue index 541f0fbe..cadc02c0 100644 --- a/frontend/src/views/learning-paths/LearningPathPage.vue +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -2,31 +2,52 @@ import {Language} from "@/services/learning-content/language.ts"; import {getLearningPath} from "@/services/learning-content/learning-path-service.ts"; import UsingRemoteResource from "@/components/UsingRemoteResource.vue"; - import type {LearningPath} from "@/services/learning-content/learning-path.ts"; - import {onMounted, reactive, watch} from "vue"; + import {type LearningPath} from "@/services/learning-content/learning-path.ts"; + import {computed, watch, watchEffect} from "vue"; import type {LearningObject} from "@/services/learning-content/learning-object.ts"; import {useRouter} from "vue-router"; - import type {SuccessState} from "@/services/api-client/remote-resource.ts"; + import {loadResource, remoteResource, type SuccessState} from "@/services/api-client/remote-resource.ts"; + import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; const router = useRouter(); const props = defineProps<{hruid: string, language: Language, learningObjectHruid?: string}>() - const learningPathResource = reactive(getLearningPath(props.hruid, props.language)); + const learningPathResource = remoteResource(); + watchEffect(() => { + loadResource(learningPathResource, getLearningPath(props.hruid, props.language)); + }); + + const learningObjectListResource = remoteResource(); + watch(learningPathResource, () => { + if (learningPathResource.state.type === "success") { + loadResource(learningObjectListResource, learningPathResource.state.data.learningObjectsAsList) + } + }, {immediate: true}); + + const currentNode = computed(() => { + let currentHruid = props.learningObjectHruid; + if (learningPathResource.state.type === "success") { + return learningPathResource.state.data.nodesAsList.filter(it => it.learningobjectHruid === currentHruid)[0] + } else { + return undefined; + } + }); if (!props.learningObjectHruid) { watch(() => learningPathResource.state, (newValue) => { - console.log("state changed!!"); if (newValue.type === "success") { router.push(router.currentRoute.value.path + "/" + (newValue as SuccessState).data.startNode.learningobjectHruid); } }); } - From 4356a1ccd20e8583ca5bbf92007cabdf2dd2f829 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Mon, 24 Mar 2025 22:58:44 +0100 Subject: [PATCH 015/112] feat(frontend): "Volgende" en "vorige"-knop toegevoegd aan leerpadpagina. --- .../src/components/UsingRemoteResource.vue | 1 + frontend/src/i18n/locale/de.json | 4 +- frontend/src/i18n/locale/en.json | 4 +- frontend/src/i18n/locale/fr.json | 4 +- frontend/src/i18n/locale/nl.json | 4 +- .../learning-content/learning-path-service.ts | 15 ++- .../learning-content/learning-path.ts | 4 +- .../views/learning-paths/LearningPathPage.vue | 98 ++++++++++++++++--- 8 files changed, 112 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/UsingRemoteResource.vue b/frontend/src/components/UsingRemoteResource.vue index 5248d7a9..a33e327b 100644 --- a/frontend/src/components/UsingRemoteResource.vue +++ b/frontend/src/components/UsingRemoteResource.vue @@ -35,6 +35,7 @@ diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json index b9a087fc..13638aaa 100644 --- a/frontend/src/i18n/locale/de.json +++ b/frontend/src/i18n/locale/de.json @@ -1,4 +1,6 @@ { "welcome": "Willkommen", - "error_title": "Fehler" + "error_title": "Fehler", + "previous": "Zurück", + "next": "Weiter" } diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index a04b3fa5..3e149759 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -6,5 +6,7 @@ "classes": "classes", "discussions": "discussions", "logout": "log out", - "error_title": "Error" + "error_title": "Error", + "previous": "Previous", + "next": "Next" } diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json index 1d59c4dd..924dc94b 100644 --- a/frontend/src/i18n/locale/fr.json +++ b/frontend/src/i18n/locale/fr.json @@ -1,4 +1,6 @@ { "welcome": "Bienvenue", - "error_title": "Erreur" + "error_title": "Erreur", + "previous": "Précédente", + "next": "Suivante" } diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json index 576a95b4..8e058396 100644 --- a/frontend/src/i18n/locale/nl.json +++ b/frontend/src/i18n/locale/nl.json @@ -6,5 +6,7 @@ "classes": "klassen", "discussions": "discussies", "logout": "log uit", - "error_title": "Fout" + "error_title": "Fout", + "previous": "Vorige", + "next": "Volgende" } diff --git a/frontend/src/services/learning-content/learning-path-service.ts b/frontend/src/services/learning-content/learning-path-service.ts index 6c0043ad..a0a9f1a9 100644 --- a/frontend/src/services/learning-content/learning-path-service.ts +++ b/frontend/src/services/learning-content/learning-path-service.ts @@ -3,16 +3,21 @@ import {LearningPath, type LearningPathDTO} from "@/services/learning-content/le import type {Language} from "@/services/learning-content/language.ts"; import {single} from "@/utils/response-assertions.ts"; -const learningPathEndpoint = new GetEndpoint<{}, {search?: string, hruid?: string, language?: Language}, LearningPathDTO[]>( - "/learningPath" -); +const learningPathEndpoint = new GetEndpoint< + {}, + {search?: string, hruid?: string, language?: Language, forGroup?: string, forStudent?: string}, + LearningPathDTO[] +>("/learningPath"); export async function searchLearningPaths(query: string): Promise { let dtos = await learningPathEndpoint.get({}, {search: query}) return dtos.map(dto => LearningPath.fromDTO(dto)); } -export async function getLearningPath(hruid: string, language: Language): Promise { - let dtos = await learningPathEndpoint.get({}, {hruid, language}); +export async function getLearningPath(hruid: string, language: Language, options?: {forGroup?: string, forStudent?: string}): Promise { + let dtos = await learningPathEndpoint.get( + {}, + {hruid, language, forGroup: options?.forGroup, forStudent: options?.forStudent} + ); return LearningPath.fromDTO(single(dtos)); } diff --git a/frontend/src/services/learning-content/learning-path.ts b/frontend/src/services/learning-content/learning-path.ts index a07faf9c..2bbef7f2 100644 --- a/frontend/src/services/learning-content/learning-path.ts +++ b/frontend/src/services/learning-content/learning-path.ts @@ -49,7 +49,8 @@ export class LearningPathNode { public readonly language: Language, public readonly transitions: {next: LearningPathNode, default: boolean}[], public readonly createdAt: Date, - public readonly updatedAt: Date + public readonly updatedAt: Date, + public readonly done: boolean = false ) { } @@ -80,6 +81,7 @@ export class LearningPathNode { }), new Date(dto.created_at), new Date(dto.updatedAt), + dto.done ) } } diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue index cadc02c0..05f0e116 100644 --- a/frontend/src/views/learning-paths/LearningPathPage.vue +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -2,20 +2,36 @@ import {Language} from "@/services/learning-content/language.ts"; import {getLearningPath} from "@/services/learning-content/learning-path-service.ts"; import UsingRemoteResource from "@/components/UsingRemoteResource.vue"; - import {type LearningPath} from "@/services/learning-content/learning-path.ts"; - import {computed, watch, watchEffect} from "vue"; + import {type LearningPath, LearningPathNode} from "@/services/learning-content/learning-path.ts"; + import {computed, type ComputedRef, watch} from "vue"; import type {LearningObject} from "@/services/learning-content/learning-object.ts"; - import {useRouter} from "vue-router"; + import {useRoute, useRouter} from "vue-router"; import {loadResource, remoteResource, type SuccessState} from "@/services/api-client/remote-resource.ts"; import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; + import {useI18n} from "vue-i18n"; const router = useRouter(); + const route = useRoute(); + const { t } = useI18n(); + const props = defineProps<{hruid: string, language: Language, learningObjectHruid?: string}>() + interface QueryParams { + forStudent?: string, + forGroup?: string + } + const learningPathResource = remoteResource(); - watchEffect(() => { - loadResource(learningPathResource, getLearningPath(props.hruid, props.language)); - }); + watch([() => props.hruid, () => props.language, () => route.query.forStudent, () => route.query.forGroup], () => { + loadResource( + learningPathResource, + getLearningPath( + props.hruid, + props.language, + route.query as QueryParams + ) + ) + }, {immediate: true}); const learningObjectListResource = remoteResource(); watch(learningPathResource, () => { @@ -24,12 +40,36 @@ } }, {immediate: true}); - const currentNode = computed(() => { - let currentHruid = props.learningObjectHruid; + const nodesList: ComputedRef = computed(() => { if (learningPathResource.state.type === "success") { - return learningPathResource.state.data.nodesAsList.filter(it => it.learningobjectHruid === currentHruid)[0] + return learningPathResource.state.data.nodesAsList; } else { - return undefined; + return null; + } + }) + + const currentNode = computed(() => { + const currentHruid = props.learningObjectHruid; + if (nodesList.value) { + return nodesList.value.filter(it => it.learningobjectHruid === currentHruid)[0] + } + }); + + const nextNode = computed(() => { + if (!currentNode.value || !nodesList.value) + return; + const currentIndex = nodesList.value?.indexOf(currentNode.value); + if (currentIndex < nodesList.value?.length) { + return nodesList.value?.[currentIndex + 1]; + } + }); + + const previousNode = computed(() => { + if (!currentNode.value || !nodesList.value) + return; + const currentIndex = nodesList.value?.indexOf(currentNode.value); + if (currentIndex < nodesList.value?.length) { + return nodesList.value?.[currentIndex - 1]; } }); @@ -41,6 +81,17 @@ } }); } + + function isLearningObjectCompleted(learningObject: LearningObject): boolean { + if (learningPathResource.state.type === "success") { + return learningPathResource.state.data.nodesAsList.filter(it => + it.learningobjectHruid === learningObject.key + && it.version === learningObject.version + && it.language == learningObject.language + )[0].done; + } + return false; + }