Compare commits

...
155 Commits
Author SHA1 Message Date
Otto Ahoniemi 33c0aa71d9 Destructure props on function declaration 2021-11-17 11:02:59 +02:00
Otto Ahoniemi 2e4572c871 Drop use of hocs in populationstudents 2021-11-17 11:01:28 +02:00
Otto Ahoniemi f6b14777fe Combine tabs to studenttable 2021-11-17 10:37:13 +02:00
Otto Ahoniemi f251f9456e Make studyprogramme and associated year sortable 2021-11-17 10:25:59 +02:00
Petri Palmu f364eb4f67 few changes to the population statistics tests 2021-11-16 14:01:03 +02:00
Petri Palmu 4896f9e458 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-12 12:43:07 +02:00
Petri Palmu 096535e470 little change how updater handles open uni courses 2021-11-12 12:42:56 +02:00
Otto Ahoniemi 7c949b47a7 Initial refactoring of populationStudents from class component to functional component 2021-11-11 17:01:55 +02:00
Otto AhoniemiandGitHub c3b7d96b06 Merge pull request #3304 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/axios-0.21.2
Bump axios from 0.21.1 to 0.21.2 in /services/frontend
2021-11-11 15:46:17 +02:00
Otto AhoniemiandGitHub 9a3783bc45 Merge pull request #3305 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/tmpl-1.0.5
Bump tmpl from 1.0.4 to 1.0.5 in /services/frontend
2021-11-11 15:46:10 +02:00
Otto AhoniemiandGitHub 4882d1d9d3 Merge pull request #3306 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/tar-6.1.11
Bump tar from 6.1.0 to 6.1.11 in /services/frontend
2021-11-11 15:46:00 +02:00
dependabot[bot]andGitHub 9575d86d9c Bump tar from 6.1.0 to 6.1.11 in /services/frontend
Bumps [tar](https://github.com/npm/node-tar) from 6.1.0 to 6.1.11.
- [Release notes](https://github.com/npm/node-tar/releases)
- [Changelog](https://github.com/npm/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-tar/compare/v6.1.0...v6.1.11)

---
updated-dependencies:
- dependency-name: tar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-11 12:57:47 +00:00
dependabot[bot]andGitHub 85c7e86ed9 Bump tmpl from 1.0.4 to 1.0.5 in /services/frontend
Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

---
updated-dependencies:
- dependency-name: tmpl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-11 12:57:46 +00:00
dependabot[bot]andGitHub d527c6ba5f Bump axios from 0.21.1 to 0.21.2 in /services/frontend
Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-11 12:57:32 +00:00
Otto AhoniemiandGitHub 6d5b91782c Merge pull request #3287 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/backend/validator-13.7.0
Bump validator from 13.6.0 to 13.7.0 in /services/backend
2021-11-11 14:56:48 +02:00
Otto AhoniemiandGitHub b8762aae34 Merge pull request #3288 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/userservice/validator-13.7.0
Bump validator from 13.6.0 to 13.7.0 in /services/userservice
2021-11-11 14:56:41 +02:00
Otto Ahoniemi 4da78df1e6 Refactor studyguidancegroup logic 2021-11-11 14:12:39 +02:00
Otto Ahoniemi 93258ad12a Fix toggling between panel statuses throwing user away from current panel 2021-11-11 13:29:19 +02:00
Otto Ahoniemi 0028b69661 Revert tunkki solution for getting study guidance group population instead of generic population from redux 2021-11-11 12:52:27 +02:00
Otto Ahoniemi ef902b1c0c Fix population stats failing 2021-11-11 08:10:25 +02:00
Otto Ahoniemi d8e3ee092f Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-11 07:48:49 +02:00
Mitja Karhusaari 36e8c2f7f8 Fix lint 2021-11-11 05:01:21 +02:00
Otto Ahoniemi 8763b1868e Prevent manual typing to datetime field 2021-11-11 02:40:51 +02:00
Otto Ahoniemi 700d799b00 Add more filters to study guidance groups 2021-11-11 02:31:37 +02:00
Otto Ahoniemi b0ecc3d4f5 Fix showing students in study guidance groups, add better indicators for links 2021-11-11 02:27:43 +02:00
Otto Ahoniemi e681d5a94f Style betterments 2021-11-11 01:42:01 +02:00
Otto Ahoniemi 980cd8ecf0 Don't load mandatory programmes if already in state 2021-11-11 01:17:24 +02:00
Otto Ahoniemi 357c0b3f44 Make it possible to toggle showing programme structure / flat list in courses of population 2021-11-11 01:12:43 +02:00
Otto Ahoniemi 6467f5046e Make it possible to toggle showing credits from associated year 2021-11-10 23:09:57 +02:00
Otto Ahoniemi 21ff440ebd Cleanup unused props and proptypes 2021-11-10 23:07:23 +02:00
Otto Ahoniemi 73228fb26f Allow absolute imports 2021-11-10 21:46:43 +02:00
Otto Ahoniemi 2c1807fe10 Migrate studyguidancegroup redux logic to rtk query, fix some issues in overview 2021-11-10 21:02:30 +02:00
Otto Ahoniemi 50091a3098 Add RTK query baseapi and migrate studyguidancegroups to use it 2021-11-10 20:59:12 +02:00
Otto Ahoniemi 3ba7965402 Add original token to state and export dev headers in order to use them in rtk query 2021-11-10 20:32:04 +02:00
Otto Ahoniemi 067a53a072 Return copy instead of modifying course array 2021-11-10 16:41:39 +02:00
Otto Ahoniemi 9a3956832d Random fixes' 2021-11-10 00:40:28 +02:00
Otto Ahoniemi 831b638c99 Migrate populationcoursestatsflat from hoc to hooks, fix state updates on unmounted 2021-11-09 13:32:51 +02:00
Otto Ahoniemi 38c343b8d2 Add study guidance group filterset 2021-11-08 13:33:24 +02:00
Otto Ahoniemi c05346ad9a proptype cleaning 2021-11-08 13:25:33 +02:00
Petri Palmu dd12cd3626 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-08 10:58:22 +02:00
Petri Palmu 55eebe32a6 groomed course search method 2021-11-08 10:58:05 +02:00
Mitja Karhusaari 58592df2a0 Avoid accidental indirect dependency 2021-11-06 01:39:04 +02:00
Mitja Karhusaari dd110d21e3 🎉 Happy birthday to this one guy in the test dataset 2021-11-06 01:22:27 +02:00
Mitja Karhusaari 88443f0117 Scroll the instructions popup if neccessary when clicking a "See more..." info link 2021-11-06 00:37:10 +02:00
Mitja Karhusaari accd5c9120 Add a toggle for choosing between credit and student counts 2021-11-06 00:37:10 +02:00
Mitja Karhusaari c11f5d78ba Remove selectedStudents prop from the graph component
The component does not use the non-selected students for anything,
so we can just pass the pre-filtered set of students to the component.

The previous method of filtering students based on student numbers
in-component caused problems with the credit date filter. As this filter
not only filtered the students, but also modified them, the list of
filtered students needs to be passed to the components. Filtering the
whole population of students based on student numbers afterwards would
ignore these changes.
2021-11-06 00:09:38 +02:00
Mitja Karhusaari 9f4466d246 Convert PopulationStatistics into a functional component 2021-11-06 00:08:29 +02:00
Mitja Karhusaari 5504f02c31 Refoactor how credit graph data ranges are passed 2021-11-06 00:08:16 +02:00
Mitja Karhusaari 863e84e226 Add course credit date filter onto the population statistics page 2021-11-05 23:20:40 +02:00
Mitja Karhusaari 0ce965abe3 Rewrite the credit accumulation graph using a functional component 2021-11-05 23:20:40 +02:00
Petri Palmu e9242b3063 update frontend packages 2021-11-04 08:22:58 +02:00
Petri Palmu 6ccf320bb2 Fix to the open uni programme trends 2021-11-04 08:14:03 +02:00
Petri Palmu 4e9b579843 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-04 08:06:13 +02:00
Petri Palmu 985084864c Added open uni programme view to the trends 2021-11-04 08:05:57 +02:00
dependabot[bot]andGitHub 8099dff1b6 Bump validator from 13.6.0 to 13.7.0 in /services/userservice
Bumps [validator](https://github.com/validatorjs/validator.js) from 13.6.0 to 13.7.0.
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.6.0...13.7.0)

---
updated-dependencies:
- dependency-name: validator
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-03 19:43:02 +00:00
dependabot[bot]andGitHub 1528ff3d80 Bump validator from 13.6.0 to 13.7.0 in /services/backend
Bumps [validator](https://github.com/validatorjs/validator.js) from 13.6.0 to 13.7.0.
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.6.0...13.7.0)

---
updated-dependencies:
- dependency-name: validator
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-03 19:42:56 +00:00
Otto Ahoniemi 71c61c3393 Fix single group loading failing 2021-11-03 15:26:36 +02:00
Otto Ahoniemi f3b1f639b1 Disable immutable & serializablechecks from middleware 2021-11-03 15:01:15 +02:00
Otto Ahoniemi e599e620c0 Add student counts 2021-11-03 14:34:12 +02:00
Otto Ahoniemi f5b081656f use native isnan instead of lodash 2021-11-03 14:04:16 +02:00
Otto Ahoniemi 7af8e1a4df Change single year to academic year 2021-11-03 14:00:18 +02:00
Otto Ahoniemi 8c092c8d9f Remove more redux boilerplate 2021-11-03 12:47:52 +02:00
Otto Ahoniemi e0789186ba Change combinereducer import location 2021-11-03 12:26:56 +02:00
Otto Ahoniemi c798a4747f Remove unnecessary crudreducer abstraction 2021-11-03 12:20:18 +02:00
Otto Ahoniemi 8f396a9913 Cleanup studyguidancegroup reducer 2021-11-03 12:11:30 +02:00
Otto Ahoniemi 148a5acc25 Use one source of truth for redux types 2021-11-03 09:49:09 +02:00
Otto Ahoniemi 9cf1b87b3f Remove deprecated context dev tool pkg 2021-11-03 09:34:58 +02:00
Otto Ahoniemi 82ed55e1f9 Simplify store config 2021-11-03 09:30:41 +02:00
Otto Ahoniemi 82041e673d Swap individual redux&reselect to official redux toolkit package 2021-11-03 09:25:45 +02:00
Otto Ahoniemi 1e4405aac9 Change frontpage to work with hooks 2021-11-02 22:06:42 +02:00
Otto Ahoniemi dcefdd12cb Fix error when year tag not set 2021-11-02 20:29:11 +02:00
Otto AhoniemiandGitHub fd775252b8 Merge pull request #3282 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/ansi-regex-5.0.1
Bump ansi-regex from 5.0.0 to 5.0.1
2021-11-02 20:19:09 +02:00
Otto Ahoniemi acd7dc65f6 Add prop for creditacc starting year, cleanup proptypes 2021-11-02 20:14:32 +02:00
Otto Ahoniemi a82d272a27 moar cleanups 2021-11-02 20:14:32 +02:00
Otto Ahoniemi 1bb5ddba96 Finalize form on frontend 2021-11-02 20:14:32 +02:00
Otto Ahoniemi ae14a55885 Cleanups 2021-11-02 20:14:32 +02:00
Otto Ahoniemi 9308016cf3 Show previosly selected value as placeholder when editing 2021-11-02 20:14:32 +02:00
Otto Ahoniemi b5252a01f8 Add dropdown to search studyprogrammes 2021-11-02 20:14:32 +02:00
Otto Ahoniemi 4b22902fa4 Add support for studyguidancegroups in backend and add edits to frontend 2021-11-02 20:14:32 +02:00
Otto Ahoniemi 267ebce457 Cleaning up again some proptypes 2021-11-02 20:14:32 +02:00
Otto Ahoniemi ee8013cba2 Add initial support for adding study guidance group programme & year in front 2021-11-02 20:14:32 +02:00
Otto Ahoniemi 4ce07eaf31 Refactor 2021-11-02 20:14:32 +02:00
Mitja Karhusaari e0d4e4072d Improve date selection behavior when no date is selected or it is the current date 2021-11-02 16:31:38 +02:00
Mitja Karhusaari d4aeece78e Minor UI behaviour fix in a tooltip popup 2021-11-02 16:19:37 +02:00
dependabot[bot]andGitHub 02adcbc4f8 Bump ansi-regex from 5.0.0 to 5.0.1
Bumps [ansi-regex](https://github.com/chalk/ansi-regex) from 5.0.0 to 5.0.1.
- [Release notes](https://github.com/chalk/ansi-regex/releases)
- [Commits](https://github.com/chalk/ansi-regex/compare/v5.0.0...v5.0.1)

---
updated-dependencies:
- dependency-name: ansi-regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-02 13:58:40 +00:00
Mitja Karhusaari a9d44b3d76 Fix tests for population statistics
The tests were dependent of the current month being October of 2021.
2021-11-02 15:57:31 +02:00
Mitja Karhusaari 3d310ee184 Update tooltips 2021-11-01 19:11:38 +02:00
Mitja Karhusaari 0adf1eae76 Misc UI asjustments in trends status page 2021-11-01 18:48:33 +02:00
Mitja Karhusaari e10d3dd3c7 Wrap Status and Graduated tabs' contents in a pane 2021-11-01 16:29:39 +02:00
Mitja Karhusaari fce0e482d3 Move settings pane next to the usage guide and behind a popup 2021-11-01 16:29:39 +02:00
Mitja Karhusaari 4d57d00ddb Move trends status usage info behind a popup window 2021-11-01 16:29:39 +02:00
Mitja Karhusaari 7cc90a4378 Implement drilling in trends so that the browser navigation buttons work 2021-11-01 16:29:39 +02:00
Mitja Karhusaari ff0f1a545f Display accumulated / total values for course student counts 2021-11-01 16:29:39 +02:00
Saara Satokangas 6eb69a036a Add tests for Credits Gained -tab 2021-10-28 18:34:50 +03:00
Saara Satokangas 3c8a47fc76 Add hover to make the toggle more noticeable 2021-10-28 16:28:26 +03:00
Saara Satokangas 735ec7efa0 Move "NEW!" label to specifically admission types 2021-10-28 16:11:46 +03:00
Saara Satokangas c33f842c9a Do not show dividers if no admission type data available 2021-10-28 16:03:17 +03:00
Saara Satokangas 2340e39fb4 Don't show admission type credit table if only zeros 2021-10-28 11:51:29 +03:00
Saara Satokangas 0221d1f70e Add divider to credit statistics 2021-10-28 11:48:14 +03:00
Saara Satokangas c6fe105b46 Add credit gains by admission type 2021-10-28 11:47:33 +03:00
Saara Satokangas 6ec1bc118d Move label "NEW!" from age stats to credit stats 2021-10-28 09:53:30 +03:00
Saara Satokangas 098dd5425e Remove proptypes from CreditGainStats-tables 2021-10-28 09:49:03 +03:00
Saara Satokangas 9eeed5094f Remove proptypes from CreditGainStats 2021-10-28 09:47:57 +03:00
Otto Ahoniemi ada4f66105 Add navbar styles back 2021-10-28 08:22:42 +03:00
Otto Ahoniemi b25c90e338 remove jstat 2021-10-28 08:22:42 +03:00
Otto Ahoniemi 47995056cb Report errors in case normal logging process fails 2021-10-28 08:22:42 +03:00
Otto Ahoniemi 46ee5b7764 Hardcode importer url instead of getting it from env 2021-10-28 08:22:42 +03:00
Petri Palmu c8f0e421fe little correction to the avoin trends 2021-10-28 08:21:38 +03:00
Petri Palmu 3db6a4443e Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-10-28 08:04:58 +03:00
Petri Palmu 77d20ad189 open uni added to the trends 2021-10-28 08:01:46 +03:00
Otto Ahoniemi bd6b2adef0 Change test to target cypress attr instead of classname 2021-10-27 19:50:14 +03:00
Otto Ahoniemi 7457ada25a Move select prompt reset to main cli.sh 2021-10-27 19:46:23 +03:00
Otto Ahoniemi ab94a0b27c Add option to reset single database interactively from cli 2021-10-27 19:34:54 +03:00
Otto Ahoniemi 41e86af663 Remove old sis button configs from navbar, allow it to overflow before stacking 2021-10-27 19:34:54 +03:00
Otto Ahoniemi 19e752b7ca Add formik 2021-10-27 19:34:54 +03:00
Otto Ahoniemi 3d4f71dbad Merge logging logic in different modules, fixing #3243 2021-10-27 19:34:53 +03:00
Saara Satokangas ff4d858efc Remove the quartiles also from tests 2021-10-27 11:20:57 +03:00
Saara Satokangas 59271c9e38 Remove mention of Oodi-Sis -credit mismatch from frontend 2021-10-27 10:30:47 +03:00
Saara Satokangas d87fb6d0fa Remove the mention of oodi-sisu-credit-mismatch 2021-10-27 10:28:38 +03:00
Saara Satokangas 646d87ab9e Add comments about the BH modules and programmes 2021-10-27 10:21:24 +03:00
Saara Satokangas 9e687f27a3 Update infotexts for population credit statistics 2021-10-27 10:11:40 +03:00
Saara Satokangas b699322e6f Make credit stats adjust to chosen admission type filter better 2021-10-27 10:11:19 +03:00
Saara Satokangas 3c01e3b7b1 Add admission types to credit statistics 2021-10-27 10:11:05 +03:00
Petri Palmu cb256976d7 kielikeskus added to the trends 2021-10-26 11:13:13 +03:00
Petri Palmu 1424461e26 open uni sql added to the trends and lot of pull merges 2021-10-25 09:55:07 +03:00
Otto Ahoniemi 86bd472c41 re-introduce the fix I just somehow managed to revert 2021-10-22 14:00:40 +03:00
Otto Ahoniemi 8984ea14f1 Add better error reporting to userservice 2021-10-22 13:58:54 +03:00
Otto Ahoniemi 1af42bf012 Fix userservice failing because of wrong property check 2021-10-22 13:38:17 +03:00
Mitja Karhusaari f606a7e8b7 Display course, programme and faculty codes on hover 2021-10-22 11:33:38 +03:00
Mitja Karhusaari 6d938a34af Add "type": "course" properties to the generated data 2021-10-22 11:28:30 +03:00
Mitja Karhusaari 77c5721dff Show course student numbers in trends 2021-10-22 11:28:30 +03:00
Mitja Karhusaari c5561ab6dc Bump trends redis key version number 2021-10-22 11:27:54 +03:00
Mitja Karhusaari 0b647e1b32 Revert "Calculate student counts in trends based on attainments instead of study rights"
This reverts commit 9e282ae634.
2021-10-22 11:27:54 +03:00
Mitja Karhusaari 1437bb9d0e Display zero instead of infinity if both the nominator and the
denominator are zero
2021-10-22 11:27:54 +03:00
Mitja Karhusaari 66249aabd8 Display yearly stats up to the current year, even if nothing to show 2021-10-22 11:27:54 +03:00
Otto AhoniemiandGitHub 02c63d1d9f Stop logging to file in updater 2021-10-22 10:12:30 +03:00
Mitja Karhusaari 00b7dc933e Add back accumulated statistics to the trends view
Resolves #3269
2021-10-21 12:58:56 +03:00
Mitja Karhusaari 9f089fb6db Fix year labels wrapping in the trends view on Chrome
Resolves #3270
2021-10-21 12:58:56 +03:00
Mitja Karhusaari 542267042e Add explanation of the relative statistics option in trends 2021-10-21 12:58:56 +03:00
Mitja Karhusaari 9e282ae634 Calculate student counts in trends based on attainments instead of study rights 2021-10-21 12:58:56 +03:00
Otto Ahoniemi 6a625b1da7 Remove faculty programmes from userservice 2021-10-20 16:45:25 +03:00
Otto Ahoniemi 4982bd8f3c Wrap everything needed to enricher 2021-10-20 16:35:18 +03:00
Otto Ahoniemi 908e33ccd3 Moar userservice cleanups 2021-10-19 15:12:39 +03:00
Otto Ahoniemi d429480f80 Remove faculties from rights returned by userservice 2021-10-19 14:21:09 +03:00
Otto Ahoniemi dada995398 Remove facultyprogramme route 2021-10-19 14:08:26 +03:00
Otto Ahoniemi 69c9b05657 Moar enriching from faculties 2021-10-19 14:06:06 +03:00
Petri Palmu db66880e25 kielikeskus added to the trends 2021-10-19 13:35:06 +03:00
Otto Ahoniemi 0b5ac0b61b Set modifyaccess to work on username, not internal id 2021-10-19 11:20:39 +03:00
Otto Ahoniemi a902961942 Cleanup users elementdetails usage 2021-10-19 11:08:06 +03:00
Otto Ahoniemi 7c2fa532d4 Merge some user data fetching routes 2021-10-19 11:08:06 +03:00
Otto Ahoniemi 36267f9fca Enrich all users list with facultyprogrammes on backend side 2021-10-19 11:08:06 +03:00
Otto Ahoniemi c2d99d6944 remove unused userservice ping 2021-10-19 11:08:06 +03:00
Otto Ahoniemi 0b1be20780 remove facultyprogramme tests 2021-10-19 11:08:06 +03:00
Otto Ahoniemi cc9f5f6906 Set trends to use static facultyarray instead of fetching from userservice 2021-10-19 11:08:06 +03:00
Otto Ahoniemi d5bca4dc57 proptypes :trash: 2021-10-19 11:08:06 +03:00
114 changed files with 3183 additions and 66045 deletions
-1
View File
@@ -1,4 +1,3 @@
# Populate and change this to .env
IMPORTER_DB_API_TOKEN=
IMPORTER_DB_API_URL=https://importer.cs.helsinki.fi
PATE_TOKEN=
+15 -11
View File
@@ -44,20 +44,22 @@ will take care of setting up Oodikone for you. See README for more details."
msg ""
}
# Define custom shell prompt for the interactive select loop
PS3="Please enter your choice: "
set_custom_select_prompt() {
PS3="Please enter your choice: "
options=(
"Set up oodikone from scratch."
"Reset all real data."
"Reset sis-importer data."
"Quit."
)
options=(
"Set up oodikone from scratch."
"Reset all real data."
"Reset single database."
"Quit."
)
}
# Run scripts before showing interactive prompt
# Run scripts and set the prompt
show_welcome
init_dirs
get_username
set_custom_select_prompt
while true; do
select opt in "${options[@]}"; do
@@ -66,8 +68,10 @@ while true; do
set_up_oodikone_from_scratch;;
"Reset all real data.")
reset_all_real_data;;
"Reset sis-importer data.")
reset_sis_importer_data;;
"Reset single database.")
reset_single_database
set_custom_select_prompt # Set prompt back to initial values
;;
"Quit.")
break 2;;
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
+2 -2
View File
@@ -89,7 +89,7 @@ describe('Population Statistics', () => {
runTestStepWithPreAndPostParts('ageFilter-header', () => {
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('40')
checkFilteringResult(43)
checkFilteringResult(42)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
})
@@ -190,7 +190,7 @@ describe('Course Statistics', () => {
runTestStepWithPreAndPostParts('ageFilter-header', () => {
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('40')
checkFilteringResult(33)
checkFilteringResult(32)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
})
+56 -5
View File
@@ -1,5 +1,8 @@
/// <reference types="Cypress" />
const moment = require('moment')
const _ = require('lodash')
const setPopStatsUntil = (until, includeSettings = []) => {
cy.contains('Advanced settings').siblings().get('[data-cy=advanced-toggle]').click()
includeSettings.forEach(setting => {
@@ -13,6 +16,8 @@ const setPopStatsUntil = (until, includeSettings = []) => {
describe('Population Statistics tests', () => {
const pathToCSBach2017 =
'/populations?months=36&semesters=FALL&semesters=SPRING&studyRights={%22programme%22%3A%22KH50_005%22}&tag&year=2017'
const pathToCSMaster2019 =
'/populations?months=27&semesters=FALL&semesters=SPRING&studyRights=%7B%22programme%22%3A%22MH50_009%22%7D&tag&year=2019'
describe('when using basic user', () => {
beforeEach(() => {
cy.init('/populations')
@@ -59,6 +64,7 @@ describe('Population Statistics tests', () => {
it('Population statistics is usable on general level', () => {
cy.visit(pathToCSBach2017)
cy.cs('filtered-students')
setPopStatsUntil('toukokuu 2020')
cy.get('.card').within(() => {
@@ -69,7 +75,8 @@ describe('Population Statistics tests', () => {
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
})
cy.contains('Courses of population').click({ force: true })
cy.cs('filtered-students')
cy.contains('Courses of population').click()
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
// eslint-disable-next-line cypress/no-unnecessary-waiting
@@ -123,18 +130,62 @@ describe('Population Statistics tests', () => {
cy.contains('Credit accumulation (for 170 students)')
})
it('Credit Statistics, Statistics pane works', () => {
it('Credit Statistics, Credits Gained tab works', () => {
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('Credit statistics').click()
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the population')
const months = Math.ceil(moment.duration(moment().diff(moment('2017-08-1'))).asMonths())
const limits = [1, ..._.range(1, 4).map(p => Math.ceil(months * ((p * 15) / 12))), null]
const ranges = _.range(1, limits.length).map(i => _.slice(limits, i - 1, i + 1))
cy.get('.credits-gained-table').should('contain', '(n=149)')
for (const [start, end] of ranges) {
let value = 'credits'
if (start !== null) {
value = `${start} ≤ ${value}`
}
if (end !== null) {
value = `${value} < ${end}`
}
cy.get('.credits-gained-table').should('contain', value)
}
cy.get("[data-cy='credits-gained-table-body'] td:nth-child(3)").then($els => {
const sum = [...$els].map($el => parseInt($el.innerText)).reduce((a, b) => a + b, 0)
expect(sum).to.equal(149)
})
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('not.exist')
})
it('Credit Statistics, Credits Gained tab shows stats by admissions', () => {
cy.visit(pathToCSMaster2019)
cy.cs('filtered-students')
cy.contains('Credit statistics')
cy.get("[data-cy='credit-statistics']").click()
cy.get('.credits-gained-divider').click()
cy.get("[data-cy='credits-gained-table-Avoin väylä']").should('exist')
cy.get("[data-cy='credits-gained-table-Yhteispisteet']").should('exist')
cy.get("[data-cy='credits-gained-table-Muu']").should('exist')
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('exist')
})
it('Credit Statistics, Statistics pane works', () => {
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('Credit statistics').click({ force: true })
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
cy.get("[data-cy='credit-stats-table-name-header']").should('contain', 'Statistic for n = 149 Students')
cy.get("[data-cy='credit-stats-mean']").should('contain', '123.89')
cy.get("[data-cy='credit-stats-stdev']").should('contain', '61.32')
cy.get("[data-cy='credit-stats-min']").should('contain', '11')
cy.get("[data-cy='credit-stats-q1']").should('contain', '70')
cy.get("[data-cy='credit-stats-q2']").should('contain', '129')
cy.get("[data-cy='credit-stats-q3']").should('contain', '168')
cy.get("[data-cy='credit-stats-max']").should('contain', '314')
})
})
+15 -15
View File
@@ -4,16 +4,16 @@ describe('Users tests', () => {
describe('Using user with just grp-oodikone-user, no other rights', () => {
it('shows only frontpage, trends feedback', () => {
cy.init('', 'norights') // login with norights user
cy.get('.navBar').contains('Trends').should('exist')
cy.get('.navBar').contains('Give feedback').should('exist')
cy.get('[data-cy="navBar"]').contains('Trends').should('exist')
cy.get('[data-cy="navBar"]').contains('Give feedback').should('exist')
cy.get('.navBar').contains('Study programme').should('not.exist')
cy.get('.navBar').contains('Student statistics').should('not.exist')
cy.get('.navBar').contains('Course statistics').should('not.exist')
cy.get('.navBar').contains('Teachers').should('not.exist')
cy.get('.navBar').contains('Users').should('not.exist')
cy.get('.navBar').contains('Faculty').should('not.exist')
cy.get('.navBar').contains('Updater').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Study programme').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Student statistics').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Course statistics').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Teachers').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Users').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Faculty').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Updater').should('not.exist')
})
})
@@ -29,12 +29,12 @@ describe('Users tests', () => {
})
it('should see more stuff than others', () => {
cy.get('.navBar').contains('Study programme').should('exist')
cy.get('.navBar').contains('Student statistics').should('exist')
cy.get('.navBar').contains('Course statistics').should('exist')
// TODO: admin should probably see this as well, since can access this page: cy.get('.navBar').contains('Teachers').should('exist')
cy.get('.navBar').contains('Users').should('exist')
cy.get('.navBar').contains('Trends').should('exist')
cy.get('[data-cy="navBar"]').contains('Study programme').should('exist')
cy.get('[data-cy="navBar"]').contains('Student statistics').should('exist')
cy.get('[data-cy="navBar"]').contains('Course statistics').should('exist')
// TODO: admin should probably see this as well, since can access this page: cy.get('[data-cy="navBar"]').contains('Teachers').should('exist')
cy.get('[data-cy="navBar"]').contains('Users').should('exist')
cy.get('[data-cy="navBar"]').contains('Trends').should('exist')
})
it("mocking normal user shows only the mocked user's programmes", () => {
+34 -6593
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -49,5 +49,9 @@
"stylelint-config-prettier": "^8.0.2",
"stylelint-config-standard": "^22.0.0",
"stylelint-prettier": "^1.2.0"
},
"dependencies": {
"lodash": "^4.17.21",
"moment": "^2.29.1"
}
}
+48 -6
View File
@@ -110,12 +110,54 @@ reset_all_real_data() {
reset_databases "${DATABASES[@]}"
}
reset_sis_importer_data() {
infomsg "Downloading sis-importer-db dump"
local database=$SIS_IMPORTER_DB_NAME
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
download_real_dump $database $url
reset_databases $database
reset_single_database() {
# Define custom shell prompt for the nested interactive select loop
PS3="Please enter which database to reset: "
options=(
"kone-db"
"sis-db"
"sis-importer-db"
"user-db"
"Return to main menu."
)
local database=""
local url=""
while true; do
select opt in "${options[@]}"; do
case $opt in
"kone-db")
local database=$KONE_DB_NAME
local url=$KONE_DB_REAL_DUMP_URL
break 2;;
"sis-db")
local database=$SIS_DB_NAME
local url=$SIS_DB_REAL_DUMP_URL
break 2;;
"sis-importer-db")
local database=$SIS_IMPORTER_DB_NAME
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
break 2;;
"user-db")
local database=$USER_DB_NAME
local url=$USER_DB_REAL_DUMP_URL
break 2;;
"Return to main menu.")
break 2;;
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
";;
esac
break
done
done
if [ -n "$database" ]; then
infomsg "Downloading $database dump"
download_real_dump $database $url
reset_databases $database
fi
}
set_up_oodikone_from_scratch() {
+3 -3
View File
@@ -3353,9 +3353,9 @@
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
},
"validator": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz",
"integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg=="
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
"integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw=="
},
"vary": {
"version": "1.1.2",
-5
View File
@@ -18,7 +18,6 @@ const pateToken = process.env.PATE_API_TOKEN || ''
// Importer client
const importerToken = process.env.IMPORTER_DB_API_TOKEN || ''
const importerUrl = process.env.IMPORTER_DB_API_URL || ''
// Networking: Urls & ports
const baseUrl = isDev ? '/api' : ''
@@ -62,8 +61,6 @@ const addSlashToEnd = url => (url.endsWith('/') ? url : url + '/')
const ACCESS_TOKEN_HEADER_KEY = 'x-access-token'
const PWD = process.env.PWD || '/usr/src/app'
module.exports = {
DB_URL_KONE,
DB_MAX_CONNECTIONS,
@@ -91,6 +88,4 @@ module.exports = {
frontUrl,
backendPort,
importerToken,
importerUrl,
PWD,
}
+1 -1
View File
@@ -79,7 +79,7 @@ const initializeDatabaseConnection = async () => {
const migrations = await migrator.up()
logger.info({ message: 'Kone Migrations up to date', meta: migrations })
} catch (e) {
logger.error('Kone Migration error')
logger.error({ message: 'Kone Migration error: ', meta: e })
throw e
}
}
@@ -0,0 +1,34 @@
const { BIGINT, STRING, DATE } = require('sequelize')
module.exports = {
up: async queryInterface => {
await queryInterface.createTable(
'study_guidance_group_tags',
{
id: {
primaryKey: true,
type: BIGINT,
autoIncrement: true,
},
study_guidance_group_id: {
type: STRING,
unique: true,
},
study_programme: {
type: STRING,
},
year: {
type: STRING,
},
created_at: {
type: DATE,
},
updated_at: {
type: DATE,
},
},
{ underscored: true, timestamps: true }
)
},
down: async () => {},
}
@@ -135,6 +135,28 @@ const CustomPopulationSearch = sequelizeKone.define('custom_population_searches'
},
})
const StudyGuidanceGroupTag = sequelizeKone.define(
'study_guidance_group_tags',
{
id: {
primaryKey: true,
type: Sequelize.BIGINT,
autoIncrement: true,
},
studyGuidanceGroupId: {
type: Sequelize.STRING,
unique: true,
},
studyProgramme: {
type: Sequelize.STRING,
},
year: {
type: Sequelize.STRING,
},
},
{ underscored: true, timestamps: true }
)
MandatoryCourse.belongsTo(MandatoryCourseLabels, {
foreignKey: 'label',
sourceKey: 'id',
@@ -157,4 +179,5 @@ module.exports = {
CustomPopulationSearch,
sequelizeKone,
ExcludedCourse,
StudyGuidanceGroupTag,
}
+3 -2
View File
@@ -1,5 +1,5 @@
const router = require('express').Router()
const userService = require('../services/userService')
const { getUserDataFor } = require('../services/userService')
const { sendFeedbackToToska } = require('../services/mailservice')
const logger = require('../util/logger')
@@ -8,7 +8,8 @@ router.post('/email', async (req, res) => {
const {
decodedToken: { userId },
} = req
const { email, full_name } = await userService.byUsername(userId)
const { email, full_name } = await getUserDataFor(userId)
const result = await sendFeedbackToToska({
feedbackContent: content,
+1 -12
View File
@@ -2,7 +2,6 @@ const router = require('express').Router()
const Student = require('../services/students')
const userService = require('../services/userService')
const Unit = require('../services/units')
const { mismatchedStudents } = require('../services/student_credit_total_mismatches')
const filterStudentTags = (student, userId) => {
return {
@@ -11,13 +10,6 @@ const filterStudentTags = (student, userId) => {
}
}
const creditTotalMismatch = student => {
return {
...student,
mismatch: mismatchedStudents.has(student.studentNumber),
}
}
router.get('/students', async (req, res) => {
const {
roles,
@@ -58,10 +50,7 @@ router.get('/students/:id', async (req, res) => {
const results = await Student.withId(studentId)
return results.error
? res.status(400).json({ error: 'error finding student' }).end()
: res
.status(200)
.json(creditTotalMismatch(filterStudentTags(results, decodedToken.id)))
.end()
: res.status(200).json(filterStudentTags(results, decodedToken.id)).end()
}
const uid = req.decodedToken.userId
@@ -1,6 +1,6 @@
const router = require('express').Router()
const { logger } = require('../util/logger')
const { getAllGroupsAndStudents } = require('../services/studyGuidanceGroups')
const { getAllGroupsAndStudents, changeGroupTags } = require('../services/studyGuidanceGroups')
router.get('/', async (req, res) => {
const {
@@ -13,4 +13,27 @@ router.get('/', async (req, res) => {
return res.json(await getAllGroupsAndStudents(sisPersonId))
})
router.put('/:id/tags', async (req, res) => {
const {
decodedToken: { uid, sisPersonId },
body: tags,
params: { id: groupId },
} = req
if (!sisPersonId) {
logger.error(`User ${uid} tried to change study guidance group tags but personId was ${sisPersonId} in header`)
return res.status(400).json({ error: 'Not possible to change groups without personId' })
}
const groupsUserHasAccessTo = (await getAllGroupsAndStudents(sisPersonId)).map(group => group.id)
if (!groupsUserHasAccessTo.includes(groupId)) {
logger.error(
`User ${uid} tried to change study guidance group tags, but didn't have permission for the group ${groupId}`
)
return res.status(400).json({ error: 'Access denied' })
}
return res.status(200).json(await changeGroupTags({ groupId, tags }))
})
module.exports = router
+4 -2
View File
@@ -25,9 +25,11 @@ router.get('/access_groups', async (req, res) => {
router.post('/modifyaccess', async (req, res) => {
try {
const { uid } = req.body
const { username } = req.body
const result = await userService.modifyAccess(req.body)
const user = await userService.byId(uid)
const user = {
username,
}
await addUserToBlacklist(user, req.decodedToken)
res.status(200).json(result)
} catch (e) {
+3 -33
View File
@@ -368,39 +368,9 @@ const byNameAndOrCodeLike = async (name, code) => {
})
const courses = rawCourses
.map(
({
id,
code,
name,
latest_instance_date,
is_study_module,
coursetypecode,
startdate,
max_attainment_date,
min_attainment_date,
createdAt,
updatedAt,
substitutions,
organizations,
}) => {
return {
id,
code,
name,
latest_instance_date,
is_study_module,
coursetypecode,
startdate,
max_attainment_date,
min_attainment_date,
createdAt,
updatedAt,
substitutions,
organizations: organizations.map(o => o.id),
}
}
)
.map(course => {
return { ...course.dataValues, organizations: course.organizations.map(o => o.id) }
})
.sort(a => (a.code.match(/^[A-Za-z]{3}[0-9]{1}/) ? -1 : 1))
let substitutionGroupIndex = 0
@@ -3,6 +3,154 @@ const { Organization, ProgrammeModule } = require('../models')
const Op = Sequelize.Op
const { dbConnections } = require('../database/connection')
// temp refactoring faculty <-> userservice mappings away from userservice
const facultiesAndProgrammesInUserService = [
['H10', 'MH10_001'],
['H10', 'MH40_011'],
['H10', 'KH10_001'],
['H20', 'KH20_001'],
['H20', 'MH20_001'],
['H20', 'MH20_002'],
['H30', 'KH30_001'],
['H30', 'KH30_002'],
['H30', 'MH30_002'],
['H30', 'MH30_003'],
['H30', 'MH30_004'],
['H30', 'MH30_005'],
['H30', 'MH30_001'],
['H40', 'KH40_001'],
['H40', 'KH40_002'],
['H40', 'KH40_003'],
['H40', 'KH40_004'],
['H40', 'KH40_005'],
['H40', 'KH40_006'],
['H40', 'MH40_001'],
['H40', 'MH40_002'],
['H40', 'MH40_003'],
['H40', 'MH40_004'],
['H40', 'MH40_005'],
['H40', 'MH40_006'],
['H40', 'MH40_007'],
['H40', 'MH40_008'],
['H40', 'MH40_009'],
['H40', 'MH40_010'],
['H40', 'MH40_011'],
['H40', 'MH40_012'],
['H40', 'MH40_013'],
['H40', 'MH40_014'],
['H40', 'MH40_015'],
['H40', 'MH70_001'],
['H40', 'MH70_006'],
['H50', 'KH50_001'],
['H50', 'KH50_002'],
['H50', 'KH50_003'],
['H50', 'KH50_004'],
['H50', 'KH50_005'],
['H50', 'KH50_006'],
['H50', 'KH50_007'],
['H50', 'MH50_001'],
['H50', 'MH50_002'],
['H50', 'MH50_003'],
['H50', 'MH50_004'],
['H50', 'MH50_005'],
['H50', 'MH50_006'],
['H50', 'MH50_007'],
['H50', 'MH50_008'],
['H50', 'MH50_009'],
['H50', 'MH50_010'],
['H50', 'MH50_011'],
['H50', 'MH50_012'],
['H50', 'MH50_013'],
['H55', 'KH55_001'],
['H55', 'MH55_001'],
['H57', 'KH57_001'],
['H57', 'KH57_002'],
['H57', 'KH57_003'],
['H57', 'MH50_002'],
['H57', 'MH50_013'],
['H57', 'MH57_001'],
['H57', 'MH57_002'],
['H57', 'MH57_003'],
['H57', 'MH57_004'],
['H57', 'MH57_005'],
['H57', 'MH80_007'],
['H60', 'KH60_001'],
['H60', 'MH60_001'],
['H70', 'KH40_001'],
['H70', 'KH50_001'],
['H70', 'KH70_001'],
['H70', 'KH70_002'],
['H70', 'KH70_003'],
['H70', 'KH70_004'],
['H70', 'MH50_001'],
['H70', 'MH57_005'],
['H70', 'MH70_001'],
['H70', 'MH70_002'],
['H70', 'MH70_003'],
['H70', 'MH70_004'],
['H70', 'MH70_005'],
['H70', 'MH70_006'],
['H70', 'MH70_007'],
['H70', 'MH70_008'],
['H70', 'MH70_009'],
['H70', 'MH70_010'],
['H74', 'KH74_001'],
['H80', 'KH57_002'],
['H80', 'KH80_001'],
['H80', 'KH80_002'],
['H80', 'KH80_003'],
['H80', 'KH80_004'],
['H80', 'MH57_002'],
['H80', 'MH57_003'],
['H80', 'MH57_005'],
['H80', 'MH80_001'],
['H80', 'MH80_002'],
['H80', 'MH80_003'],
['H80', 'MH80_004'],
['H80', 'MH80_005'],
['H80', 'MH80_006'],
['H80', 'MH80_007'],
['H90', 'KH90_001'],
['H30', '447000'],
['H30', '447001'],
['H30', '447002'],
['H60', 'EDUK730'],
['H60', 'K-MUUT-ERIL'],
['H60', 'ED400'],
['H60', 'ED200'],
['H60', 'ED700'],
['H60', 'ED100'],
['H60', 'ED300'],
['H60', 'ED600'],
['H60', '8009'],
['H60', '8118'],
['H60', '8600'],
['H60', 'DUK-PED'],
['H60', '65'],
['H60', '1102'],
['H60', '20018'],
['H60', '8601'],
['H60', '9290'],
['H60', '8172'],
['H60', '83600'],
['H60', 'DUM-PED'],
['H60', 'ED500'],
['H60', '83601'],
['H60', '0130'],
['H60', '20005-e'],
['H60', '1040'],
['H60', '8703'],
['H60', '0005'],
['H60', '7900'],
['H60', '0152 '],
['H90', 'MH90_001'],
]
const facultiesAndProgrammesForTrends = facultiesAndProgrammesInUserService.map(f => ({
faculty_code: f[0],
programme_code: f[1],
}))
// Have facultyfetching to work like it worked during oodi-db time
const facultiesInOodi = [
'H10',
@@ -66,4 +214,5 @@ module.exports = {
degreeProgrammeCodesOfFaculty,
isFaculty,
providersOfFaculty,
facultiesAndProgrammesForTrends,
}
@@ -6,7 +6,6 @@ const logger = require('../util/logger')
const { dbConnections } = require('../database/connection')
const recursivelyGetModuleAndChildren = async (code, type) => {
// TODO use only sis connection once sis is stable
const connection = dbConnections.sequelize
try {
const [result] = await connection.query(
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
const { getImporterClient } = require('../util/importerClient')
const importerClient = getImporterClient()
const { StudyGuidanceGroupTag } = require('../models/models_kone')
const getAllStudentsUserHasInGroups = async sisPersonId => {
if (!importerClient) {
@@ -19,10 +20,33 @@ const getAllGroupsAndStudents = async sisPersonId => {
return []
}
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
return Object.values(data)
const tagsByGroupId = (await StudyGuidanceGroupTag.findAll()).reduce((acc, curr) => {
const { studyProgramme, year } = curr
return { ...acc, [curr.studyGuidanceGroupId]: { studyProgramme, year } }
}, {})
return Object.values(data).map(group => ({ ...group, tags: tagsByGroupId[group.id] }))
}
const changeGroupTags = async ({ groupId, tags }) => {
const { studyProgramme, year } = tags
const tagToUpdate = studyProgramme ? { studyProgramme } : { year }
const [result] = await StudyGuidanceGroupTag.upsert(
{
studyGuidanceGroupId: groupId,
...tagToUpdate,
},
{
where: {
studyGuidanceGroupId: groupId,
},
}
)
return result
}
module.exports = {
getAllStudentsUserHasInGroups,
getAllGroupsAndStudents,
changeGroupTags,
}
@@ -2,7 +2,6 @@ const {
dbConnections: { sequelize },
} = require('../database/connection')
const { Studyright, StudyrightElement, ElementDetail } = require('../models')
const { getUserElementDetails } = require('./userService')
const moment = require('moment')
const { redisClient } = require('../services/redis')
const _ = require('lodash')
@@ -140,15 +139,6 @@ const getAllStudyrightElementsAndAssociations = async () => {
return JSON.parse(studyright_elements)
}
const getStudyrightElementsAndAssociationsForUser = async username => {
const studyrightelementcodes = await getUserElementDetails(username)
if (studyrightelementcodes.length === 0) {
return []
}
const associations = await getAssociatedStudyrights(studyrightelementcodes)
return formatStudyrightElements(studyrightelementcodes, associations)
}
const getAllProgrammes = async () => {
const elementDetails = ElementDetail.findAll({
where: {
@@ -324,21 +314,13 @@ const getFilteredAssociations = async codes => {
return associations
}
const getUserAssociations = async userid => {
const codes = await getUserElementDetails(userid)
const associations = await getFilteredAssociations(codes)
return associations
}
module.exports = {
byStudent,
studentNumbersWithAllStudyRightElements,
getAssociatedStudyrights,
getAllStudyrightElementsAndAssociations,
getStudyrightElementsAndAssociationsForUser,
getAssociations,
getFilteredAssociations,
getUserAssociations,
refreshAssociationsInRedis,
getAllProgrammes,
getAllElementDetails,
@@ -1,15 +1,8 @@
const axios = require('axios')
const { redisClient } = require('../../services/redis')
const { USERSERVICE_URL } = require('../../conf-backend')
const {
dbConnections: { sequelize },
} = require('../../database/connection')
const userServiceClient = axios.create({
baseURL: USERSERVICE_URL,
headers: { secret: process.env.USERSERVICE_SECRET },
})
const getRedisCDS = async REDIS_KEY => {
const raw = await redisClient.getAsync(REDIS_KEY)
return raw && JSON.parse(raw)
@@ -177,6 +170,5 @@ const getTargetStudentCounts = async ({ codes, includeOldAttainments, excludeNon
module.exports = {
getRedisCDS,
saveToRedis,
userServiceClient,
getTargetStudentCounts,
}
@@ -4,9 +4,10 @@ const {
} = require('../../database/connection')
const { mapToProviders } = require('../../util/utils')
const { ElementDetail, Organization } = require('../../models')
const { getRedisCDS, saveToRedis, userServiceClient } = require('./shared')
const { getRedisCDS, saveToRedis } = require('./shared')
const { facultiesAndProgrammesForTrends } = require('../../services/organisations')
const REDIS_KEY_STATUS = 'STATUS_DATA_V3'
const REDIS_KEY_STATUS = 'STATUS_DATA_V5'
const getCurrentStudyYearStartDate = _.memoize(
async unixMillis =>
@@ -117,13 +118,13 @@ const getOrganizationStatistics = async (organizations, start, end) => {
if (organizationStats[orgId] === undefined) {
organizationStats[orgId] = {
direct: { credits: 0, students: new Set() },
accumulated: { credits: 0, students: new Set() },
aggregated: { credits: 0, students: new Set() },
}
}
const orgStats = organizationStats[orgId]
const statsRefs = isDirect ? [orgStats.direct, orgStats.accumulated] : [orgStats.accumulated]
const statsRefs = isDirect ? [orgStats.direct, orgStats.aggregated] : [orgStats.aggregated]
statsRefs.forEach(statsRef => {
statsRef.credits += stats.credits
@@ -142,20 +143,20 @@ const getOrganizationStatistics = async (organizations, start, end) => {
updateOrgStats(orgId, { credits, students }, true)
if (parentOrgId) {
updateOrgStats(parentOrgId, organizationStats[orgId].accumulated, false)
updateOrgStats(parentOrgId, organizationStats[orgId].aggregated, false)
}
}
return Object.entries(organizationStats).reduce((acc, [id, { direct, accumulated }]) => {
return Object.entries(organizationStats).reduce((acc, [id, { direct, aggregated }]) => {
acc[id] = {
id,
direct: {
students: direct.students.size,
credits: direct.credits,
},
accumulated: {
students: accumulated.students.size,
credits: accumulated.credits,
aggregated: {
students: aggregated.students.size,
credits: aggregated.credits,
},
}
@@ -177,7 +178,7 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'su
WHERE
cr.attainment_date BETWEEN :a AND :b
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
AND cr.credittypecode IN (4, 9)
AND cr.credittypecode IN (4, 9)
GROUP BY co.id, o.code
-- HAVING SUM(cr.credits) > 0
`,
@@ -188,13 +189,40 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'su
)
}
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2') => {
const getTotalOpenUniCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'sum2') => {
return sequelize.query(
`
SELECT SUM(cr.credits) AS ${alias}, COUNT(DISTINCT(cr.student_studentnumber)) AS ${alias2},
o.code AS organizationcode, co.code, co.id, co.name
FROM credit cr
INNER JOIN course co ON cr.course_code = co.code
INNER JOIN course_providers cp ON cp.coursecode = co.id
INNER JOIN organization o ON o.id = cp.organizationcode
AND cr.attainment_date BETWEEN :a AND :b
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
AND cr.credittypecode IN (4, 9) AND cp.organizationcode = 'hy-org-48645785'
AND cr.student_studentnumber NOT IN
(SELECT student_studentnumber FROM studyright
WHERE studyright.student_studentnumber = cr.student_studentnumber
AND cr.attainment_date BETWEEN studyright.startdate AND studyright.enddate)
GROUP BY co.id, o.code -- HAVING SUM(cr.credits) > 0
`,
{
type: sequelize.QueryTypes.SELECT,
replacements: { a, b },
}
)
}
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2', avoin) => {
return years.map(
year =>
new Promise(async res => {
const diff = currentYear - year
const { from, to } = getRange(diff)
const creditsByCourse = await getTotalCreditsOfCoursesBetween(from, to, alias, alias2)
const creditsByCourse = avoin
? await getTotalOpenUniCreditsOfCoursesBetween(from, to, alias, alias2)
: await getTotalCreditsOfCoursesBetween(from, to, alias, alias2)
res(
creditsByCourse.map(c => {
c['year'] = year
@@ -207,10 +235,37 @@ const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum',
const getOrgStats = (stats, orgId) =>
stats[orgId] ?? {
accumulated: { students: 0, credits: 0 },
direct: { students: 0, credits: 0 },
accumulated: {
aggregated: { students: 0, credits: 0 },
direct: { students: 0, credits: 0 },
},
total: {
aggregated: { students: 0, credits: 0 },
direct: { students: 0, credits: 0 },
},
}
const getOrgsOfOpenUniReqularCounterparts = async codes => {
const rows = await sequelize.query(
`
SELECT co.id, co.code, cp.organizationcode, org.code AS organization
FROM course co, course_providers cp, organization org
WHERE co.code IN(:codes)
AND co.id = cp.coursecode
AND org.id = cp.organizationcode;
`,
{
type: sequelize.QueryTypes.SELECT,
replacements: { codes },
}
)
return rows.reduce((acc, { code, organization }) => {
acc[code] = organization
return acc
}, {})
}
const calculateStatusStatistics = async (unixMillis, showByYear) => {
const YEAR_TO_MILLISECONDS = 31556952000
/* Memoize parses booleans into strings... */
@@ -218,67 +273,135 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
const startYear = startDate.getFullYear()
const startTime = startDate.getTime()
const yearRange = _.range(2017, startYear + 1)
const getRangeAcc = diff => ({
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
to:
showByYear === 'true'
? new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS)
: new Date(unixMillis - diff * YEAR_TO_MILLISECONDS),
})
const getRangeTotal = diff => ({
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
})
const yearlyAccCreditsPromises = makeYearlyCreditsPromises(
startYear,
yearRange,
diff => ({
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
to:
showByYear === 'true'
? new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS)
: new Date(unixMillis - diff * YEAR_TO_MILLISECONDS),
}),
getRangeAcc,
'acc',
'students'
'students',
false
)
const yearlyOpenUniAccCreditsPromises = makeYearlyCreditsPromises(
startYear,
yearRange,
getRangeAcc,
'acc',
'students',
true
)
const yearlyTotalCreditsPromises = makeYearlyCreditsPromises(
startYear,
yearRange.slice(0, -1),
diff => ({
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
}),
getRangeTotal,
'total',
'students'
'students',
false
)
const yearlyOpenUniTotalCreditsPromises = makeYearlyCreditsPromises(
startYear,
yearRange.slice(0, -1),
getRangeTotal,
'total',
'students',
true
)
/* Gather all required data */
const [yearlyAccCredits, yearlyTotalCredits, elementDetails, faculties, { data: facultyProgrammes }] =
await Promise.all([
Promise.all(yearlyAccCreditsPromises),
Promise.all(yearlyTotalCreditsPromises),
ElementDetail.findAll(),
Organization.findAll(),
userServiceClient.get('/faculty_programmes'),
])
const yearlyOrgStatPromises = yearRange.map(async year => [
year,
await getOrganizationStatistics(
faculties,
new Date(startTime - (startYear - year) * YEAR_TO_MILLISECONDS),
new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
),
const [
yearlyAccCredits,
yearlyTotalCredits,
yearlyOpenUniAccCredits,
yearlyOpenUniTotalCredits,
elementDetails,
faculties,
] = await Promise.all([
Promise.all(yearlyAccCreditsPromises),
Promise.all(yearlyTotalCreditsPromises),
Promise.all(yearlyOpenUniAccCreditsPromises),
Promise.all(yearlyOpenUniTotalCreditsPromises),
ElementDetail.findAll(),
Organization.findAll(),
])
const isOpenUniCourseCode = code => code.match(/^AY?(.+?)(?:en|fi|sv)?$/)
const unifyOpenUniversity = code => {
const regexresult = isOpenUniCourseCode(code)
if (!regexresult) return code
return regexresult[1]
}
const fromAyToNormalCourses = [
...new Set(_.flatten(yearlyOpenUniAccCredits).map(c => unifyOpenUniversity(c.code))),
...new Set(_.flatten(yearlyOpenUniTotalCredits).map(c => unifyOpenUniversity(c.code))),
]
const orgsOfOpenUniReqularCounterparts = await getOrgsOfOpenUniReqularCounterparts(fromAyToNormalCourses)
const facultyProgrammes = facultiesAndProgrammesForTrends
const yearlyOrgStatPromises = yearRange.map(async year => {
const accumulatedStats = await getOrganizationStatistics(
faculties,
new Date(startTime - (startYear - year) * YEAR_TO_MILLISECONDS),
showByYear === 'true'
? new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
: new Date(unixMillis - (startYear - year) * YEAR_TO_MILLISECONDS)
)
const totalStats =
year !== startYear &&
(await getOrganizationStatistics(
faculties,
new Date(startTime - (startYear - year) * YEAR_TO_MILLISECONDS),
new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
))
return [
year,
_.merge(
_.mapValues(totalStats, total => ({ total })),
_.mapValues(accumulatedStats, accumulated => ({ accumulated }))
),
]
})
const orgStats = await Promise.all(yearlyOrgStatPromises)
const yearlyOrgStats = Object.fromEntries(orgStats)
const [[, currentOrgStats], [, prevOrgStats]] = orgStats.reverse()
const defaultYearStats = _.chain(_.range(2017, startYear + 1))
.map(year => [year, { acc: 0, accStudents: 0 }])
.fromPairs()
.value()
const getOrgYearlyStats = orgId =>
Object.entries(yearlyOrgStats).reduce((acc, [year, stats]) => {
const { credits, students } = getOrgStats(stats, orgId).accumulated
_.chain(yearlyOrgStats)
.mapValues(stats => {
const orgStats = getOrgStats(stats, orgId)
acc[year] = {
acc: credits,
accStudents: students,
total: credits,
totalStudents: students,
}
return acc
}, {})
return {
acc: orgStats.accumulated.aggregated.credits,
accStudents: orgStats.accumulated.aggregated.students,
total: orgStats.total?.aggregated?.credits,
totalStudents: orgStats.total?.aggregated?.students,
}
})
.defaults(defaultYearStats)
.value()
/* Construct some helper maps */
const facultyCodeToFaculty = faculties.reduce((res, curr) => {
@@ -292,6 +415,8 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return res
}, {})
programmeToFaculties['H906'] = ['H906']
const providerToProgramme = elementDetails.reduce((res, curr) => {
const [p] = mapToProviders([curr.code])
res[p] = {
@@ -301,26 +426,66 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return res
}, {})
providerToProgramme['H906'] = {
code: 'H906',
name: { en: 'Language Centre', fi: 'Kielikeskus', sv: 'Språkcentrum' },
}
providerToProgramme['H930'] = {
code: 'H930',
name: { en: 'Open University', fi: 'Avoin yliopisto', sv: 'Öppna universitetet' },
}
const organizationCodeToOrganization = faculties.reduce((acc, org) => {
acc[org.code] = org
return acc
}, {})
const sumMerge = (a, b) => {
if (!a) return b
if (!b) return a
return a + b
}
const mergele = (a, b) => {
if (!a) return _.clone(b)
if (!b) return _.clone(a)
return _.mergeWith(a, b, sumMerge)
}
const refactoredOpenUniCredits = [..._.flatten(yearlyOpenUniAccCredits), ..._.flatten(yearlyOpenUniTotalCredits)].map(
ele => {
const org = orgsOfOpenUniReqularCounterparts[unifyOpenUniversity(ele.code)]
return {
...ele,
organizationcode: org ? org : 'H930',
}
}
)
/* Calculate course level stats and group by providers */
const coursesGroupedByProvider = Object.entries(
_.groupBy([..._.flatten(yearlyAccCredits), ..._.flatten(yearlyTotalCredits)], 'organizationcode')
).reduce((acc, [organizationcode, courseCredits]) => {
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
(acc, [courseCode, yearlyInstances]) => {
acc[courseCode] = { yearly: {}, name: yearlyInstances[0].name }
const array = [2020, 2019, 2018, 2017]
acc[courseCode] = {
yearly: {},
name: yearlyInstances[0].name,
type: 'course',
code: courseCode,
}
const array = _.range(2017, startYear + 1)
array.forEach(year => {
acc[courseCode]['yearly'][year] = {}
acc[courseCode]['yearly'][year]['acc'] = 0
acc[courseCode]['yearly'][year]['accStudents'] = 0
acc[courseCode]['yearly'][year]['total'] = 0
acc[courseCode]['yearly'][year]['totalStudents'] = 0
acc[courseCode]['yearly'][year]['total'] = undefined
acc[courseCode]['yearly'][year]['totalStudents'] = undefined
})
yearlyInstances.forEach(instance => {
if (!acc[courseCode]['yearly'][instance.year]) acc[courseCode]['yearly'][instance.year] = {}
@@ -332,6 +497,52 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
acc[courseCode]['yearly'][instance.year]['totalStudents'] = Number(instance.students)
}
})
acc[courseCode]['current'] = _.get(acc, [courseCode, 'yearly', startYear, 'acc']) || 0
acc[courseCode]['previous'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'acc']) || 0
acc[courseCode]['currentStudents'] = _.get(acc, [courseCode, 'yearly', startYear, 'accStudents']) || 0
acc[courseCode]['previousStudents'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'accStudents']) || 0
return acc
},
{}
)
return acc
}, {})
const openUniCoursesGroupedByProvider = Object.entries(
_.groupBy(refactoredOpenUniCredits, 'organizationcode')
).reduce((acc, [organizationcode, courseCredits]) => {
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
(acc, [courseCode, yearlyInstances]) => {
acc[courseCode] = {
yearly: {},
name: yearlyInstances[0].name,
type: 'course',
code: courseCode,
}
const array = _.range(2017, startYear + 1)
array.forEach(year => {
acc[courseCode]['yearly'][year] = {}
acc[courseCode]['yearly'][year]['acc'] = 0
acc[courseCode]['yearly'][year]['accStudents'] = 0
acc[courseCode]['yearly'][year]['total'] = undefined
acc[courseCode]['yearly'][year]['totalStudents'] = undefined
})
yearlyInstances.forEach(instance => {
if (!acc[courseCode]['yearly'][instance.year]) acc[courseCode]['yearly'][instance.year] = {}
if (instance.acc !== undefined) {
acc[courseCode]['yearly'][instance.year]['acc'] = instance.acc
acc[courseCode]['yearly'][instance.year]['accStudents'] = Number(instance.students)
} else {
acc[courseCode]['yearly'][instance.year]['total'] = instance.total
acc[courseCode]['yearly'][instance.year]['totalStudents'] = Number(instance.students)
}
})
acc[courseCode]['current'] = _.get(acc, [courseCode, 'yearly', startYear, 'acc']) || 0
acc[courseCode]['previous'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'acc']) || 0
acc[courseCode]['currentStudents'] = _.get(acc, [courseCode, 'yearly', startYear, 'accStudents']) || 0
@@ -348,44 +559,111 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
const programme = providerToProgramme[organizationcode]
if (programme && programme.code) {
if (programme.code === 'H906') {
const courseValues = Object.values(courses)
const yearlyValues = courseValues.map(c => c.yearly)
acc[programme.code] = {
type: 'programme',
code: programme.code,
name: programme.name,
drill: courses,
yearly: _.mergeWith({}, ...yearlyValues, mergele),
current: _.sumBy(courseValues, 'current') | undefined,
previous: _.sumBy(courseValues, 'previous') | undefined,
currentStudents: _.sumBy(courseValues, 'currentStudents') || 'undefined',
previousStudents: _.sumBy(courseValues, 'previousStudents') | 'undefined',
}
return acc
}
const orgId = organizationCodeToOrganization[organizationcode]?.id
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated.aggregated
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated.aggregated
const yearly = getOrgYearlyStats(orgId)
acc[programme.code] = {
type: 'programme',
code: programme.code,
name: programme.name,
drill: courses,
yearly,
current,
previous,
currentStudents,
previousStudents,
currentStudents: currentStudents,
previousStudents: previousStudents,
}
}
return acc
}, {})
const openUniGroupedByProgramme = Object.entries(openUniCoursesGroupedByProvider).reduce(
(acc, [organizationcode, courses]) => {
const programme = providerToProgramme[organizationcode]
if (programme && programme.code) {
const courseValues = Object.values(courses)
const yearlyValues = courseValues.map(c => c.yearly)
acc[programme.code] = {
type: 'programme',
code: programme.code,
name: programme.name,
drill: courses,
yearly: _.mergeWith({}, ...yearlyValues, mergele),
current: _.sumBy(courseValues, 'current') | undefined,
previous: _.sumBy(courseValues, 'previous') | undefined,
currentStudents: _.sumBy(courseValues, 'currentStudents') || 'undefined',
previousStudents: _.sumBy(courseValues, 'previousStudents') | 'undefined',
}
return acc
}
return acc
},
{}
)
/* Group programmes into faculties and calculate faculty level stats */
const groupedByFaculty = Object.entries(groupedByProgramme).reduce((acc, [programmeCode, programmeStats]) => {
const facultyCodes = programmeToFaculties[programmeCode]
if (!facultyCodes) return acc
facultyCodes.forEach(facultyCode => {
if (!facultyCode) return
if (facultyCode === 'H906' || facultyCode === 'H930') {
if (!acc[facultyCode]) {
acc[facultyCode] = {
type: 'faculty',
code: facultyCode,
drill: {},
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
yearly: programmeStats.yearly,
current: programmeStats.current,
previous: programmeStats.previous,
currentStudents: programmeStats.currentStudents,
previousStudents: programmeStats.previousStudents,
}
}
}
if (!acc[facultyCode]) {
const orgId = organizationCodeToOrganization[facultyCode].id
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated
.aggregated
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated
.aggregated
const yearly = getOrgYearlyStats(orgId)
acc[facultyCode] = {
type: 'faculty',
code: facultyCode,
drill: {},
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
yearly,
@@ -401,7 +679,31 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return acc
}, {})
return groupedByFaculty
const openUniGroupedByFaculty = Object.entries(openUniGroupedByProgramme).reduce(
(acc, [programmeCode, programmeStats]) => {
const facultyCode = 'H930'
if (!acc[facultyCode]) {
acc[facultyCode] = {
type: 'faculty',
code: facultyCode,
drill: {},
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
yearly: {},
current: 0,
previous: 0,
}
}
acc[facultyCode]['drill'][programmeCode] = programmeStats
acc[facultyCode]['yearly'] = _.mergeWith(acc[facultyCode]['yearly'], programmeStats.yearly, mergele)
acc[facultyCode]['current'] += programmeStats.current
acc[facultyCode]['previous'] += programmeStats.previous
return acc
},
{}
)
return { ...groupedByFaculty, ...openUniGroupedByFaculty }
}
const getStatus = async (unixMillis, showByYear, doRefresh = false) => {
+96 -108
View File
@@ -1,24 +1,42 @@
const axios = require('axios')
const _ = require('lodash')
const { USERSERVICE_URL } = require('../conf-backend')
const UnitService = require('./units')
const elementDetailService = require('./elementdetails')
const { filterStudentnumbersByAccessrights } = require('./students')
const { userDataCache } = require('./cache')
const client = axios.create({ baseURL: USERSERVICE_URL, headers: { secret: process.env.USERSERVICE_SECRET } })
const { getImporterClient } = require('../util/importerClient')
const logger = require('../util/logger')
const { facultiesAndProgrammesForTrends } = require('../services/organisations')
const ping = async () => {
const url = '/ping'
const response = await axios.get(url)
return response.data
const client = axios.create({ baseURL: USERSERVICE_URL, headers: { secret: process.env.USERSERVICE_SECRET } })
// Private helpers
const enrichProgrammesFromFaculties = faculties =>
facultiesAndProgrammesForTrends.filter(f => faculties.includes(f.faculty_code)).map(f => f.programme_code)
const enrichWithProgrammes = toEnrich => {
if (Array.isArray(toEnrich)) {
return toEnrich.map(obj => enrichWithProgrammes(obj))
}
if (toEnrich.rights) {
return {
...toEnrich,
rights: _.union(toEnrich.rights, enrichProgrammesFromFaculties(toEnrich.faculties)),
}
}
if (toEnrich.elementdetails) {
return {
...toEnrich,
elementdetails: _.union(
toEnrich.elementdetails,
enrichProgrammesFromFaculties(toEnrich.faculty.map(f => f.faculty_code))
),
}
}
return toEnrich
}
const findAll = async () => {
const response = await client.get('/findall')
return response.data
}
const checkStudyGuidanceGroupsAccess = async hyPersonSisuId => {
if (!hyPersonSisuId) {
logger.error('Not possible to get groups without personId header')
@@ -30,101 +48,20 @@ const checkStudyGuidanceGroupsAccess = async hyPersonSisuId => {
return data && Object.values(data).length > 0
}
const login = async (uid, full_name, hyGroups, affiliations, email, hyPersonSisuId) => {
const hasStudyGuidanceGroupAccess = await checkStudyGuidanceGroupsAccess(hyPersonSisuId)
const response = await client.post('/login', {
uid,
full_name,
hyGroups,
affiliations,
email,
hyPersonSisuId,
hasStudyGuidanceGroupAccess,
})
return response.data
}
const superlogin = async (uid, asUser) => {
const response = await client.post('/superlogin', {
uid,
asUser,
})
return response.data
}
const byUsername = async uid => {
const url = `/user/${uid}`
const byUsernameData = async username => {
// OK, enriched
const url = `/user/${username}/user_data`
const response = await client.get(url)
return response.data
return enrichWithProgrammes(response.data)
}
const byUsernameData = async uid => {
const url = `/user/${uid}/user_data`
const response = await client.get(url)
return response.data
}
const getUserElementDetails = async username => {
const url = `/user/elementdetails/${username}`
const response = await client.get(url)
const elementdetailcodes = response.data
return elementDetailService.byCodes(elementdetailcodes)
}
const byId = async id => {
const url = `/user/id/${id}`
const response = await client.get(url)
return response.data
}
const updateUser = async (uid, fields) => {
userDataCache.del(uid)
const url = `/user/${uid}`
const response = await client.put(url, fields)
return response.data
}
const getRolesFor = async user => {
const url = `/get_roles/${user}`
const response = await client.get(url)
return response.data
}
const enableElementDetails = async (uid, codes) => {
const response = await client.post('/add_rights', { uid, codes })
return response.data.user
}
const removeElementDetails = async (uid, codes) => {
const response = await client.post('/remove_rights', { uid, codes })
return response.data.user
}
const setFaculties = async (uid, faculties) => {
const response = await client.post('/set_faculties', { uid, faculties })
return response.data.user
}
const getUnitsFromElementDetails = async username => {
const elementDetails = await getUserElementDetails(username)
return elementDetails.map(element => UnitService.parseUnitFromElement(element))
}
const modifyAccess = async body => {
const response = await client.post('/modifyaccess', body)
return response.data
}
const getAccessGroups = async () => {
const response = await client.get('/access_groups')
return response.data
}
const getUserDataFor = async uid => {
let userData = userDataCache.get(uid)
// Exported helpers
const getUserDataFor = async username => {
// no need to enrich
let userData = userDataCache.get(username)
if (!userData) {
userData = await byUsernameData(uid)
userDataCache.set(uid, userData)
userData = await byUsernameData(username)
userDataCache.set(username, userData)
}
return {
@@ -133,9 +70,21 @@ const getUserDataFor = async uid => {
}
}
const getUnitsFromElementDetails = async username => {
// No need to enrich
let userData = userDataCache.get(username)
if (!userData) {
userData = await byUsernameData(username)
userDataCache.set(username, userData)
}
const elementDetails = await elementDetailService.byCodes(userData.rights)
return elementDetails.map(element => UnitService.parseUnitFromElement(element))
}
const getStudentsUserCanAccess = async (studentnumbers, roles, userId) => {
// No need to enrich
let studentsUserCanAccess
if (roles && roles.includes('admin')) {
if (roles?.includes('admin')) {
studentsUserCanAccess = new Set(studentnumbers)
} else {
const unitsUserCanAccess = await getUnitsFromElementDetails(userId)
@@ -145,12 +94,52 @@ const getStudentsUserCanAccess = async (studentnumbers, roles, userId) => {
return studentsUserCanAccess
}
// FOR ROUTES 1-to-1 mapping
const modifyAccess = async body => enrichWithProgrammes((await client.post('/modifyaccess', body)).data)
const getAccessGroups = async () => enrichWithProgrammes((await client.get('/access_groups')).data)
const enableElementDetails = async (uid, codes) =>
enrichWithProgrammes((await client.post('/add_rights', { uid, codes })).data.user)
const findAll = async () => enrichWithProgrammes((await client.get('/findall')).data)
const setFaculties = async (uid, faculties) =>
enrichWithProgrammes((await client.post('/set_faculties', { uid, faculties })).data.user)
const removeElementDetails = async (uid, codes) =>
enrichWithProgrammes((await client.post('/remove_rights', { uid, codes })).data.user)
const updateUser = async (username, fields) => {
userDataCache.del(username)
return enrichWithProgrammes((await client.put(`/user/${username}`, fields)).data)
}
const login = async (uid, full_name, hyGroups, affiliations, email, hyPersonSisuId) => {
const hasStudyGuidanceGroupAccess = await checkStudyGuidanceGroupsAccess(hyPersonSisuId)
return (
await client.post('/login', {
uid,
full_name,
hyGroups,
affiliations,
email,
hyPersonSisuId,
hasStudyGuidanceGroupAccess,
})
).data
}
const superlogin = async (uid, asUser) =>
(
await client.post('/superlogin', {
uid,
asUser,
})
).data
module.exports = {
ping,
byUsername,
updateUser,
byId,
getUserElementDetails,
login,
superlogin,
enableElementDetails,
@@ -159,7 +148,6 @@ module.exports = {
modifyAccess,
getAccessGroups,
getUnitsFromElementDetails,
getRolesFor,
setFaculties,
getUserDataFor,
getStudentsUserCanAccess,
+4 -4
View File
@@ -1,17 +1,17 @@
const axios = require('axios')
const { importerToken, importerUrl } = require('../conf-backend')
const { importerToken } = require('../conf-backend')
const logger = require('./logger')
const importerClient = axios.create({
headers: {
token: importerToken,
},
baseURL: importerUrl,
baseURL: 'https://importer.cs.helsinki.fi',
})
const getImporterClient = () => {
if (!importerToken || !importerUrl) {
logger.error("Importer token or importer url not set, can't return client!")
if (!importerToken) {
logger.error("Importer token not set, can't return client!")
return null
}
return importerClient
+7 -12
View File
@@ -1,15 +1,10 @@
const winston = require('winston')
const path = require('path')
const { isProduction, PWD } = require('../conf-backend')
const { isDev } = require('../conf-backend')
const { combine, timestamp, printf, splat } = winston.format
const transports = []
// Write logfile to docker containers pwd
const filename = path.join(PWD, 'debug.log')
transports.push(new winston.transports.File({ filename, level: 'debug' }))
let transports = []
if (!isProduction) {
if (isDev) {
const devFormat = printf(
({ level, message, timestamp, ...rest }) => `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
)
@@ -20,9 +15,7 @@ if (!isProduction) {
format: combine(splat(), timestamp(), devFormat),
})
)
}
if (isProduction) {
} else {
const levels = {
error: 0,
warn: 1,
@@ -40,9 +33,11 @@ if (isProduction) {
})
)
transports.push(new winston.transports.Console({ format: prodFormat, level: 'info' }))
transports.push(new winston.transports.Console({ format: prodFormat }))
}
const logger = winston.createLogger({ transports })
logger.on('error', e => console.error('Logging failed! Reason: ', e)) // eslint-disable-line no-console
module.exports = logger
+3 -2
View File
@@ -1,7 +1,8 @@
{
"root": true,
"env": {
"browser": true
"browser": true,
"node": true
},
"parser": "@babel/eslint-parser",
"parserOptions": {
@@ -30,7 +31,7 @@
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/prefer-stateless-function": "error",
"import/no-unresolved": "error",
"import/no-unresolved": "off",
"react/jsx-no-bind": "error",
"prefer-object-spread/prefer-object-spread": "error",
"babel/no-invalid-this": "error",
+6
View File
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"baseUrl": "src"
},
"include": ["src"]
}
+113 -22562
View File
File diff suppressed because it is too large Load Diff
+4 -7
View File
@@ -6,13 +6,14 @@
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build"
},
"dependencies": {
"@reduxjs/toolkit": "^1.6.2",
"@sentry/browser": "^6.9.0",
"@sentry/tracing": "^6.11.0",
"axios": "^0.21.1",
"axios": "^0.21.2",
"formik": "^2.2.9",
"highcharts": "^9.1.2",
"highcharts-custom-events": "^3.0.10",
"history": "^4.10.1",
"jstat": "^1.9.5",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"moment": "^2.29.1",
@@ -22,18 +23,14 @@
"qs": "^6.10.1",
"query-string": "^6.14.1",
"react": "^16.14.0",
"react-context-devtool": "^2.0.3",
"react-datetime": "^3.0.4",
"react-dom": "^16.14.0",
"react-highcharts": "^16.1.0",
"react-markdown": "^6.0.2",
"react-redux": "^7.2.4",
"react-redux": "^7.2.6",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"react-scroll-to-component": "^1.0.2",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^2.0.3",
"unfuck-spa-shibboleth-session": "^3.1.3",
-5
View File
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import FilterContextProvider from './components/FilterTray/FilterContextProvider'
import { LanguageProvider } from './components/LanguagePicker/useLanguage'
@@ -9,8 +8,4 @@ const CommonContext = ({ children }) => (
</LanguageProvider>
)
CommonContext.propTypes = {
children: PropTypes.node.isRequired,
}
export default CommonContext
+45 -17
View File
@@ -1,9 +1,11 @@
import axios from 'axios'
import * as Sentry from '@sentry/browser'
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { getMocked, setMocking } from '../common'
import { apiBasePath, isDev } from '../conf'
import { loginPrefix, logoutPrefix } from '../redux/auth'
const getHeaders = () => {
export const getHeaders = () => {
// Set up dev user for development environment, mimicking production admin user
const devUserHeaders = {
uid: 'mluukkai',
@@ -19,16 +21,16 @@ const getHeaders = () => {
export const api = axios.create({ baseURL: apiBasePath, headers: getHeaders() })
const types = {
attempt: prefix => `${prefix}ATTEMPT`,
failure: prefix => `${prefix}FAILURE`,
success: prefix => `${prefix}SUCCESS`,
export const actionSuffixes = {
attempt: `ATTEMPT`,
failure: `FAILURE`,
success: `SUCCESS`,
}
export const actionTypes = prefix => ({
attempt: types.attempt(prefix),
failure: types.failure(prefix),
success: types.success(prefix),
attempt: `${prefix}${actionSuffixes.attempt}`,
failure: `${prefix}${actionSuffixes.failure}`,
success: `${prefix}${actionSuffixes.success}`,
})
export const logout = async () => {
@@ -69,7 +71,8 @@ export const callController = (route, prefix, data, method = 'get', query, param
params,
onProgress,
}
return { type: `${prefix}ATTEMPT`, requestSettings }
const { attempt } = actionTypes(prefix)
return { type: attempt, requestSettings }
}
const handleError = (err, actionHistory = []) => {
@@ -90,20 +93,24 @@ export const handleRequest = store => next => async action => {
const { requestSettings } = action
if (requestSettings) {
const { route, method, data, prefix, query, params, onProgress } = requestSettings
const { success, failure } = actionTypes(prefix)
try {
const res = await callApi(route, method, data, params, 0, onProgress)
store.dispatch({ type: `${prefix}SUCCESS`, response: res.data, query })
store.dispatch({ type: success, response: res.data, query })
} catch (e) {
store.dispatch({ type: `${prefix}FAILURE`, response: e, query })
store.dispatch({ type: failure, response: e, query })
handleError(e, store.getState().actionHistory)
}
}
}
export const handleAuth = store => next => async action => {
const loginTypes = actionTypes(loginPrefix)
const logoutTypes = actionTypes(logoutPrefix)
next(action)
const { type } = action
if (type === 'LOGIN_ATTEMPT') {
if (type === loginTypes.attempt) {
const { success, failure } = loginTypes
try {
let token
const mocked = getMocked()
@@ -118,18 +125,39 @@ export const handleAuth = store => next => async action => {
...api.defaults.headers.common,
'x-access-token': token,
}
store.dispatch({ type: 'LOGIN_SUCCESS', token })
store.dispatch({ type: success, token })
} catch (err) {
store.dispatch({ type: 'LOGIN_FAILURE' })
store.dispatch({ type: failure })
handleError(err, store.getState().actionHistory)
}
} else if (type === 'LOGOUT_ATTEMPT') {
} else if (type === loginTypes.attempt) {
const { success, failure } = logoutTypes
try {
await logout()
store.dispatch({ type: 'LOGOUT_SUCCESSFUL' })
store.dispatch({ type: success })
} catch (err) {
store.dispatch({ type: 'LOGOUT_FAILURE' })
store.dispatch({ type: failure })
handleError(err, store.getState().actionHistory)
}
}
}
// Redux-toolkit query based API
// Api can be extended with enhanceEndpoints method
// All tags used for invalidating cache must be defined here
export const RTKApi = createApi({
reducerPath: 'api',
tagTypes: ['StudyGuidanceGroups'],
baseQuery: fetchBaseQuery({
baseUrl: apiBasePath,
prepareHeaders: (headers, { getState }) => {
// Get token from state and add it to headers
const token = getState().auth.encodedToken
if (token) headers.set('x-access-token', token)
// Add possible default headers
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
return headers
},
}),
endpoints: () => ({}),
})
@@ -57,17 +57,7 @@ export default {
Nuoli kuvaa muutosta, paljonko opintopisteitä on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna. Vain suoritetut ja hyväksiluetut opintopisteet huomioidaan.
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä.
Valinta "Näytä edelliset vuodet" näyttää vuosittaisen opintopiste-kertymän tähän päivään
mennessä vuonna X sekä koko lukuvuoden X opintopisteet muodossa "kerääntymä vuonna X / koko lukuvuoden X opintopisteet".
Valinta "Näytä kalenterivuosittain" laskee kertymän kalenterivuoden alusta,
eikä lukuvuoden alusta.
Kohdasta "Näytä päivänä" voi valita tarkastelupäivän, joka on oletusarvoisesti tämänhetkinen päivä.
Avoimen yliopiston suorituksia ei vielä lasketa mukaan.
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä. Mahdolliset valinnat ovat selitettyinä alla.
`,
statusGraduated: `
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien ja ohjelmien valmistumiset nykyisen lukuvuoden aikana
@@ -19,16 +19,28 @@ export default {
X-akselin skaalaa voi säätää harmaista painikkeista ”1m”, ”3m”, ”6m”, ”YTD”, ”1y” ja ”ALL” tai kuvaajan alla olevan sinisen näkymän harmaita ”kahvoja” siirtämällä.
Y-akselin skaalaa voi säätää harmaista painikkeista ”small” ”medium” ja "large”`,
CreditStatistics: `**Opintopistekertymä**
Taulukon rivit skaalautuvat valitun tarkastelujakson mukaan.
Jos tarkastelujaksoksi valitsee 12 kuukautta, on ylin kategoria 55 opintopistettä tai yli, eli opiskelija etenee tavoiteajassa.
Taulukosta näkee silmäyksellä tavoiteajassa etenevät (ylin kategoria) ja esim. nollasuorittajat.
**Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.**
CreditStatistics: `
**Credits gained**
Taulukko kertoo opintopistekertymän valitulle opiskelijapopulaatiolle. Opintopisteluokat on suhteutettu tarkasteltavan populaation aloitusvuoteen.
Ylin luokka kertoo siksi aina tavoiteajassa etenevien määrän ja suhteellisen osuuden.
Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.
Kategorioita voi myös itsessään käyttää suodattimina. Klikkaamalla rivin vasemmassa reunassa olevaa suodatinta jäävät näkyviin vain sen kategorian opiskelijat.
Suodatin aukeaa samalla ikkunan vasempaan laitaan, josta sitä voi käyttää vapaavalintaisilla arvoilla.
**Credit statistics**
Taulukko kertoo kaikkien tarkasteluun valittujen opiskelijoiden opintopistemääristä tähän päivään mennessä. Statistiikka on suodatettu käytettyjen filttereiden mukaan.
Esimerkiksi, jos mukaan on valittu vain naisopiskelijat, statistiikat koskevat vain heitä.
+ **Total** on kokonaisopintopistemäärä kyseiselle populaatiolle.
+ **Mean** kuvaa opintopistemäärän opiskelijakohtaista keskiarvoa.
+ **Keskihajonta** kuvaa vaihtelua opintopistemäärissä. Karkeasti, mitä suurempi keskihajonta, sitä enemmän opintopistemäärissä on vaihtelua opiskelijoiden kesken.
+ **Min ja Max** kertovat pienimmän ja suurimman opintopistemäärän, jotka jollain opiskelijoilla kyseisessä populaatiossa on.
Mikäli valintatapatieto kyseiselle populaatiolle on saatavissa, näytetään myös samat statistiikat per valintatapa.
`,
CoursesOfPopulation: `**Courses of population**
+5
View File
@@ -224,6 +224,7 @@ export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
export const isNewHYStudyProgramme = code => !!(code && code.match(/^[A-Z]*[0-9]*_[0-9]*$/))
// These are the new Bachelor's programmes in Matlu, that have BH possibility
export const bachelorHonoursProgrammes = [
'KH50_001',
'KH50_002',
@@ -235,6 +236,8 @@ export const bachelorHonoursProgrammes = [
'KH50_008',
]
// Basic modules from Sisu programme structures. All modules that can be found for the past years' structures
// taken into account
export const bachelorHonoursBasicModules = {
KH50_001: ['MAT110', 'MAT120', 'MAT130', 'MAT120', 'TKT1'],
KH50_002: ['FYS1100', 'FYS1200'],
@@ -246,6 +249,8 @@ export const bachelorHonoursBasicModules = {
KH50_008: ['BSCH1000', 'BSCS1000', 'BSMA1000', 'BSMS1000', 'BSPH1000'],
}
// Similarly intermediate modules from Sisu programme structures. All modules that can be found for the past years' structures
// taken into account
export const bachelorHonoursIntermediateModules = {
KH50_001: ['mat217', 'MAT210', 'MAT220', 'MAT240', 'MAT213', 'MAT230', 'MAT218', 'MAT011'],
KH50_002: ['FYS2100', 'FYS2200', 'FYS2300', 'FYS2400', 'FYS2500', 'FYS2600'],
@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react'
import { connect } from 'react-redux'
import { Segment, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Divider, Segment, Button, Popup, Loader, Icon, Checkbox, Form, Breadcrumb } from 'semantic-ui-react'
import _ from 'lodash'
import moment from 'moment'
import ReactMarkdown from 'react-markdown'
import Datetime from 'react-datetime'
import { useHistory, useLocation } from 'react-router-dom'
import TSA from '../../common/tsa'
import { getTextIn } from '../../common'
import { useLocalStorage } from '../../common/hooks'
@@ -27,7 +28,77 @@ const mapValueToRange = (x, min1, max1, min2, max2) => {
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
}
const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, showRelativeValues, showByYear }) => {
const settingDefinitions = {
showByYear: {
label: 'Näytä kalenterivuosittain',
short: 'Näytä tilastot kalenterivuosittain lukuvuosien sijasta.',
long: `
Kun tämä valinta on käytössä, vuosittaiset ajanjaksot lasketaan kalenterivuoden alusta sen loppuun.
Muulloin vuosittaiset ajanjaksot lasketaan lukukauden alusta seuraavan lukukauden alkuun.
`,
defaultValue: false,
},
showYearlyValues: {
label: 'Näytä edelliset vuodet',
short: 'Näytä tilastot vuosittain, alkaen vuodesta 2017.',
long: `
Näyttää tilastot vuodesta 2017 eteenpäin.
Jokaiselta vuodelta näytetään kaksi tilastoa: ajanjakson kokonaistilasto ja ns. tähän mennessä "*kertynyt*" tilasto.
Kokonaistilasto vastaa nimensä mukaisesti koko ajanjaksoa, kun taas *kertynyt* tilasto kattaa ajanjakson vuoden
tai lukuvuoden alusta "Näytä päivänä"-valintaa vastaavaan päivämäärään tuona vuotena. Luvut näytetään muodossa *<kerynyt>*/*<kokonais>*.
Kuluvalta vuodelta näytetään ainoastaan kertynyt tilasto.
`,
defaultValue: false,
},
showRelativeValues: {
label: 'Näytä suhteutettuna opiskelijoiden määrään',
short:
'Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.',
long: `
Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.
Opiskelijoiden määrä perustuu ajanjaksolla kyseisen organisaation alaisista kursseista suoritusmerkintöjä saaneiden opiskelijoiden määrään.
Luku siis sisältää muutkin kuin kyseiseen ohjelman tai osaston opinto-oikeuden omaavat opiskelijat.
`,
defaultValue: false,
},
showCountingFrom: {
label: 'Näytä päivänä',
short: 'Valitse päivä johon asti kertyneet tilastot näytetään.',
long: `
Tämä valinta määrittää päivämäärän, jota käyttäen kertyneet tilastot lasketaan.
Esimerkiksi "Näytä kalenterivuosittain" valinnan ollessa pois päältä,
lasketaan kertyneet tilastot (vrt. lukuvuosien kokonaistilastot) kunkin lukuvuoden alusta
tätä päivämäärää vastaavaan päivään kyseisenä lukuvuonna.
`,
defaultValue: null,
},
showStudentCounts: {
label: 'Näytä kurssien opiskelijamäärät',
short: 'Näyttää suoritettujen opintopisteiden sijasta opiskelijoiden määrät kurssitason näkymässä.',
long: `
Oletuksena kurssitason näkymässä näytetään organisaatio- ja ohjelmatason näkymien tapaan suoritettujen opintopisteiden
kokonaismäärä. Kun tämä valinta on käytössä, tämän sijasta näytettävät luvut vastaavat kurssin suorittaneiden
*yksilöityjen* opiskelijoiden määrää. Kukin opiskelija lasketaan siis vain kerran tähän tilastoon.
`,
defaultValue: false,
},
}
const StatusContainer = ({
stats,
handleClick,
min1,
max1,
showYearlyValues,
showRelativeValues,
showByYear,
showStudentCounts,
}) => {
const title = getTextIn(stats.name)
const clickable = !!stats.drill
@@ -64,10 +135,22 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
signDisplay: 'always',
})
const roundToPrecision = (value, precision) => Math.round(value * 10 ** precision) / 10 ** precision
const getDisplayValue = (value, denominator) => {
let displayValue
const getDisplayValue = (value, denominator) =>
showRelativeValues ? roundToPrecision(denominator === 0 ? 0 : value / denominator, 2) : value
if (denominator === 0) {
displayValue = value === 0 ? 0 : Infinity
} else if (showRelativeValues) {
displayValue = value / denominator
} else {
displayValue = value
}
return displayValue.toLocaleString('fi', {
minimumFractionDigits: showRelativeValues ? 2 : 0,
maximumFractionDigits: showRelativeValues ? 2 : 0,
})
}
return (
<Segment
@@ -94,12 +177,16 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
overflowWrap: 'break-word',
}}
>
<span className="status-title">{title}</span>
<Popup position="bottom center" size="tiny" trigger={<span className="status-title">{title}</span>}>
{stats.code}
</Popup>
<div style={{ margin: '10px 0' }}>
<Icon
style={{
color: getColor(change),
transform: `rotateZ(${-mapValueToRange(change * 100, min1, max1, -45, 45)}deg)`,
transitionProperty: 'transform',
transitionDuration: '200ms',
}}
size="huge"
name="arrow right"
@@ -115,31 +202,34 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
</div>
</div>
{showYearlyValues && (
<div className="years">
<div className={`years item-type-${stats.type}`}>
{_.orderBy(Object.entries(stats.yearly), ([y]) => y, ['desc']).map(([year, yearStats]) => {
return (
<span style={{ display: 'contents' }} className="year-row">
<b>
<span className="year-row">
<b className="year-label">
{year}
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
</b>
{stats.type === 'course' ? (
{stats.type === 'course' && showStudentCounts ? (
<>
<span className="year-value">{yearStats.accStudents}</span>
{!!yearStats.totalStudents && (
<>
<span className="separator">{yearStats.totalStudents !== undefined && '/'}</span>
<span className="year-value">{yearStats.totalStudents}</span>
</>
)}
<span className="unit">students</span>
</>
) : (
<>
<span
className="year-value"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: `${getDisplayValue(yearStats.acc, yearStats.accStudents)
.toLocaleString('fi', { minimumFractionDigits: 2 })
.replace(/,?0*$/, match => `<span class="decimals">${match}</span>`)}`,
}}
/>
<span className="unit">credits{showRelativeValues ? '/student' : ''}</span>
<span className="year-value accumulated">
{getDisplayValue(yearStats.acc, yearStats.accStudents)}
</span>
<span className="separator">{yearStats.total !== undefined && '/'}</span>
<span className="year-value total">
{yearStats.total !== undefined && getDisplayValue(yearStats.total, yearStats.totalStudents)}
</span>
</>
)}
</span>
@@ -151,185 +241,22 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
)
}
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
const StatusContent = ({ data, settings, onDrill }) => {
const { showByYear, showRelativeValues, showYearlyValues, showStudentCounts } = settings
const Status = ({ getStatusDispatch, data, loading }) => {
const DATE_FORMAT = 'DD.MM.YYYY'
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', true)
const [showByYear, setShowByYear] = useLocalStorage('showByYear', false)
const [showRelativeValues, setShowRelativeValues] = useLocalStorage('showRelativeValues', false)
const [drillStack, setDrillStack] = useState([])
const [showSettings, setShowSettings] = useState(true)
const [selectedDate, setSelectedDate] = useState(moment())
const [codes, setCodes] = useState([])
const { CoolDataScience } = InfoToolTips
const isValidDate = d => moment.isMoment(d) && moment().diff(d) > 0
useEffect(() => {
if (selectedDate && isValidDate(selectedDate)) {
getStatusDispatch({ date: selectedDate.valueOf(), showByYear })
}
}, [selectedDate, showByYear])
useEffect(() => {
if (codes.length > 0) {
const updatedDrillStack = codes.reduce((acc, code) => {
const drilled = data[code]
// check if the code is on first level of drilldown
if (drilled) acc.push(drilled.drill)
// if not on first level then use the previous object in array
// this might be source of bugs but its the best I could come up with
else if (acc.length > 0 && acc[0][code]) acc.push(acc[0][code].drill)
return acc
}, [])
setDrillStack(updatedDrillStack)
}
}, [data])
const handleShowYearlyValuesToggled = () => {
const yearlyValues = showYearlyValues
setShowYearlyValues(!showYearlyValues)
sendAnalytics(`S Show yearly values toggle ${!yearlyValues ? 'on' : 'off'}`, 'Status')
}
const handleShowByYearToggled = () => {
const byYear = showByYear
setShowByYear(!showByYear)
sendAnalytics(`S Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status')
}
const handleShowRelativeValuesChanged = () => {
sendAnalytics(`S Show relative values toggle ${!showRelativeValues ? 'on' : 'off'}`)
setShowRelativeValues(!showRelativeValues)
}
const pushToDrillStack = (values, code) => {
const updatedCodes = [...codes].concat(code)
const updatedDrillStack = [...drillStack].concat(values)
setCodes(updatedCodes)
setDrillStack(updatedDrillStack)
sendAnalytics('S Drilldown clicked', 'Status')
}
const popFromDrillStack = () => {
const updatedDrillStack = _.dropRight([...drillStack], 1)
const updatedCodes = _.dropRight([...codes], 1)
setCodes(updatedCodes)
setDrillStack(updatedDrillStack)
sendAnalytics('S Drillup clicked', 'Status')
}
const renderSettings = () => {
return (
<Accordion style={{ padding: 0, paddingTop: '10px', flex: 1 }}>
<Accordion.Title style={{ padding: 0, cursor: 'default' }} active={showSettings}>
<span style={{ cursor: 'pointer' }} onClick={() => setShowSettings(!showSettings)}>
<Icon name="setting" />
<span>Asetukset</span>
</span>
</Accordion.Title>
<Accordion.Content style={{ padding: 0, marginTop: '10px' }} active={showSettings}>
<Segment style={{ display: 'flex', alignItems: 'center' }}>
<div
style={{
width: '10px',
height: '10px',
background: 'red',
transform: 'rotateY(0deg) rotate(45deg)',
position: 'absolute',
top: '-6px',
left: '35px',
border: '1px solid #dededf',
borderRight: 'none',
borderBottom: 'none',
backgroundColor: 'white',
}}
/>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label="Näytä edelliset vuodet"
onChange={handleShowYearlyValuesToggled}
checked={showYearlyValues}
/>
<VerticalLine />
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label="Näytä kalenterivuosittain"
onChange={handleShowByYearToggled}
checked={showByYear}
/>
<VerticalLine />
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label="Näytä suhteutettuna opiskelijoiden määrään"
onChange={handleShowRelativeValuesChanged}
checked={showRelativeValues}
/>
<VerticalLine />
<Form>
<Form.Field error={!isValidDate(selectedDate)} style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: '0.9em' }}>Näytä päivänä:</span>
<Datetime
className="status-date-time-input"
dateFormat={DATE_FORMAT}
timeFormat={false}
closeOnSelect
value={selectedDate}
locale="fi"
isValidDate={isValidDate}
onChange={setSelectedDate}
/>
</Form.Field>
</Form>
</Segment>
</Accordion.Content>
</Accordion>
)
}
const DrilldownMessage = () => (
<Message
color="blue"
content="Klikkaamalla tiedekuntaa pystyt porautumaan koulutusohjelma tasolle ja ohjelmaa klikkaamalla pystyt porautumaan kurssitasolle.
Vasemmassa yläkulmassa olevaa nuolta klikkaamalla pääset edelliseen näkymään."
/>
)
if (!data || loading)
return (
<Segment style={{ padding: '40px' }} textAlign="center">
<Dimmer inverted active />
<Loader active={loading} />
</Segment>
)
const orderedAbsDiffs = _.orderBy(
Object.values(_.last(drillStack) || data).map(({ current, currentStudents, previous, previousStudents }) => {
return Math.abs(
Math.floor(
(getP(
showRelativeValues ? current / currentStudents : current,
showRelativeValues ? previous / previousStudents : previous
) -
1) *
1000
) / 10
)
const orderedAbsDiffs = _.chain(data)
.map(({ current, currentStudents, previous, previousStudents }) => {
const currentValue = showRelativeValues ? current / currentStudents : current
const previousValue = showRelativeValues ? previous / previousStudents : previous
return Math.abs(Math.floor((getP(currentValue, previousValue) - 1) * 1000) / 10)
})
)
.orderBy()
.value()
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
return (
<>
<h2>Koulutusohjelmien tuottamat opintopisteet</h2>
<DrilldownMessage />
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
{drillStack.length > 0 && (
<Icon onClick={popFromDrillStack} style={{ fontSize: '40px', cursor: 'pointer' }} name="arrow left" />
)}
</div>
<div
style={{
display: 'grid',
@@ -340,20 +267,21 @@ const Status = ({ getStatusDispatch, data, loading }) => {
}}
>
{_.orderBy(
Object.entries(_.last(drillStack) || data),
([, { current, previous, currentStudents, previousStudents }]) =>
data,
({ current, previous, currentStudents, previousStudents }) =>
getP(current || currentStudents, previous || previousStudents), // oh god<r
['desc']
).map(([code, stats]) => {
const handleClick = () => pushToDrillStack(stats.drill, code)
).map(stats => {
const handleClick = () => onDrill(stats.code)
return (
<StatusContainer
key={code}
key={stats.code}
stats={stats}
handleClick={handleClick}
showRelativeValues={showRelativeValues}
showYearlyValues={showYearlyValues}
showStudentCounts={showStudentCounts}
min1={-medianDiff * 2}
max1={medianDiff * 2}
showByYear={showByYear}
@@ -361,20 +289,367 @@ const Status = ({ getStatusDispatch, data, loading }) => {
)
})}
</div>
{renderSettings()}
<Message>
{
// eslint-disable-next-line react/no-children-prop
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
}
</Message>
</>
)
}
const mapStateToProps = ({ coolDataScience }) => ({
data: coolDataScience.data.status,
loading: coolDataScience.pending.status,
})
const WithHelpTooltip = ({ children, tooltip, onOpenDetails, ...rest }) => {
const popupContext = useRef()
const [popupOpen, setPopupOpen] = useState(false)
export default connect(mapStateToProps, { getStatusDispatch: getStatus })(Status)
const trigger = (
<div>
{children}
<div ref={popupContext} style={{ display: 'inline-block', paddingTop: '0.2em', cursor: 'help' }}>
<Icon
onClick={() => setPopupOpen(!popupOpen)}
ref={popupContext}
style={{ marginLeft: '0.3em', color: '#888' }}
name="question circle outline"
/>
</div>
</div>
)
const popupProps = _.defaults(rest, {
position: 'right center',
})
return (
<>
<Popup
hoverable
size="tiny"
open={popupOpen}
onOpen={() => setPopupOpen(true)}
onClose={() => setPopupOpen(false)}
trigger={trigger}
context={popupContext}
{...popupProps}
on="hover"
mouseEnterDelay={1000}
>
<div>{tooltip}</div>
<span style={{ color: '#2185d0', cursor: 'pointer' }} onClick={onOpenDetails}>
Lue lisää...
</span>
</Popup>
</>
)
}
const isValidDate = d => moment().diff(moment(d)) > 0
const StatusSettings = ({ onSettingsChange, settings, onOpenDetails }) => {
const { selectedDate } = settings
const DATE_FORMAT = 'DD.MM.YYYY'
const changeSetting = (property, value) => {
sendAnalytics(`S Set setting "${property}" to ${value}`, 'Status')
onSettingsChange({
...settings,
[property]: value,
})
}
const itemStyles = {
margin: '0.5rem 1rem',
display: 'flex',
alignItems: 'center',
}
const createSettingToggle = key => (
<div style={itemStyles}>
<WithHelpTooltip tooltip={settingDefinitions[key].short} onOpenDetails={() => onOpenDetails(key)}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={settingDefinitions[key].label}
checked={settings[key]}
onChange={() => changeSetting(key, !settings[key])}
/>
</WithHelpTooltip>
</div>
)
return (
<div style={{ display: 'flex', alignItems: 'stretch', padding: 0, flexDirection: 'column' }}>
{createSettingToggle('showYearlyValues')}
{createSettingToggle('showByYear')}
{createSettingToggle('showRelativeValues')}
{createSettingToggle('showStudentCounts')}
<div style={itemStyles}>
<Form>
<Form.Field
error={selectedDate !== null && !isValidDate(selectedDate)}
style={{ display: 'flex', alignItems: 'center' }}
>
<WithHelpTooltip
tooltip={settingDefinitions.showCountingFrom.short}
onOpenDetails={() => onOpenDetails('showCountingFrom')}
>
<span style={{ fontSize: '0.9em' }}>{settingDefinitions.showCountingFrom.label}</span>
</WithHelpTooltip>
<Datetime
className="status-date-time-input"
dateFormat={DATE_FORMAT}
timeFormat={false}
closeOnSelect
value={moment(selectedDate)}
locale="fi"
isValidDate={isValidDate}
inputProps={{ placeholder: 'Valise päivämäärä' }}
onChange={value => {
changeSetting('selectedDate', value.format(DATE_FORMAT) === moment().format(DATE_FORMAT) ? null : value)
}}
/>
</Form.Field>
</Form>
</div>
</div>
)
}
const getDefaultSettings = () =>
_.chain(settingDefinitions)
.toPairs()
.map(([key, { defaultValue }]) => [key, defaultValue])
.fromPairs()
.value()
const Status = () => {
const [explicitSettings, setSettings] = useLocalStorage('trendsStatusSettings', {})
const [usageDetailsOpen, setUsageDetailsOpen] = useState(false)
const moreDetailsRef = useRef(null)
const history = useHistory()
const location = useLocation()
const drillStack = location.state?.drillStack ?? []
const data = useSelector(state => state.coolDataScience.data.status)
const loading = useSelector(state => state.coolDataScience.pending.status)
const dispatch = useDispatch()
const settings = useMemo(() => _.defaults(explicitSettings, getDefaultSettings()), [explicitSettings])
const { CoolDataScience } = InfoToolTips
useEffect(() => {
const { selectedDate, showByYear } = settings
let date
if (!selectedDate) {
date = moment().valueOf()
} else if (isValidDate(selectedDate)) {
date = moment(selectedDate).valueOf()
} else {
return
}
dispatch(
getStatus({
date,
showByYear,
})
)
}, [settings.selectedDate, settings.showByYear])
const pushToDrillStack = code => {
history.push('/trends/status', {
drillStack: [...drillStack, code],
})
sendAnalytics('S Drilldown clicked', 'Status')
}
const breadcrumb = useMemo(() => {
const stack = _.chain(drillStack)
.reduce(
([stack, data], code) => {
let content = code
let next = null
if (data) {
const entry = data[code]
if (entry) {
content = getTextIn(entry.name)
next = entry.drill
}
}
const item = {
key: code,
content,
link: !!next,
active: !next,
onClick: () => {
history.push('/trends/status', {
drillStack: [...stack.map(({ key }) => key).splice(1), code],
})
},
}
return [[...stack, item], next]
},
[[], data]
)
.value()[0]
const hy = {
key: '',
content: getTextIn({
fi: 'Helsingin Yliopisto',
en: 'University of Helsinki',
sv: 'Helsingfors universitet',
}),
link: true,
onClick: () =>
history.push('/trends/status', {
drillStack: [],
}),
}
return [hy, ...stack]
}, [data, drillStack])
const drilledData = useMemo(
() =>
_.chain(drillStack)
.reduce((data, code) => data && _.values(data).find(item => item.code === code)?.drill, data)
.defaultTo({})
.values()
.value(),
[data, drillStack]
)
const popFromDrillStack = () => {
const newDrillStack = [...drillStack]
newDrillStack.pop()
history.push('/trends/status', {
drillStack: newDrillStack,
})
sendAnalytics('S Drillup clicked', 'Status')
}
const [settingsOpen, setSettingsOpen] = useState(false)
const attentionSeekers = useRef({})
const scrollToAttentionSeeker = el => {
el.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
})
}
const attentionSeekerRef = key => ref => {
if (ref !== null && attentionSeekers.current[key] === true) {
scrollToAttentionSeeker(ref)
}
attentionSeekers.current[key] = ref
}
return (
<>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }} ref={moreDetailsRef}>
<h2 style={{ margin: 0, flexGrow: 1 }}>Koulutusohjelmien tuottamat opintopisteet</h2>
<Popup
trigger={
<Button>
<Icon name="settings" /> Asetukset
</Button>
}
position="bottom right"
on="click"
wide="very"
open={settingsOpen}
onOpen={() => setSettingsOpen(true)}
onClose={() => {
// Close the popup only after the event has had a chance to propagate.
setTimeout(() => setSettingsOpen(false), 0)
}}
style={{ padding: '0.25em 0em', maxHeight: '80vh' }}
>
<StatusSettings
settings={settings}
onSettingsChange={setSettings}
onOpenDetails={key => {
if (moreDetailsRef.current) {
moreDetailsRef.current.scrollIntoView({
block: 'start',
inline: 'end',
behavior: 'smooth',
})
}
if (attentionSeekers.current[key]) {
scrollToAttentionSeeker(attentionSeekers.current[key])
} else {
attentionSeekers.current[key] = true
}
setUsageDetailsOpen(true)
}}
/>
</Popup>
<Popup
trigger={
<Button>
<Icon name="question circle" /> Käyttöohje
</Button>
}
position="bottom right"
on="click"
wide="very"
open={usageDetailsOpen}
onOpen={() => setUsageDetailsOpen(true)}
onClose={() => setUsageDetailsOpen(false)}
style={{ padding: '0', paddingBottom: '0.5em', maxHeight: '80vh', overflowY: 'auto' }}
>
<div style={{ padding: '1em' }}>
{
// eslint-disable-next-line react/no-children-prop
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
}
</div>
{_.toPairs(settingDefinitions).map(([key, { label, long }]) => (
<div key={key}>
<Divider />
<div style={{ padding: '0 1em' }} ref={attentionSeekerRef(key)}>
<b>
Valinta "<i>{label}</i>"
</b>
<div style={{ margin: '0.5em', fontSize: '0.9em' }}>
<ReactMarkdown escapeHtml={false}>{long.replace(/(^|\n)[ \t]+/g, '\n')}</ReactMarkdown>
</div>
</div>
</div>
))}
</Popup>
</div>
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px', alignItems: 'center' }}>
<Breadcrumb icon="right angle" sections={breadcrumb} size="large" />
{drillStack.length > 0 && (
<Icon
onClick={popFromDrillStack}
style={{ cursor: 'pointer', color: 'rgba(0,0,0,.28)', marginLeft: '0.4em', marginTop: '-0.17em' }}
name="arrow left"
/>
)}
</div>
{data && !loading ? (
<StatusContent settings={settings} onDrill={pushToDrillStack} data={drilledData} />
) : (
<Loader active={loading} inline="centered" />
)}
</>
)
}
export default Status
@@ -5,8 +5,22 @@ import Graduated from './StatusGraduated'
const StatusPanel = () => {
const panes = [
{ menuItem: 'Attainments', render: () => <Status /> },
{ menuItem: 'Graduations', render: () => <Graduated /> },
{
menuItem: 'Attainments',
render: () => (
<Tab.Pane>
<Status />
</Tab.Pane>
),
},
{
menuItem: 'Graduations',
render: () => (
<Tab.Pane>
<Graduated />
</Tab.Pane>
),
},
]
return <Tab panes={panes} />
}
@@ -10,41 +10,45 @@
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
}
.unit,
.decimals {
opacity: 0;
}
.years {
margin-top: 10px;
text-align: start;
font-variant-numeric: tabular-nums;
display: grid;
grid-template-columns: fit-content(0) fit-content(0) auto;
grid-template-columns: repeat(4, fit-content(0));
}
.year-row .unit {
padding-left: 0.5em;
color: rgba(0, 0, 0, 0.3);
font-size: 0.9em;
.subtitle {
color: #888;
}
.unit {
margin-left: 0.3em;
}
.year-label {
white-space: nowrap;
padding-right: 0.5em;
}
.year-value {
text-align: right;
white-space: nowrap;
padding-left: 0.5em;
}
.year-row {
position: relative;
padding-left: 0.5em;
display: inline-block;
display: contents;
}
.status-card .years .year-row:hover .unit,
.status-card .years .year-row:hover .decimals,
.status-card:hover .years:not(:hover) .year-row:first-child .unit,
.status-card:hover .years:not(:hover) .year-row:first-child .decimals {
opacity: 1;
.year-row .unit {
grid-column: 5;
}
.year-row .separator {
color: #aaa;
margin: 0 0.3em;
}
.status-date-time-input {
@@ -1,19 +1,17 @@
import React, { Component } from 'react'
import React, { useState, useRef, useMemo } from 'react'
import { renderToString } from 'react-dom/server'
import { connect } from 'react-redux'
import moment from 'moment'
import { arrayOf, bool, object, string, func, number, shape } from 'prop-types'
import { withRouter } from 'react-router-dom'
import { useHistory } from 'react-router-dom'
import Highcharts from 'highcharts/highstock'
import { Button } from 'semantic-ui-react'
import boostcanvas from 'highcharts/modules/boost-canvas'
import _ from 'lodash/fp'
import boost from 'highcharts/modules/boost'
import ReactHighstock from 'react-highcharts/ReactHighstock'
import './creditAccumulationGraphHC.css'
import CreditGraphTooltip from '../CreditGraphTooltip'
import { clearLoading } from '../../redux/graphSpinner'
import { setChartHeight } from '../../redux/settings'
import { reformatDate, getTextIn } from '../../common'
import useLanguage from '../LanguagePicker/useLanguage'
import { DISPLAY_DATE_FORMAT, API_DATE_FORMAT } from '../../constants'
// boost canvas needed because tests break with large population
@@ -23,313 +21,249 @@ boost(Highcharts)
const SINGLE_GRAPH_GOAL_SERIES_NAME = 'Goal'
class CreditAccumulationGraphHighCharts extends Component {
constructor(props) {
super(props)
this.state = {
options: [],
updateGraph: false,
}
const createGraphOptions = ({
singleStudent,
seriesData,
graphHeight,
tooltipFormatter,
onPointClicked,
startDate,
endDate,
}) => {
const tooltip = {
shared: false,
split: false,
}
componentDidMount() {
const { students, selectedStudents, currentGraphSize, render } = this.props
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
}
componentWillReceiveProps(nextProps) {
if (nextProps.students) {
const nextStudents = nextProps.students.map(student => student.studentNumber)
const oldStudents = this.props.students.map(student => student.studentNumber)
const changedStudentAmount = nextProps.selectedStudents.length !== this.props.selectedStudents.length
const changedGraphSize = nextProps.currentGraphSize !== this.props.currentGraphSize
const changed =
nextStudents.some(student => !oldStudents.includes(student)) ||
oldStudents.some(student => !nextStudents.includes(student))
if (changed || changedStudentAmount || changedGraphSize) {
this.createGraphOptions(
nextProps.students,
nextProps.selectedStudents,
nextProps.currentGraphSize,
nextProps.render
)
}
}
/**
* Trigger updating the chart after filter tray transition has finished.
* This hack remedies the problem that highcharts will not dynamically adapt to container width
* if a CSS transition is used. The resize must fire only after the transition has completed to
* ensure correct size.
*/
if (this.props.trayOpen !== nextProps.trayOpen) {
setTimeout(() => {
this.props.setChartHeight(this.props.currentGraphSize - 1)
}, 700)
}
}
componentDidUpdate() {
const { updateGraph } = this.state
const { students, selectedStudents, currentGraphSize, render } = this.props
if (updateGraph) {
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
}
}
getXAxisMonth = (date, startDate) =>
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
sortCoursesByDate = courses => courses.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
filterCoursesByDate = (courses, date) => courses.filter(c => moment(c.date).isSameOrAfter(moment(date)))
createTooltip = point => {
const { students, language } = this.props
if (point.series.name === SINGLE_GRAPH_GOAL_SERIES_NAME) {
return null
}
const targetCourse = this.sortCoursesByDate(students[0].courses).find(
c => point.key === c.course_code && point.x === new Date(c.date).getTime()
)
if (!targetCourse.course) return null
const payload = [
{
name: students[0].studentNumber,
payload: {
...targetCourse,
date: reformatDate(targetCourse.date, DISPLAY_DATE_FORMAT),
title: getTextIn(targetCourse.course.name, language),
},
if (singleStudent) {
Object.assign(tooltip, {
formatter() {
tooltipFormatter(this)
},
]
return renderToString(<CreditGraphTooltip payload={payload} />)
useHTML: true,
style: {
all: 'unset',
display: 'none',
},
})
}
createGraphOptions = (students, selectedStudents, graphSize, render) => {
const studentCreditLines = this.createStudentCreditLines(students)
const dataOfSelected = studentCreditLines.filter(line => selectedStudents.includes(line.name))
let lastCredits = null
if (this.isSingleStudentGraph() && render) {
const started = moment(students.minDate)
const lastDate = moment(students.maxDate)
const lastMonth = Math.ceil(this.getXAxisMonth(lastDate, started))
let totalAbsenceMonths = 0
const absencePoints = this.props.absences
.filter(a => a.startdate >= started || (a.startdate <= started && a.enddate >= started))
.reduce((res, { startdate, enddate }) => {
const targetCreditsBeforeAbsence =
(Math.ceil(this.getXAxisMonth(moment(startdate), started)) - totalAbsenceMonths) * (55 / 12)
if (enddate < students.maxDate) {
res.push([startdate, targetCreditsBeforeAbsence])
res.push([enddate, targetCreditsBeforeAbsence])
totalAbsenceMonths += moment(enddate).diff(moment(startdate), 'months')
}
return res
}, [])
const zoneStart = absencePoints.length ? [absencePoints[0][0]] : []
const zones = absencePoints.reduce(
(acc, [start], i) => [...acc, { value: start, color: i % 2 === 0 ? '#96d7c3' : 'red' }],
zoneStart
)
lastCredits = (lastMonth - totalAbsenceMonths) * (55 / 12)
dataOfSelected.push({
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
data: [
...[[students.minDate, 0], ...absencePoints, [students.maxDate, lastCredits]].sort((a, b) => a[0] - b[0]),
],
seriesThreshold: 150,
color: '#96d7c3',
marker: {
enabled: false,
},
zones,
zoneAxis: 'x',
})
}
const self = this
const tooltipOptions = this.isSingleStudentGraph()
? {
formatter() {
return self.createTooltip(this)
},
shared: false,
useHTML: true,
style: {
all: 'unset',
display: 'none',
},
split: false,
}
: {
shared: false,
split: false,
}
const options = {
plotOptions: {
series: {
findNearestPointBy: 'xy',
point: {
events: {
click() {
if (!self.isSingleStudentGraph()) {
self.props.history.push(`/students/${this.series.name}`)
}
},
mouseOver() {
if (this.series.halo) {
this.series.halo
.attr({
class: 'highcharts-tracker',
})
.toFront()
}
},
hover: {
halo: {
size: 9,
attributes: {
fill: Highcharts.getOptions().colors[2],
'stroke-width': 2,
stroke: Highcharts.getOptions().colors[1],
},
return {
plotOptions: {
series: {
findNearestPointBy: 'xy',
point: {
events: {
click() {
onPointClicked(this)
},
mouseOver() {
if (this.series.halo) {
this.series.halo
.attr({
class: 'highcharts-tracker',
})
.toFront()
}
},
hover: {
halo: {
size: 9,
attributes: {
fill: Highcharts.getOptions().colors[2],
'stroke-width': 2,
stroke: Highcharts.getOptions().colors[1],
},
},
},
cursor: 'pointer',
},
cursor: 'pointer',
},
},
yAxis: {
max: lastCredits && lastCredits > students.maxCredits ? lastCredits : students.maxCredits,
title: { text: 'Credits' },
},
xAxis: {
max: students.maxDate,
min: students.minDateWithCredits || students.minDate,
ordinal: false,
},
series: dataOfSelected,
chart: {
height: graphSize,
},
tooltip: {
...tooltipOptions,
},
}
this.setState({ options, updateGraph: false })
},
yAxis: {
title: { text: 'Credits' },
min: 0,
max: undefined,
},
xAxis: {
ordinal: false,
min: startDate,
max: endDate,
},
series: seriesData,
chart: {
height: graphHeight,
},
tooltip,
}
}
const sortCoursesByDate = courses =>
[...courses].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
const filterCoursesByDate = (courses, date) => courses.filter(c => moment(c.date).isSameOrAfter(moment(date)))
const getXAxisMonth = (date, startDate) =>
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
const singleStudentTooltipFormatter = (point, student, language) => {
if (point.series.name === SINGLE_GRAPH_GOAL_SERIES_NAME) {
return null
}
isSingleStudentGraph = () => !!this.props.singleStudent
const targetCourse = sortCoursesByDate(student.courses).find(
({ course_code: courseCode, date }) => point.key === courseCode && point.x === new Date(date).getTime()
)
createStudentCreditLines = students =>
students.map(student => {
const { started, studyrightStart } = student
if (!targetCourse.course) {
return null
}
const startDate = this.props.selectedStudents.length === 1 ? started : studyrightStart
let credits = 0
let points = this.filterCoursesByDate(this.sortCoursesByDate(student.courses), startDate).map(course => {
if (course.passed && !course.isStudyModuleCredit) {
credits += course.credits
}
const defaultPointOptions = this.isSingleStudentGraph() ? { name: course.course_code } : {}
return {
...defaultPointOptions,
x: new Date(course.date).getTime(),
y: credits,
}
})
if (points.length < 2) {
points = [[students.minDate, 0], ...points]
const payload = [
{
name: student.studentNumber,
payload: {
...targetCourse,
date: reformatDate(targetCourse.date, DISPLAY_DATE_FORMAT),
title: getTextIn(targetCourse.course.name, language),
},
},
]
return renderToString(<CreditGraphTooltip payload={payload} />)
}
const createGoalSeries = (starting, ending, absences) => {
const lastMonth = Math.ceil(getXAxisMonth(moment(ending), moment(starting)))
let totalAbsenceMonths = 0
const absencePoints = absences
.filter(a => a.startdate >= starting || (a.startdate <= starting && a.enddate >= starting))
.reduce((res, { startdate, enddate }) => {
const targetCreditsBeforeAbsence =
(Math.ceil(getXAxisMonth(moment(startdate), moment(starting))) - totalAbsenceMonths) * (55 / 12)
if (enddate < ending) {
res.push([startdate, targetCreditsBeforeAbsence])
res.push([enddate, targetCreditsBeforeAbsence])
totalAbsenceMonths += moment(enddate).diff(moment(startdate), 'months')
}
return {
name: student.studentNumber,
data: points,
seriesThreshold: 150,
marker: {
enabled: this.isSingleStudentGraph(),
radius: 4,
return res
}, [])
const zoneStart = absencePoints.length ? [absencePoints[0][0]] : []
const zones = absencePoints.reduce(
(acc, [start], i) => [...acc, { value: start, color: i % 2 === 0 ? '#96d7c3' : 'red' }],
zoneStart
)
const lastCredits = (lastMonth - totalAbsenceMonths) * (55 / 12)
const data = [...[[starting, 0], ...absencePoints, [ending, lastCredits]].sort((a, b) => a[0] - b[0])]
return {
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
data,
seriesThreshold: 150,
color: '#96d7c3',
marker: {
enabled: false,
},
zones,
zoneAxis: 'x',
}
}
const createStudentCreditLines = (students, singleStudent) =>
students.map(student => {
const { started, studyrightStart } = student
const startDate = students.length === 1 ? started : studyrightStart
const { points } = _.flow(
sortCoursesByDate,
courses => filterCoursesByDate(courses, startDate),
_.reduce(
({ credits, points }, course) => {
const gainedCredits = course.passed && !course.isStudyModuleCredit ? course.credits : 0
const point = {
x: new Date(course.date).getTime(),
y: credits + gainedCredits,
}
if (singleStudent) {
Object.assign(point, {
name: course.course_code,
})
}
return {
points: [...points, point],
credits: credits + gainedCredits,
}
},
}
})
{ credits: 0, points: [] }
)
)(student.courses)
resizeChart = size => {
if (this.chart) {
this.props.setChartHeight(size)
return {
name: student.studentNumber,
data: points,
seriesThreshold: 150,
marker: {
enabled: !!singleStudent,
radius: 4,
},
}
})
const CreditAccumulationGraphHighCharts = ({ students, singleStudent, absences, startDate, endDate }) => {
const history = useHistory()
const chartRef = useRef()
const language = useLanguage()
const [graphHeight, setGraphHeight] = useState(600)
const seriesData = useMemo(() => createStudentCreditLines(students, singleStudent), [students, singleStudent])
if (singleStudent) {
const starting = students[0].studyrights
.map(sr => new Date(sr.startdate))
.sort()[0]
.getTime()
const ending = endDate ? new Date(endDate).getTime() : new Date().getTime()
seriesData.push(createGoalSeries(starting, ending, absences))
}
render() {
return (
<div>
<div className="graphContainer">
<div className="graphOptions">
<Button
active={this.props.currentGraphSize === 400}
onClick={() => this.resizeChart(400)}
content="Small"
/>
<Button
active={this.props.currentGraphSize === 600}
onClick={() => this.resizeChart(600)}
content="Medium"
/>
<Button
active={this.props.currentGraphSize === 1000}
onClick={() => this.resizeChart(1000)}
content="Large"
/>
</div>
<ReactHighstock
highcharts={Highcharts}
ref={HighchartsReact => {
this.chart = HighchartsReact
}}
constructorType="stockChart"
config={this.state.options}
/>
</div>
const options = createGraphOptions({
singleStudent,
seriesData,
graphHeight,
startDate: startDate ? new Date(startDate).getTime() : undefined,
endDate,
tooltipFormatter: point => singleStudent && singleStudentTooltipFormatter(point, students[0], language),
onPointClicked: point => {
if (!singleStudent) {
history.push(`/students/${point.series.name}`)
}
},
})
const makeGraphSizeButton = (height, label) => (
<Button active={graphHeight === height} onClick={() => setGraphHeight(height)} content={label} />
)
return (
<div className="graphContainer">
<div className="graphOptions">
{makeGraphSizeButton(400, 'Small')}
{makeGraphSizeButton(600, 'Medium')}
{makeGraphSizeButton(1000, 'Large')}
</div>
)
}
<ReactHighstock highcharts={Highcharts} ref={chartRef} constructorType="stockChart" config={options} />
</div>
)
}
CreditAccumulationGraphHighCharts.defaultProps = {
absences: [],
singleStudent: false,
render: true,
trayOpen: false,
}
CreditAccumulationGraphHighCharts.propTypes = {
singleStudent: bool,
students: arrayOf(object).isRequired,
selectedStudents: arrayOf(string).isRequired,
setChartHeight: func.isRequired,
currentGraphSize: number.isRequired,
language: string.isRequired,
absences: arrayOf(shape({})),
render: bool,
trayOpen: bool,
}
const mapStateToProps = state => ({
spinner: state.graphSpinner,
currentGraphSize: state.settings.chartHeight,
})
export default connect(mapStateToProps, { clearLoading, setChartHeight })(withRouter(CreditAccumulationGraphHighCharts))
export default CreditAccumulationGraphHighCharts
@@ -1,11 +1,11 @@
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { shape, arrayOf, string, bool } from 'prop-types'
import { useSelector } from 'react-redux'
import PopulationCourseStatsFlat from '../../PopulationCourseStats/PopulationCourseStatsFlat'
import useCourseFilter from '../../FilterTray/filters/Courses/useCourseFilter'
const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, error, showFilter }) => {
const CustomPopulationCourses = ({ selectedStudents, showFilter = false }) => {
const { setCourses, resetCourses } = useCourseFilter()
const { data: courses, pending, error } = useSelector(({ populationCourses }) => populationCourses)
useEffect(() => {
if (!pending && !error && courses.coursestatistics) {
@@ -21,33 +21,10 @@ const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, er
return (
<PopulationCourseStatsFlat
courses={courses}
query={query}
pending={pending}
selectedStudents={selectedStudents}
customPopulation
showFilter={showFilter}
/>
)
}
CustomPopulationCourses.propTypes = {
pending: bool.isRequired,
courses: shape([]).isRequired,
selectedStudents: arrayOf(string).isRequired,
query: shape({}).isRequired,
error: bool.isRequired,
showFilter: bool,
}
CustomPopulationCourses.defaultProps = {
showFilter: false,
}
const mapStateToProps = ({ populationCourses }) => ({
courses: populationCourses.data,
pending: populationCourses.pending,
query: populationCourses.query,
error: populationCourses.error,
})
export default connect(mapStateToProps)(CustomPopulationCourses)
export default CustomPopulationCourses
@@ -16,7 +16,6 @@ import InfoBox from '../InfoBox'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
import useFilterTray from '../FilterTray/useFilterTray'
import useLanguage from '../LanguagePicker/useLanguage'
import CustomPopulationSearch from './CustomPopulationSearch'
import UnihowDataExport from './UnihowDataExport'
@@ -35,7 +34,6 @@ const CustomPopulation = ({
const [, setSelectedSearchId] = useState('')
const [newestIndex, setNewest] = useState(null)
const { setAllStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const { progress } = useProgress(loading)
@@ -98,13 +96,7 @@ const CustomPopulation = ({
content: {
content: (
<div ref={creditGainRef}>
<CreditAccumulationGraphHighCharts
students={custompop}
selectedStudents={selectedStudents}
render={false}
trayOpen={trayOpen}
language={language}
/>
<CreditAccumulationGraphHighCharts students={filteredStudents} />
</div>
),
},
@@ -1,17 +1,24 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Popup, Button, Icon } from 'semantic-ui-react'
import { contextKey as filterTrayContextKey } from './index'
import useFilterTray from './useFilterTray'
import useAnalytics from './useAnalytics'
/**
* @param {func} applyFilter Function to apply the filter when toggled on
* @param {func} clearFilter Function to clear the filter when toggled off.
* @param {string} filterName Name to display in analytics.
* @param {active} bool Toggle state. Must be controlled in the parent.
* @param {funcs} popupContent Content component to show in the tooltip, if given. Tooltip is not shown if omitted.
* @param {string} filterPanelContextKey Used to open the filter's panel, if given. If not given, the filter tray will not be opened either.
*/
const ExternalFilterToggle = ({
filterPanelContextKey,
applyFilter,
clearFilter,
filterName,
active,
popupContent,
filterPanelContextKey,
}) => {
const [, setFilterTrayOpen] = useFilterTray(filterTrayContextKey)
const [, setCreditFilterOpen] = useFilterTray(filterPanelContextKey)
@@ -55,24 +62,4 @@ const ExternalFilterToggle = ({
)
}
ExternalFilterToggle.propTypes = {
/** Used to open the filter's panel, if given. If not given, the filter tray will not be opened either. */
filterPanelContextKey: PropTypes.string,
/** Function to apply the filter when toggled on. */
applyFilter: PropTypes.func.isRequired,
/** Function to clear the filter when toggled off. */
clearFilter: PropTypes.func.isRequired,
/** Name to display in analytics. */
filterName: PropTypes.string.isRequired,
/** Toggle state. Must be controlled in the parent. */
active: PropTypes.bool.isRequired,
/** Content to show in the tooltip, if given. Tooltip is not shown if omitted. */
popupContent: PropTypes.node,
}
ExternalFilterToggle.defaultProps = {
filterPanelContextKey: undefined,
popupContent: undefined,
}
export default ExternalFilterToggle
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { CourseFilterProvider } from './filters/Courses/useCourseFilter'
import { CreditFilterProvider } from './filters/CreditsEarned/useCreditFilter'
import { AgeFilterProvider } from './filters/Age/useAgeFilter'
@@ -23,8 +22,4 @@ const filterProviders = [
const FilterContextProvider = ({ children }) =>
filterProviders.reduceRight((children, Provider) => <Provider>{children}</Provider>, children)
FilterContextProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default FilterContextProvider
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react'
import useAnalytics from './useAnalytics'
import GraduatedFromProgramme from './filters/GraduatedFromProgramme'
import TransferredToProgramme from './filters/TransferredToProgramme'
import EnrollmentStatus from './filters/EnrollmentStatus'
@@ -7,69 +8,67 @@ import Gender from './filters/Gender'
import StartYearAtUni from './filters/StartYearAtUni'
import Courses from './filters/Courses'
import Programmes from './filters/Programmes'
import useAnalytics from './useAnalytics'
import Grade from './filters/Grade'
import Date from './filters/Date'
import Age from './filters/Age'
import AdmissionType from './filters/AdmissionType'
import Tags from './filters/Tags'
export const PopulationStatisticsFilters = ({ query }) => {
const Filters = ({ name, children }) => {
const analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Population Statistics')
analytics.setTarget(name)
})
return (
<>
<GraduatedFromProgramme />
<TransferredToProgramme />
<EnrollmentStatus />
<CreditsEarned />
<Age />
<Gender />
<StartYearAtUni />
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
<Courses />
<Tags />
</>
)
return React.Children.toArray(children)
}
export const CoursePopulationFilters = () => {
const analytics = useAnalytics()
export const PopulationStatisticsFilters = ({ query }) => (
<Filters name="Population Statistics">
<GraduatedFromProgramme />
<TransferredToProgramme />
<EnrollmentStatus />
<CreditsEarned />
<Age />
<Gender />
<StartYearAtUni />
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
<Courses />
<Tags />
<Date />
</Filters>
)
useEffect(() => {
analytics.setTarget('Course Population')
})
export const CoursePopulationFilters = () => (
<Filters name="Course Population">
<Grade />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Programmes />
</Filters>
)
return (
<>
<Grade />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Programmes />
</>
)
}
export const CustomPopulationFilters = () => (
<Filters name="Custom Population">
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Date />
</Filters>
)
export const CustomPopulationFilters = () => {
const analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Custom Population')
})
return (
<>
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Date />
</>
)
}
export const StudyGuidanceGroupFilters = () => (
<Filters name="Study Guidance Group">
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<AdmissionType />
<GraduatedFromProgramme />
<Tags />
<Courses />
</Filters>
)
@@ -1,5 +1,4 @@
import React, { createContext, useState, useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { getPopulationSelectedStudentCourses } from '../../../../redux/populationSelectedStudentCourses'
@@ -23,11 +22,6 @@ const CourseFilterProvider = ({ children, getPopulationSelectedStudentCourses })
return <CourseFilterContext.Provider value={[state, setState]}>{children}</CourseFilterContext.Provider>
}
CourseFilterProvider.propTypes = {
children: PropTypes.node.isRequired,
getPopulationSelectedStudentCourses: PropTypes.func.isRequired,
}
const ConnectedProvider = connect(null, { getPopulationSelectedStudentCourses })(CourseFilterProvider)
export { ConnectedProvider as CourseFilterProvider }
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Button, Icon } from 'semantic-ui-react'
const ClearFilterButton = ({ disabled, onClick, name }) => (
@@ -9,10 +8,4 @@ const ClearFilterButton = ({ disabled, onClick, name }) => (
</Button>
)
ClearFilterButton.propTypes = {
disabled: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
}
export default ClearFilterButton
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Icon, Header } from 'semantic-ui-react'
import useFilterTray from '../../useFilterTray'
@@ -23,12 +22,4 @@ const FilterCard = ({ title, children, contextKey, name, active }) => {
)
}
FilterCard.propTypes = {
title: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
contextKey: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
active: PropTypes.bool.isRequired,
}
export default FilterCard
@@ -1,8 +1,16 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Input, Label, Popup, Button, Icon } from 'semantic-ui-react'
const NumericInput = ({ onChange, onKeyDown, onClear, value, label, clearButtonDisabled, className, name }) => (
const NumericInput = ({
onChange,
onKeyDown = () => {},
onClear,
value,
label,
clearButtonDisabled,
className,
name,
}) => (
<Input
labelPosition="left"
size="mini"
@@ -30,20 +38,4 @@ const NumericInput = ({ onChange, onKeyDown, onClear, value, label, clearButtonD
</Input>
)
NumericInput.propTypes = {
onChange: PropTypes.func.isRequired,
onKeyDown: PropTypes.func,
onClear: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
label: PropTypes.node.isRequired,
clearButtonDisabled: PropTypes.bool.isRequired,
className: PropTypes.string,
name: PropTypes.string.isRequired,
}
NumericInput.defaultProps = {
onKeyDown: () => {},
className: null,
}
export default NumericInput
@@ -1,12 +1,11 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Segment, Header } from 'semantic-ui-react'
import './filterTray.css'
import useFilters from './useFilters'
export const contextKey = 'filterTray'
const FilterTray = ({ children, filterSet, visible }) => {
const FilterTray = ({ children, filterSet, visible = true }) => {
const { filteredStudents, allStudents } = useFilters()
if (!visible || !allStudents.length) {
@@ -17,7 +16,7 @@ const FilterTray = ({ children, filterSet, visible }) => {
<>
<div style={{ display: 'inline-flex', alignItems: 'flex-start', width: '100%' }}>
<Segment style={{ marginLeft: '0.5rem', width: '12rem', position: 'sticky', top: '10px' }}>
<Header size="small" style={{ textAlign: 'center' }}>
<Header size="small" style={{ textAlign: 'center' }} data-cy="filtered-students">
Filter students
</Header>
<div style={{ textAlign: 'center' }}>
@@ -34,14 +33,4 @@ const FilterTray = ({ children, filterSet, visible }) => {
)
}
FilterTray.propTypes = {
children: PropTypes.node.isRequired,
filterSet: PropTypes.node.isRequired,
visible: PropTypes.bool,
}
FilterTray.defaultProps = {
visible: true,
}
export default FilterTray
@@ -1,5 +1,4 @@
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
import TSA from '../../common/tsa'
const FilterAnalyticsContext = createContext([[], () => {}])
@@ -10,10 +9,6 @@ export const FilterAnalyticsProvider = ({ children }) => {
return <FilterAnalyticsContext.Provider value={[state, setState]}>{children}</FilterAnalyticsContext.Provider>
}
FilterAnalyticsProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default () => {
const [state, setState] = useContext(FilterAnalyticsContext)
const category = `Filters (${state})`
@@ -2,7 +2,6 @@
* Context for those expandable filter panels that need to be opened/closed remotely.
*/
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
const FilterTrayContext = createContext([[], () => {}])
FilterTrayContext.displayName = 'Filter Tray'
@@ -13,10 +12,6 @@ export const FilterTrayProvider = ({ children }) => {
return <FilterTrayContext.Provider value={[state, setState]}>{children}</FilterTrayContext.Provider>
}
FilterTrayProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default key => {
const [state, setState] = useContext(FilterTrayContext)
@@ -2,7 +2,6 @@
* Context for managing filtering.
*/
import React, { createContext, useState, useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import lodash from 'lodash'
const defaultState = {
@@ -37,10 +36,6 @@ export const FilterProvider = ({ children }) => {
return <FilterContext.Provider value={[state, setState]}>{children}</FilterContext.Provider>
}
FilterProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default () => {
const [state, setState] = useContext(FilterContext)
+6 -28
View File
@@ -1,9 +1,7 @@
import React, { useEffect, useState } from 'react'
import { Container, Header, Image, Divider, List } from 'semantic-ui-react'
import moment from 'moment'
import { connect } from 'react-redux'
import propTypes from 'prop-types'
import { isEqual } from 'lodash'
import { useSelector } from 'react-redux'
import { images, getUserRoles, checkUserAccess } from '../common'
import { useTitle } from '../common/hooks'
import { builtAt } from '../conf'
@@ -49,10 +47,11 @@ const OodiToOodikone = () => {
)
}
const FrontPage = props => {
const { userRoles, rights } = props
const FrontPage = () => {
const { roles, rights } = useSelector(state => state.auth.token)
const userRoles = getUserRoles(roles)
useTitle()
const showItems = {
populations: userRoles.includes('admin') || rights.length !== 0,
studyProgramme: userRoles.includes('admin') || rights.length !== 0,
@@ -122,25 +121,4 @@ const FrontPage = props => {
)
}
FrontPage.propTypes = {
userRoles: propTypes.arrayOf(propTypes.string),
rights: propTypes.arrayOf(propTypes.string),
}
FrontPage.defaultProps = {
userRoles: [],
rights: [],
}
const mapStateToProps = ({
auth: {
token: { roles, rights },
},
}) => ({
userRoles: getUserRoles(roles),
rights,
})
export default connect(mapStateToProps, null, null, {
areStatePropsEqual: isEqual,
})(FrontPage)
export default FrontPage
@@ -1,11 +1,11 @@
import React from 'react'
import './navigationBar.css'
import { Menu, Dropdown, Button, Label } from 'semantic-ui-react'
import { NavLink, Link } from 'react-router-dom'
import { connect } from 'react-redux'
import { isEqual } from 'lodash'
import { getUserRoles, checkUserAccess } from '../../common'
import { logout as logoutAction } from '../../redux/auth'
import './navigationBar.css'
import LanguagePicker from '../LanguagePicker'
import { isDev, adminerUrls } from '../../conf'
import { useShowAsUser } from '../../common/hooks'
@@ -134,7 +134,7 @@ const NavigationBar = props => {
)
return (
<Menu stackable fluid className="navBar">
<Menu data-cy="navBar" stackable fluid className="navBar">
{renderHome()}
{renderNavigationRoutes()}
{renderUserMenu()}
@@ -1,5 +1,3 @@
/* stylelint-disable */
.navBar {
display: grid !important;
grid-auto-flow: column !important;
@@ -25,56 +23,3 @@
.logoText {
color: var(--black) ;;
}
.sis-destruction-siren {
width: 75px;
height: 75px;
animation: 0.5s infinite running sis-siren;
}
@keyframes sis-siren {
from {
transform: rotateZ(0);
}
to {
transform: rotateZ(360deg);
}
}
.sis-danger-zone-button {
background-color: #d01919;
animation: 2s ease-in-out infinite running sis-danger-zone-button-heartbeat;
}
@keyframes sis-danger-zone-button-heartbeat {
from {
background-color: #d01919;
transform: scale(1.05);
}
to {
background-color: rgba(230, 107, 107, 0.76);
transform: scale(1);
}
}
.sis-danger-zone-button::after {
position: absolute;
content: '';
width: 100%;
height: 100%;
left: 0;
top: 0;
border-radius: 0.28571429rem;
box-shadow: 0 0 25px 5px rgba(224, 114, 40, 0.75);
opacity: 1;
animation: 2s ease-in-out infinite running sis-danger-zone-opacity;
}
@keyframes sis-danger-zone-opacity {
from {
opacity: 1;
}
to {
opacity: 0.2;
}
}
@@ -1,9 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import { connect } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'
import { Table, Form, Input, Tab, Icon } from 'semantic-ui-react'
import { func, arrayOf, object, shape, string, bool } from 'prop-types'
import { orderBy, debounce } from 'lodash'
import { withRouter } from 'react-router-dom'
import { clearCourseStats } from '../../redux/coursestats'
import './populationCourseStats.css'
import { PopulationCourseContext } from './PopulationCourseContext'
@@ -43,7 +41,7 @@ const lodashSortOrderTypes = {
DESC: 'desc',
}
function updateCourseStatisticsCriteria(props, language, state) {
const updateCourseStatisticsCriteria = (props, language, state) => {
const { studentAmountLimit, sortCriteria, codeFilter, nameFilter, reversed } = state
const {
courses: { coursestatistics },
@@ -90,8 +88,21 @@ const initialState = props => ({
selectedStudentsLength: props.selectedStudentsLength || 0,
})
function PopulationCourseStats(props) {
const PopulationCourseStats = ({ courses, pending, selectedStudents, showFilter = true }) => {
const dispatch = useDispatch()
const { years } = useSelector(({ semesters }) => semesters.data)
const populationCourses = useSelector(({ populationCourses }) => populationCourses)
const isAdmin = getUserIsAdmin(useSelector(({ auth }) => auth.token.roles))
const { language } = useLanguage()
const props = {
courses,
isAdmin,
pending,
populationCourses,
selectedStudents,
showFilter,
years,
}
const [filterFields, setFilterFields] = useState({ codeFilter: '', nameFilter: '' })
const [courseStatistics, setCourseStatistics] = useState(
@@ -105,7 +116,7 @@ function PopulationCourseStats(props) {
const filterAnalytics = useAnalytics()
useEffect(() => {
if (state && props.courses) {
if (!pending && state && props.courses) {
const studentAmountLimit =
state.selectedStudentsLength !== props.selectedStudents.length
? Math.round(props.selectedStudents.length * 0.3)
@@ -119,44 +130,46 @@ function PopulationCourseStats(props) {
})
setCourseStatistics(updateCourseStatisticsCriteria(props, language, state))
}
}, [props.courses, props.selectedStudents])
}, [props.courses, props.selectedStudents, pending])
useEffect(() => {
const { studentAmountLimit, codeFilter, nameFilter, reversed, sortCriteria } = state
const {
courses: { coursestatistics },
language,
} = props
const studentAmountFilter = ({ stats }) => {
const { students } = stats
return studentAmountLimit === 0 || students >= studentAmountLimit
if (!pending) {
const { studentAmountLimit, codeFilter, nameFilter, reversed, sortCriteria } = state
const {
courses: { coursestatistics },
language,
} = props
const studentAmountFilter = ({ stats }) => {
const { students } = stats
return studentAmountLimit === 0 || students >= studentAmountLimit
}
const courseCodeFilter = ({ course }) => {
const { code } = course
return code.toLowerCase().includes(codeFilter.toLowerCase())
}
const courseNameFilter = ({ course }) => {
const { name } = course
return getTextIn(name, language).toLowerCase().includes(nameFilter.toLowerCase())
}
const filteredCourses =
coursestatistics &&
coursestatistics
.filter(studentAmountFilter)
.filter(c => !codeFilter || courseCodeFilter(c))
.filter(c => !nameFilter || courseNameFilter(c))
const lodashSortOrder = reversed ? lodashSortOrderTypes.DESC : lodashSortOrderTypes.ASC
const sortedStatistics = orderBy(
filteredCourses,
[course => course.stats[sortCriteria], course => course.course.code],
[lodashSortOrder, lodashSortOrderTypes.ASC]
)
setCourseStatistics(sortedStatistics)
}
const courseCodeFilter = ({ course }) => {
const { code } = course
return code.toLowerCase().includes(codeFilter.toLowerCase())
}
const courseNameFilter = ({ course }) => {
const { name } = course
return getTextIn(name, language).toLowerCase().includes(nameFilter.toLowerCase())
}
const filteredCourses =
coursestatistics &&
coursestatistics
.filter(studentAmountFilter)
.filter(c => !codeFilter || courseCodeFilter(c))
.filter(c => !nameFilter || courseNameFilter(c))
const lodashSortOrder = reversed ? lodashSortOrderTypes.DESC : lodashSortOrderTypes.ASC
const sortedStatistics = orderBy(
filteredCourses,
[course => course.stats[sortCriteria], course => course.course.code],
[lodashSortOrder, lodashSortOrderTypes.ASC]
)
setCourseStatistics(sortedStatistics)
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter])
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter, pending])
const onFilterChange = (e, field) => {
const {
@@ -174,8 +187,8 @@ function PopulationCourseStats(props) {
)
useEffect(() => {
setFilters(filterFields)
}, [filterFields])
if (!pending) setFilters(filterFields)
}, [filterFields, pending])
const handleCourseStatisticsCriteriaChange = () => {
// eslint-disable-next-line react/no-access-state-in-setstate
@@ -232,13 +245,14 @@ function PopulationCourseStats(props) {
}
const onGoToCourseStatisticsClick = courseCode => {
const { clearCourseStats: clearCourseStatsfn } = props
sendAnalytics('Courses of Population course stats button clicked', courseCode)
clearCourseStatsfn()
dispatch(clearCourseStats())
}
const onCourseNameCellClick = code => {
const courseStatistic = props.populationCourses.data.coursestatistics.find(cs => cs.course.code === code)
const courseStatistic =
props.populationCourses.data.coursestatistics?.find(cs => cs.course.code === code) ||
props.courses.coursestatistics?.find(cs => cs.course.code === code)
if (courseStatistic) {
const isSelected = courseIsSelected(code)
const name = 'Course Filtername'
@@ -280,7 +294,6 @@ function PopulationCourseStats(props) {
)
}
const { courses, pending } = props
const { sortCriteria, reversed } = state
const contextValue = {
courseStatistics,
@@ -324,8 +337,6 @@ function PopulationCourseStats(props) {
return null
}
const { showFilter = true } = props
return (
<div>
{showFilter && (
@@ -343,37 +354,4 @@ function PopulationCourseStats(props) {
)
}
PopulationCourseStats.propTypes = {
courses: shape({
coursestatistics: arrayOf(object),
disciplines: shape({}),
}).isRequired,
populationCourses: shape({
data: shape({ coursestatistics: arrayOf(shape({ course: shape({ code: string, name: shape({}) }) })) }),
}).isRequired,
clearCourseStats: func.isRequired,
pending: bool.isRequired,
selectedStudents: arrayOf(string).isRequired,
years: shape({}), // eslint-disable-line
showFilter: bool,
language: string.isRequired,
}
PopulationCourseStats.defaultProps = {
showFilter: false,
}
const mapStateToProps = state => {
const { years } = state.semesters.data
const isAdmin = getUserIsAdmin(state.auth.token.roles)
return {
years,
populationCourses: state.populationCourses,
isAdmin,
}
}
export default connect(mapStateToProps, {
clearCourseStats,
})(withRouter(PopulationCourseStats))
export default PopulationCourseStats
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import { connect, useSelector } from 'react-redux'
import { Table, Input, Tab, Icon } from 'semantic-ui-react'
import { func, arrayOf, object, shape, string, bool } from 'prop-types'
import { orderBy, debounce } from 'lodash'
import { withRouter } from 'react-router-dom'
import { clearCourseStats } from '../../redux/coursestats'
@@ -92,7 +91,7 @@ const initialState = props => ({
selectedStudentsLength: props.selectedStudentsLength || 0,
})
function PopulationCourseStats(props) {
const PopulationCourseStats = props => {
const { language } = useLanguage()
const [filterFields, setFilterFields] = useState({ codeFilter: '', nameFilter: '' })
@@ -268,7 +267,9 @@ function PopulationCourseStats(props) {
}
const onCourseNameCellClick = code => {
const courseStatistic = props.populationCourses.data.coursestatistics.find(cs => cs.course.code === code)
const courseStatistic =
props.populationCourses.data.coursestatistics?.find(cs => cs.course.code === code) ||
props.courses.coursestatistics?.find(cs => cs.course.code === code)
if (courseStatistic) {
const isSelected = courseIsSelected(code)
const name = 'Course Filtername'
@@ -397,19 +398,6 @@ function PopulationCourseStats(props) {
)
}
PopulationCourseStats.propTypes = {
courses: shape({
coursestatistics: arrayOf(object),
}).isRequired,
populationCourses: shape({
data: shape({ coursestatistics: arrayOf(shape({ course: shape({ code: string, name: shape({}) }) })) }),
}).isRequired,
clearCourseStats: func.isRequired,
pending: bool.isRequired,
selectedStudents: arrayOf(string).isRequired,
language: string.isRequired,
}
const mapStateToProps = ({ populationCourses }) => ({
populationCourses,
})
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react'
import { connect } from 'react-redux'
import { shape, arrayOf, string, bool } from 'prop-types'
import { useSelector } from 'react-redux'
import { Segment, Button } from 'semantic-ui-react'
import uuidv4 from 'uuid/v4'
import SegmentDimmer from '../SegmentDimmer'
@@ -11,15 +10,13 @@ import FilterDegreeCoursesModal from './FilterDegreeCoursesModal'
import useCourseFilter from '../FilterTray/filters/Courses/useCourseFilter'
import infotooltips from '../../common/InfoToolTips'
const PopulationCourses = ({
populationSelectedStudentCourses,
populationCourses,
selectedStudents,
query,
filteredStudents,
}) => {
const PopulationCourses = ({ selectedStudents, query = {}, filteredStudents }) => {
const { setCoursesOnce, resetCourses, runCourseQuery } = useCourseFilter()
const [showByStudytrack, setShowByStudytrack] = useState(true)
const populationCourses = useSelector(({ populationCourses }) => populationCourses)
const populationSelectedStudentCourses = useSelector(
({ populationSelectedStudentCourses }) => populationSelectedStudentCourses
)
const selectedPopulationCourses = populationSelectedStudentCourses.data
? populationSelectedStudentCourses
@@ -89,7 +86,6 @@ const PopulationCourses = ({
<PopulationCourseStats
key={selectedPopulationCourses.query.uuid}
courses={selectedPopulationCourses.data}
query={selectedPopulationCourses.query}
pending={pending}
selectedStudents={selectedStudents}
/>
@@ -100,21 +96,4 @@ const PopulationCourses = ({
)
}
PopulationCourses.defaultPropTypes = {
query: {},
}
PopulationCourses.propTypes = {
populationSelectedStudentCourses: shape({ query: shape({}), data: shape({}), pending: bool }).isRequired,
populationCourses: shape({ query: shape({}), data: shape({}), pending: bool }).isRequired,
selectedStudents: arrayOf(string).isRequired,
query: shape({}).isRequired,
filteredStudents: arrayOf(shape({})).isRequired,
}
const mapStateToProps = ({ populationSelectedStudentCourses, populationCourses }) => ({
populationCourses,
populationSelectedStudentCourses,
})
export default connect(mapStateToProps)(PopulationCourses)
export default PopulationCourses
@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { Table, Progress, Icon } from 'semantic-ui-react'
import { number, func } from 'prop-types'
import ExternalCreditFilterToggle from './ExternalCreditFilterToggle'
const CollapsibleCreditRow = ({ min, max, studentCount, filteredLength, months }) => {
@@ -76,17 +76,4 @@ const CollapsibleCreditRow = ({ min, max, studentCount, filteredLength, months }
)
}
CollapsibleCreditRow.propTypes = {
min: number,
max: number,
studentCount: func.isRequired,
filteredLength: number.isRequired,
months: number.isRequired,
}
CollapsibleCreditRow.defaultProps = {
min: undefined,
max: undefined,
}
export default CollapsibleCreditRow
@@ -1,62 +1,59 @@
import React, { useMemo } from 'react'
import PropTypes from 'prop-types'
import { useLocation } from 'react-router-dom'
import { Table } from 'semantic-ui-react'
import { getMonths } from '../../../common/query'
import { getStudentTotalCredits } from '../../../common'
import CollapsibleCreditRow from './CollapsibleCreditRow'
import React, { useState } from 'react'
import { Divider, Icon, Grid, Label } from 'semantic-ui-react'
const CreditsGainedTab = ({ filteredStudents }) => {
const months = getMonths(useLocation())
const creditList = useMemo(() => filteredStudents.map(student => getStudentTotalCredits(student)), [filteredStudents])
import CreditsGainedTable from './CreditsGainedTable'
const studentCount = (min, max = Infinity) =>
max === 0
? creditList.filter(credits => credits === 0).length
: creditList.filter(credits => credits < max && credits >= min).length
const admissionTypes = [
'Todistusvalinta',
'Koepisteet',
'Yhteispisteet',
'Avoin väylä',
'Kilpailumenestys',
'Muu',
null,
]
const limits = [
[Math.ceil(months * (60 / 12))],
[Math.ceil(months * (45 / 12)), Math.ceil(months * (60 / 12))],
[Math.ceil(months * (30 / 12)), Math.ceil(months * (45 / 12))],
[Math.ceil(months * (15 / 12)), Math.ceil(months * (30 / 12))],
[1, Math.ceil(months * (15 / 12))],
[null, 0],
]
const CreditsGainedTab = ({ allStudents, query }) => {
const [show, setShow] = useState(false)
if (!allStudents || !allStudents.length || !query) return null
const { studyRights } = query
const filterFunction = (student, type) =>
student.studyrights.some(
sr => sr.studyright_elements.some(e => e.code === studyRights?.programme) && type === sr.admission_type
)
const getCreditsGainedTable = type => {
const filteredStudents = allStudents.filter(s => filterFunction(s, type))
return <CreditsGainedTable type={type || 'Ei valintatapaa'} filteredStudents={filteredStudents} />
}
const admissionTypesAvailable = !allStudents.every(s => filterFunction(s, null))
return (
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell collapsing />
<Table.HeaderCell>Credits Gained During First {months} Months</Table.HeaderCell>
<Table.HeaderCell>
Number of Students
<br />
<span style={{ fontWeight: 100 }}>(n={filteredStudents.length})</span>
</Table.HeaderCell>
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{limits.map(([min, max]) => (
<CollapsibleCreditRow
key={`table-row-${min}-${max}`}
min={min}
max={max}
studentCount={studentCount}
filteredLength={filteredStudents.length}
months={Number(months)}
/>
))}
</Table.Body>
</Table>
<Grid>
<Grid.Row>
<Grid.Column width={16} data-cy="credits-gained-main-table">
<CreditsGainedTable type="All students of the population" filteredStudents={allStudents} />
</Grid.Column>
</Grid.Row>
{admissionTypesAvailable && (
<Divider
className="credits-gained-divider"
horizontal
style={{ cursor: 'pointer' }}
onClick={() => setShow(!show)}
>
By admission type <Icon name={`angle ${show ? 'down' : 'right'}`} />
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
NEW!
</Label>
</Divider>
)}
{show && <Grid.Row>{admissionTypes.map(type => getCreditsGainedTable(type))}</Grid.Row>}
</Grid>
)
}
CreditsGainedTab.propTypes = {
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
}
export default CreditsGainedTab
@@ -0,0 +1,62 @@
import React, { useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import { Table } from 'semantic-ui-react'
import { getMonths } from '../../../common/query'
import { getStudentTotalCredits } from '../../../common'
import CollapsibleCreditRow from './CollapsibleCreditRow'
const CreditsGainedTable = ({ filteredStudents, type }) => {
const months = getMonths(useLocation())
const creditList = useMemo(() => filteredStudents.map(student => getStudentTotalCredits(student)), [filteredStudents])
if (!filteredStudents || !filteredStudents.length || !type) return null
const studentCount = (min, max = Infinity) =>
max === 0
? creditList.filter(credits => credits === 0).length
: creditList.filter(credits => credits < max && credits >= min).length
const limits = [
[Math.ceil(months * (60 / 12))],
[Math.ceil(months * (45 / 12)), Math.ceil(months * (60 / 12))],
[Math.ceil(months * (30 / 12)), Math.ceil(months * (45 / 12))],
[Math.ceil(months * (15 / 12)), Math.ceil(months * (30 / 12))],
[1, Math.ceil(months * (15 / 12))],
[null, 0],
]
return (
<div className="credits-gained-table" data-cy={`credits-gained-table-${type}`}>
<h3>{type}</h3>
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell collapsing />
<Table.HeaderCell>Credits Gained During First {months} Months</Table.HeaderCell>
<Table.HeaderCell>
Number of Students
<br />
<span style={{ fontWeight: 100 }}>(n={filteredStudents.length})</span>
</Table.HeaderCell>
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body data-cy="credits-gained-table-body">
{limits.map(([min, max]) => (
<CollapsibleCreditRow
key={`table-row-${min}-${max}`}
min={min}
max={max}
studentCount={studentCount}
filteredLength={filteredStudents.length}
months={Number(months)}
/>
))}
</Table.Body>
</Table>
</div>
)
}
export default CreditsGainedTable
@@ -1,55 +1,52 @@
import React from 'react'
import { jStat } from 'jstat'
import { Table } from 'semantic-ui-react'
import { getStudentTotalCredits } from '../../../common'
import { Divider, Grid, Label } from 'semantic-ui-react'
const StatisticsTab = ({ filteredStudents }) => {
const credits = filteredStudents.map(student => getStudentTotalCredits(student))
const formatNumber = (x, decimals) => (Number.isNaN(x) || !x ? 0 : x).toFixed(decimals)
const quartiles = jStat.quartiles(credits)
import StatisticsTable from './StatisticsTable'
const admissionTypes = [
'Todistusvalinta',
'Koepisteet',
'Yhteispisteet',
'Avoin väylä',
'Kilpailumenestys',
'Muu',
null,
]
const StatisticsTab = ({ allStudents, query }) => {
if (!allStudents || !allStudents.length || !query) return null
const { studyRights } = query
const filterFunction = (student, type) =>
student.studyrights.some(
sr => sr.studyright_elements.some(e => e.code === studyRights?.programme) && type === sr.admission_type
)
const getStatisticsTable = type => {
const filteredStudents = allStudents.filter(s => filterFunction(s, type))
return <StatisticsTable type={type || 'Ei valintatapaa'} filteredStudents={filteredStudents} />
}
const admissionTypesAvailable = !allStudents.every(s => filterFunction(s, null))
return (
<Table celled collapsing className="statistics-table">
<Table.Header>
<Table.Row>
<Table.HeaderCell data-cy="credit-stats-table-name-header">
{`Statistic for n = ${credits.length} Students`}
</Table.HeaderCell>
<Table.HeaderCell>Credits Earned</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Average</Table.Cell>
<Table.Cell data-cy="credit-stats-mean">{formatNumber(jStat.mean(credits), 2)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Standard Deviation</Table.Cell>
<Table.Cell data-cy="credit-stats-stdev">{formatNumber(jStat.stdev(credits), 2)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Min</Table.Cell>
<Table.Cell data-cy="credit-stats-min">{formatNumber(jStat.min(credits), 0)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>1st Quartile (25 %)</Table.Cell>
<Table.Cell data-cy="credit-stats-q1">{formatNumber(quartiles[0], 0)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>2nd Quartile (50 %)</Table.Cell>
<Table.Cell data-cy="credit-stats-q2">{formatNumber(quartiles[1], 0)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>3rd Quartile (75 %)</Table.Cell>
<Table.Cell data-cy="credit-stats-q3">{formatNumber(quartiles[2], 0)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Max</Table.Cell>
<Table.Cell data-cy="credit-stats-max">{formatNumber(jStat.max(credits), 0)}</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
<Grid padded centered>
<Grid.Row>
<StatisticsTable type="All students of the population" filteredStudents={allStudents} />
</Grid.Row>
{admissionTypesAvailable && (
<>
<Divider horizontal>
By admission type
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
NEW!
</Label>
</Divider>
<Grid.Row>{admissionTypes.map(type => getStatisticsTable(type))}</Grid.Row>
</>
)}
</Grid>
)
}
@@ -0,0 +1,53 @@
import React from 'react'
import * as _ from 'lodash'
import { Table } from 'semantic-ui-react'
import { getStudentTotalCredits } from '../../../common'
const StatisticsTable = ({ filteredStudents, type }) => {
if (!filteredStudents || !filteredStudents.length) return null
const credits = filteredStudents.map(student => getStudentTotalCredits(student))
const formatNumber = (x, decimals) => (Number.isNaN(x) || !x ? 0 : x).toFixed(decimals)
const mean = _.mean(credits)
const stdev = Math.sqrt(_.sum(_.map(credits, c => (c - mean) * (c - mean))) / credits.length)
return (
<div className="statistics-table">
<h3>{type}</h3>
<Table celled collapsing>
<Table.Header>
<Table.Row>
<Table.HeaderCell data-cy="credit-stats-table-name-header">
{`Statistic for n = ${credits.length} Students`}
</Table.HeaderCell>
<Table.HeaderCell>Credits Earned</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Total</Table.Cell>
<Table.Cell data-cy="credit-stats-total">{formatNumber(_.sum(credits))}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Average</Table.Cell>
<Table.Cell data-cy="credit-stats-mean">{formatNumber(mean, 2)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Standard Deviation</Table.Cell>
<Table.Cell data-cy="credit-stats-stdev">{formatNumber(stdev, 2)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Min</Table.Cell>
<Table.Cell data-cy="credit-stats-min">{formatNumber(_.min(credits), 0)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Max</Table.Cell>
<Table.Cell data-cy="credit-stats-max">{formatNumber(_.max(credits), 0)}</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</div>
)
}
export default StatisticsTable
@@ -1,5 +1,14 @@
#credit-gain-stats .statistics-table {
margin: 0 auto;
margin: 10px auto;
}
#credit-gain-stats .credits-gained-table {
margin: 10px auto;
}
#credit-gain-stats .credits-gained-divider:hover {
color: gray;
cursor: pointer;
}
#credit-gain-stats .collapsing + th,
@@ -1,5 +1,4 @@
import React, { useCallback } from 'react'
import PropTypes from 'prop-types'
import { Tab } from 'semantic-ui-react'
import { useTabChangeAnalytics } from '../../../common/hooks'
import InfoBox from '../../InfoBox'
@@ -8,11 +7,11 @@ import CreditsGainedTab from './CreditsGainedTab'
import infotooltips from '../../../common/InfoToolTips'
import './creditGainStats.css'
const CreditGainStats = ({ filteredStudents }) => {
const CreditGainStats = ({ filteredStudents, query }) => {
const renderCreditsGainTab = useCallback(() => {
return (
<Tab.Pane attached={false}>
<CreditsGainedTab filteredStudents={filteredStudents} />
<CreditsGainedTab allStudents={filteredStudents} query={query} />
</Tab.Pane>
)
}, [filteredStudents])
@@ -20,7 +19,7 @@ const CreditGainStats = ({ filteredStudents }) => {
const renderQuartersTab = useCallback(() => {
return (
<Tab.Pane attached={false}>
<StatisticsTab filteredStudents={filteredStudents} />
<StatisticsTab allStudents={filteredStudents} query={query} />
</Tab.Pane>
)
}, [filteredStudents])
@@ -51,8 +50,4 @@ const CreditGainStats = ({ filteredStudents }) => {
)
}
CreditGainStats.propTypes = {
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
}
export default CreditGainStats
@@ -1,7 +1,7 @@
import React, { useRef } from 'react'
import { connect } from 'react-redux'
import { object, arrayOf, bool, shape, node } from 'prop-types'
import { Message, Accordion, Label } from 'semantic-ui-react'
import { Message, Accordion } from 'semantic-ui-react'
import { useLocalStorage } from '../../common/hooks'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
@@ -18,7 +18,7 @@ import infotooltips from '../../common/InfoToolTips'
const sendAnalytics = sendEvent.populationStatistics
const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStudentsByYear, dataExport }) => {
const PopulationDetails = ({ samples, queryIsSet, isLoading, query, dataExport }) => {
const { allStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray('filterTray')
const { language } = useLanguage()
@@ -58,11 +58,9 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
const graphs = (
<CreditAccumulationGraphHighCharts
students={samples}
students={filteredStudents}
title="Id"
label={samples.label}
maxCredits={samples.maxCredits}
selectedStudents={selectedStudents}
trayOpen={trayOpen}
language={language}
/>
@@ -102,7 +100,10 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
key: 1,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
<span
style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}
data-cy="credit-statistics"
>
Credit statistics
</span>
),
@@ -111,7 +112,7 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
content: {
content: !query.years ? (
<div ref={creditGainRef}>
<CreditGainStats filteredStudents={filteredStudents} />
<CreditGainStats query={query} filteredStudents={filteredStudents} />
</div>
) : (
<div>This table is omitted when searching population of multiple years</div>
@@ -124,9 +125,6 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Age distribution
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
NEW!
</Label>
</span>
),
},
@@ -150,7 +148,6 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
<div ref={courseTableRef}>
<PopulationCourses
selectedStudents={selectedStudents}
selectedStudentsByYear={selectedStudentsByYear}
query={query}
allStudents={allStudents}
filteredStudents={filteredStudents}
@@ -190,7 +187,6 @@ PopulationDetails.propTypes = {
samples: arrayOf(object).isRequired,
queryIsSet: bool.isRequired,
isLoading: bool.isRequired,
selectedStudentsByYear: shape({}).isRequired,
query: shape({}).isRequired,
dataExport: node,
}
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import qs from 'query-string'
import { func, arrayOf, shape, bool, string, object, oneOfType } from 'prop-types'
import { Form, Button, Message, Icon, Grid } from 'semantic-ui-react'
import uuidv4 from 'uuid/v4'
import Datetime from 'react-datetime'
@@ -21,7 +20,6 @@ import { momentFromFormat, reformatDate, textAndDescriptionSearch, getTextIn, ca
import { useSearchHistory } from '../../common/hooks'
import { setLoading } from '../../redux/graphSpinner'
import './populationSearch.css'
import { dropdownType } from '../../constants/types'
import SearchHistory from '../SearchHistory'
const YEAR_DATE_FORMAT = 'YYYY'
@@ -568,28 +566,6 @@ const PopulationSearchForm = props => {
)
}
PopulationSearchForm.propTypes = {
language: string.isRequired,
getProgrammes: func.isRequired,
getPopulationStatistics: func.isRequired,
getPopulationCourses: func.isRequired,
getMandatoryCourses: func.isRequired,
queries: shape({}).isRequired,
studyProgrammes: shape({}), //eslint-disable-line
studyTracks: arrayOf(dropdownType), //eslint-disable-line
setLoading: func.isRequired,
pending: bool, //eslint-disable-line
getSemesters: func.isRequired,
semesters: shape({}).isRequired,
history: shape({}).isRequired,
location: shape({}).isRequired,
getTagsByStudytrackAction: func.isRequired,
tags: oneOfType([arrayOf(shape({ tag_id: string, tagname: string })), object]).isRequired,
onProgress: func.isRequired,
clearSelected: func.isRequired,
clearPopulations: func.isRequired,
}
const mapStateToProps = ({ semesters, settings, populations, populationProgrammes, tags }) => {
const { language } = settings
const { pending } = populationProgrammes
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Segment, Header, Divider, Form, Button, Icon } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
@@ -41,19 +40,6 @@ const PopulationSearch = ({ populationFound, history, location, loading }) => {
)
}
PopulationSearch.propTypes = {
history: PropTypes.shape({
location: PropTypes.shape({
search: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
location: PropTypes.shape({
search: PropTypes.string.isRequired,
}).isRequired,
populationFound: PropTypes.bool.isRequired,
loading: PropTypes.bool.isRequired,
}
const mapStateToProps = ({ populations }) => ({
populationFound: populations.data.students !== undefined,
loading: !!populations.pending,
@@ -1,9 +1,9 @@
import React, { memo, useEffect } from 'react'
import { connect } from 'react-redux'
import React, { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { createSelector } from '@reduxjs/toolkit'
import { useLocation, useHistory } from 'react-router-dom'
import { Header, Segment } from 'semantic-ui-react'
import { flattenDeep } from 'lodash'
import PopulationDetails from '../PopulationDetails'
import { getTotalCreditsFromCourses } from '../../common'
import { useTitle } from '../../common/hooks'
import selectors from '../../selectors/populationDetails'
import FilterTray from '../FilterTray'
@@ -12,9 +12,23 @@ import { PopulationStatisticsFilters } from '../FilterTray/FilterSets'
import PopulationSearch from '../PopulationSearch'
import DataExport from './DataExport'
const PopulationStatistics = memo(props => {
const { queryIsSet, selectedStudentsByYear, query, samples, location, history, isLoading, students } = props
const selectPopulations = createSelector(
({ populations }) => populations,
populations => ({
query: populations.query ?? {},
queryIsSet: !!populations.query,
isLoading: populations.pending === true,
students: populations.data.students ?? [],
})
)
const PopulationStatistics = () => {
const location = useLocation()
const history = useHistory()
const { samples } = useSelector(selectors.makePopulationsToData)
const { query, queryIsSet, isLoading, students } = useSelector(selectPopulations)
const { setAllStudents } = useFilters()
useTitle('Population statistics')
// Pass students to filter context.
@@ -33,7 +47,6 @@ const PopulationStatistics = memo(props => {
{location.search !== '' ? (
<PopulationDetails
queryIsSet={queryIsSet}
selectedStudentsByYear={selectedStudentsByYear}
query={query}
samples={samples}
isLoading={isLoading}
@@ -44,41 +57,6 @@ const PopulationStatistics = memo(props => {
</div>
</FilterTray>
)
})
const mapStateToProps = state => {
// haha copied from other place :mintu:
const { samples, selectedStudentsByYear } = selectors.makePopulationsToData(state)
// REFACTOR YES, IF YOU SEE THIS COMMENT YOU ARE OBLIGATED TO FIX IT
if (samples.length > 0) {
const creditsAndDates = samples.map(s => {
const passedCourses = s.courses.filter(c => c.passed)
const passedCredits = getTotalCreditsFromCourses(passedCourses)
const dates = passedCourses.map(c => c.date)
const datesWithCredits = passedCourses.filter(c => c.credits > 0).map(c => c.date)
return { passedCredits, dates, datesWithCredits }
})
const credits = creditsAndDates.map(cd => cd.passedCredits)
const dates = flattenDeep(creditsAndDates.map(cd => cd.dates)).map(date => new Date(date).getTime())
const datesWithCredits = flattenDeep(creditsAndDates.map(cd => cd.datesWithCredits)).map(date =>
new Date(date).getTime()
)
samples.maxCredits = Math.max(...credits)
samples.maxDate = Math.max(...dates)
samples.minDate = Math.min(...dates)
samples.minDateWithCredits = Math.min(...datesWithCredits)
}
const { populations } = state
return {
query: populations.query ? populations.query : {},
queryIsSet: !!populations.query,
selectedStudentsByYear,
samples,
isLoading: populations.pending === true,
students: populations.data.students || [],
}
}
export default connect(mapStateToProps)(PopulationStatistics)
export default PopulationStatistics
@@ -37,6 +37,8 @@ const GeneralTab = ({
const [popupStates, setPopupStates] = useState({})
const sendAnalytics = sendEvent.populationStudents
if (!populationStatistics) return null
// TODO: Refactor this away:
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
@@ -1,14 +1,12 @@
import React, { Component, Fragment } from 'react'
import { connect } from 'react-redux'
import { string, arrayOf, object, func, bool, shape, node } from 'prop-types'
import { Icon, Tab, Grid, Ref, Item, Dropdown } from 'semantic-ui-react'
import { withRouter, Link } from 'react-router-dom'
import React, { useState, useEffect, useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Icon, Tab, Grid, Item, Dropdown } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { orderBy, uniqBy, flatten, sortBy, isNumber } from 'lodash'
import scrollToComponent from 'react-scroll-to-component'
import { getTextIn, getUserRoles } from '../../common'
import { useTabChangeAnalytics } from '../../common/hooks'
import { getTextIn } from '../../common'
import { useTabChangeAnalytics, usePrevious, useIsAdmin } from '../../common/hooks'
import { toggleStudentListVisibility } from '../../redux/settings'
import { getTagsByStudytrackAction } from '../../redux/tags'
import { getStudentTagsByStudytrackAction } from '../../redux/tagstudent'
@@ -24,79 +22,95 @@ import GeneralTab from './StudentTable/GeneralTab'
import sendEvent, { ANALYTICS_CATEGORIES } from '../../common/sendEvent'
import infotoolTips from '../../common/InfoToolTips'
// TODO: Refactoring in process, contains lot of duplicate code.
const sendAnalytics = sendEvent.populationStudents
const StudentTableTabs = ({ panes, filterPanes }) => {
// only its own component really because I needed to use hooks and didn't want to refactor
// this megamillion line component :) /Joona
const PopulationStudents = ({
language,
filteredStudents,
studentToTargetCourseDateMap,
dataExport,
customPopulation = false,
coursePopulation = false,
coursecode = [],
}) => {
const [state, setState] = useState({})
const studentRef = useRef()
const dispatch = useDispatch()
const { namesVisible: showNames, studentlistVisible: showList } = useSelector(({ settings }) => settings)
const { data: tags } = useSelector(({ tags }) => tags)
const { query } = useSelector(({ populations }) => populations)
const queryStudyrights = query ? Object.values(query.studyRights) : []
const { data: mandatoryCourses } = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses)
const { data: populationCourses } = useSelector(({ populationCourses }) => populationCourses)
const prevShowList = usePrevious(showList)
const admin = useIsAdmin()
const { handleTabChange } = useTabChangeAnalytics(
ANALYTICS_CATEGORIES.populationStudents,
'Change students table tab'
)
return <Tab onTabChange={handleTabChange} panes={filterPanes(panes)} data-cy="student-table-tabs" />
}
useEffect(() => {
if (tags && tags.length > 0) return
const queryStudyright = queryStudyrights[0]
dispatch(getTagsByStudytrackAction(queryStudyright))
dispatch(getStudentTagsByStudytrackAction(queryStudyright))
setState({ ...state, admin })
}, [])
StudentTableTabs.propTypes = {
panes: arrayOf(object).isRequired,
filterPanes: func.isRequired,
}
class PopulationStudents extends Component {
constructor(props) {
super(props)
this.state = {}
this.studentsRef = React.createRef()
}
componentDidMount() {
const queryStudyright = this.props.queryStudyrights[0]
const admin = this.props.userRoles.includes('admin')
if (queryStudyright) {
this.props.getTagsByStudytrack(queryStudyright)
this.props.getStudentTagsStudyTrack(queryStudyright)
useEffect(() => {
if (!prevShowList && showList) {
scrollToComponent(studentRef.current, { align: 'bottom' })
}
this.setState({ admin })
}, [prevShowList])
const mandatoryCodes = mandatoryCourses.filter(course => course.visible && course.visible.visibility).map(c => c.code)
let mandatoryPassed = {}
if (populationCourses.coursestatistics) {
const mandatorycodesMapCourseCodeToCourseID = new Map()
mandatoryCourses
.filter(course => course.visible && course.visible.visibility)
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
const courses = populationCourses.coursestatistics
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
const foundCourse = !!courses.find(c => c.course.code === code)
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
let foundSubsCourse
if (searchCode) {
foundSubsCourse = courses.find(c => {
if (!c.course.substitutions) return false
if (c.course.substitutions.length === null) return false
return c.course.substitutions.includes(searchCode)
})
}
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
if (foundSubsCourse) {
passedArray = passedArray
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
: Object.keys(foundSubsCourse.students.passed)
}
obj[code] = passedArray
return obj
}, {})
}
componentDidUpdate = prevProps => {
if (!prevProps.showList && this.props.showList) {
scrollToComponent(this.studentsRef.current, { align: 'bottom' })
}
}
studyrightCodes = (studyrights, value) => {
const { queryStudyrights } = this.props
return studyrights
.filter(sr => {
const { studyright_elements: studyrightElements } = sr
return studyrightElements.filter(sre => queryStudyrights.includes(sre.code)).length >= queryStudyrights.length
})
.map(a => a[value])
}
handleRef = node => {
this.studentsRef.current = node
}
renderStudentTable() {
const { customPopulation, coursePopulation } = this.props
const renderStudentTable = () => {
const verticalTitle = title => <div className="verticalTitle">{title}</div>
const hasPassedMandatory = (studentNumber, code) =>
this.props.mandatoryPassed[code] && this.props.mandatoryPassed[code].includes(studentNumber)
mandatoryPassed[code] && mandatoryPassed[code].includes(studentNumber)
const totalMandatoryPassed = studentNumber => {
this.props.mandatoryCourses.reduce((acc, m) => {
mandatoryCourses.reduce((acc, m) => {
return hasPassedMandatory(studentNumber, m.code) ? acc + 1 : acc
}, 0)
}
const nameColumns = this.props.showNames
const nameColumns = showNames
? [
{
key: 'lastname',
@@ -164,7 +178,7 @@ class PopulationStudents extends Component {
const mandatoryCourseLabels = []
const labelToMandatoryCourses = this.props.mandatoryCourses.reduce((acc, e) => {
const labelToMandatoryCourses = mandatoryCourses.reduce((acc, e) => {
const label = e.label ? e.label.label : ''
acc[label] = acc[label] || []
if (acc[label].some(l => l.code === e.code)) return acc
@@ -183,14 +197,14 @@ class PopulationStudents extends Component {
const mandatoryTitle = m => {
return (
<>
{getTextIn(m.name, this.props.language)}
{getTextIn(m.name, language)}
<br />
{m.code}
</>
)
}
const { visibleLabels, visibleCourseCodes } = this.props.mandatoryCourses.reduce(
const { visibleLabels, visibleCourseCodes } = mandatoryCourses.reduce(
(acc, cur) => {
if (cur.visible && cur.visible.visibility) {
acc.visibleLabels.add(cur.label_code)
@@ -220,7 +234,11 @@ class PopulationStudents extends Component {
</div>
),
parent: true,
headerProps: { colSpan: labelToMandatoryCourses[e.label].length, title: e.label, ordernumber: e.orderNumber },
headerProps: {
colSpan: labelToMandatoryCourses[e.label].length,
title: e.label,
ordernumber: e.orderNumber,
},
}))
)
@@ -242,8 +260,8 @@ class PopulationStudents extends Component {
.map(m => ({
key: `${m.label ? m.label.label : 'fix'}-${m.code}`, // really quick and dirty fix
title: verticalTitle(mandatoryTitle(m)),
cellProps: { title: `${m.code}, ${getTextIn(m.name, this.props.language)}` },
headerProps: { title: `${m.code}, ${getTextIn(m.name, this.props.language)}` },
cellProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
headerProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
getRowVal: s => (s.total ? getTotalRowVal(s, m) : hasPassedMandatory(s.studentNumber, m.code)),
getRowContent: s => {
if (s.total) return getTotalRowVal(s, m)
@@ -257,22 +275,20 @@ class PopulationStudents extends Component {
),
]
const totals = this.props.filteredStudents.reduce(
const totals = filteredStudents.reduce(
(acc, s) => {
const passedCourses = new Set()
this.props.mandatoryCourses.forEach(m => {
mandatoryCourses.forEach(m => {
if (passedCourses.has(m.code)) return
passedCourses.add(m.code)
if (hasPassedMandatory(s.studentNumber, m.code)) ++acc[m.code]
})
return acc
},
this.props.mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
)
const mandatoryCourseData = [totals, ...this.props.filteredStudents]
const mandatoryCourseData = [totals, filteredStudents]
// FIXME: here only for refactorment
const { showNames, studentToTargetCourseDateMap, coursecode } = this.props
const panes = [
{
menuItem: 'General',
@@ -294,7 +310,7 @@ class PopulationStudents extends Component {
<Tab.Pane>
<div style={{ display: 'flex' }}>
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
{this.props.mandatoryCourses.length > 0 && (
{mandatoryCourses.length > 0 && (
<SortableTable
getRowKey={s => (s.total ? 'totals' : s.studentNumber)}
tableProps={{
@@ -308,7 +324,7 @@ class PopulationStudents extends Component {
textAlign: 'center',
}}
collapsingHeaders
showNames={this.props.showNames}
showNames={showNames}
columns={mandatoryCourseColumns}
data={mandatoryCourseData}
/>
@@ -323,7 +339,7 @@ class PopulationStudents extends Component {
render: () => (
<Tab.Pane>
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
{this.props.tags.length === 0 && (
{tags.length === 0 && (
<div
style={{
paddingLeft: '10px',
@@ -337,7 +353,7 @@ class PopulationStudents extends Component {
<h3>
No tags defined. You can define them{' '}
<Link
to={`/study-programme/${this.props.queryStudyrights[0]}?p_m_tab=0&p_tab=6`}
to={`/study-programme/${queryStudyrights[0]}?p_m_tab=0&p_tab=6`}
onClick={() => {
sendAnalytics('No tags defined button clicked', 'Tags tab')
}}
@@ -348,14 +364,14 @@ class PopulationStudents extends Component {
</h3>
</div>
)}
{this.props.tags.length > 0 && (
{tags.length > 0 && (
<>
<TagPopulation
tags={this.props.tags}
selectedStudents={this.props.filteredStudents.map(stu => stu.studentNumber)}
studytrack={this.props.queryStudyrights[0]}
tags={tags}
selectedStudents={filteredStudents.map(stu => stu.studentNumber)}
studytrack={queryStudyrights[0]}
/>
<TagList studytrack={this.props.queryStudyrights[0]} selectedStudents={this.props.filteredStudents} />
<TagList studytrack={queryStudyrights[0]} selectedStudents={filteredStudents} />
</>
)}
</div>
@@ -377,142 +393,34 @@ class PopulationStudents extends Component {
<Grid.Column>
<StudentNameVisibilityToggle />
</Grid.Column>
{this.props.dataExport && (
{dataExport && (
<Grid.Column textAlign="right">
<Dropdown text="Export Data" icon="save" button labeled className="icon" direction="left">
<Dropdown.Menu>{this.props.dataExport}</Dropdown.Menu>
<Dropdown.Menu>{dataExport}</Dropdown.Menu>
</Dropdown>
</Grid.Column>
)}
</Grid>
<StudentTableTabs panes={panes} filterPanes={filteredPanes} />
<Tab onTabChange={handleTabChange} panes={filteredPanes(panes)} data-cy="student-table-tabs" />
</>
)
}
render() {
if (this.props.filteredStudents.length === 0) {
return null
}
if (filteredStudents.length === 0) return null
return (
<Ref innerRef={this.handleRef}>
<>
<span style={{ marginRight: '0.5rem' }}>
<InfoBox
content={
this.props.coursePopulation
? infotoolTips.CoursePopulation.Students
: infotoolTips.PopulationStatistics.Students
}
/>
</span>
{this.state.admin ? (
<CheckStudentList students={this.props.filteredStudents.map(stu => stu.studentNumber)} />
) : null}
{this.renderStudentTable()}
</>
</Ref>
)
}
return (
<>
<span style={{ marginRight: '0.5rem' }} ref={studentRef}>
<InfoBox
content={
coursePopulation ? infotoolTips.CoursePopulation.Students : infotoolTips.PopulationStatistics.Students
}
/>
</span>
{admin ? <CheckStudentList students={filteredStudents.map(stu => stu.studentNumber)} /> : null}
{renderStudentTable()}
</>
)
}
PopulationStudents.defaultProps = {
studentToTargetCourseDateMap: null,
customPopulation: false,
coursePopulation: false,
dataExport: null,
coursecode: [],
}
PopulationStudents.propTypes = {
showNames: bool.isRequired,
showList: bool.isRequired,
language: string.isRequired,
queryStudyrights: arrayOf(string).isRequired,
mandatoryCourses: arrayOf(
shape({
name: shape({}).isRequired,
code: string.isRequired,
})
).isRequired,
mandatoryPassed: shape({}).isRequired,
tags: arrayOf(shape({ tag_id: string, tagname: string, studytrack: string })).isRequired,
getTagsByStudytrack: func.isRequired,
getStudentTagsStudyTrack: func.isRequired,
userRoles: arrayOf(string).isRequired,
studentToTargetCourseDateMap: shape({}),
coursePopulation: bool,
customPopulation: bool,
filteredStudents: arrayOf(shape({})).isRequired,
dataExport: node,
coursecode: arrayOf(string),
}
const mapStateToProps = state => {
const {
settings,
populations,
populationCourses,
populationMandatoryCourses,
tags,
auth: {
token: { roles },
},
} = state
const mandatoryCodes = populationMandatoryCourses.data
.filter(course => course.visible && course.visible.visibility)
.map(c => c.code)
let mandatoryPassed = {}
if (populationCourses.data.coursestatistics) {
const mandatorycodesMapCourseCodeToCourseID = new Map()
populationMandatoryCourses.data
.filter(course => course.visible && course.visible.visibility)
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
const courses = populationCourses.data.coursestatistics
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
const foundCourse = !!courses.find(c => c.course.code === code)
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
let foundSubsCourse
if (searchCode) {
foundSubsCourse = courses.find(c => {
if (!c.course.substitutions) return false
if (c.course.substitutions.length === null) return false
return c.course.substitutions.includes(searchCode)
})
}
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
if (foundSubsCourse) {
passedArray = passedArray
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
: Object.keys(foundSubsCourse.students.passed)
}
obj[code] = passedArray
return obj
}, {})
}
return {
showNames: settings.namesVisible,
showList: settings.studentlistVisible,
queryStudyrights: populations.query ? Object.values(populations.query.studyRights) : [],
mandatoryCourses: populationMandatoryCourses.data,
mandatoryPassed,
tags: tags.data,
userRoles: getUserRoles(roles),
}
}
export default connect(mapStateToProps, {
toggleStudentListVisibility,
getTagsByStudytrack: getTagsByStudytrackAction,
getStudentTagsStudyTrack: getStudentTagsByStudytrackAction,
})(withRouter(PopulationStudents))
export default PopulationStudents
@@ -1,6 +1,5 @@
import React, { useState } from 'react'
import { Table } from 'semantic-ui-react'
import { shape, arrayOf, string, func, bool, element, oneOfType } from 'prop-types'
import { sortBy } from 'lodash'
import { useChunk } from '../../common/hooks'
@@ -166,39 +165,4 @@ const SortableTable = ({
)
}
SortableTable.propTypes = {
tableProps: shape({}),
getRowKey: func.isRequired,
getRowProps: func,
columns: arrayOf(
shape({
key: string.isRequired,
title: oneOfType([element, string]),
headerProps: shape({}),
getRowVal: func,
getRowContent: func,
getCellProps: func,
cellProps: shape({}),
group: bool,
children: arrayOf(),
})
).isRequired,
data: arrayOf(shape({})).isRequired,
defaultdescending: bool,
defaultsortkey: string,
collapsingHeaders: bool,
showNames: bool,
chunkifyBy: string,
}
SortableTable.defaultProps = {
tableProps: undefined,
getRowProps: undefined,
defaultdescending: false,
defaultsortkey: null,
collapsingHeaders: false,
showNames: undefined,
chunkifyBy: undefined,
}
export default SortableTable
@@ -1,52 +1,31 @@
import React, { useState } from 'react'
import { shape, string, arrayOf } from 'prop-types'
import { Menu, Tab, Input, Message } from 'semantic-ui-react'
import { flattenDeep, cloneDeep } from 'lodash'
import { flattenDeep } from 'lodash'
import Highcharts from 'highcharts/highstock'
import ReactHighcharts from 'react-highcharts'
import CreditAccumulationGraphHighCharts from '../../CreditAccumulationGraphHighCharts'
import { byDateDesc, reformatDate, getTextIn } from '../../../common'
import TSA from '../../../common/tsa'
import useLanguage from '../../LanguagePicker/useLanguage'
const ANALYTICS_CATEGORY = 'Student stats'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const CreditsGraph = ({ graphYearStart, student, absences }) => {
const { language } = useLanguage()
const selectedStart = graphYearStart || student.started
const filteredCourses = student.courses.filter(c => new Date(c.date) > new Date(selectedStart))
const newStudent = cloneDeep(student)
newStudent.courses = filteredCourses
const sample = [newStudent]
const selectedStart = new Date(graphYearStart ?? student.started)
const dates = flattenDeep(student.courses.map(c => c.date)).map(d => new Date(d).getTime())
sample.maxCredits = newStudent.courses.reduce((a, c) => {
if (c.isStudyModuleCredit || !c.passed) return a + 0
return a + c.credits
}, 0)
sample.maxDate = dates.length > 0 ? Math.max(...dates) : new Date().getTime()
sample.minDate = new Date(selectedStart).getTime()
const endDate = dates.length > 0 ? Math.max(...dates) : new Date().getTime()
return (
<CreditAccumulationGraphHighCharts
singleStudent
students={sample}
selectedStudents={[student.studentNumber]}
title="Credit Accumulation"
maxCredits={sample.maxCredits}
students={[student]}
startDate={selectedStart}
endDate={endDate}
absences={absences}
language={language}
/>
)
}
CreditsGraph.propTypes = {
student: shape({}).isRequired,
absences: arrayOf(shape({})).isRequired,
graphYearStart: string.isRequired,
}
const chunkifyArray = (array, size = 1) => {
if (!array) return []
const firstChunk = array.slice(0, size) // create the first chunk of the given array
@@ -205,12 +184,6 @@ const GradeGraph = ({ student, semesters, language }) => {
)
}
GradeGraph.propTypes = {
student: shape({}).isRequired,
semesters: shape({}).isRequired,
language: string.isRequired,
}
const StudentGraphs = ({ student, absences, graphYearStart, semesters, language }) => {
const panes = [
{
@@ -233,12 +206,4 @@ const StudentGraphs = ({ student, absences, graphYearStart, semesters, language
return <Tab panes={panes} />
}
StudentGraphs.propTypes = {
student: shape({}).isRequired,
absences: arrayOf(shape({})).isRequired,
graphYearStart: string.isRequired,
semesters: shape({}).isRequired,
language: string.isRequired,
}
export default StudentGraphs
@@ -39,7 +39,7 @@ const StudentDetails = ({
}) => {
const dispatch = useDispatch()
const { language } = useLanguage()
const [graphYearStart, setGraphYear] = useState('')
const [graphYearStart, setGraphYear] = useState(null)
const [studyrightid, setStudyrightid] = useState('')
const [honoursCode, setHonoursCode] = useState(null)
@@ -49,7 +49,7 @@ const StudentDetails = ({
}, [])
useEffect(() => {
setGraphYear('')
setGraphYear(null)
if (studentNumber.length > 0) getStudent(studentNumber)
else {
resetStudent()
@@ -151,7 +151,7 @@ const StudentDetails = ({
const handleStartDateChange = (elements, id) => {
if (id === studyrightid) {
setGraphYear('')
setGraphYear(null)
setStudyrightid('')
return
}
@@ -45,15 +45,7 @@ const StudentInfoCard = ({ student }) => {
<div className="startDate">{`Started: ${reformatDate(student.started, DISPLAY_DATE_FORMAT)}`}</div>
<div className="startDate">{email}</div>
</Card.Meta>
<Card.Description>
{`Credits: ${student.credits || 0}`}
{student.mismatch && '*'}
<p>
<i>
{student.mismatch && '*There is difference between the total credits of this student in Oodi and Sisu'}
</i>
</p>
</Card.Description>
<Card.Description>{`Credits: ${student.credits || 0}`}</Card.Description>
<div style={{ paddingTop: '4px' }}>
<EnrollmentAccordion semesterEnrollments={student.semesterenrollments} />
<p style={{ fontSize: 14 }}>{`Updated at ${formattedTimestamp}`}</p>
@@ -1,63 +1,95 @@
/* eslint-disable react/prop-types */
// temp disable prop types
import React, { useEffect, useState, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Button, Loader, Header, Accordion, Divider, Segment } from 'semantic-ui-react'
import React, { useCallback, useEffect, useState, useRef } from 'react'
import { useSelector } from 'react-redux'
import { Button, Header, Accordion, Divider, Label, Segment } from 'semantic-ui-react'
import { useHistory } from 'react-router-dom'
import scrollToComponent from 'react-scroll-to-component'
import { getCustomPopulation } from '../../redux/populations'
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
import StyledMessage from './StyledMessage'
import { getTextIn } from '../../common'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
import useFilterTray from '../FilterTray/useFilterTray'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
import PopulationStudents from '../PopulationStudents'
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
import InfoBox from '../InfoBox'
import infotooltips from '../../common/InfoToolTips'
import { getTextIn } from 'common'
import { useToggle } from 'common/hooks'
import FilterTray from 'components/FilterTray'
import useFilters from 'components/FilterTray/useFilters'
import PopulationStudents from 'components/PopulationStudents'
import CreditAccumulationGraphHighCharts from 'components/CreditAccumulationGraphHighCharts'
import { StudyGuidanceGroupFilters } from 'components/FilterTray/FilterSets'
import { useGetStudyGuidanceGroupPopulationQuery } from 'redux/studyGuidanceGroups'
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
import StudyGuidanceGroupPopulationCourses from './StudyGuidanceGroupPopulationCourses'
import { startYearToAcademicYear, Wrapper, StyledMessage } from './common'
const SingleStudyGroupContent = ({ population, language }) => {
const custompop = population?.students || []
const { setAllStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const creditGainRef = useRef()
const programmeRef = useRef()
const coursesRef = useRef()
const studentRef = useRef()
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
const [activeIndex, setIndex] = useState([])
const [newestIndex, setNewest] = useState(null)
const createAcademicYearStartDate = year => new Date(year, 7, 1)
const handleClick = index => {
const indexes = [...activeIndex].sort()
if (indexes.includes(index)) {
indexes.splice(
indexes.findIndex(ind => ind === index),
1
)
} else {
indexes.push(index)
const takeOnlyCoursesStartingFromGivenAcademicYear = ({ students, year }) => {
if (!year) return students
const academicYearStartDate = createAcademicYearStartDate(year)
return students.map(student => ({
...student,
courses: student.courses.filter(course => new Date(course.date) > academicYearStartDate),
}))
}
const useIsMounted = () => {
const isMounted = useRef(false)
useEffect(() => {
isMounted.current = true
return () => {
isMounted.current = false
}
if (activeIndex.length < indexes.length) setNewest(index)
else setNewest(null)
setIndex(indexes)
}, [])
return useCallback(() => isMounted.current, [])
}
const useToggleAndSetNewestIndex = ({ defaultValue, indexOfPanelToScroll, setNewestIndex, isMounted }) => {
const [state, toggle] = useToggle(defaultValue)
useEffect(() => {
if (isMounted()) setNewestIndex(indexOfPanelToScroll)
}, [state])
return [state, toggle]
}
const SingleStudyGroupContent = ({ population, group, language }) => {
const isMounted = useIsMounted()
const { setAllStudents, filteredStudents } = useFilters()
const refs = [useRef(), useRef(), useRef(), useRef()]
const [activeIndex, setActiveIndex] = useState([])
const [newestIndex, setNewestIndex] = useState(null)
const [creditsStartingFromAssociatedYear, toggleCreditsStartingFromAssociatedYear] = useToggleAndSetNewestIndex({
defaultValue: !!group.tags?.year,
indexOfPanelToScroll: 0,
setNewestIndex,
isMounted,
})
const [coursesStructuredByProgramme, toggleCoursesStructuredByProgramme] = useToggleAndSetNewestIndex({
defaultValue: !!group.tags?.studyProgramme,
indexOfPanelToScroll: 1,
setNewestIndex,
isMounted,
})
const filteredStudentsWithFilteredCourses = takeOnlyCoursesStartingFromGivenAcademicYear({
students: filteredStudents,
year: group.tags?.year,
})
const togglePanel = index => {
const currentActiveIndex = new Set(activeIndex)
if (currentActiveIndex.has(index)) {
currentActiveIndex.delete(index)
} else {
currentActiveIndex.add(index)
setNewestIndex(index)
}
setActiveIndex([...currentActiveIndex])
}
useEffect(() => {
setAllStudents(custompop)
handleClick(0)
}, [custompop])
if (newestIndex) scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
}, [newestIndex])
useEffect(() => {
if (newestIndex) {
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
}
}, [activeIndex])
setAllStudents(population?.students || [])
togglePanel(0)
}, [population])
const panels = [
{
@@ -65,20 +97,22 @@ const SingleStudyGroupContent = ({ population, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Credit accumulation (for {selectedStudents.length} students)
Credit accumulation (for {filteredStudents.length} students)
</span>
),
},
onTitleClick: () => handleClick(0),
onTitleClick: () => togglePanel(0),
content: {
content: (
<div ref={creditGainRef}>
<div ref={refs[0]}>
{group.tags?.year && (
<Button primary onClick={() => toggleCreditsStartingFromAssociatedYear()}>
{creditsStartingFromAssociatedYear ? 'Show all credits' : 'Show starting from associated year'}
</Button>
)}
<CreditAccumulationGraphHighCharts
students={custompop}
selectedStudents={selectedStudents}
render={false}
trayOpen={trayOpen}
language={language}
students={creditsStartingFromAssociatedYear ? filteredStudentsWithFilteredCourses : filteredStudents}
startDate={creditsStartingFromAssociatedYear && createAcademicYearStartDate(group.tags?.year)}
/>
</div>
),
@@ -89,16 +123,20 @@ const SingleStudyGroupContent = ({ population, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Programme distribution
Courses of population
</span>
),
},
onTitleClick: () => handleClick(1),
onTitleClick: () => togglePanel(1),
content: {
content: (
<div ref={programmeRef}>
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
<CustomPopulationProgrammeDist samples={custompop} selectedStudents={selectedStudents} />
<div ref={refs[1]}>
<StudyGuidanceGroupPopulationCourses
selectedStudents={filteredStudents.map(({ studentNumber }) => studentNumber)}
showStructured={coursesStructuredByProgramme}
toggleShowStructured={toggleCoursesStructuredByProgramme}
studyProgramme={group.tags?.studyProgramme}
/>
</div>
),
},
@@ -108,33 +146,15 @@ const SingleStudyGroupContent = ({ population, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Courses of population
Students ({filteredStudents.length})
</span>
),
},
onTitleClick: () => handleClick(2),
onTitleClick: () => togglePanel(2),
content: {
content: (
<div ref={coursesRef}>
<CustomPopulationCourses selectedStudents={selectedStudents} />
</div>
),
},
},
{
key: 3,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Students ({selectedStudents.length})
</span>
),
},
onTitleClick: () => handleClick(3),
content: {
content: (
<div ref={studentRef}>
<PopulationStudents language={language} filteredStudents={filteredStudents} customPopulation />
<div ref={refs[2]}>
<PopulationStudents customPopulation language={language} filteredStudents={filteredStudents} />
</div>
),
},
@@ -142,73 +162,73 @@ const SingleStudyGroupContent = ({ population, language }) => {
]
return (
<FilterTray filterSet={<CustomPopulationFilters />}>
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
<FilterTray filterSet={<StudyGuidanceGroupFilters />}>
<Segment className="contentSegment">
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
</Segment>
</FilterTray>
)
}
const SingleStudyGroupViewWrapper = ({ history, groupName, language, children }) => {
const SingleStudyGroupViewWrapper = ({ group, language, isLoading, studyProgrammes, children }) => {
const history = useHistory()
const handleBack = () => {
history.push('/studyguidancegroups')
}
return (
<>
<div className="segmentContainer">
<Segment className="contentSegment">
<Button icon="arrow circle left" content="Back" onClick={() => history.push('/studyguidancegroups')} />
<Divider />
<Header size="medium">{getTextIn(groupName, language)}</Header>
</Segment>
</div>
<Wrapper isLoading={isLoading}>
<Button icon="arrow circle left" content="Back" onClick={handleBack} />
<Divider />
<div style={{ display: 'flex', gap: '8px', alignItems: 'baseline' }}>
<Header size="medium" style={{ marginRight: 'auto' }}>
{group.name && language && getTextIn(group.name, language)}
</Header>
{group.tags?.studyProgramme && (
<Label tag content={studyProgrammes.find(p => p.value === group.tags.studyProgramme)?.text} color="blue" />
)}
{group.tags?.year && <Label tag content={startYearToAcademicYear(group.tags.year)} color="blue" />}
</div>
</Wrapper>
{children}
</>
)
}
const SingleStudyGuidanceGroupContainer = ({ groupid }) => {
const dispatch = useDispatch()
const history = useHistory()
const SingleStudyGuidanceGroupContainer = ({ group }) => {
const { language } = useSelector(({ settings }) => settings)
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const group = groups.find(g => g.id === groupid)
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
const { data: population, pending } = useSelector(({ populations }) => populations)
useEffect(() => {
if (!group) return
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
if (groupStudentNumbers.length === 0) return
dispatch(getCustomPopulation({ studentnumberlist: groupStudentNumbers, usingStudyGuidanceGroups: true }))
dispatch(
getCustomPopulationCoursesByStudentnumbers({
studentnumberlist: groupStudentNumbers,
usingStudyGuidanceGroups: true,
})
)
}, [])
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
const { data, isLoading } = useGetStudyGuidanceGroupPopulationQuery(groupStudentNumbers)
if (!group) {
return (
<StyledMessage>
Couldn't find group with id {groupid}! Please check that id is correct and you have rights to this study
guidance group.
</StyledMessage>
<SingleStudyGroupViewWrapper>
<StyledMessage>
Couldn't find group with this id! Please check that id is correct and you have rights to this study guidance
group.
</StyledMessage>
</SingleStudyGroupViewWrapper>
)
}
const isLoading = pending === undefined || pending === true
const isLoaded = pending === false && Object.keys(population).length > 0
const renderMessage = groupStudentNumbers.length === 0
const renderLoader = groupStudentNumbers.length > 0 && isLoading
const renderGroup = groupStudentNumbers.length > 0 && isLoaded
if (groupStudentNumbers.length === 0) {
return (
<SingleStudyGroupViewWrapper>
<StyledMessage>This study guidance group doesn't contain any students.</StyledMessage>
</SingleStudyGroupViewWrapper>
)
}
return (
<SingleStudyGroupViewWrapper history={history} groupName={group.name} language={language}>
{renderMessage ? <StyledMessage>This study guidance group doesn't contain any students.</StyledMessage> : null}
{renderLoader ? (
<Loader active inline="centered">
Loading
</Loader>
) : null}
{renderGroup ? <SingleStudyGroupContent population={population} language={language} /> : null}
<SingleStudyGroupViewWrapper
group={group}
language={language}
isLoading={isLoading}
studyProgrammes={studyProgrammes}
>
{!isLoading && <SingleStudyGroupContent population={data} group={group} language={language} />}
</SingleStudyGroupViewWrapper>
)
}
@@ -1,38 +1,180 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import SortableTable from '../SortableTable'
import { getTextIn } from '../../common'
import StyledMessage from './StyledMessage'
import { Link, useHistory } from 'react-router-dom'
import { Formik } from 'formik'
import { Form, Button, Icon } from 'semantic-ui-react'
import Datetime from 'react-datetime'
import { getTextIn, textAndDescriptionSearch } from 'common'
import { useChangeStudyGuidanceGroupTagsMutation } from 'redux/studyGuidanceGroups'
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
import { useToggle } from 'common/hooks'
import SortableTable from 'components/SortableTable'
import { startYearToAcademicYear, StyledMessage } from './common'
const StudyGuidanceGroupOverview = () => {
const LinkToGroup = ({ group, language }) => {
const history = useHistory()
const dest = `/studyguidancegroups/${group.id}`
return (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={dest}
>
{getTextIn(group.name, language)}
<Icon color="blue" name="level up alternate" onClick={() => history.push(dest)} />
</Link>
)
}
const prettifyCamelCase = str => {
const splitted = str.match(/[A-Za-z][a-z]*/g) || []
return splitted.map(w => w.charAt(0).toLowerCase() + w.substring(1)).join(' ')
}
const cellWrapper = { display: 'flex', gap: '8px', width: '100%' }
const cellContent = { flex: '0 1 50%' }
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
const [changeStudyGuidanceGroupTags, { isLoading }] = useChangeStudyGuidanceGroupTagsMutation()
return (
<div>
<Formik
initialValues={{ [tagName]: '' }}
onSubmit={values => {
changeStudyGuidanceGroupTags({ groupId: group.id, tags: values })
if (group.tags?.[tagName]) toggleEdit()
}}
validate={values => (!values[tagName] ? { [tagName]: `${tagName} is required` } : {})}
>
{formik => (
<Form onSubmit={formik.handleSubmit} style={{ ...cellWrapper, alignItems: 'center' }}>
<div style={cellContent}>
{tagName === 'studyProgramme' ? (
<Form.Select
name={tagName}
search={textAndDescriptionSearch}
fluid
placeholder={
selectFieldItems.find(p => p.value === group.tags?.[tagName])?.text || 'Select study programme'
}
options={selectFieldItems}
closeOnChange
value={formik.values[tagName]}
onChange={(_, value) => formik.setFieldValue(tagName, value?.value)}
/>
) : (
<Datetime
name={tagName}
dateFormat="YYYY"
timeFormat={false}
initialvalue={group.tags?.[tagName]}
inputProps={{ readOnly: true }}
renderYear={(props, selectableYear) => <td {...props}>{startYearToAcademicYear(selectableYear)}</td>}
renderInput={({ value, ...rest }) => {
return (
<div>
<input value={startYearToAcademicYear(value)} placeholder="Select year" {...rest} />
</div>
)
}}
closeOnSelect
value={formik.values[tagName]}
onChange={value => formik.setFieldValue(tagName, value?.format('YYYY'))}
/>
)}
</div>
<div style={cellContent}>
<Button type="submit" style={{ margin: '0' }} disabled={isLoading}>
Add {prettifyCamelCase(tagName)}
</Button>
</div>
{group.tags?.[tagName] && (
<div style={cellContent}>
<Button icon type="button" style={{ margin: '0' }} onClick={() => toggleEdit()}>
Close
<Icon name="close" />
</Button>
</div>
)}
</Form>
)}
</Formik>
</div>
)
}
const TagCell = ({ tagName, group, studyProgrammes }) => {
const [showEdit, toggleEdit] = useToggle()
const getText = () => {
switch (tagName) {
case 'studyProgramme':
return studyProgrammes.find(p => p.value === group.tags?.[tagName])?.text
case 'year':
return startYearToAcademicYear(group.tags?.[tagName])
default:
throw Error(`Wrong tagname: ${tagName}`)
}
}
return group.tags?.[tagName] && !showEdit ? (
<div style={{ ...cellWrapper, alignItems: 'baseline' }}>
<p style={{ ...cellContent, textAlign: 'center' }}>{getText()}</p>
<div style={cellContent}>
<Button type="button" onClick={() => toggleEdit()}>
Edit {prettifyCamelCase(tagName)}
</Button>
</div>
</div>
) : (
<AssociateTagForm group={group} tagName={tagName} toggleEdit={toggleEdit} selectFieldItems={studyProgrammes} />
)
}
const StudyGuidanceGroupOverview = ({ groups }) => {
const { language } = useSelector(({ settings }) => settings)
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
const headers = [
{
key: 'name',
title: 'name',
getRowVal: group => getTextIn(group.name, language),
getRowContent: group => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/studyguidancegroups/${group.id}`}
>
{getTextIn(group.name, language)}
</Link>
),
getRowContent: group => <LinkToGroup group={group} language={language} />,
cellProps: {
style: {
padding: '0',
},
className: 'iconCellNoPointer',
},
},
{
key: 'students',
title: 'students',
getRowVal: group => group.members?.length || 0,
getRowContent: group => group.members?.length || 0,
cellProps: {
style: {
padding: '0',
textAlign: 'center',
},
},
},
{
key: 'studyProgramme',
title: 'Study programme',
getRowVal: group => group.tags?.studyProgramme,
getRowContent: group => <TagCell tagName="studyProgramme" studyProgrammes={studyProgrammes} group={group} />,
},
{
key: 'associatedyear',
title: 'Associated starting academic year',
getRowVal: group => group.tags?.year,
getRowContent: group => <TagCell tagName="year" group={group} />,
},
]
if (groups.length === 0) {
@@ -41,7 +183,11 @@ const StudyGuidanceGroupOverview = () => {
return (
<>
<StyledMessage>
<p>Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin.</p>
<p>
Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin. Voit halutessasi
lisätä ohjausryhmään aloitusvuoden ja koulutusohjelman, jolloin yksittäisen ohjausryhmän näkymään avautuu
lisäominaisuuksia.{' '}
</p>
</StyledMessage>
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
</>
@@ -0,0 +1,62 @@
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Segment, Button } from 'semantic-ui-react'
import InfoBox from 'components/InfoBox'
import PopulationCourseStatsFlat from 'components/PopulationCourseStats/PopulationCourseStatsFlat'
import PopulationCourseStats from 'components/PopulationCourseStats'
import useCourseFilter from 'components/FilterTray/filters/Courses/useCourseFilter'
import SegmentDimmer from 'components/SegmentDimmer'
import { useGetStudyGuidanceGroupPopulationCoursesQuery } from 'redux/studyGuidanceGroups'
import { getMandatoryCourses, getMandatoryCourseModules } from 'redux/populationMandatoryCourses'
import infotooltips from 'common/InfoToolTips'
const StudyGuidanceGroupPopulationCourses = ({
selectedStudents,
showStructured,
toggleShowStructured,
studyProgramme,
}) => {
const { setCourses, resetCourses } = useCourseFilter()
const dispatch = useDispatch()
const mandatoryCourses = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses.data)
const { data: courses, isLoading } = useGetStudyGuidanceGroupPopulationCoursesQuery(selectedStudents)
useEffect(() => {
// ensure mandatory courses are available for course stats structured
if (showStructured && studyProgramme && mandatoryCourses.length === 0) {
dispatch(getMandatoryCourses(studyProgramme))
dispatch(getMandatoryCourseModules(studyProgramme))
}
}, [showStructured])
useEffect(() => {
if (!isLoading) setCourses(courses.coursestatistics)
}, [courses])
useEffect(() => {
return resetCourses
}, [])
if (isLoading) return <SegmentDimmer isLoading={isLoading} />
return (
<Segment basic>
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
{studyProgramme && (
<Button primary onClick={() => toggleShowStructured()} style={{ marginLeft: '1em' }}>
{showStructured ? 'Show courses as flat list' : 'Show by programme structure'}
</Button>
)}
{showStructured ? (
<PopulationCourseStats courses={courses} pending={isLoading} selectedStudents={selectedStudents} />
) : (
<PopulationCourseStatsFlat
courses={courses}
pending={isLoading}
selectedStudents={selectedStudents}
showFilter={false}
/>
)}
</Segment>
)
}
export default StudyGuidanceGroupPopulationCourses
@@ -1,4 +0,0 @@
import React from 'react'
import { Message } from 'semantic-ui-react'
export default ({ children }) => <Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
@@ -0,0 +1,18 @@
import React from 'react'
import { Message, Segment } from 'semantic-ui-react'
export const startYearToAcademicYear = year => {
return year === '' || Number.isNaN(year) ? '' : `${year} - ${parseInt(year, 10) + 1}`
}
export const StyledMessage = ({ children }) => (
<Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
)
export const Wrapper = ({ isLoading, children }) => (
<div className="segmentContainer">
<Segment loading={isLoading} className="contentSegment">
{children}
</Segment>
</div>
)
@@ -1,26 +1,16 @@
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useRouteMatch } from 'react-router-dom'
import { Header, Loader, Segment } from 'semantic-ui-react'
import { useTitle } from '../../common/hooks'
import { getStudyGuidanceGroups } from '../../redux/studyGuidanceGroups'
import React from 'react'
import { useParams } from 'react-router-dom'
import { Header } from 'semantic-ui-react'
import { useGetAllStudyGuidanceGroupsQuery } from 'redux/studyGuidanceGroups'
import { useTitle } from 'common/hooks'
import StudyGuidanceGroupOverview from './StudyGuidanceGroupOverview'
import SingleStudyGuidanceGroup from './SingleStudyGuidanceGroup'
import { Wrapper } from './common'
const StudyGuidanceGroups = () => {
useTitle('Study guidance groups')
const dispatch = useDispatch()
const { pending, data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const match = useRouteMatch('/studyguidancegroups/:groupid')
const groupid = match?.params?.groupid
useEffect(() => {
if (groups && groups.length > 0) return
dispatch(getStudyGuidanceGroups())
}, [dispatch])
const isLoading = pending === undefined || pending === true
const isLoaded = pending === false
const { groupid } = useParams()
const { data, isLoading } = useGetAllStudyGuidanceGroupsQuery()
return (
<>
@@ -28,16 +18,9 @@ const StudyGuidanceGroups = () => {
<Header className="segmentTitle" size="large">
Study guidance groups
</Header>
<Segment className="contentSegment">
{isLoading ? (
<Loader active inline="centered">
Loading
</Loader>
) : null}
{isLoaded && !groupid ? <StudyGuidanceGroupOverview /> : null}
</Segment>
</div>
<div>{isLoaded && groupid ? <SingleStudyGuidanceGroup groupid={groupid} /> : null}</div>
<Wrapper isLoading={isLoading}>{!isLoading && !groupid && <StudyGuidanceGroupOverview groups={data} />}</Wrapper>
{!isLoading && groupid && <SingleStudyGuidanceGroup group={data.find(group => group.id === groupid)} />}
</>
)
}
@@ -1,5 +1,4 @@
import React, { useEffect } from 'react'
import { string, func, shape, bool, oneOfType, array } from 'prop-types'
import { connect } from 'react-redux'
import ProductivityTable from '../ProductivityTable'
import ThroughputTable from '../ThroughputTable'
@@ -53,29 +52,6 @@ const Overview = props => {
)
}
Overview.propTypes = {
studyprogramme: string.isRequired,
dispatchGetProductivity: func.isRequired,
dispatchGetThroughput: func.isRequired,
dispatchGetBachelors: func.isRequired,
history: shape({}).isRequired,
productivity: shape({
error: bool,
pending: bool,
data: shape({}),
}).isRequired, // eslint-disable-line
throughput: shape({
error: bool,
pending: bool,
data: shape({}),
}).isRequired, // eslint-disable-line
bachelors: shape({
error: bool,
pending: bool,
data: oneOfType([shape({}), array]),
}).isRequired // eslint-disable-line
}
const mapStateToProps = ({ studyProgrammeProductivity, studyProgrammeThroughput, studyProgrammeBachelors }) => ({
productivity: studyProgrammeProductivity,
throughput: studyProgrammeThroughput,
@@ -30,7 +30,7 @@ const AccessGroups = ({
selected.forEach(value => {
rights[value] = true
})
modifyAccessGroups(user.id, rights)
modifyAccessGroups(user.username, rights)
}
return (
+2 -9
View File
@@ -4,18 +4,14 @@ import { Provider } from 'react-redux'
import 'semantic-ui-css/semantic.min.css'
import 'react-datetime/css/react-datetime.css'
import './styles/custom.css'
import { debugContextDevtool } from 'react-context-devtool'
import store from './store'
import store from './redux'
import ErrorBoundary from './components/ErrorBoundary'
import Main from './components/Main'
import CommonContext from './CommonContext'
import initializeSentry from './util/sentry'
import { isProduction } from './conf'
initializeSentry()
const container = document.getElementById('root')
ReactDOM.render(
<Provider store={store}>
<CommonContext>
@@ -24,8 +20,5 @@ ReactDOM.render(
</ErrorBoundary>
</CommonContext>
</Provider>,
container
document.getElementById('root')
)
// Best not to use this in production as the performance impact appears to be quite high.
debugContextDevtool(container, { disable: isProduction })
+7 -1
View File
@@ -1,12 +1,15 @@
import { decodeToken } from '../common'
export const loginPrefix = 'LOGIN_'
export const logoutPrefix = 'LOGOUT_'
export const login = () => ({
type: 'LOGIN_ATTEMPT',
})
export const logout = () => ({ type: 'LOGOUT_ATTEMPT' })
const reducer = (state = { pending: false, error: false, token: null }, action) => {
const reducer = (state = { pending: false, error: false, token: null, encodedToken: null }, action) => {
switch (action.type) {
case 'LOGIN_ATTEMPT':
return {
@@ -28,6 +31,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: false,
token: decodeToken(action.token),
encodedToken: action.token,
}
case 'LOGOUT_ATTEMPT':
@@ -43,6 +47,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: true,
token: null,
encodedToken: null,
}
case 'LOGOUT_SUCCESS':
@@ -51,6 +56,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: false,
token: null,
encodedToken: null,
}
default:
@@ -1,85 +0,0 @@
import { actionTypes } from '../../apiConnection'
const additionalTypes = prefix => ({
reset: `${prefix}_CLEAR`,
})
const createReducer = (
prefix,
{
reduceGET = (_, action) => action.response,
reducePOST = (data, action) => data.concat(action.response),
reduceDELETE = (data, action) => data.filter(d => d.id === action.query.id),
defaults = undefined,
}
) => {
const prefixGET = `${prefix}GET_`
const prefixPOST = `${prefix}POST_`
const prefixDELETE = `${prefix}DELETE_`
const read = actionTypes(prefixGET)
const create = actionTypes(prefixPOST)
const destroy = actionTypes(prefixDELETE)
const other = additionalTypes(prefix)
const initialState = {
pending: false,
error: false,
data: [],
...defaults,
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case destroy.attempt:
case create.attempt:
case read.attempt:
return {
...state,
error: false,
pending: true,
}
case destroy.failure:
case create.failure:
case read.failure:
return {
...state,
error: true,
pending: false,
}
case read.success:
return {
...state,
data: reduceGET(state.data, action),
error: false,
pending: false,
}
case create.success:
return {
...state,
data: reducePOST(state.data, action),
error: false,
pending: false,
}
case destroy.success:
return {
...state,
data: reduceDELETE(state.data, action),
error: false,
pending: false,
}
case other.reset: {
return {
...initialState,
}
}
default:
return state
}
}
return {
reducer,
prefixGET,
prefixPOST,
prefixDELETE,
}
}
export default createReducer
@@ -0,0 +1,33 @@
import { actionSuffixes } from '../../apiConnection'
export const matcherReducers = [
{
matcher: action => action.type.endsWith(actionSuffixes.attempt),
reducer(state) {
state.error = false
state.pending = true
},
},
{
matcher: action => action.type.endsWith(actionSuffixes.failure),
reducer(state) {
state.error = true
state.pending = false
},
},
{
matcher: action => action.type.endsWith(actionSuffixes.success),
reducer(state) {
state.error = false
state.pending = false
},
},
]
export const defaultReducer = () => {}
export const initialState = {
pending: false,
error: false,
data: [],
}
@@ -1,5 +1,8 @@
import { RTKApi } from 'apiConnection'
import { getTextIn } from 'common'
import { callController } from '../apiConnection'
// original elementdetail stuff
export const getElementDetails = () => {
const route = '/elementdetails/all'
const prefix = 'GET_ELEMENTDETAILS_'
@@ -31,4 +34,38 @@ const reducer = (state = { data: [] }, action) => {
}
}
// RTK query based stuff
const elementDetailsApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getAllElementDetails: builder.query({
query: () => '/elementdetails/all',
providesTags: result => [
...result?.map(({ id }) => ({ type: 'StudyGuidanceGroups', id })),
{ type: 'StudyGuidanceGroups', id: 'LIST' },
],
}),
}),
overrideExisting: false,
})
export const { useGetAllElementDetailsQuery } = elementDetailsApi
// Returns only newest studyprogrammes and formats them to be used in semantic ui dropdowns
export const useFilteredAndFormattedElementDetails = language => {
const { data, isLoading } = useGetAllElementDetailsQuery()
const filteredAndFormatted = isLoading
? []
: data
.filter(elem => elem.code.startsWith('KH') || elem.code.startsWith('MH'))
.map(elem => ({
key: elem.code,
value: elem.code,
description: elem.code,
text: getTextIn(elem.name, language),
}))
return filteredAndFormatted
}
export default reducer
+57 -44
View File
@@ -1,4 +1,6 @@
import { combineReducers } from 'redux'
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query/react'
import { handleRequest, handleAuth, RTKApi } from 'apiConnection'
import actionHistory from './actionHistory'
import users from './users'
import populations from './populations'
@@ -39,48 +41,59 @@ import singleCourseStats from './singleCourseStats'
import userAccessEmail from './userAccessEmail'
import customPopulationSearch from './customPopulationSearch'
import coolDataScience from './coolDataScience'
import studyGuidanceGroups from './studyGuidanceGroups'
export default combineReducers({
actionHistory,
users,
mandatoryCourseLabels,
populations,
populationCourses,
populationSelectedStudentCourses,
populationProgrammes,
populationProgrammesUnfiltered,
populationMandatoryCourses,
studyProgrammeProductivity,
studyProgrammeThroughput,
studyProgrammeBachelors,
faculties,
units,
students,
errors,
courses,
courseInstances,
graphSpinner,
settings,
teachers,
providers,
semesters,
teacherStatistics,
teachersTop,
teachersTopCategories,
courseSearch,
courseStats,
courseSummaryForm,
thesisCourses,
accessGroups,
elementdetails,
feedback,
tags,
tagstudent,
auth,
singleCourseStats,
userAccessEmail,
customPopulationSearch,
coolDataScience,
studyGuidanceGroups,
const store = configureStore({
reducer: {
actionHistory,
users,
mandatoryCourseLabels,
populations,
populationCourses,
populationSelectedStudentCourses,
populationProgrammes,
populationProgrammesUnfiltered,
populationMandatoryCourses,
studyProgrammeProductivity,
studyProgrammeThroughput,
studyProgrammeBachelors,
faculties,
units,
students,
errors,
courses,
courseInstances,
graphSpinner,
settings,
teachers,
providers,
semesters,
teacherStatistics,
teachersTop,
teachersTopCategories,
courseSearch,
courseStats,
courseSummaryForm,
thesisCourses,
accessGroups,
elementdetails,
feedback,
tags,
tagstudent,
auth,
singleCourseStats,
userAccessEmail,
customPopulationSearch,
coolDataScience,
[RTKApi.reducerPath]: RTKApi.reducer,
},
// oodikone is currently too heavy for other middlewares than thunk, but
// feel free to take use them at some point if possible
middleware: getDefaultMiddleware => [
...getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }),
handleRequest,
handleAuth,
RTKApi.middleware,
],
})
setupListeners(store.dispatch)
export default store
@@ -1,35 +1,43 @@
import { callController } from '../apiConnection'
import { RTKApi } from 'apiConnection'
const prefix = 'GET_STUDY_GUIDANCE_GROUPS_'
const studyGuidanceGroupsApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getAllStudyGuidanceGroups: builder.query({
query: () => '/studyguidancegroups',
providesTags: result => [
...result?.map(({ id }) => ({ type: 'StudyGuidanceGroups', id })),
{ type: 'StudyGuidanceGroups', id: 'LIST' },
],
}),
changeStudyGuidanceGroupTags: builder.mutation({
query: ({ groupId, tags }) => ({
url: `/studyguidancegroups/${groupId}/tags`,
method: 'PUT',
body: tags,
}),
invalidatesTags: ({ studyGuidanceGroupId }) => [{ type: 'StudyGuidanceGroups', id: studyGuidanceGroupId }],
}),
getStudyGuidanceGroupPopulation: builder.query({
query: studentnumberlist => ({
url: '/v3/populationstatisticsbystudentnumbers',
method: 'POST',
body: { studentnumberlist, usingStudyGuidanceGroups: true },
}),
}),
getStudyGuidanceGroupPopulationCourses: builder.query({
query: studentnumberlist => ({
url: '/v2/populationstatistics/coursesbystudentnumberlist',
method: 'POST',
body: { studentnumberlist, usingStudyGuidanceGroups: true },
}),
}),
}),
overrideExisting: false,
})
export const getStudyGuidanceGroups = () => {
const route = '/studyguidancegroups'
return callController(route, prefix)
}
const reducer = (state = { data: [] }, action) => {
switch (action.type) {
case `${prefix}ATTEMPT`:
return {
...state,
pending: true,
}
case `${prefix}FAILURE`:
return {
...state,
pending: false,
error: true,
}
case `${prefix}SUCCESS`:
return {
...state,
pending: false,
error: false,
data: action.response,
}
default:
return state
}
}
export default reducer
export const {
useGetAllStudyGuidanceGroupsQuery,
useChangeStudyGuidanceGroupTagsMutation,
useGetStudyGuidanceGroupPopulationQuery,
useGetStudyGuidanceGroupPopulationCoursesQuery,
} = studyGuidanceGroupsApi
+83 -2
View File
@@ -1,5 +1,86 @@
import createReducer from './common/crudreducer'
import { callController } from '../apiConnection/index'
import { callController, actionTypes } from '../apiConnection/index'
const additionalTypes = prefix => ({
reset: `${prefix}_CLEAR`,
})
const createReducer = (
prefix,
{
reduceGET = (_, action) => action.response,
reducePOST = (data, action) => data.concat(action.response),
reduceDELETE = (data, action) => data.filter(d => d.id === action.query.id),
defaults = undefined,
}
) => {
const prefixGET = `${prefix}GET_`
const prefixPOST = `${prefix}POST_`
const prefixDELETE = `${prefix}DELETE_`
const read = actionTypes(prefixGET)
const create = actionTypes(prefixPOST)
const destroy = actionTypes(prefixDELETE)
const other = additionalTypes(prefix)
const initialState = {
pending: false,
error: false,
data: [],
...defaults,
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case destroy.attempt:
case create.attempt:
case read.attempt:
return {
...state,
error: false,
pending: true,
}
case destroy.failure:
case create.failure:
case read.failure:
return {
...state,
error: true,
pending: false,
}
case read.success:
return {
...state,
data: reduceGET(state.data, action),
error: false,
pending: false,
}
case create.success:
return {
...state,
data: reducePOST(state.data, action),
error: false,
pending: false,
}
case destroy.success:
return {
...state,
data: reduceDELETE(state.data, action),
error: false,
pending: false,
}
case other.reset: {
return {
...initialState,
}
}
default:
return state
}
}
return {
reducer,
prefixGET,
prefixPOST,
prefixDELETE,
}
}
const prefix = 'PROGRAMME_THESIS_'
@@ -1,4 +1,4 @@
import { combineReducers } from 'redux'
import { combineReducers } from '@reduxjs/toolkit'
import { callController, actionTypes } from '../apiConnection'
const CLEAR_USER_ACCESS_ERRORS = 'CLEAR_USER_ACCESS_ERRORS'
+2 -2
View File
@@ -6,11 +6,11 @@ export const getUsers = () => {
return callController(route, prefix)
}
export const modifyAccessGroups = (uid, accessgroups) => {
export const modifyAccessGroups = (username, accessgroups) => {
const route = '/users/modifyaccess'
const prefix = 'MODIFY_ACCESSGROUPS_'
const method = 'post'
const data = { uid, accessgroups }
const data = { username, accessgroups }
return callController(route, prefix, data, method)
}
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
import { sortBy, flatten } from 'lodash'
const courseStatsSelector = state => state.courseStats.data
+1 -1
View File
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
import { byDateDesc, reformatDate } from '../common'
const getData = obj => obj
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
const getPopulations = state => state.populations
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
const associationsSelector = state => state.populationProgrammesUnfiltered.data
+1 -1
View File
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
import { reformatDate } from '../common'
const getStudents = students => students.data

Some files were not shown because too many files have changed in this diff Show More