Compare commits

...
239 Commits
Author SHA1 Message Date
Otto Ahoniemi d601dbe597 Run all the filter tests again 2021-12-17 00:15:51 +02:00
Otto Ahoniemi e356186152 Remove sis warning flag toggle since it's not used anymore 2021-12-16 23:17:13 +02:00
Otto Ahoniemi 4c6153c76d Restore couple of student tests and test that student page doesn't crash without studyrights or courses 2021-12-16 23:17:10 +02:00
Otto Ahoniemi abebc5d410 Start adding separate and agreement studyrights to sis-db 2021-12-16 21:45:56 +02:00
Otto Ahoniemi 3dcb5f299c Fix credit graph failing if student has no studyrights 2021-12-16 21:19:39 +02:00
Otto Ahoniemi 0632fa94f7 Don't crash if study guidance groups not available 2021-12-16 19:57:28 +02:00
Mitja Karhusaari ed85513290 Make courses filter resilient against missing course information 2021-12-16 12:47:25 +02:00
Mitja Karhusaari 07ad8cb434 Use rtk query to fetch course statistics 2021-12-16 12:47:25 +02:00
Mitja Karhusaari 901b719888 Second try at the previous 2021-12-16 03:03:37 +02:00
Mitja Karhusaari 9850faf400 Don't show custom population filters before population has been loaded
Resolves #3341
2021-12-16 02:17:21 +02:00
Mitja Karhusaari 176eca36eb Refetch population course data whenever filter results change 2021-12-16 01:38:07 +02:00
Saara Satokangas 77d5435c30 Start using the course_unit_type in the new studyprogramme-view 2021-12-14 16:17:00 +02:00
Saara Satokangas 3f99df4971 Add course_unit_type to customAttainmentParser in updater 2021-12-14 09:18:50 +02:00
Saara Satokangas 819ba0db15 Add course_unit_type to the course-mapper in updater 2021-12-14 09:06:21 +02:00
Mitja Karhusaari e8871cc845 Make the date selection not persistent 2021-12-10 17:26:55 +02:00
Mitja Karhusaari e2c152ddc1 Extract common functionality from status pages 2021-12-10 16:58:09 +02:00
Mitja Karhusaari 63c9d316d0 Move info texts from component files to under the InfoToolTips directory 2021-12-10 08:04:49 +02:00
Mitja Karhusaari 6b1ff9e9cb Fix graduation trends page on initial page load 2021-12-10 07:47:12 +02:00
Petri Palmu 7f913c9927 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-12-09 13:58:00 +02:00
Petri Palmu b7616ec2ed moved info texts to commons 2021-12-09 11:54:14 +02:00
Mitja Karhusaari d0840a7de3 Fix data export 2021-12-09 11:12:51 +02:00
Petri Palmu cc214179f6 solved merge conflicts with pukinkontti 2021-12-09 10:45:13 +02:00
Otto Ahoniemi cbfe5aedc1 Fix workflow job dependencies 2021-12-09 02:35:08 +02:00
Otto Ahoniemi c0b61d6d4e Run migrations also for anon user-db and kone-db 2021-12-09 02:33:09 +02:00
Otto Ahoniemi dd28ee3c48 Run migration push also when test yaml changes + fix script trying to run down docker-compose 2021-12-09 02:24:09 +02:00
Otto Ahoniemi 3f5e1e53b9 Add sis person ids to students 2021-12-09 02:14:15 +02:00
Otto Ahoniemi a5ef63bc2f Add creds 2021-12-09 02:04:55 +02:00
Mitja Karhusaari 5f7f496472 Copy settings and usage instruction components from attainment status page 2021-12-09 02:01:00 +02:00
Mitja Karhusaari 8058f8ca0a Use the DrillStack component on the graduation trends page 2021-12-09 02:01:00 +02:00
Mitja Karhusaari 6fd5304414 Separate drilling into a separate component 2021-12-09 02:01:00 +02:00
Otto Ahoniemi 2fde626606 Remove garbage column from student model 2021-12-09 02:00:16 +02:00
Otto Ahoniemi 75ff72e545 Fix typo in ci docker-compose container name and add profile so sis-updater won't be run by default 2021-12-09 01:56:18 +02:00
Otto Ahoniemi 8b80f11728 Build anon sis-db and push to toska hub if there are new sis-db migrations 2021-12-09 01:42:16 +02:00
Otto Ahoniemi 4648f49ebc Fix lint staged shell linter failing because it was being ran inside alpine container 2021-12-09 01:31:59 +02:00
Otto Ahoniemi 92fbe3b2d3 Move anonyymioodi scripts and readme to this main repo 2021-12-09 01:31:29 +02:00
Otto Ahoniemi 1e6c418c7e Hotfix user right check when user has rights for programme and studyguidancegroups 2021-12-09 01:00:30 +02:00
Otto Ahoniemi c120b9c6a9 Add note about tests failing if anon db hasn't been updated manually 2021-12-08 23:12:39 +02:00
Saara Satokangas ec7d77dca0 Add course_unit_type to course-model 2021-12-08 14:02:51 +02:00
Saara Satokangas 4a15d97207 Show Studyprogramme options for others than admins as well 2021-12-08 13:03:06 +02:00
Saara Satokangas 40bb1dbc3e Make new studyprogramme view to admins with teachers-role 2021-12-08 13:03:06 +02:00
Saara Satokangas ad95ab6ce3 Initial version of adding enrolled students to the graphs 2021-12-08 13:03:06 +02:00
Saara Satokangas 4d0a62f26c Separate helpers, stats and db-queries for newStudyprogramme
And try to avoid modules with 500 rows
2021-12-08 13:00:11 +02:00
Saara Satokangas f30b415f05 Add loader to new studyprogramme overview 2021-12-08 13:00:11 +02:00
Saara Satokangas c0de93ce55 Add total-column to credits of the studyprogramme 2021-12-08 13:00:11 +02:00
Saara Satokangas 6560e7bf26 Adjust data table size 2021-12-08 13:00:11 +02:00
Saara Satokangas 4d782f9ec0 Correct order for radio-labels 2021-12-08 12:59:42 +02:00
Saara Satokangas 62dcd0a4ca Enable fetching graduation and thesis status by academic year 2021-12-08 12:59:42 +02:00
Saara Satokangas 994cb180a0 Enable getting credits stats also by academic year 2021-12-08 12:59:42 +02:00
Saara Satokangas a28a1b0a6c Add possibility to get basic stats by academic year 2021-12-08 12:59:42 +02:00
Petri Palmu 2a5c690ab5 added test for the language filter 2021-12-08 12:19:32 +02:00
Saara SatokangasandGitHub d7e739e20e Update importer-path for api-gateway 2021-12-08 10:04:35 +02:00
Mitja Karhusaari e2c77fe63b Revert changes to studyprogramme tests 2021-12-08 09:28:35 +02:00
Petri Palmu 39e9a3084b little correction to the language dist 2021-12-07 10:37:21 +02:00
Petri Palmu 13dc659d18 language distribution added to the course stats 2021-12-03 18:44:42 +02:00
Mitja Karhusaari aeee083d0a Implement "Show all credits" button using the new filter system 2021-12-03 04:37:04 +02:00
Mitja Karhusaari cd56b74d25 Add spinner study guidance group course stats 2021-12-03 04:26:21 +02:00
Mitja Karhusaari 77eb31f840 Fix course statistics in study guidance groups view 2021-12-03 04:25:21 +02:00
Mitja Karhusaari 1988182134 Fix course filter in study guidance groups 2021-12-03 03:55:09 +02:00
Mitja Karhusaari 1b248665c4 Reimplement #3303 using the new filter system 2021-12-03 03:44:08 +02:00
Mitja Karhusaari 942b02c8e2 Take initial filter view options into account in filter selectors 2021-12-03 03:23:58 +02:00
Mitja Karhusaari 7a34ad66e8 Improve the behavior of default and initial filter options when reseting a filter 2021-12-03 03:23:58 +02:00
Mitja Karhusaari 4fc7b97813 Fix course statistics 2021-12-03 03:23:58 +02:00
Mitja Karhusaari 77a87c5b1e Implement default filter values for study guidance groups 2021-12-03 03:23:58 +02:00
Mitja Karhusaari c7d2a552c9 Fix tests and make them pass 2021-12-03 03:23:58 +02:00
Mitja Karhusaari 63205fbfa1 Implement functionality for the "Clear All Filters" -button 2021-12-03 03:23:58 +02:00
Mitja Karhusaari df68671181 Change filter reset button look 2021-12-03 03:23:58 +02:00
Mitja Karhusaari 00cee9f14b Fix filter card layouts 2021-12-03 03:23:58 +02:00
Mitja Karhusaari 905d19f46f Update admission type filter 2021-12-03 03:23:58 +02:00
Mitja Karhusaari 019d10c938 Fix filters in study guidance groups 2021-12-03 03:23:58 +02:00
Mitja Karhusaari 7a0d91a510 Hide filter tray until a population has been selected 2021-12-03 03:23:58 +02:00
Mitja Karhusaari 10169e92a6 Fix filter view layout and tray stickiness 2021-12-03 03:23:58 +02:00
Mitja Karhusaari c74112a356 Implement actions and selectors for filters 2021-12-03 03:23:58 +02:00
Mitja Karhusaari 9dee8d4669 Implement a new filter architecture 2021-12-03 03:23:58 +02:00
Mitja Karhusaari fc875d30b6 Allow clearing of the credit date filter dates 2021-12-03 03:23:58 +02:00
Mitja Karhusaari 47fc6ea215 Change student credit column title based on the credit date filter
options
2021-12-03 03:23:58 +02:00
Saara Satokangas 26420f9a36 Add initial infobox-texts for new studyprogramme-view 2021-12-02 13:50:04 +02:00
Saara Satokangas c8f461b7f5 Add some unique keys 2021-12-02 13:31:11 +02:00
Saara Satokangas c17e53cec5 Organize stuff 2021-12-02 13:06:50 +02:00
Saara Satokangas 9cff76f498 Add possibility to export charts from new overview 2021-12-02 13:03:56 +02:00
Saara Satokangas b91991ac37 Betterments to responsiveness of the graphs 2021-12-02 12:20:24 +02:00
Saara Satokangas 9eff7057e7 Include only thesis with ATTAINED-state to the graphs
So no improvements, and one per student
2021-12-02 10:35:48 +02:00
Saara Satokangas 4c096199d2 Start using RTK Query for new studyprogramme overview 2021-12-01 20:33:10 +02:00
Saara Satokangas 9f39ee6b87 Add graduation mean and median time charts 2021-12-01 19:20:42 +02:00
Petri Palmu d06462d727 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-12-01 16:36:14 +02:00
Petri Palmu e4f65407ed Merge branch 'kieli' 2021-12-01 16:36:01 +02:00
Petri Palmu 26d8f2befc language added to course statitics 2021-12-01 16:34:54 +02:00
Saara Satokangas 2713545eda Remove proptypes from Thesis Courses 2021-12-01 13:41:08 +02:00
Saara Satokangas fa619fed38 Fix Thesis Courses -tab breaking because of unselected language 2021-12-01 13:36:48 +02:00
Saara Satokangas 6d4f49879b Add graph and table for graduated and thesis-writers 2021-12-01 13:27:58 +02:00
Saara Satokangas 37aeb9ebe4 Add percentages to barchart tooltips 2021-12-01 09:34:30 +02:00
Saara Satokangas 5f5423971c Add colors to new overview charts 2021-12-01 09:34:21 +02:00
Petri Palmu 39b3fde1a0 backend ready for the language 2021-11-30 15:44:03 +02:00
Petri Palmu 382c900d28 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-11-30 13:37:50 +02:00
Petri Palmu 5ac2cfb13c moved filter info texts to front common infotooltip filters 2021-11-30 13:02:02 +02:00
Otto Ahoniemi aa6d2b83f5 Revert hiding of programme filter 2021-11-29 15:08:12 +02:00
Petri Palmu 201250b8af fix that open uni course goes into substitutions only once 2021-11-29 13:56:48 +02:00
Otto Ahoniemi ff36a0e8f7 Add date to study guidance group filter set 2021-11-29 13:48:11 +02:00
Otto Ahoniemi 71c1ce703a Add better selector for datetime picker header hider 2021-11-29 13:42:27 +02:00
Otto Ahoniemi c94711234f Don't show tag filter when tags aren't available 2021-11-29 13:34:08 +02:00
Otto Ahoniemi 563d2cb4b0 Fix course tab in population student table 2021-11-29 12:59:50 +02:00
Otto Ahoniemi 3727bd03dd Set datepicker to start from 2017 + hide year header 2021-11-29 11:05:36 +02:00
Otto Ahoniemi efb0b98cdc Update README 2021-11-29 10:19:12 +02:00
Petri Palmu ef5aec5fc1 language migration added to the credit 2021-11-25 08:36:11 +02:00
Petri Palmu cdbbf06c18 added info options to the filters 2021-11-24 08:55:02 +02:00
Saara SatokangasandGitHub e471b1e73a Merge pull request #3310 from UniversityOfHelsinkiCS/new_studyprogramme
New studyprogramme overview -pukinkontti
2021-11-18 16:09:59 +02:00
Saara Satokangas 1d7f9d250a Remove waste 2021-11-18 16:01:54 +02:00
Saara Satokangas 0207e58fa3 Years in correct order for tables and graphs 2021-11-18 16:01:54 +02:00
Saara Satokangas aecca8426d Modify tooltips 2021-11-18 16:01:54 +02:00
Saara Satokangas ef0d0a5eb7 Refactor stats basis into its own function 2021-11-18 16:01:54 +02:00
Saara Satokangas 284c5b270d Rewrite how major and non-major students' credits are defined 2021-11-18 16:01:54 +02:00
Saara Satokangas 51f2a7573e Increase height of the containers 2021-11-18 16:01:54 +02:00
Saara Satokangas 0837e1d972 Add transferred credits and non-major credits to studyprogramme credits
And general betterments
2021-11-18 16:01:54 +02:00
Saara Satokangas e468186af2 Add major students credit stats to the data table 2021-11-18 16:01:54 +02:00
Saara Satokangas 3fcfe56bd0 Show credits for major students in the graph 2021-11-18 16:01:54 +02:00
Saara Satokangas 85ad68c97d Move also new studyprogramme-stuff in backend into its own file
Again, why did I not do this already last week?
2021-11-18 16:01:54 +02:00
Saara Satokangas ede3b8efac Move the new view into its own folder
Should have done this from the start
2021-11-18 16:01:54 +02:00
Saara Satokangas cd1d65b29b Add basis for fetching credit stats 2021-11-18 16:01:54 +02:00
Saara Satokangas 456fe927e7 Initial adding of credits stats to studyprogramme-view 2021-11-18 16:01:54 +02:00
Saara Satokangas df36d58288 Add datatable, clean up graph 2021-11-18 16:01:54 +02:00
Saara Satokangas 918869b307 Add also started to the initial graph 2021-11-18 16:01:54 +02:00
Saara Satokangas 2ba06ae787 Add also cancelled and transfers to the initial new graph 2021-11-18 16:01:54 +02:00
Saara Satokangas ac0dc90661 Very initial commit for new studyprogramme graphs 2021-11-18 16:01:54 +02:00
Otto AhoniemiandGitHub 8622bd8e6d Merge pull request #3307 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/url-parse-1.5.3
Bump url-parse from 1.5.1 to 1.5.3 in /services/frontend
2021-11-17 19:01:48 +02:00
Otto Ahoniemi 76988e3ec5 show admission type for study guidance group students as well 2021-11-17 18:59:50 +02:00
Otto Ahoniemi b0a9fb48d7 Fix some study guidance group filtering related bugs 2021-11-17 18:56:37 +02:00
Otto Ahoniemi 16d603538f Fix study guidance group layout failing when tags not set 2021-11-17 18:35:09 +02:00
Otto Ahoniemi 7dd810cad6 Allow users with study guidance group accesses to view student page 2021-11-17 18:28:59 +02:00
Otto Ahoniemi 42afea5e0c Show student statistics search if user has studyguidancegroup role 2021-11-17 17:30:32 +02:00
Otto Ahoniemi 6d0c5e552b Add study guidance group spesific stuff to generaltab 2021-11-17 16:54:00 +02:00
Otto Ahoniemi 897c2e4c88 Add variants to generaltab + render study guidance groups in a custom manner 2021-11-17 16:13:56 +02:00
Otto Ahoniemi 673ce9f9c0 Change from population booleans to variant in studenttable 2021-11-17 16:10:31 +02:00
Otto Ahoniemi 77054187a0 Refactor column if elses away from generaltab 2021-11-17 15:27:17 +02:00
Otto Ahoniemi a9c244b1f6 Remove hocs and passing unnecessary props to generaltab 2021-11-17 13:00:01 +02:00
Otto Ahoniemi ed5e2da875 Declare populationtable contents in container instead of using if elses 2021-11-17 11:48:54 +02:00
Otto Ahoniemi 25d5599466 Remove unused components 2021-11-17 11:18:09 +02:00
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
dependabot[bot]andGitHub 2a6653c700 Bump url-parse from 1.5.1 to 1.5.3 in /services/frontend
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.1 to 1.5.3.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.1...1.5.3)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-11 13:47:08 +00: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
216 changed files with 7977 additions and 34646 deletions
+99 -1
View File
@@ -59,7 +59,6 @@ jobs:
run: npm run eslint services/userservice
# === Build images ===
build_backend:
name: 'Build backend'
runs-on: ubuntu-latest
@@ -142,6 +141,81 @@ jobs:
with:
path: /tmp/userservice.tar
build_and_push_anon_sis_db:
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: dorny/paths-filter@v2
id: check_migrations
with:
filters: |
migrations:
- 'updater/sis-updater-worker/src/db/migrations/**'
- '.github/workflows/test-oodikone.yaml'
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Let sis-updater-worker run migrations, then push new version of image to toska hub'
if: steps.check_migrations.outputs.migrations == 'true'
run: |
docker-compose --file docker-compose.ci.yml up --detach sis-updater-worker
sleep 30 # wait for sis-updater-worker to start and run migrations
./anonyymioodi/commit_and_push_to_toska_hub.sh sis-db
build_and_push_anon_kone_db:
name: 'Build anon kone-db and push to toska hub if new migrations have been added'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: dorny/paths-filter@v2
id: check_migrations
with:
filters: |
migrations:
- 'services/backend/src/database/migrations_kone/**'
- '.github/workflows/test-oodikone.yaml'
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Let backend run migrations, then push new version of image to toska hub'
if: steps.check_migrations.outputs.migrations == 'true'
run: |
docker-compose --file docker-compose.ci.yml up --detach backend
sleep 30 # wait for sis-updater-worker to start and run migrations
./anonyymioodi/commit_and_push_to_toska_hub.sh kone-db
build_and_push_anon_user_db:
name: 'Build anon user-db and push to toska hub if new migrations have been added'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: dorny/paths-filter@v2
id: check_migrations
with:
filters: |
migrations:
- 'services/userservice/src/database/migrations/**'
- '.github/workflows/test-oodikone.yaml'
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Let userservice run migrations, then push new version of image to toska hub'
if: steps.check_migrations.outputs.migrations == 'true'
run: |
docker-compose --file docker-compose.ci.yml up --detach userservice
sleep 30 # wait for sis-updater-worker to start and run migrations
./anonyymioodi/commit_and_push_to_toska_hub.sh user-db
# === Integration tests ===
#
run_userservice_integration_tests:
@@ -160,6 +234,9 @@ jobs:
- build_backend
- build_frontend
- build_userservice
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@@ -203,6 +280,9 @@ jobs:
- build_backend
- build_frontend
- build_userservice
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@@ -246,6 +326,9 @@ jobs:
- build_backend
- build_frontend
- build_userservice
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@@ -289,6 +372,9 @@ jobs:
- build_backend
- build_frontend
- build_userservice
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@@ -332,6 +418,9 @@ jobs:
- build_backend
- build_frontend
- build_userservice
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@@ -375,6 +464,9 @@ jobs:
- build_backend
- build_frontend
- build_userservice
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@@ -418,6 +510,9 @@ jobs:
- build_backend
- build_frontend
- build_userservice
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@@ -465,6 +560,9 @@ jobs:
- build_backend
- build_frontend
- build_userservice
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
+2 -2
View File
@@ -7,8 +7,8 @@ module.exports = {
'{services,updater}/**/*.{js,jsx}': files => `eslint --fix ${files.join(' ')}`,
'*.{js,json,md,yml,yaml,html}': files => `prettier --write ${files.join(' ')}`,
'*.css': files => `stylelint --fix ${files.join(' ')}`,
Dockerfile: files => `${dockerCmdBase} hadolint/hadolint:v2.7.0-alpine hadolint ${relativeFilePaths(files)}`,
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck-alpine:v0.7.2 ${relativeFilePaths(files)} -x`,
Dockerfile: files => `${dockerCmdBase} hadolint/hadolint:v2.7.0 hadolint ${relativeFilePaths(files)}`,
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.7.2 ${relativeFilePaths(files)} -x`,
'.github/workflows/*': files => `${dockerCmdBase} rhysd/actionlint:1.6.2 ${relativeFilePaths(files)}`,
'docker-compose*': files => {
const composeFiles = file => {
+7 -3
View File
@@ -43,8 +43,8 @@ What different CLI options do:
- Cleans up any previous real data databases
- Downloads needed database dumps from production servers.
- Creates real data databases and populates them with downloaded dumps.
3. Reset sis-importer data
- Like option 2, but only for sis-importer database. Useful when developing updater microservice.
3. Reset single database
- Like option 2, but only for single database. Leaves other databases untouched.
## ⌨️ Development
@@ -124,12 +124,16 @@ Continuous integration (CI) works with Github actions and is defined in workflow
### Modules are missing after updating package.json
You should always install the dependencies **inside** the container to have the application **inside** the container access them. This might be the case when someone else installs a new library and you only pull the changes in package.json. Use `docker exec -it <service> sh` to get inside the container and run `npm ci` to install modules.
You should always install the dependencies **inside** the container to have the application **inside** the container access them. Module might be missing for example when someone else installs a new library and you only pull the changes in package.json. Use `docker exec <service> npm ci` to install modules inside the container.
### I get the "You are not authorized to use Oodikone" -message when I try to work with anon-data
Make sure you have your VPN on
### I made a change to sis-db models aand created a migration, but tests are failing in CI!
Migrations are handled by sis-importer-worker, since oodikone is using only replica. Since CI is testing only services included in oodikone, your new changes aren't migrated and thus are failing. Open anonyymioodi-folder in this repo to find more about how to to update the anon sis-db.
### Everything is broken, can't get oodikone running, data is not there etc.
Just do clean install by launching cli with `npm cli` and running option 1: _Set up oodikone from scratch_.
+2
View File
@@ -0,0 +1,2 @@
*.sqz
+65
View File
@@ -0,0 +1,65 @@
# Anonyymioodi - anonymous database setup for oodikone
(name anonyymioodi for :culture: reasons)
This repository contains a guide and scripts to create docker images from database dumps and/or push database images to Toska Hub docker registry. Images are mostly used by oodikone.
## How to update database images
Updating images varies a bit depending on the db you're trying to update. Complete steps as defined below and run `./commit_and_push_to_toska_hub.sh <dbname>` to publish the image you've created.
### Sis-importer-db
The following steps are for building a clean sis-importer-db image from scratch. If you want to just modify the existing image a bit, follow the steps defined for user-db / kone-db.
- See [sis-importer/importer-db-staging-sampletaker](https://github.com/UniversityOfHelsinkiCS/sis-importer/tree/master/importer-db-staging-sampletaker) and create `sis-importer-db.sqz` dump
- Move `sis-importer-db.sqz` to this folder
- Run `./create_container_from_dump.sh sis-importer-db`
- Enrich data according to what's in `enrich_data.sql` file
### Sis-db:
The following steps are for building a clean sis-importer-db image from scratch using the sis-updater. If you want to just modify the existing image a bit, follow the steps defined for user-db / kone-db.
- Temporarily replace sis-db -service in oodikone's docker-compose.yml with sis-db -service from this repo's docker-compose.yml
- Ensure the newest sis-importer-db image is pulled from toska hub in oodikone repo
- Modify sis scheduler limit in config.js by setting: `module.exports.DEV_SCHEDULE_COUNT = null`
- Start updater, see oodikone readme for more info.
- Populate sis-db from sis-importer-db:
```
curl --request GET --url 'http://localhost:8082/v1/meta?token=dev' && \
curl --request GET --url 'http://localhost:8082/v1/programmes?token=dev' && \
curl --request GET --url 'http://localhost:8082/v1/students?token=dev'
```
(It might be a good idea to follow sis-updater-worker logs while updater is running)
- Clean up any extra stuff you don't want to keep in sis-db.
- (Bonus: check that cypress tests run okay)
### User-db / kone-db
- Temporarily replace user-db/kone-db -service in oodikone's docker-compose.yml with the same service from this repo's docker-compose.yml
- Ensure the newest user-db/kone-db image is pulled from the registry with `docker pull`
- Start oodikone, let it run migrations
- Modify db to wanted state. Tips:
- Easy way to create new users is to modify dev user details in the frontend API config. Userservice creates a new user during login if the given uid is not present in user-db.
By default, previous user-db/kone-db images are used as a base. If you want to build the image from a clean Postgres image, just comment out toska hub image and uncomment the clean Postgres image.
## How to modify database images
If you need to clean up / modify / do whatever with dbs, either use `psql` directly in docker by running `docker exec -it <dbname> psql -U postgres <dbname>` or start adminer with oodikone's docker-compose.
## How to update Postgres versions
When Postgres is updated on the server, anon db images need to be updated to match server versions. Do following:
- Ensure you're using toska hub image in docker-compose and that you've pulled the newest image from toska hub: `docker-compose pull <dbname>`.
- Start database with docker-compose
- Create dump from database by running `docker exec -i <dbname> pg_dump -Fc -U postgres <dbname> > <dbname>.sqz`
- Close container with docker-compose down.
- Update the Postgres version in docker-compose.yml in this folder.
- Uncomment row with Postgres image and comment row with Toskahub image.
- Run `./create_container_from_dump.sh <dbname>`, followed by `./commit_and_push_to_toska_hub.sh <dbname>`
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# === Config ===
# Quit if failing
set -euo pipefail
# Print messages and logs that are not script output to stderr
msg() {
echo >&2 -e "${1-}"
}
# Some special types of messages with colours
successmsg() {
msg "${GREEN}$1${NOFORMAT}
"
}
errormsg() {
msg "${RED}$1${NOFORMAT}
"
}
infomsg() {
msg "${BLUE}$1${NOFORMAT}
"
}
warningmsg() {
msg "${ORANGE}$1${NOFORMAT}
"
}
# Setup colors for messages if running interactive shell
if [[ -t 2 ]] && [[ "${TERM-}" != "dumb" ]]; then
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m'
else
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE=''
fi
args=("$@")
if [[ ${#args[@]} -ne 1 ]]; then
errormsg "No database given, exiting"
exit 1
fi
infomsg "Committing and pushing to registry"
database=${args[0]}
docker commit "$database" "registry.toska.cs.helsinki.fi/$database"
docker push "registry.toska.cs.helsinki.fi/$database"
successmsg "Succesfully pushed"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# === Config ===
# Quit if failing
set -euo pipefail
# Print messages and logs that are not script output to stderr
msg() {
echo >&2 -e "${1-}"
}
# Some special types of messages with colours
successmsg() {
msg "${GREEN}$1${NOFORMAT}
"
}
errormsg() {
msg "${RED}$1${NOFORMAT}
"
}
infomsg() {
msg "${BLUE}$1${NOFORMAT}
"
}
warningmsg() {
msg "${ORANGE}$1${NOFORMAT}
"
}
# Setup colors for messages if running interactive shell
if [[ -t 2 ]] && [[ "${TERM-}" != "dumb" ]]; then
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m'
else
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE=''
fi
# === Create images ===
retry () {
sleep 5
for i in {1..60}; do
"$@" && break || warningmsg "Retry attempt $i failed, waiting..." && sleep 5;
done
}
args=("$@")
if [[ ${#args[@]} -ne 1 ]]; then
errormsg "No database given, exiting"
exit 1
fi
infomsg "Restoring PostgreSQL dumps from backups. This might take a while."
database=${args[0]}
docker-compose down --remove-orphans
docker-compose up -d "$database"
database_dump="$database.sqz"
infomsg "Attempting to restore database $database from dump $database_dump"
retry docker-compose exec "$database" pg_isready --dbname="$database"
docker exec -i "$database" /bin/bash -c "pg_restore --username=postgres \
--format=custom --dbname=$database --no-owner" < "$database_dump"
successmsg "Database $database succesfully created"
+50
View File
@@ -0,0 +1,50 @@
x-defaults: &defaults
restart: unless-stopped
services:
kone-db:
<<: *defaults
container_name: kone-db
environment:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
image: registry.toska.cs.helsinki.fi/kone-db
# image: postgres:13.4-alpine
profiles:
- oodikone
sis-db:
<<: *defaults
container_name: sis-db
environment:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
# image: registry.toska.cs.helsinki.fi/sis-db
image: postgres:13.2
shm_size: 2gb
sis-importer-db:
<<: *defaults
container_name: sis-importer-db
environment:
PGDATA: /data
POSTGRES_DB: sis-importer-db
POSTGRES_HOST_AUTH_METHOD: trust
# image: registry.toska.cs.helsinki.fi/sis-importer-db
image: postgres:13.1
profiles:
- updater
user-db:
<<: *defaults
container_name: user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: registry.toska.cs.helsinki.fi/user-db
# image: postgres:13.4-alpine
profiles:
- oodikone
+133
View File
@@ -0,0 +1,133 @@
-- add these to sis-importer db by running:
-- docker exec -i sis-importer-db psql -U postgres sis-importer-db < enrich_sampledata.sql
-- if you need to run updater for these students only, see studentnumberlist in the end
-- this file
-- Add admission types (valintaperusteet) to studyrights for some of the computer
-- science 2018-2019 population students. See person_ids / student_number below
-- (in case you need to update dumps).
-- TODO: this should be moved into the sampledata creator script in
-- importer repo, but since this file contains studentnumbers and person ids, it's
-- better to keep this here
-- KP (koepisteet)
-- hy-hlo-1412620 | 013683126
-- hy-hlo-1485246 | 014340523
-- hy-hlo-32230770 | 010589388
-- hy-hlo-35626233 | 010775888
-- hy-hlo-40150144 | 011065995
UPDATE studyrights
SET admission_type_urn = 'urn:code:admission-type:kp'
WHERE person_id IN (
'hy-hlo-1412620',
'hy-hlo-1485246',
'hy-hlo-32230770',
'hy-hlo-35626233',
'hy-hlo-40150144'
);
-- TV (todistusvalinta)
-- hy-hlo-45783433 | 011321150
-- hy-hlo-48537946 | 010764653
-- hy-hlo-49801905 | 011194400
-- hy-hlo-53321302 | 011338871
-- hy-hlo-51223300 | 013951984
-- hy-hlo-53310137 | 012520688
-- hy-hlo-53621583 | 011196796
-- hy-hlo-53863064 | 011736884
-- hy-hlo-57711592 | 010262566
-- hy-hlo-57732872 | 013670647
UPDATE studyrights
SET admission_type_urn = 'urn:code:admission-type:tv'
WHERE person_id IN (
'hy-hlo-45783433',
'hy-hlo-48537946',
'hy-hlo-49801905',
'hy-hlo-53321302',
'hy-hlo-51223300',
'hy-hlo-53310137',
'hy-hlo-53621583',
'hy-hlo-53863064',
'hy-hlo-57711592',
'hy-hlo-57732872'
);
-- YP:
-- hy-hlo-67768022 | 011952325
-- hy-hlo-73740025 | 011736606
-- hy-hlo-74780917 | 012775433
-- hy-hlo-79615371 | 014113941
-- hy-hlo-78118455 | 014300615
UPDATE studyrights
SET admission_type_urn = 'urn:code:admission-type:yp'
WHERE person_id IN (
'hy-hlo-67768022',
'hy-hlo-73740025',
'hy-hlo-74780917',
'hy-hlo-79615371',
'hy-hlo-78118455'
);
-- AV:
-- hy-hlo-78777265 | 010779392
-- hy-hlo-81909550 | 011894153
-- hy-hlo-84571890 | 013897158
-- hy-hlo-80330834 | 015085926
-- hy-hlo-84775722 | 011791272
UPDATE studyrights
SET admission_type_urn = 'urn:code:admission-type:av'
WHERE person_id IN (
'hy-hlo-78777265',
'hy-hlo-81909550',
'hy-hlo-84571890',
'hy-hlo-80330834',
'hy-hlo-84775722'
);
-- M:
-- hy-hlo-87439827 | 011928456
-- hy-hlo-96612371 | 014418361
-- hy-hlo-84840261 | 010975895
-- hy-hlo-90330681 | 010522688
-- hy-hlo-96594603 | 013951573
UPDATE studyrights
SET admission_type_urn = 'urn:code:admission-type:m'
WHERE person_id IN (
'hy-hlo-87439827',
'hy-hlo-96612371',
'hy-hlo-84840261',
'hy-hlo-90330681',
'hy-hlo-96594603'
);
-- all studentnumbers for running updater:
013683126
014340523
010589388
010775888
011065995
011321150
010764653
011194400
011338871
013951984
012520688
011196796
011736884
010262566
013670647
011952325
011736606
012775433
014113941
014300615
010779392
011894153
013897158
015085926
011791272
011928456
014418361
010975895
010522688
013951573
+12
View File
@@ -186,6 +186,18 @@ describe('Course Statistics tests', () => {
cy.contains('td', '010674989').siblings().contains('1')
})
it.skip('Language distribution is correct', () => {
cy.get("input[placeholder='Search by a course code']").type('TKT20003')
cy.contains(/^TKT20003$/).click()
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('TKT20003, 582219 Käyttöjärjestelmät')
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click()
cy.contains('Population of course Käyttöjärjestelmät 2019-2020')
cy.contains('Language distribution').click()
cy.contains('td', 'finnish').siblings().contains('122')
cy.contains('td', 'english').siblings().contains('5')
})
describe('When searching unified course stats', () => {
beforeEach(() => {
cy.url().should('include', '/coursestatistics')
+87 -71
View File
@@ -9,23 +9,24 @@ const checkFilteringResult = studentCount => {
// Semantic UI doesn't allow injection of data-cy:s for single multiple dropdown selection.
// This function tries to click "x" on all selections inside dropdown matching given attribute
const clearSemanticUIMultipleDropDownSelection = dataCyAttribute => {
cy.cs(dataCyAttribute).find('i.delete').click()
cy.cs(dataCyAttribute).find('i.delete').click({ force: true })
cy.cs(dataCyAttribute).click()
}
const clearSingleDropdownSelection = dataCyAttribute => {
cy.cs(dataCyAttribute).find('i.clear').click()
cy.cs(dataCyAttribute).find('i.clear').click({ force: true })
}
// Helper tool to create pre and post steps for each filter step. Created to avoid copypasting clicking and checking
// to every it-function. Reason behind using test function wrapper is that Cypresses internal beforeEach / afterEach
// functions don't take any parameters and using global object for matching test step name seemed overcomplicated.
const createRunTestStepWithPreAndPostPartsFunction = amountWithoutFiltering => {
return (dataCyAttributeOfHeaderToClick, testStepFunctionToRun) => {
cy.cs(dataCyAttributeOfHeaderToClick).click()
return (filterName, testStepFunctionToRun) => {
cy.get(`[data-cy="${filterName}-header"]`).click()
checkFilteringResult(amountWithoutFiltering)
testStepFunctionToRun()
checkFilteringResult(amountWithoutFiltering)
cy.cs(dataCyAttributeOfHeaderToClick).click()
cy.get(`[data-cy="${filterName}-header"]`).click({ force: true })
}
}
@@ -41,80 +42,85 @@ describe('Population Statistics', () => {
it('Transfer filter is on not transferred by default', () => {
cy.contains('By default only students who have not transferred to this study programme are shown.')
cy.cs('transferredToProgrammeFilter-havenot').find('input').should('be.checked')
const card = cy.cs('TransferredToProgramme-filter-card')
card.cs('TransferredToProgramme-header').click()
card.get('[data-cy="option-havenot"] input').should('be.checked')
card.cs('TransferredToProgramme-header').click()
})
it('Graduation filter works', () => {
runTestStepWithPreAndPostParts('graduatedFromProgrammeFilter-header', () => {
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
const card = cy.cs('GraduatedFromProgramme-filter-card')
const graduated = 11
cy.cs('graduatedFromProgrammeFilter-graduated-true').click()
card.cs('option-graduated-true').click()
checkFilteringResult(graduated)
cy.cs('graduatedFromProgrammeFilter-graduated-false').click()
card.cs('option-graduated-false').click()
checkFilteringResult(defaultAmountOfStudents - graduated)
cy.cs('graduatedFromProgrammeFilter-all').click()
card.get('[data-cy="GraduatedFromProgramme-filter-card"] [data-cy="option-all"]').click()
})
})
it('Transfer filter works', () => {
runTestStepWithPreAndPostParts('transferredToProgrammeFilter-header', () => {
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
const transferred = 1
cy.cs('transferredToProgrammeFilter-have').click()
cy.cs('option-have').click()
checkFilteringResult(transferred)
cy.cs('transferredToProgrammeFilter-all').click()
cy.cs('option-all').click()
checkFilteringResult(defaultAmountOfStudents + transferred)
cy.cs('transferredToProgrammeFilter-havenot').click()
cy.cs('option-havenot').click()
})
})
it('Enrollment filter works', () => {
runTestStepWithPreAndPostParts('enrollmentStatusFilter-header', () => {
cy.selectFromDropdown('enrollmentStatusFilter-status', 0)
cy.selectFromDropdown('enrollmentStatusFilter-semesters', [0])
runTestStepWithPreAndPostParts('EnrollmentStatus', () => {
cy.cs('enrollmentStatusFilter-status').selectFromDropdown(0)
cy.cs('enrollmentStatusFilter-semesters').selectFromDropdown(0)
checkFilteringResult(137)
clearSemanticUIMultipleDropDownSelection('enrollmentStatusFilter-semesters')
})
})
it('Credit filter works', () => {
runTestStepWithPreAndPostParts('credit-filter-header', () => {
runTestStepWithPreAndPostParts('CreditsEarned', () => {
cy.cs('credit-filter-min').type('50')
cy.cs('credit-filter-max').type('150')
checkFilteringResult(116)
cy.cs('credit-filter-min').find('input').clear()
cy.cs('credit-filter-max').find('input').clear()
cy.cs('credit-filter-min').clear()
cy.cs('credit-filter-max').clear()
})
})
it('Age filter works', () => {
runTestStepWithPreAndPostParts('ageFilter-header', () => {
runTestStepWithPreAndPostParts('Age', () => {
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()
})
})
it('Gender filter works', () => {
runTestStepWithPreAndPostParts('genderFilter-header', () => {
cy.selectFromDropdown('genderFilter-dropdown', 0)
runTestStepWithPreAndPostParts('Gender', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(36)
cy.selectFromDropdown('genderFilter-dropdown', 1)
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(112)
cy.selectFromDropdown('genderFilter-dropdown', 2)
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
checkFilteringResult(0)
cy.selectFromDropdown('genderFilter-dropdown', 3)
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
checkFilteringResult(0)
clearSingleDropdownSelection('genderFilter-dropdown')
})
})
it('Starting year filter works', () => {
runTestStepWithPreAndPostParts('startYearAtUni-header', () => {
cy.selectFromDropdown('startYearAtUni-dropdown', 0)
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
checkFilteringResult(1)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
cy.selectFromDropdown('startYearAtUni-dropdown', 14)
cy.cs('startYearAtUni-dropdown').selectFromDropdown(14)
checkFilteringResult(121)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
})
@@ -122,10 +128,10 @@ describe('Population Statistics', () => {
// Return when we have +2020 population with admission types in data
it.skip('Admission type filter works', () => {
runTestStepWithPreAndPostParts('admissionTypeFilter-header', () => {
cy.selectFromDropdown('admissionTypeFilter-dropdown', 0)
runTestStepWithPreAndPostParts('AdmissionTypeFilter', () => {
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(5)
cy.selectFromDropdown('admissionTypeFilter-dropdown', 2)
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(2)
checkFilteringResult(10)
clearSingleDropdownSelection('admissionTypeFilter-dropdown')
})
@@ -135,31 +141,32 @@ describe('Population Statistics', () => {
it.skip('Courses filter works', () => {
// courses takes some time to load, wait for it to complete
cy.wait(10000)
runTestStepWithPreAndPostParts('courseFilter-header', () => {
runTestStepWithPreAndPostParts('Courses', () => {
cy.cs('courseFilter-course-dropdown').click()
const courses = ['TKT20001', 'MAT11002']
cy.cs('courseFilter-course-dropdown').click().contains(courses[0]).click()
checkFilteringResult(140)
cy.selectFromDropdown(`courseFilter-${courses[0]}-dropdown`, 1)
cy.cs(`courseFilter-${courses[0]}-dropdown`).selectFromDropdown(1)
checkFilteringResult(131)
cy.cs('courseFilter-course-dropdown').click().contains(courses[1]).click()
checkFilteringResult(56)
cy.selectFromDropdown(`courseFilter-${courses[1]}-dropdown`, 2)
cy.cs(`courseFilter-${courses[1]}-dropdown`).selectFromDropdown(2)
checkFilteringResult(2)
courses.forEach(course => cy.cs(`courseFilter-${course}-clear`).click())
})
})
it('Filter combinations work', () => {
runTestStepWithPreAndPostParts('graduatedFromProgrammeFilter-header', () => {
runTestStepWithPreAndPostParts('ageFilter-header', () => {
cy.cs('graduatedFromProgrammeFilter-graduated-true').click()
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
runTestStepWithPreAndPostParts('Age', () => {
const card = cy.cs('GraduatedFromProgramme-filter-card')
card.cs('option-graduated-true').click()
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('30')
checkFilteringResult(11)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
cy.cs('graduatedFromProgrammeFilter-all').click()
card.cs('option-all').click()
})
})
})
@@ -176,7 +183,7 @@ describe('Course Statistics', () => {
})
it('Grade filter works', () => {
runTestStepWithPreAndPostParts('gradeFilter-header', () => {
runTestStepWithPreAndPostParts('Grade', () => {
cy.cs('gradeFilter-5').click()
checkFilteringResult(20)
cy.cs('gradeFilter-3').click()
@@ -187,43 +194,43 @@ describe('Course Statistics', () => {
})
it('Age filter works', () => {
runTestStepWithPreAndPostParts('ageFilter-header', () => {
runTestStepWithPreAndPostParts('Age', () => {
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()
})
})
it('Gender filter works', () => {
runTestStepWithPreAndPostParts('genderFilter-header', () => {
cy.selectFromDropdown('genderFilter-dropdown', 0)
runTestStepWithPreAndPostParts('Gender', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(27)
cy.selectFromDropdown('genderFilter-dropdown', 1)
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(91)
cy.selectFromDropdown('genderFilter-dropdown', 2)
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
checkFilteringResult(0)
cy.selectFromDropdown('genderFilter-dropdown', 3)
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
checkFilteringResult(0)
clearSingleDropdownSelection('genderFilter-dropdown')
})
})
it('Starting year filter works', () => {
runTestStepWithPreAndPostParts('startYearAtUni-header', () => {
cy.selectFromDropdown('startYearAtUni-dropdown', [0])
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
cy.cs('startYearAtUni-dropdown').selectFromDropdown([0])
checkFilteringResult(1)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
cy.selectFromDropdown('startYearAtUni-dropdown', 12)
cy.cs('startYearAtUni-dropdown').selectFromDropdown(12)
checkFilteringResult(29)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
})
})
it('Filter combinations work', () => {
runTestStepWithPreAndPostParts('gradeFilter-header', () => {
runTestStepWithPreAndPostParts('ageFilter-header', () => {
runTestStepWithPreAndPostParts('Grade', () => {
runTestStepWithPreAndPostParts('Age', () => {
cy.cs('gradeFilter-3').click()
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('30')
@@ -245,13 +252,14 @@ describe('Custom Population Statistics', () => {
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(students.length)
cy.cs('student-no-input').click().type(students.join('/n'))
cy.cs('student-no-input').click().type(students.join('\n'))
cy.wait(10000)
})
cy.cs('search-button').click()
})
it('Age filter works', () => {
runTestStepWithPreAndPostParts('ageFilter-header', () => {
runTestStepWithPreAndPostParts('Age', () => {
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('30')
checkFilteringResult(1)
@@ -261,35 +269,43 @@ describe('Custom Population Statistics', () => {
})
it('Gender filter works', () => {
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter-dropdown', 0)
checkFilteringResult(3)
cy.selectFromDropdown('genderFilter-dropdown', 1)
checkFilteringResult(2)
cy.selectFromDropdown('genderFilter-dropdown', 2)
checkFilteringResult(0)
clearSingleDropdownSelection('genderFilter-dropdown')
checkFilteringResult(5, true)
runTestStepWithPreAndPostParts('Gender', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(3)
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(2)
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
checkFilteringResult(0)
clearSingleDropdownSelection('genderFilter-dropdown')
checkFilteringResult(5, true)
})
})
it('Starting year filter works', () => {
runTestStepWithPreAndPostParts('startYearAtUni-header', () => {
cy.selectFromDropdown('startYearAtUni-dropdown', [0])
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
cy.cs('startYearAtUni-dropdown').selectFromDropdown([0])
checkFilteringResult(1)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
cy.cs('startYearAtUni-dropdown').click()
})
})
it('Courses filter works', () => {
runTestStepWithPreAndPostParts('courseFilter-header', () => {
runTestStepWithPreAndPostParts('Courses', () => {
const courses = ['MAT11001', 'TKT20004']
cy.cs('courseFilter-course-dropdown').click().contains(courses[0]).click()
cy.cs('courseFilter-course-dropdown')
.click()
.contains(courses[0] + ' ')
.click()
checkFilteringResult(5)
cy.selectFromDropdown(`courseFilter-${courses[0]}-dropdown`, 1)
cy.cs(`courseFilter-${courses[0]}-dropdown`).selectFromDropdown(1)
checkFilteringResult(5)
cy.cs('courseFilter-course-dropdown').click().contains(courses[1]).click()
cy.cs('courseFilter-course-dropdown')
.click()
.contains(courses[1] + ' ')
.click()
checkFilteringResult(3)
cy.selectFromDropdown(`courseFilter-${courses[1]}-dropdown`, 3)
cy.cs(`courseFilter-${courses[1]}-dropdown`).selectFromDropdown(3)
checkFilteringResult(1)
courses.forEach(course => cy.cs(`courseFilter-${course}-clear`).click())
})
+56 -2
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,9 +130,56 @@ 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')
+12 -2
View File
@@ -13,7 +13,7 @@ describe('Student Statistics tests', () => {
studentnumber: '010654019',
}
it.only('Student statistics search form is usable', () => {
it('Student statistics search form is usable', () => {
cy.contains('Show student names')
cy.url().should('include', '/students')
cy.get('.prompt').type(student.lastname)
@@ -80,7 +80,7 @@ describe('Student Statistics tests', () => {
cy.contains('Student number').should('not.exist')
})
it.only('Can jump to population page', () => {
it('Can jump to population page', () => {
cy.get('.prompt').type(student.studentnumber)
cy.contains(student.studentnumber).click()
cy.get('i.level.up.alternate.icon').eq(0).click()
@@ -88,3 +88,13 @@ describe('Student Statistics tests', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
})
})
// Use admin to see all students
describe('Testing with admin rights', () => {
it('Does not crash if student has no studyright or courses', () => {
const studentNumber = '011825096'
cy.init(`/students/${studentNumber}`, 'admin') // use admin to see all students
cy.contains('Started: Unavailable')
cy.contains('Credits: 0')
})
})
+9 -7
View File
@@ -54,8 +54,6 @@ Cypress.Commands.add('init', (path = '', userId = 'basic') => {
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
req.headers = headersToUse
})
// Hide SIS warning flag to avoid screen size problems when running headlessly
window.localStorage.setItem('SIS_WARNING_FLAG', 'true')
const baseUrl = Cypress.config().baseUrl
cy.visit(baseUrl.concat(path))
})
@@ -63,24 +61,28 @@ Cypress.Commands.add('init', (path = '', userId = 'basic') => {
/**
* Shorthand for using "Cypress Selectors" (CS), i.e., `data-cy` attributes.
*/
Cypress.Commands.add('cs', name => {
return cy.get(`[data-cy='${name}']`)
Cypress.Commands.add('cs', { prevSubject: 'optional' }, (subject, name) => {
const p = subject === undefined ? cy : cy.wrap(subject)
return p.get(`[data-cy='${name}']`)
})
/**
* Select item specified by `index` number from a semantic-ui dropdown with
* `data-cy` attribute value `name`.
*/
Cypress.Commands.add('selectFromDropdown', (name, index) => {
Cypress.Commands.add('selectFromDropdown', { prevSubject: true }, (s, index) => {
const subject = cy.wrap(s)
const indexes = Array.isArray(index) ? index : [index]
indexes.forEach(i => {
cy.cs(name).click().children('.menu').children().eq(i).click({ force: true })
subject.click()
subject.children('.menu').children().eq(i).click({ force: true })
})
// Close multiple selection so it does not block elements underneath it.
if (Array.isArray(index)) {
cy.cs(name).children('.icon').click({ force: true })
cy.wrap(s).children('.icon').click({ force: true })
}
})
+55 -5
View File
@@ -2,7 +2,6 @@ x-defaults: &defaults
restart: unless-stopped
services:
backend:
<<: *defaults
container_name: backend
@@ -11,7 +10,7 @@ services:
- redis
- sis-db
environment:
CI: "true"
CI: 'true'
DB_URL_KONE: postgres://postgres@kone-db:5432/kone-db
FRONT_URL: http://localhost:1337
REDIS: redis
@@ -31,11 +30,15 @@ services:
kone-db:
<<: *defaults
container_name: kone-db
environment:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
image: registry.toska.cs.helsinki.fi/kone-db
redis:
<<: *defaults
command: "redis-server --appendonly yes"
command: 'redis-server --appendonly yes'
container_name: redis
image: redis
ports:
@@ -45,10 +48,19 @@ services:
<<: *defaults
container_name: sis-db
image: registry.toska.cs.helsinki.fi/sis-db
environment:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
shm_size: 2gb
user-db:
<<: *defaults
container_name: user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: registry.toska.cs.helsinki.fi/user-db
userservice:
@@ -57,7 +69,7 @@ services:
depends_on:
- user-db
environment:
CI: "true"
CI: 'true'
DB_URL: postgres://postgres@user-db:5432/user-db
SECRET: Luukk4inenApprov3dVitunMinttuSalainenAvain666NurmivaaraPJ2019
TOKEN_SECRET: IWannaBeTheVeryBest
@@ -71,6 +83,44 @@ services:
- backend
- userservice
ports:
- 1337:80
- 1337:80
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
# Following services are used to run sis-updater-worker when anon sis-db needs to be updated. Could probably also use production version, but this certainly works.
sis-importer-db:
<<: *defaults
container_name: sis-importer-db
image: registry.toska.cs.helsinki.fi/sis-importer-db
profiles:
- updater
sis-updater-worker:
<<: *defaults
build:
target: development
context: updater/sis-updater-worker
depends_on:
- redis
- sis-db
- sis-importer-db
- sis-updater-nats
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_URI: //redis:6379
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
profiles:
- updater
sis-updater-nats:
<<: *defaults
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
container_name: sis-updater-nats
image: nats-streaming:0.16.2
profiles:
- updater
+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"
}
}
+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",
+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 () => {},
}
+3
View File
@@ -47,6 +47,9 @@ Course.init(
substitutions: {
type: JSONB,
},
course_unit_type: {
type: STRING,
},
},
{
underscored: false,
+3
View File
@@ -75,6 +75,9 @@ Credit.init(
updatedAt: {
type: DATE,
},
language: {
type: STRING,
},
},
{
underscored: false,
@@ -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,
}
+1 -1
View File
@@ -17,7 +17,6 @@ Student.init(
dateofuniversityenrollment: { type: DATE },
email: { type: STRING },
national_student_number: { type: STRING },
home_county_id: { type: INTEGER },
country_fi: { type: STRING },
country_sv: { type: STRING },
country_en: { type: STRING },
@@ -25,6 +24,7 @@ Student.init(
home_country_sv: { type: STRING },
home_country_en: { type: STRING },
gender_code: { type: INTEGER },
sis_person_id: { type: STRING },
createdAt: {
type: DATE,
},
+2
View File
@@ -26,6 +26,7 @@ const customPopulationSearch = require('./routes/customPopulationSearch')
const trends = require('./routes/trends')
const programmeModules = require('./routes/programmeModules')
const studyGuidanceGroups = require('./routes/studyGuidanceGroups')
const studyProgramme = require('./routes/studyProgramme')
module.exports = (app, url) => {
app.use(url, ping)
@@ -54,6 +55,7 @@ module.exports = (app, url) => {
app.use(url, semesters)
app.use(url, tags)
app.use(url, programmeModules)
app.use(url, studyProgramme)
app.use(`${url}/faculties`, faculties)
app.use(`${url}/updater`, auth.roles(['admin']), updater)
app.use(`${url}/teachers`, auth.roles(['teachers']), teachers)
+17 -3
View File
@@ -2,6 +2,7 @@ const router = require('express').Router()
const Student = require('../services/students')
const userService = require('../services/userService')
const Unit = require('../services/units')
const { getAllStudentsUserHasInGroups } = require('../services/studyGuidanceGroups')
const filterStudentTags = (student, userId) => {
return {
@@ -13,7 +14,7 @@ const filterStudentTags = (student, userId) => {
router.get('/students', async (req, res) => {
const {
roles,
decodedToken: { userId },
decodedToken: { userId, sisPersonId },
query: { searchTerm },
} = req
@@ -37,8 +38,14 @@ router.get('/students', async (req, res) => {
} else {
const unitsUserCanAccess = await userService.getUnitsFromElementDetails(userId)
const codes = unitsUserCanAccess.map(unit => unit.id)
let extraStudents = []
if (roles.includes('studyGuidanceGroups')) {
const matchingOnlyByTerm = await Student.bySearchTerm(trimmedSearchTerm)
const studentsUserCanAccess = await getAllStudentsUserHasInGroups(sisPersonId)
extraStudents.push(...matchingOnlyByTerm.filter(s => studentsUserCanAccess.has(s.studentNumber)))
}
const matchingStudents = await Student.bySearchTermAndElements(trimmedSearchTerm, codes)
res.json(matchingStudents)
res.json([...matchingStudents, ...extraStudents])
}
})
@@ -46,7 +53,7 @@ router.get('/students/:id', async (req, res) => {
const { id: studentId } = req.params
const { roles, decodedToken } = req
if (roles && roles.includes('admin')) {
if (roles?.includes('admin')) {
const results = await Student.withId(studentId)
return results.error
? res.status(400).json({ error: 'error finding student' }).end()
@@ -67,6 +74,13 @@ router.get('/students/:id', async (req, res) => {
})
)
if (roles?.includes('studyGuidanceGroups')) {
const studentsUserCanAccess = await getAllStudentsUserHasInGroups(decodedToken.sisPersonId)
if (studentsUserCanAccess.has(studentId)) {
return res.status(200).json(filterStudentTags(student, decodedToken.id)).end()
}
}
if (rights.some(right => right !== null)) {
res.status(200).json(filterStudentTags(student, decodedToken.id)).end()
} else {
@@ -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
@@ -0,0 +1,103 @@
const router = require('express').Router()
const {
getBasicStatsForStudytrack,
getCreditStatsForStudytrack,
getGraduationStatsForStudytrack,
} = require('../services/studyprogrammeStats')
const {
getBasicStats,
setBasicStats,
getCreditStats,
setCreditStats,
getGraduationStats,
setGraduationStats,
} = require('../services/analyticsService')
const logger = require('../util/logger')
router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
const code = req.params.id
const yearType = req.query?.year_type
if (code) {
let data = null
try {
data = await getBasicStats(code, yearType)
} catch (e) {
logger.error(`Failed to get code ${code} basic stats: ${e}`)
}
if (!data) {
try {
const result = await getBasicStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
})
data = await setBasicStats(result, yearType)
} catch (e) {
logger.error(`Failed to update code ${code} basic stats: ${e}`)
}
}
return res.json(data)
} else {
res.status(422).end()
}
})
router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
const code = req.params.id
const yearType = req.query?.year_type
if (code) {
let data = null
try {
data = await getCreditStats(code, yearType)
} catch (e) {
logger.error(`Failed to get code ${code} credit stats`)
}
if (!data) {
try {
const result = await getCreditStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
})
data = await setCreditStats(result, yearType)
} catch (e) {
logger.error(`Failed to update code ${code} credit stats`)
}
}
return res.json(data)
} else {
res.status(422).end()
}
})
router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
const code = req.params.id
const yearType = req.query?.year_type
if (code) {
let data = null
try {
data = await getGraduationStats(code, yearType)
} catch (e) {
logger.error(`Failed to get code ${code} graduation stats`)
}
if (!data) {
try {
const result = await getGraduationStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
})
data = await setGraduationStats(result, yearType)
} catch (e) {
logger.error(`Failed to update code ${code} graduation stats`)
}
}
return res.json(data)
} else {
res.status(422).end()
}
})
router.use('*', (req, res, next) => next())
module.exports = router
@@ -3,6 +3,9 @@ const moment = require('moment')
const createRedisKeyForProductivity = id => `PRODUCTIVITY_${id}`
const createRedisKeyForThroughput = id => `THROUGHPUT_${id}`
const createRedisKeyForBasicStats = (id, yearType) => `BASIC_STATS_${id}_${yearType}`
const createRedisKeyForCreditStats = (id, yearType) => `CREDIT_STATS_${id}_${yearType}`
const createRedisKeyForGraduationStats = (id, yearType) => `GRADUATION_STATS_${id}_${yearType}`
const getProductivity = async id => {
const redisKey = createRedisKeyForProductivity(id)
@@ -87,6 +90,66 @@ const patchThroughput = async data => {
}
}
const getBasicStats = async (id, yearType) => {
const redisKey = createRedisKeyForBasicStats(id, yearType)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setBasicStats = async (data, yearType) => {
const { id } = data
const redisKey = createRedisKeyForBasicStats(id, yearType)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
}
const getCreditStats = async (id, yearType) => {
const redisKey = createRedisKeyForCreditStats(id, yearType)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setCreditStats = async (data, yearType) => {
const { id } = data
const redisKey = createRedisKeyForCreditStats(id, yearType)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
}
const getGraduationStats = async (id, yearType) => {
const redisKey = createRedisKeyForGraduationStats(id, yearType)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setGraduationStats = async (data, yearType) => {
const { id } = data
const redisKey = createRedisKeyForGraduationStats(id, yearType)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
}
module.exports = {
getProductivity,
setProductivity,
@@ -94,4 +157,10 @@ module.exports = {
getThroughput,
setThroughput,
patchThroughput,
getBasicStats,
setBasicStats,
getCreditStats,
setCreditStats,
getGraduationStats,
setGraduationStats,
}
+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
@@ -0,0 +1,311 @@
const sequelize = require('sequelize')
const { Op } = sequelize
const {
Credit,
Course,
Organization,
Studyright,
StudyrightElement,
ElementDetail,
Transfer,
Student,
SemesterEnrollment,
Semester,
} = require('../models')
const { formatStudyright } = require('./studyprogrammeHelpers')
const enrolledStudyrights = async studytrack => {
const studyrights = await Studyright.findAll({
attributes: ['studyrightid', 'studystartdate', 'enddate', 'prioritycode'],
include: [
{
model: StudyrightElement,
required: true,
where: { code: studytrack },
},
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
],
})
const studentnumbers = studyrights.map(s => s.student.studentnumber)
const students = await Student.findAll({
attributes: ['studentnumber'],
include: [
{
model: Studyright,
include: [
{
model: StudyrightElement,
required: true,
where: {
code: studytrack,
},
},
],
attributes: ['studyrightid'],
},
{
model: SemesterEnrollment,
attributes: ['semestercode'],
include: [
{
model: Semester,
required: true,
},
],
where: {
enrollmenttype: 1,
},
},
],
where: {
studentnumber: {
[Op.in]: studentnumbers,
},
},
})
let enrollments = new Set()
students.forEach(student => {
student.semester_enrollments.forEach(e => {
const enrollmentStartDate = e.semester.startdate
const enrollmentEndDate = e.semester.enddate
const correctStudyRight = studyrights.find(
s =>
student.studentnumber === s.student.studentnumber &&
s.studystartdate <= enrollmentStartDate && // Studying in the programme should have started on the same day or earlier than the enrollment
s.enddate >= enrollmentEndDate && // Studying in the programme should have ended on the same day or later than the enrollment
(!s.canceldate || s.canceldate > enrollmentStartDate) && // Studyright should not be canceled OR the possible canceldate should be after the enrollmentStartDate
(s.prioritycode === 1 || s.prioritycode === 30) // Studyright should be primary or the student should have graduated
)
if (correctStudyRight) {
enrollments.add({
studentnumber: student.studentnumber,
startdate: e.semester.startdate,
})
}
})
})
return enrollments
}
const startedStudyrights = async (studytrack, since) =>
await Studyright.findAll({
include: {
model: StudyrightElement,
required: true,
include: {
model: ElementDetail,
required: true,
where: {
code: studytrack,
},
},
},
where: {
studystartdate: {
[Op.gte]: since,
},
},
})
const graduatedStudyRights = async (studytrack, since) =>
await Studyright.findAll({
include: {
model: StudyrightElement,
required: true,
include: {
model: ElementDetail,
required: true,
where: {
code: studytrack,
},
},
},
where: {
graduated: 1,
enddate: {
[Op.gte]: since,
},
},
})
const cancelledStudyRights = async (studytrack, since) => {
return await Studyright.findAll({
include: {
model: StudyrightElement,
required: true,
where: {
code: {
[Op.eq]: studytrack,
},
},
},
where: {
canceldate: {
[Op.gte]: since,
},
},
})
}
const transfersAway = async (studytrack, since) => {
return await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: since,
},
sourcecode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
}
const transfersTo = async (studytrack, since) => {
return await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: since,
},
targetcode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
}
const getProgrammesStudents = async studyprogramme =>
(
await Studyright.findAll({
attributes: ['studyrightid', 'studystartdate', 'enddate', 'graduated', 'prioritycode', 'extentcode'],
include: [
{
model: StudyrightElement,
required: true,
where: {
code: {
[Op.in]: [studyprogramme],
},
},
},
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
],
})
).map(formatStudyright)
const getCreditsForStudyProgramme = async (provider, since) =>
await Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
include: {
model: Course,
attributes: ['code'],
required: true,
where: {
is_study_module: false,
},
include: {
model: Organization,
required: true,
where: {
code: provider,
},
},
},
where: {
credittypecode: {
[Op.notIn]: [10, 9, 7],
},
isStudyModule: {
[Op.not]: true,
},
attainment_date: {
[Op.gte]: since,
},
},
})
const getTransferredCredits = async (provider, since) =>
await Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'credittypecode', 'student_studentnumber'],
include: {
model: Course,
attributes: ['code'],
required: true,
where: {
is_study_module: false,
},
include: {
model: Organization,
required: true,
where: {
code: provider,
},
},
},
where: {
credittypecode: {
[Op.eq]: [9],
},
attainment_date: {
[Op.gte]: since,
},
},
})
const getThesisCredits = async (provider, since, thesisType) =>
await Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
include: {
model: Course,
attributes: ['code'],
required: true,
where: {
course_unit_type: {
[Op.in]: thesisType,
},
},
include: {
model: Organization,
required: true,
where: {
code: provider,
},
},
},
where: {
credittypecode: {
[Op.notIn]: [10, 9, 7],
},
isStudyModule: {
[Op.not]: true,
},
attainment_date: {
[Op.gte]: since,
},
},
})
module.exports = {
enrolledStudyrights,
startedStudyrights,
graduatedStudyRights,
cancelledStudyRights,
transfersAway,
transfersTo,
getProgrammesStudents,
getCreditsForStudyProgramme,
getTransferredCredits,
getThesisCredits,
}
+4 -1
View File
@@ -65,7 +65,7 @@ const formatStudentForPopulationStatistics = (
startDateMoment,
endDateMoment
) => {
const toCourse = ({ grade, attainment_date, credits, course_code, credittypecode, isStudyModule }) => {
const toCourse = ({ grade, attainment_date, credits, course_code, credittypecode, isStudyModule, language }) => {
const attainment_date_normailized =
attainment_date < startDate ? startDateMoment.clone().add(1, 'day').toISOString() : attainment_date
const passed = Credit.passed({ credittypecode })
@@ -78,6 +78,7 @@ const formatStudentForPopulationStatistics = (
credits,
isStudyModuleCredit: isStudyModule,
credittypecode,
language,
}
}
@@ -265,6 +266,7 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
'isStudyModule',
'student_studentnumber',
'course_code',
'language',
],
where: creditsOfStudent,
raw: true,
@@ -494,6 +496,7 @@ const studentnumbersWithAllStudyrightElements = async (
if (!newestStudytrack) return false
return studyRights.includes(newestStudytrack.code)
})
const studentnumberlist = filteredStudentnumbers.length > 0 ? filteredStudentnumbers : studentnumbers
// fetch students that have transferred out of the programme and filter out these studentnumbers
@@ -1,28 +1,63 @@
const { getImporterClient } = require('../util/importerClient')
const importerClient = getImporterClient()
const { StudyGuidanceGroupTag } = require('../models/models_kone')
const logger = require('../util/logger')
const getAllStudentsUserHasInGroups = async sisPersonId => {
if (!importerClient) {
return new Set()
}
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
return new Set(
Object.values(data)
.map(group => group.members)
.flat()
.map(member => member.personStudentNumber)
)
try {
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
return new Set(
Object.values(data)
.map(group => group.members)
.flat()
.map(member => member.personStudentNumber)
)
} catch (error) {
logger.error("Couldn't fetch users studyguidance groups")
return new Set()
}
}
const getAllGroupsAndStudents = async sisPersonId => {
if (!importerClient) {
return []
}
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
return Object.values(data)
try {
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
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] }))
} catch (error) {
logger.error("Couldn't fetch users studyguidance groups")
return []
}
}
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,
}
@@ -211,7 +211,7 @@ const findProgrammeThesisCredits = async code => {
},
where: {
credittypecode: {
[Op.ne]: 10,
[Op.eq]: 4,
},
},
})
@@ -0,0 +1,109 @@
const { mean } = require('lodash')
// Helper functions
const formatStudyright = studyright => {
const { studyrightid, studystartdate, enddate, graduated, prioritycode, extentcode, student } = studyright
return {
studyrightid,
studystartdate,
enddate,
graduated,
prioritycode,
extentcode,
studentnumber: student.studentnumber,
}
}
const getYearsArray = (since, isAcademicYear) => {
const years = []
for (let i = since; i <= new Date().getFullYear(); i++) {
const year = isAcademicYear ? `${i} - ${i + 1}` : i
years.push(year)
}
return years
}
const getYearsObject = (years, emptyArrays = false) => {
let yearsObject = {}
for (const year of years) {
yearsObject = { ...yearsObject, [year]: emptyArrays ? [] : 0 }
}
return yearsObject
}
const getStatsBasis = years => {
return {
graphStats: new Array(years.length).fill(0),
tableStats: getYearsObject(years),
}
}
const isMajorStudentCredit = (studyright, attainment_date) =>
studyright &&
(studyright.prioritycode === 1 || studyright.prioritycode === 30) && // Is studyright state = MAIN or state = GRADUATED
studyright.studystartdate <= attainment_date && // Has the credit been attained after studying in the programme started
studyright.enddate >= attainment_date && // Has the credit been attained before the studyright ended
(!studyright.canceldate || studyright.canceldate >= attainment_date) // If the studyright was cancelled, was the credit attained before it was cancelled
const getMedian = values => {
if (values.length === 0) return 0
values.sort((a, b) => a - b)
const half = Math.floor(values.length / 2)
if (values.length % 2) return values[half]
return (values[half - 1] + values[half]) / 2.0
}
const getMean = values => {
if (values.length === 0) return 0
return Math.round(mean(values))
}
const defineYear = (date, isAcademicYear) => {
if (!date) return ''
const year = date.getFullYear()
if (!isAcademicYear) return year
if (date < new Date(`${year}-07-31`)) return `${year - 1} - ${year}`
return `${year} - ${year + 1}`
}
const getStartDate = (studyprogramme, isAcademicYear) => {
if ((studyprogramme.includes('KH') || studyprogramme.includes('MH')) && isAcademicYear) return new Date('2017-08-01')
if (studyprogramme.includes('KH') || studyprogramme.includes('MH')) return new Date('2017-01-01')
if (isAcademicYear) return new Date('2000-08-01')
return new Date('2000-01-01')
}
// There are 9 course_unit_types
// 1. urn:code:course-unit-type:regular
// 2. urn:code:course-unit-type:bachelors-thesis
// 3. urn:code:course-unit-type:masters-thesis
// 4. urn:code:course-unit-type:doctors-thesis
// 5. urn:code:course-unit-type:licentiate-thesis
// 6. urn:code:course-unit-type:bachelors-maturity-examination
// 7. urn:code:course-unit-type:masters-maturity-examination
// 8. urn:code:course-unit-type:communication-and-linguistic-studies
// 9. urn:code:course-unit-type:practical-training-homeland
// Four of these are thesis types
const getThesisType = studyprogramme => {
if (studyprogramme.includes('MH') || studyprogramme.includes('ma'))
return ['urn:code:course-unit-type:masters-thesis']
if (studyprogramme.includes('KH') || studyprogramme.includes('ba'))
return ['urn:code:course-unit-type:bachelors-thesis']
if (/^(T)[0-9]{6}$/.test(studyprogramme))
return ['urn:code:course-unit-type:doctors-thesis', 'urn:code:course-unit-type:licentiate-thesis']
return 'thesis-type-not-defined'
}
module.exports = {
formatStudyright,
getYearsArray,
getYearsObject,
getStatsBasis,
isMajorStudentCredit,
getMedian,
getMean,
defineYear,
getStartDate,
getThesisType,
}
@@ -0,0 +1,326 @@
const moment = require('moment')
const { indexOf } = require('lodash')
const { mapToProviders } = require('../util/utils')
const {
getStatsBasis,
defineYear,
isMajorStudentCredit,
getYearsObject,
getYearsArray,
getMedian,
getMean,
getStartDate,
getThesisType,
} = require('./studyprogrammeHelpers')
const {
graduatedStudyRights,
startedStudyrights,
cancelledStudyRights,
transfersAway,
transfersTo,
getProgrammesStudents,
getCreditsForStudyProgramme,
getTransferredCredits,
getThesisCredits,
enrolledStudyrights,
} = require('./newStudyprogramme')
const getEnrolledStats = async (studytrack, since, years, isAcademicYear) => {
const students = await enrolledStudyrights(studytrack)
const { graphStats, tableStats } = getStatsBasis(years)
students.forEach(({ startdate }) => {
if (startdate.getMonth() === 7) {
const enrolledYear = defineYear(startdate, isAcademicYear)
graphStats[indexOf(years, enrolledYear)] += 1
tableStats[enrolledYear] += 1
}
})
return { graphStats, tableStats }
}
const getGraduatedStats = async (studytrack, since, years, isAcademicYear) => {
const studyrights = await graduatedStudyRights(studytrack, since)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ enddate }) => {
const graduationYear = defineYear(enddate, isAcademicYear)
graphStats[indexOf(years, graduationYear)] += 1
tableStats[graduationYear] += 1
})
return { graphStats, tableStats }
}
const getStartedStats = async (studytrack, since, years, isAcademicYear) => {
const studyrights = await startedStudyrights(studytrack, since)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ studystartdate }) => {
const startYear = defineYear(studystartdate, isAcademicYear)
graphStats[indexOf(years, startYear)] += 1
tableStats[startYear] += 1
})
return { graphStats, tableStats }
}
const getCancelledStats = async (studytrack, since, years, isAcademicYear) => {
const studyrights = await cancelledStudyRights(studytrack, since)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ canceldate }) => {
const cancelYear = defineYear(canceldate, isAcademicYear)
graphStats[indexOf(years, cancelYear)] += 1
tableStats[cancelYear] += 1
})
return { graphStats, tableStats }
}
const getTransferredAwayStats = async (studytrack, since, years, isAcademicYear) => {
const studyrights = await transfersAway(studytrack, since)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ transferdate }) => {
const transferYear = defineYear(transferdate, isAcademicYear)
graphStats[indexOf(years, transferYear)] += 1
tableStats[transferYear] += 1
})
return { graphStats, tableStats }
}
const getTransferredToStats = async (studytrack, since, years, isAcademicYear) => {
const studyrights = await transfersTo(studytrack, since)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ transferdate }) => {
const transferYear = defineYear(transferdate, isAcademicYear)
graphStats[indexOf(years, transferYear)] += 1
tableStats[transferYear] += 1
})
return { graphStats, tableStats }
}
const getRegularCreditStats = async (studytrack, since, years, isAcademicYear) => {
const providercode = mapToProviders([studytrack])[0]
const studyrights = await getProgrammesStudents(studytrack)
const credits = await getCreditsForStudyProgramme(providercode, since)
let majors = getStatsBasis(years)
let nonMajors = getStatsBasis(years)
credits.forEach(({ student_studentnumber, attainment_date, credits }) => {
const studyright = studyrights.find(studyright => studyright.studentnumber == student_studentnumber)
const attainmentYear = defineYear(attainment_date, isAcademicYear)
if (isMajorStudentCredit(studyright, attainment_date)) {
majors.graphStats[indexOf(years, attainmentYear)] += credits || 0
majors.tableStats[attainmentYear] += credits || 0
} else {
nonMajors.graphStats[indexOf(years, attainmentYear)] += credits || 0
nonMajors.tableStats[attainmentYear] += credits || 0
}
})
return { majors, nonMajors }
}
const getTransferredCreditStats = async (studytrack, since, years, isAcademicYear) => {
const providercode = mapToProviders([studytrack])[0]
const credits = await getTransferredCredits(providercode, since)
const { graphStats, tableStats } = getStatsBasis(years)
credits.forEach(({ attainment_date, credits }) => {
const attainmentYear = defineYear(attainment_date, isAcademicYear)
graphStats[indexOf(years, attainmentYear)] += credits || 0
tableStats[attainmentYear] += credits || 0
})
return { graphStats, tableStats }
}
const getThesisStats = async (studytrack, since, years, isAcademicYear) => {
const providercode = mapToProviders([studytrack])[0]
const thesisType = getThesisType(studytrack)
const credits = await getThesisCredits(providercode, since, thesisType)
const { graphStats, tableStats } = getStatsBasis(years)
credits.forEach(({ attainment_date }) => {
const attainmentYear = defineYear(attainment_date, isAcademicYear)
graphStats[indexOf(years, attainmentYear)] += 1
tableStats[attainmentYear] += 1
})
return { graphStats, tableStats }
}
const getGraduationTimeStats = async (studytrack, since, years, isAcademicYear) => {
const studyrights = await graduatedStudyRights(studytrack, since)
let graduationAmounts = getYearsObject(years)
let graduationTimes = getYearsObject(years, true)
studyrights.forEach(({ enddate, studystartdate }) => {
const graduationYear = defineYear(enddate, isAcademicYear)
const timeToGraduation = moment(enddate).diff(moment(studystartdate), 'months')
graduationAmounts[graduationYear] += 1
graduationTimes[graduationYear] = [...graduationTimes[graduationYear], timeToGraduation]
})
const medians = getYearsObject(years, true)
const means = getYearsObject(years, true)
// The maximum amount of months in the graph depends on the studyprogramme intended graduation time
const comparisonValue = studytrack.includes('KH') ? 72 : 48
// HighCharts graph require the data to have this format (ie. actual value, "empty value")
years.forEach(year => {
const median = getMedian(graduationTimes[year])
const mean = getMean(graduationTimes[year])
medians[year] = [
['', median],
['', comparisonValue - median],
]
means[year] = [
['', mean],
['', comparisonValue - mean],
]
})
return { medians, means, graduationAmounts }
}
const getBasicStatsForStudytrack = async ({ studyprogramme, yearType }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
const started = await getStartedStats(studyprogramme, since, years, isAcademicYear)
const graduated = await getGraduatedStats(studyprogramme, since, years, isAcademicYear)
const cancelled = await getCancelledStats(studyprogramme, since, years, isAcademicYear)
const transferredAway = await getTransferredAwayStats(studyprogramme, since, years, isAcademicYear)
const transferredTo = await getTransferredToStats(studyprogramme, since, years, isAcademicYear)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const tableStats = reversedYears.map(year => [
year,
started.tableStats[year],
graduated.tableStats[year],
cancelled.tableStats[year],
transferredAway.tableStats[year],
transferredTo.tableStats[year],
])
return {
id: studyprogramme,
years,
graphStats: [
{
name: 'Started',
data: started.graphStats,
},
{
name: 'Graduated',
data: graduated.graphStats,
},
{
name: 'Cancelled',
data: cancelled.graphStats,
},
{
name: 'Transferred away',
data: transferredAway.graphStats,
},
{
name: 'Transferred To',
data: transferredTo.graphStats,
},
],
tableStats,
}
}
const getCreditStatsForStudytrack = async ({ studyprogramme, yearType }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
const { majors, nonMajors } = await getRegularCreditStats(studyprogramme, since, years, isAcademicYear)
const transferred = await getTransferredCreditStats(studyprogramme, since, years, isAcademicYear)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const tableStats = reversedYears.map(year => [
year,
majors.tableStats[year] + nonMajors.tableStats[year] + transferred.tableStats[year],
majors.tableStats[year],
nonMajors.tableStats[year],
transferred.tableStats[year],
])
return {
id: studyprogramme,
years,
tableStats,
graphStats: [
{
name: 'Major students credits',
data: majors.graphStats,
},
{
name: 'Non-major students credits',
data: nonMajors.graphStats,
},
{
name: 'Transferred credits',
data: transferred.graphStats,
},
],
}
}
const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
const enrolled = await getEnrolledStats(studyprogramme, since, years, isAcademicYear)
const thesis = await getThesisStats(studyprogramme, since, years, isAcademicYear)
const graduated = await getGraduatedStats(studyprogramme, since, years, isAcademicYear)
const graduationTimeStats = await getGraduationTimeStats(studyprogramme, since, years, isAcademicYear)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const tableStats = reversedYears.map(year => [
year,
enrolled.tableStats[year],
graduated.tableStats[year],
thesis.tableStats[year],
])
return {
id: studyprogramme,
years,
tableStats,
graphStats: [
{
name: 'Enrolled students',
data: enrolled.graphStats,
},
{
name: 'Graduated students',
data: graduated.graphStats,
},
{
name: 'Wrote thesis',
data: thesis.graphStats,
},
],
graduationMedianTime: graduationTimeStats.medians,
graduationMeanTime: graduationTimeStats.means,
graduationAmounts: graduationTimeStats.graduationAmounts,
}
}
module.exports = {
getBasicStatsForStudytrack,
getCreditStatsForStudytrack,
getGraduationStatsForStudytrack,
}
@@ -193,7 +193,7 @@ const getTotalOpenUniCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias
return sequelize.query(
`
SELECT SUM(cr.credits) AS ${alias}, COUNT(DISTINCT(cr.student_studentnumber)) AS ${alias2},
o.code AS organizationcode, co.code, co.id
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
@@ -245,6 +245,27 @@ const getOrgStats = (stats, orgId) =>
},
}
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... */
@@ -314,6 +335,21 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
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 => {
@@ -380,7 +416,6 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
}, {})
programmeToFaculties['H906'] = ['H906']
programmeToFaculties['H930'] = ['H930']
const providerToProgramme = elementDetails.reduce((res, curr) => {
const [p] = mapToProviders([curr.code])
@@ -418,17 +453,64 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
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),
..._.flatten(yearlyOpenUniAccCredits),
..._.flatten(yearlyOpenUniTotalCredits),
],
'organizationcode'
_.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,
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
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]) => {
@@ -477,7 +559,7 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
const programme = providerToProgramme[organizationcode]
if (programme && programme.code) {
if (programme.code === 'H906' || programme.code === 'H930') {
if (programme.code === 'H906') {
const courseValues = Object.values(courses)
const yearlyValues = courseValues.map(c => c.yearly)
@@ -519,6 +601,34 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
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]
@@ -569,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) => {
+1 -1
View File
@@ -6,7 +6,7 @@ const importerClient = axios.create({
headers: {
token: importerToken,
},
baseURL: 'https://importer.cs.helsinki.fi',
baseURL: 'https://importer.cs.helsinki.fi/api/importer',
})
const getImporterClient = () => {
+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"]
}
+98 -22599
View File
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -6,13 +6,16 @@
"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",
"highcharts-exporting": "^0.1.7",
"history": "^4.10.1",
"immer": "^9.0.7",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"moment": "^2.29.1",
@@ -22,18 +25,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",
+1 -11
View File
@@ -1,16 +1,6 @@
import React from 'react'
import PropTypes from 'prop-types'
import FilterContextProvider from './components/FilterTray/FilterContextProvider'
import { LanguageProvider } from './components/LanguagePicker/useLanguage'
const CommonContext = ({ children }) => (
<LanguageProvider>
<FilterContextProvider>{children}</FilterContextProvider>
</LanguageProvider>
)
CommonContext.propTypes = {
children: PropTypes.node.isRequired,
}
const CommonContext = ({ children }) => <LanguageProvider>{children}</LanguageProvider>
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: () => ({}),
})
@@ -50,41 +50,108 @@ export default {
**Include attainments attained before the studyright start** laskee mukaan opiskelijan ennen opinto-oikeuden alkua saadut opintopisteet.
Vakiona opintopisteet ennen opinto-oikeutta suodatetaan pois.
`,
status: `
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien, ohjelmien ja kurssien tuottamat opintopisteet nykyisen lukuvuoden aikana
verrattuna edelliseen lukuvuoteen.
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.
status: {
general: `
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien, ohjelmien ja kurssien tuottamat opintopisteet nykyisen lukuvuoden aikana
verrattuna edelliseen lukuvuoteen.
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ä. Mahdolliset valinnat ovat selitettyinä alla.
`,
settings: {
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.
`,
},
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.
`,
},
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.
`,
},
showCountingFrom: {
label: 'Valitse päivämäärä',
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.
`,
},
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.
`,
},
},
},
statusGraduated: {
general: `
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien ja ohjelmien valmistumiset nykyisen lukuvuoden aikana
verrattuna edelliseen lukuvuoteen.
Nuoli kuvaa muutosta, paljonko valmistuneita on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna.
Tilannekuvassa voi porautua kortteja klikkaamalla ohjelmatasolle.
Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä.
`,
settings: {
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.
`,
},
Valinta "Näytä suhteutettuna opiskelijoiden määrään" näyttää opintopistemäärät suhteutettuna kyseisen ajanjakson aikana kyseisen organisaation alaisuuteen kuuluvia kursseja suorittaineiden opiskelijoiden määrään. Tämä luku sisältää myös avoimen yliopiston opiskelijat.
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.
`,
statusGraduated: `
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien ja ohjelmien valmistumiset nykyisen lukuvuoden aikana
verrattuna edelliseen lukuvuoteen.
Nuoli kuvaa muutosta, paljonko valmistuneita on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna.
Tilannekuvassa voi porautua kortteja klikkaamalla ohjelmatasolle.
Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä.
Valinta "Näytä edelliset vuodet" näyttää vuosittaisen valmistumiskertymän tähän päivään mennessä vuonna X sekä koko lukuvuoden X valmistuneet muodossa "kerääntymä vuonna X / koko lukuvuoden X valmistuneet".
Valinta "Näytä vuositasolla" 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ä.
`,
showYearlyValues: {
label: 'Näytä edelliset vuodet',
short: 'Näytä tilastot vuosittain, alkaen vuodesta 2017.',
long: `
Näyttää vuosittaisen valmistumiskertymän tähän päivään mennessä vuonna X
sekä koko lukuvuoden X valmistuneet muodossa *"kerääntymä vuonna X / koko
lukuvuoden X valmistuneet"*.
`,
},
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.
`,
},
},
},
}
@@ -0,0 +1,15 @@
export default {
enrollmentStatus: {
label: 'Filter students present or absent',
short:
'Suodata opiskelijoita sen mukaan ovatko he ilmoittautuneet läsnä- tai poissaoleviksi kyseiselle lukukaudelle',
},
transferred: {
label: 'Include and exclude students from this program',
short: 'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet tutkinto-ohjelmaan',
},
courseCredits: {
label: null,
short: 'Rajaa kurssisuorituksia tietyn ajanjakson mukaan. Tämä rajaus ei muuta opiskelijoiden määrää',
},
}
@@ -3,6 +3,7 @@ import PopulationOverview from './populationOverview'
import CoolDataScience from './coolDataScience'
import CourseStatistics from './courseStatistics'
import CoursePopulation from './coursePopulation'
import Studyprogramme from './studyprogramme'
export default {
PopulationStatistics,
@@ -10,4 +11,5 @@ export default {
CoolDataScience,
CourseStatistics,
CoursePopulation,
Studyprogramme,
}
@@ -0,0 +1,33 @@
export default {
StudentsOfTheStudyprogramme: `
**Started**: Sisältää kyseisenä vuonna aloitetut opinto-oikeudet riippumatta siitä, minä vuonna kyseinen opiskeluoikeus on myönnetty. Opiskelija on myös voinut keskeyttää opintonsa tai siirtyä pois ohjelmasta.\n
**Graduated**: Sisältää kyseisenä vuonna valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty. Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.
**Cancelled**: Sisältää kyseisenä vuonna perutut opiskeluoikeudet riippumatta siitä, onko kyseinen opiskelija ikinä edes alkanut opiskelemaan ohjelmassa tai opiskelun aloitusvuodesta.
**Transferred away**: Sisältää kyseisenä vuonna pois ohjelmasta siirtyneet opinto-oikeudet. Sama opiskelija voi esiintyä tilastoissa useana vuonna. Kyseiset opiskelijat ovat saattaneet aloittaa ohjelmassa tai eivät koskaan ole aloittaneet ohjelmassa.
**Transferred to**: Sisältää kyseisenä vuonna ohjelmaan siirtyneet opinto-oikeudet.
HUOM! Ohjelmaan siirtyneiden määrä on erityisen suuri vuonna 2020, jolloin vanhoista koulutusohjelmista luovuttiin ja opiskelijat siirtyivät uusiin ohjelmiin.
`,
CreditsProducedByTheStudyprogramme: `
Sisältää opintopisteet suoritusvuosittain (Sisussa näkyvä kurssin suorituspäivä)
**Major students' credits**: Sisältää ne opintopisteet, joiden suorittajalla on suoritushetkellä ollut ensisijainen opinto-oikeus kyseiseen koulutusohjelmaan.
**Non-major students' credits**: Sisältää koulutusohjelman tuottamat opintopisteet, joiden suorittajalla EI ole ollut suoritushetkellä ensisijaista opinto-oikeutta kyseiseen koulutusohjelmaan.
**Transferred credits**: Sisältää opintopisteet, jotka on suoritettu kyseisenä vuonna, ja hyväksiluettu tähän koulutusohjelmaan.
`,
GraduatedAndThesisWritersOfTheProgramme: `
**Wrote thesis**: Sisältää kyseisenä vuonna kandidaatintutkielman hyväksytysti suorittaneet opiskelijat. Mukana ei ole mahdollisia arvosanan korotuksia.
**Graduated**: Sisältää kyseisenä vuonna valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty. Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.
`,
GraduationMedianTime: `Näyttää kyseisenä vuonna valmistuneiden opintojen keston **mediaanin** kuukausina.
Mukana ovat vain valmistuneet opiskelijat. Kandiohjelmissa tavoiteaika on 36 kk, maisteriohjelmissa 24 kk`,
GraduationMeanTime: `Näyttää kyseisenä vuonna valmistuneiden opintojen keston **keskiarvon** kuukausina.
Mukana ovat vain valmistuneet opiskelijat. Kandiohjelmissa tavoiteaika on 36 kk, maisteriohjelmissa 24 kk`,
}
+6
View File
@@ -261,3 +261,9 @@ export const bachelorHonoursIntermediateModules = {
KH50_007: ['MAA-200'],
KH50_008: ['BSCH2000', 'BSCS2000', 'BSMA2000', 'BSMS2000', 'BSPH2000'],
}
export const languageAbbreviations = {
fi: 'finnish',
sv: 'swedish',
en: 'english',
}
@@ -0,0 +1,142 @@
import React, { useMemo } from 'react'
import { useLocation, useHistory } from 'react-router-dom'
import { Breadcrumb, Icon } from 'semantic-ui-react'
import _ from 'lodash'
import { getTextIn } from '../../common'
import TSA from '../../common/tsa'
const ANALYTICS_CATEGORY = 'Trends'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const getP = (a, b) => {
if (a === 0 || b === 0) return 1
return a / b
}
const DrillStack = ({ data, renderCard, historyKey }) => {
const history = useHistory()
const location = useLocation()
const rootLabel = getTextIn({
fi: 'Helsingin Yliopisto',
en: 'University of Helsinki',
se: 'Helsingfors Universitet',
})
const sortBy = ({ currentValue, previousValue }) => 1 - currentValue / previousValue
const path = _.get(location.state, historyKey) ?? []
const breadcrumbSections = useMemo(() => {
const stack = _.chain(path)
.reduce(
([stack, data], segment) => {
let content = segment
let next = null
if (data) {
const entry = data.find(({ key }) => key === segment)
if (entry) {
content = entry.label
next = entry.children
}
}
const item = {
key: segment,
content,
link: !!next,
active: !next,
onClick: () => {
history.push(location.pathname, {
[historyKey]: [...stack.map(({ key }) => key).splice(1), segment],
})
},
}
return [[...stack, item], next]
},
[[], data]
)
.value()[0]
const root = {
key: '',
content: rootLabel,
link: true,
onClick: () =>
history.push(location.pathname, {
[historyKey]: [],
}),
}
return [root, ...stack]
}, [data, path, history, location])
const drilledData = useMemo(
() => _.reduce(path, (data, segment) => (!data ? null : data.find(item => item.key === segment)?.children), data),
[data, path]
)
const pushToDrillStack = segment => {
history.push(location.pathname, {
[historyKey]: [...path, segment],
})
}
const popFromDrillStack = () => {
const newPath = [...path]
newPath.pop()
history.push(location.pathname, {
[historyKey]: newPath,
})
sendAnalytics('S Drillup clicked', 'Status')
}
const stats = useMemo(() => {
const orderedAbsDiffs = _.chain(drilledData)
.map(({ currentValue, previousValue }) =>
Math.abs(Math.floor((getP(currentValue, previousValue) - 1) * 1000) / 10)
)
.orderBy()
.value()
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
return { medianDiff }
}, [drilledData])
return (
<>
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px', alignItems: 'center' }}>
<Breadcrumb icon="right angle" sections={breadcrumbSections} size="large" />
{path.length > 0 && (
<Icon
onClick={popFromDrillStack}
style={{ cursor: 'pointer', color: 'rgba(0,0,0,.28)', marginLeft: '0.4em', marginTop: '-0.17em' }}
name="arrow left"
/>
)}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, 240px)',
gridTemplateRows: 'repeat(auto-fill) 20px',
gridGap: '20px',
justifyContent: 'center',
}}
>
{_.sortBy(drilledData, sortBy).map(item => {
const handleClick = () => pushToDrillStack(item.key)
return renderCard(item, handleClick, stats)
})}
</div>
</>
)
}
export default DrillStack
@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react'
import { connect } from 'react-redux'
import { Segment, Popup, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
import React, { useState, useEffect, useMemo } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Loader } from 'semantic-ui-react'
import _ from 'lodash'
import moment from 'moment'
import ReactMarkdown from 'react-markdown'
import Datetime from 'react-datetime'
import DrillStack from './DrillStack'
import StatusCard from './StatusCard'
import Toolbar from './Toolbar'
import TSA from '../../common/tsa'
import { getTextIn } from '../../common'
import { useLocalStorage } from '../../common/hooks'
@@ -16,25 +17,58 @@ import './status.css'
const ANALYTICS_CATEGORY = 'Trends'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const getP = (a, b) => {
if (a === 0 || b === 0) return 1
return a / b
}
const settingDefinitions = _.map(
[
{
key: 'showByYear',
type: 'checkbox',
defaultValue: false,
},
{
key: 'showYearlyValues',
type: 'checkbox',
defaultValue: false,
},
{
key: 'showRelativeValues',
type: 'checkbox',
defaultValue: false,
},
{
key: 'showStudentCounts',
type: 'checkbox',
defaultValue: false,
},
{
key: 'showCountingFrom',
type: 'date',
defaultValue: () => moment(),
persist: false,
},
],
setting => ({
...setting,
...InfoToolTips.CoolDataScience.status.settings[setting.key],
})
)
const mapValueToRange = (x, min1, max1, min2, max2) => {
if (x < min1) return min2
if (x > max1) return max2
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
}
const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, showRelativeValues, showByYear }) => {
const StatusContainer = ({
stats,
handleClick,
min1,
max1,
showYearlyValues,
showRelativeValues,
showByYear,
showStudentCounts,
clickable,
}) => {
const title = getTextIn(stats.name)
const clickable = !!stats.drill
let current
let previous
if (!stats.drill) {
if (stats.type === 'course') {
current = stats.currentStudents
previous = stats.previousStudents
} else {
@@ -47,345 +81,155 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
}
}
const diff = current - previous
const p = getP(current, previous)
const change = p - 1
let yearlyValues = null
const getColor = v => {
if (v > 0.025) return '#6ab04c'
if (v < -0.025) return '#ff7979'
return '#7B9FCF'
}
const plussify = (x, decimals = 0) =>
x.toLocaleString('fi', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
signDisplay: 'always',
})
const getDisplayValue = (value, denominator) => {
let displayValue
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,
})
if (showYearlyValues) {
yearlyValues = _.orderBy(Object.entries(stats.yearly), ([y]) => y, ['desc']).map(([year, yearStats]) => ({
label: showByYear ? year : `${year}-${`${Number(year) + 1}`.slice(-2)}`,
accumulated: showStudentCounts && stats.type === 'course' ? yearStats.accStudents : yearStats.acc,
total: showStudentCounts && stats.type === 'course' ? yearStats.totalStudents : yearStats.total,
}))
}
return (
<Segment
className="status-card"
onClick={clickable ? handleClick : null}
style={{
display: 'flex',
flexDirection: 'column',
maxWidth: '240px',
minWidth: '240px',
cursor: clickable ? 'pointer' : 'default',
flex: 1,
margin: 0,
}}
textAlign="center"
compact
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
overflowWrap: 'break-word',
}}
>
<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)`,
}}
size="huge"
name="arrow right"
/>
</div>
<div>
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(change) }}>
{plussify(diff, showRelativeValues ? 2 : 0)}
</span>
<br />
<span style={{ fontWeight: 'bold', color: getColor(change) }}>({plussify(change * 100, 1)}%)</span>
<br />
</div>
</div>
{showYearlyValues && (
<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 className="year-label">
{year}
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
</b>
{stats.type === 'course' ? (
<>
<span className="year-value">{yearStats.accStudents}</span>
<span className="unit">students</span>
</>
) : (
<>
<span className="year-value accumulated">
{getDisplayValue(yearStats.acc, yearStats.accStudents)}
</span>
<span style={{ color: '#AAA', margin: '0 0.3em' }}>{yearStats.total !== undefined && '/'}</span>
<span className="year-value total">
{yearStats.total !== undefined && getDisplayValue(yearStats.total, yearStats.totalStudents)}
</span>
</>
)}
</span>
)
})}
</div>
)}
</Segment>
<StatusCard
title={title}
clickable={clickable}
onClick={handleClick}
changeRange={[min1, max1]}
currentValue={current}
previousValue={previous}
precision={0}
tooltip={stats.code}
unit={stats.type === 'course' && showStudentCounts ? 'students' : null}
yearlyValues={yearlyValues}
/>
)
}
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
const StatusContent = ({ data, settings }) => {
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."
return (
<DrillStack
historyKey="Status"
data={data}
rootLabel="Helsingin Yliopisto"
renderCard={(data, drill, { medianDiff }) => (
<StatusContainer
key={data.code}
clickable={!!data.children}
stats={data}
handleClick={drill}
showRelativeValues={showRelativeValues}
showYearlyValues={showYearlyValues}
showStudentCounts={showStudentCounts}
min1={-2 * medianDiff}
max1={2 * medianDiff}
showByYear={showByYear}
/>
)}
/>
)
}
if (!data || loading)
return (
<Segment style={{ padding: '40px' }} textAlign="center">
<Dimmer inverted active />
<Loader active={loading} />
</Segment>
)
const getDefaultSettings = () =>
_.chain(settingDefinitions)
.map(({ key, defaultValue }) => (typeof defaultValue === 'function' ? [key, defaultValue()] : [key, defaultValue]))
.fromPairs()
.value()
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 createDrillData = (storeData, showRelativeValues) => {
if (!storeData) return null
return Object.values(storeData).map(item => ({
key: item.code,
label: getTextIn(item.name),
children: createDrillData(item.drill),
currentValue: showRelativeValues ? item.current / item.currentStudents : item.current,
previousValue: showRelativeValues ? item.previous / item.previousStudents : item.previous,
..._.omit(item, 'drill'),
}))
}
const isValidDate = d => moment().diff(moment(d)) > 0
const Status = () => {
const [explicitSettings, setSettings] = useLocalStorage('trendsStatusSettings', {})
const [nonpersistentExplicitSettings, setNonpersistentSettings] = useState({})
const storeData = useSelector(state => state.coolDataScience.data.status)
const loading = useSelector(state => state.coolDataScience.pending.status)
const dispatch = useDispatch()
const settings = useMemo(
() => _.defaults({ ...explicitSettings, ...nonpersistentExplicitSettings }, getDefaultSettings()),
[explicitSettings, nonpersistentExplicitSettings]
)
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
const data = useMemo(
() => createDrillData(storeData, settings.showRelativeValues),
[storeData, settings.showRelativeValues]
)
const { CoolDataScience } = InfoToolTips
useEffect(() => {
const { showCountingFrom, showByYear } = settings
let date
if (!showCountingFrom) {
date = moment().valueOf()
} else if (isValidDate(showCountingFrom)) {
date = moment(showCountingFrom).valueOf()
} else {
return
}
dispatch(
getStatus({
date,
showByYear,
})
)
}, [settings.showCountingFrom, settings.showByYear])
const changeSetting = (property, value) => {
sendAnalytics(`S Set setting "${property}" to ${value}`, 'Status')
if (settingDefinitions.find(s => s.key === property).persist === false) {
setNonpersistentSettings({
...nonpersistentExplicitSettings,
[property]: value,
})
} else {
setSettings({
...settings,
[property]: value,
})
}
}
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 style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0, flexGrow: 1 }}>Koulutusohjelmien tuottamat opintopisteet</h2>
<Toolbar
value={settings}
settings={settingDefinitions}
generalHelp={CoolDataScience.status.general}
changeSetting={changeSetting}
/>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, 240px)',
gridTemplateRows: 'repeat(auto-fill) 20px',
gridGap: '20px',
justifyContent: 'center',
}}
>
{_.orderBy(
Object.entries(_.last(drillStack) || data),
([, { current, previous, currentStudents, previousStudents }]) =>
getP(current || currentStudents, previous || previousStudents), // oh god<r
['desc']
).map(([code, stats]) => {
const handleClick = () => pushToDrillStack(stats.drill, code)
return (
<StatusContainer
key={code}
stats={stats}
handleClick={handleClick}
showRelativeValues={showRelativeValues}
showYearlyValues={showYearlyValues}
min1={-medianDiff * 2}
max1={medianDiff * 2}
showByYear={showByYear}
/>
)
})}
</div>
{renderSettings()}
<Message>
{
// eslint-disable-next-line react/no-children-prop
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
}
</Message>
{data && !loading ? (
<StatusContent settings={settings} data={data} />
) : (
<Loader active={loading} inline="centered" />
)}
</>
)
}
const mapStateToProps = ({ coolDataScience }) => ({
data: coolDataScience.data.status,
loading: coolDataScience.pending.status,
})
export default connect(mapStateToProps, { getStatusDispatch: getStatus })(Status)
export default Status
@@ -0,0 +1,129 @@
import React from 'react'
import { Popup, Segment, Icon } from 'semantic-ui-react'
const getP = (a, b) => {
if (a === 0 || b === 0) return 1
return a / b
}
const mapValueToRange = (x, min1, max1, min2, max2) => {
if (x < min1) return min2
if (x > max1) return max2
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
}
export default ({
clickable,
currentValue,
changeRange,
onClick,
previousValue,
title,
tooltip,
type,
unit,
yearlyValues,
precision,
}) => {
const diff = currentValue - previousValue
const p = getP(currentValue, previousValue)
const change = p - 1
const getColor = v => {
if (v > 0.025) return '#6ab04c'
if (v < -0.025) return '#ff7979'
return '#7B9FCF'
}
const plussify = (x, decimals = 0) =>
x.toLocaleString('fi', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
signDisplay: 'always',
})
const getDisplayValue = number =>
number.toLocaleString('fi', {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
})
let titleEl = <span className="status-title">{title}</span>
if (tooltip) {
titleEl = (
<Popup position="bottom center" size="tiny" trigger={titleEl}>
{tooltip}
</Popup>
)
}
const arrowAngle = -mapValueToRange(change * 100, changeRange[0], changeRange[1], -45, 45)
return (
<Segment
className="status-card"
onClick={clickable ? onClick : null}
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'start',
maxWidth: '240px',
minWidth: '240px',
cursor: clickable ? 'pointer' : 'default',
flex: 1,
margin: 0,
}}
textAlign="center"
compact
>
<div style={{ paddingTop: '100%', position: 'relative' }}>
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
overflowWrap: 'break-word',
}}
>
{titleEl}
<div style={{ margin: '10px 0' }}>
<Icon
style={{
color: getColor(change),
transform: `rotateZ(${arrowAngle}deg)`,
transitionProperty: 'transform',
transitionDuration: '200ms',
}}
size="huge"
name="arrow right"
/>
</div>
<div>
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(change) }}>
{plussify(diff, precision)}
</span>
<br />
<span style={{ fontWeight: 'bold', color: getColor(change) }}>({plussify(change * 100, 1)}%)</span>
<br />
</div>
</div>
</div>
{yearlyValues && (
<div className={`years item-type-${type}`}>
{yearlyValues.map(({ label, accumulated, total }) => (
<span className="year-row">
<b className="year-label">{label}:</b>
<span className="year-value">{getDisplayValue(accumulated)}</span>
<span className="separator">{typeof total === 'number' ? '/' : ''}</span>
<span className="year-value">{typeof total === 'number' && getDisplayValue(total)}</span>
<span className="unit">{unit && unit}</span>
</span>
))}
</div>
)}
</Segment>
)
}
@@ -1,26 +1,41 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useMemo, useEffect } from 'react'
import PropTypes, { shape, bool, func } from 'prop-types'
import { connect } from 'react-redux'
import { Segment, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
import { Loader } from 'semantic-ui-react'
import _ from 'lodash'
import moment from 'moment'
import ReactMarkdown from 'react-markdown'
import Datetime from 'react-datetime'
import StatusCard from './StatusCard'
import Toolbar from './Toolbar'
import TSA from '../../common/tsa'
import { getTextIn } from '../../common'
import { useLocalStorage } from '../../common/hooks'
import InfoToolTips from '../../common/InfoToolTips'
import DrillStack from './DrillStack'
import { getStatusGraduated } from '../../redux/coolDataScience'
import './status.css'
const ANALYTICS_CATEGORY = 'Trends'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const getP = (a, b) => {
if (a === 0 || b === 0) return 1
return a / b
}
const settingDefinitions = [
{
key: 'showByYear',
type: 'checkbox',
defaultValue: false,
},
{
key: 'showYearlyValues',
type: 'checkbox',
defaultValue: false,
},
{
key: 'showCountingFrom',
type: 'date',
defaultValue: () => moment(),
persist: false,
},
].map(setting => ({ ...setting, ...InfoToolTips.CoolDataScience.statusGraduated.settings[setting.key] }))
const StatusContainer = ({
title,
@@ -34,113 +49,29 @@ const StatusContainer = ({
yearlyValues,
showByYear,
}) => {
const mapValueToRange = (x, min1, max1, min2, max2) => {
if (x < min1) return min2
if (x > max1) return max2
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
let displayedYearlyValues = null
if (showYearlyValues) {
displayedYearlyValues = Object.entries(yearlyValues)
.sort(([b], [a]) => a - b)
.map(([year, { acc, total }], i) => ({
label: showByYear ? year : `${year}-${`${Number(year) + 1}`.slice(-2)}`,
accumulated: acc,
total: i > 0 ? total : null,
}))
}
const getColor = v => {
if (v > 2.5) return '#6ab04c'
if (v < -2.5) return '#ff7979'
return '#7B9FCF'
}
const plussify = x => {
if (x > 0) return `+${x.toLocaleString('fi')}`
return x.toLocaleString('fi')
}
// TODO: Maybe just fix this in backend and add default value 0 for everything?
const getDiffAndChange = (current, previous, noTitleDataToShow, hasChangedButCantShowPercentage) => {
if (noTitleDataToShow) return [0, 0]
const diff = Math.round(current - previous)
let change
if (hasChangedButCantShowPercentage) {
change = diff > 0 ? 100 : -100
} else {
change = Math.round((getP(current, previous) - 1) * 1000) / 10
}
return [diff, change]
}
const noTitleDataToShow = current === null
const hasChangedButCantShowPercentage = (previous === 0 || current === 0) && previous !== current
const [diff, change] = getDiffAndChange(current, previous, noTitleDataToShow, hasChangedButCantShowPercentage)
return (
<Segment
className="status-card"
onClick={clickable ? handleClick : null}
style={{
display: 'flex',
flexDirection: 'column',
maxWidth: '240px',
minWidth: '240px',
cursor: clickable ? 'pointer' : 'default',
flex: 1,
margin: 0,
}}
textAlign="center"
compact
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
overflowWrap: 'break-word',
}}
>
<span className="status-title">{title}</span>
<div style={{ margin: '10px 0' }}>
<Icon
style={{
color: getColor(change),
transform: `rotateZ(${-mapValueToRange(change, min1, max1, -45, 45)}deg)`,
}}
size="huge"
name="arrow right"
/>
</div>
{noTitleDataToShow ? (
<div>
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(0) }}>
Ei vielä dataa tältä lukuvuodelta.
</span>
</div>
) : (
<div>
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(change) }}>{plussify(diff)}</span>
{hasChangedButCantShowPercentage ? null : (
<div>
<br />
<span style={{ fontWeight: 'bold', color: getColor(change) }}>({plussify(change)}%)</span>
</div>
)}
</div>
)}
</div>
{showYearlyValues && (
<div style={{ marginTop: '10px', textAlign: 'start' }}>
{_.orderBy(Object.entries(yearlyValues), ([y]) => y, ['desc']).map(([year, { acc, total }]) => {
return (
<div style={{ margin: '5px 0' }} key={`${title}-${year}`}>
<span>
<b>
{year}
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
</b>{' '}
{acc} {total > 0 ? `/ ${total}` : ''}
</span>
</div>
)
})}
</div>
)}
</Segment>
<StatusCard
title={title}
changeRange={[min1, max1]}
currentValue={current}
previousValue={previous}
clickable={clickable}
onClick={handleClick}
precision={0}
yearlyValues={displayedYearlyValues}
/>
)
}
@@ -162,200 +93,104 @@ StatusContainer.defaultProps = {
previous: 0,
}
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
const createDrillData = data => {
if (!data) return null
return Object.entries(data).map(([key, item]) => ({
key,
label: getTextIn(item.name),
currentValue: item.current,
previousValue: item.previous,
children: createDrillData(item.drill),
..._.omit(item, 'drill'),
}))
}
const getDefaultSettings = () =>
_.chain(settingDefinitions)
.map(({ key, defaultValue }) => (typeof defaultValue === 'function' ? [key, defaultValue()] : [key, defaultValue]))
.fromPairs()
.value()
const Status = ({ getStatusGraduatedDispatch, data, loading }) => {
const DATE_FORMAT = 'DD.MM.YYYY'
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', true)
const [showByYear, setShowByYear] = useLocalStorage('showByYear', true)
const [drillStack, setDrillStack] = useState([])
const [showSettings, setShowSettings] = useState(true)
const [selectedDate, setSelectedDate] = useState(moment())
const [codes, setCodes] = useState([])
const [explicitSettings, setSettings] = useLocalStorage('trendsGraduationStatusSettings', {})
const [nonpersistentExplicitSettings, setNonpersistentSettings] = useState({})
const settings = useMemo(
() => _.defaults({ ...explicitSettings, ...nonpersistentExplicitSettings }, getDefaultSettings()),
[explicitSettings, nonpersistentExplicitSettings]
)
if (typeof settings.showCountingFrom === 'string') {
settings.showCountingFrom = moment(settings.showCountingFrom)
} else if (settings.showCountingFrom === null) {
settings.showCountingFrom = moment()
}
const { showYearlyValues, showByYear, showCountingFrom } = settings
const { CoolDataScience } = InfoToolTips
const isValidDate = d => moment.isMoment(d) && moment().diff(d) > 0
useEffect(() => {
if (selectedDate && isValidDate(selectedDate)) {
getStatusGraduatedDispatch({ date: selectedDate.valueOf(), showByYear })
if (showCountingFrom && isValidDate(showCountingFrom)) {
getStatusGraduatedDispatch({ date: showCountingFrom.valueOf(), showByYear })
}
}, [selectedDate, showByYear])
}, [showCountingFrom, 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)
const drillData = useMemo(() => createDrillData(data), [data])
const changeSetting = (property, value) => {
sendAnalytics(`S Set setting "${property}" to ${value}`, 'Status')
if (settingDefinitions.find(s => s.key === property).persist === false) {
setNonpersistentSettings({
...nonpersistentExplicitSettings,
[property]: value,
})
} else {
setSettings({
...settings,
[property]: value,
})
}
}, [data])
const handleShowYearlyValuesToggled = () => {
const yearlyValues = showYearlyValues
setShowYearlyValues(!showYearlyValues)
sendAnalytics(`SG Show yearly values toggle ${!yearlyValues ? 'on' : 'off'}`, 'Status graduated')
}
const handleShowByYearToggled = () => {
const byYear = showByYear
setShowByYear(!showByYear)
sendAnalytics(`SG Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status graduated')
}
const pushToDrillStack = (values, code) => {
const updatedCodes = [...codes].concat(code)
const updatedDrillStack = [...drillStack].concat(values)
setCodes(updatedCodes)
setDrillStack(updatedDrillStack)
sendAnalytics('SG Drilldown clicked', 'Status graduated')
}
const popFromDrillStack = () => {
const updatedDrillStack = _.dropRight([...drillStack], 1)
const updatedCodes = _.dropRight([...codes], 1)
setCodes(updatedCodes)
setDrillStack(updatedDrillStack)
sendAnalytics('SG Drillup clicked', 'Status graduated')
}
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 />
<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.
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, previous }) => {
return Math.abs(Math.floor((getP(current, previous) - 1) * 1000) / 10)
})
)
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
return (
<>
<h2>Koulutusohjelmista valmistuneet</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 style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0, flexGrow: 1 }}>Koulutusohjelmista valmistuneet</h2>
<Toolbar
value={settings}
settings={settingDefinitions}
generalHelp={CoolDataScience.statusGraduated.general}
changeSetting={changeSetting}
/>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, 240px)',
gridTemplateRows: 'repeat(auto-fill) 20px',
gridGap: '20px',
justifyContent: 'center',
}}
>
{_.orderBy(
Object.entries(_.last(drillStack) || data),
([, { current, previous }]) => getP(current, previous), // oh god<r
['desc']
).map(([code, stats]) => {
const handleClick = () => pushToDrillStack(stats.drill, code)
return (
{data && !loading ? (
<DrillStack
historyKey="StatusGraduated"
data={drillData}
renderCard={(data, drill, { medianDiff }) => (
<StatusContainer
key={code}
clickable={!!stats.drill}
handleClick={handleClick}
title={getTextIn(stats.name)}
current={stats.current}
previous={stats.previous}
key={data.code}
clickable={!!data.children}
handleClick={drill}
title={getTextIn(data.name)}
current={data.current}
previous={data.previous}
showYearlyValues={showYearlyValues}
min1={-medianDiff * 2}
max1={medianDiff * 2}
yearlyValues={stats.yearly}
yearlyValues={data.yearly}
showByYear={showByYear}
/>
)
})}
</div>
{renderSettings()}
<Message>
{
// eslint-disable-next-line react/no-children-prop
<ReactMarkdown children={CoolDataScience.statusGraduated} escapeHtml={false} />
}
</Message>
)}
/>
) : (
<Loader active inline="centered" />
)}
</>
)
}
@@ -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} />
}
@@ -0,0 +1,207 @@
import React, { useRef, useState } from 'react'
import { Form, Checkbox, Divider, Popup, Button, Icon } from 'semantic-ui-react'
import moment from 'moment'
import ReactMarkdown from 'react-markdown'
import Datetime from 'react-datetime'
import WithHelpTooltip from './WithHelpTooltip'
const unindent = s => s.replace(/(^|\n)[ \t]+/g, '\n')
const isValidDate = d => moment().diff(moment(d)) > 0
const DateSetting = ({ onChange, value, onOpenDetails, tooltip, placeholder }) => {
const DATE_FORMAT = 'DD.MM.YYYY'
return (
<div style={{ marginRight: '0.3rem' }}>
<Form>
<Form.Field error={value !== null && !isValidDate(value)} style={{ display: 'flex', alignItems: 'center' }}>
<WithHelpTooltip
tooltip={tooltip}
onOpenDetails={onOpenDetails}
iconStyle={{
position: 'absolute',
display: 'flex',
top: 0,
bottom: 0,
right: '0.5rem',
alignItems: 'center',
}}
iconPosition={{
top: '-0.2em',
}}
>
<Datetime
className="status-date-time-input"
dateFormat={DATE_FORMAT}
timeFormat={false}
closeOnSelect
value={moment(value)}
locale="fi"
isValidDate={isValidDate}
inputProps={{ placeholder }}
onChange={value => {
if (value === '') {
onChange(null)
} else if (isValidDate(value)) {
onChange(value.format(DATE_FORMAT) === moment().format(DATE_FORMAT) ? null : value)
}
}}
/>
</WithHelpTooltip>
</Form.Field>
</Form>
</div>
)
}
const settingWidgetFactories = {
checkbox: (value, onChange, onOpenDetails, { short, label }) => (
<WithHelpTooltip tooltip={short} onOpenDetails={onOpenDetails} iconPosition={{ top: '0.1em' }}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={label}
checked={value}
onChange={() => onChange(!value)}
/>
</WithHelpTooltip>
),
}
const StatusSettings = ({ onSettingChange, value, settings, onOpenDetails }) => {
const itemStyles = {
margin: '0.5rem 1rem',
display: 'flex',
alignItems: 'center',
}
const settingWidgets = settings.map(definition => {
const { key, type } = definition
const factory = settingWidgetFactories[type]
if (!factory) return null
const content = factory(
value[key],
value => onSettingChange(key, value),
() => onOpenDetails(key),
definition
)
return <div style={itemStyles}>{content}</div>
})
return (
<div style={{ display: 'flex', alignItems: 'stretch', padding: 0, flexDirection: 'column' }}>{settingWidgets}</div>
)
}
export default ({ value, changeSetting, settings, generalHelp }) => {
const [usageDetailsOpen, setUsageDetailsOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const moreDetailsRef = useRef(null)
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
}
const openDetails = 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)
}
const dateSetting = settings.find(s => s.type === 'date')
return (
<>
<DateSetting
value={value[dateSetting.key]}
onChange={value => changeSetting(dateSetting.key, value)}
onOpenDetails={() => openDetails(dateSetting.key)}
tooltip={dateSetting.short}
placeholder={dateSetting.label}
/>
<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 value={value} settings={settings} onSettingChange={changeSetting} onOpenDetails={openDetails} />
</Popup>
<div ref={moreDetailsRef} />
<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={unindent(generalHelp)} escapeHtml={false} />
}
</div>
{settings.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}>{unindent(long ?? '')}</ReactMarkdown>
</div>
</div>
</div>
))}
</Popup>
</>
)
}
@@ -0,0 +1,53 @@
import React, { useRef, useState } from 'react'
import { Icon, Popup } from 'semantic-ui-react'
import _ from 'lodash'
const WithHelpTooltip = ({ children, tooltip, onOpenDetails, iconStyle = {}, iconPosition = {}, ...rest }) => {
const popupContext = useRef()
const [popupOpen, setPopupOpen] = useState(false)
const trigger = (
<div>
{children}
<div
ref={popupContext}
style={{ marginLeft: '0.3em', display: 'inline-block', paddingTop: '0.2em', cursor: 'help', ...iconStyle }}
>
<Icon
onClick={() => setPopupOpen(!popupOpen)}
ref={popupContext}
style={{ position: 'relative', color: '#888', ...iconPosition }}
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>
</>
)
}
export default WithHelpTooltip
@@ -26,10 +26,6 @@
margin-left: 0.3em;
}
.years.item-type-course {
grid-template-columns: repeat(2, fit-content(0)) auto;
}
.year-label {
white-space: nowrap;
padding-right: 0.5em;
@@ -43,7 +39,16 @@
.year-row {
position: relative;
padding-left: 0.5em;
display: inline-block;
display: contents;
}
.year-row .unit {
grid-column: 5;
}
.year-row .separator {
color: #aaa;
margin: 0 0.3em;
}
.status-date-time-input {
@@ -3,7 +3,7 @@ import { connect } from 'react-redux'
import { shape, arrayOf, string, number } from 'prop-types'
import { Table, Tab } from 'semantic-ui-react'
import { maxBy } from 'lodash'
import InfoBox from '../../InfoBox'
import InfoBox from '../../Info/InfoBox'
import infotooltips from '../../../common/InfoToolTips'
import { getNewestProgramme, getTextIn } from '../../../common'
import useLanguage from '../../LanguagePicker/useLanguage'
@@ -1,26 +1,23 @@
import React from 'react'
import PropTypes from 'prop-types'
import useGradeFilter from '../../FilterTray/filters/Grade/useGradeFilter'
import ExternalFilterToggle from '../../FilterTray/ExternalFilterToggle'
import { contextKey } from '../../FilterTray/filters/Grade'
import FilterToggle from '../../FilterView/FilterToggle'
import useFilters from '../../FilterView/useFilters'
import gradeFilter from '../../FilterView/filters/grade'
const ExternalGradeFilterToggle = ({ grade }) => {
const { value, setValue } = useGradeFilter()
const { useFilterSelector, filterDispatch } = useFilters()
const isActive = useFilterSelector(gradeFilter.selectors.isGradeSelected(grade))
return (
<ExternalFilterToggle
filterPanelContextKey={contextKey}
applyFilter={() => setValue(value.concat(grade))}
clearFilter={() => setValue(value.filter(val => val !== grade))}
active={value.includes(grade)}
<FilterToggle
filter={gradeFilter}
active={isActive}
applyFilter={() => filterDispatch(gradeFilter.actions.selectGrade(grade))}
clearFilter={() => filterDispatch(gradeFilter.actions.unselectGrade(grade))}
filterName="Grade Filter"
popupContent="Rajaa opiskelijat kurssin arvosanan perusteella."
/>
)
}
ExternalGradeFilterToggle.propTypes = {
grade: PropTypes.string.isRequired,
}
export default ExternalGradeFilterToggle
@@ -4,12 +4,10 @@ import { Progress, Table } from 'semantic-ui-react'
import { intersection, orderBy } from 'lodash'
import { shape, bool, arrayOf, string, number } from 'prop-types'
import { getHighestGradeOfCourseBetweenRange } from '../../../common'
import useGradeFilter from '../../FilterTray/filters/Grade/useGradeFilter'
import ExternalGradeFilterToggle from './ExternalGradeFilterToggle'
const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStudents, samples, codes, from, to }) => {
const [courseGrades, setCourseGrades] = useState([])
const { setGrades } = useGradeFilter()
useEffect(() => {
if (samples && singleCourseStats.alternatives) {
@@ -34,7 +32,6 @@ const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStuden
})
setCourseGrades(filteredGradeArray)
setGrades(grades)
}
}, [pending, selectedStudents])
@@ -0,0 +1,73 @@
import React, { useState, useEffect } from 'react'
import { Table, Progress } from 'semantic-ui-react'
import { languageAbbreviations } from '../../../common'
const CoursePopulationLanguageDist = ({ samples, codes, from, to }) => {
const [languages, setLanguages] = useState()
const [total, setTotal] = useState(0)
useEffect(() => {
if (samples) {
const lang = {}
let students = 0
samples.forEach(student => {
const courses = student.courses.filter(c => codes.includes(c.course_code))
const filteredCourses = courses
.filter(
course =>
new Date(from).getTime() <= new Date(course.date).getTime() &&
new Date(course.date).getTime() <= new Date(to).getTime()
)
// .filter(course => course.passed === true)
.sort((a, b) => (new Date(a.date) <= new Date(b.date) ? -1 : 1))
if (filteredCourses[0] && filteredCourses[0].language !== null) {
if (!lang[filteredCourses[0].language]) {
lang[filteredCourses[0].language] = 0
}
lang[filteredCourses[0].language] += 1
students++
}
})
setLanguages(lang)
setTotal(students)
}
}, [samples])
const getLanguage = lang => (lang in languageAbbreviations ? languageAbbreviations[lang] : lang)
if (total === 0) return <p>No data available!</p>
return (
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Languages</Table.HeaderCell>
<Table.HeaderCell>
Language Distribution
<br />
<span style={{ fontWeight: 100 }}>(n={total})</span>
</Table.HeaderCell>
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{total !== 0 &&
Object.entries(languages).map(language => (
<Table.Row key={`language-table-row-${language[0]}`}>
<Table.Cell>{getLanguage(language[0])}</Table.Cell>
<Table.Cell>{language[1]}</Table.Cell>
<Table.Cell>
{total !== 0 && (
<Progress percent={Math.round((language[1] / total) * 100)} progress style={{ margin: 0 }} />
)}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
)
}
export default CoursePopulationLanguageDist
@@ -4,13 +4,16 @@ import { connect } from 'react-redux'
import { shape, func, bool } from 'prop-types'
import { Segment, Header, Accordion } from 'semantic-ui-react'
import scrollToComponent from 'react-scroll-to-component'
import InfoBox from 'components/Info/InfoBox'
import { getCoursePopulation } from '../../redux/populations'
import { getSingleCourseStats } from '../../redux/singleCourseStats'
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
import { useGetStudentListCourseStatisticsQuery } from '../../redux/populationCourses'
import { getFaculties } from '../../redux/faculties'
import { getSemesters } from '../../redux/semesters'
import { getElementDetails } from '../../redux/elementdetails'
import PopulationStudents from '../PopulationStudents'
import CoursePopulationGradeDist from './CoursePopulationGradeDist'
import CoursePopulationLanguageDist from './CoursePopulationLanguageDist'
import CoursePopulationCreditGainTable from './CoursePopulationCreditGainTable'
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
@@ -18,30 +21,33 @@ import ProgressBar from '../ProgressBar'
import { getStudentToTargetCourseDateMap, getUserIsAdmin, getTextIn } from '../../common'
import { useProgress, useTitle } from '../../common/hooks'
import infotooltips from '../../common/InfoToolTips'
import InfoBox from '../InfoBox'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import useProgrammeFilter from '../FilterTray/filters/Programmes/useProgrammeFilter'
import { CoursePopulationFilters } from '../FilterTray/FilterSets'
import {
ageFilter,
gradeFilter,
genderFilter,
courseFilter,
creditsEarnedFilter,
startYearAtUniFilter,
programmeFilter,
} from '../FilterView/filters'
import FilterView from '../FilterView'
import useLanguage from '../LanguagePicker/useLanguage'
import { queryParamsFromUrl } from '../../common/query'
const CoursePopulation = ({
getCoursePopulationDispatch,
getSingleCourseStatsDispatch,
getElementDetails,
studentData,
pending,
elementDetails,
history,
courseData,
getSemestersDispatch,
semesters,
getFacultiesDispatch,
getCustomPopulationCoursesByStudentnumbers,
}) => {
const { language } = useLanguage()
const { setAllStudents, filteredStudents } = useFilters()
const { setCourseCodes } = useProgrammeFilter()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const [codes, setCodes] = useState([])
const [headerYears, setYears] = useState('')
@@ -51,6 +57,7 @@ const CoursePopulation = ({
const [newestIndex, setNewest] = useState(null)
const gradeDistRef = useRef()
const languageDistRef = useRef()
const programmeRef = useRef()
const creditGainRef = useRef()
const studentRef = useRef()
@@ -63,18 +70,12 @@ const CoursePopulation = ({
[studentData.students, codes]
)
// Pass students to filter context.
useEffect(() => {
setAllStudents(studentData.students || [])
// Data fetching for courses of population tab
if (!studentData.students) return
getCustomPopulationCoursesByStudentnumbers({
studentnumberlist: studentData.students.map(student => student.studentNumber),
})
}, [studentData.students])
const { data: courseStatistics } = useGetStudentListCourseStatisticsQuery({
studentNumbers: studentData.students ? studentData.students.map(student => student.studentNumber) : [],
})
useEffect(() => {
getElementDetails()
getSemestersDispatch()
}, [])
@@ -108,7 +109,6 @@ const CoursePopulation = ({
separate,
})
setCodes(parsedCourseCodes)
setCourseCodes(parsedCourseCodes)
if (years) {
setYears(years)
} else {
@@ -152,7 +152,7 @@ const CoursePopulation = ({
setIndex(indexes)
}
const panels = [
const panels = filtered => [
{
key: 0,
title: {
@@ -168,10 +168,10 @@ const CoursePopulation = ({
<div ref={gradeDistRef}>
<InfoBox content={infotooltips.PopulationStatistics.GradeDistributionCoursePopulation} />
<CoursePopulationGradeDist
selectedStudents={selectedStudents}
selectedStudents={filtered.map(s => s.studentNumber)}
from={dateFrom}
to={dateTo}
samples={studentData.students}
samples={filtered}
codes={codes}
/>
</div>
@@ -183,20 +183,16 @@ const CoursePopulation = ({
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Programme distribution
Language distribution
</span>
),
},
onTitleClick: () => handleClick(1),
content: {
content: (
<div ref={programmeRef}>
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
<CustomPopulationProgrammeDist
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
samples={studentData.students}
selectedStudents={selectedStudents}
/>
<div ref={languageDistRef}>
<InfoBox content={infotooltips.PopulationStatistics.GradeDistributionCoursePopulation} />
<CoursePopulationLanguageDist from={dateFrom} to={dateTo} samples={studentData.students} codes={codes} />
</div>
),
},
@@ -206,7 +202,7 @@ const CoursePopulation = ({
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Courses of population
Programme distribution
</span>
),
},
@@ -214,7 +210,12 @@ const CoursePopulation = ({
content: {
content: (
<div ref={programmeRef}>
<CustomPopulationCourses selectedStudents={selectedStudents} showFilter={false} />
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
<CustomPopulationProgrammeDist
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
samples={filtered}
selectedStudents={filtered.map(s => s.studentNumber)}
/>
</div>
),
},
@@ -224,22 +225,15 @@ const CoursePopulation = ({
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Credit gains
Courses of population
</span>
),
},
onTitleClick: () => handleClick(3),
content: {
content: (
<div ref={creditGainRef}>
<CoursePopulationCreditGainTable
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
selectedStudents={selectedStudents}
samples={studentData.students}
codes={codes}
from={dateFrom}
to={dateTo}
/>
<div ref={programmeRef}>
<CustomPopulationCoursesWrapper filteredStudents={filtered} showFilter={false} />
</div>
),
},
@@ -249,17 +243,43 @@ const CoursePopulation = ({
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Students ({selectedStudents.length})
Credit gains
</span>
),
},
onTitleClick: () => handleClick(4),
content: {
content: (
<div ref={creditGainRef}>
<CoursePopulationCreditGainTable
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
selectedStudents={filtered.map(s => s.studentNumber)}
samples={filtered}
codes={codes}
from={dateFrom}
to={dateTo}
/>
</div>
),
},
},
{
key: 5,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Students ({filtered.length})
</span>
),
},
onTitleClick: () => handleClick(5),
content: {
content: (
<div ref={studentRef}>
<PopulationStudents
variant="coursePopulation"
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
filteredStudents={studentData.students.filter(stu => selectedStudents.includes(stu.studentNumber))}
filteredStudents={filtered}
coursePopulation
language={language}
coursecode={codes}
@@ -270,23 +290,53 @@ const CoursePopulation = ({
},
]
const courses = JSON.parse(queryParamsFromUrl(history.location).coursecodes)
return (
<FilterTray filterSet={<CoursePopulationFilters />}>
<div className="segmentContainer">
<Segment className="contentSegment">
<Header className="segmentTitle" size="large" textAlign="center">
Population of course {header}
</Header>
<Header className="segmentTitle" size="medium" textAlign="center">
{subHeader}
</Header>
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
</Segment>
</div>
</FilterTray>
<FilterView
name="CoursePopulation"
filters={[
genderFilter,
ageFilter,
courseFilter({ courses: courseStatistics?.coursestatistics }),
creditsEarnedFilter,
startYearAtUniFilter,
programmeFilter({ courses, elementDetails }),
gradeFilter({
courseCodes: courses,
from: dateFrom,
to: dateTo,
}),
]}
students={studentData.students ?? []}
>
{filtered => (
<div className="segmentContainer">
<Segment className="contentSegment">
<Header className="segmentTitle" size="large" textAlign="center">
Population of course {header}
</Header>
<Header className="segmentTitle" size="medium" textAlign="center">
{subHeader}
</Header>
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels(filtered)} />
</Segment>
</div>
)}
</FilterView>
)
}
const CustomPopulationCoursesWrapper = props => {
const { data: courseStatistics, isLoading } = useGetStudentListCourseStatisticsQuery({
studentNumbers: props.filteredStudents.map(student => student.studentNumber),
})
if (isLoading) return null
return <CustomPopulationCourses {...props} courses={courseStatistics} />
}
CoursePopulation.propTypes = {
getCoursePopulationDispatch: func.isRequired,
getSingleCourseStatsDispatch: func.isRequired,
@@ -300,7 +350,6 @@ CoursePopulation.propTypes = {
years: shape({}),
}).isRequired,
getFacultiesDispatch: func.isRequired,
getCustomPopulationCoursesByStudentnumbers: func.isRequired,
}
const mapStateToProps = ({
@@ -316,6 +365,7 @@ const mapStateToProps = ({
pending: populations.pending,
courseData: singleCourseStats.stats || {},
semesters: semesters.data,
elementDetails: populations?.data?.elementdetails?.data,
isAdmin: getUserIsAdmin(roles),
}
}
@@ -326,6 +376,6 @@ export default withRouter(
getSingleCourseStatsDispatch: getSingleCourseStats,
getSemestersDispatch: getSemesters,
getFacultiesDispatch: getFaculties,
getCustomPopulationCoursesByStudentnumbers,
getElementDetails,
})(CoursePopulation)
)
@@ -6,7 +6,7 @@ import { dataSeriesType, viewModeNames } from './Panes/util'
import PassRate from './Panes/passRate'
import Distribution from './Panes/distribution'
import Tables from './Panes/tables'
import InfoBox from '../../InfoBox'
import InfoBox from '../../Info/InfoBox'
import { useTabs } from '../../../common/hooks'
import './resultTabs.css'
@@ -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,252 @@ 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 = _.chain(students[0].studyRights || students[0].courses)
.map(elem => new Date(elem.startdate || elem.date))
.sortBy()
.head()
.defaultTo(new Date())
.value()
.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,53 +1,15 @@
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { shape, arrayOf, string, bool } from 'prop-types'
import React from 'react'
import PopulationCourseStatsFlat from '../../PopulationCourseStats/PopulationCourseStatsFlat'
import useCourseFilter from '../../FilterTray/filters/Courses/useCourseFilter'
const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, error, showFilter }) => {
const { setCourses, resetCourses } = useCourseFilter()
useEffect(() => {
if (!pending && !error && courses.coursestatistics) {
setCourses(courses.coursestatistics)
}
}, [courses])
// Clear course filter data on unmount.
useEffect(() => {
return resetCourses
}, [])
const CustomPopulationCourses = ({ courses, pending, filteredStudents, showFilter }) => {
return (
<PopulationCourseStatsFlat
courses={courses}
query={query}
pending={pending}
selectedStudents={selectedStudents}
customPopulation
filteredStudents={filteredStudents}
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
@@ -5,7 +5,8 @@ import { arrayOf, string, shape } from 'prop-types'
import SearchResultTable from '../../SearchResultTable'
import { getNewestProgramme, getTextIn } from '../../../common'
import useLanguage from '../../LanguagePicker/useLanguage'
import useProgrammeFilter from '../../FilterTray/filters/Programmes/useProgrammeFilter'
import useFilters from '../../FilterView/useFilters'
import { isProgrammeSelected, toggleProgrammeSelection } from '../../FilterView/filters/programmes'
import FilterToggleIcon from '../../FilterToggleIcon'
const CustomPopulationProgrammeDist = ({
@@ -15,7 +16,7 @@ const CustomPopulationProgrammeDist = ({
populationStatistics,
}) => {
const { language } = useLanguage()
const { selectedProgrammes, toggleFilterProgramme } = useProgrammeFilter()
const [tableRows, setRows] = useState([])
useEffect(() => {
@@ -61,27 +62,29 @@ const CustomPopulationProgrammeDist = ({
const headers = ['Programme', 'Code', `Students (all=${selectedStudents.length})`, 'Percentage of population']
const handleFilterToggle = code => {
toggleFilterProgramme(code)
}
const isProgrammeFilterActive = code => selectedProgrammes.find(p => p.code === code) !== undefined
return (
<SearchResultTable
headers={headers}
rows={tableRows}
selectable
noResultText="placeholder"
actionTrigger={row => (
<span style={{ display: 'inline-block', marginRight: '0.3em' }}>
<FilterToggleIcon onClick={() => handleFilterToggle(row[1])} isActive={isProgrammeFilterActive(row[1])} />
</span>
)}
actionTrigger={row => <ProgrammeFilterToggleCell programme={row[1]} />}
/>
)
}
const ProgrammeFilterToggleCell = ({ programme }) => {
const { useFilterSelector, filterDispatch } = useFilters()
const isActive = useFilterSelector(isProgrammeSelected(programme))
return (
<span style={{ display: 'inline-block', marginRight: '0.3em' }}>
<FilterToggleIcon onClick={() => filterDispatch(toggleProgrammeSelection(programme))} isActive={isActive} />
</span>
)
}
CustomPopulationProgrammeDist.defaultProps = {
studentToTargetCourseDateMap: null,
}
@@ -3,17 +3,14 @@ import moment from 'moment'
import xlsx from 'xlsx'
import { Dropdown } from 'semantic-ui-react'
import { getStudentGradeMean, getStudentGradeMeanWeightedByCredits, getStudentTotalCredits } from '../../common'
import useFilters from '../FilterTray/useFilters'
import sendEvent from '../../common/sendEvent'
import DataExport from '../PopulationStatistics/DataExport'
const sendAnalytics = sendEvent.populationStudents
export default () => {
const { filteredStudents } = useFilters()
export default ({ students }) => {
const getXlsx = () => {
const data = filteredStudents.map(student => ({
const data = students.map(student => ({
'Student Number': student.studentNumber,
'Total Credits (no transferred credits)': getStudentTotalCredits(student, false),
'Grade Mean (no transferred credits)': getStudentGradeMean(student, false),
@@ -27,11 +24,11 @@ export default () => {
return workbook
}
const filename = `oodikone_export_${filteredStudents.length}_students_${moment().format('YYYYMMDD-hhmmss')}.xlsx`
const filename = `oodikone_export_${students.length}_students_${moment().format('YYYYMMDD-hhmmss')}.xlsx`
return (
<>
<DataExport />
<DataExport students={students} />
<Dropdown.Item
onClick={() => {
xlsx.writeFile(getXlsx(), filename)
@@ -1,61 +1,115 @@
import React, { useState, useEffect, useRef } from 'react'
import { connect } from 'react-redux'
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { createSelector } from '@reduxjs/toolkit'
import { useSelector, useDispatch } from 'react-redux'
import { Segment, Header, Accordion, Message } from 'semantic-ui-react'
import { shape, func, arrayOf, bool, string } from 'prop-types'
import _ from 'lodash'
import scrollToComponent from 'react-scroll-to-component'
import { useProgress, useTitle } from '../../common/hooks'
import infotooltips from '../../common/InfoToolTips'
import { getCustomPopulationSearches } from '../../redux/customPopulationSearch'
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
import { useGetStudentListCourseStatisticsQuery } from '../../redux/populationCourses'
import { getSemesters } from '../../redux/semesters'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
import PopulationStudents from '../PopulationStudents'
import CustomPopulationCourses from './CustomPopulationCourses'
import CustomPopulationProgrammeDist from './CustomPopulationProgrammeDist'
import ProgressBar from '../ProgressBar'
import InfoBox from '../InfoBox'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
import useFilterTray from '../FilterTray/useFilterTray'
import InfoBox from '../Info/InfoBox'
import FilterView from '../FilterView'
import {
ageFilter,
courseFilter,
programmeFilter,
creditsEarnedFilter,
genderFilter,
transferredToProgrammeFilter,
startYearAtUniFilter,
tagsFilter,
creditDateFilter,
enrollmentStatusFilter,
} from '../FilterView/filters'
import useLanguage from '../LanguagePicker/useLanguage'
import CustomPopulationSearch from './CustomPopulationSearch'
import UnihowDataExport from './UnihowDataExport'
const CustomPopulation = ({
getCustomPopulationSearchesDispatch,
custompop,
customPopulationFlag,
loading,
customPopulationSearches,
latestCreatedCustomPopulationSearchId,
searchedCustomPopulationSearchId,
}) => {
const selectCustomPopulationData = createSelector(
state => state.semesters.data,
state => state.populations.data,
(semesters, populations) => ({
allSemesters: semesters?.semesters ?? [],
elementDetails: populations?.elementDetails?.data ?? [],
custompop: populations?.students ?? [],
})
)
const CustomPopulation = () => {
const { language } = useLanguage()
const dispatch = useDispatch()
const { allSemesters, elementDetails, custompop } = useSelector(selectCustomPopulationData)
const { data: courseStats } = useGetStudentListCourseStatisticsQuery({
studentNumbers: custompop.map(s => s.studentNumber),
})
useTitle('Custom population')
useEffect(() => {
dispatch(getSemesters())
dispatch(getCustomPopulationSearches())
}, [])
const filters = useMemo(
() => [
genderFilter,
ageFilter,
courseFilter({ courses: courseStats?.coursestatistics ?? [] }),
creditsEarnedFilter,
transferredToProgrammeFilter,
startYearAtUniFilter,
tagsFilter,
programmeFilter({
courses: courseStats?.coursestatistics ? courseStats?.coursestatistics.map(c => c.course.code) : [],
elementDetails,
}),
creditDateFilter,
enrollmentStatusFilter({
allSemesters: allSemesters ?? [],
language,
}),
],
[courseStats, elementDetails, allSemesters, language]
)
return (
<FilterView name="CustomPopulation" filters={filters} students={custompop} displayTray={custompop.length > 0}>
{students => <CustomPopulationContent students={students} custompop={custompop} />}
</FilterView>
)
}
const CustomPopulationContent = ({ students, custompop }) => {
const { language } = useLanguage()
const [activeIndex, setIndex] = useState([])
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)
const creditGainRef = useRef()
const programmeRef = useRef()
const coursesRef = useRef()
const studentRef = useRef()
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
useTitle('Custom population')
const { customPopulationSearches, searchedCustomPopulationSearchId } = useSelector(
state => state.customPopulationSearch
)
// Pass students to filter context.
useEffect(() => {
setAllStudents(custompop || [])
}, [custompop])
const populations = useSelector(state => state.populations)
const { customPopulationFlag } = populations
useEffect(() => {
getCustomPopulationSearchesDispatch()
}, [])
const { progress } = useProgress(populations.loading)
const { data: courseStats } = useGetStudentListCourseStatisticsQuery({
studentNumbers: students.map(s => s.studentNumber),
})
useEffect(() => {
if (newestIndex) {
@@ -63,12 +117,6 @@ const CustomPopulation = ({
}
}, [activeIndex])
useEffect(() => {
if (latestCreatedCustomPopulationSearchId) {
setSelectedSearchId(latestCreatedCustomPopulationSearchId)
}
}, [latestCreatedCustomPopulationSearchId])
const handleClick = index => {
const indexes = [...activeIndex].sort()
if (indexes.includes(index)) {
@@ -83,14 +131,13 @@ const CustomPopulation = ({
else setNewest(null)
setIndex(indexes)
}
const panels = [
const createPanels = () => [
{
key: 0,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Credit accumulation (for {selectedStudents.length} students)
Credit accumulation (for {students.length} students)
</span>
),
},
@@ -98,13 +145,7 @@ const CustomPopulation = ({
content: {
content: (
<div ref={creditGainRef}>
<CreditAccumulationGraphHighCharts
students={custompop}
selectedStudents={selectedStudents}
render={false}
trayOpen={trayOpen}
language={language}
/>
<CreditAccumulationGraphHighCharts students={students} />
</div>
),
},
@@ -123,7 +164,7 @@ const CustomPopulation = ({
content: (
<div ref={programmeRef}>
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
<CustomPopulationProgrammeDist samples={custompop} selectedStudents={selectedStudents} />
<CustomPopulationProgrammeDist samples={students} selectedStudents={_.map(students, 'studentNumber')} />
</div>
),
},
@@ -141,7 +182,7 @@ const CustomPopulation = ({
content: {
content: (
<div ref={coursesRef}>
<CustomPopulationCourses selectedStudents={selectedStudents} />
<CustomPopulationCourses filteredStudents={students} courses={courseStats} />
</div>
),
},
@@ -151,7 +192,7 @@ const CustomPopulation = ({
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Students ({selectedStudents.length})
Students ({students.length})
</span>
),
},
@@ -160,9 +201,10 @@ const CustomPopulation = ({
content: (
<div ref={studentRef}>
<PopulationStudents
variant="customPopulation"
language={language}
filteredStudents={filteredStudents}
dataExport={<UnihowDataExport />}
filteredStudents={students}
dataExport={<UnihowDataExport students={students} />}
customPopulation
/>
</div>
@@ -170,6 +212,7 @@ const CustomPopulation = ({
},
},
]
const renderCustomPopulation = () => (
<div>
{custompop && (
@@ -180,62 +223,32 @@ const CustomPopulation = ({
: ''}
</Header>
)}
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={createPanels()} />
</div>
)
return (
<FilterTray filterSet={<CustomPopulationFilters />}>
<div className="segmentContainer">
<Message style={{ maxWidth: '800px' }}>
<Message.Header>Custom population</Message.Header>
<p>
Here you can create custom population using a list of studentnumbers. Clicking the blue custom population
button will open a modal where you can enter a list of studentnumbers. You can also save a custom population
by giving it a name and clicking the save button in the modal. It will then appear in the saved populations
list. These populations are personal meaning that they will only show to you. You can only search
studentnumbers you have access rights to i.e. you have rights to the programme they are in.
</p>
</Message>
<CustomPopulationSearch />
{custompop.length > 0 && customPopulationFlag ? (
<Segment className="contentSegment">{renderCustomPopulation()}</Segment>
) : (
<Segment className="contentSegment">
<ProgressBar progress={progress} />
</Segment>
)}
</div>
</FilterTray>
<div className="segmentContainer">
<Message style={{ maxWidth: '800px' }}>
<Message.Header>Custom population</Message.Header>
<p>
Here you can create custom population using a list of studentnumbers. Clicking the blue custom population
button will open a modal where you can enter a list of studentnumbers. You can also save a custom population
by giving it a name and clicking the save button in the modal. It will then appear in the saved populations
list. These populations are personal meaning that they will only show to you. You can only search
studentnumbers you have access rights to i.e. you have rights to the programme they are in.
</p>
</Message>
<CustomPopulationSearch />
{custompop.length > 0 && customPopulationFlag ? (
<Segment className="contentSegment">{renderCustomPopulation(students)}</Segment>
) : (
<Segment className="contentSegment">
<ProgressBar progress={progress} />
</Segment>
)}
</div>
)
}
CustomPopulation.defaultProps = {
latestCreatedCustomPopulationSearchId: null,
searchedCustomPopulationSearchId: null,
}
CustomPopulation.propTypes = {
custompop: arrayOf(shape({})).isRequired,
customPopulationFlag: bool.isRequired,
loading: bool.isRequired,
customPopulationSearches: arrayOf(shape({})).isRequired,
latestCreatedCustomPopulationSearchId: string,
searchedCustomPopulationSearchId: string,
getCustomPopulationSearchesDispatch: func.isRequired,
}
const mapStateToProps = ({ populations, populationCourses, customPopulationSearch }) => ({
loading: populations.pending,
custompop: populations.data.students || [],
courses: populationCourses.data,
customPopulationFlag: populations.customPopulationFlag,
customPopulationSearches: customPopulationSearch.customPopulationSearches,
latestCreatedCustomPopulationSearchId: customPopulationSearch.latestCreatedCustomPopulationSearchId,
searchedCustomPopulationSearchId: customPopulationSearch.searchedCustomPopulationSearchId,
})
export default connect(mapStateToProps, {
getCustomPopulationCoursesByStudentnumbers,
getCustomPopulationSearchesDispatch: getCustomPopulationSearches,
})(CustomPopulation)
export default CustomPopulation
@@ -1,78 +0,0 @@
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'
const ExternalFilterToggle = ({
filterPanelContextKey,
applyFilter,
clearFilter,
filterName,
active,
popupContent,
}) => {
const [, setFilterTrayOpen] = useFilterTray(filterTrayContextKey)
const [, setCreditFilterOpen] = useFilterTray(filterPanelContextKey)
const filterAnalytics = useAnalytics()
const updateFilters = () => {
if (active) {
clearFilter()
filterAnalytics.clearFilterViaTable(filterName)
} else {
applyFilter()
filterAnalytics.setFilterViaTable(filterName)
if (filterPanelContextKey) {
setFilterTrayOpen(true)
setCreditFilterOpen(true)
}
}
}
const Toggle = () => (
<Button onClick={updateFilters} size="mini" icon basic={!active} primary={active}>
<Icon name="filter" />
</Button>
)
// Trigger must be wrapped in <div> for the tooltip to work.
// See https://stackoverflow.com/questions/63611315/semantics-popup-does-not-show-up-when-passing-a-custom-component-as-trigger
return popupContent ? (
<Popup
content={popupContent}
size="mini"
trigger={
<div>
<Toggle />
</div>
}
/>
) : (
<Toggle />
)
}
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,30 +0,0 @@
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'
import { FilterTrayProvider } from './useFilterTray'
import { ProgrammeFilterProvider } from './filters/Programmes/useProgrammeFilter'
import { FilterProvider } from './useFilters'
import { FilterAnalyticsProvider } from './useAnalytics'
import { GradeFilterProvider } from './filters/Grade/useGradeFilter'
const filterProviders = [
FilterAnalyticsProvider,
FilterTrayProvider,
FilterProvider,
CourseFilterProvider,
CreditFilterProvider,
AgeFilterProvider,
GradeFilterProvider,
ProgrammeFilterProvider,
]
const FilterContextProvider = ({ children }) =>
filterProviders.reduceRight((children, Provider) => <Provider>{children}</Provider>, children)
FilterContextProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default FilterContextProvider
@@ -1,75 +0,0 @@
import React, { useEffect } from 'react'
import GraduatedFromProgramme from './filters/GraduatedFromProgramme'
import TransferredToProgramme from './filters/TransferredToProgramme'
import EnrollmentStatus from './filters/EnrollmentStatus'
import CreditsEarned from './filters/CreditsEarned'
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 analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Population Statistics')
})
return (
<>
<GraduatedFromProgramme />
<TransferredToProgramme />
<EnrollmentStatus />
<CreditsEarned />
<Age />
<Gender />
<StartYearAtUni />
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
<Courses />
<Tags />
</>
)
}
export const CoursePopulationFilters = () => {
const analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Course Population')
})
return (
<>
<Grade />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Programmes />
</>
)
}
export const CustomPopulationFilters = () => {
const analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Custom Population')
})
return (
<>
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Date />
</>
)
}
@@ -1,92 +0,0 @@
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Form, Dropdown } from 'semantic-ui-react'
import { connect } from 'react-redux'
import FilterCard from './common/FilterCard'
import ClearFilterButton from './common/ClearFilterButton'
import useFilters from '../useFilters'
import useAnalytics from '../useAnalytics'
const AdmissionType = ({ code }) => {
const { addFilter, removeFilter, activeFilters, withoutFilter } = useFilters()
const analytics = useAnalytics()
// Using undefined as default, since students can be filtered with null value
const [value, setValue] = useState(undefined)
const name = 'admissionTypeFilter'
// Naming follows convention from SIS API (e.g urn:code:admissiont-type:m for "Muu")
const admissionTypes = {
M: 'Muu',
KM: 'Kilpailumenestys',
TV: 'Todistusvalinta',
AV: 'Avoin väylä',
KP: 'Koepisteet',
YP: 'Yhteispisteet',
N: null,
}
const filterFunction = value => student => {
return value === 'Ei valintatapaa'
? student.studyrights.some(sr => sr.studyright_elements.some(e => e.code === code) && !sr.admission_type)
: student.studyrights.some(sr => sr.studyright_elements.some(e => e.code === code) && value === sr.admission_type)
}
useEffect(() => {
if (!value) {
removeFilter(name)
analytics.clearFilter(name)
} else {
addFilter(name, filterFunction(value))
analytics.setFilter(name, value)
}
}, [value])
const count = admissionType => withoutFilter(name).filter(filterFunction(admissionType)).length
const options = Object.entries(admissionTypes).map(([key, admissionType]) => {
const value = admissionType || 'Ei valintatapaa'
return {
key,
text: `${value} (${count(admissionType)})`,
value,
}
})
return (
<FilterCard
title="Admission type"
contextKey={name}
active={Object.keys(activeFilters).includes(name)}
footer={<ClearFilterButton disabled={!value} onClick={() => setValue(null)} name={name} />}
name={name}
>
<div className="card-content">
<Form>
<Dropdown
options={options}
value={value}
onChange={(_, { value: inputValue }) => setValue(inputValue)}
placeholder="Choose admission type"
className="mini"
selection
selectOnBlur={false}
fluid
button
clearable
data-cy={`${name}-dropdown`}
/>
</Form>
</div>
</FilterCard>
)
}
AdmissionType.propTypes = {
code: PropTypes.string.isRequired,
}
const mapStateToProps = ({ populations }) => ({
code: populations.query ? populations.query.studyRights.programme : '',
})
export default connect(mapStateToProps)(AdmissionType)
@@ -1,102 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Form, Label, Input } from 'semantic-ui-react'
import FilterCard from '../common/FilterCard'
import useAgeFilter from './useAgeFilter'
import useFilters from '../../useFilters'
import useAnalytics from '../../useAnalytics'
export const contextKey = 'ageFilter'
const getAge = toDate => {
const today = new Date()
const birthDate = new Date(toDate)
let age = today.getFullYear() - birthDate.getFullYear()
const m = today.getMonth() - birthDate.getMonth()
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
age--
}
return age
}
const AgeFilter = () => {
const { currentValue, setCurrentValue } = useAgeFilter()
const { addFilter, removeFilter, activeFilters } = useFilters()
const analytics = useAnalytics()
const [updatedAt, setUpdatedAt] = useState({ min: null, max: null })
const labels = { min: 'At Least', max: 'Less Than' }
const now = () => new Date().getTime()
const names = Object.fromEntries(Object.keys(currentValue).map(key => [key, `age${key}`]))
const filterFunctions = limit => ({
min: student => getAge(student.birthdate) >= Number(limit),
max: student => getAge(student.birthdate) < Number(limit),
})
const updateFilters = key => {
const name = names[key]
if (currentValue[key] !== '') {
addFilter(name, filterFunctions(currentValue[key])[key])
analytics.setFilter(name, currentValue[key])
} else {
removeFilter(name)
analytics.clearFilter(name)
}
}
// Update filters automatically 2 sec after value change.
useEffect(() => {
const timer = setTimeout(() => {
Object.keys(updatedAt).forEach(key => {
if (updatedAt[key] && now() - updatedAt[key] > 1900) {
updateFilters(key)
setUpdatedAt(prev => ({ ...prev, [key]: null }))
}
})
}, 2000)
return () => clearTimeout(timer)
}, [updatedAt])
const onChange =
key =>
(_, { value }) => {
setCurrentValue({ [key]: value })
setUpdatedAt(prev => ({ ...prev, [key]: now() }))
}
const onKeyDown = key => event => {
if (event.keyCode === 13) {
event.preventDefault()
setUpdatedAt(prev => ({ ...prev, [key]: null }))
updateFilters(key)
}
}
const active = Object.values(names).some(name => Object.keys(activeFilters).includes(name))
return (
<FilterCard title="Age" active={active} className="total-age-filter" contextKey={contextKey} name="ageFilter">
<Form>
<div className="card-content">
{Object.keys(currentValue).map(key => (
<Form.Field key={`total-age-filter-${key}`}>
<Label style={{ marginBottom: '0.5rem' }}>{labels[key]}</Label>
<Input
size="mini"
onChange={onChange(key)}
value={currentValue[key]}
onKeyDown={onKeyDown(key)}
data-cy={`ageFilter-${key}`}
style={{ width: '100px' }}
/>
</Form.Field>
))}
</div>
</Form>
</FilterCard>
)
}
export default AgeFilter
@@ -1,37 +0,0 @@
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
const defaultState = {
currentValue: { min: '', max: '' },
requestedValue: { min: null, max: null },
}
const AgeFilterContext = createContext([[], () => {}])
AgeFilterContext.displayName = 'Age Filter'
export const AgeFilterProvider = ({ children }) => {
const [state, setState] = useState(defaultState)
return <AgeFilterContext.Provider value={[state, setState]}>{children}</AgeFilterContext.Provider>
}
AgeFilterProvider.propTypes = {
children: PropTypes.node.isRequired,
}
const useAgeFilter = () => {
const [state, setState] = useContext(AgeFilterContext)
const { currentValue } = state
const setCurrentValue = newValue =>
setState(prev => ({
...prev,
currentValue: {
...prev.currentValue,
...newValue,
},
}))
return { currentValue, setCurrentValue }
}
export default useAgeFilter
@@ -1,115 +0,0 @@
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Label, Dropdown, Button, Icon, Popup } from 'semantic-ui-react'
import { getTextIn } from '../../../../common'
import useCourseFilter from './useCourseFilter'
import useFilters from '../../useFilters'
import useAnalytics from '../../useAnalytics'
import useLanguage from '../../../LanguagePicker/useLanguage'
const CourseCard = ({ courseStats }) => {
const { language } = useLanguage()
const { addFilter, removeFilter } = useFilters()
const { course, students } = courseStats
const { toggleCourseSelection } = useCourseFilter()
const analytics = useAnalytics()
const [selectedOption, setSelectedOption] = useState(0)
const name = `courseFilter-${course.code}`
const subFilters = [
{
label: 'All',
func: ({ studentNumber }) => Object.keys(students.all).includes(studentNumber),
},
{
label: 'Passed',
func: ({ studentNumber }) => Object.keys(students.passed).includes(studentNumber),
},
{
label: 'Passed After Failure',
func: ({ studentNumber }) => Object.keys(students.retryPassed).includes(studentNumber),
info: 'Student passed the course after failing it at least once.',
},
{
label: 'Failed',
func: ({ studentNumber }) => Object.keys(students.failed).includes(studentNumber),
},
{
label: 'Failed Many Times',
func: ({ studentNumber }) => Object.keys(students.failedMany).includes(studentNumber),
},
{
label: 'Not Participated',
func: ({ studentNumber }) => !Object.keys(students.all).includes(studentNumber),
},
{
label: "Didn't pass",
func: ({ studentNumber }) =>
!Object.keys(students.all).includes(studentNumber) || Object.keys(students.failed).includes(studentNumber),
info: "Students that failed or didn't participate in the course",
},
]
// Apply filter on mount or selection change.
useEffect(() => {
addFilter(name, subFilters[selectedOption].func)
}, [selectedOption])
// Remove filter on unmount.
useEffect(() => {
return () => removeFilter(name)
}, [])
const onClick = (_, { value }) => setSelectedOption(value)
const clear = () => {
toggleCourseSelection(course.code)
analytics.clearFilter(course.code)
}
return (
<>
<Label style={{ marginTop: '0.5rem' }}>
{getTextIn(course.name, language)}
<Dropdown
text={subFilters[selectedOption].label}
value={selectedOption}
fluid
className="mini"
button
data-cy={`${name}-dropdown`}
style={{ marginTop: '0.5rem' }}
>
<Dropdown.Menu>
{subFilters.map((option, i) => {
if (option.info) {
return (
<Popup
key={option.label}
basic
trigger={<Dropdown.Item text={option.label} value={i} onClick={onClick} />}
content={option.info}
/>
)
}
return <Dropdown.Item key={option.label} text={option.label} value={i} onClick={onClick} />
})}
</Dropdown.Menu>
</Dropdown>
<Button compact size="tiny" onClick={clear} icon data-cy={`${name}-clear`} style={{ marginTop: '0.5rem' }}>
<Icon name="close" />
</Button>
</Label>
</>
)
}
CourseCard.propTypes = {
courseStats: PropTypes.shape({
course: PropTypes.object,
students: PropTypes.object,
}).isRequired,
}
export default CourseCard
@@ -1,69 +0,0 @@
import React from 'react'
import { Dropdown } from 'semantic-ui-react'
import FilterCard from '../common/FilterCard'
import CourseCard from './CourseCard'
import { getTextIn } from '../../../../common'
import useCourseFilter from './useCourseFilter'
import './courseFilter.css'
import useFilters from '../../useFilters'
import useAnalytics from '../../useAnalytics'
import useLanguage from '../../../LanguagePicker/useLanguage'
export const contextKey = 'coursesFilter'
const Courses = () => {
const { language } = useLanguage()
const { courses: courseStats, selectedCourses, toggleCourseSelection } = useCourseFilter()
const { filteredStudents } = useFilters()
const analytics = useAnalytics()
const name = 'courseFilter'
// Wrestle course stats into something semantic-ui eats without throwing up.
const makeLabel = cs => `${cs.course.code} - ${getTextIn(cs.course.name, language)}`
const options = courseStats
.filter(cs => cs.stats.students > Math.round(filteredStudents.length * 0.3))
.filter(cs => !selectedCourses.some(c => c.course.code === cs.course.code))
.sort((a, b) => makeLabel(a).localeCompare(makeLabel(b)))
.map(cs => ({
key: `course-filter-option-${cs.course.code}`,
text: makeLabel(cs),
value: cs.course.code,
}))
const onChange = (_, { value }) => {
const courseCode = value[0]
toggleCourseSelection(courseCode)
analytics.setFilter(name, courseCode)
}
return (
<FilterCard
title="Courses"
active={!!selectedCourses.length}
className="courses-filter"
contextKey={contextKey}
name={name}
>
<Dropdown
options={options}
placeholder="Select Course"
selection
className="mini course-filter-selection"
fluid
button
value={[]}
onChange={onChange}
multiple
closeOnChange
search
name={name}
data-cy={`${name}-course-dropdown`}
/>
{selectedCourses.map(course => (
<CourseCard courseStats={course} key={`course-filter-selected-course-${course.course.code}`} />
))}
</FilterCard>
)
}
export default Courses
@@ -1,79 +0,0 @@
import React, { createContext, useState, useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { getPopulationSelectedStudentCourses } from '../../../../redux/populationSelectedStudentCourses'
const defaultState = {
courses: [],
selectedCourses: [],
dispatchCourseQuery: null,
}
const CourseFilterContext = createContext([[], () => {}])
CourseFilterContext.displayName = 'Course Filter'
const CourseFilterProvider = ({ children, getPopulationSelectedStudentCourses }) => {
const [state, setState] = useState(defaultState)
// On load, extract the course query dispatch function from redux for further use.
useEffect(() => {
setState(prev => ({ ...prev, dispatchCourseQuery: 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 }
// Acual hook functions.
export default () => {
const [state, setState] = useContext(CourseFilterContext)
const { courses, selectedCourses, dispatchCourseQuery } = state
const setCourses = courses => setState(prev => ({ ...prev, courses }))
const setCoursesOnce = courses => {
if (!state.courses.length) {
setState(prev => ({ ...prev, courses }))
}
}
const resetCourses = () => setState(prev => ({ ...prev, courses: [] }))
const toggleCourseSelection = courseCode =>
setState(prev => {
const course = courses.find(course => course.course.code === courseCode)
const isSelected = prev.selectedCourses.some(c => c.course.code === courseCode)
return {
...prev,
selectedCourses: isSelected
? prev.selectedCourses.filter(c => c.course.code !== courseCode)
: prev.selectedCourses.concat(course),
}
})
const courseIsSelected = courseCode => selectedCourses.some(course => course.course.code === courseCode)
const runCourseQuery = opts => {
dispatchCourseQuery(opts)
}
return {
courses,
selectedCourses,
setCourses,
setCoursesOnce,
resetCourses,
toggleCourseSelection,
courseIsSelected,
runCourseQuery,
}
}
@@ -1,111 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Form, Input, Label } from 'semantic-ui-react'
import { getStudentTotalCredits } from '../../../../common'
import FilterCard from '../common/FilterCard'
import useCreditFilter from './useCreditFilter'
import useFilters from '../../useFilters'
import useAnalytics from '../../useAnalytics'
export const contextKey = 'creditFilter'
export default () => {
const { currentValue, requestedValue, setCurrentValue } = useCreditFilter()
const { addFilter, removeFilter, activeFilters } = useFilters()
const analytics = useAnalytics()
const [updatedAt, setUpdatedAt] = useState({ min: null, max: null })
const labels = { min: 'At Least', max: 'Less Than' }
const now = () => new Date().getTime()
const names = Object.fromEntries(Object.keys(currentValue).map(key => [key, `totalCredits${key}`]))
const filterFunctions = limit => ({
min: student => getStudentTotalCredits(student) >= Number(limit),
max: student => getStudentTotalCredits(student) < Number(limit),
})
const updateFilters = key => {
const name = names[key]
if (currentValue[key] !== '') {
addFilter(name, filterFunctions(currentValue[key])[key])
analytics.setFilter(name, currentValue[key])
} else {
removeFilter(name)
analytics.clearFilter(name)
}
}
// Update filters automatically 2 sec after value change.
useEffect(() => {
const timer = setTimeout(() => {
Object.keys(updatedAt).forEach(key => {
if (updatedAt[key] && now() - updatedAt[key] > 1900) {
updateFilters(key)
setUpdatedAt(prev => ({ ...prev, [key]: null }))
}
})
}, 2000)
return () => clearTimeout(timer)
}, [updatedAt])
// Listen for external filtering requests (from credit stats table rows).
useEffect(() => {
Object.keys(currentValue).forEach(key => {
const newValue = requestedValue[key] === null ? '' : requestedValue[key]
const name = names[key]
setCurrentValue({ [key]: String(newValue) })
if (newValue === '') {
removeFilter(name)
} else {
addFilter(name, filterFunctions(newValue)[key])
}
})
}, [requestedValue])
const onChange =
key =>
(_, { value }) => {
setCurrentValue({ [key]: value })
setUpdatedAt(prev => ({ ...prev, [key]: now() }))
}
const onKeyDown = key => event => {
if (event.keyCode === 13) {
event.preventDefault()
setUpdatedAt(prev => ({ ...prev, [key]: null }))
updateFilters(key)
}
}
const active = Object.values(names).some(name => Object.keys(activeFilters).includes(name))
return (
<FilterCard
title="Credits"
active={active}
className="total-credits-filter"
contextKey={contextKey}
name="credit-filter"
>
<Form>
<div className="card-content">
{Object.keys(currentValue).map(key => (
<Form.Field key={`total-credits-filter-${key}`}>
<Label style={{ marginBottom: '0.5rem' }}>{labels[key]}</Label>
<Input
size="mini"
onChange={onChange(key)}
value={currentValue[key]}
onKeyDown={onKeyDown(key)}
data-cy={`credit-filter-${key}`}
style={{ width: '100px' }}
/>
</Form.Field>
))}
</div>
</Form>
</FilterCard>
)
}
@@ -1,44 +0,0 @@
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
const defaultState = {
currentValue: { min: '', max: '' },
requestedValue: { min: null, max: null },
}
const CreditFilterContext = createContext([[], () => {}])
CreditFilterContext.displayName = 'Credit Filter'
export const CreditFilterProvider = ({ children }) => {
const [state, setState] = useState(defaultState)
return <CreditFilterContext.Provider value={[state, setState]}>{children}</CreditFilterContext.Provider>
}
CreditFilterProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default () => {
const [state, setState] = useContext(CreditFilterContext)
const { currentValue, requestedValue } = state
const setCurrentValue = newValue =>
setState(prev => ({
...prev,
currentValue: {
...prev.currentValue,
...newValue,
},
}))
const setRequestedValue = newValue =>
setState(prev => ({
...prev,
requestedValue: {
...prev.currentValue,
...newValue,
},
}))
return { currentValue, requestedValue, setCurrentValue, setRequestedValue }
}
@@ -1,16 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Button } from 'semantic-ui-react'
import './creditDateFilter.css'
// FYI: I spent over an hour trying to make this button also close the date picker.
// Do not try it, use the time to roast react-datetime devs instead...
const DateButton = ({ value }, openCalendar) => {
return <Button content={value} onClick={openCalendar} className="credit-date-filter-input" />
}
DateButton.propTypes = {
value: PropTypes.string.isRequired,
}
export default DateButton
@@ -1,18 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import Datetime from 'react-datetime'
import 'moment/locale/fi'
import DateButton from './DateButton'
const DateTime = ({ value, onChange }) => {
return (
<Datetime value={value} onChange={onChange} timeFormat={false} locale="fi" closeOnSelect renderInput={DateButton} />
)
}
DateTime.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.shape({})]).isRequired,
onChange: PropTypes.func.isRequired,
}
export default DateTime
@@ -1,69 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Form } from 'semantic-ui-react'
import 'moment/locale/fi'
import ClearFilterButton from '../common/ClearFilterButton'
import FilterCard from '../common/FilterCard'
import useFilters from '../../useFilters'
import DateTime from './DateTime'
/**
* Filter courses according to the date when credits were earned.
* Unlike other filters, this one does not filter out students from the population. Rather it
* operates on the students' courses, filtering them according to the date range input.
*/
export default () => {
const { setCreditDateFilter } = useFilters()
const label = 'Select Date'
const [startDate, setStartDate] = useState(label)
const [endDate, setEndDate] = useState(label)
const name = 'creditDateFilter'
useEffect(() => {
const filterFn = course => {
if (startDate !== label && startDate.isAfter(course.date)) {
return false
}
if (endDate !== label && endDate.isBefore(course.date)) {
return false
}
return true
}
setCreditDateFilter(filterFn)
}, [startDate, endDate])
const reset = () => {
setStartDate(label)
setEndDate(label)
}
const clearButtonDisabled = startDate === label && endDate === label
return (
<FilterCard
title="Date of Course Credits"
contextKey={name}
footer={<ClearFilterButton disabled={clearButtonDisabled} onClick={reset} name={name} />}
active={!clearButtonDisabled}
name={name}
>
<div className="description-text">
Include course credits from the selected date range only. Does not filter out students.
</div>
<div className="card-content" style={{ marginTop: '0.5rem' }}>
<Form>
<Form.Field>
<label>Start Date:</label>
<DateTime value={startDate} onChange={date => setStartDate(date)} />
</Form.Field>
<Form.Field>
<label>End Date:</label>
<DateTime value={endDate} onChange={date => setEndDate(date)} />
</Form.Field>
</Form>
</div>
</FilterCard>
)
}
@@ -1,120 +0,0 @@
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import lodash from 'lodash'
import { Form, Dropdown } from 'semantic-ui-react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'
import FilterCard from './common/FilterCard'
import ClearFilterButton from './common/ClearFilterButton'
import useFilters from '../useFilters'
import useAnalytics from '../useAnalytics'
import { getTextIn } from '../../../common'
const EnrollmentStatus = ({ allSemesters, language }) => {
const [status, setStatus] = useState(null)
const [semesters, setSemesters] = useState([])
const { allStudents, addFilter, removeFilter } = useFilters()
const analytics = useAnalytics()
const name = 'enrollmentStatusFilter'
const active = !!status && !!semesters.length
const statusOptions = [
{ key: 'enrl-status-present', text: 'Present', value: 1 },
{ key: 'enrl-status-absent', text: 'Absent', value: 2 },
]
const semesterCodes = lodash(allStudents.map(student => student.semesterenrollments.map(enrl => enrl.semestercode)))
.flatten()
.uniq()
.value()
useEffect(() => {
if (active) {
addFilter(name, student =>
semesters.every(sem => {
const enrollment = student.semesterenrollments.find(enrl => enrl.semestercode === sem)
// If enrollment info not found, return false. This may or may not be what we want?
return enrollment ? enrollment.enrollmenttype === status : false
})
)
analytics.setFilter(name, `${statusOptions[status]} (${semesters.map(sem => allSemesters[sem].en).join(', ')})`)
} else {
removeFilter(name)
analytics.clearFilter(name)
}
}, [status, semesters])
if (!Object.keys(allSemesters).length) {
return null
}
const semesterOptions = semesterCodes.map(code => ({
key: `semester-option-${code}`,
text: getTextIn(allSemesters[code].name, language),
value: code,
}))
const clear = () => {
setStatus(null)
setSemesters([])
}
return (
<FilterCard
title="Enrollment Status"
contextKey="enrollmentStatus"
active={active}
footer={<ClearFilterButton disabled={!status && !semesters.length} onClick={clear} name={name} />}
name={name}
>
<div className="card-content">
<Form>
<Form.Field>
<Dropdown
options={statusOptions}
value={status}
onChange={(_, { value }) => setStatus(value)}
placeholder="Choose Enrollment Status"
className="mini"
selection
fluid
button
clearable
data-cy={`${name}-status`}
/>
</Form.Field>
<Form.Field>
<Dropdown
multiple
selection
fluid
options={semesterOptions}
button
className="mini"
placeholder="Choose Semesters"
onChange={(_, { value }) => setSemesters(value)}
value={semesters}
data-cy={`${name}-semesters`}
/>
</Form.Field>
</Form>
</div>
</FilterCard>
)
}
EnrollmentStatus.propTypes = {
allSemesters: PropTypes.shape({}),
language: PropTypes.string.isRequired,
}
EnrollmentStatus.defaultProps = {
allSemesters: {},
}
const mapStateToProps = ({ semesters, settings }) => ({
allSemesters: semesters.data.semesters,
language: settings.language,
})
export default withRouter(connect(mapStateToProps)(EnrollmentStatus))
@@ -1,70 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Form, Dropdown } from 'semantic-ui-react'
import ClearFilterButton from './common/ClearFilterButton'
import FilterCard from './common/FilterCard'
import useFilters from '../useFilters'
import useAnalytics from '../useAnalytics'
export default () => {
const { addFilter, removeFilter, withoutFilter, activeFilters } = useFilters()
const analytics = useAnalytics()
const [value, setValue] = useState(null)
const name = 'genderFilter'
const genderCodes = {
female: { label: 'Female', value: 2 },
male: { label: 'Male', value: 1 },
other: { label: 'Other', value: 3 },
unknown: { label: 'Unknown', value: 0 },
}
useEffect(() => {
if (
!Object.values(genderCodes)
.map(gc => gc.value)
.includes(value)
) {
removeFilter(name)
analytics.clearFilter(name)
} else {
addFilter(name, student => value === Number(student.gender_code))
analytics.setFilter(name, value)
}
}, [value])
const count = genderCode => withoutFilter(name).filter(student => Number(student.gender_code) === genderCode).length
const options = Object.entries(genderCodes).map(([key, gender]) => ({
key,
text: `${gender.label} (${count(gender.value)})`,
value: gender.value,
}))
return (
<FilterCard
title="Gender"
contextKey="genderFilter"
footer={<ClearFilterButton disabled={!value} onClick={() => setValue(null)} name={name} />}
active={Object.keys(activeFilters).includes(name)}
name={name}
>
<div className="card-content">
<Form>
<Dropdown
options={options}
value={value}
onChange={(_, { value: inputValue }) => setValue(inputValue)}
placeholder="Choose Gender"
className="mini"
selection
selectOnBlur={false}
fluid
button
clearable
data-cy={`${name}-dropdown`}
/>
</Form>
</div>
</FilterCard>
)
}
@@ -1,72 +0,0 @@
// FIXME: Remove when feature is ready.
/* eslint-disable no-unused-vars */
import React, { useState, useEffect } from 'react'
import { Form, Checkbox } from 'semantic-ui-react'
import useFilters from '../../useFilters'
import useAnalytics from '../../useAnalytics'
import FilterCard from '../common/FilterCard'
import ClearFilterButton from '../common/ClearFilterButton'
import useGradeFilter from './useGradeFilter'
export const contextKey = 'gradeFilter'
/**
* Grade filter.
* Only applicable to a single course.
*/
export default () => {
const { addFilter, removeFilter, activeFilters } = useFilters()
const { value, setValue, grades } = useGradeFilter()
const analytics = useAnalytics()
const name = 'gradeFilter'
const options = Object.keys(grades).sort((a, b) => b - a)
// Filter function update hook.
useEffect(() => {
if (value.length) {
const studentNumbers = value.reduce((a, b) => a.concat(grades[b]), [])
addFilter(name, student => studentNumbers.includes(student.studentNumber))
} else {
removeFilter(name)
}
}, [value])
const checked = grade => value.includes(grade)
const onChange = grade => () => {
if (checked(grade)) {
setValue(value.filter(val => val !== grade))
} else {
setValue(value.concat(grade))
}
}
return (
<FilterCard
title="Grade"
contextKey={contextKey}
footer={<ClearFilterButton disabled={!value.length} onClick={() => setValue([])} name={name} />}
active={Object.keys(activeFilters).includes(name)}
name={name}
>
<div className="card-content">
<Form>
{options.map(grade => (
<Form.Field key={`${name}-${grade}`}>
<Checkbox
label={
<label data-cy={`${name}-${grade}`}>
{grade}
<span className="filter-option-count">{`(${grades[grade].length} students)`}</span>
</label>
}
checked={checked(grade)}
onChange={onChange(grade)}
/>
</Form.Field>
))}
</Form>
</div>
</FilterCard>
)
}
@@ -1,30 +0,0 @@
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
const defaultState = {
value: [],
grades: {},
}
const GradeFilterContext = createContext([[], () => {}])
GradeFilterContext.displayName = 'Grade Filter'
export const GradeFilterProvider = ({ children }) => {
const [state, setState] = useState(defaultState)
return <GradeFilterContext.Provider value={[state, setState]}>{children}</GradeFilterContext.Provider>
}
GradeFilterProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default () => {
const [state, setState] = useContext(GradeFilterContext)
return {
value: state.value,
setValue: value => setState(prev => ({ ...prev, value })),
grades: state.grades,
setGrades: grades => setState(prev => ({ ...prev, grades })),
}
}
@@ -1,97 +0,0 @@
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Form, Radio } from 'semantic-ui-react'
import { connect } from 'react-redux'
import FilterCard from './common/FilterCard'
import ClearFilterButton from './common/ClearFilterButton'
import useFilters from '../useFilters'
import useAnalytics from '../useAnalytics'
const GraduatedFromProgramme = ({ code }) => {
const { addFilter, removeFilter } = useFilters()
const analytics = useAnalytics()
const [value, setValue] = useState(null)
const name = 'graduatedFromProgrammeFilter'
const active = value !== null
// Old-style study programmes need separation between bachelor's and master's.
const combinedExtent = !code.includes('_')
const graduated = studyrights =>
studyrights.some(sr =>
sr.studyright_elements.some(sre => {
const dateMatch = new Date(sre.enddate) >= new Date(sr.enddate)
return sre.code === code && dateMatch && sr.graduated
})
)
const graduatedWithExtent = (studyrights, extent) => graduated(studyrights.filter(sr => sr.extentcode === extent))
const filterFn = wanted => student =>
combinedExtent && wanted > 0
? graduatedWithExtent(student.studyrights, wanted)
: graduated(student.studyrights) === !!wanted
useEffect(() => {
if (active) {
addFilter(name, filterFn(value))
analytics.setFilter(name, value)
} else {
removeFilter(name)
analytics.clearFilter(name)
}
}, [value])
const options = [{ key: 'graduated-false', text: `Not Graduated`, value: 0 }].concat(
combinedExtent
? [
{ key: 'graduated-bachelor', text: `Graduated with Bachelor's`, value: 1 },
{ key: 'graduated-master', text: `Graduated with Master's`, value: 2 },
]
: [{ key: 'graduated-true', text: `Graduated`, value: 1 }]
)
return (
<FilterCard
title="Graduation Status"
contextKey={name}
active={active}
footer={<ClearFilterButton disabled={!active} onClick={() => setValue(null)} name={name} />}
name={name}
>
<Form>
<div className="card-content">
<Form.Field>
<Radio
label="All"
checked={value === null}
onChange={() => setValue(null)}
style={{ marginBottom: '0.5rem' }}
data-cy={`${name}-all`}
/>
{options.map(option => (
<Radio
key={option.key}
label={option.text}
name="radioGroup"
style={{ marginBottom: '0.5rem' }}
checked={value === option.value}
onChange={() => setValue(option.value)}
data-cy={`${name}-${option.key}`}
/>
))}
</Form.Field>
</div>
</Form>
</FilterCard>
)
}
GraduatedFromProgramme.propTypes = {
code: PropTypes.string.isRequired,
}
const mapStateToProps = ({ populations }) => ({
code: populations.query ? populations.query.studyRights.programme : '',
})
export default connect(mapStateToProps)(GraduatedFromProgramme)
@@ -1,60 +0,0 @@
import React, { useEffect, useMemo } from 'react'
import { Dropdown } from 'semantic-ui-react'
import FilterCard from '../common/FilterCard'
import { getTextIn } from '../../../../common'
import useLanguage from '../../../LanguagePicker/useLanguage'
import useProgrammeFilter from './useProgrammeFilter'
import useFilters from '../../useFilters'
const Programme = () => {
const { programmes, selectedProgrammes, studentToProgrammeMap, selectFilterProgramme, removeFilterProgramme } =
useProgrammeFilter()
const { removeFilter, addFilter } = useFilters()
const { language } = useLanguage()
const name = 'programmeFilter'
const options = useMemo(
() =>
programmes
.map(program => ({
key: `programme-filter-value-${program.code}`,
text: getTextIn(program.name, language),
value: program.code,
}))
.sort((a, b) => a.text.localeCompare(b.text)),
[programmes, language]
)
const handleChange = (_, { value }) => {
value.filter(code => selectedProgrammes.findIndex(p => p.code === code) === -1).forEach(selectFilterProgramme)
selectedProgrammes.filter(({ code }) => value.indexOf(code) === -1).forEach(removeFilterProgramme)
}
useEffect(() => {
if (selectedProgrammes.length === 0) {
removeFilter(name)
} else {
addFilter(name, ({ studentNumber }) => {
return selectedProgrammes.findIndex(p => p.code === studentToProgrammeMap[studentNumber]) !== -1
})
}
}, [selectedProgrammes, studentToProgrammeMap])
return (
<FilterCard title="Programme" active={selectedProgrammes.length > 0} className="programmes-filter" name={name}>
<Dropdown
options={options}
placeholder="Select Programme"
onChange={handleChange}
button
value={selectedProgrammes.map(p => p.code)}
name={name}
closeOnChange
multiple
/>
</FilterCard>
)
}
export default Programme
@@ -1,119 +0,0 @@
import React, { useState, createContext, useContext, useMemo } from 'react'
import { useSelector } from 'react-redux'
import { getStudentToTargetCourseDateMap, getNewestProgramme } from '../../../../common'
import useFilters from '../../useFilters'
const ProgrammeFilterContext = createContext([[], () => {}])
ProgrammeFilterContext.displayName = 'Programme Filter'
const defaultState = {
selectedProgrammes: [],
}
export const ProgrammeFilterProvider = ({ children }) => {
const [state, setState] = useState(defaultState)
const [courseCodes, setCourseCodes] = useState([])
const { selectedProgrammes } = state
const setSelectedProgrammes = programmes => {
setState({
selectedProgrammes: programmes,
})
}
const context = {
selectedProgrammes,
setSelectedProgrammes,
courseCodes,
setCourseCodes,
}
return <ProgrammeFilterContext.Provider value={context}>{children}</ProgrammeFilterContext.Provider>
}
export default () => {
const { selectedProgrammes, courseCodes, setCourseCodes, setSelectedProgrammes } = useContext(ProgrammeFilterContext)
const { allStudents } = useFilters()
const studentToTargetCourseDateMap = useMemo(
() => getStudentToTargetCourseDateMap(allStudents, courseCodes),
[allStudents]
)
const elementDetails = useSelector(state => state?.populations?.data?.elementdetails?.data)
const { programmes, studentToProgrammeMap } = useMemo(() => {
if (!elementDetails) {
return {
programmes: [],
studentToProgrammeMap: {},
}
}
const programmeMap = {}
const studentToProgrammeMap = {}
allStudents
.map(student => [
student,
getNewestProgramme(student.studyrights, student.studentNumber, studentToTargetCourseDateMap, elementDetails),
])
.forEach(([{ studentNumber }, programme]) => {
programmeMap[programme.code] = programme
studentToProgrammeMap[studentNumber] = programme.code
})
return {
programmes: Object.values(programmeMap),
studentToProgrammeMap,
}
}, [allStudents, elementDetails])
const selectFilterProgramme = code => {
const index = selectedProgrammes.findIndex(p => p.code === code)
if (index !== -1) {
return
}
const programme = programmes.find(p => p.code === code)
if (programme === undefined) {
return
}
setSelectedProgrammes([...selectedProgrammes, programme])
}
const removeFilterProgramme = code => {
const index = selectedProgrammes.findIndex(p => p.code === code)
const newSelection = [...selectedProgrammes]
newSelection.splice(index, 1)
setSelectedProgrammes(newSelection)
}
const toggleFilterProgramme = code => {
const index = selectedProgrammes.findIndex(p => p.code === code)
if (index === -1) {
const programme = programmes.find(p => p.code === code)
setSelectedProgrammes([...selectedProgrammes, programme])
} else {
const newSelection = [...selectedProgrammes]
newSelection.splice(index, 1)
setSelectedProgrammes(newSelection)
}
}
return {
programmes,
selectedProgrammes,
toggleFilterProgramme,
selectFilterProgramme,
removeFilterProgramme,
setCourseCodes,
studentToProgrammeMap,
}
}
@@ -1,64 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Form, Dropdown } from 'semantic-ui-react'
import ClearFilterButton from './common/ClearFilterButton'
import FilterCard from './common/FilterCard'
import useFilters from '../useFilters'
import useAnalytics from '../useAnalytics'
export default () => {
const { addFilter, removeFilter, withoutFilter, activeFilters } = useFilters()
const analytics = useAnalytics()
const [value, setValue] = useState([])
const name = 'startYearAtUni'
const isActive = () => value.length > 0
useEffect(() => {
if (!isActive()) {
removeFilter(name)
analytics.clearFilter(name)
} else {
addFilter(name, student => value.some(year => year === new Date(student.started).getFullYear()))
analytics.setFilter(name, value.join(', '))
}
}, [value])
const countsByYear = {}
withoutFilter(name).forEach(student => {
const year = new Date(student.started).getFullYear()
countsByYear[year] = countsByYear[year] ? countsByYear[year] + 1 : 1
})
const options = Object.keys(countsByYear).map(year => ({
key: `year-${year}`,
text: `${year} (${countsByYear[year]})`,
value: Number(year),
}))
return (
<FilterCard
title="Starting Year"
contextKey="startYearFilter"
footer={<ClearFilterButton disabled={!isActive()} onClick={() => setValue([])} name={name} />}
active={Object.keys(activeFilters).includes(name)}
name={name}
>
<div className="card-content">
<Form>
<Dropdown
multiple
selection
fluid
options={options}
button
className="mini"
placeholder="Choose Years to Include"
onChange={(_, { value: inputValue }) => setValue(inputValue)}
value={value}
data-cy={`${name}-dropdown`}
/>
</Form>
</div>
</FilterCard>
)
}
@@ -1,72 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Form, Dropdown } from 'semantic-ui-react'
import ClearFilterButton from './common/ClearFilterButton'
import FilterCard from './common/FilterCard'
import useFilters from '../useFilters'
import useAnalytics from '../useAnalytics'
export default () => {
const { addFilter, removeFilter, withoutFilter, activeFilters } = useFilters()
const analytics = useAnalytics()
const [value, setValue] = useState([])
const name = 'tagsFilter'
const isActive = () => value.length > 0
useEffect(() => {
if (!isActive()) {
removeFilter(name)
analytics.clearFilter(name)
} else {
addFilter(name, student =>
value.some(tag => {
const studentTags = student.tags ? student.tags.map(studentTag => studentTag.tag.tagname) : []
return studentTags.includes(tag)
})
)
analytics.setFilter(name, value.join(', '))
}
}, [value])
const countsByTag = {}
withoutFilter(name).forEach(student => {
student.tags.forEach(tag => {
const name = tag.tag.tagname
countsByTag[name] = countsByTag[name] ? countsByTag[name] + 1 : 1
})
})
const options = Object.keys(countsByTag).map(tag => ({
key: `tag-${tag}`,
text: `${tag} (${countsByTag[tag]})`,
value: tag,
}))
return (
<FilterCard
title="Tags"
contextKey="tagsFilter"
footer={<ClearFilterButton disabled={!isActive()} onClick={() => setValue([])} name={name} />}
active={Object.keys(activeFilters).includes(name)}
name={name}
>
<div className="card-content">
<Form>
<Dropdown
multiple
selection
fluid
options={options}
button
className="mini"
placeholder="Choose Tags to include"
onChange={(_, { value: inputValue }) => setValue(inputValue)}
value={value}
data-cy={`${name}-dropdown`}
/>
</Form>
</div>
</FilterCard>
)
}
@@ -1,79 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Form, Radio } from 'semantic-ui-react'
import FilterCard from './common/FilterCard'
import ClearFilterButton from './common/ClearFilterButton'
import useFilters from '../useFilters'
import useAnalytics from '../useAnalytics'
import { usePrevious } from '../../../common/hooks'
export default () => {
const { addFilter, removeFilter, activeFilters } = useFilters()
const analytics = useAnalytics()
const [value, setValue] = useState(0)
const name = 'transferredToProgrammeFilter'
const active = value !== null
const filterIsActive = name in activeFilters
const prevValue = usePrevious(filterIsActive)
const filterFn = wanted => student => student.transferredStudyright === wanted
useEffect(() => {
if (active) {
addFilter(name, filterFn(!!value))
analytics.setFilter(name, value)
} else {
removeFilter(name)
analytics.clearFilter(name)
}
}, [value])
useEffect(() => {
// bit of a hack but basically to keep visual of the filter
// up to date with the actual state of the filter when controlled
// from outside. Better solution would be to treat context as one
// source of truth for filters.
if (prevValue && !filterIsActive) setValue(null)
}, [filterIsActive])
const toggle = buttonValue => () => setValue(prev => (prev === buttonValue ? null : buttonValue))
return (
<FilterCard
title="Transfer Status"
contextKey={name}
active={active}
footer={<ClearFilterButton disabled={!active} onClick={() => setValue(null)} name={name} />}
name={name}
>
<Form>
<div className="card-content">
<Form.Field>
<Radio
label="All"
name="radioGroup"
checked={value === null}
onChange={toggle(null)}
data-cy={`${name}-all`}
/>
<Radio
label="Transferred"
name="radioGroup"
checked={value === 1}
onChange={toggle(1)}
data-cy={`${name}-have`}
style={{ margin: '0.5rem 0' }}
/>
<Radio
label="Not Transferred"
name="radioGroup"
checked={value === 0}
onChange={toggle(0)}
data-cy={`${name}-havenot`}
/>
</Form.Field>
</div>
</Form>
</FilterCard>
)
}
@@ -1,18 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Button, Icon } from 'semantic-ui-react'
const ClearFilterButton = ({ disabled, onClick, name }) => (
<Button compact color="red" size="tiny" disabled={disabled} onClick={onClick} data-cy={`${name}-clear`}>
<Icon name="close" />
Clear
</Button>
)
ClearFilterButton.propTypes = {
disabled: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
}
export default ClearFilterButton
@@ -1,34 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Icon, Header } from 'semantic-ui-react'
import useFilterTray from '../../useFilterTray'
const FilterCard = ({ title, children, contextKey, name, active }) => {
const [open, , toggleOpen] = useFilterTray(contextKey)
return (
<div>
<div
style={{ display: 'flex', alignItems: 'center', cursor: active ? undefined : 'pointer', margin: '1rem 0' }}
onClick={active ? undefined : toggleOpen}
data-cy={`${name}-header`}
>
<Icon name={open ? 'caret down' : 'caret right'} style={{ visibility: active ? 'hidden' : 'visible' }} />
<Header size="tiny" style={{ marginTop: '0' }}>
{title}
</Header>
</div>
{(active || open) && children}
</div>
)
}
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,47 +0,0 @@
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 { filteredStudents, allStudents } = useFilters()
if (!visible || !allStudents.length) {
return children
}
return (
<>
<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' }}>
Filter students
</Header>
<div style={{ textAlign: 'center' }}>
<b>
{filteredStudents.length} {filteredStudents.length === 1 ? 'student' : 'students'}
</b>{' '}
out of {allStudents.length} shown
</div>
{filterSet}
</Segment>
{children}
</div>
</>
)
}
FilterTray.propTypes = {
children: PropTypes.node.isRequired,
filterSet: PropTypes.node.isRequired,
visible: PropTypes.bool,
}
FilterTray.defaultProps = {
visible: true,
}
export default FilterTray
@@ -1,42 +0,0 @@
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
import TSA from '../../common/tsa'
const FilterAnalyticsContext = createContext([[], () => {}])
FilterAnalyticsContext.displayName = 'Filter Analytics'
export const FilterAnalyticsProvider = ({ children }) => {
const [state, setState] = useState(null)
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})`
const sendEvent = (event, name, value = undefined) => state && TSA.Matomo.sendEvent(category, event, name, value)
const setTarget = name => setState(name)
const unsetTarget = () => setState(null)
const setFilter = (name, value) => sendEvent('Set Filter', name, value)
const clearFilter = name => sendEvent('Clear Filter', name)
const openTray = () => sendEvent('Toggle Filter Tray', 'Open')
const closeTray = () => sendEvent('Toggle Filter Tray', 'Close')
const setFilterViaTable = (name, value) => sendEvent('Set Filter Via Table', name, value)
const clearFilterViaTable = (name, value) => sendEvent('Clear Filter Via Table', name, value)
return {
setTarget,
unsetTarget,
setFilter,
clearFilter,
openTray,
closeTray,
setFilterViaTable,
clearFilterViaTable,
}
}
@@ -1,28 +0,0 @@
/**
* 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'
export const FilterTrayProvider = ({ children }) => {
// transferredToProgrammeFilter is open by default since it is also active by default
const [state, setState] = useState({ transferredToProgrammeFilter: true })
return <FilterTrayContext.Provider value={[state, setState]}>{children}</FilterTrayContext.Provider>
}
FilterTrayProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default key => {
const [state, setState] = useContext(FilterTrayContext)
const value = state[key] || false
const setValue = newValue => setState(prev => ({ ...prev, [key]: !!newValue }))
const toggleValue = () => setState(prev => ({ ...prev, [key]: !prev[key] }))
return [value, setValue, toggleValue]
}
@@ -1,74 +0,0 @@
/**
* Context for managing filtering.
*/
import React, { createContext, useState, useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import lodash from 'lodash'
const defaultState = {
allStudents: [],
filteredStudents: [],
activeFilters: {},
// This is a special filter that operates on students' courses instead of the student's themselves.
creditDateFilter: null,
}
const applyFilters = (filters, allStudents) =>
Object.values(filters).reduce((students, nextFilter) => students.filter(nextFilter), allStudents)
const FilterContext = createContext([[], () => {}])
FilterContext.displayName = 'Filters'
export const FilterProvider = ({ children }) => {
const [state, setState] = useState(defaultState)
const { activeFilters, allStudents, creditDateFilter } = state
// Apply filters as a side-effect.
useEffect(() => {
const withRegularFilters = applyFilters(activeFilters, allStudents)
const filteredStudents = withRegularFilters.map(({ courses, ...rest }) => ({
...rest,
courses: creditDateFilter ? courses.filter(creditDateFilter) : courses,
}))
setState(prev => ({ ...prev, filteredStudents }))
}, [activeFilters, allStudents, creditDateFilter])
return <FilterContext.Provider value={[state, setState]}>{children}</FilterContext.Provider>
}
FilterProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default () => {
const [state, setState] = useContext(FilterContext)
const setAllStudents = allStudents => setState(prev => ({ ...prev, allStudents }))
const setCreditDateFilter = creditDateFilter => setState(prev => ({ ...prev, creditDateFilter }))
const addFilter = (name, filterFn) =>
setState(prev => ({ ...prev, activeFilters: { ...prev.activeFilters, [name]: filterFn } }))
const removeFilter = name => setState(prev => ({ ...prev, activeFilters: lodash.omit(prev.activeFilters, name) }))
/**
* Apply all active filters except for the one named as the argument.
* This provides a way for a filter to count objects without itself affecting the sample.
* @param {string} name Name of the filter to skip.
*/
const withoutFilter = name => applyFilters(lodash.omit(state.activeFilters, name), state.allStudents)
const { allStudents, filteredStudents, activeFilters } = state
return {
allStudents,
filteredStudents,
activeFilters,
setAllStudents,
setCreditDateFilter,
addFilter,
removeFilter,
withoutFilter,
}
}
@@ -0,0 +1,36 @@
import React from 'react'
import { Popup, Button, Icon } from 'semantic-ui-react'
const FilterToggle = ({ active, applyFilter, clearFilter, popupContent }) => {
const toggleFilter = () => {
if (active) {
clearFilter()
} else {
applyFilter()
}
}
const Toggle = () => (
<Button onClick={toggleFilter} size="mini" icon basic={!active} primary={active}>
<Icon name="filter" />
</Button>
)
// Trigger must be wrapped in <div> for the tooltip to work.
// See https://stackoverflow.com/questions/63611315/semantics-popup-does-not-show-up-when-passing-a-custom-component-as-trigger
return popupContent ? (
<Popup
content={popupContent}
size="mini"
trigger={
<div>
<Toggle />
</div>
}
/>
) : (
<Toggle />
)
}
export default FilterToggle

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