Compare commits

..

215 commits

Author SHA1 Message Date
ce7614b8bc
chore: Update package-lock.json
Some checks failed
Backend Testing / Run backend unit tests (push) Has been cancelled
Frontend Testing / Run frontend unit tests (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
2025-05-20 21:45:27 +02:00
87773a81e1
chore: Update package versies 2025-05-20 21:43:21 +02:00
Timo De Meyst
716f63842f
Merge pull request #295 from SELab-2/chore/docker-optimalization
chore: `**/tests/` niet in docker container
2025-05-20 21:42:20 +02:00
ef52b8f1f0
ci: Specify docker syntax 2025-05-20 21:23:40 +02:00
Gabriellvl
d68564c953
Merge pull request #289 from SELab-2/fix/questions-toon-enkel-groep
fix: Questions, submissions & discussions
2025-05-20 20:57:19 +02:00
Joyelle Ndagijimana
0abd91373d fix: query refetchen ipv hele pagina reloaden 2025-05-20 20:31:54 +02:00
Timo De Meyst
8905182f78
Merge pull request #286 from SELab-2/extra/discussions
extra: Discussions optimalisaties
2025-05-20 20:25:29 +02:00
Timo De Meyst
6090a9d12e optimalization: test bestanden worden niet mee gekopieerd naar docker containers 2025-05-20 19:21:16 +02:00
Lint Action
13c95c9021 style: fix linting issues met Prettier 2025-05-20 17:16:12 +00:00
75dc025f55
Merge pull request #294 from SELab-2/chore/server-aanpassingen
chore: Server voorbereiden op release v1.0.0
2025-05-20 19:12:03 +02:00
1069a6a368
ci: IDP in dev moet wel realms importeren 2025-05-20 19:08:58 +02:00
Lint Action
a227694f7d style: fix linting issues met Prettier 2025-05-20 17:03:41 +00:00
3c20b52b27
Merge branch 'dev' into extra/discussions 2025-05-20 19:02:44 +02:00
Gerald Schmittinger
fe28770016 Merge branch 'fix/questions-toon-enkel-groep' of https://github.com/SELab-2/Dwengo-1 into fix/questions-toon-enkel-groep 2025-05-20 19:00:24 +02:00
Gerald Schmittinger
4b40d8e9b6 fix(frontend): Leerkrachten zien enkel nog vragen voor de geselecteerde groep 2025-05-20 19:00:17 +02:00
49afdf9867
fixup! fix(frontend): Submission status per groep 2025-05-20 18:57:52 +02:00
cd2f8583f9
fixup! fix(frontend): Submission status per groep 2025-05-20 18:51:06 +02:00
Lint Action
0e8b1d803f style: fix linting issues met Prettier 2025-05-20 16:44:00 +00:00
c9406f52aa
fix(frontend): Submission status per groep 2025-05-20 18:43:02 +02:00
Lint Action
fa0e45ea48 style: fix linting issues met Prettier 2025-05-20 16:34:19 +00:00
Gerald Schmittinger
d1e9303d3a fix(frontend): Teacher only leerobjecten niet meer getoond aan studenten 2025-05-20 18:30:01 +02:00
Lint Action
da0137e0a7 style: fix linting issues met Prettier 2025-05-20 16:14:17 +00:00
Gerald Schmittinger
fe4643d030 Merge branch 'fix/questions-toon-enkel-groep' of https://github.com/SELab-2/Dwengo-1 into fix/questions-toon-enkel-groep 2025-05-20 18:12:00 +02:00
Gerald Schmittinger
8f1194a021 fix(frontend): Assignment wordt met taal uit locale i.p.v. juiste taal aangemaakt. 2025-05-20 18:10:14 +02:00
Adriaan Jacquet
4e412f47f5 fix: missende import voor query in learningpathpage 2025-05-20 17:53:16 +02:00
73ca508ff3
Merge branch 'dev' into fix/questions-toon-enkel-groep 2025-05-20 16:37:34 +02:00
Laure Jablonski
475bd73cb2
Merge pull request #292 from SELab-2/291-maak-enkel-content-scrollable-in-sidebar-leerpaden
feat: Enkel content scrollable, niet titels, knoppen, ...
2025-05-20 16:06:34 +02:00
Lint Action
65d79b010c style: fix linting issues met Prettier 2025-05-20 13:57:58 +00:00
Gerald Schmittinger
1e7f8a1969 Merge branch 'fix/questions-toon-enkel-groep' of https://github.com/SELab-2/Dwengo-1 into fix/questions-toon-enkel-groep 2025-05-20 15:57:04 +02:00
Gerald Schmittinger
2b90f20d45 fix(backend): Workaround MikroORM syntax error 2025-05-20 15:57:01 +02:00
Lint Action
bbbb23b700 style: fix linting issues met Prettier 2025-05-20 13:34:44 +00:00
4ca57d8b8c
fix(frontend): Alleen fetchen bij open 2025-05-20 15:33:40 +02:00
Joyelle Ndagijimana
a88390f46b fix: query oproepen buiten de template 2025-05-20 15:18:15 +02:00
Lint Action
5afa4c2312 style: fix linting issues met Prettier 2025-05-20 12:45:24 +00:00
Gerald Schmittinger
cac944e9c3 fix(frontend): Juiste groep ID gebruiken op leerpadpagina.
Vroeger werd zowel bij de redirect vanuit assignments als bij het kiezen van een groep in de ComboBox de "mooie" groeps-ID i.p.v. de echte gebruikt.
2025-05-20 14:44:26 +02:00
Lint Action
74099fedc7 style: fix linting issues met Prettier 2025-05-20 11:52:23 +00:00
Gabriellvl
1c118a42f3 fix: snackbar message bij groep aanpassen 2025-05-20 13:50:24 +02:00
Gerald Schmittinger
3dae015e3e Merge branch 'fix/questions-toon-enkel-groep' of https://github.com/SELab-2/Dwengo-1 into fix/questions-toon-enkel-groep 2025-05-20 13:46:03 +02:00
Gerald Schmittinger
762131ed34 fix: Vragen stellen 2025-05-20 13:44:45 +02:00
Lint Action
ca95603f97 style: fix linting issues met Prettier 2025-05-20 11:30:53 +00:00
Gerald Schmittinger
85221b4cfe Merge branch 'fix/questions-toon-enkel-groep' of https://github.com/SELab-2/Dwengo-1 into fix/questions-toon-enkel-groep 2025-05-20 13:26:43 +02:00
Gerald Schmittinger
8edd4f0225 fix: Logica vragen stellen gecorrigeerd (WIP) 2025-05-20 13:26:39 +02:00
Lint Action
4b5f215246 style: fix linting issues met Prettier 2025-05-20 11:16:13 +00:00
dea9930705
fix: groupId -> forGroup 2025-05-20 13:13:23 +02:00
Timo De Meyst
65a2a0f662 test-realms worden niet meer geïmport 2025-05-20 12:20:57 +02:00
Lint Action
62ba67b5b6 style: fix linting issues met Prettier 2025-05-20 10:07:24 +00:00
Lint Action
c04b13c555 style: fix linting issues met Prettier 2025-05-20 09:34:58 +00:00
Gerald Schmittinger
1d9141bab7 fix(frontend): foutieve queries in QuestionBox.vue 2025-05-20 11:34:00 +02:00
laurejablonski
d4e1f0d721 Merge branch '291-maak-enkel-content-scrollable-in-sidebar-leerpaden' of https://github.com/SELab-2/Dwengo-1 into 291-maak-enkel-content-scrollable-in-sidebar-leerpaden 2025-05-20 11:26:41 +02:00
laurejablonski
6022c36bb6 style: format en lint 2025-05-20 11:26:24 +02:00
Lint Action
4604a8200d style: fix linting issues met Prettier 2025-05-20 09:23:57 +00:00
Lint Action
3867151a05 style: fix linting issues met ESLint 2025-05-20 09:23:50 +00:00
laurejablonski
e7fa469bde fix: json syntax error 2025-05-20 11:21:09 +02:00
laurejablonski
66ac71c6cb fix: verwijder onnodige queries 2025-05-20 11:19:55 +02:00
laurejablonski
c7c3a209e1 fix: dubbel punt moet niet in link 2025-05-20 11:19:34 +02:00
laurejablonski
4a7b321b53 fix: submissions en assignments veiliger opvragen 2025-05-20 11:19:18 +02:00
laurejablonski
7d80c8bdc1 feat: enkel content scrollable, niet titels, knoppen, ... 2025-05-20 09:27:27 +02:00
Lint Action
23d29f4a3c style: fix linting issues met Prettier 2025-05-20 06:59:10 +00:00
f9b74c5f2d
chore(frontend): console weghalen 2025-05-20 08:55:39 +02:00
04f11ae30b
fix(frontend): Groepen met meerdere leden
Overzich van medeleerlingen in groep werkt terug
Maakt gebruik van andere query
2025-05-20 08:55:00 +02:00
Gerald Schmittinger
98ac8ab5a7
Merge pull request #290 from SELab-2/fix/verbeteringen-eigen-leerpaden
fix: Kleine verbeteringen omtrent eigen leerpaden
2025-05-20 08:15:21 +02:00
Lint Action
95c1723dcb style: fix linting issues met Prettier 2025-05-19 22:14:46 +00:00
laurejablonski
8e50bdef21 fix: questions worden meteen herladen bij aanmaken nieuwe question zodat deze meteen getoond wordt 2025-05-20 00:13:24 +02:00
Gerald Schmittinger
778a823ac8 fix(frontend): Het startleerobject wordt getoond bij het openen van een leerpad vanuit my-content. 2025-05-20 00:05:10 +02:00
Gerald Schmittinger
4d20e223e5 fix(frontend): knoppen ownLearningContent pagina bovenaan 2025-05-19 23:53:24 +02:00
Lint Action
84b2cb1749 style: fix linting issues met Prettier 2025-05-19 21:44:54 +00:00
Gabriellvl
f85abea6f3 fix: maak questions zichtbaar enkel voor groep 2025-05-19 23:43:01 +02:00
Timo De Meyst
d4dc519a59
Merge branch 'dev' into extra/discussions 2025-05-19 21:43:30 +02:00
Timo De Meyst
4be55e1b9a feat: leerobjecten in discussions sidebar tonen wanneer er een vraag is gesteld 2025-05-19 21:37:16 +02:00
Timo De Meyst
b66026382b fix: geselecteerde leerobject wordt weer aangeduidt in discussions sidebar 2025-05-19 21:36:11 +02:00
Timo De Meyst
f6c2f71edb
Merge pull request #269 from SELab-2/feat/discussions
feat: Discussions pagina's
2025-05-19 21:28:38 +02:00
Lint Action
0ea343172f style: fix linting issues met Prettier 2025-05-19 19:00:21 +00:00
f5bf11d812
chore(frontend): Tweaks 2025-05-19 20:53:05 +02:00
e28a57754f
Merge branch 'dev' into feat/discussions 2025-05-19 19:59:10 +02:00
Joyelle Ndagijimana
a649713e15
Merge pull request #280 from SELab-2/feat/232-assignments-pagina-ui-ux
feat: Assignments UI/UX
2025-05-19 19:54:36 +02:00
Lint Action
6671ee0605 style: fix linting issues met Prettier 2025-05-19 17:12:00 +00:00
bed875c8d8
chore: Tweaks 2025-05-19 19:07:59 +02:00
Lint Action
fc10fb96d6 style: fix linting issues met Prettier 2025-05-19 16:27:22 +00:00
laurejablonski
9a0fc31255 fix: fout in testdata 2025-05-19 18:23:07 +02:00
Lint Action
9bf8c9a75e style: fix linting issues met Prettier 2025-05-19 15:55:22 +00:00
38319ebe5a
feat(frontend): Klasnaam is link naar klas 2025-05-19 17:54:16 +02:00
Lint Action
3760b3f6f3 style: fix linting issues met Prettier 2025-05-19 15:47:30 +00:00
Gabriellvl
902518f9b1 feat: snackbar message opdrachten lijst 2025-05-19 17:44:47 +02:00
Gabriellvl
2ac1b8187c fix: imports + extra error type try and catch 2025-05-19 17:44:13 +02:00
Gabriellvl
219226148d fix: undefined | string/number types 2025-05-19 17:09:41 +02:00
Gabriellvl
a6a34071de refactor: stomme typo 2025-05-19 17:00:00 +02:00
Lint Action
ef511a1889 style: fix linting issues met Prettier 2025-05-19 14:54:59 +00:00
Gabriellvl
b6d522ce0b fix: lint opnieuw 2025-05-19 16:52:38 +02:00
Lint Action
0862cb5c6b style: fix linting issues met Prettier 2025-05-19 14:38:09 +00:00
Gabriellvl
13e29d7d5f fix: lint 2025-05-19 16:36:44 +02:00
Gabriellvl
bd2c66b877 Merge remote-tracking branch 'origin/feat/232-assignments-pagina-ui-ux' into feat/232-assignments-pagina-ui-ux 2025-05-19 16:29:47 +02:00
Lint Action
098be7a7fd style: fix linting issues met Prettier 2025-05-19 14:24:26 +00:00
ad1e5a30ae
chore(frontend): Lazy and autoexpand 2025-05-19 16:20:01 +02:00
90050a2d87
chore(frontend): Vuetify collapsables 2025-05-19 16:19:23 +02:00
Gabriellvl
8cf2f16ff7 fix: error message delete assignment knop met vragen of submissions 2025-05-19 16:06:44 +02:00
Gabriellvl
29f6bd9cad fix: leerling kon alle opdrachten zien + test 2025-05-19 16:05:39 +02:00
Lint Action
893e632009 style: fix linting issues met Prettier 2025-05-19 12:58:58 +00:00
Joyelle Ndagijimana
59ed7ab3b5 fix: mag geen lege groepen aanmaken 2025-05-19 14:53:11 +02:00
Lint Action
b245edc54d style: fix linting issues met Prettier 2025-05-19 12:53:06 +00:00
a67315e05d
feat(frontend): Discussions sidebar volgt taal 2025-05-19 14:50:44 +02:00
Joyelle Ndagijimana
2f67033251 fix: alle studenten hoeven niet in een groep bij drag en drop + opdracht deadline mag null zijn 2025-05-19 14:31:03 +02:00
159d984121
fix(frontend): Leerkracht progress bar 2025-05-19 14:06:31 +02:00
Lint Action
d51ebae322 style: fix linting issues met Prettier 2025-05-19 10:19:40 +00:00
1aa823bf5c
fix(backend): onlyAllowSubmitter username opvragen 2025-05-19 12:17:03 +02:00
3ca516b490
fix(frontend): i18n ReferenceError 2025-05-19 11:47:56 +02:00
Adriaan Jacquet
e00fc0b613 fix: foute groupnummer wanneer op knop geklikt wordt die naar leerpad gaat in opdracht 2025-05-18 18:19:10 +02:00
Adriaan Jacquet
11d77168e0 Merge branch 'dev' into feat/232-assignments-pagina-ui-ux 2025-05-18 18:00:42 +02:00
Adriaan Jacquet
025811c247 fix: type in en internationalisation 2025-05-18 16:52:11 +02:00
Adriaan J.
390d198653
Merge pull request #284 from SELab-2/fix/submission-authorization
fix: authorizatie in submissions gefixt
2025-05-18 16:50:35 +02:00
Adriaan Jacquet
1fcd84367d merge: fixed merge errors 2025-05-18 16:46:54 +02:00
Adriaan Jacquet
56de56564f fix: linting errors 2025-05-18 16:46:09 +02:00
Lint Action
01c90586bd style: fix linting issues met Prettier 2025-05-18 14:44:53 +00:00
Adriaan Jacquet
705bce75e9 fix: req.query ipv req.params bij het authorizeren op root endpoint van submissions 2025-05-18 15:52:47 +02:00
Gerald Schmittinger
5646598229 Merge branch 'feat/discussions' of https://github.com/SELab-2/Dwengo-1 into feat/discussions 2025-05-18 14:56:53 +02:00
Gerald Schmittinger
5b6d371e9c fix(frontend): Vragen werden niet aan het juiste leerobject toegevoegd 2025-05-18 14:56:47 +02:00
Lint Action
b9cd7a756a style: fix linting issues met Prettier 2025-05-18 12:43:24 +00:00
Gerald Schmittinger
bf4ff9ffbe Merge branch 'feat/discussions' of https://github.com/SELab-2/Dwengo-1 into feat/discussions 2025-05-18 14:40:44 +02:00
Gerald Schmittinger
97f6a603f5 feat(frontend): UI vragen & antwoorden verbeterd. 2025-05-18 14:40:37 +02:00
Lint Action
57da85e14a style: fix linting issues met Prettier 2025-05-18 11:41:17 +00:00
Joyelle Ndagijimana
1b41163064 Merge remote-tracking branch 'origin/feat/232-assignments-pagina-ui-ux' into feat/232-assignments-pagina-ui-ux 2025-05-18 13:34:23 +02:00
Joyelle Ndagijimana
1d84d60737 fix: extra frontend tests 2025-05-18 13:34:10 +02:00
Lint Action
604c9236bc style: fix linting issues met Prettier 2025-05-18 11:08:42 +00:00
Joyelle Ndagijimana
9af2f136eb fix: lint proberen fixen 2025-05-18 13:06:25 +02:00
Joyelle Ndagijimana
844dce839f Merge remote-tracking branch 'origin/feat/232-assignments-pagina-ui-ux' into feat/232-assignments-pagina-ui-ux 2025-05-18 12:52:42 +02:00
Joyelle Ndagijimana
2901e94b53 fix: backend testen 2025-05-18 12:52:13 +02:00
Lint Action
42c69f55aa style: fix linting issues met Prettier 2025-05-18 10:44:49 +00:00
Joyelle Ndagijimana
2537045658 fix: kleine ui fix 2025-05-18 12:43:03 +02:00
Lint Action
cc506a431f style: fix linting issues met Prettier 2025-05-18 10:39:11 +00:00
Gerald Schmittinger
10a329bed3 fix(frontend): ontbrekende import in SingleQuestion.vue
Hierdoor crashte de leerpadpagina.
2025-05-18 12:38:16 +02:00
Gerald Schmittinger
3f5bf0559d
Merge pull request #281 from SELab-2/fix/log-out-dialog-elipsis-#279
fix: Ellipsis bevestigingsdialoog logout
2025-05-18 12:17:52 +02:00
Joyelle Ndagijimana
c4f178aa52 feat: extra vertalingen 2025-05-18 12:15:05 +02:00
c627806e27
fix(frontend): Typo
Co-authored-by: Gabriellvl <vanlangenhovefga@gmail.com>
2025-05-18 11:49:09 +02:00
Adriaan Jacquet
6d62d8f586 fix: authorizatie in submissions gefixt 2025-05-18 10:33:13 +02:00
Lint Action
c25dabb2ed style: fix linting issues met Prettier 2025-05-18 08:14:26 +00:00
c7f5f144a5
chore(frontend): Restore learningpath script 2025-05-18 10:13:17 +02:00
Gerald Schmittinger
e7119cf28d fix(frontend): style questions LearningPathPage 2025-05-18 10:02:54 +02:00
Joyelle Ndagijimana
149e4e80fc fix: 403 error bij het opvragen van een opdracht 2025-05-18 09:24:02 +02:00
Gerald Schmittinger
d32981b726 fix(frontend): syntax & import fouten 2025-05-18 08:55:11 +02:00
Lint Action
c9643f898a style: fix linting issues met Prettier 2025-05-18 00:19:50 +00:00
Gerald Schmittinger
6b84fca5b6 fix(frontend): ellipsis bevestigingsdialoog logout 2025-05-18 02:12:41 +02:00
Joyelle Ndagijimana
96076844a5 Merge branch 'dev' into feat/232-assignments-pagina-ui-ux
Merge dev into feat/232-assignments-pagina-ui-ux
2025-05-18 00:17:41 +02:00
Joyelle Ndagijimana
6d9396348a feat: format 2025-05-18 00:17:11 +02:00
Joyelle Ndagijimana
8f57da4bc1 Merge remote-tracking branch 'origin/feat/232-assignments-pagina-ui-ux' into feat/232-assignments-pagina-ui-ux
# Conflicts:
#	frontend/src/views/assignments/TeacherAssignment.vue
2025-05-18 00:12:08 +02:00
Joyelle Ndagijimana
922e5dd078 feat: meer engels vertalingen 2025-05-18 00:11:22 +02:00
Lint Action
323d66bbcb style: fix linting issues met Prettier 2025-05-17 18:47:26 +00:00
Lint Action
5b00066106 style: fix linting issues met ESLint 2025-05-17 18:47:20 +00:00
679c95b0a0
fix(backend): Hernoem geteste functie 2025-05-17 20:40:19 +02:00
1f05935418
chore(backend): Optimise imports 2025-05-17 20:31:07 +02:00
Joyelle Ndagijimana
7f1c66c757 fix: kleine fixes 2025-05-17 20:27:28 +02:00
Lint Action
fc92570282 style: fix linting issues met Prettier 2025-05-17 17:59:13 +00:00
2f5bb333db
chore(backend): Verwijder redundante methoden 2025-05-17 19:56:46 +02:00
edc52a559c
Merge branch 'dev' into feat/discussions 2025-05-17 19:52:21 +02:00
bc459114d8
feat(backend): SearchByAdmin gebruikt ingelogde gebruiker 2025-05-17 19:40:28 +02:00
bf8d331253
chore(frontend): QuestionBox component 2025-05-17 19:39:56 +02:00
Joyelle Ndagijimana
0abe9b1bce fix: bug in assignment vertalingen 2025-05-17 19:07:52 +02:00
Joyelle Ndagijimana
f67e3f5a1a feat: deadline is editeerbaar 2025-05-17 18:50:18 +02:00
965ba5dd20
test(frontend): searchLearningPathsByAdmin 2025-05-17 18:44:49 +02:00
1639fbdabf
feat(backend): SearchByAdmins service 2025-05-17 18:18:54 +02:00
0d2b486a2c
chore(frontend): DiscussionsSideBar component 2025-05-17 15:50:47 +02:00
fe397c54e3
chore(frontend): QandA i18n 2025-05-17 15:16:13 +02:00
Joyelle Ndagijimana
912111fce4 fix: students assignments pagina bug gefixd 2025-05-17 13:54:05 +02:00
Adriaan Jacquet
3d8c8302d6 fix: bug in put assignment waarbij groepen niet werden aangepast gefixt 2025-05-17 12:13:37 +02:00
Joyelle Ndagijimana
b7486b4d1b fix: deadline veld 2025-05-17 02:22:11 +02:00
Joyelle Ndagijimana
368130c431 Merge fix/progress-bar into feat/232-assignments-pagina-ui-ux 2025-05-17 01:44:37 +02:00
Joyelle Ndagijimana
43317c5dee Merge dev into feat/progress-bar 2025-05-17 01:27:19 +02:00
Joyelle Ndagijimana
72602289a6 fix: PUT request van groepen verbeteren 2025-05-17 01:20:23 +02:00
Joyelle Ndagijimana
f0b723cc63 feat: kleine verbetering 2025-05-17 01:06:12 +02:00
Joyelle Ndagijimana
92e7bccf5f feat: beschrijving van assignment editen werkt 2025-05-17 00:58:50 +02:00
Joyelle Ndagijimana
862e72ef4a feat: drag en drop werkt op mobiel 2025-05-17 00:04:31 +02:00
Joyelle Ndagijimana
45563b68ea feat: edit groups pop up verbeteren 2025-05-16 22:18:57 +02:00
Joyelle Ndagijimana
a3185ed1c1 feat: drag and drop en random selection voor groepen 2025-05-16 19:45:23 +02:00
Joyelle Ndagijimana
936a34b709 feat: bezig met edit groups 2025-05-16 00:35:30 +02:00
Joyelle Ndagijimana
cc31effd61 feat: assignment description en lp editen is mogelijk 2025-05-16 00:00:39 +02:00
Joyelle Ndagijimana
f83d5b54c0 feat: bericht in assignment pagina van student als er nog geen groepen bestaan 2025-05-15 23:02:07 +02:00
Joyelle Ndagijimana
5805294f4c feat: create assignment process minimaliseren maar in aparte pagina houden 2025-05-15 22:09:13 +02:00
Timo De Meyst
e6706d1750 feat: het wordt duidelijk gemaakt als er geen vragen zijn 2025-05-15 19:27:52 +02:00
Timo De Meyst
233d89b5bf feat: tip wanneer er geen leerobject geselecteerd is in discussies 2025-05-15 19:04:36 +02:00
Timo De Meyst
b193d47d42 fix: linting issues 2025-05-15 18:51:50 +02:00
Timo De Meyst
2d08be070f style: betere dropdown 2025-05-15 17:52:22 +02:00
Timo De Meyst
3de391da9a chore: i18n 2025-05-15 17:47:33 +02:00
Timo De Meyst
860a46712b style: discussions titel zoals de rest 2025-05-15 17:24:02 +02:00
Timo De Meyst
e2abaa2625 feat: algemene discussions pagina terug 2025-05-15 17:23:48 +02:00
Timo De Meyst
f3b4050f0d style: antwoorden zijn duidelijker gescheiden van elkaar 2025-05-15 17:02:20 +02:00
Timo De Meyst
a3893ac1db style: enkel leerpad van huidig leerobject is volledig zichtbaar 2025-05-15 16:59:58 +02:00
Timo De Meyst
0c1b87ea99 fix: workaround voor queries die niet opnieuw ingesteld worden in SingleDiscussion 2025-05-15 16:30:16 +02:00
Timo De Meyst
8c16d57eea fix: watch toevoegen 2025-05-15 13:14:05 +02:00
Timo De Meyst
cfc0fd92d1 debug: print verwijderen 2025-05-15 13:13:38 +02:00
Joyelle Ndagijimana
779ebb28ee Merge branch 'fix/mark-as-completed-bij-leerobjecten-werkt-niet-altijd-#253' into fix/progress-bar
Merge fix/mark-as-completed-bij-leerobjecten-werkt-niet-altijd-#253 into fix/progress-bar
2025-05-14 19:45:24 +02:00
Lint Action
1f503de6e5 style: fix linting issues met Prettier 2025-05-14 06:49:36 +00:00
Gerald Schmittinger
f3ba47eb30 fix(backend): Vooruitgang wordt nu enkel op basis van effectief bereikbare leerobjecten berekend. 2025-05-14 08:45:44 +02:00
Timo De Meyst
c8d1112db2 fix: mikrORM fix voor questions 2025-05-13 10:12:32 +02:00
Timo De Meyst
50cfcb69a6 Merge branch 'fix/testdata-niet-meer-correct-opgezet' into feat/discussions 2025-05-13 09:29:21 +02:00
Joyelle Ndagijimana
fe9ff5ec16 Merge dev into fix/progress-bar 2025-05-13 09:21:34 +02:00
Timo De Meyst
0d2fd8b4ee feat: sidebar elements gaan naar juiste link 2025-05-13 09:20:38 +02:00
Timo De Meyst
84ef0e4af0 feat: elk leerpad is zichtbaar in sidebar 2025-05-13 09:11:29 +02:00
Timo De Meyst
a11440e18f feat: pagina scrollt automatisch zodat antwoorden in zicht komen als ze uitgeklapt worden 2025-05-13 00:54:45 +02:00
Timo De Meyst
e61f47a889 style: single question style update 2025-05-13 00:38:55 +02:00
Timo De Meyst
c7a70f901e style: questions header styling op learningobject 2025-05-13 00:29:03 +02:00
Timo De Meyst
f3690902d1 feat: huidig leerpad wordt in inhoudstafel getoond met zijn leerobjecten waarop je kan klikken 2025-05-09 02:34:23 +02:00
Timo De Meyst
f318bb296b feat: component die in inhoudstafel een leerpad toont met al zijn leerobjecten eronder (werkt nog niet) 2025-05-09 02:33:32 +02:00
Timo De Meyst
d13e3c4df4 cleanup 2025-05-09 00:38:58 +02:00
Timo De Meyst
37947c768f feat: leerpad pagina omgezet naar discussions pagina 2025-05-09 00:37:07 +02:00
Timo De Meyst
ab1c94f012 feat: link naar discussions toegevoegd in leerpad pagina 2025-05-09 00:36:35 +02:00
Timo De Meyst
d03f8431a8 feat: discussions link veranderd 2025-05-09 00:35:49 +02:00
Joyelle Ndagijimana
53097102ba fix: meer vertalingen 2025-05-06 08:54:58 +02:00
Joyelle Ndagijimana
0f1009ba43 fix: linting + small fixes 2025-05-04 16:51:48 +02:00
Joyelle Ndagijimana
5facb54290 feat: leerkracht krijgt link naar indiening 2025-05-04 16:32:12 +02:00
Joyelle Ndagijimana
d7688bc54c feat: leerling kan progress van groep zien 2025-05-04 15:12:09 +02:00
Joyelle Ndagijimana
195e192598 feat: leerkracht kan progress van groepen zien 2025-05-04 13:26:19 +02:00
laurejablonski
20173169b7 feat: edit assignments 2025-05-04 10:25:02 +02:00
Adriaan Jacquet
6c28c0fc3d feat: werkende aan group edit pagina 2025-05-03 11:44:43 +02:00
Adriaan Jacquet
d8a7a86da0 fix: bug in populate groups van assignment 2025-05-02 21:12:34 +02:00
Adriaan Jacquet
509db70c73 feat: bezig met ui/ux assignment, bug in testdata 2025-05-02 20:23:14 +02:00
Adriaan Jacquet
7e4e179121 feat: nieuwe lijstview voor assignment 2025-05-02 14:01:00 +02:00
Adriaan Jacquet
c03669eda7 feat: teacher's assignments full stack geimplementeerd 2025-05-02 13:36:12 +02:00
80 changed files with 3592 additions and 1300 deletions

View file

@ -1,3 +1,5 @@
#syntax=docker/dockerfile:1.7-labs
FROM node:22 AS build-stage
WORKDIR /app/dwengo
@ -17,7 +19,7 @@ RUN npm install --silent
# Root tsconfig.json
COPY tsconfig.json tsconfig.build.json ./
COPY backend ./backend
COPY --exclude=backend/tests/ backend ./backend
COPY common ./common
COPY docs ./docs

View file

@ -1,6 +1,6 @@
{
"name": "@dwengo-1/backend",
"version": "0.2.0",
"version": "1.0.0",
"description": "Backend for Dwengo-1",
"private": true,
"type": "module",

View file

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

View file

@ -1,4 +1,4 @@
import { Request, Response } from 'express';
import { Response } from 'express';
import { themes } from '../data/themes.js';
import { FALLBACK_LANG } from '../config.js';
import learningPathService from '../services/learning-paths/learning-path-service.js';
@ -15,7 +15,7 @@ import { requireFields } from './error-helper.js';
/**
* Fetch learning paths based on query parameters.
*/
export async function getLearningPaths(req: Request, res: Response): Promise<void> {
export async function getLearningPaths(req: AuthenticatedRequest, res: Response): Promise<void> {
const admin = req.query.admin;
if (admin) {
const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string);
@ -59,6 +59,19 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
return;
} else {
hruidList = themes.flatMap((theme) => theme.hruids);
const apiLearningPathResponse = await learningPathService.fetchLearningPaths(hruidList, language as Language, 'All themes', forGroup);
const apiLearningPaths: LearningPath[] = apiLearningPathResponse.data || [];
let allLearningPaths: LearningPath[] = apiLearningPaths;
if (req.auth) {
const adminUsername = req.auth.username;
const userLearningPaths = (await learningPathService.getLearningPathsAdministratedBy(adminUsername)) || [];
allLearningPaths = apiLearningPaths.concat(userLearningPaths);
}
res.json(allLearningPaths);
return;
}
const learningPaths = await learningPathService.fetchLearningPaths(

View file

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

View file

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

View file

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

View file

@ -7,14 +7,20 @@ import { LearningPathTransition } from '../../entities/content/learning-path-tra
export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions', 'admins'] });
return this.findOne(
{
hruid: hruid,
language: language,
},
{ populate: ['nodes', 'nodes.transitions', 'admins'] }
);
}
/**
* Returns all learning paths which have the given language and whose title OR description contains the
* query string.
*
* @param query The query string we want to seach for in the title or description.
* @param query The query string we want to search for in the title or description.
* @param language The language of the learning paths we want to find.
*/
public async findByQueryStringAndLanguage(query: string, language: Language): Promise<LearningPath[]> {

View file

@ -1,12 +1,25 @@
import { EntityRepository, FilterQuery } from '@mikro-orm/core';
import { EntityRepository, FilterQuery, SyntaxErrorException } from '@mikro-orm/core';
import { EntityAlreadyExistsException } from '../exceptions/entity-already-exists-exception.js';
import { getLogger } from '../logging/initalize.js';
export abstract class DwengoEntityRepository<T extends object> extends EntityRepository<T> {
public async save(entity: T, options?: { preventOverwrite?: boolean }): Promise<void> {
if (options?.preventOverwrite && (await this.findOne(entity))) {
throw new EntityAlreadyExistsException(`A ${this.getEntityName()} with this identifier already exists.`);
}
await this.getEntityManager().persistAndFlush(entity);
try {
await this.getEntityManager().persistAndFlush(entity);
} catch (e: unknown) {
// Workaround for MikroORM bug: Sometimes, queries are generated with random syntax errors.
// The faulty query is then retried everytime something is persisted. By clearing the entity
// Manager in that case, we make sure that future queries will work.
if (e instanceof SyntaxErrorException) {
getLogger().error('SyntaxErrorException caught => entity manager cleared.');
this.em.clear();
} else {
throw e;
}
}
}
public async deleteWhere(query: FilterQuery<T>): Promise<void> {
const toDelete = await this.findOne(query);
@ -16,4 +29,13 @@ export abstract class DwengoEntityRepository<T extends object> extends EntityRep
await em.flush();
}
}
public async deleteAllWhere(query: FilterQuery<T>): Promise<void> {
const toDelete = await this.find(query);
const em = this.getEntityManager();
if (toDelete) {
em.remove(toDelete);
await em.flush();
}
}
}

View file

@ -18,13 +18,9 @@ export class QuestionRepository extends DwengoEntityRepository<Question> {
content: question.content,
timestamp: new Date(),
});
questionEntity.learningObjectHruid = question.loId.hruid;
questionEntity.learningObjectLanguage = question.loId.language;
questionEntity.learningObjectVersion = question.loId.version;
questionEntity.author = question.author;
questionEntity.inGroup = question.inGroup;
questionEntity.content = question.content;
return await this.insert(questionEntity);
// Don't check for overwrite since this is impossible anyway due to autoincrement.
await this.save(questionEntity, { preventOverwrite: false });
return questionEntity;
}
public async findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise<Question[]> {
return this.findAll({

View file

@ -6,6 +6,9 @@ import { Group } from '../assignments/group.entity.js';
@Entity({ repository: () => QuestionRepository })
export class Question {
@PrimaryKey({ type: 'integer', autoincrement: true })
sequenceNumber?: number;
@PrimaryKey({ type: 'string' })
learningObjectHruid!: string;
@ -18,9 +21,6 @@ export class Question {
@PrimaryKey({ type: 'number' })
learningObjectVersion = 1;
@PrimaryKey({ type: 'integer', autoincrement: true })
sequenceNumber?: number;
@ManyToOne({ entity: () => Group })
inGroup!: Group;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -62,6 +62,7 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
data: learningPaths,
};
},
async searchLearningPaths(query: string, language: string, personalizedFor: Group): Promise<LearningPath[]> {
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
const params = { all: query, language };
@ -75,7 +76,8 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
},
async getLearningPathsAdministratedBy(_adminUsername: string) {
return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user.
// Dwengo API does not have the concept of admins, so we cannot filter by them.
return [];
},
};

View file

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

View file

@ -1,12 +1,13 @@
import { getAssignmentRepository, getGroupRepository, getSubmissionRepository } from '../data/repositories.js';
import { getSubmissionRepository } from '../data/repositories.js';
import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js';
import { NotFoundException } from '../exceptions/not-found-exception.js';
import { mapToSubmission, mapToSubmissionDTO } from '../interfaces/submission.js';
import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission';
import { fetchStudent } from './students.js';
import { getExistingGroupFromGroupDTO } from './groups.js';
import { fetchGroup, getExistingGroupFromGroupDTO } from './groups.js';
import { Submission } from '../entities/assignments/submission.entity.js';
import { Language } from '@dwengo-1/common/util/language';
import { fetchAssignment } from './assignments.js';
export async function fetchSubmission(loId: LearningObjectIdentifier, submissionNumber: number): Promise<Submission> {
const submissionRepository = getSubmissionRepository();
@ -64,15 +65,18 @@ export async function getSubmissionsForLearningObjectAndAssignment(
groupId?: number
): Promise<SubmissionDTO[]> {
const loId = new LearningObjectIdentifier(learningObjectHruid, language, version);
const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, assignmentId);
let submissions: Submission[];
if (groupId !== undefined) {
const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, groupId);
submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndGroup(loId, group!);
} else {
submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!);
try {
let submissions: Submission[];
if (groupId !== undefined) {
const group = await fetchGroup(classId, assignmentId, groupId);
submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndGroup(loId, group);
} else {
const assignment = await fetchAssignment(classId, assignmentId);
submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndAssignment(loId, assignment);
}
return submissions.map((s) => mapToSubmissionDTO(s));
} catch (_) {
return [];
}
return submissions.map((s) => mapToSubmissionDTO(s));
}

View file

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

View file

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

View file

@ -14,11 +14,14 @@ import {
testLearningObjectEssayQuestion,
testLearningObjectMultipleChoice,
} from '../../test_assets/content/learning-objects.testdata';
import { testLearningPathWithConditions } from '../../test_assets/content/learning-paths.testdata';
import { testLearningPath02, testLearningPathWithConditions } from '../../test_assets/content/learning-paths.testdata';
import { mapToLearningPath } from '../../../src/services/learning-paths/learning-path-service';
import { getTestGroup01, getTestGroup02 } from '../../test_assets/assignments/groups.testdata';
import { Group } from '../../../src/entities/assignments/group.entity.js';
import { Teacher } from '../../../src/entities/users/teacher.entity.js';
import { RequiredEntityData } from '@mikro-orm/core';
import { getFooFighters, getLimpBizkit } from '../../test_assets/users/teachers.testdata';
import { mapToTeacherDTO } from '../../../src/interfaces/teacher';
function expectBranchingObjectNode(result: LearningPathResponse): LearningObjectNode {
const branchingObjectMatches = result.data![0].nodes.filter((it) => it.learningobject_hruid === testLearningObjectMultipleChoice.hruid);
@ -33,6 +36,8 @@ describe('DatabaseLearningPathProvider', () => {
let finalLearningObject: RequiredEntityData<LearningObject>;
let groupA: Group;
let groupB: Group;
let teacherA: Teacher;
let teacherB: Teacher;
beforeAll(async () => {
await setupTestApp();
@ -42,6 +47,8 @@ describe('DatabaseLearningPathProvider', () => {
finalLearningObject = testLearningObjectEssayQuestion;
groupA = getTestGroup01();
groupB = getTestGroup02();
teacherA = getFooFighters();
teacherB = getLimpBizkit();
// Place different submissions for group A and B.
const submissionRepo = getSubmissionRepository();
@ -140,4 +147,18 @@ describe('DatabaseLearningPathProvider', () => {
expect(result.length).toBe(0);
});
});
describe('getLearningPathsAdministratedBy', () => {
it('returns the learning path owned by the admin', async () => {
const expectedLearningPath = mapToLearningPath(testLearningPath02, [mapToTeacherDTO(teacherB)]);
const result = await databaseLearningPathProvider.getLearningPathsAdministratedBy([teacherB], expectedLearningPath.language);
expect(result.length).toBe(1);
expect(result[0].title).toBe(expectedLearningPath.title);
expect(result[0].description).toBe(expectedLearningPath.description);
});
it('returns an empty result when querying admins that do not have custom learning paths', async () => {
const result = await databaseLearningPathProvider.getLearningPathsAdministratedBy([teacherA], testLearningPath.language);
expect(result.length).toBe(0);
});
});
});

View file

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

View file

@ -14,10 +14,12 @@ import {
testLearningObjectMultipleChoice,
testLearningObjectPnNotebooks,
} from './learning-objects.testdata';
import { getLimpBizkit } from '../users/teachers.testdata';
export function makeTestLearningPaths(_em: EntityManager): LearningPath[] {
const learningPath01 = mapToLearningPath(testLearningPath01, []);
const learningPath02 = mapToLearningPath(testLearningPath02, []);
learningPath02.admins = [getLimpBizkit()];
const partiallyDatabasePartiallyDwengoApiLearningPath = mapToLearningPath(testPartiallyDatabaseAndPartiallyDwengoApiLearningPath, []);
const learningPathWithConditions = mapToLearningPath(testLearningPathWithConditions, []);

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@dwengo-1/common",
"version": "0.2.0",
"version": "1.0.0",
"description": "Common types and utilities for Dwengo-1",
"private": true,
"type": "module",

View file

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

View file

@ -0,0 +1,10 @@
export enum MatchMode {
/**
* Match any
*/
ANY = 'ANY',
/**
* Match all
*/
ALL = 'ALL',
}

View file

@ -60,7 +60,7 @@ services:
extends:
file: ./compose.yml
service: idp
command: ['start', '--http-port', '7080', '--https-port', '7443', '--import-realm']
command: ['start', '--http-port', '7080', '--https-port', '7443']
networks:
- dwengo-1
labels:

View file

@ -20,7 +20,7 @@ services:
image: quay.io/keycloak/keycloak:latest
ports:
- '7080:7080'
# - '7443:7443'
# - '7443:7443'
command: ['start-dev', '--http-port', '7080', '--https-port', '7443', '--import-realm']
restart: unless-stopped
volumes:

View file

@ -2,7 +2,7 @@ import swaggerAutogen from 'swagger-autogen';
const doc = {
info: {
version: '0.1.0',
version: '1.0.0',
title: 'Dwengo-1 Backend API',
description: 'Dwengo-1 Backend API using Express, based on VZW Dwengo',
license: {

View file

@ -1,6 +1,6 @@
{
"name": "dwengo-1-docs",
"version": "0.2.0",
"version": "1.0.0",
"description": "Documentation for Dwengo-1",
"private": true,
"scripts": {

View file

@ -1,3 +1,5 @@
#syntax=docker/dockerfile:1.7-labs
FROM node:22 AS build-stage
# install simple http server for serving static content
@ -26,7 +28,7 @@ RUN npm run build --workspace=common
WORKDIR /app/dwengo/frontend
COPY frontend ./
COPY --exclude=frontend/tests/ frontend ./
RUN npx vite build

View file

@ -1,6 +1,6 @@
{
"name": "dwengo-1-frontend",
"version": "0.2.0",
"version": "1.0.0",
"description": "Frontend for Dwengo-1",
"private": true,
"type": "module",
@ -17,7 +17,6 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@dwengo-1/common": "^0.2.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/vue-query": "^5.69.0",
"@vueuse/core": "^13.1.0",

View file

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

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import type { LearningObject } from "@/data-objects/learning-objects/learning-object";
import type { LearningPath } from "@/data-objects/learning-paths/learning-path";
import { useLearningObjectListForPathQuery } from "@/queries/learning-objects";
import { useRoute } from "vue-router";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import QuestionNotification from "@/components/QuestionNotification.vue";
const route = useRoute();
const props = defineProps<{
path: LearningPath;
activeObjectId: string;
}>();
const learningObjects = useLearningObjectListForPathQuery(props.path);
</script>
<template>
<using-query-result
:query-result="learningObjects"
v-slot="learningObjects: { data: LearningObject[] }"
>
<template
v-for="node in learningObjects.data"
:key="node.key"
>
<v-list-item
link
:to="{
path: `/discussion-reload/${props.path.hruid}/${node.language}/${node.key}`,
query: route.query,
}"
:title="node.title"
:active="node.key === props.activeObjectId"
>
<template v-slot:append>
<QuestionNotification :node="node"></QuestionNotification>
</template>
</v-list-item>
</template>
</using-query-result>
</template>
<style scoped></style>

View file

@ -0,0 +1,93 @@
<script setup lang="ts">
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import DiscussionSideBarElement from "@/components/DiscussionSideBarElement.vue";
import { useI18n } from "vue-i18n";
import { useGetAllLearningPaths } from "@/queries/learning-paths.ts";
import { ref, watch } from "vue";
import { useRoute } from "vue-router";
const { t, locale } = useI18n();
const route = useRoute();
const props = defineProps<{
learningObjectHruid: string;
}>();
const navigationDrawerShown = ref(true);
const currentLocale = ref(locale.value);
const expanded = ref([route.params.hruid]);
watch(locale, (newLocale) => {
currentLocale.value = newLocale;
});
const allLearningPathsResult = useGetAllLearningPaths(() => currentLocale.value);
</script>
<template>
<v-navigation-drawer
v-model="navigationDrawerShown"
:width="350"
app
>
<div class="d-flex flex-column h-100">
<v-list-item>
<template v-slot:title>
<div class="title">{{ t("discussions") }}</div>
</template>
</v-list-item>
<v-divider></v-divider>
<div class="nav-scroll-area">
<v-expansion-panels v-model="expanded">
<using-query-result
:query-result="allLearningPathsResult"
v-slot="learningPaths: { data: LearningPath[] }"
>
<v-expansion-panel
v-for="learningPath in learningPaths.data"
:key="learningPath.hruid"
:value="learningPath.hruid"
>
<v-expansion-panel-title>
{{ learningPath.title }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-lazy>
<DiscussionSideBarElement
:path="learningPath"
:activeObjectId="props.learningObjectHruid"
/>
</v-lazy>
</v-expansion-panel-text>
</v-expansion-panel>
</using-query-result>
</v-expansion-panels>
</div>
</div>
</v-navigation-drawer>
<div class="control-bar-above-content">
<v-btn
:icon="navigationDrawerShown ? 'mdi-menu-open' : 'mdi-menu'"
class="navigation-drawer-toggle-button"
variant="plain"
@click="navigationDrawerShown = !navigationDrawerShown"
></v-btn>
</div>
</template>
<style scoped>
.title {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
padding-top: 2%;
font-size: 36px;
}
.nav-scroll-area {
overflow-y: auto;
flex-grow: 1;
min-height: 0;
}
</style>

View file

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

View file

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

View file

@ -0,0 +1,58 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import type { SubmissionsResponse } from "@/controllers/submissions.ts";
import { ref, watch } from "vue";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
const props = defineProps<{
learningPathHruid: string;
language: string;
group: object;
assignmentId: number;
classId: string;
goToGroupSubmissionLink: (groupNo: number) => void;
}>();
const emit = defineEmits<(e: "update:hasSubmission", hasSubmission: boolean) => void>();
const { t } = useI18n();
const hasMadeProgress = ref(false);
const getLearningPathQuery = useGetLearningPathQuery(
() => props.learningPathHruid,
() => props.language,
() => ({
forGroup: props.group.originalGroupNo,
assignmentNo: props.assignmentId,
classId: props.classId,
}),
);
watch(
() => getLearningPathQuery.data.value,
(learningPath) => {
if (learningPath) {
hasMadeProgress.value = learningPath.amountOfNodes !== learningPath.amountOfNodesLeft;
emit("update:hasSubmission", hasMadeProgress.value);
}
},
{ immediate: true },
);
</script>
<template>
<using-query-result
:query-result="getLearningPathQuery"
v-slot="{ data }: { data: SubmissionsResponse }"
>
<v-btn
:color="hasMadeProgress ? 'green' : 'red'"
variant="text"
:to="hasMadeProgress ? goToGroupSubmissionLink(props.group.originalGroupNo) : undefined"
:disabled="!hasMadeProgress"
>
{{ hasMadeProgress ? t("submission") : t("noSubmissionsYet") }}
</v-btn>
</using-query-result>
</template>

View file

@ -87,14 +87,13 @@
>
{{ t("classes") }}
</v-btn>
<!-- TODO Re-enable this button when the discussion page is ready -->
<!-- <v-btn-->
<!-- class="menu_item"-->
<!-- variant="text"-->
<!-- to="/user/discussion"-->
<!-- >-->
<!-- {{ t("discussions") }}-->
<!-- </v-btn>-->
<v-btn
class="menu_item"
variant="text"
to="/discussion"
>
{{ t("discussions") }}
</v-btn>
</v-toolbar-items>
<v-menu
open-on-hover
@ -149,7 +148,8 @@
</template>
<template v-slot:default="{ isActive }">
<v-card :title="t('logoutVerification')">
<v-card>
<v-card-title class="logout-verification-title">{{ t("logoutVerification") }}</v-card-title>
<v-card-actions>
<v-spacer></v-spacer>
@ -230,7 +230,7 @@
</v-list-item>
<v-list-item
to="/user/discussion"
to="/discussion"
link
>
<v-list-item-title class="menu_item">{{ t("discussions") }}</v-list-item-title>
@ -298,6 +298,13 @@
margin-left: 10px;
}
.logout-verification-title {
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
text-overflow: unset;
}
@media (max-width: 700px) {
.menu {
display: none;

View file

@ -1,6 +1,9 @@
<script setup lang="ts">
import type { QuestionDTO } from "@dwengo-1/common/interfaces/question";
import SingleQuestion from "./SingleQuestion.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
defineProps<{
questions: QuestionDTO[];
@ -8,13 +11,28 @@
</script>
<template>
<div class="space-y-4">
<div
v-for="question in questions"
:key="(question.sequenceNumber, question.content)"
class="border rounded-2xl p-4 shadow-sm bg-white"
>
<SingleQuestion :question="question"></SingleQuestion>
<div v-if="questions.length != 0">
<div
v-for="question in questions"
:key="(question.sequenceNumber, question.content)"
>
<SingleQuestion :question="question"></SingleQuestion>
</div>
</div>
<div v-else>
<p class="no-questions">{{ t("no-questions") }}</p>
</div>
</div>
</template>
<style scoped></style>
<style scoped>
.no-questions {
display: flex;
justify-content: center;
align-items: center;
height: 40vh;
text-align: center;
font-size: 18px;
color: #666;
padding: 0 20px;
}
</style>

View file

@ -0,0 +1,89 @@
<script setup lang="ts">
import authService from "@/services/auth/auth-service.ts";
import { computed, type ComputedRef, ref } from "vue";
import type { GroupDTOId } from "@dwengo-1/common/interfaces/group";
import type { QuestionData } from "@dwengo-1/common/interfaces/question";
import type { LearningObjectIdentifierDTO } from "@dwengo-1/interfaces/learning-content";
import { useCreateQuestionMutation } from "@/queries/questions.ts";
import { useI18n } from "vue-i18n";
import { AccountType } from "@dwengo-1/common/util/account-types.ts";
const props = defineProps<{
learningObjectHruid: string;
learningObjectLanguage: string;
learningObjectVersion: number;
forGroup?: GroupDTOId | undefined;
withTitle?: boolean;
}>();
const { t } = useI18n();
const emit = defineEmits(["updated"]);
const questionInput = ref("");
const loID: ComputedRef<LearningObjectIdentifierDTO> = computed(() => ({
hruid: props.learningObjectHruid as string,
language: props.learningObjectLanguage,
version: props.learningObjectVersion,
}));
const createQuestionMutation = useCreateQuestionMutation(loID);
const showQuestionBox = computed(() => authService.authState.activeRole === AccountType.Student && props.forGroup);
function submitQuestion(): void {
if (props.forGroup && questionInput.value !== "") {
const questionData: QuestionData = {
author: authService.authState.user?.profile.preferred_username,
content: questionInput.value,
inGroup: props.forGroup,
};
createQuestionMutation.mutate(questionData, {
onSuccess: async () => {
questionInput.value = ""; // Clear the input field after submission
emit("updated");
},
onError: (_) => {
// TODO Handle error
// - console.error(e);
},
});
}
}
</script>
<template>
<h3 v-if="props.withTitle && showQuestionBox">{{ t("askAQuestion") }}:</h3>
<div
class="question-box"
v-if="showQuestionBox"
>
<v-textarea
:label="t('question-input-placeholder')"
v-model="questionInput"
class="question-field"
density="compact"
rows="1"
variant="outlined"
auto-grow
>
<template v-slot:append-inner>
<v-btn
icon="mdi mdi-send"
size="small"
variant="plain"
class="question-button"
@click="submitQuestion"
/>
</template>
</v-textarea>
</div>
</template>
<style scoped>
.question-box {
width: 100%;
max-width: 400px;
margin: 20px auto;
}
</style>

View file

@ -5,17 +5,34 @@
import UsingQueryResult from "./UsingQueryResult.vue";
import type { AnswersResponse } from "@/controllers/answers";
import type { AnswerData, AnswerDTO } from "@dwengo-1/common/interfaces/answer";
import type { UserDTO } from "@dwengo-1/common/interfaces/user";
import authService from "@/services/auth/auth-service";
import { useI18n } from "vue-i18n";
import { AccountType } from "@dwengo-1/common/util/account-types";
const { t } = useI18n();
const props = defineProps<{
question: QuestionDTO;
}>();
const expanded = ref(false);
const answersContainer = ref<HTMLElement | null>(null); // Ref for the answers container
function toggle(): void {
expanded.value = !expanded.value;
// Scroll to the answers container if expanded
if (expanded.value && answersContainer.value) {
setTimeout(() => {
if (answersContainer.value) {
answersContainer.value.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}, 100);
}
}
function formatDate(timestamp: string | Date): string {
@ -49,141 +66,121 @@
createAnswerMutation.mutate(answerData, {
onSuccess: async () => {
answer.value = "";
expanded.value = true;
await answersQuery.refetch();
},
});
}
}
function displayNameFor(user: UserDTO) {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`;
} else {
return user.username;
}
}
</script>
<template>
<div class="space-y-4">
<div
class="flex justify-between items-center mb-2"
style="
margin-right: 5px;
margin-left: 5px;
font-weight: bold;
display: flex;
flex-direction: row;
justify-content: space-between;
"
>
<span class="font-semibold text-lg text-gray-800">{{
question.author.firstName + " " + question.author.lastName
}}</span>
<span class="text-sm text-gray-500">{{ formatDate(question.timestamp) }}</span>
</div>
<div
class="text-gray-700 mb-3"
style="margin-left: 10px"
>
{{ question.content }}
</div>
<div
v-if="authService.authState.activeRole === AccountType.Teacher"
class="answer-input-container"
>
<input
v-model="answer"
type="text"
placeholder="answer: ..."
class="answer-input"
/>
<button
@click="submitAnswer"
class="submit-button"
<v-card class="question-card">
<v-card-title class="author-title">{{ displayNameFor(question.author) }}</v-card-title>
<v-card-subtitle>{{ formatDate(question.timestamp) }}</v-card-subtitle>
<v-card-text>
{{ question.content }}
</v-card-text>
<template
v-slot:actions
v-if="
authService.authState.activeRole === AccountType.Teacher ||
answersQuery.data?.value?.answers?.length > 0
"
>
</button>
</div>
<using-query-result
:query-result="answersQuery"
v-slot="answersResponse: { data: AnswersResponse }"
>
<button
v-if="answersResponse.data.answers && answersResponse.data.answers.length > 0"
@click="toggle()"
class="text-blue-600 hover:underline text-sm"
>
{{ expanded ? "Hide Answers" : "Show Answers" }}
</button>
<div
v-if="expanded"
class="mt-3 pl-4 border-l-2 border-blue-200 space-y-2"
>
<div
v-for="(answer, answerIndex) in answersResponse.data.answers as AnswerDTO[]"
:key="answerIndex"
class="text-gray-600"
>
<div
class="flex justify-between items-center mb-2"
style="
margin-right: 5px;
margin-left: 5px;
font-weight: bold;
display: flex;
flex-direction: row;
justify-content: space-between;
"
<div class="question-actions-container">
<v-textarea
v-if="authService.authState.activeRole === AccountType.Teacher"
:label="t('answer-input-placeholder')"
v-model="answer"
class="answer-field"
density="compact"
rows="1"
variant="outlined"
auto-grow
>
<span class="font-semibold text-lg text-gray-800">{{ answer.author.username }}</span>
<span class="text-sm text-gray-500">{{ formatDate(answer.timestamp) }}</span>
</div>
<div
class="text-gray-700 mb-3"
style="margin-left: 10px"
<template v-slot:append-inner>
<v-btn
icon="mdi mdi-send"
size="small"
variant="plain"
class="answer-button"
@click="submitAnswer"
/>
</template>
</v-textarea>
<using-query-result
:query-result="answersQuery"
v-slot="answersResponse: { data: AnswersResponse }"
>
{{ answer.content }}
</div>
<v-btn
v-if="answersResponse.data.answers && answersResponse.data.answers.length > 0"
@click="toggle()"
>
{{ expanded ? t("answers-toggle-hide") : t("answers-toggle-show") }}
</v-btn>
<div
v-show="expanded"
ref="answersContainer"
class="mt-3 pl-4 border-l-2 border-blue-200 space-y-2"
>
<v-card
v-for="(answer, answerIndex) in answersResponse.data.answers as AnswerDTO[]"
:key="answerIndex"
class="answer-card"
>
<v-card-title class="author-title">{{ displayNameFor(answer.author) }}</v-card-title>
<v-card-subtitle>{{ formatDate(answer.timestamp) }}</v-card-subtitle>
<v-card-text>
{{ answer.content }}
</v-card-text>
</v-card>
</div>
</using-query-result>
</div>
</div>
</using-query-result>
</template>
</v-card>
</div>
</template>
<style scoped>
.answer-input {
flex-grow: 1;
outline: none;
border: none;
background: transparent;
color: #374151; /* gray-700 */
font-size: 0.875rem; /* smaller font size */
.answer-field {
max-width: 500px;
}
.answer-input::placeholder {
color: #9ca3af; /* gray-400 */
.answer-button {
margin: auto;
}
.submit-button {
margin-left: 0.25rem;
padding: 0.25rem;
background-color: #f3f4f6; /* gray-100 */
border-radius: 9999px;
transition: background-color 0.2s;
border: none;
cursor: pointer;
}
.submit-button:hover {
background-color: #e5e7eb; /* gray-200 */
}
.submit-icon {
width: 0.75rem;
height: 0.75rem;
color: #4b5563; /* gray-600 */
}
.answer-input-container {
display: flex;
align-items: center;
border: 1px solid #d1d5db; /* gray-300 */
border-radius: 9999px;
padding: 0.5rem 1rem;
max-width: 28rem;
margin: 5px;
}
.question-card {
margin: 10px;
}
.question-actions-container {
width: 100%;
margin-left: 10px;
margin-right: 10px;
}
.answer-card {
margin-top: 10px;
margin-bottom: 10px;
}
.author-title {
font-size: 14pt;
margin-bottom: -10px;
}
</style>

View file

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

View file

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

View file

@ -25,7 +25,7 @@ export class AssignmentController extends BaseController {
return this.get<AssignmentResponse>(`/${num}`);
}
async createAssignment(data: AssignmentDTO): Promise<AssignmentResponse> {
async createAssignment(data: Partial<AssignmentDTO>): Promise<AssignmentResponse> {
return this.post<AssignmentResponse>(`/`, data);
}
@ -48,4 +48,12 @@ export class AssignmentController extends BaseController {
async getGroups(assignmentNumber: number, full = true): Promise<GroupsResponse> {
return this.get<GroupsResponse>(`/${assignmentNumber}/groups`, { full });
}
async getSubmissionsByGroup(
assignmentNumber: number,
groupNumber: number,
full = true,
): Promise<SubmissionsResponse> {
return this.get<SubmissionsResponse>(`/${assignmentNumber}/groups/${groupNumber}/submissions`, { full });
}
}

View file

@ -14,10 +14,19 @@ export class QuestionController extends BaseController {
loId: LearningObjectIdentifierDTO;
constructor(loId: LearningObjectIdentifierDTO) {
super(`learningObject/${loId.hruid}/:${loId.version}/questions`);
super(`learningObject/${loId.hruid}/${loId.version}/questions`);
this.loId = loId;
}
async getAllGroup(
classId: string,
assignmentId: string,
forStudent: string,
full = true,
): Promise<QuestionsResponse> {
return this.get<QuestionsResponse>("/", { lang: this.loId.language, full, classId, assignmentId, forStudent });
}
async getAll(full = true): Promise<QuestionsResponse> {
return this.get<QuestionsResponse>("/", { lang: this.loId.language, full });
}

View file

@ -23,7 +23,14 @@ export class SubmissionController extends BaseController {
groupId?: number,
full = true,
): Promise<SubmissionsResponse> {
return this.get<SubmissionsResponse>(`/`, { language, version, classId, assignmentId, groupId, full });
return this.get<SubmissionsResponse>(`/`, {
language,
version,
classId,
assignmentId,
forGroup: groupId,
full,
});
}
async getByNumber(
@ -39,7 +46,7 @@ export class SubmissionController extends BaseController {
version,
classId,
assignmentId,
groupId,
forGroup: groupId,
});
}

View file

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

View file

@ -105,7 +105,6 @@
"assignLearningPath": "Als Aufgabe geben",
"group": "Gruppe",
"description": "Beschreibung",
"no-submission": "keine vorlage",
"submission": "Einreichung",
"progress": "Fortschritte",
"remove": "entfernen",
@ -166,5 +165,31 @@
"pathContainsNonExistingLearningObjects": "Mindestens eines der in diesem Pfad referenzierten Lernobjekte existiert nicht.",
"targetAgesMandatory": "Zielalter müssen angegeben werden.",
"hintRemoveIfUnconditionalTransition": "(entfernen, wenn dies ein bedingungsloser Übergang sein soll)",
"hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt"
"hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt",
"title-required": "Titel darf nicht leer sein.",
"class-required": "Du musst eine Klasse auswählen.",
"deadline-invalid": "Ungültiges Datum oder Uhrzeit.",
"deadline-past": "Die Frist muss in der Zukunft liegen.",
"lp-required": "Du musst einen Lernpfad auswählen.",
"lp-invalid": "Der ausgewählte Lernpfad existiert nicht.",
"currently-no-groups": "Es gibt keine Gruppen für diese Aufgabe.",
"random-grouping": "Gruppen zufällig erstellen",
"drag-and-drop": "Gruppen manuell erstellen",
"generate-groups": "erzeugen",
"auto-generate-groups": "Gruppen gleicher Größe erstellen",
"preview": "Vorschau",
"current-groups": "Aktuelle Gruppen",
"group-size-label": "Gruppengröße",
"save": "Speichern",
"unassigned": "Nicht zugewiesen",
"questions": "Fragen",
"view-questions": "Fragen anzeigen auf ",
"question-input-placeholder": "Ihre Frage...",
"answer-input-placeholder": "Ihre Antwort...",
"answers-toggle-hide": "Antworten verstecken",
"answers-toggle-show": "Antworten anzeigen",
"no-questions": "Keine Fragen",
"no-discussion-tip": "Wählen Sie ein Lernobjekt aus, um dessen Fragen anzuzeigen",
"askAQuestion": "Eine Frage stellen",
"questionsCapitalized": "Fragen"
}

View file

@ -4,7 +4,7 @@
"teacher": "teacher",
"assignments": "Assignments",
"classes": "Classes",
"discussions": "discussions",
"discussions": "Discussions",
"logout": "log out",
"error_title": "Error",
"previous": "Previous",
@ -104,7 +104,6 @@
"assignLearningPath": "assign",
"group": "Group",
"description": "Description",
"no-submission": "no submission",
"submission": "Submission",
"progress": "Progress",
"created": "created",
@ -122,6 +121,7 @@
"invite": "invite",
"assignmentIndicator": "ASSIGNMENT",
"searchAllLearningPathsTitle": "Search all learning paths",
"not-in-group-message": "You are not part of a group yet",
"searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths.",
"no-students-found": "This class has no students.",
"no-invitations-found": "You have no pending invitations.",
@ -166,5 +166,31 @@
"pathContainsNonExistingLearningObjects": "At least one of the learning objects referenced in this path does not exist.",
"targetAgesMandatory": "Target ages must be specified.",
"hintRemoveIfUnconditionalTransition": "(remove this if this should be an unconditional transition)",
"hintKeywordsSeparatedBySpaces": "Keywords separated by spaces"
"hintKeywordsSeparatedBySpaces": "Keywords separated by spaces",
"title-required": "Title cannot be empty.",
"class-required": "You must select a class.",
"deadline-invalid": "Invalid date or time.",
"deadline-past": "The deadline must be in the future.",
"lp-required": "You must select a learning path.",
"lp-invalid": "The selected learning path doesn't exist.",
"currently-no-groups": "There are no groups for this assignment.",
"random-grouping": "Randomly create groups",
"drag-and-drop": "Manually create groups",
"generate-groups": "generate",
"auto-generate-groups": "Create groups of equal size",
"preview": "Preview",
"current-groups": "Current groups",
"group-size-label": "Group size",
"save": "Save",
"unassigned": "Unassigned",
"questions": "questions",
"view-questions": "View questions in ",
"question-input-placeholder": "Your question...",
"answer-input-placeholder": "Your answer...",
"answers-toggle-hide": "Hide answers",
"answers-toggle-show": "Show answers",
"no-questions": "No questions asked yet",
"no-discussion-tip": "Choose a learning object to view its questions",
"askAQuestion": "Ask a question",
"questionsCapitalized": "Questions"
}

View file

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

View file

@ -4,7 +4,7 @@
"teacher": "leerkracht",
"assignments": "Opdrachten",
"classes": "Klassen",
"discussions": "discussies",
"discussions": "Discussies",
"logout": "log uit",
"error_title": "Fout",
"previous": "Vorige",
@ -105,7 +105,6 @@
"assignLearningPath": "Als opdracht geven",
"group": "Groep",
"description": "Beschrijving",
"no-submission": "geen indiening",
"submission": "Indiening",
"progress": "Vooruitgang",
"remove": "verwijder",
@ -166,5 +165,31 @@
"pathContainsNonExistingLearningObjects": "Ten minste één van de leerobjecten in dit pad bestaat niet.",
"targetAgesMandatory": "Doelleeftijden moeten worden opgegeven.",
"hintRemoveIfUnconditionalTransition": "(verwijder dit voor onvoorwaardelijke overgangen)",
"hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties"
"hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties",
"title-required": "Titel mag niet leeg zijn.",
"class-required": "Je moet een klas selecteren.",
"deadline-invalid": "Ongeldige datum of tijd.",
"deadline-past": "De deadline moet in de toekomst liggen.",
"lp-required": "Je moet een leerpad selecteren.",
"lp-invalid": "Het geselecteerde leerpad bestaat niet.",
"currently-no-groups": "Er zijn geen groepen voor deze opdracht.",
"random-grouping": "Groepeer willekeurig",
"drag-and-drop": "Stel groepen handmatig samen",
"generate-groups": "genereren",
"auto-generate-groups": "Maak groepen van gelijke grootte",
"preview": "Voorbeeld",
"current-groups": "Huidige groepen",
"group-size-label": "Grootte van groepen",
"save": "Opslaan",
"unassigned": "Niet toegewezen",
"questions": "vragen",
"view-questions": "Bekijk vragen in ",
"question-input-placeholder": "Uw vraag...",
"answer-input-placeholder": "Uw antwoord...",
"answers-toggle-hide": "Verberg antwoorden",
"answers-toggle-show": "Toon antwoorden",
"no-questions": "Nog geen vragen gesteld",
"no-discussion-tip": "Kies een leerobject om zijn vragen te bekijken",
"askAQuestion": "Stel een vraag",
"questionsCapitalized": "Vragen"
}

View file

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

View file

@ -9,6 +9,7 @@ import {
useQueryClient,
type UseQueryReturnType,
} from "@tanstack/vue-query";
import type { Language } from "@dwengo-1/common/util/language";
export function questionsQueryKey(
loId: LearningObjectIdentifierDTO,
@ -17,6 +18,16 @@ export function questionsQueryKey(
return ["questions", loId.hruid, loId.version!, loId.language, full];
}
export function questionsGroupQueryKey(
loId: LearningObjectIdentifierDTO,
classId: string,
assignmentId: string,
student: string,
full: boolean,
): [string, string, number, Language, boolean, string, string, string] {
return ["questions", loId.hruid, loId.version!, loId.language, full, classId, assignmentId, student];
}
export function questionQueryKey(questionId: QuestionId): [string, string, number, string, number] {
const loId = questionId.learningObjectIdentifier;
return ["question", loId.hruid, loId.version!, loId.language, questionId.sequenceNumber];
@ -33,6 +44,34 @@ export function useQuestionsQuery(
});
}
export function useQuestionsGroupQuery(
loId: MaybeRefOrGetter<LearningObjectIdentifierDTO>,
classId: MaybeRefOrGetter<string>,
assignmentId: MaybeRefOrGetter<string>,
student: MaybeRefOrGetter<string>,
full: MaybeRefOrGetter<boolean> = true,
): UseQueryReturnType<QuestionsResponse, Error> {
return useQuery({
queryKey: computed(() =>
questionsGroupQueryKey(
toValue(loId),
toValue(classId),
toValue(assignmentId),
toValue(student),
toValue(full),
),
),
queryFn: async () =>
new QuestionController(toValue(loId)).getAllGroup(
toValue(classId),
toValue(assignmentId),
toValue(student),
toValue(full),
),
enabled: () => Boolean(toValue(loId)),
});
}
export function useQuestionQuery(
questionId: MaybeRefOrGetter<QuestionId>,
): UseQueryReturnType<QuestionResponse, Error> {

View file

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

View file

@ -14,6 +14,8 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue";
import SingleTheme from "@/views/SingleTheme.vue";
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
import authService from "@/services/auth/auth-service";
import DiscussionForward from "@/views/discussions/DiscussionForward.vue";
import NoDiscussion from "@/views/discussions/NoDiscussion.vue";
import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue";
import { allowRedirect, Redirect } from "@/utils/redirect.ts";
@ -57,12 +59,6 @@ const router = createRouter({
name: "UserClasses",
component: UserClasses,
},
// TODO Re-enable this route when the discussion page is ready
// {
// Path: "discussion",
// Name: "UserDiscussions",
// Component: UserDiscussions,
// },
],
},
@ -102,9 +98,23 @@ const router = createRouter({
meta: { requiresAuth: true },
},
{
path: "/discussion/:id",
path: "/discussion",
name: "Discussions",
component: NoDiscussion,
meta: { requiresAuth: true },
},
{
path: "/discussion/:hruid/:language/:learningObjectHruid",
name: "SingleDiscussion",
component: SingleDiscussion,
props: true,
meta: { requiresAuth: true },
},
{
path: "/discussion-reload/:hruid/:language/:learningObjectHruid",
name: "DiscussionForwardWorkaround",
component: DiscussionForward,
props: true,
meta: { requiresAuth: true },
},
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Language } from "@/data-objects/language";
import { onMounted } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const props = defineProps<{
hruid: string;
language: Language;
learningObjectHruid?: string;
}>();
const discussionURL = "/discussion" + "/" + props.hruid + "/" + props.language + "/" + props.learningObjectHruid;
onMounted(async () => {
await router.replace(discussionURL);
});
</script>
<template>
<main></main>
</template>
<style scoped></style>

View file

@ -0,0 +1,105 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import DiscussionsSideBar from "@/components/DiscussionsSideBar.vue";
const { t } = useI18n();
</script>
<template>
<DiscussionsSideBar></DiscussionsSideBar>
<div>
<p class="no-discussion-tip">{{ t("no-discussion-tip") }}</p>
</div>
</template>
<style scoped>
.no-discussion-tip {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
text-align: center;
font-size: 18px;
color: #666;
padding: 0 20px;
}
.learning-path-title {
white-space: normal;
}
.search-field-container {
min-width: 250px;
}
.control-bar-above-content {
margin-left: 5px;
margin-right: 5px;
margin-bottom: -30px;
display: flex;
justify-content: space-between;
}
.learning-object-view-container {
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
}
.navigation-buttons-container {
padding: 20px;
display: flex;
justify-content: space-between;
}
.assignment-indicator {
position: absolute;
bottom: 10px;
left: 10px;
padding: 4px 12px;
border: 2px solid #f8bcbc;
border-radius: 20px;
color: #f36c6c;
background-color: rgba(248, 188, 188, 0.1);
font-weight: bold;
font-family: Arial, sans-serif;
font-size: 14px;
text-transform: uppercase;
z-index: 2; /* Less than modals/popups */
}
.question-box {
width: 100%;
max-width: 400px;
margin: 20px auto;
font-family: sans-serif;
}
.input-wrapper {
display: flex;
align-items: center;
border: 1px solid #ccc;
border-radius: 999px;
padding: 8px 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.question-input {
flex: 1;
border: none;
outline: none;
font-size: 14px;
background-color: transparent;
}
.question-input::placeholder {
color: #999;
}
.discussion-link {
margin-top: 8px;
font-size: 13px;
color: #444;
}
.discussion-link a {
color: #3b82f6; /* blue */
text-decoration: none;
}
.discussion-link a:hover {
text-decoration: underline;
}
</style>

View file

@ -1,7 +1,195 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { Language } from "@/data-objects/language.ts";
import { computed, type ComputedRef, watch } from "vue";
import { useRoute } from "vue-router";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts";
import { useQuestionsQuery } from "@/queries/questions";
import type { QuestionsResponse } from "@/controllers/questions";
import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content";
import QandA from "@/components/QandA.vue";
import type { QuestionDTO } from "@dwengo-1/common/interfaces/question";
import DiscussionsSideBar from "@/components/DiscussionsSideBar.vue";
import QuestionBox from "@/components/QuestionBox.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const route = useRoute();
const props = defineProps<{
hruid: string;
language: Language;
learningObjectHruid?: string;
}>();
interface LearningPathPageQuery {
forGroup?: string;
assignmentNo?: string;
classId?: string;
}
const query = computed(() => route.query as LearningPathPageQuery);
const forGroup = computed(() => {
if (query.value.forGroup && query.value.assignmentNo && query.value.classId) {
return {
forGroup: parseInt(query.value.forGroup),
assignmentNo: parseInt(query.value.assignmentNo),
classId: query.value.classId,
};
}
return undefined;
});
const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, forGroup);
const nodesList: ComputedRef<LearningPathNode[] | null> = computed(
() => learningPathQueryResult.data.value?.nodesAsList ?? null,
);
const currentNode = computed(() => {
const currentHruid = props.learningObjectHruid;
return nodesList.value?.find((it) => it.learningobjectHruid === currentHruid);
});
const getQuestionsQuery = useQuestionsQuery(
computed(
() =>
({
language: currentNode.value?.language,
hruid: currentNode.value?.learningobjectHruid,
version: currentNode.value?.version,
}) as LearningObjectIdentifierDTO,
),
);
watch(
() => [route.params.hruid, route.params.language, route.params.learningObjectHruid],
() => {
//TODO: moet op een of andere manier createQuestionMutation opnieuw kunnen instellen
// Momenteel opgelost door de DiscussionsForward page workaround
},
);
</script>
<template>
<main></main>
<DiscussionsSideBar :learningObjectHruid="props.learningObjectHruid"> </DiscussionsSideBar>
<div class="discussions-container">
<QuestionBox
:hruid="props.hruid"
:language="props.language"
:learningObjectHruid="props.learningObjectHruid"
:forGroup="forGroup"
withTitle
/>
<h3>{{ t("questionsCapitalized") }}:</h3>
<using-query-result
:query-result="getQuestionsQuery"
v-slot="questionsResponse: { data: QuestionsResponse }"
>
<QandA :questions="(questionsResponse.data.questions as QuestionDTO[]) ?? []" />
</using-query-result>
</div>
</template>
<style scoped></style>
<style scoped>
.discussions-container {
margin: 20px;
}
.learning-path-title {
white-space: normal;
}
.search-field-container {
min-width: 250px;
}
.control-bar-above-content {
margin-left: 5px;
margin-right: 5px;
margin-bottom: -30px;
display: flex;
justify-content: space-between;
}
.learning-object-view-container {
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
}
.navigation-buttons-container {
padding: 20px;
display: flex;
justify-content: space-between;
}
.assignment-indicator {
position: absolute;
bottom: 10px;
left: 10px;
padding: 4px 12px;
border: 2px solid #f8bcbc;
border-radius: 20px;
color: #f36c6c;
background-color: rgba(248, 188, 188, 0.1);
font-weight: bold;
font-family: Arial, sans-serif;
font-size: 14px;
text-transform: uppercase;
z-index: 2; /* Less than modals/popups */
}
.question-box {
width: 100%;
max-width: 400px;
margin: 20px auto;
font-family: sans-serif;
}
.input-wrapper {
display: flex;
align-items: center;
border: 1px solid #ccc;
border-radius: 999px;
padding: 8px 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.question-input {
flex: 1;
border: none;
outline: none;
font-size: 14px;
background-color: transparent;
}
.question-input::placeholder {
color: #999;
}
.send-button {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #555;
transition: color 0.2s ease;
}
.send-button:hover {
color: #000;
}
.discussion-link a {
color: #3b82f6; /* blue */
text-decoration: none;
}
.discussion-link a:hover {
text-decoration: underline;
}
</style>

View file

@ -16,18 +16,14 @@
const groupsQuery = useGroupsQuery(props.classId, props.assignmentNumber, true);
interface GroupSelectorOption {
groupNumber: number | undefined;
label: string;
function sortedGroups(groups: GroupDTO[]): GroupDTO[] {
return [...groups].sort((a, b) => a.groupNumber - b.groupNumber);
}
function groupOptions(groups: GroupDTO[]): GroupSelectorOption[] {
return [...groups]
.sort((a, b) => a.groupNumber - b.groupNumber)
.map((group, index) => ({
groupNumber: group.groupNumber,
label: `${index + 1}`,
}));
function groupOptions(groups: GroupDTO[]): number[] {
return sortedGroups(groups).map((group) => group.groupNumber);
}
function labelForGroup(groups: GroupDTO[], groupId: number): string {
return `${sortedGroups(groups).findIndex((group) => group.groupNumber === groupId) + 1}`;
}
</script>
@ -40,7 +36,8 @@
:label="t('viewAsGroup')"
:items="groupOptions(data.groups)"
v-model="model"
item-title="label"
:item-title="(item) => labelForGroup(data.groups, parseInt(`${item}`))"
:item-value="(item) => item"
class="group-selector-cb"
variant="outlined"
clearable

View file

@ -13,15 +13,13 @@
import authService from "@/services/auth/auth-service.ts";
import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts";
import LearningPathGroupSelector from "@/views/learning-paths/LearningPathGroupSelector.vue";
import { useCreateQuestionMutation, useQuestionsQuery } from "@/queries/questions";
import { useQuestionsGroupQuery, useQuestionsQuery } from "@/queries/questions";
import type { QuestionsResponse } from "@/controllers/questions";
import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content";
import QandA from "@/components/QandA.vue";
import type { QuestionData, QuestionDTO } from "@dwengo-1/common/interfaces/question";
import { useStudentAssignmentsQuery, useStudentGroupsQuery } from "@/queries/students";
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import type { QuestionDTO } from "@dwengo-1/common/interfaces/question";
import QuestionNotification from "@/components/QuestionNotification.vue";
import QuestionBox from "@/components/QuestionBox.vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
const router = useRouter();
@ -58,7 +56,12 @@
const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data);
const nodesList: ComputedRef<LearningPathNode[] | null> = computed(
() => learningPathQueryResult.data.value?.nodesAsList ?? null,
() =>
learningPathQueryResult.data.value?.nodesAsList.filter(
(node) =>
authService.authState.activeRole === AccountType.Teacher ||
!getLearningObjectForNode(node)?.teacherExclusive,
) ?? null,
);
const currentNode = computed(() => {
@ -78,19 +81,44 @@
return currentIndex < nodesList.value?.length ? nodesList.value?.[currentIndex - 1] : undefined;
});
const getQuestionsQuery = useQuestionsQuery(
computed(
() =>
({
language: currentNode.value?.language,
hruid: currentNode.value?.learningobjectHruid,
version: currentNode.value?.version,
}) as LearningObjectIdentifierDTO,
),
);
let getQuestionsQuery;
if (authService.authState.activeRole === AccountType.Student) {
getQuestionsQuery = useQuestionsGroupQuery(
computed(
() =>
({
language: currentNode.value?.language,
hruid: currentNode.value?.learningobjectHruid,
version: currentNode.value?.version,
}) as LearningObjectIdentifierDTO,
),
computed(() => query.value.classId ?? ""),
computed(() => query.value.assignmentNo ?? ""),
computed(() => authService.authState.user?.profile.preferred_username ?? ""),
);
} else {
getQuestionsQuery = useQuestionsQuery(
computed(
() =>
({
language: currentNode.value?.language,
hruid: currentNode.value?.learningobjectHruid,
version: currentNode.value?.version,
}) as LearningObjectIdentifierDTO,
),
);
}
const navigationDrawerShown = ref(true);
function getLearningObjectForNode(node: LearningPathNode): LearningObject | undefined {
return learningObjectListQueryResult.data.value?.find(
(obj) =>
obj.key === node.learningobjectHruid && obj.language === node.language && obj.version === node.version,
);
}
function isLearningObjectCompleted(learningObject: LearningObject): boolean {
if (learningObjectListQueryResult.isSuccess) {
return (
@ -147,49 +175,29 @@
});
}
const studentAssignmentsQueryResult = useStudentAssignmentsQuery(
authService.authState.user?.profile.preferred_username,
const discussionLink = computed(
() =>
"/discussion" +
"/" +
props.hruid +
"/" +
currentNode.value?.language +
"/" +
currentNode.value?.learningobjectHruid,
);
const pathIsAssignment = computed(() => {
const assignments = (studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[]) || [];
return assignments.some(
(assignment) => assignment.learningPath === props.hruid && assignment.language === props.language,
/**
* Filter the given list of questions such that only the questions for the assignment and group specified
* in the query parameters are shown. This is relevant for teachers since they can view questions of all groups.
*/
function filterQuestions(questions?: QuestionDTO[]): QuestionDTO[] {
return (
questions?.filter(
(q) =>
q.inGroup.groupNumber === forGroup.value?.forGroup &&
q.inGroup.assignment === forGroup.value?.assignmentNo,
) ?? []
);
});
const loID: LearningObjectIdentifierDTO = {
hruid: props.learningObjectHruid as string,
language: props.language,
};
const createQuestionMutation = useCreateQuestionMutation(loID);
const groupsQueryResult = useStudentGroupsQuery(authService.authState.user?.profile.preferred_username);
const questionInput = ref("");
function submitQuestion(): void {
const assignments = studentAssignmentsQueryResult.data.value?.assignments as AssignmentDTO[];
const assignment = assignments.find(
(assignment) => assignment.learningPath === props.hruid && assignment.language === props.language,
);
const groups = groupsQueryResult.data.value?.groups as GroupDTO[];
const group = groups?.find((group) => group.assignment === assignment?.id) as GroupDTO;
const questionData: QuestionData = {
author: authService.authState.user?.profile.preferred_username,
content: questionInput.value,
inGroup: group, //TODO: POST response zegt dat dit null is???
};
if (questionInput.value !== "") {
createQuestionMutation.mutate(questionData, {
onSuccess: async () => {
questionInput.value = ""; // Clear the input field after submission
await getQuestionsQuery.refetch(); // Reload the questions
},
onError: (_) => {
// TODO Handle error
// - console.error(e);
},
});
}
}
</script>
@ -236,7 +244,7 @@
</p>
</template>
</v-list-item>
<v-list-itemF
<v-list-item
v-if="
query.classId && query.assignmentNo && authService.authState.activeRole === AccountType.Teacher
"
@ -248,9 +256,9 @@
v-model="forGroupQueryParam"
/>
</template>
</v-list-itemF>
</v-list-item>
<v-divider></v-divider>
<div>
<div class="nav-scroll-area">
<using-query-result
:query-result="learningObjectListQueryResult"
v-slot="learningObjects: { data: LearningObject[] }"
@ -301,7 +309,7 @@
</v-list-item>
<v-list-item>
<div
v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment"
v-if="authService.authState.activeRole === AccountType.Student && forGroup"
class="assignment-indicator"
>
{{ t("assignmentIndicator") }}
@ -329,25 +337,6 @@
v-if="currentNode"
></learning-object-view>
</div>
<div
v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment"
class="question-box"
>
<div class="input-wrapper">
<input
type="text"
placeholder="question : ..."
class="question-input"
v-model="questionInput"
/>
<button
@click="submitQuestion"
class="send-button"
>
</button>
</div>
</div>
<div class="navigation-buttons-container">
<v-btn
prepend-icon="mdi-chevron-left"
@ -367,15 +356,47 @@
</v-btn>
</div>
<using-query-result
v-if="currentNode && forGroup"
:query-result="getQuestionsQuery"
v-slot="questionsResponse: { data: QuestionsResponse }"
>
<QandA :questions="(questionsResponse.data.questions as QuestionDTO[]) ?? []" />
<v-divider :thickness="6"></v-divider>
<div class="question-header">
<span class="question-title">{{ t("questions") }}</span>
<span class="discussion-link-text">
{{ t("view-questions") }}
<router-link :to="discussionLink">
{{ t("discussions") }}
</router-link>
</span>
</div>
<QuestionBox
:learningObjectHruid="currentNode.learningobjectHruid"
:learningObjectLanguage="currentNode.language"
:learningObjectVersion="currentNode.version"
:forGroup="{
assignment: forGroup.assignmentNo,
class: forGroup.classId,
groupNumber: forGroup.forGroup,
}"
/>
<QandA :questions="filterQuestions(questionsResponse.data.questions as QuestionDTO[])" />
</using-query-result>
</using-query-result>
</template>
<style scoped>
.question-title {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
font-size: 24px;
}
.question-header {
display: flex;
justify-content: space-between;
padding: 10px;
}
.learning-path-title {
white-space: normal;
}
@ -414,45 +435,6 @@
text-transform: uppercase;
z-index: 2; /* Less than modals/popups */
}
.question-box {
width: 100%;
max-width: 400px;
margin: 20px auto;
font-family: sans-serif;
}
.input-wrapper {
display: flex;
align-items: center;
border: 1px solid #ccc;
border-radius: 999px;
padding: 8px 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.question-input {
flex: 1;
border: none;
outline: none;
font-size: 14px;
background-color: transparent;
}
.question-input::placeholder {
color: #999;
}
.send-button {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #555;
transition: color 0.2s ease;
}
.send-button:hover {
color: #000;
}
.discussion-link {
margin-top: 8px;
@ -468,4 +450,10 @@
.discussion-link a:hover {
text-decoration: underline;
}
.nav-scroll-area {
overflow-y: auto;
flex-grow: 1;
min-height: 0;
}
</style>

View file

@ -18,6 +18,15 @@
version: number;
group: { forGroup: number; assignmentNo: number; classId: string };
}>();
function parseContent(content: string): SubmissionData {
if (content === "") {
return [];
}
return JSON.parse(content);
}
const emit = defineEmits<(e: "update:submissionData", value: SubmissionData) => void>();
const submissionQuery = useSubmissionsQuery(
@ -35,7 +44,7 @@
}
function emitSubmission(submission: SubmissionDTO): void {
emitSubmissionData(JSON.parse(submission.content));
emitSubmissionData(parseContent(submission.content));
}
watch(submissionQuery.data, () => {
@ -47,12 +56,13 @@
}
});
const lastSubmission = computed<SubmissionData>(() => {
const lastSubmission = computed<SubmissionData | undefined>(() => {
const submissions = submissionQuery.data.value;
if (!submissions || submissions.length === 0) {
return undefined;
}
return JSON.parse(submissions[submissions.length - 1].content);
return parseContent(submissions[submissions.length - 1].content);
});
const showSubmissionTable = computed(() => props.submissionData !== undefined && props.submissionData.length > 0);

View file

@ -89,6 +89,15 @@
props.selectedLearningPath.language !== parsedLearningPath.value.language),
);
const selectedLearningPathLink = computed(() => {
if (!props.selectedLearningPath) {
return undefined;
}
const { hruid, language } = props.selectedLearningPath;
const startNode = props.selectedLearningPath.nodes.find((it) => it.start_node);
return `/learningPath/${hruid}/${language}/${startNode.learningobject_hruid}`;
});
function getErrorMessage(): string | null {
if (postError.value) {
return t(extractErrorMessage(postError.value));
@ -104,7 +113,43 @@
</script>
<template>
<v-card :title="props.selectedLearningPath ? t('editLearningPath') : t('newLearningPath')">
<v-card>
<template v-slot:title>
<div class="title-container">
<span class="title">{{
props.selectedLearningPath ? t("editLearningPath") : t("newLearningPath")
}}</span>
<span class="actions">
<v-btn
@click="uploadLearningPath"
prependIcon="mdi mdi-check"
:loading="isPostPending || isPutPending"
:disabled="parsedLearningPath.hruid === DEFAULT_LEARNING_PATH.hruid || isIdModified"
variant="text"
>
{{ props.selectedLearningPath ? t("saveChanges") : t("create") }}
</v-btn>
<button-with-confirmation
@confirm="deleteLearningPath"
:disabled="!props.selectedLearningPath"
:text="t('delete')"
color="red"
prependIcon="mdi mdi-delete"
:confirmQueryText="t('learningPathDeleteQuery')"
variant="text"
/>
<v-btn
:href="selectedLearningPathLink"
target="_blank"
prepend-icon="mdi mdi-open-in-new"
:disabled="!props.selectedLearningPath"
variant="text"
>
{{ t("open") }}
</v-btn>
</span>
</div>
</template>
<template v-slot:text>
<json-editor-vue v-model="learningPath"></json-editor-vue>
<v-alert
@ -115,33 +160,21 @@
:text="getErrorMessage()!"
></v-alert>
</template>
<template v-slot:actions>
<v-btn
@click="uploadLearningPath"
prependIcon="mdi mdi-check"
:loading="isPostPending || isPutPending"
:disabled="parsedLearningPath.hruid === DEFAULT_LEARNING_PATH.hruid || isIdModified"
>
{{ props.selectedLearningPath ? t("saveChanges") : t("create") }}
</v-btn>
<button-with-confirmation
@confirm="deleteLearningPath"
:disabled="!props.selectedLearningPath"
:text="t('delete')"
color="red"
prependIcon="mdi mdi-delete"
:confirmQueryText="t('learningPathDeleteQuery')"
/>
<v-btn
:href="`/learningPath/${props.selectedLearningPath?.hruid}/${props.selectedLearningPath?.language}/start`"
target="_blank"
prepend-icon="mdi mdi-open-in-new"
:disabled="!props.selectedLearningPath"
>
{{ t("open") }}
</v-btn>
</template>
</v-card>
</template>
<style scoped></style>
<style scoped>
.title-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.title {
flex: 1;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
</style>

View file

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

View file

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

View file

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

View file

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

358
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "dwengo-1",
"version": "0.2.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dwengo-1",
"version": "0.2.0",
"version": "1.0.0",
"license": "MIT",
"workspaces": [
"backend",
@ -29,7 +29,7 @@
},
"backend": {
"name": "@dwengo-1/backend",
"version": "0.2.0",
"version": "1.0.0",
"dependencies": {
"@mikro-orm/core": "6.4.12",
"@mikro-orm/knex": "6.4.12",
@ -95,20 +95,19 @@
},
"common": {
"name": "@dwengo-1/common",
"version": "0.2.0"
"version": "1.0.0"
},
"docs": {
"name": "dwengo-1-docs",
"version": "0.2.0",
"version": "1.0.0",
"devDependencies": {
"swagger-autogen": "^2.23.7"
}
},
"frontend": {
"name": "dwengo-1-frontend",
"version": "0.2.0",
"version": "1.0.0",
"dependencies": {
"@dwengo-1/common": "^0.2.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/vue-query": "^5.69.0",
"@vueuse/core": "^13.1.0",
@ -184,9 +183,9 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.1.7.tgz",
"integrity": "sha512-Ok5fYhtwdyJQmU1PpEv6Si7Y+A4cYb8yNM9oiIJC9TzXPMuN9fvdonKJqcnz9TbFqV6bQ8z0giRq0iaOpGZV2g==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
@ -2515,9 +2514,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz",
"integrity": "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz",
"integrity": "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A==",
"cpu": [
"arm"
],
@ -2528,9 +2527,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz",
"integrity": "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.0.tgz",
"integrity": "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ==",
"cpu": [
"arm64"
],
@ -2541,9 +2540,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz",
"integrity": "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.0.tgz",
"integrity": "sha512-2KOU574vD3gzcPSjxO0eyR5iWlnxxtmW1F5CkNOHmMlueKNCQkxR6+ekgWyVnz6zaZihpUNkGxjsYrkTJKhkaw==",
"cpu": [
"arm64"
],
@ -2554,9 +2553,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz",
"integrity": "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.0.tgz",
"integrity": "sha512-gE5ACNSxHcEZyP2BA9TuTakfZvULEW4YAOtxl/A/YDbIir/wPKukde0BNPlnBiP88ecaN4BJI2TtAd+HKuZPQQ==",
"cpu": [
"x64"
],
@ -2567,9 +2566,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz",
"integrity": "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.0.tgz",
"integrity": "sha512-GSxU6r5HnWij7FoSo7cZg3l5GPg4HFLkzsFFh0N/b16q5buW1NAWuCJ+HMtIdUEi6XF0qH+hN0TEd78laRp7Dg==",
"cpu": [
"arm64"
],
@ -2580,9 +2579,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz",
"integrity": "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.0.tgz",
"integrity": "sha512-KGiGKGDg8qLRyOWmk6IeiHJzsN/OYxO6nSbT0Vj4MwjS2XQy/5emsmtoqLAabqrohbgLWJ5GV3s/ljdrIr8Qjg==",
"cpu": [
"x64"
],
@ -2593,9 +2592,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz",
"integrity": "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.0.tgz",
"integrity": "sha512-46OzWeqEVQyX3N2/QdiU/CMXYDH/lSHpgfBkuhl3igpZiaB3ZIfSjKuOnybFVBQzjsLwkus2mjaESy8H41SzvA==",
"cpu": [
"arm"
],
@ -2606,9 +2605,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz",
"integrity": "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.0.tgz",
"integrity": "sha512-lfgW3KtQP4YauqdPpcUZHPcqQXmTmH4nYU0cplNeW583CMkAGjtImw4PKli09NFi2iQgChk4e9erkwlfYem6Lg==",
"cpu": [
"arm"
],
@ -2619,9 +2618,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz",
"integrity": "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.0.tgz",
"integrity": "sha512-nn8mEyzMbdEJzT7cwxgObuwviMx6kPRxzYiOl6o/o+ChQq23gfdlZcUNnt89lPhhz3BYsZ72rp0rxNqBSfqlqw==",
"cpu": [
"arm64"
],
@ -2632,9 +2631,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz",
"integrity": "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.0.tgz",
"integrity": "sha512-l+QK99je2zUKGd31Gh+45c4pGDAqZSuWQiuRFCdHYC2CSiO47qUWsCcenrI6p22hvHZrDje9QjwSMAFL3iwXwQ==",
"cpu": [
"arm64"
],
@ -2645,9 +2644,9 @@
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz",
"integrity": "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.0.tgz",
"integrity": "sha512-WbnJaxPv1gPIm6S8O/Wg+wfE/OzGSXlBMbOe4ie+zMyykMOeqmgD1BhPxZQuDqwUN+0T/xOFtL2RUWBspnZj3w==",
"cpu": [
"loong64"
],
@ -2658,9 +2657,9 @@
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz",
"integrity": "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.0.tgz",
"integrity": "sha512-eRDWR5t67/b2g8Q/S8XPi0YdbKcCs4WQ8vklNnUYLaSWF+Cbv2axZsp4jni6/j7eKvMLYCYdcsv8dcU+a6QNFg==",
"cpu": [
"ppc64"
],
@ -2671,9 +2670,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz",
"integrity": "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.0.tgz",
"integrity": "sha512-TWrZb6GF5jsEKG7T1IHwlLMDRy2f3DPqYldmIhnA2DVqvvhY2Ai184vZGgahRrg8k9UBWoSlHv+suRfTN7Ua4A==",
"cpu": [
"riscv64"
],
@ -2684,9 +2683,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz",
"integrity": "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.0.tgz",
"integrity": "sha512-ieQljaZKuJpmWvd8gW87ZmSFwid6AxMDk5bhONJ57U8zT77zpZ/TPKkU9HpnnFrM4zsgr4kiGuzbIbZTGi7u9A==",
"cpu": [
"riscv64"
],
@ -2697,9 +2696,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz",
"integrity": "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.0.tgz",
"integrity": "sha512-/L3pW48SxrWAlVsKCN0dGLB2bi8Nv8pr5S5ocSM+S0XCn5RCVCXqi8GVtHFsOBBCSeR+u9brV2zno5+mg3S4Aw==",
"cpu": [
"s390x"
],
@ -2710,9 +2709,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz",
"integrity": "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.0.tgz",
"integrity": "sha512-XMLeKjyH8NsEDCRptf6LO8lJk23o9wvB+dJwcXMaH6ZQbbkHu2dbGIUindbMtRN6ux1xKi16iXWu6q9mu7gDhQ==",
"cpu": [
"x64"
],
@ -2723,9 +2722,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz",
"integrity": "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.0.tgz",
"integrity": "sha512-m/P7LycHZTvSQeXhFmgmdqEiTqSV80zn6xHaQ1JSqwCtD1YGtwEK515Qmy9DcB2HK4dOUVypQxvhVSy06cJPEg==",
"cpu": [
"x64"
],
@ -2736,9 +2735,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz",
"integrity": "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.0.tgz",
"integrity": "sha512-4yodtcOrFHpbomJGVEqZ8fzD4kfBeCbpsUy5Pqk4RluXOdsWdjLnjhiKy2w3qzcASWd04fp52Xz7JKarVJ5BTg==",
"cpu": [
"arm64"
],
@ -2749,9 +2748,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz",
"integrity": "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.0.tgz",
"integrity": "sha512-tmazCrAsKzdkXssEc65zIE1oC6xPHwfy9d5Ta25SRCDOZS+I6RypVVShWALNuU9bxIfGA0aqrmzlzoM5wO5SPQ==",
"cpu": [
"ia32"
],
@ -2762,9 +2761,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz",
"integrity": "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.0.tgz",
"integrity": "sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA==",
"cpu": [
"x64"
],
@ -2954,9 +2953,9 @@
"license": "MIT"
},
"node_modules/@tsconfig/node22": {
"version": "22.0.1",
"resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.1.tgz",
"integrity": "sha512-VkgOa3n6jvs1p+r3DiwBqeEwGAwEvnVCg/hIjiANl5IEcqP3G0u5m8cBJspe1t9qjZRlZ7WFgqq5bJrGdgAKMg==",
"version": "22.0.2",
"resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.2.tgz",
"integrity": "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA==",
"dev": true,
"license": "MIT"
},
@ -3013,9 +3012,9 @@
"license": "MIT"
},
"node_modules/@types/express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz",
"integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.2.tgz",
"integrity": "sha512-BtjL3ZwbCQriyb0DGw+Rt12qAXPiBTPs815lsUvtt1Grk0vLRMZNMUZ741d5rjk+UQOxfDiBZ3dxpX00vSkK3g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3120,18 +3119,18 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.15.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.18.tgz",
"integrity": "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==",
"version": "22.15.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.20.tgz",
"integrity": "sha512-A6BohGFRGHAscJsTslDCA9JG7qSJr/DWUvrvY8yi9IgnGtMxCyat7vvQ//MFa0DnLsyuS3wYTpLdw4Hf+Q5JXw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qs": {
"version": "6.9.18",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
"integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"license": "MIT"
},
"node_modules/@types/range-parser": {
@ -3427,9 +3426,9 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.3.tgz",
"integrity": "sha512-cj76U5gXCl3g88KSnf80kof6+6w+K4BjOflCl7t6yRJPDuCrHtVu0SgNYOUARJOL5TI8RScDbm5x4s1/P9bvpw==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.4.tgz",
"integrity": "sha512-G4p6OtioySL+hPV7Y6JHlhpsODbJzt1ndwHAFkyk6vVjpK03PFsKnauZIzcd0PrK4zAbc5lc+jeZ+eNGiMA+iw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3450,8 +3449,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "3.1.3",
"vitest": "3.1.3"
"@vitest/browser": "3.1.4",
"vitest": "3.1.4"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@ -3481,14 +3480,14 @@
}
},
"node_modules/@vitest/expect": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.3.tgz",
"integrity": "sha512-7FTQQuuLKmN1Ig/h+h/GO+44Q1IlglPlR2es4ab7Yvfx+Uk5xsv+Ykk+MEt/M2Yn/xGmzaLKxGw2lgy2bwuYqg==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz",
"integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.1.3",
"@vitest/utils": "3.1.3",
"@vitest/spy": "3.1.4",
"@vitest/utils": "3.1.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@ -3497,13 +3496,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.3.tgz",
"integrity": "sha512-PJbLjonJK82uCWHjzgBJZuR7zmAOrSvKk1QBxrennDIgtH4uK0TB1PvYmc0XBCigxxtiAVPfWtAdy4lpz8SQGQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz",
"integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.1.3",
"@vitest/spy": "3.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@ -3534,9 +3533,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.3.tgz",
"integrity": "sha512-i6FDiBeJUGLDKADw2Gb01UtUNb12yyXAqC/mmRWuYl+m/U9GS7s8us5ONmGkGpUUo7/iAYzI2ePVfOZTYvUifA==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz",
"integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3547,13 +3546,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.3.tgz",
"integrity": "sha512-Tae+ogtlNfFei5DggOsSUvkIaSuVywujMj6HzR97AHK6XK8i3BuVyIifWAm/sE3a15lF5RH9yQIrbXYuo0IFyA==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz",
"integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.1.3",
"@vitest/utils": "3.1.4",
"pathe": "^2.0.3"
},
"funding": {
@ -3561,13 +3560,13 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.3.tgz",
"integrity": "sha512-XVa5OPNTYUsyqG9skuUkFzAeFnEzDp8hQu7kZ0N25B1+6KjGm4hWLtURyBbsIAOekfWQ7Wuz/N/XXzgYO3deWQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz",
"integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.1.3",
"@vitest/pretty-format": "3.1.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@ -3576,9 +3575,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.3.tgz",
"integrity": "sha512-x6w+ctOEmEXdWaa6TO4ilb7l9DxPR5bwEb6hILKuxfU1NqWT2mpJD9NJN7t3OTfxmVlOMrvtoFJGdgyzZ605lQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz",
"integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3589,13 +3588,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.3.tgz",
"integrity": "sha512-2Ltrpht4OmHO9+c/nmHtF09HWiyWdworqnHIwjfvDyWjuwKbdkcS9AnhsDn+8E2RM4x++foD1/tNuLPVvWG1Rg==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz",
"integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.1.3",
"@vitest/pretty-format": "3.1.4",
"loupe": "^3.1.3",
"tinyrainbow": "^2.0.0"
},
@ -5302,9 +5301,9 @@
}
},
"node_modules/dompurify": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
"integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@ -6745,9 +6744,9 @@
}
},
"node_modules/get-tsconfig": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz",
"integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==",
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
"integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -7393,12 +7392,12 @@
"license": "ISC"
},
"node_modules/isomorphic-dompurify": {
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.24.0.tgz",
"integrity": "sha512-SgKoDBCQveodymGMBPpzs9MOTCk4Luq0bTfwoPrUKa7q0FnCLZMtqR25Rnq228zJfMTsX1ZItiJbDtjb2lyv4A==",
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.25.0.tgz",
"integrity": "sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==",
"license": "MIT",
"dependencies": {
"dompurify": "^3.2.5",
"dompurify": "^3.2.6",
"jsdom": "^26.1.0"
},
"engines": {
@ -7804,9 +7803,9 @@
}
},
"node_modules/jwks-rsa/node_modules/@types/express": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
"version": "4.17.22",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz",
"integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==",
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
@ -8277,9 +8276,9 @@
}
},
"node_modules/marked": {
"version": "15.0.11",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.11.tgz",
"integrity": "sha512-1BEXAU2euRCG3xwgLVT1y0xbJEld1XOrmRJpUwRCcy7rxhSCwMrmEu9LXoPhHSCJG41V7YcQ2mjKRr5BA3ITIA==",
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
@ -9710,9 +9709,9 @@
"license": "ISC"
},
"node_modules/protobufjs": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.2.tgz",
"integrity": "sha512-f2ls6rpO6G153Cy+o2XQ+Y0sARLOZ17+OGVLHrc3VUKcLHYKEKWbkSujdBWQXM7gKn5NTfp0XnRPZn1MIu8n9w==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz",
"integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@ -10079,9 +10078,9 @@
}
},
"node_modules/rollup": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz",
"integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz",
"integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.7"
@ -10094,26 +10093,26 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.40.2",
"@rollup/rollup-android-arm64": "4.40.2",
"@rollup/rollup-darwin-arm64": "4.40.2",
"@rollup/rollup-darwin-x64": "4.40.2",
"@rollup/rollup-freebsd-arm64": "4.40.2",
"@rollup/rollup-freebsd-x64": "4.40.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.40.2",
"@rollup/rollup-linux-arm-musleabihf": "4.40.2",
"@rollup/rollup-linux-arm64-gnu": "4.40.2",
"@rollup/rollup-linux-arm64-musl": "4.40.2",
"@rollup/rollup-linux-loongarch64-gnu": "4.40.2",
"@rollup/rollup-linux-powerpc64le-gnu": "4.40.2",
"@rollup/rollup-linux-riscv64-gnu": "4.40.2",
"@rollup/rollup-linux-riscv64-musl": "4.40.2",
"@rollup/rollup-linux-s390x-gnu": "4.40.2",
"@rollup/rollup-linux-x64-gnu": "4.40.2",
"@rollup/rollup-linux-x64-musl": "4.40.2",
"@rollup/rollup-win32-arm64-msvc": "4.40.2",
"@rollup/rollup-win32-ia32-msvc": "4.40.2",
"@rollup/rollup-win32-x64-msvc": "4.40.2",
"@rollup/rollup-android-arm-eabi": "4.41.0",
"@rollup/rollup-android-arm64": "4.41.0",
"@rollup/rollup-darwin-arm64": "4.41.0",
"@rollup/rollup-darwin-x64": "4.41.0",
"@rollup/rollup-freebsd-arm64": "4.41.0",
"@rollup/rollup-freebsd-x64": "4.41.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.41.0",
"@rollup/rollup-linux-arm-musleabihf": "4.41.0",
"@rollup/rollup-linux-arm64-gnu": "4.41.0",
"@rollup/rollup-linux-arm64-musl": "4.41.0",
"@rollup/rollup-linux-loongarch64-gnu": "4.41.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.41.0",
"@rollup/rollup-linux-riscv64-gnu": "4.41.0",
"@rollup/rollup-linux-riscv64-musl": "4.41.0",
"@rollup/rollup-linux-s390x-gnu": "4.41.0",
"@rollup/rollup-linux-x64-gnu": "4.41.0",
"@rollup/rollup-linux-x64-musl": "4.41.0",
"@rollup/rollup-win32-arm64-msvc": "4.41.0",
"@rollup/rollup-win32-ia32-msvc": "4.41.0",
"@rollup/rollup-win32-x64-msvc": "4.41.0",
"fsevents": "~2.3.2"
}
},
@ -10917,9 +10916,9 @@
}
},
"node_modules/svelte": {
"version": "5.30.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.30.1.tgz",
"integrity": "sha512-QIYtKnJGkubWXtNkrUBKVCvyo9gjcccdbnvXfwsGNhvbeNNdQjRDTa/BiQcJ2kWXbXPQbWKyT7CUu53KIj1rfw==",
"version": "5.32.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.32.1.tgz",
"integrity": "sha512-tT02QOeF0dbSIQ+/rUZw+76DyO6ATHvZJGOM2A/Ed6fBwZwUxqIun3beErpePAtwFIK3Mi9k2QAnhFVvUBun8g==",
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
@ -11044,14 +11043,13 @@
"license": "MIT"
},
"node_modules/synckit": {
"version": "0.11.5",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.5.tgz",
"integrity": "sha512-frqvfWyDA5VPVdrWfH24uM6SI/O8NLpVbIIJxb8t/a3YGsp4AW9CYgSKC0OaSEfexnp7Y1pVh2Y6IHO8ggGDmA==",
"version": "0.11.6",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.6.tgz",
"integrity": "sha512-2pR2ubZSV64f/vqm9eLPz/KOvR9Dm+Co/5ChLgeHl0yEDRc6h5hXHoxEQH8Y5Ljycozd3p1k5TTSVdzYGkPvLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.4",
"tslib": "^2.8.1"
"@pkgr/core": "^0.2.4"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
@ -11849,9 +11847,9 @@
}
},
"node_modules/vite-node": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.3.tgz",
"integrity": "sha512-uHV4plJ2IxCl4u1up1FQRrqclylKAogbtBfOTwcuJ28xFi+89PZ57BRh+naIRvH70HPwxy5QHYzg1OrEaC7AbA==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz",
"integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -11991,19 +11989,19 @@
}
},
"node_modules/vitest": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.3.tgz",
"integrity": "sha512-188iM4hAHQ0km23TN/adso1q5hhwKqUpv+Sd6p5sOuh6FhQnRNW3IsiIpvxqahtBabsJ2SLZgmGSpcYK4wQYJw==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz",
"integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "3.1.3",
"@vitest/mocker": "3.1.3",
"@vitest/pretty-format": "^3.1.3",
"@vitest/runner": "3.1.3",
"@vitest/snapshot": "3.1.3",
"@vitest/spy": "3.1.3",
"@vitest/utils": "3.1.3",
"@vitest/expect": "3.1.4",
"@vitest/mocker": "3.1.4",
"@vitest/pretty-format": "^3.1.4",
"@vitest/runner": "3.1.4",
"@vitest/snapshot": "3.1.4",
"@vitest/spy": "3.1.4",
"@vitest/utils": "3.1.4",
"chai": "^5.2.0",
"debug": "^4.4.0",
"expect-type": "^1.2.1",
@ -12016,7 +12014,7 @@
"tinypool": "^1.0.2",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0",
"vite-node": "3.1.3",
"vite-node": "3.1.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
@ -12032,8 +12030,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.1.3",
"@vitest/ui": "3.1.3",
"@vitest/browser": "3.1.4",
"@vitest/ui": "3.1.4",
"happy-dom": "*",
"jsdom": "*"
},

View file

@ -1,6 +1,6 @@
{
"name": "dwengo-1",
"version": "0.2.0",
"version": "1.0.0",
"description": "Monorepo for Dwengo-1",
"private": true,
"type": "module",