Compare commits

...
115 Commits
Author SHA1 Message Date
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
Otto Ahoniemi aa6d2b83f5 Revert hiding of programme filter 2021-11-29 15:08:12 +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 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
104 changed files with 3082 additions and 24883 deletions
+3 -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,7 +124,7 @@ 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
+2 -2
View File
@@ -89,7 +89,7 @@ describe('Population Statistics', () => {
runTestStepWithPreAndPostParts('ageFilter-header', () => {
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('40')
checkFilteringResult(43)
checkFilteringResult(42)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
})
@@ -190,7 +190,7 @@ describe('Course Statistics', () => {
runTestStepWithPreAndPostParts('ageFilter-header', () => {
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('40')
checkFilteringResult(33)
checkFilteringResult(32)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
})
+7 -3
View File
@@ -64,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(() => {
@@ -74,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
@@ -165,7 +167,9 @@ describe('Population Statistics tests', () => {
it('Credit Statistics, Credits Gained tab shows stats by admissions', () => {
cy.visit(pathToCSMaster2019)
cy.contains('Credit statistics').click()
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')
@@ -175,7 +179,7 @@ describe('Population Statistics tests', () => {
it('Credit Statistics, Statistics pane works', () => {
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('Credit statistics').click()
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')
+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",
+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)
+20 -2
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])
}
})
@@ -53,6 +60,17 @@ router.get('/students/:id', async (req, res) => {
: res.status(200).json(filterStudentTags(results, decodedToken.id)).end()
}
if (roles && roles.includes('studyGuidanceGroups')) {
const studentsUserCanAccess = await getAllStudentsUserHasInGroups(decodedToken.sisPersonId)
if (!studentsUserCanAccess.has(studentId)) {
return res.status(400).json({ error: 'error finding student' }).end()
}
const results = await Student.withId(studentId)
return results.error
? res.status(400).json({ error: 'error finding student' }).end()
: res.status(200).json(filterStudentTags(results, decodedToken.id)).end()
}
const uid = req.decodedToken.userId
const student = await Student.withId(studentId)
if (student.error) {
@@ -0,0 +1,121 @@
const router = require('express').Router()
const {
getBasicStatsForStudytrack,
getCreditStatsForStudytrack,
getGraduationStatsForStudytrack,
} = require('../services/newStudyprogramme')
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
if (code) {
let data = null
try {
data = await getBasicStats(code)
} catch (e) {
logger.error(`Failed to get code ${code} basic stats`)
}
if (!data) {
try {
let result
if (code.includes('MH') || code.includes('KH')) {
result = await getBasicStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2017-01-01'),
})
} else {
result = await getBasicStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2000-01-01'),
})
}
data = await setBasicStats(result)
} catch (e) {
logger.error(`Failed to update code ${code} basic stats`)
}
}
return res.json({ data })
} else {
res.status(422).end()
}
})
router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
const code = req.params.id
if (code) {
let data = null
try {
data = await getCreditStats(code)
} catch (e) {
logger.error(`Failed to get code ${code} credit stats`)
}
if (!data) {
try {
let result
if (code.includes('MH') || code.includes('KH')) {
result = await getCreditStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2017-01-01'),
})
} else {
result = await getCreditStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2000-01-01'),
})
}
data = await setCreditStats(result)
} 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
if (code) {
let data = null
try {
data = await getGraduationStats(code)
} catch (e) {
logger.error(`Failed to get code ${code} graduation stats`)
}
if (!data) {
try {
let result
if (code.includes('MH') || code.includes('KH')) {
result = await getGraduationStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2017-01-01'),
})
} else {
result = await getGraduationStatsForStudytrack({
studyprogramme: req.params.id,
startDate: new Date('2000-01-01'),
})
}
data = await setGraduationStats(result)
} 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 => `BASIC_STATS_${id}`
const createRedisKeyForCreditStats = id => `CREDIT_STATS_${id}`
const createRedisKeyForGraduationStats = id => `GRADUATION_STATS_${id}`
const getProductivity = async id => {
const redisKey = createRedisKeyForProductivity(id)
@@ -87,6 +90,66 @@ const patchThroughput = async data => {
}
}
const getBasicStats = async id => {
const redisKey = createRedisKeyForBasicStats(id)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setBasicStats = async data => {
const { id } = data
const redisKey = createRedisKeyForBasicStats(id)
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 => {
const redisKey = createRedisKeyForCreditStats(id)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setCreditStats = async data => {
const { id } = data
const redisKey = createRedisKeyForCreditStats(id)
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 => {
const redisKey = createRedisKeyForGraduationStats(id)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setGraduationStats = async data => {
const { id } = data
const redisKey = createRedisKeyForGraduationStats(id)
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,484 @@
const sequelize = require('sequelize')
const { Op } = sequelize
const { indexOf } = require('lodash')
const {
Credit,
Course,
Organization,
Studyright,
StudyrightElement,
ElementDetail,
Transfer,
Student,
} = require('../models')
const { ThesisCourse } = require('../models/models_kone')
const { mapToProviders } = require('../util/utils')
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 => {
const years = []
for (let i = since; i <= new Date().getFullYear(); i++) {
years.push(i)
}
return years
}
const getYearsObject = years => {
let yearsObject = {}
for (const year of years) {
yearsObject = { ...yearsObject, [year]: 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 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 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 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 (studyprogramme, startDate) => {
const thesiscourses = await ThesisCourse.findAll({
where: {
programmeCode: studyprogramme,
},
})
return await Credit.findAll({
include: {
model: Course,
required: true,
distinct: true,
col: 'student_studentnumber',
where: {
code: {
[Op.in]: thesiscourses.map(tc => tc.courseCode),
},
},
},
where: {
credittypecode: {
[Op.ne]: 10,
},
attainment_date: {
[Op.gte]: startDate,
},
},
})
}
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, startDate) => {
return await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: startDate,
},
sourcecode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
}
const transfersTo = async (studytrack, startDate) => {
return await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: startDate,
},
targetcode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
}
const getGraduatedStats = async (studytrack, startDate, years) => {
const studyrights = await graduatedStudyRights(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ enddate }) => {
const graduationYear = enddate.getFullYear()
graphStats[indexOf(years, graduationYear)] += 1
tableStats[graduationYear] += 1
})
return { graphStats, tableStats }
}
const getStartedStats = async (studytrack, startDate, years) => {
const studyrights = await startedStudyrights(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ studystartdate }) => {
const startYear = studystartdate.getFullYear()
graphStats[indexOf(years, startYear)] += 1
tableStats[startYear] += 1
})
return { graphStats, tableStats }
}
const getCancelledStats = async (studytrack, startDate, years) => {
const studyrights = await cancelledStudyRights(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ canceldate }) => {
const cancelYear = canceldate.getFullYear()
graphStats[indexOf(years, cancelYear)] += 1
tableStats[cancelYear] += 1
})
return { graphStats, tableStats }
}
const getTransferredAwayStats = async (studytrack, startDate, years) => {
const studyrights = await transfersAway(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ transferdate }) => {
const transferYear = transferdate.getFullYear()
graphStats[indexOf(years, transferYear)] += 1
tableStats[transferYear] += 1
})
return { graphStats, tableStats }
}
const getTransferredToStats = async (studytrack, startDate, years) => {
const studyrights = await transfersTo(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ transferdate }) => {
const transferYear = transferdate.getFullYear()
graphStats[indexOf(years, transferYear)] += 1
tableStats[transferYear] += 1
})
return { graphStats, tableStats }
}
const getRegularCreditStats = async (studytrack, startDate, years) => {
const providercode = mapToProviders([studytrack])[0]
const studyrights = await getProgrammesStudents(studytrack)
const credits = await getCreditsForStudyProgramme(providercode, startDate)
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 = attainment_date.getFullYear()
// Map all credits for the studyprogramme and
// divide them into major students' and nonmajors' credits by year
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, startDate, years) => {
const providercode = mapToProviders([studytrack])[0]
const credits = await getTransferredCredits(providercode, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
credits.forEach(({ attainment_date, credits }) => {
const attainmentYear = attainment_date.getFullYear()
graphStats[indexOf(years, attainmentYear)] += credits || 0
tableStats[attainmentYear] += credits || 0
})
return { graphStats, tableStats }
}
const getThesisStats = async (studytrack, startDate, years) => {
const credits = await getThesisCredits(studytrack, startDate)
const { graphStats, tableStats } = getStatsBasis(years)
credits.forEach(({ attainment_date }) => {
const attainmentYear = attainment_date.getFullYear()
graphStats[indexOf(years, attainmentYear)] += 1
tableStats[attainmentYear] += 1
})
return { graphStats, tableStats }
}
const getBasicStatsForStudytrack = async ({ studyprogramme, startDate }) => {
const years = getYearsArray(startDate.getFullYear())
const started = await getStartedStats(studyprogramme, startDate, years)
const graduated = await getGraduatedStats(studyprogramme, startDate, years)
const cancelled = await getCancelledStats(studyprogramme, startDate, years)
const transferredAway = await getTransferredAwayStats(studyprogramme, startDate, years)
const transferredTo = await getTransferredToStats(studyprogramme, startDate, years)
const reversedYears = getYearsArray(startDate.getFullYear()).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, startDate }) => {
const years = getYearsArray(startDate.getFullYear())
const { majors, nonMajors } = await getRegularCreditStats(studyprogramme, startDate, years)
const transferred = await getTransferredCreditStats(studyprogramme, startDate, years)
const reversedYears = getYearsArray(startDate.getFullYear()).reverse()
const tableStats = reversedYears.map(year => [
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, startDate }) => {
const years = getYearsArray(startDate.getFullYear())
const thesis = await getThesisStats(studyprogramme, startDate, years)
const graduated = await getGraduatedStats(studyprogramme, startDate, years)
const reversedYears = getYearsArray(startDate.getFullYear()).reverse()
const tableStats = reversedYears.map(year => [year, graduated.tableStats[year], thesis.tableStats[year]])
return {
id: studyprogramme,
years,
tableStats,
graphStats: [
{
name: 'Graduated students',
data: graduated.graphStats,
},
{
name: 'Wrote thesis',
data: thesis.graphStats,
},
],
}
}
module.exports = {
getBasicStatsForStudytrack,
getCreditStatsForStudytrack,
getGraduationStatsForStudytrack,
}
@@ -494,6 +494,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
@@ -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) => {
+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"]
}
+85 -22596
View File
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -6,9 +6,10 @@
"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",
@@ -22,18 +23,14 @@
"qs": "^6.10.1",
"query-string": "^6.14.1",
"react": "^16.14.0",
"react-context-devtool": "^2.0.3",
"react-datetime": "^3.0.4",
"react-dom": "^16.14.0",
"react-highcharts": "^16.1.0",
"react-markdown": "^6.0.2",
"react-redux": "^7.2.4",
"react-redux": "^7.2.6",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"react-scroll-to-component": "^1.0.2",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^2.0.3",
"unfuck-spa-shibboleth-session": "^3.1.3",
-5
View File
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import FilterContextProvider from './components/FilterTray/FilterContextProvider'
import { LanguageProvider } from './components/LanguagePicker/useLanguage'
@@ -9,8 +8,4 @@ const CommonContext = ({ children }) => (
</LanguageProvider>
)
CommonContext.propTypes = {
children: PropTypes.node.isRequired,
}
export default CommonContext
+45 -17
View File
@@ -1,9 +1,11 @@
import axios from 'axios'
import * as Sentry from '@sentry/browser'
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { getMocked, setMocking } from '../common'
import { apiBasePath, isDev } from '../conf'
import { loginPrefix, logoutPrefix } from '../redux/auth'
const getHeaders = () => {
export const getHeaders = () => {
// Set up dev user for development environment, mimicking production admin user
const devUserHeaders = {
uid: 'mluukkai',
@@ -19,16 +21,16 @@ const getHeaders = () => {
export const api = axios.create({ baseURL: apiBasePath, headers: getHeaders() })
const types = {
attempt: prefix => `${prefix}ATTEMPT`,
failure: prefix => `${prefix}FAILURE`,
success: prefix => `${prefix}SUCCESS`,
export const actionSuffixes = {
attempt: `ATTEMPT`,
failure: `FAILURE`,
success: `SUCCESS`,
}
export const actionTypes = prefix => ({
attempt: types.attempt(prefix),
failure: types.failure(prefix),
success: types.success(prefix),
attempt: `${prefix}${actionSuffixes.attempt}`,
failure: `${prefix}${actionSuffixes.failure}`,
success: `${prefix}${actionSuffixes.success}`,
})
export const logout = async () => {
@@ -69,7 +71,8 @@ export const callController = (route, prefix, data, method = 'get', query, param
params,
onProgress,
}
return { type: `${prefix}ATTEMPT`, requestSettings }
const { attempt } = actionTypes(prefix)
return { type: attempt, requestSettings }
}
const handleError = (err, actionHistory = []) => {
@@ -90,20 +93,24 @@ export const handleRequest = store => next => async action => {
const { requestSettings } = action
if (requestSettings) {
const { route, method, data, prefix, query, params, onProgress } = requestSettings
const { success, failure } = actionTypes(prefix)
try {
const res = await callApi(route, method, data, params, 0, onProgress)
store.dispatch({ type: `${prefix}SUCCESS`, response: res.data, query })
store.dispatch({ type: success, response: res.data, query })
} catch (e) {
store.dispatch({ type: `${prefix}FAILURE`, response: e, query })
store.dispatch({ type: failure, response: e, query })
handleError(e, store.getState().actionHistory)
}
}
}
export const handleAuth = store => next => async action => {
const loginTypes = actionTypes(loginPrefix)
const logoutTypes = actionTypes(logoutPrefix)
next(action)
const { type } = action
if (type === 'LOGIN_ATTEMPT') {
if (type === loginTypes.attempt) {
const { success, failure } = loginTypes
try {
let token
const mocked = getMocked()
@@ -118,18 +125,39 @@ export const handleAuth = store => next => async action => {
...api.defaults.headers.common,
'x-access-token': token,
}
store.dispatch({ type: 'LOGIN_SUCCESS', token })
store.dispatch({ type: success, token })
} catch (err) {
store.dispatch({ type: 'LOGIN_FAILURE' })
store.dispatch({ type: failure })
handleError(err, store.getState().actionHistory)
}
} else if (type === 'LOGOUT_ATTEMPT') {
} else if (type === loginTypes.attempt) {
const { success, failure } = logoutTypes
try {
await logout()
store.dispatch({ type: 'LOGOUT_SUCCESSFUL' })
store.dispatch({ type: success })
} catch (err) {
store.dispatch({ type: 'LOGOUT_FAILURE' })
store.dispatch({ type: failure })
handleError(err, store.getState().actionHistory)
}
}
}
// Redux-toolkit query based API
// Api can be extended with enhanceEndpoints method
// All tags used for invalidating cache must be defined here
export const RTKApi = createApi({
reducerPath: 'api',
tagTypes: ['StudyGuidanceGroups'],
baseQuery: fetchBaseQuery({
baseUrl: apiBasePath,
prepareHeaders: (headers, { getState }) => {
// Get token from state and add it to headers
const token = getState().auth.encodedToken
if (token) headers.set('x-access-token', token)
// Add possible default headers
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
return headers
},
}),
endpoints: () => ({}),
})
@@ -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,12 @@
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
**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.
`,
}
@@ -28,7 +28,7 @@ const mapValueToRange = (x, min1, max1, min2, max2) => {
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
}
const actionTooltips = {
const settingDefinitions = {
showByYear: {
label: 'Näytä kalenterivuosittain',
short: 'Näytä tilastot kalenterivuosittain lukuvuosien sijasta.',
@@ -36,6 +36,7 @@ const actionTooltips = {
Kun tämä valinta on käytössä, vuosittaiset ajanjaksot lasketaan kalenterivuoden alusta sen loppuun.
Muulloin vuosittaiset ajanjaksot lasketaan lukukauden alusta seuraavan lukukauden alkuun.
`,
defaultValue: false,
},
showYearlyValues: {
@@ -49,6 +50,7 @@ const actionTooltips = {
tai lukuvuoden alusta "Näytä päivänä"-valintaa vastaavaan päivämäärään tuona vuotena. Luvut näytetään muodossa *<kerynyt>*/*<kokonais>*.
Kuluvalta vuodelta näytetään ainoastaan kertynyt tilasto.
`,
defaultValue: false,
},
showRelativeValues: {
@@ -60,6 +62,7 @@ const actionTooltips = {
Opiskelijoiden määrä perustuu ajanjaksolla kyseisen organisaation alaisista kursseista suoritusmerkintöjä saaneiden opiskelijoiden määrään.
Luku siis sisältää muutkin kuin kyseiseen ohjelman tai osaston opinto-oikeuden omaavat opiskelijat.
`,
defaultValue: false,
},
showCountingFrom: {
@@ -71,10 +74,31 @@ const actionTooltips = {
lasketaan kertyneet tilastot (vrt. lukuvuosien kokonaistilastot) kunkin lukuvuoden alusta
tätä päivämäärää vastaavaan päivään kyseisenä lukuvuonna.
`,
defaultValue: null,
},
showStudentCounts: {
label: 'Näytä kurssien opiskelijamäärät',
short: 'Näyttää suoritettujen opintopisteiden sijasta opiskelijoiden määrät kurssitason näkymässä.',
long: `
Oletuksena kurssitason näkymässä näytetään organisaatio- ja ohjelmatason näkymien tapaan suoritettujen opintopisteiden
kokonaismäärä. Kun tämä valinta on käytössä, tämän sijasta näytettävät luvut vastaavat kurssin suorittaneiden
*yksilöityjen* opiskelijoiden määrää. Kukin opiskelija lasketaan siis vain kerran tähän tilastoon.
`,
defaultValue: false,
},
}
const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, showRelativeValues, showByYear }) => {
const StatusContainer = ({
stats,
handleClick,
min1,
max1,
showYearlyValues,
showRelativeValues,
showByYear,
showStudentCounts,
}) => {
const title = getTextIn(stats.name)
const clickable = !!stats.drill
@@ -186,7 +210,7 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
{year}
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
</b>
{stats.type === 'course' ? (
{stats.type === 'course' && showStudentCounts ? (
<>
<span className="year-value">{yearStats.accStudents}</span>
{!!yearStats.totalStudents && (
@@ -218,7 +242,7 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
}
const StatusContent = ({ data, settings, onDrill }) => {
const { showByYear, showRelativeValues, showYearlyValues } = settings
const { showByYear, showRelativeValues, showYearlyValues, showStudentCounts } = settings
const orderedAbsDiffs = _.chain(data)
.map(({ current, currentStudents, previous, previousStudents }) => {
@@ -257,6 +281,7 @@ const StatusContent = ({ data, settings, onDrill }) => {
handleClick={handleClick}
showRelativeValues={showRelativeValues}
showYearlyValues={showYearlyValues}
showStudentCounts={showStudentCounts}
min1={-medianDiff * 2}
max1={medianDiff * 2}
showByYear={showByYear}
@@ -316,7 +341,7 @@ const WithHelpTooltip = ({ children, tooltip, onOpenDetails, ...rest }) => {
const isValidDate = d => moment().diff(moment(d)) > 0
const StatusSettings = ({ onSettingsChange, settings, onOpenDetails }) => {
const { showYearlyValues, showByYear, showRelativeValues, selectedDate } = settings
const { selectedDate } = settings
const DATE_FORMAT = 'DD.MM.YYYY'
const changeSetting = (property, value) => {
@@ -334,46 +359,36 @@ const StatusSettings = ({ onSettingsChange, settings, onOpenDetails }) => {
alignItems: 'center',
}
const createSettingToggle = key => (
<div style={itemStyles}>
<WithHelpTooltip tooltip={settingDefinitions[key].short} onOpenDetails={() => onOpenDetails(key)}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={settingDefinitions[key].label}
checked={settings[key]}
onChange={() => changeSetting(key, !settings[key])}
/>
</WithHelpTooltip>
</div>
)
return (
<div style={{ display: 'flex', alignItems: 'stretch', padding: 0, flexDirection: 'column' }}>
<div style={itemStyles}>
<WithHelpTooltip tooltip={actionTooltips.showYearlyValues.short} onOpenDetails={onOpenDetails}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={actionTooltips.showYearlyValues.label}
checked={showYearlyValues}
onChange={() => changeSetting('showYearlyValues', !showYearlyValues)}
/>
</WithHelpTooltip>
</div>
<div style={itemStyles}>
<WithHelpTooltip tooltip={actionTooltips.showByYear.short} onOpenDetails={onOpenDetails}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={actionTooltips.showByYear.label}
checked={showByYear}
onChange={() => changeSetting('showByYear', !showByYear)}
/>
</WithHelpTooltip>
</div>
<div style={itemStyles}>
<WithHelpTooltip tooltip={actionTooltips.showRelativeValues.short} onOpenDetails={onOpenDetails}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={actionTooltips.showRelativeValues.label}
checked={showRelativeValues}
onChange={() => changeSetting('showRelativeValues', !showRelativeValues)}
/>
</WithHelpTooltip>
</div>
{createSettingToggle('showYearlyValues')}
{createSettingToggle('showByYear')}
{createSettingToggle('showRelativeValues')}
{createSettingToggle('showStudentCounts')}
<div style={itemStyles}>
<Form>
<Form.Field
error={selectedDate !== null && !isValidDate(selectedDate)}
style={{ display: 'flex', alignItems: 'center' }}
>
<WithHelpTooltip tooltip={actionTooltips.showCountingFrom.short} onOpenDetails={onOpenDetails}>
<span style={{ fontSize: '0.9em' }}>{actionTooltips.showCountingFrom.label}</span>
<WithHelpTooltip
tooltip={settingDefinitions.showCountingFrom.short}
onOpenDetails={() => onOpenDetails('showCountingFrom')}
>
<span style={{ fontSize: '0.9em' }}>{settingDefinitions.showCountingFrom.label}</span>
</WithHelpTooltip>
<Datetime
className="status-date-time-input"
@@ -395,13 +410,12 @@ const StatusSettings = ({ onSettingsChange, settings, onOpenDetails }) => {
)
}
const getDefaultSettings = () => ({
showYearlyValues: true,
showByYear: false,
showRelativeValues: false,
showSettings: true,
selectedDate: null,
})
const getDefaultSettings = () =>
_.chain(settingDefinitions)
.toPairs()
.map(([key, { defaultValue }]) => [key, defaultValue])
.fromPairs()
.value()
const Status = () => {
const [explicitSettings, setSettings] = useLocalStorage('trendsStatusSettings', {})
@@ -521,6 +535,23 @@ const Status = () => {
}
const [settingsOpen, setSettingsOpen] = useState(false)
const attentionSeekers = useRef({})
const scrollToAttentionSeeker = el => {
el.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
})
}
const attentionSeekerRef = key => ref => {
if (ref !== null && attentionSeekers.current[key] === true) {
scrollToAttentionSeeker(ref)
}
attentionSeekers.current[key] = ref
}
return (
<>
@@ -546,7 +577,7 @@ const Status = () => {
<StatusSettings
settings={settings}
onSettingsChange={setSettings}
onOpenDetails={() => {
onOpenDetails={key => {
if (moreDetailsRef.current) {
moreDetailsRef.current.scrollIntoView({
block: 'start',
@@ -555,6 +586,12 @@ const Status = () => {
})
}
if (attentionSeekers.current[key]) {
scrollToAttentionSeeker(attentionSeekers.current[key])
} else {
attentionSeekers.current[key] = true
}
setUsageDetailsOpen(true)
}}
/>
@@ -579,10 +616,10 @@ const Status = () => {
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
}
</div>
{_.toPairs(actionTooltips).map(([key, { label, long }]) => (
{_.toPairs(settingDefinitions).map(([key, { label, long }]) => (
<div key={key}>
<Divider />
<div style={{ padding: '0 1em' }}>
<div style={{ padding: '0 1em' }} ref={attentionSeekerRef(key)}>
<b>
Valinta "<i>{label}</i>"
</b>
@@ -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'
@@ -18,7 +18,7 @@ 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 InfoBox from '../Info/InfoBox'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import useProgrammeFilter from '../FilterTray/filters/Programmes/useProgrammeFilter'
@@ -258,9 +258,9 @@ const CoursePopulation = ({
content: (
<div ref={studentRef}>
<PopulationStudents
variant="coursePopulation"
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
filteredStudents={studentData.students.filter(stu => selectedStudents.includes(stu.studentNumber))}
coursePopulation
language={language}
coursecode={codes}
/>
@@ -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,18 +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 { 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
@@ -22,294 +21,249 @@ boost(Highcharts)
const SINGLE_GRAPH_GOAL_SERIES_NAME = 'Goal'
class CreditAccumulationGraphHighCharts extends Component {
constructor(props) {
super(props)
this.state = {
options: [],
updateGraph: false,
}
const createGraphOptions = ({
singleStudent,
seriesData,
graphHeight,
tooltipFormatter,
onPointClicked,
startDate,
endDate,
}) => {
const tooltip = {
shared: false,
split: false,
}
componentDidMount() {
const { students, selectedStudents, currentGraphSize, render } = this.props
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
}
componentWillReceiveProps(nextProps) {
if (nextProps.students) {
const nextStudents = nextProps.students.map(student => student.studentNumber)
const oldStudents = this.props.students.map(student => student.studentNumber)
const changedStudentAmount = nextProps.selectedStudents.length !== this.props.selectedStudents.length
const changedGraphSize = nextProps.currentGraphSize !== this.props.currentGraphSize
const changed =
nextStudents.some(student => !oldStudents.includes(student)) ||
oldStudents.some(student => !nextStudents.includes(student))
if (changed || changedStudentAmount || changedGraphSize) {
this.createGraphOptions(
nextProps.students,
nextProps.selectedStudents,
nextProps.currentGraphSize,
nextProps.render
)
}
}
/**
* Trigger updating the chart after filter tray transition has finished.
* This hack remedies the problem that highcharts will not dynamically adapt to container width
* if a CSS transition is used. The resize must fire only after the transition has completed to
* ensure correct size.
*/
if (this.props.trayOpen !== nextProps.trayOpen) {
setTimeout(() => {
this.props.setChartHeight(this.props.currentGraphSize - 1)
}, 700)
}
}
componentDidUpdate() {
const { updateGraph } = this.state
const { students, selectedStudents, currentGraphSize, render } = this.props
if (updateGraph) {
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
}
}
getXAxisMonth = (date, startDate) =>
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
sortCoursesByDate = courses => courses.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
filterCoursesByDate = (courses, date) => courses.filter(c => moment(c.date).isSameOrAfter(moment(date)))
createTooltip = point => {
const { students, language } = this.props
if (point.series.name === SINGLE_GRAPH_GOAL_SERIES_NAME) {
return null
}
const targetCourse = this.sortCoursesByDate(students[0].courses).find(
c => point.key === c.course_code && point.x === new Date(c.date).getTime()
)
if (!targetCourse.course) return null
const payload = [
{
name: students[0].studentNumber,
payload: {
...targetCourse,
date: reformatDate(targetCourse.date, DISPLAY_DATE_FORMAT),
title: getTextIn(targetCourse.course.name, language),
},
if (singleStudent) {
Object.assign(tooltip, {
formatter() {
tooltipFormatter(this)
},
]
return renderToString(<CreditGraphTooltip payload={payload} />)
useHTML: true,
style: {
all: 'unset',
display: 'none',
},
})
}
createGraphOptions = (students, selectedStudents, graphSize, render) => {
const studentCreditLines = this.createStudentCreditLines(students)
const dataOfSelected = studentCreditLines.filter(line => selectedStudents.includes(line.name))
let lastCredits = null
if (this.isSingleStudentGraph() && render) {
const started = moment(students.minDate)
const lastDate = moment(students.maxDate)
const lastMonth = Math.ceil(this.getXAxisMonth(lastDate, started))
let totalAbsenceMonths = 0
const absencePoints = this.props.absences
.filter(a => a.startdate >= started || (a.startdate <= started && a.enddate >= started))
.reduce((res, { startdate, enddate }) => {
const targetCreditsBeforeAbsence =
(Math.ceil(this.getXAxisMonth(moment(startdate), started)) - totalAbsenceMonths) * (55 / 12)
if (enddate < students.maxDate) {
res.push([startdate, targetCreditsBeforeAbsence])
res.push([enddate, targetCreditsBeforeAbsence])
totalAbsenceMonths += moment(enddate).diff(moment(startdate), 'months')
}
return res
}, [])
const zoneStart = absencePoints.length ? [absencePoints[0][0]] : []
const zones = absencePoints.reduce(
(acc, [start], i) => [...acc, { value: start, color: i % 2 === 0 ? '#96d7c3' : 'red' }],
zoneStart
)
lastCredits = (lastMonth - totalAbsenceMonths) * (55 / 12)
dataOfSelected.push({
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
data: [
...[[students.minDate, 0], ...absencePoints, [students.maxDate, lastCredits]].sort((a, b) => a[0] - b[0]),
],
seriesThreshold: 150,
color: '#96d7c3',
marker: {
enabled: false,
},
zones,
zoneAxis: 'x',
})
}
const self = this
const tooltipOptions = this.isSingleStudentGraph()
? {
formatter() {
return self.createTooltip(this)
},
shared: false,
useHTML: true,
style: {
all: 'unset',
display: 'none',
},
split: false,
}
: {
shared: false,
split: false,
}
const options = {
plotOptions: {
series: {
findNearestPointBy: 'xy',
point: {
events: {
click() {
if (!self.isSingleStudentGraph()) {
self.props.history.push(`/students/${this.series.name}`)
}
},
mouseOver() {
if (this.series.halo) {
this.series.halo
.attr({
class: 'highcharts-tracker',
})
.toFront()
}
},
hover: {
halo: {
size: 9,
attributes: {
fill: Highcharts.getOptions().colors[2],
'stroke-width': 2,
stroke: Highcharts.getOptions().colors[1],
},
return {
plotOptions: {
series: {
findNearestPointBy: 'xy',
point: {
events: {
click() {
onPointClicked(this)
},
mouseOver() {
if (this.series.halo) {
this.series.halo
.attr({
class: 'highcharts-tracker',
})
.toFront()
}
},
hover: {
halo: {
size: 9,
attributes: {
fill: Highcharts.getOptions().colors[2],
'stroke-width': 2,
stroke: Highcharts.getOptions().colors[1],
},
},
},
cursor: 'pointer',
},
cursor: 'pointer',
},
},
yAxis: {
max: lastCredits && lastCredits > students.maxCredits ? lastCredits : students.maxCredits,
title: { text: 'Credits' },
},
xAxis: {
max: students.maxDate,
min: students.minDateWithCredits || students.minDate || this.props.startYear,
ordinal: false,
},
series: dataOfSelected,
chart: {
height: graphSize,
},
tooltip: {
...tooltipOptions,
},
}
this.setState({ options, updateGraph: false })
}
isSingleStudentGraph = () => !!this.props.singleStudent
createStudentCreditLines = students =>
students.map(student => {
const { started, studyrightStart } = student
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]
}
return {
name: student.studentNumber,
data: points,
seriesThreshold: 150,
marker: {
enabled: this.isSingleStudentGraph(),
radius: 4,
},
}
})
resizeChart = size => {
if (this.chart) {
this.props.setChartHeight(size)
}
}
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>
</div>
)
},
yAxis: {
title: { text: 'Credits' },
min: 0,
max: undefined,
},
xAxis: {
ordinal: false,
min: startDate,
max: endDate,
},
series: seriesData,
chart: {
height: graphHeight,
},
tooltip,
}
}
const mapStateToProps = state => ({
spinner: state.graphSpinner,
currentGraphSize: state.settings.chartHeight,
})
const sortCoursesByDate = courses =>
[...courses].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
export default connect(mapStateToProps, { clearLoading, setChartHeight })(withRouter(CreditAccumulationGraphHighCharts))
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
}
const targetCourse = sortCoursesByDate(student.courses).find(
({ course_code: courseCode, date }) => point.key === courseCode && point.x === new Date(date).getTime()
)
if (!targetCourse.course) {
return null
}
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 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)
return {
name: student.studentNumber,
data: points,
seriesThreshold: 150,
marker: {
enabled: !!singleStudent,
radius: 4,
},
}
})
const CreditAccumulationGraphHighCharts = ({ students, singleStudent, absences, startDate, endDate }) => {
const history = useHistory()
const chartRef = useRef()
const language = useLanguage()
const [graphHeight, setGraphHeight] = useState(600)
const seriesData = useMemo(() => createStudentCreditLines(students, singleStudent), [students, singleStudent])
if (singleStudent) {
const starting = students[0].studyrights
.map(sr => new Date(sr.startdate))
.sort()[0]
.getTime()
const ending = endDate ? new Date(endDate).getTime() : new Date().getTime()
seriesData.push(createGoalSeries(starting, ending, absences))
}
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>
)
}
export default CreditAccumulationGraphHighCharts
@@ -1,11 +1,11 @@
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { shape, arrayOf, string, bool } from 'prop-types'
import { useSelector } from 'react-redux'
import PopulationCourseStatsFlat from '../../PopulationCourseStats/PopulationCourseStatsFlat'
import useCourseFilter from '../../FilterTray/filters/Courses/useCourseFilter'
const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, error, showFilter }) => {
const CustomPopulationCourses = ({ selectedStudents, showFilter = false }) => {
const { setCourses, resetCourses } = useCourseFilter()
const { data: courses, pending, error } = useSelector(({ populationCourses }) => populationCourses)
useEffect(() => {
if (!pending && !error && courses.coursestatistics) {
@@ -21,33 +21,10 @@ const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, er
return (
<PopulationCourseStatsFlat
courses={courses}
query={query}
pending={pending}
selectedStudents={selectedStudents}
customPopulation
showFilter={showFilter}
/>
)
}
CustomPopulationCourses.propTypes = {
pending: bool.isRequired,
courses: shape([]).isRequired,
selectedStudents: arrayOf(string).isRequired,
query: shape({}).isRequired,
error: bool.isRequired,
showFilter: bool,
}
CustomPopulationCourses.defaultProps = {
showFilter: false,
}
const mapStateToProps = ({ populationCourses }) => ({
courses: populationCourses.data,
pending: populationCourses.pending,
query: populationCourses.query,
error: populationCourses.error,
})
export default connect(mapStateToProps)(CustomPopulationCourses)
export default CustomPopulationCourses
@@ -12,11 +12,10 @@ import PopulationStudents from '../PopulationStudents'
import CustomPopulationCourses from './CustomPopulationCourses'
import CustomPopulationProgrammeDist from './CustomPopulationProgrammeDist'
import ProgressBar from '../ProgressBar'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
import useFilterTray from '../FilterTray/useFilterTray'
import useLanguage from '../LanguagePicker/useLanguage'
import CustomPopulationSearch from './CustomPopulationSearch'
import UnihowDataExport from './UnihowDataExport'
@@ -35,7 +34,6 @@ const CustomPopulation = ({
const [, setSelectedSearchId] = useState('')
const [newestIndex, setNewest] = useState(null)
const { setAllStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const { progress } = useProgress(loading)
@@ -98,13 +96,7 @@ const CustomPopulation = ({
content: {
content: (
<div ref={creditGainRef}>
<CreditAccumulationGraphHighCharts
students={custompop}
selectedStudents={selectedStudents}
render={false}
trayOpen={trayOpen}
language={language}
/>
<CreditAccumulationGraphHighCharts students={filteredStudents} />
</div>
),
},
@@ -160,6 +152,7 @@ const CustomPopulation = ({
content: (
<div ref={studentRef}>
<PopulationStudents
variant="customPopulation"
language={language}
filteredStudents={filteredStudents}
dataExport={<UnihowDataExport />}
@@ -1,17 +1,24 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Popup, Button, Icon } from 'semantic-ui-react'
import { contextKey as filterTrayContextKey } from './index'
import useFilterTray from './useFilterTray'
import useAnalytics from './useAnalytics'
/**
* @param {func} applyFilter Function to apply the filter when toggled on
* @param {func} clearFilter Function to clear the filter when toggled off.
* @param {string} filterName Name to display in analytics.
* @param {active} bool Toggle state. Must be controlled in the parent.
* @param {funcs} popupContent Content component to show in the tooltip, if given. Tooltip is not shown if omitted.
* @param {string} filterPanelContextKey Used to open the filter's panel, if given. If not given, the filter tray will not be opened either.
*/
const ExternalFilterToggle = ({
filterPanelContextKey,
applyFilter,
clearFilter,
filterName,
active,
popupContent,
filterPanelContextKey,
}) => {
const [, setFilterTrayOpen] = useFilterTray(filterTrayContextKey)
const [, setCreditFilterOpen] = useFilterTray(filterPanelContextKey)
@@ -55,24 +62,4 @@ const ExternalFilterToggle = ({
)
}
ExternalFilterToggle.propTypes = {
/** Used to open the filter's panel, if given. If not given, the filter tray will not be opened either. */
filterPanelContextKey: PropTypes.string,
/** Function to apply the filter when toggled on. */
applyFilter: PropTypes.func.isRequired,
/** Function to clear the filter when toggled off. */
clearFilter: PropTypes.func.isRequired,
/** Name to display in analytics. */
filterName: PropTypes.string.isRequired,
/** Toggle state. Must be controlled in the parent. */
active: PropTypes.bool.isRequired,
/** Content to show in the tooltip, if given. Tooltip is not shown if omitted. */
popupContent: PropTypes.node,
}
ExternalFilterToggle.defaultProps = {
filterPanelContextKey: undefined,
popupContent: undefined,
}
export default ExternalFilterToggle
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { CourseFilterProvider } from './filters/Courses/useCourseFilter'
import { CreditFilterProvider } from './filters/CreditsEarned/useCreditFilter'
import { AgeFilterProvider } from './filters/Age/useAgeFilter'
@@ -23,8 +22,4 @@ const filterProviders = [
const FilterContextProvider = ({ children }) =>
filterProviders.reduceRight((children, Provider) => <Provider>{children}</Provider>, children)
FilterContextProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default FilterContextProvider
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react'
import useAnalytics from './useAnalytics'
import GraduatedFromProgramme from './filters/GraduatedFromProgramme'
import TransferredToProgramme from './filters/TransferredToProgramme'
import EnrollmentStatus from './filters/EnrollmentStatus'
@@ -7,69 +8,70 @@ import Gender from './filters/Gender'
import StartYearAtUni from './filters/StartYearAtUni'
import Courses from './filters/Courses'
import Programmes from './filters/Programmes'
import useAnalytics from './useAnalytics'
import Grade from './filters/Grade'
import Date from './filters/Date'
import Age from './filters/Age'
import AdmissionType from './filters/AdmissionType'
import Tags from './filters/Tags'
export const PopulationStatisticsFilters = ({ query }) => {
const Filters = ({ name, children }) => {
const analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Population Statistics')
analytics.setTarget(name)
})
return (
<>
<GraduatedFromProgramme />
<TransferredToProgramme />
<EnrollmentStatus />
<CreditsEarned />
<Age />
<Gender />
<StartYearAtUni />
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
<Courses />
<Tags />
</>
)
return React.Children.toArray(children)
}
export const CoursePopulationFilters = () => {
const analytics = useAnalytics()
export const PopulationStatisticsFilters = ({ query }) => (
<Filters name="Population Statistics">
<GraduatedFromProgramme />
<TransferredToProgramme />
<EnrollmentStatus />
<CreditsEarned />
<Age />
<Gender />
<StartYearAtUni />
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
<Courses />
<Tags />
<Date />
</Filters>
)
useEffect(() => {
analytics.setTarget('Course Population')
})
export const CoursePopulationFilters = () => (
<Filters name="Course Population">
<Grade />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Programmes />
</Filters>
)
return (
<>
<Grade />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Programmes />
</>
)
}
export const CustomPopulationFilters = () => (
<Filters name="Custom Population">
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Date />
</Filters>
)
export const CustomPopulationFilters = () => {
const analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Custom Population')
})
return (
<>
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Date />
</>
)
}
export const StudyGuidanceGroupFilters = ({ group }) => (
<Filters name="Study Guidance Group">
<EnrollmentStatus />
<Age />
<Gender />
<StartYearAtUni />
{group?.tags?.studyProgramme && group?.tags?.year >= 2020 && (
<AdmissionType overrideCode={group.tags.studyProgramme} />
)}
{group?.tags?.studyProgramme && <GraduatedFromProgramme overrideCode={group.tags.studyProgramme} />}
<Tags />
<Courses />
<Date />
</Filters>
)
@@ -1,13 +1,14 @@
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useSelector } from 'react-redux'
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 AdmissionType = ({ overrideCode = '' }) => {
const { query } = useSelector(({ populations }) => populations)
const code = overrideCode || query?.studyRights.programme || ''
const { addFilter, removeFilter, activeFilters, withoutFilter } = useFilters()
const analytics = useAnalytics()
// Using undefined as default, since students can be filtered with null value
@@ -81,12 +82,4 @@ const AdmissionType = ({ code }) => {
)
}
AdmissionType.propTypes = {
code: PropTypes.string.isRequired,
}
const mapStateToProps = ({ populations }) => ({
code: populations.query ? populations.query.studyRights.programme : '',
})
export default connect(mapStateToProps)(AdmissionType)
export default AdmissionType
@@ -76,8 +76,17 @@ const AgeFilter = () => {
const active = Object.values(names).some(name => Object.keys(activeFilters).includes(name))
const infoText = null
return (
<FilterCard title="Age" active={active} className="total-age-filter" contextKey={contextKey} name="ageFilter">
<FilterCard
title="Age"
active={active}
className="total-age-filter"
contextKey={contextKey}
name="ageFilter"
info={infoText}
>
<Form>
<div className="card-content">
{Object.keys(currentValue).map(key => (
@@ -1,5 +1,4 @@
import React, { createContext, useState, useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { getPopulationSelectedStudentCourses } from '../../../../redux/populationSelectedStudentCourses'
@@ -23,11 +22,6 @@ const CourseFilterProvider = ({ children, getPopulationSelectedStudentCourses })
return <CourseFilterContext.Provider value={[state, setState]}>{children}</CourseFilterContext.Provider>
}
CourseFilterProvider.propTypes = {
children: PropTypes.node.isRequired,
getPopulationSelectedStudentCourses: PropTypes.func.isRequired,
}
const ConnectedProvider = connect(null, { getPopulationSelectedStudentCourses })(CourseFilterProvider)
export { ConnectedProvider as CourseFilterProvider }
@@ -41,6 +41,11 @@ export default () => {
const clearButtonDisabled = startDate === label && endDate === label
const infoText = {
label: 'Selected date range only.',
short: 'Include course credits from the selected date range only. Does not filter out students.',
}
return (
<FilterCard
title="Date of Course Credits"
@@ -48,10 +53,8 @@ export default () => {
footer={<ClearFilterButton disabled={clearButtonDisabled} onClick={reset} name={name} />}
active={!clearButtonDisabled}
name={name}
info={infoText}
>
<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>
@@ -67,3 +70,6 @@ export default () => {
</FilterCard>
)
}
// <div className="description-text"></div>
// Include course credits from the selected date range only. Does not filter out students.
@@ -59,6 +59,11 @@ const EnrollmentStatus = ({ allSemesters, language }) => {
setSemesters([])
}
const infoText = {
label: 'Filter students present or absent',
short: 'Filter students based on if they are present or absent in certain semester/semesters.',
}
return (
<FilterCard
title="Enrollment Status"
@@ -66,6 +71,7 @@ const EnrollmentStatus = ({ allSemesters, language }) => {
active={active}
footer={<ClearFilterButton disabled={!status && !semesters.length} onClick={clear} name={name} />}
name={name}
info={infoText}
>
<div className="card-content">
<Form>
@@ -1,13 +1,14 @@
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Form, Radio } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { useSelector } 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 GraduatedFromProgramme = ({ overrideCode = '' }) => {
const { query } = useSelector(({ populations }) => populations)
const code = overrideCode || query?.studyRights.programme || ''
const { addFilter, removeFilter } = useFilters()
const analytics = useAnalytics()
const [value, setValue] = useState(null)
@@ -86,12 +87,4 @@ const GraduatedFromProgramme = ({ code }) => {
)
}
GraduatedFromProgramme.propTypes = {
code: PropTypes.string.isRequired,
}
const mapStateToProps = ({ populations }) => ({
code: populations.query ? populations.query.studyRights.programme : '',
})
export default connect(mapStateToProps)(GraduatedFromProgramme)
export default GraduatedFromProgramme
@@ -43,6 +43,8 @@ export default () => {
value: tag,
}))
if (Object.entries(countsByTag).length === 0) return null
return (
<FilterCard
title="Tags"
@@ -1,7 +1,6 @@
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'
@@ -38,13 +37,20 @@ export default () => {
const toggle = buttonValue => () => setValue(prev => (prev === buttonValue ? null : buttonValue))
const infoText = {
label: 'Include and exclude students from this program',
short: 'Possibility to exclude or include students who have transferred into this program',
}
const rightMargin = { margin: '0 0.5rem 0' }
return (
<FilterCard
title="Transfer Status"
contextKey={name}
active={active}
footer={<ClearFilterButton disabled={!active} onClick={() => setValue(null)} name={name} />}
className="total-transfer-filter"
name={name}
info={infoText}
>
<Form>
<div className="card-content">
@@ -55,6 +61,7 @@ export default () => {
checked={value === null}
onChange={toggle(null)}
data-cy={`${name}-all`}
style={rightMargin}
/>
<Radio
label="Transferred"
@@ -62,7 +69,7 @@ export default () => {
checked={value === 1}
onChange={toggle(1)}
data-cy={`${name}-have`}
style={{ margin: '0.5rem 0' }}
style={rightMargin}
/>
<Radio
label="Not Transferred"
@@ -70,6 +77,7 @@ export default () => {
checked={value === 0}
onChange={toggle(0)}
data-cy={`${name}-havenot`}
style={rightMargin}
/>
</Form.Field>
</div>
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Button, Icon } from 'semantic-ui-react'
const ClearFilterButton = ({ disabled, onClick, name }) => (
@@ -9,10 +8,4 @@ const ClearFilterButton = ({ disabled, onClick, name }) => (
</Button>
)
ClearFilterButton.propTypes = {
disabled: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
}
export default ClearFilterButton
@@ -1,11 +1,32 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Icon, Header } from 'semantic-ui-react'
import WithHelpTooltip from '../../../Info/InfoWithHelpTooltip'
import useFilterTray from '../../useFilterTray'
const FilterCard = ({ title, children, contextKey, name, active }) => {
const FilterCard = ({ title, children, contextKey, name, active, info }) => {
const [open, , toggleOpen] = useFilterTray(contextKey)
const renderWithHelp = info => {
if (info) {
return (
<WithHelpTooltip tooltip={info} data-cy="tooltip-div">
<Icon name={open ? 'caret down' : 'caret right'} style={{ visibility: active ? 'hidden' : 'visible' }} />
<Header size="tiny" style={{ marginTop: '0', marginBottom: 0 }}>
{title}
</Header>
</WithHelpTooltip>
)
}
return (
<>
<Icon name={open ? 'caret down' : 'caret right'} style={{ visibility: active ? 'hidden' : 'visible' }} />
<Header size="tiny" style={{ marginTop: '0', marginBottom: 0 }}>
{title}
</Header>
</>
)
}
return (
<div>
<div
@@ -13,22 +34,11 @@ const FilterCard = ({ title, children, contextKey, name, active }) => {
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>
{renderWithHelp(info)}
</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,8 +1,16 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Input, Label, Popup, Button, Icon } from 'semantic-ui-react'
const NumericInput = ({ onChange, onKeyDown, onClear, value, label, clearButtonDisabled, className, name }) => (
const NumericInput = ({
onChange,
onKeyDown = () => {},
onClear,
value,
label,
clearButtonDisabled,
className,
name,
}) => (
<Input
labelPosition="left"
size="mini"
@@ -30,20 +38,4 @@ const NumericInput = ({ onChange, onKeyDown, onClear, value, label, clearButtonD
</Input>
)
NumericInput.propTypes = {
onChange: PropTypes.func.isRequired,
onKeyDown: PropTypes.func,
onClear: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
label: PropTypes.node.isRequired,
clearButtonDisabled: PropTypes.bool.isRequired,
className: PropTypes.string,
name: PropTypes.string.isRequired,
}
NumericInput.defaultProps = {
onKeyDown: () => {},
className: null,
}
export default NumericInput
@@ -1,12 +1,11 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Segment, Header } from 'semantic-ui-react'
import './filterTray.css'
import useFilters from './useFilters'
export const contextKey = 'filterTray'
const FilterTray = ({ children, filterSet, visible }) => {
const FilterTray = ({ children, filterSet, visible = true }) => {
const { filteredStudents, allStudents } = useFilters()
if (!visible || !allStudents.length) {
@@ -16,8 +15,8 @@ const FilterTray = ({ children, filterSet, visible }) => {
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' }}>
<Segment style={{ marginLeft: '0.5rem', width: '16rem', position: 'sticky', top: '10px' }}>
<Header size="small" style={{ textAlign: 'center' }} data-cy="filtered-students">
Filter students
</Header>
<div style={{ textAlign: 'center' }}>
@@ -34,14 +33,4 @@ const FilterTray = ({ children, filterSet, visible }) => {
)
}
FilterTray.propTypes = {
children: PropTypes.node.isRequired,
filterSet: PropTypes.node.isRequired,
visible: PropTypes.bool,
}
FilterTray.defaultProps = {
visible: true,
}
export default FilterTray
@@ -1,5 +1,4 @@
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
import TSA from '../../common/tsa'
const FilterAnalyticsContext = createContext([[], () => {}])
@@ -10,10 +9,6 @@ export const FilterAnalyticsProvider = ({ children }) => {
return <FilterAnalyticsContext.Provider value={[state, setState]}>{children}</FilterAnalyticsContext.Provider>
}
FilterAnalyticsProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default () => {
const [state, setState] = useContext(FilterAnalyticsContext)
const category = `Filters (${state})`
@@ -2,7 +2,6 @@
* Context for those expandable filter panels that need to be opened/closed remotely.
*/
import React, { createContext, useState, useContext } from 'react'
import PropTypes from 'prop-types'
const FilterTrayContext = createContext([[], () => {}])
FilterTrayContext.displayName = 'Filter Tray'
@@ -13,10 +12,6 @@ export const FilterTrayProvider = ({ children }) => {
return <FilterTrayContext.Provider value={[state, setState]}>{children}</FilterTrayContext.Provider>
}
FilterTrayProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default key => {
const [state, setState] = useContext(FilterTrayContext)
@@ -2,7 +2,6 @@
* Context for managing filtering.
*/
import React, { createContext, useState, useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import lodash from 'lodash'
const defaultState = {
@@ -37,10 +36,6 @@ export const FilterProvider = ({ children }) => {
return <FilterContext.Provider value={[state, setState]}>{children}</FilterContext.Provider>
}
FilterProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export default () => {
const [state, setState] = useContext(FilterContext)
+10 -32
View File
@@ -1,9 +1,7 @@
import React, { useEffect, useState } from 'react'
import { Container, Header, Image, Divider, List } from 'semantic-ui-react'
import moment from 'moment'
import { connect } from 'react-redux'
import propTypes from 'prop-types'
import { isEqual } from 'lodash'
import { useSelector } from 'react-redux'
import { images, getUserRoles, checkUserAccess } from '../common'
import { useTitle } from '../common/hooks'
import { builtAt } from '../conf'
@@ -49,16 +47,17 @@ const OodiToOodikone = () => {
)
}
const FrontPage = props => {
const { userRoles, rights } = props
const FrontPage = () => {
const { roles, rights } = useSelector(state => state.auth.token)
const userRoles = getUserRoles(roles)
useTitle()
const showItems = {
populations: userRoles.includes('admin') || rights.length !== 0,
studyProgramme: userRoles.includes('admin') || rights.length !== 0,
students: userRoles.includes('admin') || rights.length !== 0,
populations: userRoles.includes('admin') || rights.length > 0,
studyProgramme: userRoles.includes('admin') || rights.length > 0,
students: checkUserAccess(['studyGuidanceGroups', 'admin'], userRoles) || rights.length > 0,
courseStatistics: checkUserAccess(['courseStatistics', 'admin'], userRoles) || rights.length > 0,
teachers: userRoles.includes('admin') || userRoles.includes('teachers'),
teachers: checkUserAccess(['teachers', 'admin'], userRoles),
trends: true,
feedback: true,
}
@@ -122,25 +121,4 @@ const FrontPage = props => {
)
}
FrontPage.propTypes = {
userRoles: propTypes.arrayOf(propTypes.string),
rights: propTypes.arrayOf(propTypes.string),
}
FrontPage.defaultProps = {
userRoles: [],
rights: [],
}
const mapStateToProps = ({
auth: {
token: { roles, rights },
},
}) => ({
userRoles: getUserRoles(roles),
rights,
})
export default connect(mapStateToProps, null, null, {
areStatePropsEqual: isEqual,
})(FrontPage)
export default FrontPage
@@ -0,0 +1,60 @@
import React, { useState, useRef } from 'react'
import { Popup, Icon } from 'semantic-ui-react'
import _ from 'lodash'
const InfoWithHelpTooltip = ({ children, tooltip, ...rest }) => {
const popupContext = useRef()
const [popupOpen, setPopupOpen] = useState(false)
const [detailsOpen, setDetailsOPen] = useState(false)
const trigger = (
<div style={{ display: 'flex' }} data-cy="tooltip-trigger">
{children}
<div ref={popupContext} style={{ display: 'inline-block', cursor: 'help' }} data-cy="popup-context">
<Icon
onClick={() => setPopupOpen(!popupOpen)}
ref={popupContext}
style={{ marginLeft: '0.5em', color: '#888' }}
name="question circle outline"
/>
</div>
</div>
)
const popupProps = _.defaults(rest, {
position: 'right center',
})
return (
<>
<Popup
wide={detailsOpen}
hoverable
size="tiny"
open={popupOpen}
onOpen={() => setPopupOpen(true)}
onClose={() => {
setPopupOpen(false)
setDetailsOPen(false)
}}
trigger={trigger}
context={popupContext}
{...popupProps}
on="hover"
mouseEnterDelay={2000}
>
{!detailsOpen && (
<>
<div>{tooltip.label}</div>
<span style={{ color: '#2185d0', cursor: 'pointer' }} onClick={() => setDetailsOPen(!detailsOpen)}>
Lue lisää...
</span>
</>
)}
{detailsOpen && <span>{tooltip.short}</span>}
</Popup>
</>
)
}
export default InfoWithHelpTooltip
@@ -42,7 +42,7 @@ const NavigationBar = props => {
const visibleNavigationItems = {}
Object.keys(allNavigationItems).forEach(key => {
if (key === 'populations' || key === 'students') {
if (!userRoles.includes('admin') && rights.length === 0) {
if (!checkUserAccess(['admin', 'studyGuidanceGroups'], userRoles) && rights.length === 0) {
return
}
} else if (key === 'courseStatistics') {
@@ -1,9 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import { connect } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'
import { Table, Form, Input, Tab, Icon } from 'semantic-ui-react'
import { func, arrayOf, object, shape, string, bool } from 'prop-types'
import { orderBy, debounce } from 'lodash'
import { withRouter } from 'react-router-dom'
import { clearCourseStats } from '../../redux/coursestats'
import './populationCourseStats.css'
import { PopulationCourseContext } from './PopulationCourseContext'
@@ -43,7 +41,7 @@ const lodashSortOrderTypes = {
DESC: 'desc',
}
function updateCourseStatisticsCriteria(props, language, state) {
const updateCourseStatisticsCriteria = (props, language, state) => {
const { studentAmountLimit, sortCriteria, codeFilter, nameFilter, reversed } = state
const {
courses: { coursestatistics },
@@ -90,8 +88,21 @@ const initialState = props => ({
selectedStudentsLength: props.selectedStudentsLength || 0,
})
function PopulationCourseStats(props) {
const PopulationCourseStats = ({ courses, pending, selectedStudents, showFilter = true }) => {
const dispatch = useDispatch()
const { years } = useSelector(({ semesters }) => semesters.data)
const populationCourses = useSelector(({ populationCourses }) => populationCourses)
const isAdmin = getUserIsAdmin(useSelector(({ auth }) => auth.token.roles))
const { language } = useLanguage()
const props = {
courses,
isAdmin,
pending,
populationCourses,
selectedStudents,
showFilter,
years,
}
const [filterFields, setFilterFields] = useState({ codeFilter: '', nameFilter: '' })
const [courseStatistics, setCourseStatistics] = useState(
@@ -105,7 +116,7 @@ function PopulationCourseStats(props) {
const filterAnalytics = useAnalytics()
useEffect(() => {
if (state && props.courses) {
if (!pending && state && props.courses) {
const studentAmountLimit =
state.selectedStudentsLength !== props.selectedStudents.length
? Math.round(props.selectedStudents.length * 0.3)
@@ -119,44 +130,46 @@ function PopulationCourseStats(props) {
})
setCourseStatistics(updateCourseStatisticsCriteria(props, language, state))
}
}, [props.courses, props.selectedStudents])
}, [props.courses, props.selectedStudents, pending])
useEffect(() => {
const { studentAmountLimit, codeFilter, nameFilter, reversed, sortCriteria } = state
const {
courses: { coursestatistics },
language,
} = props
const studentAmountFilter = ({ stats }) => {
const { students } = stats
return studentAmountLimit === 0 || students >= studentAmountLimit
if (!pending) {
const { studentAmountLimit, codeFilter, nameFilter, reversed, sortCriteria } = state
const {
courses: { coursestatistics },
language,
} = props
const studentAmountFilter = ({ stats }) => {
const { students } = stats
return studentAmountLimit === 0 || students >= studentAmountLimit
}
const courseCodeFilter = ({ course }) => {
const { code } = course
return code.toLowerCase().includes(codeFilter.toLowerCase())
}
const courseNameFilter = ({ course }) => {
const { name } = course
return getTextIn(name, language).toLowerCase().includes(nameFilter.toLowerCase())
}
const filteredCourses =
coursestatistics &&
coursestatistics
.filter(studentAmountFilter)
.filter(c => !codeFilter || courseCodeFilter(c))
.filter(c => !nameFilter || courseNameFilter(c))
const lodashSortOrder = reversed ? lodashSortOrderTypes.DESC : lodashSortOrderTypes.ASC
const sortedStatistics = orderBy(
filteredCourses,
[course => course.stats[sortCriteria], course => course.course.code],
[lodashSortOrder, lodashSortOrderTypes.ASC]
)
setCourseStatistics(sortedStatistics)
}
const courseCodeFilter = ({ course }) => {
const { code } = course
return code.toLowerCase().includes(codeFilter.toLowerCase())
}
const courseNameFilter = ({ course }) => {
const { name } = course
return getTextIn(name, language).toLowerCase().includes(nameFilter.toLowerCase())
}
const filteredCourses =
coursestatistics &&
coursestatistics
.filter(studentAmountFilter)
.filter(c => !codeFilter || courseCodeFilter(c))
.filter(c => !nameFilter || courseNameFilter(c))
const lodashSortOrder = reversed ? lodashSortOrderTypes.DESC : lodashSortOrderTypes.ASC
const sortedStatistics = orderBy(
filteredCourses,
[course => course.stats[sortCriteria], course => course.course.code],
[lodashSortOrder, lodashSortOrderTypes.ASC]
)
setCourseStatistics(sortedStatistics)
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter])
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter, pending])
const onFilterChange = (e, field) => {
const {
@@ -174,8 +187,8 @@ function PopulationCourseStats(props) {
)
useEffect(() => {
setFilters(filterFields)
}, [filterFields])
if (!pending) setFilters(filterFields)
}, [filterFields, pending])
const handleCourseStatisticsCriteriaChange = () => {
// eslint-disable-next-line react/no-access-state-in-setstate
@@ -232,13 +245,14 @@ function PopulationCourseStats(props) {
}
const onGoToCourseStatisticsClick = courseCode => {
const { clearCourseStats: clearCourseStatsfn } = props
sendAnalytics('Courses of Population course stats button clicked', courseCode)
clearCourseStatsfn()
dispatch(clearCourseStats())
}
const onCourseNameCellClick = code => {
const courseStatistic = props.populationCourses.data.coursestatistics.find(cs => cs.course.code === code)
const courseStatistic =
props.populationCourses.data.coursestatistics?.find(cs => cs.course.code === code) ||
props.courses.coursestatistics?.find(cs => cs.course.code === code)
if (courseStatistic) {
const isSelected = courseIsSelected(code)
const name = 'Course Filtername'
@@ -280,7 +294,6 @@ function PopulationCourseStats(props) {
)
}
const { courses, pending } = props
const { sortCriteria, reversed } = state
const contextValue = {
courseStatistics,
@@ -324,8 +337,6 @@ function PopulationCourseStats(props) {
return null
}
const { showFilter = true } = props
return (
<div>
{showFilter && (
@@ -343,37 +354,4 @@ function PopulationCourseStats(props) {
)
}
PopulationCourseStats.propTypes = {
courses: shape({
coursestatistics: arrayOf(object),
disciplines: shape({}),
}).isRequired,
populationCourses: shape({
data: shape({ coursestatistics: arrayOf(shape({ course: shape({ code: string, name: shape({}) }) })) }),
}).isRequired,
clearCourseStats: func.isRequired,
pending: bool.isRequired,
selectedStudents: arrayOf(string).isRequired,
years: shape({}), // eslint-disable-line
showFilter: bool,
language: string.isRequired,
}
PopulationCourseStats.defaultProps = {
showFilter: false,
}
const mapStateToProps = state => {
const { years } = state.semesters.data
const isAdmin = getUserIsAdmin(state.auth.token.roles)
return {
years,
populationCourses: state.populationCourses,
isAdmin,
}
}
export default connect(mapStateToProps, {
clearCourseStats,
})(withRouter(PopulationCourseStats))
export default PopulationCourseStats
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import { connect, useSelector } from 'react-redux'
import { Table, Input, Tab, Icon } from 'semantic-ui-react'
import { func, arrayOf, object, shape, string, bool } from 'prop-types'
import { orderBy, debounce } from 'lodash'
import { withRouter } from 'react-router-dom'
import { clearCourseStats } from '../../redux/coursestats'
@@ -92,7 +91,7 @@ const initialState = props => ({
selectedStudentsLength: props.selectedStudentsLength || 0,
})
function PopulationCourseStats(props) {
const PopulationCourseStats = props => {
const { language } = useLanguage()
const [filterFields, setFilterFields] = useState({ codeFilter: '', nameFilter: '' })
@@ -268,7 +267,9 @@ function PopulationCourseStats(props) {
}
const onCourseNameCellClick = code => {
const courseStatistic = props.populationCourses.data.coursestatistics.find(cs => cs.course.code === code)
const courseStatistic =
props.populationCourses.data.coursestatistics?.find(cs => cs.course.code === code) ||
props.courses.coursestatistics?.find(cs => cs.course.code === code)
if (courseStatistic) {
const isSelected = courseIsSelected(code)
const name = 'Course Filtername'
@@ -397,19 +398,6 @@ function PopulationCourseStats(props) {
)
}
PopulationCourseStats.propTypes = {
courses: shape({
coursestatistics: arrayOf(object),
}).isRequired,
populationCourses: shape({
data: shape({ coursestatistics: arrayOf(shape({ course: shape({ code: string, name: shape({}) }) })) }),
}).isRequired,
clearCourseStats: func.isRequired,
pending: bool.isRequired,
selectedStudents: arrayOf(string).isRequired,
language: string.isRequired,
}
const mapStateToProps = ({ populationCourses }) => ({
populationCourses,
})
@@ -1,25 +1,22 @@
import React, { useEffect, useState } from 'react'
import { connect } from 'react-redux'
import { shape, arrayOf, string, bool } from 'prop-types'
import { useSelector } from 'react-redux'
import { Segment, Button } from 'semantic-ui-react'
import uuidv4 from 'uuid/v4'
import SegmentDimmer from '../SegmentDimmer'
import PopulationCourseStats from '../PopulationCourseStats'
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import FilterDegreeCoursesModal from './FilterDegreeCoursesModal'
import useCourseFilter from '../FilterTray/filters/Courses/useCourseFilter'
import infotooltips from '../../common/InfoToolTips'
const PopulationCourses = ({
populationSelectedStudentCourses,
populationCourses,
selectedStudents,
query,
filteredStudents,
}) => {
const PopulationCourses = ({ selectedStudents, query = {}, filteredStudents }) => {
const { setCoursesOnce, resetCourses, runCourseQuery } = useCourseFilter()
const [showByStudytrack, setShowByStudytrack] = useState(true)
const populationCourses = useSelector(({ populationCourses }) => populationCourses)
const populationSelectedStudentCourses = useSelector(
({ populationSelectedStudentCourses }) => populationSelectedStudentCourses
)
const selectedPopulationCourses = populationSelectedStudentCourses.data
? populationSelectedStudentCourses
@@ -89,7 +86,6 @@ const PopulationCourses = ({
<PopulationCourseStats
key={selectedPopulationCourses.query.uuid}
courses={selectedPopulationCourses.data}
query={selectedPopulationCourses.query}
pending={pending}
selectedStudents={selectedStudents}
/>
@@ -100,21 +96,4 @@ const PopulationCourses = ({
)
}
PopulationCourses.defaultPropTypes = {
query: {},
}
PopulationCourses.propTypes = {
populationSelectedStudentCourses: shape({ query: shape({}), data: shape({}), pending: bool }).isRequired,
populationCourses: shape({ query: shape({}), data: shape({}), pending: bool }).isRequired,
selectedStudents: arrayOf(string).isRequired,
query: shape({}).isRequired,
filteredStudents: arrayOf(shape({})).isRequired,
}
const mapStateToProps = ({ populationSelectedStudentCourses, populationCourses }) => ({
populationCourses,
populationSelectedStudentCourses,
})
export default connect(mapStateToProps)(PopulationCourses)
export default PopulationCourses
@@ -1,7 +1,7 @@
import React, { useCallback } from 'react'
import { Tab } from 'semantic-ui-react'
import { useTabChangeAnalytics } from '../../../common/hooks'
import InfoBox from '../../InfoBox'
import InfoBox from '../../Info/InfoBox'
import StatisticsTab from './StatisticsTab'
import CreditsGainedTab from './CreditsGainedTab'
import infotooltips from '../../../common/InfoToolTips'
@@ -7,7 +7,7 @@ import { useLocalStorage } from '../../common/hooks'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
import PopulationStudents from '../PopulationStudents'
import PopulationCourses from '../PopulationCourses'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import CreditGainStats from './CreditGainStats'
import AgeStats from './AgeStats'
import useFilters from '../FilterTray/useFilters'
@@ -18,7 +18,7 @@ import infotooltips from '../../common/InfoToolTips'
const sendAnalytics = sendEvent.populationStatistics
const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStudentsByYear, dataExport }) => {
const PopulationDetails = ({ samples, queryIsSet, isLoading, query, dataExport }) => {
const { allStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray('filterTray')
const { language } = useLanguage()
@@ -58,11 +58,9 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
const graphs = (
<CreditAccumulationGraphHighCharts
students={samples}
students={filteredStudents}
title="Id"
label={samples.label}
maxCredits={samples.maxCredits}
selectedStudents={selectedStudents}
trayOpen={trayOpen}
language={language}
/>
@@ -102,7 +100,10 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
key: 1,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
<span
style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}
data-cy="credit-statistics"
>
Credit statistics
</span>
),
@@ -147,7 +148,6 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
<div ref={courseTableRef}>
<PopulationCourses
selectedStudents={selectedStudents}
selectedStudentsByYear={selectedStudentsByYear}
query={query}
allStudents={allStudents}
filteredStudents={filteredStudents}
@@ -169,7 +169,12 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
content: {
content: (
<div ref={studentTableRef}>
<PopulationStudents language={language} filteredStudents={filteredStudents} dataExport={dataExport} />
<PopulationStudents
variant="population"
language={language}
filteredStudents={filteredStudents}
dataExport={dataExport}
/>
</div>
),
},
@@ -187,7 +192,6 @@ PopulationDetails.propTypes = {
samples: arrayOf(object).isRequired,
queryIsSet: bool.isRequired,
isLoading: bool.isRequired,
selectedStudentsByYear: shape({}).isRequired,
query: shape({}).isRequired,
dataExport: node,
}
@@ -12,7 +12,7 @@ import TSA from '../../common/tsa'
import './populationSearch.css'
import infotooltips from '../../common/InfoToolTips'
import { getTextIn } from '../../common'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import FilterActiveNote from './FilterActiveNote'
const PopulationsQueryTSA = ({ programmeCode, unitData }) => {
@@ -5,7 +5,7 @@ import { Link } from 'react-router-dom'
import PopulationSearchForm from './PopulationSearchForm'
import PopulationSearchHistory from './PopulationSearchHistory'
import ProgressBar from '../ProgressBar'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import { useProgress } from '../../common/hooks'
import infotoolTips from '../../common/InfoToolTips'
@@ -1,9 +1,9 @@
import React, { memo, useEffect } from 'react'
import { connect } from 'react-redux'
import React, { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { createSelector } from '@reduxjs/toolkit'
import { useLocation, useHistory } from 'react-router-dom'
import { Header, Segment } from 'semantic-ui-react'
import { flattenDeep } from 'lodash'
import PopulationDetails from '../PopulationDetails'
import { getTotalCreditsFromCourses } from '../../common'
import { useTitle } from '../../common/hooks'
import selectors from '../../selectors/populationDetails'
import FilterTray from '../FilterTray'
@@ -12,9 +12,23 @@ import { PopulationStatisticsFilters } from '../FilterTray/FilterSets'
import PopulationSearch from '../PopulationSearch'
import DataExport from './DataExport'
const PopulationStatistics = memo(props => {
const { queryIsSet, selectedStudentsByYear, query, samples, location, history, isLoading, students } = props
const selectPopulations = createSelector(
({ populations }) => populations,
populations => ({
query: populations.query ?? {},
queryIsSet: !!populations.query,
isLoading: populations.pending === true,
students: populations.data.students ?? [],
})
)
const PopulationStatistics = () => {
const location = useLocation()
const history = useHistory()
const { samples } = useSelector(selectors.makePopulationsToData)
const { query, queryIsSet, isLoading, students } = useSelector(selectPopulations)
const { setAllStudents } = useFilters()
useTitle('Population statistics')
// Pass students to filter context.
@@ -33,7 +47,6 @@ const PopulationStatistics = memo(props => {
{location.search !== '' ? (
<PopulationDetails
queryIsSet={queryIsSet}
selectedStudentsByYear={selectedStudentsByYear}
query={query}
samples={samples}
isLoading={isLoading}
@@ -44,41 +57,6 @@ const PopulationStatistics = memo(props => {
</div>
</FilterTray>
)
})
const mapStateToProps = state => {
// haha copied from other place :mintu:
const { samples, selectedStudentsByYear } = selectors.makePopulationsToData(state)
// REFACTOR YES, IF YOU SEE THIS COMMENT YOU ARE OBLIGATED TO FIX IT
if (samples.length > 0) {
const creditsAndDates = samples.map(s => {
const passedCourses = s.courses.filter(c => c.passed)
const passedCredits = getTotalCreditsFromCourses(passedCourses)
const dates = passedCourses.map(c => c.date)
const datesWithCredits = passedCourses.filter(c => c.credits > 0).map(c => c.date)
return { passedCredits, dates, datesWithCredits }
})
const credits = creditsAndDates.map(cd => cd.passedCredits)
const dates = flattenDeep(creditsAndDates.map(cd => cd.dates)).map(date => new Date(date).getTime())
const datesWithCredits = flattenDeep(creditsAndDates.map(cd => cd.datesWithCredits)).map(date =>
new Date(date).getTime()
)
samples.maxCredits = Math.max(...credits)
samples.maxDate = Math.max(...dates)
samples.minDate = Math.min(...dates)
samples.minDateWithCredits = Math.min(...datesWithCredits)
}
const { populations } = state
return {
query: populations.query ? populations.query : {},
queryIsSet: !!populations.query,
selectedStudentsByYear,
samples,
isLoading: populations.pending === true,
students: populations.data.students || [],
}
}
export default connect(mapStateToProps)(PopulationStatistics)
export default PopulationStatistics
@@ -1,128 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Table } from 'semantic-ui-react'
import { shape, arrayOf, string, func, bool, element, oneOfType } from 'prop-types'
import { sortBy } from 'lodash'
import { useChunk } from '../../common/hooks'
import './populationStudents.css'
const verticalTitle = title => {
// https://stackoverflow.com/a/41396815
return <div className="tableVerticalTitle">{title}</div>
}
const DIRECTIONS = {
ASC: 'ascending',
DESC: 'descending',
}
const FlippedCourseTable = ({
defaultdescending,
defaultsortkey,
columns,
data,
tableProps,
getRowKey,
chunkifyBy,
}) => {
const [direction, setDirection] = useState(defaultdescending ? DIRECTIONS.DESC : DIRECTIONS.ASC)
const [selected, setSelected] = useState(defaultsortkey == null ? columns[0].key : defaultsortkey)
const [sortedRows, setSortedRows] = useState(data)
const chunkedData = useChunk(data, chunkifyBy)
const handleSort = column => {
if (selected === column) {
setDirection(direction === DIRECTIONS.ASC ? DIRECTIONS.DESC : DIRECTIONS.ASC)
} else {
setSelected(column)
setDirection(DIRECTIONS.DESC)
}
}
useEffect(() => {
const column = columns.find(c => c.key === selected)
if (!column) {
setSortedRows(chunkedData)
return
}
const { getRowVal } = column
const sorted = sortBy(chunkedData, [getRowVal])
setSortedRows(direction === DIRECTIONS.ASC ? sorted : sorted.reverse())
}, [selected])
return (
<Table sortable {...tableProps} className="fixed-header" striped>
<Table.Header>
<Table.Row>
<Table.HeaderCell key="general" content={<b>Students:</b>} colSpan="2" style={{ textAlign: 'right' }} />
{sortedRows
.filter(row => row.studentNumber)
.map(row => (
<Table.HeaderCell key={getRowKey(row)} content={verticalTitle(row.studentNumber)} />
))}
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Course</Table.Cell>
<Table.Cell>Total</Table.Cell>
{sortedRows
.filter(row => row.studentNumber)
.map(row => (
<Table.Cell key={getRowKey(row)} content={columns[2].getRowVal(row)} />
))}
</Table.Row>
{columns
.filter(col => col.cellProps)
.filter(col => data[0][col.code] && data[0][col.code] > 0)
.map(col => (
<Table.Row key={col.key}>
<Table.Cell key="name" content={col.title} onClick={() => handleSort(col.key)} />
<Table.Cell key="totals" content={data[0][col.code]} />
{sortedRows
.filter(row => row.studentNumber)
.map(row => (
<Table.Cell key={getRowKey(row)} content={col.getRowContent(row)} />
))}
</Table.Row>
))}
</Table.Body>
</Table>
)
}
FlippedCourseTable.propTypes = {
tableProps: shape({}),
getRowKey: func.isRequired,
getRowProps: func,
columns: arrayOf(
shape({
key: string.isRequired,
title: oneOfType([element, string]),
headerProps: shape({}),
getRowVal: func,
getRowContent: func,
getCellProps: func,
cellProps: shape({}),
group: bool,
children: arrayOf(),
})
).isRequired,
data: arrayOf(shape({})).isRequired,
defaultdescending: bool,
defaultsortkey: string,
collapsingHeaders: bool,
showNames: bool,
chunkifyBy: string,
}
FlippedCourseTable.defaultProps = {
tableProps: undefined,
getRowProps: undefined,
defaultdescending: false,
defaultsortkey: null,
collapsingHeaders: false,
showNames: undefined,
chunkifyBy: undefined,
}
export default FlippedCourseTable
@@ -1,207 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Table } from 'semantic-ui-react'
import { shape, arrayOf, string, func, bool, element, oneOfType } from 'prop-types'
import { sortBy } from 'lodash'
import { useChunk } from '../../common/hooks'
import './populationStudents.css'
const verticalTitle = title => {
// https://stackoverflow.com/a/41396815
return <div className="tableVerticalTitle">{title}</div>
}
const intoCollapsing = column => ({
title: verticalTitle(column.headerProps.title),
headerProps: { ...column.headerProps, colSpan: 1, rowSpan: 2 },
key: column.key,
collapsed: true,
parent: column.parent,
})
const initialCollapsing = columns => {
const coll = {}
columns.forEach(column => {
if (!column.parent || column.key === 'general') return
coll[column.headerProps.title] = intoCollapsing(column)
})
return coll
}
const DIRECTIONS = {
ASC: 'ascending',
DESC: 'descending',
}
const PopulationCourseTable = ({
defaultdescending,
defaultsortkey,
columns,
data,
tableProps,
getRowProps,
getRowKey,
collapsingHeaders,
chunkifyBy,
}) => {
const [direction, setDirection] = useState(defaultdescending ? DIRECTIONS.DESC : DIRECTIONS.ASC)
const [selected, setSelected] = useState(defaultsortkey == null ? columns[0].key : defaultsortkey)
const [collapsed, setCollapsed] = useState(initialCollapsing(columns))
const [columnsWithCollapsedHeaders, setColumnsWithCollapsedHeaders] = useState(columns)
const [sortedRows, setSortedRows] = useState(data)
const chunkedData = useChunk(data, chunkifyBy)
const handleSort = column => () => {
if (selected === column) {
setDirection(direction === DIRECTIONS.ASC ? DIRECTIONS.DESC : DIRECTIONS.ASC)
} else {
setSelected(column)
setDirection(DIRECTIONS.DESC)
}
}
const handleCollapse = column => () => {
const { title } = column.headerProps
if (collapsed[title]) {
const { [title]: _, ...rest } = collapsed
setCollapsed(rest)
} else {
setCollapsed({ ...collapsed, [title]: column })
}
}
useEffect(() => {
const column = columns.find(c => c.key === selected)
if (!column) {
return chunkedData
}
const { getRowVal } = column
const sorted = sortBy(chunkedData, [getRowVal])
return setSortedRows(direction === DIRECTIONS.ASC ? sorted : sorted.reverse())
}, [columns, columnsWithCollapsedHeaders])
useEffect(() => {
setColumnsWithCollapsedHeaders(
collapsingHeaders
? columns
.filter(c => c.headerProps && !collapsed[c.headerProps.title] && !c.collapsed)
.sort((a, b) => a.headerProps.ordernumber - b.headerProps.ordernumber)
.concat(Object.values(collapsed).sort((a, b) => a.headerProps.ordernumber - b.headerProps.ordernumber))
: columns
)
}, [collapsed])
const sortDirection = name => (selected === name ? direction : null)
return (
<Table sortable {...tableProps} className="fixed-header" striped>
<Table.Header>
{columnsWithCollapsedHeaders.length > 0 && (
<Table.Row>
{columnsWithCollapsedHeaders
.filter(c => !c.child && !(c.title == null))
.map(c => (
<Table.HeaderCell
key={c.key}
content={c.title}
onClick={
c.parent && collapsingHeaders && c.key !== 'general'
? handleCollapse(intoCollapsing(c))
: handleSort(c.key)
}
sorted={c.parent ? undefined : sortDirection(c.key)}
{...c.headerProps}
/>
))}
</Table.Row>
)}
<Table.Row>
{columns
.filter(c => c.child && !(c.title == null) && !collapsed[c.childOf])
.map(c => (
<Table.HeaderCell
key={c.key}
content={c.title}
onClick={handleSort(c.key)}
sorted={sortDirection(c.key)}
{...c.headerProps}
/>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{sortedRows.map(row => (
<Table.Row key={getRowKey(row)} {...(getRowProps && getRowProps(row))}>
{columns
.filter(c => !c.parent)
.map(c => {
if (collapsed[c.childOf]) {
return null
}
if (c.key.includes('programmecode') || c.key.includes('programmename')) {
return (
<Table.Cell
width={c.key === 'programmecode' ? '1' : '16'}
key={c.key}
content={c.getRowContent ? c.getRowContent(row) : c.getRowVal(row)}
{...c.cellProps}
{...(c.getCellProps && c.getCellProps(row))}
/>
)
}
return (
<Table.Cell
key={c.key}
content={c.getRowContent ? c.getRowContent(row) : c.getRowVal(row)}
{...c.cellProps}
{...(c.getCellProps && c.getCellProps(row))}
/>
)
})}
{Object.values(collapsed).map(e => (
<Table.Cell className="tableCell" key={e.key} warning />
))}
</Table.Row>
))}
</Table.Body>
</Table>
)
}
PopulationCourseTable.propTypes = {
tableProps: shape({}),
getRowKey: func.isRequired,
getRowProps: func,
columns: arrayOf(
shape({
key: string.isRequired,
title: oneOfType([element, string]),
headerProps: shape({}),
getRowVal: func,
getRowContent: func,
getCellProps: func,
cellProps: shape({}),
group: bool,
children: arrayOf(),
})
).isRequired,
data: arrayOf(shape({})).isRequired,
defaultdescending: bool,
defaultsortkey: string,
collapsingHeaders: bool,
showNames: bool,
chunkifyBy: string,
}
PopulationCourseTable.defaultProps = {
tableProps: undefined,
getRowProps: undefined,
defaultdescending: false,
defaultsortkey: null,
collapsingHeaders: false,
showNames: undefined,
chunkifyBy: undefined,
}
export default PopulationCourseTable
@@ -1,52 +1,33 @@
import React, { useState } from 'react'
import { shape, bool, string, arrayOf } from 'prop-types'
import { Item, Icon, Popup } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { flatten } from 'lodash'
import _ from 'lodash'
import moment from 'moment'
import { connect } from 'react-redux'
import SortableTable from '../../SortableTable'
import {
getStudentTotalCredits,
getTextIn,
getNewestProgramme,
reformatDate,
copyToClipboard,
getUserIsAdmin,
} from '../../../common'
import { useSelector } from 'react-redux'
import { useIsAdmin } from 'common/hooks'
import SortableTable from 'components/SortableTable'
import { getStudentTotalCredits, getTextIn, getNewestProgramme, reformatDate, copyToClipboard } from 'common'
import { useGetStudyGuidanceGroupPopulationQuery } from 'redux/studyGuidanceGroups'
import { PRIORITYCODE_TEXTS } from '../../../constants'
import sendEvent from '../../../common/sendEvent'
import useFilters from '../../FilterTray/useFilters'
import useLanguage from '../../LanguagePicker/useLanguage'
// TODO: Refactoring in process, contains lot of duplicate code.
const GeneralTab = ({
showNames,
coursePopulation,
customPopulation,
populationStatistics,
queryStudyrights,
isAdmin,
studentToTargetCourseDateMap,
coursecode,
queryYear,
}) => {
const GeneralTab = ({ group, populations, columnKeysToInclude, studentToTargetCourseDateMap, coursecode }) => {
const { language } = useLanguage()
const { filteredStudents } = useFilters()
const [popupStates, setPopupStates] = useState({})
const sendAnalytics = sendEvent.populationStudents
// TODO: Refactor this away:
const { data: populationStatistics, query } = populations
if (!populationStatistics || !populationStatistics.elementdetails) return null
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
// TODO: This fixes crashing upon using back button. Find the root cause and fix it.
if (!populationStatistics.elementdetails) {
return null
}
const students = Object.fromEntries(filteredStudents.map(stu => [stu.studentNumber, stu]))
const queryStudyrights = query ? Object.values(query.studyRights) : []
const cleanedQueryStudyrights = queryStudyrights.filter(sr => !!sr)
const programmeCode = cleanedQueryStudyrights[0] || group?.tags?.studyProgramme
const popupTimeoutLength = 1000
let timeout = null
@@ -135,38 +116,32 @@ const GeneralTab = ({
return null
}
const studentToStudyrightStartMap = !(customPopulation || coursePopulation)
? selectedStudents.reduce((res, sn) => {
const targetStudyright = flatten(
students[sn].studyrights.reduce((acc, curr) => {
acc.push(curr.studyright_elements)
return acc
}, [])
).filter(e => e.code === cleanedQueryStudyrights[0])
res[sn] = targetStudyright[0] ? targetStudyright[0].startdate : null
return res
}, {})
: null
const studentToStudyrightStartMap = selectedStudents.reduce((res, sn) => {
const targetStudyright = _.flatten(
students[sn].studyrights.reduce((acc, curr) => {
acc.push(curr.studyright_elements)
return acc
}, [])
).filter(e => e.code === programmeCode)
res[sn] = targetStudyright[0] ? targetStudyright[0].startdate : null
return res
}, {})
const studentToStudyrightActualStartMap = !(customPopulation || coursePopulation)
? selectedStudents.reduce((res, sn) => {
const targetStudyright = students[sn].studyrights.find(studyright =>
studyright.studyright_elements.some(e => e.code === cleanedQueryStudyrights[0])
)
res[sn] = targetStudyright ? targetStudyright.studystartdate : null
return res
}, {})
: null
const studentToStudyrightActualStartMap = selectedStudents.reduce((res, sn) => {
const targetStudyright = students[sn].studyrights.find(studyright =>
studyright.studyright_elements.some(e => e.code === programmeCode)
)
res[sn] = targetStudyright ? targetStudyright.studystartdate : null
return res
}, {})
const studentToStudyrightEndMap = !(customPopulation || coursePopulation)
? selectedStudents.reduce((res, sn) => {
const targetStudyright = students[sn].studyrights.find(studyright =>
studyright.studyright_elements.some(e => e.code === cleanedQueryStudyrights[0])
)
res[sn] = targetStudyright && targetStudyright.graduated === 1 ? targetStudyright.enddate : null
return res
}, {})
: null
const studentToStudyrightEndMap = selectedStudents.reduce((res, sn) => {
const targetStudyright = students[sn].studyrights.find(studyright =>
studyright.studyright_elements.some(e => e.code === programmeCode)
)
res[sn] = targetStudyright && targetStudyright.graduated === 1 ? targetStudyright.enddate : null
return res
}, {})
const getActualStartDate = studentNumber => {
const studyRightStart = studentToStudyrightStartMap[studentNumber]
@@ -188,30 +163,30 @@ const GeneralTab = ({
sendAnalytics('Copy all student emails to clipboard', 'Copy all student emails to clipboard')
}
const containsStudyTracks = () => {
const allStudyrights = selectedStudents.map(sn => students[sn]).map(st => st.studyrights)
return allStudyrights
.map(
studyrights =>
studyrightCodes(studyrights, 'studyright_elements').reduce((acc, elemArr) => {
elemArr
.filter(el => populationStatistics.elementdetails.data[el.code].type === 30)
.forEach(el => acc.push(getTextIn(populationStatistics.elementdetails.data[el.code].name, language)))
return acc
}, []).length > 0
)
.some(el => el === true)
}
const columns = []
if (showNames) {
columns.push(
{ key: 'lastname', title: 'last name', getRowVal: s => s.lastname },
{ key: 'firstname', title: 'given names', getRowVal: s => s.firstnames }
// Filters to check data for whether to show certain columns
const containsStudyTracks = selectedStudents
.map(sn => students[sn])
.map(st => st.studyrights)
.map(
studyrights =>
studyrightCodes(studyrights, 'studyright_elements').reduce((acc, elemArr) => {
elemArr
.filter(el => populationStatistics.elementdetails.data[el.code].type === 30)
.forEach(el => acc.push(getTextIn(populationStatistics.elementdetails.data[el.code].name, language)))
return acc
}, []).length > 0
)
}
columns.push(
{
.some(el => el === true)
const containsOption = cleanedQueryStudyrights.some(code => code.startsWith('MH') || code.startsWith('KH'))
const shouldShowAdmissionType = parseInt(query?.year, 10) >= 2020 || parseInt(group?.tags?.year, 10) >= 2020
// All columns components user is able to use
const columnsAvailable = {
lastname: { key: 'lastname', title: 'last name', getRowVal: s => s.lastname },
firstname: { key: 'firstname', title: 'given names', getRowVal: s => s.firstnames },
studentnumber: {
key: 'studentnumber',
title: 'Student Number',
getRowVal: s => (!s.obfuscated ? s.studentNumber : 'hidden'),
@@ -222,7 +197,7 @@ const GeneralTab = ({
),
headerProps: { colSpan: 2 },
},
{
icon: {
key: 'icon',
getRowVal: s =>
!s.obfuscated && (
@@ -237,22 +212,28 @@ const GeneralTab = ({
</Item>
),
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
}
)
if (!(coursePopulation || customPopulation)) {
columns.push({
key: 'credits since start',
},
creditsSinceStart: {
key: 'creditsSinceStart',
title: 'credits since start of studyright',
getRowVal: s => {
const credits = getStudentTotalCredits(s)
return credits
},
})
}
if (coursecode.length > 0) {
columns.push({
key: 'grade for single course',
},
creditsSinceStartByYear: {
key: 'creditsSinceStartByYear',
title: `credits since ${group?.tags?.year}`,
getRowVal: s => {
const credits = getStudentTotalCredits({
...s,
courses: s.courses.filter(c => new Date(c.date) > new Date(group?.tags?.year, 7, 1)),
})
return credits
},
},
gradeForSingleCourse: {
key: 'gradeForSingleCourse',
title: 'Grade',
getRowVal: s => {
const grade = s.courses.filter(c => coursecode.includes(c.course_code))
@@ -262,171 +243,148 @@ const GeneralTab = ({
}
return ''
},
})
}
columns.push({
key: 'all credits',
title: 'All Credits',
getRowVal: s => s.credits,
})
if (!(coursePopulation || customPopulation)) {
columns.push({
key: 'transferred from',
},
allCredits: {
key: 'allCredits',
title: 'All Credits',
getRowVal: s => s.credits,
},
transferredFrom: {
key: 'transferredFrom',
title: 'Transferred From',
getRowVal: s => (s.transferredStudyright ? transferFrom(s) : ''),
})
}
if (containsStudyTracks() && !(coursePopulation || customPopulation)) {
columns.push({
key: 'studytrack',
},
studyTrack: containsStudyTracks && {
key: 'studyTrack',
title: 'Study Track',
getRowVal: s => studytrack(s.studyrights).map(st => st.name)[0],
})
}
if (isAdmin && !(coursePopulation || customPopulation)) {
columns.push(
{
key: 'priority',
title: 'priority',
getRowVal: s => priorityText(s.studyrights),
},
{
key: 'extent',
title: 'extent',
getRowVal: s => extentCodes(s.studyrights),
}
)
}
columns.push({
key: 'tags',
title: 'Tags',
getRowVal: s => (!s.obfuscated ? tags(s.tags) : ''),
})
if (!(coursePopulation || customPopulation)) {
if (isAdmin) {
columns.push({
key: 'studystartdate',
title: 'start of studyright',
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]).getTime(),
getRowContent: s => reformatDate(studentToStudyrightStartMap[s.studentNumber], 'YYYY-MM-DD'),
})
}
// potentially will replace the 'start of studyright' column - both present for validation
columns.push({
key: 'studystartdateactual',
},
priority: {
key: 'priority',
title: 'priority',
getRowVal: s => priorityText(s.studyrights),
},
extent: {
key: 'extent',
title: 'extent',
getRowVal: s => extentCodes(s.studyrights),
},
tags: {
key: 'tags',
title: 'Tags',
getRowVal: s => (!s.obfuscated ? tags(s.tags) : ''),
},
studyStartDate: {
key: 'studyStartDate',
title: 'start of studyright',
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]).getTime(),
getRowContent: s => reformatDate(studentToStudyrightStartMap[s.studentNumber], 'YYYY-MM-DD'),
},
studyStartDateActual: {
key: 'studyStartDateActual',
title: 'started in studyright',
getRowVal: s => new Date(getActualStartDate(s.studentNumber)).getTime(),
getRowContent: s => reformatDate(getActualStartDate(s.studentNumber), 'YYYY-MM-DD'),
})
columns.push({
key: 'enddate',
},
endDate: {
key: 'endDate',
title: 'graduation date',
getRowVal: s => new Date(studentToStudyrightEndMap[s.studentNumber]).getTime(),
getRowContent: s =>
studentToStudyrightEndMap[s.studentNumber]
? reformatDate(studentToStudyrightEndMap[s.studentNumber], 'YYYY-MM-DD')
: '',
})
}
if (!(coursePopulation || customPopulation) && parseInt(queryYear, 10) >= 2020) {
const code = queryStudyrights[0]
columns.push({
key: 'admission type',
},
admissionType: shouldShowAdmissionType && {
key: 'admissionType',
title: 'admission type',
getRowVal: s => {
const studyright = s.studyrights.find(sr => sr.studyright_elements.some(e => e.code === code))
const studyright = s.studyrights.find(sr => sr.studyright_elements.some(e => e.code === programmeCode))
return studyright && studyright.admission_type ? studyright.admission_type : 'Ei valintatapaa'
},
})
}
if (coursePopulation || customPopulation) {
columns.push(
{
key: 'programme',
title: 'Study Programme',
getRowVal: s => getTextIn(mainProgramme(s.studyrights, s.studentNumber), language) || 'No programme',
},
{
key: 'startyear',
title: 'Start Year at Uni',
getRowVal: s => (!s.obfuscated ? reformatDate(s.started, 'YYYY') : ''),
}
)
}
if (cleanedQueryStudyrights.some(code => code.startsWith('MH') || code.startsWith('KH')))
columns.push({
},
programme: {
key: 'programme',
title: 'Study Programme',
getRowVal: s => getTextIn(mainProgramme(s.studyrights, s.studentNumber), language) || 'No programme',
},
startYear: {
key: 'startYear',
title: 'Start Year at Uni',
getRowVal: s => (!s.obfuscated ? reformatDate(s.started, 'YYYY') : ''),
},
option: containsOption && {
key: 'option',
title: cleanedQueryStudyrights.some(code => code.startsWith('MH')) ? 'Bachelor' : 'Master',
getRowVal: s => (s.option ? getTextIn(s.option.name, language) : ''),
})
if (showNames) {
columns.push(
{
key: 'email',
title: (
<>
email
<Popup
trigger={<Icon link name="copy" onClick={copyToClipboardAll} style={{ float: 'right' }} />}
content="Copied email list!"
on="click"
open={popupStates['0']}
onClose={() => handlePopupClose('0')}
onOpen={() => handlePopupOpen('0')}
position="top right"
/>
</>
),
getRowVal: s => s.email,
headerProps: { colSpan: 2 },
},
{
key: 'copy email',
getRowVal: s =>
s.email && !s.obfuscated ? (
<Popup
trigger={
<Icon
link
name="copy outline"
onClick={() => {
copyToClipboard(s.email)
sendAnalytics("Copy student's email to clipboard", "Copy student's email to clipboard")
}}
style={{ float: 'right' }}
/>
}
content="Email copied!"
on="click"
open={popupStates[s.studentNumber]}
onClose={() => handlePopupClose(s.studentNumber)}
onOpen={() => handlePopupOpen(s.studentNumber)}
position="top right"
/>
) : null,
headerProps: { onClick: null, sorted: null },
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
}
)
}
if (isAdmin) {
columns.push({
},
email: {
key: 'email',
title: (
<>
email
<Popup
trigger={<Icon link name="copy" onClick={copyToClipboardAll} style={{ float: 'right' }} />}
content="Copied email list!"
on="click"
open={popupStates['0']}
onClose={() => handlePopupClose('0')}
onOpen={() => handlePopupOpen('0')}
position="top right"
/>
</>
),
getRowVal: s => s.email,
headerProps: { colSpan: 2 },
},
copyEmail: {
key: 'copyEmail',
getRowVal: s =>
s.email && !s.obfuscated ? (
<Popup
trigger={
<Icon
link
name="copy outline"
onClick={() => {
copyToClipboard(s.email)
sendAnalytics("Copy student's email to clipboard", "Copy student's email to clipboard")
}}
style={{ float: 'right' }}
/>
}
content="Email copied!"
on="click"
open={popupStates[s.studentNumber]}
onClose={() => handlePopupClose(s.studentNumber)}
onOpen={() => handlePopupOpen(s.studentNumber)}
position="top right"
/>
) : null,
headerProps: { onClick: null, sorted: null },
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
},
updatedAt: {
key: 'updatedAt',
title: 'Last Updated At',
getRowVal: s => reformatDate(s.updatedAt, 'YYYY-MM-DD HH:mm:ss'),
})
},
}
// Columns are shown in order they're declared above. JS guarantees this order of keys
// to stay for non-integer keys
const orderOfColumns = Object.keys(columnsAvailable).reduce(
(acc, curr, ind) => ({
...acc,
[curr]: ind,
}),
{}
)
const columns = _.chain(columnKeysToInclude)
.map(colKey => columnsAvailable[colKey])
.sortBy(col => orderOfColumns[col.key])
.filter(col => !!col)
.value()
return (
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
@@ -446,36 +404,69 @@ const GeneralTab = ({
)
}
GeneralTab.defaultProps = {
studentToTargetCourseDateMap: null,
customPopulation: false,
coursePopulation: false,
// study guidance groups -feature uses different population + rtk query, so it needs to
// be rendered differently. TODO: should rafactor this, maybe with using allStudents
// from useFilters and making sure that it contains same students than the population
// backend returns with population query below (so caching works)
const StudyGuidanceGroupGeneralTabContainer = ({ group, ...props }) => {
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
const populations = useGetStudyGuidanceGroupPopulationQuery(groupStudentNumbers)
return <GeneralTab populations={populations} group={group} {...props} />
}
GeneralTab.propTypes = {
showNames: bool.isRequired,
coursePopulation: bool,
customPopulation: bool,
populationStatistics: shape({}).isRequired,
queryStudyrights: arrayOf(string).isRequired,
isAdmin: bool.isRequired,
studentToTargetCourseDateMap: shape({}),
}
const GeneralTabContainer = ({ studyGuidanceGroup, variant, ...props }) => {
const populations = useSelector(({ populations }) => populations)
const { namesVisible } = useSelector(({ settings }) => settings)
const isAdmin = useIsAdmin()
const mapStateToProps = state => {
const {
populations,
auth: {
token: { roles },
},
} = state
return {
isAdmin: getUserIsAdmin(roles),
populationStatistics: populations.data,
queryStudyrights: populations.query ? Object.values(populations.query.studyRights) : [],
queryYear: populations?.query?.year,
const getStudyGuidanceGroupColumns = () => {
const cols = ['programme', 'startYear']
if (studyGuidanceGroup?.tags?.year) cols.push('creditsSinceStartByYear')
if (studyGuidanceGroup?.tags?.studyProgramme) cols.push('studyStartDate', 'studyStartDateActual', 'endDate')
if (studyGuidanceGroup?.tags?.studyProgramme && studyGuidanceGroup?.tags?.year) {
cols.push('admissionType')
}
return cols
}
const columnsByVariant = {
customPopulation: ['programme', 'startYear'],
coursePopulation: ['gradeForSingleCourse', 'programme', 'startYear'],
population: [
'creditsSinceStart',
'transferredFrom',
'priority',
'extent',
'studyStartDate',
'studyStartDateActual',
'endDate',
'studyTrack',
'admissionType',
],
studyGuidanceGroupPopulation: getStudyGuidanceGroupColumns(),
}
const baseColumns = ['studentnumber', 'icon', 'allCredits', 'tags', 'updatedAt', 'option']
const nameColumnsToAdd = namesVisible ? ['email', 'copyEmail', 'lastname', 'firstname'] : []
const adminColumnsToFilter = isAdmin ? [] : ['priority', 'extent', 'studyStartDate', 'updatedAt']
const columnKeysToInclude = _.chain(baseColumns)
.union(columnsByVariant[variant])
.union(nameColumnsToAdd)
.difference(adminColumnsToFilter)
.value()
if (variant === 'studyGuidanceGroupPopulation') {
return (
<StudyGuidanceGroupGeneralTabContainer
group={studyGuidanceGroup}
columnKeysToInclude={columnKeysToInclude}
{...props}
/>
)
}
return <GeneralTab populations={populations} columnKeysToInclude={columnKeysToInclude} {...props} />
}
export default connect(mapStateToProps)(GeneralTab)
export default GeneralTabContainer
@@ -1,21 +1,19 @@
import React, { Component, Fragment } from 'react'
import { connect } from 'react-redux'
import { string, arrayOf, object, func, bool, shape, node } from 'prop-types'
import { Icon, Tab, Grid, Ref, Item, Dropdown } from 'semantic-ui-react'
import { withRouter, Link } from 'react-router-dom'
import React, { useState, useEffect, useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Icon, Tab, Grid, Item, Dropdown } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { orderBy, uniqBy, flatten, sortBy, isNumber } from 'lodash'
import scrollToComponent from 'react-scroll-to-component'
import { getTextIn, getUserRoles } from '../../common'
import { useTabChangeAnalytics } from '../../common/hooks'
import { getTextIn } from '../../common'
import { useTabChangeAnalytics, usePrevious, useIsAdmin } from '../../common/hooks'
import { toggleStudentListVisibility } from '../../redux/settings'
import { getTagsByStudytrackAction } from '../../redux/tags'
import { getStudentTagsByStudytrackAction } from '../../redux/tagstudent'
import StudentNameVisibilityToggle from '../StudentNameVisibilityToggle'
import '../PopulationCourseStats/populationCourseStats.css'
import SortableTable from '../SortableTable'
import InfoBox from '../InfoBox'
import InfoBox from '../Info/InfoBox'
import CheckStudentList from './CheckStudentList'
import TagPopulation from '../TagPopulation'
import TagList from '../TagList'
@@ -24,79 +22,96 @@ import GeneralTab from './StudentTable/GeneralTab'
import sendEvent, { ANALYTICS_CATEGORIES } from '../../common/sendEvent'
import infotoolTips from '../../common/InfoToolTips'
// TODO: Refactoring in process, contains lot of duplicate code.
const sendAnalytics = sendEvent.populationStudents
const StudentTableTabs = ({ panes, filterPanes }) => {
// only its own component really because I needed to use hooks and didn't want to refactor
// this megamillion line component :) /Joona
const PopulationStudents = ({
language,
filteredStudents,
studentToTargetCourseDateMap,
dataExport,
contentToInclude,
coursecode = [],
variant,
studyGuidanceGroup,
}) => {
const [state, setState] = useState({})
const studentRef = useRef()
const dispatch = useDispatch()
const { namesVisible: showNames, studentlistVisible: showList } = useSelector(({ settings }) => settings)
const { data: tags } = useSelector(({ tags }) => tags)
const { query } = useSelector(({ populations }) => populations)
const queryStudyrights = query ? Object.values(query.studyRights) : []
const { data: mandatoryCourses } = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses)
const { data: populationCourses } = useSelector(({ populationCourses }) => populationCourses)
const prevShowList = usePrevious(showList)
const admin = useIsAdmin()
const { handleTabChange } = useTabChangeAnalytics(
ANALYTICS_CATEGORIES.populationStudents,
'Change students table tab'
)
return <Tab onTabChange={handleTabChange} panes={filterPanes(panes)} data-cy="student-table-tabs" />
}
useEffect(() => {
if (tags && tags.length > 0) return
const queryStudyright = queryStudyrights[0]
dispatch(getTagsByStudytrackAction(queryStudyright))
dispatch(getStudentTagsByStudytrackAction(queryStudyright))
setState({ ...state, admin })
}, [])
StudentTableTabs.propTypes = {
panes: arrayOf(object).isRequired,
filterPanes: func.isRequired,
}
class PopulationStudents extends Component {
constructor(props) {
super(props)
this.state = {}
this.studentsRef = React.createRef()
}
componentDidMount() {
const queryStudyright = this.props.queryStudyrights[0]
const admin = this.props.userRoles.includes('admin')
if (queryStudyright) {
this.props.getTagsByStudytrack(queryStudyright)
this.props.getStudentTagsStudyTrack(queryStudyright)
useEffect(() => {
if (!prevShowList && showList) {
scrollToComponent(studentRef.current, { align: 'bottom' })
}
this.setState({ admin })
}, [prevShowList])
const mandatoryCodes = mandatoryCourses.filter(course => course.visible && course.visible.visibility).map(c => c.code)
let mandatoryPassed = {}
if (populationCourses.coursestatistics) {
const mandatorycodesMapCourseCodeToCourseID = new Map()
mandatoryCourses
.filter(course => course.visible && course.visible.visibility)
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
const courses = populationCourses.coursestatistics
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
const foundCourse = !!courses.find(c => c.course.code === code)
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
let foundSubsCourse
if (searchCode) {
foundSubsCourse = courses.find(c => {
if (!c.course.substitutions) return false
if (c.course.substitutions.length === null) return false
return c.course.substitutions.includes(searchCode)
})
}
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
if (foundSubsCourse) {
passedArray = passedArray
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
: Object.keys(foundSubsCourse.students.passed)
}
obj[code] = passedArray
return obj
}, {})
}
componentDidUpdate = prevProps => {
if (!prevProps.showList && this.props.showList) {
scrollToComponent(this.studentsRef.current, { align: 'bottom' })
}
}
studyrightCodes = (studyrights, value) => {
const { queryStudyrights } = this.props
return studyrights
.filter(sr => {
const { studyright_elements: studyrightElements } = sr
return studyrightElements.filter(sre => queryStudyrights.includes(sre.code)).length >= queryStudyrights.length
})
.map(a => a[value])
}
handleRef = node => {
this.studentsRef.current = node
}
renderStudentTable() {
const { customPopulation, coursePopulation } = this.props
const renderStudentTable = () => {
const verticalTitle = title => <div className="verticalTitle">{title}</div>
const hasPassedMandatory = (studentNumber, code) =>
this.props.mandatoryPassed[code] && this.props.mandatoryPassed[code].includes(studentNumber)
mandatoryPassed[code] && mandatoryPassed[code].includes(studentNumber)
const totalMandatoryPassed = studentNumber => {
this.props.mandatoryCourses.reduce((acc, m) => {
mandatoryCourses.reduce((acc, m) => {
return hasPassedMandatory(studentNumber, m.code) ? acc + 1 : acc
}, 0)
}
const nameColumns = this.props.showNames
const nameColumns = showNames
? [
{
key: 'lastname',
@@ -164,7 +179,7 @@ class PopulationStudents extends Component {
const mandatoryCourseLabels = []
const labelToMandatoryCourses = this.props.mandatoryCourses.reduce((acc, e) => {
const labelToMandatoryCourses = mandatoryCourses.reduce((acc, e) => {
const label = e.label ? e.label.label : ''
acc[label] = acc[label] || []
if (acc[label].some(l => l.code === e.code)) return acc
@@ -183,14 +198,14 @@ class PopulationStudents extends Component {
const mandatoryTitle = m => {
return (
<>
{getTextIn(m.name, this.props.language)}
{getTextIn(m.name, language)}
<br />
{m.code}
</>
)
}
const { visibleLabels, visibleCourseCodes } = this.props.mandatoryCourses.reduce(
const { visibleLabels, visibleCourseCodes } = mandatoryCourses.reduce(
(acc, cur) => {
if (cur.visible && cur.visible.visibility) {
acc.visibleLabels.add(cur.label_code)
@@ -220,7 +235,11 @@ class PopulationStudents extends Component {
</div>
),
parent: true,
headerProps: { colSpan: labelToMandatoryCourses[e.label].length, title: e.label, ordernumber: e.orderNumber },
headerProps: {
colSpan: labelToMandatoryCourses[e.label].length,
title: e.label,
ordernumber: e.orderNumber,
},
}))
)
@@ -242,8 +261,8 @@ class PopulationStudents extends Component {
.map(m => ({
key: `${m.label ? m.label.label : 'fix'}-${m.code}`, // really quick and dirty fix
title: verticalTitle(mandatoryTitle(m)),
cellProps: { title: `${m.code}, ${getTextIn(m.name, this.props.language)}` },
headerProps: { title: `${m.code}, ${getTextIn(m.name, this.props.language)}` },
cellProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
headerProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
getRowVal: s => (s.total ? getTotalRowVal(s, m) : hasPassedMandatory(s.studentNumber, m.code)),
getRowContent: s => {
if (s.total) return getTotalRowVal(s, m)
@@ -257,33 +276,30 @@ class PopulationStudents extends Component {
),
]
const totals = this.props.filteredStudents.reduce(
const totals = filteredStudents.reduce(
(acc, s) => {
const passedCourses = new Set()
this.props.mandatoryCourses.forEach(m => {
mandatoryCourses.forEach(m => {
if (passedCourses.has(m.code)) return
passedCourses.add(m.code)
if (hasPassedMandatory(s.studentNumber, m.code)) ++acc[m.code]
})
return acc
},
this.props.mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
)
const mandatoryCourseData = [totals, ...this.props.filteredStudents]
const mandatoryCourseData = [totals, ...filteredStudents]
// FIXME: here only for refactorment
const { showNames, studentToTargetCourseDateMap, coursecode } = this.props
const panes = [
const panesAvailable = [
{
menuItem: 'General',
render: () => (
<Tab.Pane>
<GeneralTab
showNames={showNames}
coursePopulation={coursePopulation}
customPopulation={customPopulation}
variant={variant}
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
coursecode={coursecode}
studyGuidanceGroup={studyGuidanceGroup}
/>
</Tab.Pane>
),
@@ -294,7 +310,7 @@ class PopulationStudents extends Component {
<Tab.Pane>
<div style={{ display: 'flex' }}>
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
{this.props.mandatoryCourses.length > 0 && (
{mandatoryCourses.length > 0 && (
<SortableTable
getRowKey={s => (s.total ? 'totals' : s.studentNumber)}
tableProps={{
@@ -308,7 +324,7 @@ class PopulationStudents extends Component {
textAlign: 'center',
}}
collapsingHeaders
showNames={this.props.showNames}
showNames={showNames}
columns={mandatoryCourseColumns}
data={mandatoryCourseData}
/>
@@ -323,7 +339,7 @@ class PopulationStudents extends Component {
render: () => (
<Tab.Pane>
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
{this.props.tags.length === 0 && (
{tags.length === 0 && (
<div
style={{
paddingLeft: '10px',
@@ -337,7 +353,7 @@ class PopulationStudents extends Component {
<h3>
No tags defined. You can define them{' '}
<Link
to={`/study-programme/${this.props.queryStudyrights[0]}?p_m_tab=0&p_tab=6`}
to={`/study-programme/${queryStudyrights[0]}?p_m_tab=0&p_tab=6`}
onClick={() => {
sendAnalytics('No tags defined button clicked', 'Tags tab')
}}
@@ -348,14 +364,14 @@ class PopulationStudents extends Component {
</h3>
</div>
)}
{this.props.tags.length > 0 && (
{tags.length > 0 && (
<>
<TagPopulation
tags={this.props.tags}
selectedStudents={this.props.filteredStudents.map(stu => stu.studentNumber)}
studytrack={this.props.queryStudyrights[0]}
tags={tags}
selectedStudents={filteredStudents.map(stu => stu.studentNumber)}
studytrack={queryStudyrights[0]}
/>
<TagList studytrack={this.props.queryStudyrights[0]} selectedStudents={this.props.filteredStudents} />
<TagList studytrack={queryStudyrights[0]} selectedStudents={filteredStudents} />
</>
)}
</div>
@@ -364,12 +380,7 @@ class PopulationStudents extends Component {
},
]
const filteredPanes = panesToFilter => {
if (coursePopulation || customPopulation) {
return panesToFilter.slice(0, 1)
}
return panesToFilter
}
const panes = panesAvailable.filter(pane => contentToInclude.panesToInclude.includes(pane.menuItem))
return (
<>
@@ -377,142 +388,59 @@ class PopulationStudents extends Component {
<Grid.Column>
<StudentNameVisibilityToggle />
</Grid.Column>
{this.props.dataExport && (
{dataExport && (
<Grid.Column textAlign="right">
<Dropdown text="Export Data" icon="save" button labeled className="icon" direction="left">
<Dropdown.Menu>{this.props.dataExport}</Dropdown.Menu>
<Dropdown.Menu>{dataExport}</Dropdown.Menu>
</Dropdown>
</Grid.Column>
)}
</Grid>
<StudentTableTabs panes={panes} filterPanes={filteredPanes} />
<Tab onTabChange={handleTabChange} panes={panes} data-cy="student-table-tabs" />
</>
)
}
render() {
if (this.props.filteredStudents.length === 0) {
return null
}
if (filteredStudents.length === 0) return null
return (
<Ref innerRef={this.handleRef}>
<>
<span style={{ marginRight: '0.5rem' }}>
<InfoBox
content={
this.props.coursePopulation
? infotoolTips.CoursePopulation.Students
: infotoolTips.PopulationStatistics.Students
}
/>
</span>
{this.state.admin ? (
<CheckStudentList students={this.props.filteredStudents.map(stu => stu.studentNumber)} />
) : null}
{this.renderStudentTable()}
</>
</Ref>
)
return (
<>
<span style={{ marginRight: '0.5rem' }} ref={studentRef}>
<InfoBox content={contentToInclude.infotoolTipContent} />
</span>
{admin ? <CheckStudentList students={filteredStudents.map(stu => stu.studentNumber)} /> : null}
{renderStudentTable()}
</>
)
}
const PopulationStudentsContainer = ({ ...props }) => {
const { variant } = props
if (!['population', 'customPopulation', 'coursePopulation', 'studyGuidanceGroupPopulation'].includes(variant)) {
throw new Error(`${variant} is not a proper variant!`)
}
}
PopulationStudents.defaultProps = {
studentToTargetCourseDateMap: null,
customPopulation: false,
coursePopulation: false,
dataExport: null,
coursecode: [],
}
PopulationStudents.propTypes = {
showNames: bool.isRequired,
showList: bool.isRequired,
language: string.isRequired,
queryStudyrights: arrayOf(string).isRequired,
mandatoryCourses: arrayOf(
shape({
name: shape({}).isRequired,
code: string.isRequired,
})
).isRequired,
mandatoryPassed: shape({}).isRequired,
tags: arrayOf(shape({ tag_id: string, tagname: string, studytrack: string })).isRequired,
getTagsByStudytrack: func.isRequired,
getStudentTagsStudyTrack: func.isRequired,
userRoles: arrayOf(string).isRequired,
studentToTargetCourseDateMap: shape({}),
coursePopulation: bool,
customPopulation: bool,
filteredStudents: arrayOf(shape({})).isRequired,
dataExport: node,
coursecode: arrayOf(string),
}
const mapStateToProps = state => {
const {
settings,
populations,
populationCourses,
populationMandatoryCourses,
tags,
auth: {
token: { roles },
const contentByVariant = {
population: {
panesToInclude: ['General', 'Courses', 'Tags'],
infotoolTipContent: infotoolTips.PopulationStatistics.Students,
},
coursePopulation: {
panesToInclude: ['General'],
infotoolTipContent: infotoolTips.CoursePopulation.Students,
},
customPopulation: {
panesToInclude: ['General'],
infotoolTipContent: infotoolTips.PopulationStatistics.Students,
},
studyGuidanceGroupPopulation: {
panesToInclude: ['General'],
infotoolTipContent: infotoolTips.PopulationStatistics.Students,
},
} = state
const mandatoryCodes = populationMandatoryCourses.data
.filter(course => course.visible && course.visible.visibility)
.map(c => c.code)
let mandatoryPassed = {}
if (populationCourses.data.coursestatistics) {
const mandatorycodesMapCourseCodeToCourseID = new Map()
populationMandatoryCourses.data
.filter(course => course.visible && course.visible.visibility)
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
const courses = populationCourses.data.coursestatistics
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
const foundCourse = !!courses.find(c => c.course.code === code)
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
let foundSubsCourse
if (searchCode) {
foundSubsCourse = courses.find(c => {
if (!c.course.substitutions) return false
if (c.course.substitutions.length === null) return false
return c.course.substitutions.includes(searchCode)
})
}
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
if (foundSubsCourse) {
passedArray = passedArray
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
: Object.keys(foundSubsCourse.students.passed)
}
obj[code] = passedArray
return obj
}, {})
}
return {
showNames: settings.namesVisible,
showList: settings.studentlistVisible,
queryStudyrights: populations.query ? Object.values(populations.query.studyRights) : [],
mandatoryCourses: populationMandatoryCourses.data,
mandatoryPassed,
tags: tags.data,
userRoles: getUserRoles(roles),
}
return <PopulationStudents contentToInclude={contentByVariant[variant]} {...props} />
}
export default connect(mapStateToProps, {
toggleStudentListVisibility,
getTagsByStudytrack: getTagsByStudytrackAction,
getStudentTagsStudyTrack: getStudentTagsByStudytrackAction,
})(withRouter(PopulationStudents))
export default PopulationStudentsContainer
@@ -25,6 +25,9 @@ const ProtectedRoute = ({ requiredRoles = [], requireUserHasRights = false, ...r
if (requiredRoles.includes('courseStatistics')) {
return hasRequiredRoles || hasRequiredRights
}
if (rest.path.includes('students')) {
return hasRequiredRoles || hasRequiredRights
}
return hasRequiredRoles && hasRequiredRights
}
@@ -66,7 +66,13 @@ const Routes = () => (
path="/study-programme/:studyProgrammeId?"
component={StudyProgramme}
/>
<ProtectedRoute requireUserHasRights exact path={routes.students} component={StudentStatistics} />
<ProtectedRoute
requiredRoles={['admin', 'studyGuidanceGroups']}
requireUserHasRights
exact
path={routes.students}
component={StudentStatistics}
/>
<ProtectedRoute
requiredRoles={['courseStatistics']}
requireUserHasRights
@@ -1,52 +1,31 @@
import React, { useState } from 'react'
import { shape, string, arrayOf } from 'prop-types'
import { Menu, Tab, Input, Message } from 'semantic-ui-react'
import { flattenDeep, cloneDeep } from 'lodash'
import { flattenDeep } from 'lodash'
import Highcharts from 'highcharts/highstock'
import ReactHighcharts from 'react-highcharts'
import CreditAccumulationGraphHighCharts from '../../CreditAccumulationGraphHighCharts'
import { byDateDesc, reformatDate, getTextIn } from '../../../common'
import TSA from '../../../common/tsa'
import useLanguage from '../../LanguagePicker/useLanguage'
const ANALYTICS_CATEGORY = 'Student stats'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const CreditsGraph = ({ graphYearStart, student, absences }) => {
const { language } = useLanguage()
const selectedStart = graphYearStart || student.started
const filteredCourses = student.courses.filter(c => new Date(c.date) > new Date(selectedStart))
const newStudent = cloneDeep(student)
newStudent.courses = filteredCourses
const sample = [newStudent]
const selectedStart = new Date(graphYearStart ?? student.started)
const dates = flattenDeep(student.courses.map(c => c.date)).map(d => new Date(d).getTime())
sample.maxCredits = newStudent.courses.reduce((a, c) => {
if (c.isStudyModuleCredit || !c.passed) return a + 0
return a + c.credits
}, 0)
sample.maxDate = dates.length > 0 ? Math.max(...dates) : new Date().getTime()
sample.minDate = new Date(selectedStart).getTime()
const endDate = dates.length > 0 ? Math.max(...dates) : new Date().getTime()
return (
<CreditAccumulationGraphHighCharts
singleStudent
students={sample}
selectedStudents={[student.studentNumber]}
title="Credit Accumulation"
maxCredits={sample.maxCredits}
students={[student]}
startDate={selectedStart}
endDate={endDate}
absences={absences}
language={language}
/>
)
}
CreditsGraph.propTypes = {
student: shape({}).isRequired,
absences: arrayOf(shape({})).isRequired,
graphYearStart: string.isRequired,
}
const chunkifyArray = (array, size = 1) => {
if (!array) return []
const firstChunk = array.slice(0, size) // create the first chunk of the given array
@@ -205,12 +184,6 @@ const GradeGraph = ({ student, semesters, language }) => {
)
}
GradeGraph.propTypes = {
student: shape({}).isRequired,
semesters: shape({}).isRequired,
language: string.isRequired,
}
const StudentGraphs = ({ student, absences, graphYearStart, semesters, language }) => {
const panes = [
{
@@ -233,12 +206,4 @@ const StudentGraphs = ({ student, absences, graphYearStart, semesters, language
return <Tab panes={panes} />
}
StudentGraphs.propTypes = {
student: shape({}).isRequired,
absences: arrayOf(shape({})).isRequired,
graphYearStart: string.isRequired,
semesters: shape({}).isRequired,
language: string.isRequired,
}
export default StudentGraphs
@@ -39,7 +39,7 @@ const StudentDetails = ({
}) => {
const dispatch = useDispatch()
const { language } = useLanguage()
const [graphYearStart, setGraphYear] = useState('')
const [graphYearStart, setGraphYear] = useState(null)
const [studyrightid, setStudyrightid] = useState('')
const [honoursCode, setHonoursCode] = useState(null)
@@ -49,7 +49,7 @@ const StudentDetails = ({
}, [])
useEffect(() => {
setGraphYear('')
setGraphYear(null)
if (studentNumber.length > 0) getStudent(studentNumber)
else {
resetStudent()
@@ -151,7 +151,7 @@ const StudentDetails = ({
const handleStartDateChange = (elements, id) => {
if (id === studyrightid) {
setGraphYear('')
setGraphYear(null)
setStudyrightid('')
return
}
@@ -1,33 +1,20 @@
import React from 'react'
import { connect } from 'react-redux'
import { shape, string, arrayOf } from 'prop-types'
import { Segment, Header, Message } from 'semantic-ui-react'
import { Segment, Header } from 'semantic-ui-react'
import { withRouter } from 'react-router-dom'
import { findStudents, getStudent, selectStudent } from '../../redux/students'
import StudentSearch from './StudentSearch'
import StudentDetails from './StudentDetails'
import StudentNameVisibilityToggle from '../StudentNameVisibilityToggle'
import { useTitle } from '../../common/hooks'
import { getUserRoles, checkUserAccess } from '../../common'
import { getUserRoles } from '../../common'
import { toggleStudentNameVisibility } from '../../redux/settings'
const StudentStatistics = props => {
const { match, userRoles, rights } = props
const { match } = props
const { studentNumber } = match.params
useTitle('Student statistics')
checkUserAccess(['student', 'admin'], userRoles)
if (!checkUserAccess(['student', 'admin'], userRoles) && rights.length < 1)
return (
<div className="segmentContainer">
<Message
error
color="red"
header="You have no rights to access any data. If you should have access please contact grp-toska@helsinki.fi"
/>
</div>
)
return (
<div className="segmentContainer">
@@ -43,22 +30,6 @@ const StudentStatistics = props => {
)
}
StudentStatistics.propTypes = {
match: shape({
params: shape({
studentNumber: string,
}),
}),
userRoles: arrayOf(string).isRequired,
rights: arrayOf(string).isRequired,
}
StudentStatistics.defaultProps = {
match: {
params: { studentNumber: undefined },
},
}
const mapStateToProps = ({
students,
auth: {
@@ -1,63 +1,95 @@
/* eslint-disable react/prop-types */
// temp disable prop types
import React, { useEffect, useState, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Button, Loader, Header, Accordion, Divider, Segment } from 'semantic-ui-react'
import React, { useCallback, useEffect, useState, useRef } from 'react'
import { useSelector } from 'react-redux'
import { Button, Header, Accordion, Divider, Label, Segment } from 'semantic-ui-react'
import { useHistory } from 'react-router-dom'
import scrollToComponent from 'react-scroll-to-component'
import { getCustomPopulation } from '../../redux/populations'
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
import StyledMessage from './StyledMessage'
import { getTextIn } from '../../common'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
import useFilterTray from '../FilterTray/useFilterTray'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
import PopulationStudents from '../PopulationStudents'
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
import InfoBox from '../InfoBox'
import infotooltips from '../../common/InfoToolTips'
import { getTextIn } from 'common'
import { useToggle } from 'common/hooks'
import FilterTray from 'components/FilterTray'
import useFilters from 'components/FilterTray/useFilters'
import PopulationStudents from 'components/PopulationStudents'
import CreditAccumulationGraphHighCharts from 'components/CreditAccumulationGraphHighCharts'
import { StudyGuidanceGroupFilters } from 'components/FilterTray/FilterSets'
import { useGetStudyGuidanceGroupPopulationQuery } from 'redux/studyGuidanceGroups'
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
import StudyGuidanceGroupPopulationCourses from './StudyGuidanceGroupPopulationCourses'
import { startYearToAcademicYear, Wrapper, StyledMessage } from './common'
const createAcademicYearStartDate = year => new Date(year, 7, 1)
const takeOnlyCoursesStartingFromGivenAcademicYear = ({ students, year }) => {
if (!year) return students
const academicYearStartDate = createAcademicYearStartDate(year)
return students.map(student => ({
...student,
courses: student.courses.filter(course => new Date(course.date) > academicYearStartDate),
}))
}
const useIsMounted = () => {
const isMounted = useRef(false)
useEffect(() => {
isMounted.current = true
return () => {
isMounted.current = false
}
}, [])
return useCallback(() => isMounted.current, [])
}
const useToggleAndSetNewestIndex = ({ defaultValue, indexOfPanelToScroll, setNewestIndex, isMounted }) => {
const [state, toggle] = useToggle(defaultValue)
useEffect(() => {
if (isMounted()) setNewestIndex(indexOfPanelToScroll)
}, [state])
return [state, toggle]
}
const SingleStudyGroupContent = ({ population, group, language }) => {
const custompop = population?.students || []
const isMounted = useIsMounted()
const { setAllStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const creditGainRef = useRef()
const programmeRef = useRef()
const coursesRef = useRef()
const studentRef = useRef()
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
const [activeIndex, setIndex] = useState([])
const [newestIndex, setNewest] = useState(null)
const refs = [useRef(), useRef(), useRef(), useRef()]
const [activeIndex, setActiveIndex] = useState([])
const [newestIndex, setNewestIndex] = useState(null)
const [creditsStartingFromAssociatedYear, toggleCreditsStartingFromAssociatedYear] = useToggleAndSetNewestIndex({
defaultValue: !!group.tags?.year,
indexOfPanelToScroll: 0,
setNewestIndex,
isMounted,
})
const [coursesStructuredByProgramme, toggleCoursesStructuredByProgramme] = useToggleAndSetNewestIndex({
defaultValue: !!group.tags?.studyProgramme,
indexOfPanelToScroll: 1,
setNewestIndex,
isMounted,
})
const filteredStudentsWithFilteredCourses = takeOnlyCoursesStartingFromGivenAcademicYear({
students: filteredStudents,
year: group.tags?.year,
})
const handleClick = index => {
const indexes = [...activeIndex].sort()
if (indexes.includes(index)) {
indexes.splice(
indexes.findIndex(ind => ind === index),
1
)
const togglePanel = index => {
const currentActiveIndex = new Set(activeIndex)
if (currentActiveIndex.has(index)) {
currentActiveIndex.delete(index)
} else {
indexes.push(index)
currentActiveIndex.add(index)
setNewestIndex(index)
}
if (activeIndex.length < indexes.length) setNewest(index)
else setNewest(null)
setIndex(indexes)
setActiveIndex([...currentActiveIndex])
}
useEffect(() => {
setAllStudents(custompop)
handleClick(0)
}, [custompop])
if (newestIndex) scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
}, [newestIndex])
useEffect(() => {
if (newestIndex) {
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
}
}, [activeIndex])
setAllStudents(population?.students || [])
togglePanel(0)
}, [population])
const panels = [
{
@@ -65,21 +97,22 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Credit accumulation (for {selectedStudents.length} students)
Credit accumulation (for {filteredStudents.length} students)
</span>
),
},
onTitleClick: () => handleClick(0),
onTitleClick: () => togglePanel(0),
content: {
content: (
<div ref={creditGainRef}>
<div ref={refs[0]}>
{group.tags?.year && (
<Button primary onClick={() => toggleCreditsStartingFromAssociatedYear()}>
{creditsStartingFromAssociatedYear ? 'Show all credits' : 'Show starting from associated year'}
</Button>
)}
<CreditAccumulationGraphHighCharts
students={custompop}
selectedStudents={selectedStudents}
render={false}
trayOpen={trayOpen}
language={language}
startYear={new Date(parseInt(group?.tags?.year, 10), 7, 1).getTime()}
students={creditsStartingFromAssociatedYear ? filteredStudentsWithFilteredCourses : filteredStudents}
startDate={creditsStartingFromAssociatedYear && createAcademicYearStartDate(group.tags?.year)}
/>
</div>
),
@@ -90,16 +123,20 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Programme distribution
Courses of population
</span>
),
},
onTitleClick: () => handleClick(1),
onTitleClick: () => togglePanel(1),
content: {
content: (
<div ref={programmeRef}>
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
<CustomPopulationProgrammeDist samples={custompop} selectedStudents={selectedStudents} />
<div ref={refs[1]}>
<StudyGuidanceGroupPopulationCourses
selectedStudents={filteredStudents.map(({ studentNumber }) => studentNumber)}
showStructured={coursesStructuredByProgramme}
toggleShowStructured={toggleCoursesStructuredByProgramme}
studyProgramme={group.tags?.studyProgramme}
/>
</div>
),
},
@@ -109,33 +146,20 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Courses of population
Students ({filteredStudents.length})
</span>
),
},
onTitleClick: () => handleClick(2),
onTitleClick: () => togglePanel(2),
content: {
content: (
<div ref={coursesRef}>
<CustomPopulationCourses selectedStudents={selectedStudents} />
</div>
),
},
},
{
key: 3,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Students ({selectedStudents.length})
</span>
),
},
onTitleClick: () => handleClick(3),
content: {
content: (
<div ref={studentRef}>
<PopulationStudents language={language} filteredStudents={filteredStudents} customPopulation />
<div ref={refs[2]}>
<PopulationStudents
variant="studyGuidanceGroupPopulation"
language={language}
filteredStudents={filteredStudents}
studyGuidanceGroup={group}
/>
</div>
),
},
@@ -143,73 +167,73 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
]
return (
<FilterTray filterSet={<CustomPopulationFilters />}>
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
<FilterTray filterSet={<StudyGuidanceGroupFilters group={group} />}>
<Segment className="contentSegment">
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
</Segment>
</FilterTray>
)
}
const SingleStudyGroupViewWrapper = ({ history, groupName, language, children }) => {
const SingleStudyGroupViewWrapper = ({ group, language, isLoading, studyProgrammes, children }) => {
const history = useHistory()
const handleBack = () => {
history.push('/studyguidancegroups')
}
return (
<>
<div className="segmentContainer">
<Segment className="contentSegment">
<Button icon="arrow circle left" content="Back" onClick={() => history.push('/studyguidancegroups')} />
<Divider />
<Header size="medium">{getTextIn(groupName, language)}</Header>
</Segment>
</div>
<Wrapper isLoading={isLoading}>
<Button icon="arrow circle left" content="Back" onClick={handleBack} />
<Divider />
<div style={{ display: 'flex', gap: '8px', alignItems: 'baseline' }}>
<Header size="medium" style={{ marginRight: 'auto' }}>
{group.name && language && getTextIn(group.name, language)}
</Header>
{group.tags?.studyProgramme && (
<Label tag content={studyProgrammes.find(p => p.value === group.tags.studyProgramme)?.text} color="blue" />
)}
{group.tags?.year && <Label tag content={startYearToAcademicYear(group.tags.year)} color="blue" />}
</div>
</Wrapper>
{children}
</>
)
}
const SingleStudyGuidanceGroupContainer = ({ groupid }) => {
const dispatch = useDispatch()
const history = useHistory()
const SingleStudyGuidanceGroupContainer = ({ group }) => {
const { language } = useSelector(({ settings }) => settings)
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const group = groups.find(g => g.id === groupid)
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
const { data: population, pending } = useSelector(({ populations }) => populations)
useEffect(() => {
if (!group) return
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
if (groupStudentNumbers.length === 0) return
dispatch(getCustomPopulation({ studentnumberlist: groupStudentNumbers, usingStudyGuidanceGroups: true }))
dispatch(
getCustomPopulationCoursesByStudentnumbers({
studentnumberlist: groupStudentNumbers,
usingStudyGuidanceGroups: true,
})
)
}, [])
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
const { data, isLoading } = useGetStudyGuidanceGroupPopulationQuery(groupStudentNumbers)
if (!group) {
return (
<StyledMessage>
Couldn't find group with id {groupid}! Please check that id is correct and you have rights to this study
guidance group.
</StyledMessage>
<SingleStudyGroupViewWrapper>
<StyledMessage>
Couldn't find group with this id! Please check that id is correct and you have rights to this study guidance
group.
</StyledMessage>
</SingleStudyGroupViewWrapper>
)
}
const isLoading = pending === undefined || pending === true
const isLoaded = pending === false && Object.keys(population).length > 0
const renderMessage = groupStudentNumbers.length === 0
const renderLoader = groupStudentNumbers.length > 0 && isLoading
const renderGroup = groupStudentNumbers.length > 0 && isLoaded
if (groupStudentNumbers.length === 0) {
return (
<SingleStudyGroupViewWrapper>
<StyledMessage>This study guidance group doesn't contain any students.</StyledMessage>
</SingleStudyGroupViewWrapper>
)
}
return (
<SingleStudyGroupViewWrapper history={history} groupName={group.name} language={language}>
{renderMessage ? <StyledMessage>This study guidance group doesn't contain any students.</StyledMessage> : null}
{renderLoader ? (
<Loader active inline="centered">
Loading
</Loader>
) : null}
{renderGroup ? <SingleStudyGroupContent population={population} group={group} language={language} /> : null}
<SingleStudyGroupViewWrapper
group={group}
language={language}
isLoading={isLoading}
studyProgrammes={studyProgrammes}
>
{!isLoading && <SingleStudyGroupContent population={data} group={group} language={language} />}
</SingleStudyGroupViewWrapper>
)
}
@@ -0,0 +1,4 @@
/* Hide year header from datetime picker in overview */
.studyguidancegroupoverview__yeartagselector .rdtSwitch {
display: none;
}
@@ -1,130 +1,153 @@
import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Link } from 'react-router-dom'
import React from 'react'
import { useSelector } from 'react-redux'
import { Link, useHistory } from 'react-router-dom'
import { Formik } from 'formik'
import { Form, Button, Icon } from 'semantic-ui-react'
import Datetime from 'react-datetime'
import SortableTable from '../SortableTable'
import { getTextIn, textAndDescriptionSearch } from '../../common'
import StyledMessage from './StyledMessage'
import { changeStudyGuidanceGroupTags } from '../../redux/studyGuidanceGroups'
import { getElementDetails } from '../../redux/elementdetails'
import { useToggle } from '../../common/hooks'
import { getTextIn, textAndDescriptionSearch } from 'common'
import { useChangeStudyGuidanceGroupTagsMutation } from 'redux/studyGuidanceGroups'
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
import { useToggle } from 'common/hooks'
import SortableTable from 'components/SortableTable'
import { startYearToAcademicYear, StyledMessage } from './common'
import './StudyGuidanceGroupOverview.css'
const LinkToGroup = ({ group, language }) => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/studyguidancegroups/${group.id}`}
>
{getTextIn(group.name, language)}
</Link>
)
const LinkToGroup = ({ group, language }) => {
const history = useHistory()
const dest = `/studyguidancegroups/${group.id}`
return (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={dest}
>
{getTextIn(group.name, language)}
<Icon color="blue" name="level up alternate" onClick={() => history.push(dest)} />
</Link>
)
}
const prettifyCamelCase = str => {
const splitted = str.match(/[A-Za-z][a-z]*/g) || []
return splitted.map(w => w.charAt(0).toLowerCase() + w.substring(1)).join(' ')
}
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
const dispatch = useDispatch()
const cellWrapper = { display: 'flex', gap: '8px', width: '100%' }
const cellContent = { flex: '0 1 50%' }
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
const [changeStudyGuidanceGroupTags, { isLoading }] = useChangeStudyGuidanceGroupTagsMutation()
return (
<div style={{ display: 'flex', gap: '8px' }}>
<div>
<Formik
initialValues={{ [tagName]: '' }}
onSubmit={values => {
dispatch(changeStudyGuidanceGroupTags(group.id, values))
toggleEdit()
changeStudyGuidanceGroupTags({ groupId: group.id, tags: values })
if (group.tags?.[tagName]) toggleEdit()
}}
validate={values => (!values[tagName] ? { [tagName]: `${tagName} is required` } : {})}
>
{formik => (
<Form onSubmit={formik.handleSubmit} style={{ display: 'flex', gap: '8px' }}>
{tagName === 'studyProgramme' ? (
<Form.Select
name={tagName}
search={textAndDescriptionSearch}
fluid
placeholder={
selectFieldItems.find(p => p.value === group.tags?.[tagName])?.text || 'Select study programme'
}
options={selectFieldItems}
closeOnChange
value={formik.values[tagName]}
onChange={(_, value) => formik.setFieldValue(tagName, value?.value)}
/>
) : (
<Datetime
name={tagName}
dateFormat="YYYY"
timeFormat={false}
initialvalue={group.tags?.[tagName]}
renderYear={(props, selectableYear) => <td {...props}>{selectableYear}</td>}
closeOnSelect
value={formik.values[tagName]}
onChange={value => formik.setFieldValue(tagName, value?.format('YYYY'))}
/>
<Form onSubmit={formik.handleSubmit} style={{ ...cellWrapper, alignItems: 'center' }}>
<div style={cellContent}>
{tagName === 'studyProgramme' ? (
<Form.Select
name={tagName}
search={textAndDescriptionSearch}
fluid
placeholder={
selectFieldItems.find(p => p.value === group.tags?.[tagName])?.text || 'Select study programme'
}
options={selectFieldItems}
closeOnChange
value={formik.values[tagName]}
onChange={(_, value) => formik.setFieldValue(tagName, value?.value)}
/>
) : (
<Datetime
className="studyguidancegroupoverview__yeartagselector"
name={tagName}
dateFormat="YYYY"
timeFormat={false}
initialvalue={group.tags?.[tagName]}
inputProps={{ readOnly: true }}
renderYear={(props, year) => {
const shiftBy = 2 // fix to start from 2017 instead of 2019
const shiftedProps = {
...props,
key: props.key - shiftBy,
'data-value': props['data-value'] - shiftBy,
}
const formattedAndShiftedYear = startYearToAcademicYear(year - shiftBy)
return <td {...shiftedProps}> {formattedAndShiftedYear}</td>
}}
renderInput={({ value, ...rest }) => {
return (
<div>
<input value={startYearToAcademicYear(value)} placeholder="Select year" {...rest} />
</div>
)
}}
closeOnSelect
value={formik.values[tagName]}
onChange={value => formik.setFieldValue(tagName, value?.format('YYYY'))}
/>
)}
</div>
<div style={cellContent}>
<Button type="submit" style={{ margin: '0' }} disabled={isLoading}>
Add {prettifyCamelCase(tagName)}
</Button>
</div>
{group.tags?.[tagName] && (
<div style={cellContent}>
<Button icon type="button" style={{ margin: '0' }} onClick={() => toggleEdit()}>
Close
<Icon name="close" />
</Button>
</div>
)}
<Button type="submit" style={{ margin: '0 0 1em 0' }}>
Add {prettifyCamelCase(tagName)}
</Button>
</Form>
)}
</Formik>
{group.tags?.[tagName] ? (
<Button icon style={{ margin: '0 0 1em 0' }} onClick={() => toggleEdit()}>
Close
<Icon name="close" />
</Button>
) : null}
</div>
)
}
const TagCell = ({ tagName, group, studyProgrammes }) => {
const [showEdit, toggleEdit] = useToggle()
const value = group.tags?.[tagName]
const text = tagName === 'studyProgramme' ? studyProgrammes.find(p => p.value === value)?.text : value
const getText = () => {
switch (tagName) {
case 'studyProgramme':
return studyProgrammes.find(p => p.value === group.tags?.[tagName])?.text
case 'year':
return startYearToAcademicYear(group.tags?.[tagName])
default:
throw Error(`Wrong tagname: ${tagName}`)
}
}
return group.tags?.[tagName] && !showEdit ? (
<div style={{ display: 'flex', gap: '8px', alignItems: 'baseline' }}>
<p>{text}</p>
<Button type="button" onClick={() => toggleEdit()}>
Edit {prettifyCamelCase(tagName)}
</Button>
<div style={{ ...cellWrapper, alignItems: 'baseline' }}>
<p style={{ ...cellContent, textAlign: 'center' }}>{getText()}</p>
<div style={cellContent}>
<Button type="button" onClick={() => toggleEdit()}>
Edit {prettifyCamelCase(tagName)}
</Button>
</div>
</div>
) : (
<AssociateTagForm group={group} tagName={tagName} toggleEdit={toggleEdit} selectFieldItems={studyProgrammes} />
)
}
const StudyGuidanceGroupOverview = () => {
const dispatch = useDispatch()
const StudyGuidanceGroupOverview = ({ groups }) => {
const { language } = useSelector(({ settings }) => settings)
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const { data: elementDetails } = useSelector(({ elementdetails }) => elementdetails)
useEffect(() => {
if (elementDetails && elementDetails.length > 0) return
dispatch(getElementDetails())
}, [dispatch])
const studyProgrammesFilteredForDropdown =
elementDetails
?.filter(elem => elem.code.startsWith('KH') || elem.code.startsWith('MH'))
.map(elem => ({
key: elem.code,
value: elem.code,
description: elem.code,
text: getTextIn(elem.name, language),
})) || []
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
const headers = [
{
@@ -136,22 +159,31 @@ const StudyGuidanceGroupOverview = () => {
style: {
padding: '0',
},
className: 'iconCellNoPointer',
},
},
{
key: 'students',
title: 'students',
getRowVal: group => group.members?.length || 0,
getRowContent: group => group.members?.length || 0,
cellProps: {
style: {
padding: '0',
textAlign: 'center',
},
},
},
{
key: 'studyProgramme',
title: 'Study programme',
getRowVal: () => 'studyProgramme',
headerProps: { onClick: null, sorted: null },
getRowContent: group => (
<TagCell tagName="studyProgramme" studyProgrammes={studyProgrammesFilteredForDropdown} group={group} />
),
getRowVal: group => group.tags?.studyProgramme,
getRowContent: group => <TagCell tagName="studyProgramme" studyProgrammes={studyProgrammes} group={group} />,
},
{
key: 'associatedyear',
title: 'Associated year',
getRowVal: () => 'associatedYear',
headerProps: { onClick: null, sorted: null },
title: 'Associated starting academic year',
getRowVal: group => group.tags?.year,
getRowContent: group => <TagCell tagName="year" group={group} />,
},
]
@@ -0,0 +1,62 @@
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Segment, Button } from 'semantic-ui-react'
import InfoBox from 'components/Info/InfoBox'
import PopulationCourseStatsFlat from 'components/PopulationCourseStats/PopulationCourseStatsFlat'
import PopulationCourseStats from 'components/PopulationCourseStats'
import useCourseFilter from 'components/FilterTray/filters/Courses/useCourseFilter'
import SegmentDimmer from 'components/SegmentDimmer'
import { useGetStudyGuidanceGroupPopulationCoursesQuery } from 'redux/studyGuidanceGroups'
import { getMandatoryCourses, getMandatoryCourseModules } from 'redux/populationMandatoryCourses'
import infotooltips from 'common/InfoToolTips'
const StudyGuidanceGroupPopulationCourses = ({
selectedStudents,
showStructured,
toggleShowStructured,
studyProgramme,
}) => {
const { setCourses, resetCourses } = useCourseFilter()
const dispatch = useDispatch()
const mandatoryCourses = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses.data)
const { data: courses, isLoading } = useGetStudyGuidanceGroupPopulationCoursesQuery(selectedStudents)
useEffect(() => {
// ensure mandatory courses are available for course stats structured
if (showStructured && studyProgramme && mandatoryCourses.length === 0) {
dispatch(getMandatoryCourses(studyProgramme))
dispatch(getMandatoryCourseModules(studyProgramme))
}
}, [showStructured])
useEffect(() => {
if (!isLoading) setCourses(courses.coursestatistics)
}, [courses])
useEffect(() => {
return resetCourses
}, [])
if (isLoading) return <SegmentDimmer isLoading={isLoading} />
return (
<Segment basic>
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
{studyProgramme && (
<Button primary onClick={() => toggleShowStructured()} style={{ marginLeft: '1em' }}>
{showStructured ? 'Show courses as flat list' : 'Show by programme structure'}
</Button>
)}
{showStructured ? (
<PopulationCourseStats courses={courses} pending={isLoading} selectedStudents={selectedStudents} />
) : (
<PopulationCourseStatsFlat
courses={courses}
pending={isLoading}
selectedStudents={selectedStudents}
showFilter={false}
/>
)}
</Segment>
)
}
export default StudyGuidanceGroupPopulationCourses
@@ -1,4 +0,0 @@
import React from 'react'
import { Message } from 'semantic-ui-react'
export default ({ children }) => <Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
@@ -0,0 +1,18 @@
import React from 'react'
import { Message, Segment } from 'semantic-ui-react'
export const startYearToAcademicYear = year => {
return year === '' || Number.isNaN(year) ? '' : `${year} - ${parseInt(year, 10) + 1}`
}
export const StyledMessage = ({ children }) => (
<Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
)
export const Wrapper = ({ isLoading, children }) => (
<div className="segmentContainer">
<Segment loading={isLoading} className="contentSegment">
{children}
</Segment>
</div>
)
@@ -1,26 +1,16 @@
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useRouteMatch } from 'react-router-dom'
import { Header, Loader, Segment } from 'semantic-ui-react'
import { useTitle } from '../../common/hooks'
import { getStudyGuidanceGroups } from '../../redux/studyGuidanceGroups'
import React from 'react'
import { useParams } from 'react-router-dom'
import { Header } from 'semantic-ui-react'
import { useGetAllStudyGuidanceGroupsQuery } from 'redux/studyGuidanceGroups'
import { useTitle } from 'common/hooks'
import StudyGuidanceGroupOverview from './StudyGuidanceGroupOverview'
import SingleStudyGuidanceGroup from './SingleStudyGuidanceGroup'
import { Wrapper } from './common'
const StudyGuidanceGroups = () => {
useTitle('Study guidance groups')
const dispatch = useDispatch()
const { pending, data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const match = useRouteMatch('/studyguidancegroups/:groupid')
const groupid = match?.params?.groupid
useEffect(() => {
if (groups && groups.length > 0) return
dispatch(getStudyGuidanceGroups())
}, [dispatch])
const isLoading = pending === undefined || pending === true
const isLoaded = pending === false
const { groupid } = useParams()
const { data, isLoading } = useGetAllStudyGuidanceGroupsQuery()
return (
<>
@@ -28,16 +18,9 @@ const StudyGuidanceGroups = () => {
<Header className="segmentTitle" size="large">
Study guidance groups
</Header>
<Segment className="contentSegment">
{isLoading ? (
<Loader active inline="centered">
Loading
</Loader>
) : null}
{isLoaded && !groupid ? <StudyGuidanceGroupOverview /> : null}
</Segment>
</div>
<div>{isLoaded && groupid ? <SingleStudyGuidanceGroup groupid={groupid} /> : null}</div>
<Wrapper isLoading={isLoading}>{!isLoading && !groupid && <StudyGuidanceGroupOverview groups={data} />}</Wrapper>
{!isLoading && groupid && <SingleStudyGuidanceGroup group={data.find(group => group.id === groupid)} />}
</>
)
}
@@ -0,0 +1,44 @@
import React from 'react'
const ReactHighcharts = require('react-highcharts')
const colors = ['#003E65', '#1392c2', '#036415']
const StackedBarChart = ({ data, categories }) => {
const dataWithColors = data?.map((series, index) => ({ ...series, color: colors[index] }))
const defaultConfig = {
title: {
text: '',
},
series: dataWithColors,
credits: {
text: 'oodikone | TOSKA',
},
xAxis: {
categories,
},
chart: {
type: 'column',
width: 950,
height: 400,
},
plotOptions: {
column: {
dataLabels: {
enabled: true,
},
},
},
yAxis: {
allowDecimals: false,
min: 0,
reversed: false,
title: '',
},
}
return <ReactHighcharts config={defaultConfig} />
}
export default StackedBarChart
@@ -0,0 +1,32 @@
import React from 'react'
import { Table } from 'semantic-ui-react'
const DataTable = ({ data, titles }) => {
if (!data || !titles) return null
return (
<div className="table-container">
<Table celled>
<Table.Header>
<Table.Row>
{titles?.map(title => (
<Table.HeaderCell>{title}</Table.HeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{data?.map(yearArray => (
<Table.Row>
{yearArray?.map(value => (
<Table.Cell>{value}</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
</div>
)
}
export default DataTable
@@ -0,0 +1,42 @@
import React from 'react'
import '../studyprogramme.css'
const ReactHighcharts = require('react-highcharts')
const colors = ['#003E65', '#1392c2', '#E68825', '#333737', '#036415']
const LineGraph = ({ categories, data }) => {
const dataWithColors = data?.map((series, index) => ({ ...series, color: colors[index] }))
const defaultConfig = {
title: {
text: '',
},
series: dataWithColors,
credits: {
text: 'oodikone | TOSKA',
},
chart: {
height: '400px',
},
xAxis: {
categories,
},
yAxis: {
allowDecimals: false,
min: 0,
reversed: false,
title: '',
},
}
if (!data || !categories) return <div>No data available for the selected studyprogramme</div>
return (
<div className="graph-container">
<ReactHighcharts config={defaultConfig} />
</div>
)
}
export default LineGraph
@@ -0,0 +1,48 @@
import React from 'react'
const ReactHighcharts = require('react-highcharts')
const colors = ['#003E65', '#1392c2', '#036415']
const StackedBarChart = ({ data, categories }) => {
const dataWithColors = data?.map((series, index) => ({ ...series, color: colors[index] }))
const defaultConfig = {
title: {
text: '',
},
series: dataWithColors,
credits: {
text: 'oodikone | TOSKA',
},
xAxis: {
categories,
},
chart: {
type: 'column',
width: 950,
height: 400,
},
plotOptions: {
column: {
stacking: 'normal',
dataLabels: {
enabled: true,
},
},
},
tooltip: {
pointFormat: '<b>{series.name}: {point.percentage:.1f} %</b>',
},
yAxis: {
allowDecimals: false,
min: 0,
reversed: false,
title: '',
},
}
return <ReactHighcharts config={defaultConfig} />
}
export default StackedBarChart
@@ -0,0 +1,76 @@
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { Divider } from 'semantic-ui-react'
import LineGraph from './LineGraph'
import StackedBarChart from './StackedBarChart'
import BarChart from './BarChart'
import DataTable from './DataTable'
import InfoBox from '../../Info/InfoBox'
import { getBasicStats, getCreditStats, getGraduationStats } from '../../../redux/studyProgramme'
import InfotoolTips from '../../../common/InfoToolTips'
import '../studyprogramme.css'
const basicStatsTitles = ['', 'Started', 'Graduated', 'Cancelled', 'Transferred away', 'Transferred to']
const creditStatsTitles = ['', 'Major students credits', 'Non major students credits', 'Transferred credits']
const graduationStatsTitles = ['', 'Graduated', 'Wrote thesis']
const Overview = props => {
const toolTips = InfotoolTips.Studyprogramme
const {
studyprogramme,
basicStats,
creditStats,
graduationStats,
dispatchGetBasicStats,
dispatchGetCreditStats,
dispatchGetGetGraduationStats,
} = props
useEffect(() => {
dispatchGetBasicStats(studyprogramme)
dispatchGetCreditStats(studyprogramme)
dispatchGetGetGraduationStats(studyprogramme)
}, [])
const getDivider = (title, toolTipText) => (
<>
<div className="divider">
<Divider horizontal>{title}</Divider>
</div>
<InfoBox content={toolTips[toolTipText]} />
</>
)
return (
<div className="studyprogramme-overview">
{getDivider('Students of the studyprogramme', 'StudentsOfTheStudyprogramme')}
<div className="section-container">
<LineGraph categories={basicStats?.data?.years} data={basicStats?.data?.graphStats} />
<DataTable titles={basicStatsTitles} data={basicStats?.data?.tableStats} />
</div>
{getDivider('Credits produced by the studyprogramme')}
<div className="section-container">
<StackedBarChart categories={creditStats?.data?.years} data={creditStats?.data?.graphStats} />
<DataTable titles={creditStatsTitles} data={creditStats?.data?.tableStats} />
</div>
{getDivider('Graduated and thesis writers of the programme')}
<div className="section-container">
<BarChart categories={graduationStats?.data?.years} data={graduationStats?.data?.graphStats} />
<DataTable titles={graduationStatsTitles} data={graduationStats?.data?.tableStats} />
</div>
</div>
)
}
const mapStateToProps = ({ studyProgramme }) => ({
basicStats: studyProgramme?.basicStats,
creditStats: studyProgramme?.creditStats,
graduationStats: studyProgramme?.graduationStats,
})
export default connect(mapStateToProps, {
dispatchGetBasicStats: getBasicStats,
dispatchGetCreditStats: getCreditStats,
dispatchGetGetGraduationStats: getGraduationStats,
})(Overview)
@@ -4,7 +4,7 @@ import { Table, Header, Grid, Label, Segment, Dropdown } from 'semantic-ui-react
import { connect } from 'react-redux'
import { shape, number, arrayOf, bool, string, oneOfType } from 'prop-types'
import { getProductivity } from '../../../redux/productivity'
import InfoBox from '../../InfoBox'
import InfoBox from '../../Info/InfoBox'
import infotooltips from '../../../common/InfoToolTips'
import './productivityTable.css'
@@ -1,14 +1,16 @@
import React from 'react'
import { connect } from 'react-redux'
import { Segment } from 'semantic-ui-react'
import { arrayOf, shape, string, func } from 'prop-types'
import { getCourseSearchResults } from '../../../selectors/courses'
import useLanguage from '../../LanguagePicker/useLanguage'
import SortableTable from '../../SortableTable'
const SearchResult = ({ courses, getCourseActions }) =>
courses.length === 0 ? (
<Segment content="No results" />
) : (
const SearchResult = ({ courses, getCourseActions }) => {
const { language } = useLanguage()
if (!courses || courses.length === 0) return <Segment content="No results" />
return (
<SortableTable
getRowKey={c => c.code}
tableProps={{ celled: true }}
@@ -22,7 +24,7 @@ const SearchResult = ({ courses, getCourseActions }) =>
{
key: 'name',
title: 'Name',
getRowVal: c => c.name,
getRowVal: c => c.name[language],
headerProps: { width: 12, textAlign: 'left' },
},
{
@@ -35,15 +37,6 @@ const SearchResult = ({ courses, getCourseActions }) =>
data={courses}
/>
)
SearchResult.propTypes = {
courses: arrayOf(
shape({
code: string,
name: string,
})
),
getCourseActions: func.isRequired,
}
SearchResult.defaultProps = {
@@ -1,6 +1,5 @@
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { string, func, bool, arrayOf, shape } from 'prop-types'
import { Dimmer, Loader, Segment, Button } from 'semantic-ui-react'
import { getThesisCourses, deleteThesisCourse } from '../../../redux/thesisCourses'
import SortableTable from '../../SortableTable'
@@ -53,19 +52,6 @@ const ThesisCourseList = ({ studyprogramme, getThesisCourses, pending, data, del
)
}
ThesisCourseList.propTypes = {
studyprogramme: string.isRequired,
pending: bool.isRequired,
getThesisCourses: func.isRequired,
deleteThesisCourse: func.isRequired,
data: arrayOf(
shape({
courseCode: string,
thesisType: string,
})
),
}
ThesisCourseList.defaultProps = {
data: [],
}
@@ -1,6 +1,5 @@
import React from 'react'
import { Button, Message } from 'semantic-ui-react'
import { string, func } from 'prop-types'
import { connect } from 'react-redux'
import CourseSearchForm from '../../CourseSearchForm'
import ThesisCourseList from './ThesisCourseList'
@@ -34,12 +33,6 @@ const ThesisCourses = ({ studyprogramme, createThesis, getTheses }) => {
)
}
ThesisCourses.propTypes = {
studyprogramme: string.isRequired,
createThesis: func.isRequired,
getTheses: func.isRequired,
}
export default connect(null, {
createThesis: createNewThesisCourse,
getTheses: getThesisCourses,
@@ -6,7 +6,7 @@ import { Link } from 'react-router-dom'
import { flatten, uniq, range } from 'lodash'
import { getUserIsAdmin } from '../../../common'
import { isDev } from '../../../conf'
import InfoBox from '../../InfoBox'
import InfoBox from '../../Info/InfoBox'
import infotooltips from '../../../common/InfoToolTips'
const getMonths = year => {
@@ -1,16 +1,18 @@
import React, { useCallback, useEffect } from 'react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'
import { connect, useSelector } from 'react-redux'
import { Header, Segment, Tab, Button } from 'semantic-ui-react'
import { isEqual, uniqBy } from 'lodash'
import DegreeCoursesTable from './DegreeCourses'
import StudyProgrammeSelector from './StudyProgrammeSelector'
import Overview from './Overview'
import NewOverview from './NewOverview'
import StudyTrackOverview from './StudyTrackOverview'
import ThesisCourses from './ThesisCourses'
import '../PopulationQueryCard/populationQueryCard.css'
import { getTextIn, getUserRoles, getUserIsAdmin } from '../../common'
import { useTabs, useTitle } from '../../common/hooks'
import { isDev } from '../../conf'
import TSA from '../../common/tsa'
import Tags from './Tags'
@@ -23,7 +25,9 @@ import useLanguage from '../LanguagePicker/useLanguage'
const StudyProgramme = props => {
const { language } = useLanguage()
const { roles } = useSelector(state => state.auth.token)
const [tab, setTab] = useTabs('p_tab', 0, props.history)
useTitle('Study programmes')
useEffect(() => {
@@ -57,6 +61,8 @@ const StudyProgramme = props => {
})
}
const SHOW_NEW_OVERVIEW = getUserIsAdmin(roles) && isDev
const getPanes = () => {
const { match, programmes } = props
const { studyProgrammeId } = match.params
@@ -71,6 +77,12 @@ const StudyProgramme = props => {
menuItem: 'Overview',
render: () => <Overview studyprogramme={studyProgrammeId} history={props.history} />,
})
if (SHOW_NEW_OVERVIEW) {
panes.push({
menuItem: 'New Overview',
render: () => <NewOverview studyprogramme={studyProgrammeId} history={props.history} />,
})
}
if (filteredStudytracks.length > 0) {
panes.push({
menuItem: 'Studytrack overview',
@@ -0,0 +1,20 @@
.studyprogramme-overview .divider {
margin-top: 4em;
}
.studyprogramme-overview .section-container {
display: flex;
justify-content: space-between;
}
.studyprogramme-overview .graph-container {
width: 60%;
height: 400px;
}
.studyprogramme-overview .table-container {
width: 35%;
height: 400px;
margin-top: 2em;
margin-left: 2em;
}
+2 -9
View File
@@ -4,18 +4,14 @@ import { Provider } from 'react-redux'
import 'semantic-ui-css/semantic.min.css'
import 'react-datetime/css/react-datetime.css'
import './styles/custom.css'
import { debugContextDevtool } from 'react-context-devtool'
import store from './store'
import store from './redux'
import ErrorBoundary from './components/ErrorBoundary'
import Main from './components/Main'
import CommonContext from './CommonContext'
import initializeSentry from './util/sentry'
import { isProduction } from './conf'
initializeSentry()
const container = document.getElementById('root')
ReactDOM.render(
<Provider store={store}>
<CommonContext>
@@ -24,8 +20,5 @@ ReactDOM.render(
</ErrorBoundary>
</CommonContext>
</Provider>,
container
document.getElementById('root')
)
// Best not to use this in production as the performance impact appears to be quite high.
debugContextDevtool(container, { disable: isProduction })
+7 -1
View File
@@ -1,12 +1,15 @@
import { decodeToken } from '../common'
export const loginPrefix = 'LOGIN_'
export const logoutPrefix = 'LOGOUT_'
export const login = () => ({
type: 'LOGIN_ATTEMPT',
})
export const logout = () => ({ type: 'LOGOUT_ATTEMPT' })
const reducer = (state = { pending: false, error: false, token: null }, action) => {
const reducer = (state = { pending: false, error: false, token: null, encodedToken: null }, action) => {
switch (action.type) {
case 'LOGIN_ATTEMPT':
return {
@@ -28,6 +31,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: false,
token: decodeToken(action.token),
encodedToken: action.token,
}
case 'LOGOUT_ATTEMPT':
@@ -43,6 +47,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: true,
token: null,
encodedToken: null,
}
case 'LOGOUT_SUCCESS':
@@ -51,6 +56,7 @@ const reducer = (state = { pending: false, error: false, token: null }, action)
pending: false,
error: false,
token: null,
encodedToken: null,
}
default:
@@ -1,85 +0,0 @@
import { actionTypes } from '../../apiConnection'
const additionalTypes = prefix => ({
reset: `${prefix}_CLEAR`,
})
const createReducer = (
prefix,
{
reduceGET = (_, action) => action.response,
reducePOST = (data, action) => data.concat(action.response),
reduceDELETE = (data, action) => data.filter(d => d.id === action.query.id),
defaults = undefined,
}
) => {
const prefixGET = `${prefix}GET_`
const prefixPOST = `${prefix}POST_`
const prefixDELETE = `${prefix}DELETE_`
const read = actionTypes(prefixGET)
const create = actionTypes(prefixPOST)
const destroy = actionTypes(prefixDELETE)
const other = additionalTypes(prefix)
const initialState = {
pending: false,
error: false,
data: [],
...defaults,
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case destroy.attempt:
case create.attempt:
case read.attempt:
return {
...state,
error: false,
pending: true,
}
case destroy.failure:
case create.failure:
case read.failure:
return {
...state,
error: true,
pending: false,
}
case read.success:
return {
...state,
data: reduceGET(state.data, action),
error: false,
pending: false,
}
case create.success:
return {
...state,
data: reducePOST(state.data, action),
error: false,
pending: false,
}
case destroy.success:
return {
...state,
data: reduceDELETE(state.data, action),
error: false,
pending: false,
}
case other.reset: {
return {
...initialState,
}
}
default:
return state
}
}
return {
reducer,
prefixGET,
prefixPOST,
prefixDELETE,
}
}
export default createReducer
@@ -0,0 +1,33 @@
import { actionSuffixes } from '../../apiConnection'
export const matcherReducers = [
{
matcher: action => action.type.endsWith(actionSuffixes.attempt),
reducer(state) {
state.error = false
state.pending = true
},
},
{
matcher: action => action.type.endsWith(actionSuffixes.failure),
reducer(state) {
state.error = true
state.pending = false
},
},
{
matcher: action => action.type.endsWith(actionSuffixes.success),
reducer(state) {
state.error = false
state.pending = false
},
},
]
export const defaultReducer = () => {}
export const initialState = {
pending: false,
error: false,
data: [],
}
@@ -1,5 +1,8 @@
import { RTKApi } from 'apiConnection'
import { getTextIn } from 'common'
import { callController } from '../apiConnection'
// original elementdetail stuff
export const getElementDetails = () => {
const route = '/elementdetails/all'
const prefix = 'GET_ELEMENTDETAILS_'
@@ -31,4 +34,38 @@ const reducer = (state = { data: [] }, action) => {
}
}
// RTK query based stuff
const elementDetailsApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getAllElementDetails: builder.query({
query: () => '/elementdetails/all',
providesTags: result => [
...result?.map(({ id }) => ({ type: 'StudyGuidanceGroups', id })),
{ type: 'StudyGuidanceGroups', id: 'LIST' },
],
}),
}),
overrideExisting: false,
})
export const { useGetAllElementDetailsQuery } = elementDetailsApi
// Returns only newest studyprogrammes and formats them to be used in semantic ui dropdowns
export const useFilteredAndFormattedElementDetails = language => {
const { data, isLoading } = useGetAllElementDetailsQuery()
const filteredAndFormatted = isLoading
? []
: data
.filter(elem => elem.code.startsWith('KH') || elem.code.startsWith('MH'))
.map(elem => ({
key: elem.code,
value: elem.code,
description: elem.code,
text: getTextIn(elem.name, language),
}))
return filteredAndFormatted
}
export default reducer
+59 -44
View File
@@ -1,4 +1,6 @@
import { combineReducers } from 'redux'
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query/react'
import { handleRequest, handleAuth, RTKApi } from 'apiConnection'
import actionHistory from './actionHistory'
import users from './users'
import populations from './populations'
@@ -39,48 +41,61 @@ import singleCourseStats from './singleCourseStats'
import userAccessEmail from './userAccessEmail'
import customPopulationSearch from './customPopulationSearch'
import coolDataScience from './coolDataScience'
import studyGuidanceGroups from './studyGuidanceGroups'
import studyProgramme from './studyProgramme'
export default combineReducers({
actionHistory,
users,
mandatoryCourseLabels,
populations,
populationCourses,
populationSelectedStudentCourses,
populationProgrammes,
populationProgrammesUnfiltered,
populationMandatoryCourses,
studyProgrammeProductivity,
studyProgrammeThroughput,
studyProgrammeBachelors,
faculties,
units,
students,
errors,
courses,
courseInstances,
graphSpinner,
settings,
teachers,
providers,
semesters,
teacherStatistics,
teachersTop,
teachersTopCategories,
courseSearch,
courseStats,
courseSummaryForm,
thesisCourses,
accessGroups,
elementdetails,
feedback,
tags,
tagstudent,
auth,
singleCourseStats,
userAccessEmail,
customPopulationSearch,
coolDataScience,
studyGuidanceGroups,
const store = configureStore({
reducer: {
actionHistory,
users,
mandatoryCourseLabels,
populations,
populationCourses,
populationSelectedStudentCourses,
populationProgrammes,
populationProgrammesUnfiltered,
populationMandatoryCourses,
studyProgrammeProductivity,
studyProgrammeThroughput,
studyProgrammeBachelors,
faculties,
units,
students,
errors,
courses,
courseInstances,
graphSpinner,
settings,
teachers,
providers,
semesters,
teacherStatistics,
teachersTop,
teachersTopCategories,
courseSearch,
courseStats,
courseSummaryForm,
thesisCourses,
accessGroups,
elementdetails,
feedback,
tags,
tagstudent,
auth,
singleCourseStats,
userAccessEmail,
customPopulationSearch,
coolDataScience,
[RTKApi.reducerPath]: RTKApi.reducer,
studyProgramme,
},
// oodikone is currently too heavy for other middlewares than thunk, but
// feel free to take use them at some point if possible
middleware: getDefaultMiddleware => [
...getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }),
handleRequest,
handleAuth,
RTKApi.middleware,
],
})
setupListeners(store.dispatch)
export default store
@@ -1,80 +1,43 @@
import { callController, actionTypes } from '../apiConnection'
import { RTKApi } from 'apiConnection'
const getPrefix = 'GET_STUDY_GUIDANCE_GROUPS_'
const getTypes = {
...actionTypes(getPrefix),
}
const studyGuidanceGroupsApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getAllStudyGuidanceGroups: builder.query({
query: () => '/studyguidancegroups',
providesTags: result => [
...result?.map(({ id }) => ({ type: 'StudyGuidanceGroups', id })),
{ type: 'StudyGuidanceGroups', id: 'LIST' },
],
}),
changeStudyGuidanceGroupTags: builder.mutation({
query: ({ groupId, tags }) => ({
url: `/studyguidancegroups/${groupId}/tags`,
method: 'PUT',
body: tags,
}),
invalidatesTags: ({ studyGuidanceGroupId }) => [{ type: 'StudyGuidanceGroups', id: studyGuidanceGroupId }],
}),
getStudyGuidanceGroupPopulation: builder.query({
query: studentnumberlist => ({
url: '/v3/populationstatisticsbystudentnumbers',
method: 'POST',
body: { studentnumberlist, usingStudyGuidanceGroups: true },
}),
}),
getStudyGuidanceGroupPopulationCourses: builder.query({
query: studentnumberlist => ({
url: '/v2/populationstatistics/coursesbystudentnumberlist',
method: 'POST',
body: { studentnumberlist, usingStudyGuidanceGroups: true },
}),
}),
}),
overrideExisting: false,
})
export const getStudyGuidanceGroups = () => {
const route = '/studyguidancegroups'
return callController(route, getPrefix)
}
const changeTagsPrefix = 'CHANGE_STUDY_GUIDANCE_GROUP_TAGS_'
const changeTagsTypes = {
...actionTypes(changeTagsPrefix),
}
export const changeStudyGuidanceGroupTags = (groupId, tags) => {
const route = `/studyguidancegroups/${groupId}/tags`
return callController(route, changeTagsPrefix, tags, 'put')
}
const initialState = {
pending: false,
error: false,
data: [],
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case getTypes.attempt:
return {
...state,
error: false,
pending: true,
}
case getTypes.failure:
return {
...state,
pending: false,
error: true,
}
case getTypes.success:
return {
...state,
pending: false,
error: false,
data: action.response,
}
case changeTagsTypes.attempt:
return {
...state,
error: false,
pending: false,
}
case changeTagsTypes.failure:
return {
...state,
pending: false,
error: true,
}
case changeTagsTypes.success:
return {
...state,
pending: false,
error: false,
data: state.data.map(group =>
group.id === action.response.studyGuidanceGroupId
? {
...state.data.find(group => group.id === action.response.studyGuidanceGroupId),
tags: (({ studyGuidanceGroupId, ...rest }) => rest)(action.response), // omit id
}
: group
),
}
default:
return state
}
}
export default reducer
export const {
useGetAllStudyGuidanceGroupsQuery,
useChangeStudyGuidanceGroupTagsMutation,
useGetStudyGuidanceGroupPopulationQuery,
useGetStudyGuidanceGroupPopulationCoursesQuery,
} = studyGuidanceGroupsApi
@@ -0,0 +1,42 @@
import { createReducer } from '@reduxjs/toolkit'
import { callController, actionTypes } from '../apiConnection'
import { initialState, matcherReducers, defaultReducer } from './common'
const baseUrl = '/v2/studyprogrammes'
const getBasicsPrefix = 'GET_STUDY_PROGRAMME_BASIC_STATS_'
const getBasics = {
...actionTypes(getBasicsPrefix),
}
const getCreditsPrefix = 'GET_STUDY_PROGRAMME_CREDIT_STATS_'
const getCredits = {
...actionTypes(getCreditsPrefix),
}
const getGraduationPrefix = 'GET_STUDY_PROGRAMME_GRADUATION_STATS_'
const getGraduation = {
...actionTypes(getGraduationPrefix),
}
export const getBasicStats = id => callController(`${baseUrl}/${id}/basicstats`, getBasicsPrefix)
export const getCreditStats = id => callController(`${baseUrl}/${id}/creditstats`, getCreditsPrefix)
export const getGraduationStats = id => callController(`${baseUrl}/${id}/graduationstats`, getGraduationPrefix)
const reducer = createReducer(
initialState,
{
[getBasics.success]: (state, action) => {
state.basicStats = action.response
},
[getCredits.success]: (state, action) => {
state.creditStats = action.response
},
[getGraduation.success]: (state, action) => {
state.graduationStats = action.response
},
},
matcherReducers,
defaultReducer
)
export default reducer
+83 -2
View File
@@ -1,5 +1,86 @@
import createReducer from './common/crudreducer'
import { callController } from '../apiConnection/index'
import { callController, actionTypes } from '../apiConnection/index'
const additionalTypes = prefix => ({
reset: `${prefix}_CLEAR`,
})
const createReducer = (
prefix,
{
reduceGET = (_, action) => action.response,
reducePOST = (data, action) => data.concat(action.response),
reduceDELETE = (data, action) => data.filter(d => d.id === action.query.id),
defaults = undefined,
}
) => {
const prefixGET = `${prefix}GET_`
const prefixPOST = `${prefix}POST_`
const prefixDELETE = `${prefix}DELETE_`
const read = actionTypes(prefixGET)
const create = actionTypes(prefixPOST)
const destroy = actionTypes(prefixDELETE)
const other = additionalTypes(prefix)
const initialState = {
pending: false,
error: false,
data: [],
...defaults,
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case destroy.attempt:
case create.attempt:
case read.attempt:
return {
...state,
error: false,
pending: true,
}
case destroy.failure:
case create.failure:
case read.failure:
return {
...state,
error: true,
pending: false,
}
case read.success:
return {
...state,
data: reduceGET(state.data, action),
error: false,
pending: false,
}
case create.success:
return {
...state,
data: reducePOST(state.data, action),
error: false,
pending: false,
}
case destroy.success:
return {
...state,
data: reduceDELETE(state.data, action),
error: false,
pending: false,
}
case other.reset: {
return {
...initialState,
}
}
default:
return state
}
}
return {
reducer,
prefixGET,
prefixPOST,
prefixDELETE,
}
}
const prefix = 'PROGRAMME_THESIS_'
@@ -1,4 +1,4 @@
import { combineReducers } from 'redux'
import { combineReducers } from '@reduxjs/toolkit'
import { callController, actionTypes } from '../apiConnection'
const CLEAR_USER_ACCESS_ERRORS = 'CLEAR_USER_ACCESS_ERRORS'
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
import { sortBy, flatten } from 'lodash'
const courseStatsSelector = state => state.courseStats.data
+1 -1
View File
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
import { byDateDesc, reformatDate } from '../common'
const getData = obj => obj
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
const getPopulations = state => state.populations
@@ -1,4 +1,4 @@
import { createSelector } from 'reselect'
import { createSelector } from '@reduxjs/toolkit'
const associationsSelector = state => state.populationProgrammesUnfiltered.data

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