mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d601dbe597 | ||
|
|
e356186152 | ||
|
|
4c6153c76d | ||
|
|
abebc5d410 | ||
|
|
3dcb5f299c | ||
|
|
0632fa94f7 | ||
|
|
ed85513290 | ||
|
|
07ad8cb434 | ||
|
|
901b719888 | ||
|
|
9850faf400 | ||
|
|
176eca36eb | ||
|
|
77d5435c30 | ||
|
|
3f99df4971 | ||
|
|
819ba0db15 | ||
|
|
e8871cc845 | ||
|
|
e2c152ddc1 | ||
|
|
63c9d316d0 | ||
|
|
6b1ff9e9cb | ||
|
|
7f913c9927 | ||
|
|
b7616ec2ed | ||
|
|
d0840a7de3 | ||
|
|
cc214179f6 | ||
|
|
cbfe5aedc1 | ||
|
|
c0b61d6d4e | ||
|
|
dd28ee3c48 | ||
|
|
3f5e1e53b9 | ||
|
|
a5ef63bc2f | ||
|
|
5f7f496472 | ||
|
|
8058f8ca0a | ||
|
|
6fd5304414 | ||
|
|
2fde626606 | ||
|
|
75ff72e545 | ||
|
|
8b80f11728 | ||
|
|
4648f49ebc | ||
|
|
92fbe3b2d3 | ||
|
|
1e6c418c7e | ||
|
|
c120b9c6a9 | ||
|
|
ec7d77dca0 | ||
|
|
4a15d97207 | ||
|
|
40bb1dbc3e | ||
|
|
ad95ab6ce3 | ||
|
|
4d0a62f26c | ||
|
|
f30b415f05 | ||
|
|
c0de93ce55 | ||
|
|
6560e7bf26 | ||
|
|
4d782f9ec0 | ||
|
|
62dcd0a4ca | ||
|
|
994cb180a0 | ||
|
|
a28a1b0a6c | ||
|
|
2a5c690ab5 | ||
|
|
d7e739e20e | ||
|
|
e2c77fe63b | ||
|
|
39e9a3084b | ||
|
|
13dc659d18 | ||
|
|
aeee083d0a | ||
|
|
cd56b74d25 | ||
|
|
77eb31f840 | ||
|
|
1988182134 | ||
|
|
1b248665c4 | ||
|
|
942b02c8e2 | ||
|
|
7a34ad66e8 | ||
|
|
4fc7b97813 | ||
|
|
77a87c5b1e | ||
|
|
c7d2a552c9 | ||
|
|
63205fbfa1 | ||
|
|
df68671181 | ||
|
|
00cee9f14b | ||
|
|
905d19f46f | ||
|
|
019d10c938 | ||
|
|
7a0d91a510 | ||
|
|
10169e92a6 | ||
|
|
c74112a356 | ||
|
|
9dee8d4669 | ||
|
|
fc875d30b6 | ||
|
|
47fc6ea215 | ||
|
|
26420f9a36 | ||
|
|
c8f461b7f5 | ||
|
|
c17e53cec5 | ||
|
|
9cff76f498 | ||
|
|
b91991ac37 | ||
|
|
9eff7057e7 | ||
|
|
4c096199d2 | ||
|
|
9f39ee6b87 | ||
|
|
d06462d727 | ||
|
|
e4f65407ed | ||
|
|
26d8f2befc | ||
|
|
2713545eda | ||
|
|
fa619fed38 | ||
|
|
6d4f49879b | ||
|
|
37aeb9ebe4 | ||
|
|
5f5423971c | ||
|
|
39b3fde1a0 | ||
|
|
382c900d28 | ||
|
|
5ac2cfb13c | ||
|
|
aa6d2b83f5 | ||
|
|
201250b8af | ||
|
|
ff36a0e8f7 | ||
|
|
71c1ce703a | ||
|
|
c94711234f | ||
|
|
563d2cb4b0 | ||
|
|
3727bd03dd | ||
|
|
efb0b98cdc | ||
|
|
ef5aec5fc1 | ||
|
|
cdbbf06c18 | ||
|
|
e471b1e73a | ||
|
|
1d7f9d250a | ||
|
|
0207e58fa3 | ||
|
|
aecca8426d | ||
|
|
ef0d0a5eb7 | ||
|
|
284c5b270d | ||
|
|
51f2a7573e | ||
|
|
0837e1d972 | ||
|
|
e468186af2 | ||
|
|
3fcfe56bd0 | ||
|
|
85ad68c97d | ||
|
|
ede3b8efac | ||
|
|
cd1d65b29b | ||
|
|
456fe927e7 | ||
|
|
df36d58288 | ||
|
|
918869b307 | ||
|
|
2ba06ae787 | ||
|
|
ac0dc90661 | ||
|
|
8622bd8e6d | ||
|
|
76988e3ec5 | ||
|
|
b0a9fb48d7 | ||
|
|
16d603538f | ||
|
|
7dd810cad6 | ||
|
|
42afea5e0c | ||
|
|
6d0c5e552b | ||
|
|
897c2e4c88 | ||
|
|
673ce9f9c0 | ||
|
|
77054187a0 | ||
|
|
a9c244b1f6 | ||
|
|
ed5e2da875 | ||
|
|
25d5599466 | ||
|
|
33c0aa71d9 | ||
|
|
2e4572c871 | ||
|
|
f6b14777fe | ||
|
|
f251f9456e | ||
|
|
f364eb4f67 | ||
|
|
4896f9e458 | ||
|
|
096535e470 | ||
|
|
7c949b47a7 | ||
|
|
2a6653c700 | ||
|
|
c3b7d96b06 | ||
|
|
9a3783bc45 | ||
|
|
4882d1d9d3 | ||
|
|
9575d86d9c | ||
|
|
85c7e86ed9 | ||
|
|
d527c6ba5f | ||
|
|
6d5b91782c | ||
|
|
b8762aae34 | ||
|
|
4da78df1e6 | ||
|
|
93258ad12a | ||
|
|
0028b69661 | ||
|
|
ef902b1c0c | ||
|
|
d8e3ee092f | ||
|
|
36e8c2f7f8 | ||
|
|
8763b1868e | ||
|
|
700d799b00 | ||
|
|
b0ecc3d4f5 | ||
|
|
e681d5a94f | ||
|
|
980cd8ecf0 | ||
|
|
357c0b3f44 | ||
|
|
6467f5046e | ||
|
|
21ff440ebd | ||
|
|
73228fb26f | ||
|
|
2c1807fe10 | ||
|
|
50091a3098 | ||
|
|
3ba7965402 | ||
|
|
067a53a072 | ||
|
|
9a3956832d | ||
|
|
831b638c99 | ||
|
|
38c343b8d2 | ||
|
|
c05346ad9a | ||
|
|
dd12cd3626 | ||
|
|
55eebe32a6 | ||
|
|
58592df2a0 | ||
|
|
dd110d21e3 | ||
|
|
88443f0117 | ||
|
|
accd5c9120 | ||
|
|
c11f5d78ba | ||
|
|
9f4466d246 | ||
|
|
5504f02c31 | ||
|
|
863e84e226 | ||
|
|
0ce965abe3 | ||
|
|
e9242b3063 | ||
|
|
6ccf320bb2 | ||
|
|
4e9b579843 | ||
|
|
985084864c | ||
|
|
8099dff1b6 | ||
|
|
1528ff3d80 | ||
|
|
71c61c3393 | ||
|
|
f3b1f639b1 | ||
|
|
e599e620c0 | ||
|
|
f5b081656f | ||
|
|
7af8e1a4df | ||
|
|
8c092c8d9f | ||
|
|
e0789186ba | ||
|
|
c798a4747f | ||
|
|
8f396a9913 | ||
|
|
148a5acc25 | ||
|
|
9cf1b87b3f | ||
|
|
82ed55e1f9 | ||
|
|
82041e673d | ||
|
|
1e4405aac9 | ||
|
|
dcefdd12cb | ||
|
|
fd775252b8 | ||
|
|
acd7dc65f6 | ||
|
|
a82d272a27 | ||
|
|
1bb5ddba96 | ||
|
|
ae14a55885 | ||
|
|
9308016cf3 | ||
|
|
b5252a01f8 | ||
|
|
4b22902fa4 | ||
|
|
267ebce457 | ||
|
|
ee8013cba2 | ||
|
|
4ce07eaf31 | ||
|
|
e0d4e4072d | ||
|
|
d4aeece78e | ||
|
|
02adcbc4f8 | ||
|
|
a9d44b3d76 | ||
|
|
3d310ee184 | ||
|
|
0adf1eae76 | ||
|
|
e10d3dd3c7 | ||
|
|
fce0e482d3 | ||
|
|
4d57d00ddb | ||
|
|
7cc90a4378 | ||
|
|
ff0f1a545f | ||
|
|
6eb69a036a | ||
|
|
3c8a47fc76 | ||
|
|
735ec7efa0 | ||
|
|
c33f842c9a | ||
|
|
2340e39fb4 | ||
|
|
0221d1f70e | ||
|
|
c6fe105b46 | ||
|
|
6ec1bc118d | ||
|
|
098dd5425e | ||
|
|
9eeed5094f | ||
|
|
ada4f66105 | ||
|
|
b25c90e338 | ||
|
|
47995056cb | ||
|
|
46ee5b7764 | ||
|
|
c8f0e421fe | ||
|
|
3db6a4443e | ||
|
|
77d20ad189 | ||
|
|
bd6b2adef0 | ||
|
|
7457ada25a | ||
|
|
ab94a0b27c | ||
|
|
41e86af663 | ||
|
|
19e752b7ca | ||
|
|
3d4f71dbad | ||
|
|
ff4d858efc | ||
|
|
59271c9e38 | ||
|
|
d87fb6d0fa | ||
|
|
646d87ab9e | ||
|
|
9e687f27a3 | ||
|
|
b699322e6f | ||
|
|
3c01e3b7b1 | ||
|
|
cb256976d7 | ||
|
|
1424461e26 | ||
|
|
db66880e25 |
@@ -1,4 +1,3 @@
|
||||
# Populate and change this to .env
|
||||
IMPORTER_DB_API_TOKEN=
|
||||
IMPORTER_DB_API_URL=https://importer.cs.helsinki.fi
|
||||
PATE_TOKEN=
|
||||
|
||||
@@ -59,7 +59,6 @@ jobs:
|
||||
run: npm run eslint services/userservice
|
||||
|
||||
# === Build images ===
|
||||
|
||||
build_backend:
|
||||
name: 'Build backend'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -142,6 +141,81 @@ jobs:
|
||||
with:
|
||||
path: /tmp/userservice.tar
|
||||
|
||||
build_and_push_anon_sis_db:
|
||||
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: check_migrations
|
||||
with:
|
||||
filters: |
|
||||
migrations:
|
||||
- 'updater/sis-updater-worker/src/db/migrations/**'
|
||||
- '.github/workflows/test-oodikone.yaml'
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: registry.toska.cs.helsinki.fi
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
- name: 'Let sis-updater-worker run migrations, then push new version of image to toska hub'
|
||||
if: steps.check_migrations.outputs.migrations == 'true'
|
||||
run: |
|
||||
docker-compose --file docker-compose.ci.yml up --detach sis-updater-worker
|
||||
sleep 30 # wait for sis-updater-worker to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh sis-db
|
||||
|
||||
build_and_push_anon_kone_db:
|
||||
name: 'Build anon kone-db and push to toska hub if new migrations have been added'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: check_migrations
|
||||
with:
|
||||
filters: |
|
||||
migrations:
|
||||
- 'services/backend/src/database/migrations_kone/**'
|
||||
- '.github/workflows/test-oodikone.yaml'
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: registry.toska.cs.helsinki.fi
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
- name: 'Let backend run migrations, then push new version of image to toska hub'
|
||||
if: steps.check_migrations.outputs.migrations == 'true'
|
||||
run: |
|
||||
docker-compose --file docker-compose.ci.yml up --detach backend
|
||||
sleep 30 # wait for sis-updater-worker to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh kone-db
|
||||
|
||||
build_and_push_anon_user_db:
|
||||
name: 'Build anon user-db and push to toska hub if new migrations have been added'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: check_migrations
|
||||
with:
|
||||
filters: |
|
||||
migrations:
|
||||
- 'services/userservice/src/database/migrations/**'
|
||||
- '.github/workflows/test-oodikone.yaml'
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: registry.toska.cs.helsinki.fi
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
- name: 'Let userservice run migrations, then push new version of image to toska hub'
|
||||
if: steps.check_migrations.outputs.migrations == 'true'
|
||||
run: |
|
||||
docker-compose --file docker-compose.ci.yml up --detach userservice
|
||||
sleep 30 # wait for sis-updater-worker to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh user-db
|
||||
|
||||
# === Integration tests ===
|
||||
#
|
||||
run_userservice_integration_tests:
|
||||
@@ -160,6 +234,9 @@ jobs:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- build_userservice
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -203,6 +280,9 @@ jobs:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- build_userservice
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -246,6 +326,9 @@ jobs:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- build_userservice
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -289,6 +372,9 @@ jobs:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- build_userservice
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -332,6 +418,9 @@ jobs:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- build_userservice
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -375,6 +464,9 @@ jobs:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- build_userservice
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -418,6 +510,9 @@ jobs:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- build_userservice
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -465,6 +560,9 @@ jobs:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- build_userservice
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ module.exports = {
|
||||
'{services,updater}/**/*.{js,jsx}': files => `eslint --fix ${files.join(' ')}`,
|
||||
'*.{js,json,md,yml,yaml,html}': files => `prettier --write ${files.join(' ')}`,
|
||||
'*.css': files => `stylelint --fix ${files.join(' ')}`,
|
||||
Dockerfile: files => `${dockerCmdBase} hadolint/hadolint:v2.7.0-alpine hadolint ${relativeFilePaths(files)}`,
|
||||
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck-alpine:v0.7.2 ${relativeFilePaths(files)} -x`,
|
||||
Dockerfile: files => `${dockerCmdBase} hadolint/hadolint:v2.7.0 hadolint ${relativeFilePaths(files)}`,
|
||||
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.7.2 ${relativeFilePaths(files)} -x`,
|
||||
'.github/workflows/*': files => `${dockerCmdBase} rhysd/actionlint:1.6.2 ${relativeFilePaths(files)}`,
|
||||
'docker-compose*': files => {
|
||||
const composeFiles = file => {
|
||||
|
||||
@@ -43,8 +43,8 @@ What different CLI options do:
|
||||
- Cleans up any previous real data databases
|
||||
- Downloads needed database dumps from production servers.
|
||||
- Creates real data databases and populates them with downloaded dumps.
|
||||
3. Reset sis-importer data
|
||||
- Like option 2, but only for sis-importer database. Useful when developing updater microservice.
|
||||
3. Reset single database
|
||||
- Like option 2, but only for single database. Leaves other databases untouched.
|
||||
|
||||
## ⌨️ Development
|
||||
|
||||
@@ -124,12 +124,16 @@ Continuous integration (CI) works with Github actions and is defined in workflow
|
||||
|
||||
### Modules are missing after updating package.json
|
||||
|
||||
You should always install the dependencies **inside** the container to have the application **inside** the container access them. This might be the case when someone else installs a new library and you only pull the changes in package.json. Use `docker exec -it <service> sh` to get inside the container and run `npm ci` to install modules.
|
||||
You should always install the dependencies **inside** the container to have the application **inside** the container access them. Module might be missing for example when someone else installs a new library and you only pull the changes in package.json. Use `docker exec <service> npm ci` to install modules inside the container.
|
||||
|
||||
### I get the "You are not authorized to use Oodikone" -message when I try to work with anon-data
|
||||
|
||||
Make sure you have your VPN on
|
||||
|
||||
### I made a change to sis-db models aand created a migration, but tests are failing in CI!
|
||||
|
||||
Migrations are handled by sis-importer-worker, since oodikone is using only replica. Since CI is testing only services included in oodikone, your new changes aren't migrated and thus are failing. Open anonyymioodi-folder in this repo to find more about how to to update the anon sis-db.
|
||||
|
||||
### Everything is broken, can't get oodikone running, data is not there etc.
|
||||
|
||||
Just do clean install by launching cli with `npm cli` and running option 1: _Set up oodikone from scratch_.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*.sqz
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Anonyymioodi - anonymous database setup for oodikone
|
||||
|
||||
(name anonyymioodi for :culture: reasons)
|
||||
|
||||
This repository contains a guide and scripts to create docker images from database dumps and/or push database images to Toska Hub docker registry. Images are mostly used by oodikone.
|
||||
|
||||
## How to update database images
|
||||
|
||||
Updating images varies a bit depending on the db you're trying to update. Complete steps as defined below and run `./commit_and_push_to_toska_hub.sh <dbname>` to publish the image you've created.
|
||||
|
||||
### Sis-importer-db
|
||||
|
||||
The following steps are for building a clean sis-importer-db image from scratch. If you want to just modify the existing image a bit, follow the steps defined for user-db / kone-db.
|
||||
|
||||
- See [sis-importer/importer-db-staging-sampletaker](https://github.com/UniversityOfHelsinkiCS/sis-importer/tree/master/importer-db-staging-sampletaker) and create `sis-importer-db.sqz` dump
|
||||
- Move `sis-importer-db.sqz` to this folder
|
||||
- Run `./create_container_from_dump.sh sis-importer-db`
|
||||
- Enrich data according to what's in `enrich_data.sql` file
|
||||
|
||||
### Sis-db:
|
||||
|
||||
The following steps are for building a clean sis-importer-db image from scratch using the sis-updater. If you want to just modify the existing image a bit, follow the steps defined for user-db / kone-db.
|
||||
|
||||
- Temporarily replace sis-db -service in oodikone's docker-compose.yml with sis-db -service from this repo's docker-compose.yml
|
||||
- Ensure the newest sis-importer-db image is pulled from toska hub in oodikone repo
|
||||
- Modify sis scheduler limit in config.js by setting: `module.exports.DEV_SCHEDULE_COUNT = null`
|
||||
- Start updater, see oodikone readme for more info.
|
||||
- Populate sis-db from sis-importer-db:
|
||||
|
||||
```
|
||||
curl --request GET --url 'http://localhost:8082/v1/meta?token=dev' && \
|
||||
curl --request GET --url 'http://localhost:8082/v1/programmes?token=dev' && \
|
||||
curl --request GET --url 'http://localhost:8082/v1/students?token=dev'
|
||||
```
|
||||
|
||||
(It might be a good idea to follow sis-updater-worker logs while updater is running)
|
||||
|
||||
- Clean up any extra stuff you don't want to keep in sis-db.
|
||||
- (Bonus: check that cypress tests run okay)
|
||||
|
||||
### User-db / kone-db
|
||||
|
||||
- Temporarily replace user-db/kone-db -service in oodikone's docker-compose.yml with the same service from this repo's docker-compose.yml
|
||||
- Ensure the newest user-db/kone-db image is pulled from the registry with `docker pull`
|
||||
- Start oodikone, let it run migrations
|
||||
- Modify db to wanted state. Tips:
|
||||
- Easy way to create new users is to modify dev user details in the frontend API config. Userservice creates a new user during login if the given uid is not present in user-db.
|
||||
|
||||
By default, previous user-db/kone-db images are used as a base. If you want to build the image from a clean Postgres image, just comment out toska hub image and uncomment the clean Postgres image.
|
||||
|
||||
## How to modify database images
|
||||
|
||||
If you need to clean up / modify / do whatever with dbs, either use `psql` directly in docker by running `docker exec -it <dbname> psql -U postgres <dbname>` or start adminer with oodikone's docker-compose.
|
||||
|
||||
## How to update Postgres versions
|
||||
|
||||
When Postgres is updated on the server, anon db images need to be updated to match server versions. Do following:
|
||||
|
||||
- Ensure you're using toska hub image in docker-compose and that you've pulled the newest image from toska hub: `docker-compose pull <dbname>`.
|
||||
- Start database with docker-compose
|
||||
- Create dump from database by running `docker exec -i <dbname> pg_dump -Fc -U postgres <dbname> > <dbname>.sqz`
|
||||
- Close container with docker-compose down.
|
||||
- Update the Postgres version in docker-compose.yml in this folder.
|
||||
- Uncomment row with Postgres image and comment row with Toskahub image.
|
||||
- Run `./create_container_from_dump.sh <dbname>`, followed by `./commit_and_push_to_toska_hub.sh <dbname>`
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# === Config ===
|
||||
|
||||
# Quit if failing
|
||||
set -euo pipefail
|
||||
|
||||
# Print messages and logs that are not script output to stderr
|
||||
msg() {
|
||||
echo >&2 -e "${1-}"
|
||||
}
|
||||
|
||||
# Some special types of messages with colours
|
||||
successmsg() {
|
||||
msg "${GREEN}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
errormsg() {
|
||||
msg "${RED}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
infomsg() {
|
||||
msg "${BLUE}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
warningmsg() {
|
||||
msg "${ORANGE}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
|
||||
# Setup colors for messages if running interactive shell
|
||||
if [[ -t 2 ]] && [[ "${TERM-}" != "dumb" ]]; then
|
||||
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m'
|
||||
else
|
||||
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE=''
|
||||
fi
|
||||
|
||||
args=("$@")
|
||||
if [[ ${#args[@]} -ne 1 ]]; then
|
||||
errormsg "No database given, exiting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
infomsg "Committing and pushing to registry"
|
||||
database=${args[0]}
|
||||
docker commit "$database" "registry.toska.cs.helsinki.fi/$database"
|
||||
docker push "registry.toska.cs.helsinki.fi/$database"
|
||||
successmsg "Succesfully pushed"
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# === Config ===
|
||||
|
||||
# Quit if failing
|
||||
set -euo pipefail
|
||||
|
||||
# Print messages and logs that are not script output to stderr
|
||||
msg() {
|
||||
echo >&2 -e "${1-}"
|
||||
}
|
||||
|
||||
# Some special types of messages with colours
|
||||
successmsg() {
|
||||
msg "${GREEN}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
errormsg() {
|
||||
msg "${RED}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
infomsg() {
|
||||
msg "${BLUE}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
warningmsg() {
|
||||
msg "${ORANGE}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
|
||||
# Setup colors for messages if running interactive shell
|
||||
if [[ -t 2 ]] && [[ "${TERM-}" != "dumb" ]]; then
|
||||
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m'
|
||||
else
|
||||
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE=''
|
||||
fi
|
||||
|
||||
# === Create images ===
|
||||
|
||||
retry () {
|
||||
sleep 5
|
||||
for i in {1..60}; do
|
||||
"$@" && break || warningmsg "Retry attempt $i failed, waiting..." && sleep 5;
|
||||
done
|
||||
}
|
||||
|
||||
args=("$@")
|
||||
if [[ ${#args[@]} -ne 1 ]]; then
|
||||
errormsg "No database given, exiting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
infomsg "Restoring PostgreSQL dumps from backups. This might take a while."
|
||||
database=${args[0]}
|
||||
docker-compose down --remove-orphans
|
||||
docker-compose up -d "$database"
|
||||
|
||||
database_dump="$database.sqz"
|
||||
infomsg "Attempting to restore database $database from dump $database_dump"
|
||||
retry docker-compose exec "$database" pg_isready --dbname="$database"
|
||||
docker exec -i "$database" /bin/bash -c "pg_restore --username=postgres \
|
||||
--format=custom --dbname=$database --no-owner" < "$database_dump"
|
||||
successmsg "Database $database succesfully created"
|
||||
@@ -0,0 +1,50 @@
|
||||
x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
container_name: kone-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: registry.toska.cs.helsinki.fi/kone-db
|
||||
# image: postgres:13.4-alpine
|
||||
profiles:
|
||||
- oodikone
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
container_name: sis-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
# image: registry.toska.cs.helsinki.fi/sis-db
|
||||
image: postgres:13.2
|
||||
shm_size: 2gb
|
||||
|
||||
sis-importer-db:
|
||||
<<: *defaults
|
||||
container_name: sis-importer-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-importer-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
# image: registry.toska.cs.helsinki.fi/sis-importer-db
|
||||
image: postgres:13.1
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
container_name: user-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: registry.toska.cs.helsinki.fi/user-db
|
||||
# image: postgres:13.4-alpine
|
||||
profiles:
|
||||
- oodikone
|
||||
@@ -0,0 +1,133 @@
|
||||
-- add these to sis-importer db by running:
|
||||
-- docker exec -i sis-importer-db psql -U postgres sis-importer-db < enrich_sampledata.sql
|
||||
|
||||
-- if you need to run updater for these students only, see studentnumberlist in the end
|
||||
-- this file
|
||||
|
||||
-- Add admission types (valintaperusteet) to studyrights for some of the computer
|
||||
-- science 2018-2019 population students. See person_ids / student_number below
|
||||
-- (in case you need to update dumps).
|
||||
|
||||
-- TODO: this should be moved into the sampledata creator script in
|
||||
-- importer repo, but since this file contains studentnumbers and person ids, it's
|
||||
-- better to keep this here
|
||||
|
||||
-- KP (koepisteet)
|
||||
-- hy-hlo-1412620 | 013683126
|
||||
-- hy-hlo-1485246 | 014340523
|
||||
-- hy-hlo-32230770 | 010589388
|
||||
-- hy-hlo-35626233 | 010775888
|
||||
-- hy-hlo-40150144 | 011065995
|
||||
UPDATE studyrights
|
||||
SET admission_type_urn = 'urn:code:admission-type:kp'
|
||||
WHERE person_id IN (
|
||||
'hy-hlo-1412620',
|
||||
'hy-hlo-1485246',
|
||||
'hy-hlo-32230770',
|
||||
'hy-hlo-35626233',
|
||||
'hy-hlo-40150144'
|
||||
);
|
||||
|
||||
-- TV (todistusvalinta)
|
||||
-- hy-hlo-45783433 | 011321150
|
||||
-- hy-hlo-48537946 | 010764653
|
||||
-- hy-hlo-49801905 | 011194400
|
||||
-- hy-hlo-53321302 | 011338871
|
||||
-- hy-hlo-51223300 | 013951984
|
||||
-- hy-hlo-53310137 | 012520688
|
||||
-- hy-hlo-53621583 | 011196796
|
||||
-- hy-hlo-53863064 | 011736884
|
||||
-- hy-hlo-57711592 | 010262566
|
||||
-- hy-hlo-57732872 | 013670647
|
||||
UPDATE studyrights
|
||||
SET admission_type_urn = 'urn:code:admission-type:tv'
|
||||
WHERE person_id IN (
|
||||
'hy-hlo-45783433',
|
||||
'hy-hlo-48537946',
|
||||
'hy-hlo-49801905',
|
||||
'hy-hlo-53321302',
|
||||
'hy-hlo-51223300',
|
||||
'hy-hlo-53310137',
|
||||
'hy-hlo-53621583',
|
||||
'hy-hlo-53863064',
|
||||
'hy-hlo-57711592',
|
||||
'hy-hlo-57732872'
|
||||
);
|
||||
|
||||
-- YP:
|
||||
-- hy-hlo-67768022 | 011952325
|
||||
-- hy-hlo-73740025 | 011736606
|
||||
-- hy-hlo-74780917 | 012775433
|
||||
-- hy-hlo-79615371 | 014113941
|
||||
-- hy-hlo-78118455 | 014300615
|
||||
UPDATE studyrights
|
||||
SET admission_type_urn = 'urn:code:admission-type:yp'
|
||||
WHERE person_id IN (
|
||||
'hy-hlo-67768022',
|
||||
'hy-hlo-73740025',
|
||||
'hy-hlo-74780917',
|
||||
'hy-hlo-79615371',
|
||||
'hy-hlo-78118455'
|
||||
);
|
||||
-- AV:
|
||||
-- hy-hlo-78777265 | 010779392
|
||||
-- hy-hlo-81909550 | 011894153
|
||||
-- hy-hlo-84571890 | 013897158
|
||||
-- hy-hlo-80330834 | 015085926
|
||||
-- hy-hlo-84775722 | 011791272
|
||||
UPDATE studyrights
|
||||
SET admission_type_urn = 'urn:code:admission-type:av'
|
||||
WHERE person_id IN (
|
||||
'hy-hlo-78777265',
|
||||
'hy-hlo-81909550',
|
||||
'hy-hlo-84571890',
|
||||
'hy-hlo-80330834',
|
||||
'hy-hlo-84775722'
|
||||
);
|
||||
-- M:
|
||||
-- hy-hlo-87439827 | 011928456
|
||||
-- hy-hlo-96612371 | 014418361
|
||||
-- hy-hlo-84840261 | 010975895
|
||||
-- hy-hlo-90330681 | 010522688
|
||||
-- hy-hlo-96594603 | 013951573
|
||||
UPDATE studyrights
|
||||
SET admission_type_urn = 'urn:code:admission-type:m'
|
||||
WHERE person_id IN (
|
||||
'hy-hlo-87439827',
|
||||
'hy-hlo-96612371',
|
||||
'hy-hlo-84840261',
|
||||
'hy-hlo-90330681',
|
||||
'hy-hlo-96594603'
|
||||
);
|
||||
|
||||
-- all studentnumbers for running updater:
|
||||
013683126
|
||||
014340523
|
||||
010589388
|
||||
010775888
|
||||
011065995
|
||||
011321150
|
||||
010764653
|
||||
011194400
|
||||
011338871
|
||||
013951984
|
||||
012520688
|
||||
011196796
|
||||
011736884
|
||||
010262566
|
||||
013670647
|
||||
011952325
|
||||
011736606
|
||||
012775433
|
||||
014113941
|
||||
014300615
|
||||
010779392
|
||||
011894153
|
||||
013897158
|
||||
015085926
|
||||
011791272
|
||||
011928456
|
||||
014418361
|
||||
010975895
|
||||
010522688
|
||||
013951573
|
||||
@@ -44,20 +44,22 @@ will take care of setting up Oodikone for you. See README for more details."
|
||||
msg ""
|
||||
}
|
||||
|
||||
# Define custom shell prompt for the interactive select loop
|
||||
PS3="Please enter your choice: "
|
||||
set_custom_select_prompt() {
|
||||
PS3="Please enter your choice: "
|
||||
|
||||
options=(
|
||||
"Set up oodikone from scratch."
|
||||
"Reset all real data."
|
||||
"Reset sis-importer data."
|
||||
"Quit."
|
||||
)
|
||||
options=(
|
||||
"Set up oodikone from scratch."
|
||||
"Reset all real data."
|
||||
"Reset single database."
|
||||
"Quit."
|
||||
)
|
||||
}
|
||||
|
||||
# Run scripts before showing interactive prompt
|
||||
# Run scripts and set the prompt
|
||||
show_welcome
|
||||
init_dirs
|
||||
get_username
|
||||
set_custom_select_prompt
|
||||
|
||||
while true; do
|
||||
select opt in "${options[@]}"; do
|
||||
@@ -66,8 +68,10 @@ while true; do
|
||||
set_up_oodikone_from_scratch;;
|
||||
"Reset all real data.")
|
||||
reset_all_real_data;;
|
||||
"Reset sis-importer data.")
|
||||
reset_sis_importer_data;;
|
||||
"Reset single database.")
|
||||
reset_single_database
|
||||
set_custom_select_prompt # Set prompt back to initial values
|
||||
;;
|
||||
"Quit.")
|
||||
break 2;;
|
||||
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
|
||||
|
||||
@@ -186,6 +186,18 @@ describe('Course Statistics tests', () => {
|
||||
cy.contains('td', '010674989').siblings().contains('1')
|
||||
})
|
||||
|
||||
it.skip('Language distribution is correct', () => {
|
||||
cy.get("input[placeholder='Search by a course code']").type('TKT20003')
|
||||
cy.contains(/^TKT20003$/).click()
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
cy.contains('TKT20003, 582219 Käyttöjärjestelmät')
|
||||
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click()
|
||||
cy.contains('Population of course Käyttöjärjestelmät 2019-2020')
|
||||
cy.contains('Language distribution').click()
|
||||
cy.contains('td', 'finnish').siblings().contains('122')
|
||||
cy.contains('td', 'english').siblings().contains('5')
|
||||
})
|
||||
|
||||
describe('When searching unified course stats', () => {
|
||||
beforeEach(() => {
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
|
||||
@@ -9,23 +9,24 @@ const checkFilteringResult = studentCount => {
|
||||
// Semantic UI doesn't allow injection of data-cy:s for single multiple dropdown selection.
|
||||
// This function tries to click "x" on all selections inside dropdown matching given attribute
|
||||
const clearSemanticUIMultipleDropDownSelection = dataCyAttribute => {
|
||||
cy.cs(dataCyAttribute).find('i.delete').click()
|
||||
cy.cs(dataCyAttribute).find('i.delete').click({ force: true })
|
||||
cy.cs(dataCyAttribute).click()
|
||||
}
|
||||
|
||||
const clearSingleDropdownSelection = dataCyAttribute => {
|
||||
cy.cs(dataCyAttribute).find('i.clear').click()
|
||||
cy.cs(dataCyAttribute).find('i.clear').click({ force: true })
|
||||
}
|
||||
|
||||
// Helper tool to create pre and post steps for each filter step. Created to avoid copypasting clicking and checking
|
||||
// to every it-function. Reason behind using test function wrapper is that Cypresses internal beforeEach / afterEach
|
||||
// functions don't take any parameters and using global object for matching test step name seemed overcomplicated.
|
||||
const createRunTestStepWithPreAndPostPartsFunction = amountWithoutFiltering => {
|
||||
return (dataCyAttributeOfHeaderToClick, testStepFunctionToRun) => {
|
||||
cy.cs(dataCyAttributeOfHeaderToClick).click()
|
||||
return (filterName, testStepFunctionToRun) => {
|
||||
cy.get(`[data-cy="${filterName}-header"]`).click()
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
testStepFunctionToRun()
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
cy.cs(dataCyAttributeOfHeaderToClick).click()
|
||||
cy.get(`[data-cy="${filterName}-header"]`).click({ force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,80 +42,85 @@ describe('Population Statistics', () => {
|
||||
|
||||
it('Transfer filter is on not transferred by default', () => {
|
||||
cy.contains('By default only students who have not transferred to this study programme are shown.')
|
||||
cy.cs('transferredToProgrammeFilter-havenot').find('input').should('be.checked')
|
||||
const card = cy.cs('TransferredToProgramme-filter-card')
|
||||
card.cs('TransferredToProgramme-header').click()
|
||||
card.get('[data-cy="option-havenot"] input').should('be.checked')
|
||||
card.cs('TransferredToProgramme-header').click()
|
||||
})
|
||||
|
||||
it('Graduation filter works', () => {
|
||||
runTestStepWithPreAndPostParts('graduatedFromProgrammeFilter-header', () => {
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
const card = cy.cs('GraduatedFromProgramme-filter-card')
|
||||
|
||||
const graduated = 11
|
||||
cy.cs('graduatedFromProgrammeFilter-graduated-true').click()
|
||||
card.cs('option-graduated-true').click()
|
||||
checkFilteringResult(graduated)
|
||||
cy.cs('graduatedFromProgrammeFilter-graduated-false').click()
|
||||
card.cs('option-graduated-false').click()
|
||||
checkFilteringResult(defaultAmountOfStudents - graduated)
|
||||
cy.cs('graduatedFromProgrammeFilter-all').click()
|
||||
card.get('[data-cy="GraduatedFromProgramme-filter-card"] [data-cy="option-all"]').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('Transfer filter works', () => {
|
||||
runTestStepWithPreAndPostParts('transferredToProgrammeFilter-header', () => {
|
||||
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
|
||||
const transferred = 1
|
||||
cy.cs('transferredToProgrammeFilter-have').click()
|
||||
cy.cs('option-have').click()
|
||||
checkFilteringResult(transferred)
|
||||
cy.cs('transferredToProgrammeFilter-all').click()
|
||||
cy.cs('option-all').click()
|
||||
checkFilteringResult(defaultAmountOfStudents + transferred)
|
||||
cy.cs('transferredToProgrammeFilter-havenot').click()
|
||||
cy.cs('option-havenot').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('Enrollment filter works', () => {
|
||||
runTestStepWithPreAndPostParts('enrollmentStatusFilter-header', () => {
|
||||
cy.selectFromDropdown('enrollmentStatusFilter-status', 0)
|
||||
cy.selectFromDropdown('enrollmentStatusFilter-semesters', [0])
|
||||
runTestStepWithPreAndPostParts('EnrollmentStatus', () => {
|
||||
cy.cs('enrollmentStatusFilter-status').selectFromDropdown(0)
|
||||
cy.cs('enrollmentStatusFilter-semesters').selectFromDropdown(0)
|
||||
checkFilteringResult(137)
|
||||
clearSemanticUIMultipleDropDownSelection('enrollmentStatusFilter-semesters')
|
||||
})
|
||||
})
|
||||
|
||||
it('Credit filter works', () => {
|
||||
runTestStepWithPreAndPostParts('credit-filter-header', () => {
|
||||
runTestStepWithPreAndPostParts('CreditsEarned', () => {
|
||||
cy.cs('credit-filter-min').type('50')
|
||||
cy.cs('credit-filter-max').type('150')
|
||||
checkFilteringResult(116)
|
||||
cy.cs('credit-filter-min').find('input').clear()
|
||||
cy.cs('credit-filter-max').find('input').clear()
|
||||
cy.cs('credit-filter-min').clear()
|
||||
cy.cs('credit-filter-max').clear()
|
||||
})
|
||||
})
|
||||
|
||||
it('Age filter works', () => {
|
||||
runTestStepWithPreAndPostParts('ageFilter-header', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
cy.cs('ageFilter-min').type('20')
|
||||
cy.cs('ageFilter-max').type('40')
|
||||
checkFilteringResult(43)
|
||||
checkFilteringResult(42)
|
||||
cy.cs('ageFilter-min').find('input').clear()
|
||||
cy.cs('ageFilter-max').find('input').clear()
|
||||
})
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
runTestStepWithPreAndPostParts('genderFilter-header', () => {
|
||||
cy.selectFromDropdown('genderFilter-dropdown', 0)
|
||||
runTestStepWithPreAndPostParts('Gender', () => {
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(36)
|
||||
cy.selectFromDropdown('genderFilter-dropdown', 1)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(112)
|
||||
cy.selectFromDropdown('genderFilter-dropdown', 2)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
|
||||
checkFilteringResult(0)
|
||||
cy.selectFromDropdown('genderFilter-dropdown', 3)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
|
||||
checkFilteringResult(0)
|
||||
clearSingleDropdownSelection('genderFilter-dropdown')
|
||||
})
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
runTestStepWithPreAndPostParts('startYearAtUni-header', () => {
|
||||
cy.selectFromDropdown('startYearAtUni-dropdown', 0)
|
||||
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(1)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
cy.selectFromDropdown('startYearAtUni-dropdown', 14)
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(14)
|
||||
checkFilteringResult(121)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
})
|
||||
@@ -122,10 +128,10 @@ describe('Population Statistics', () => {
|
||||
|
||||
// Return when we have +2020 population with admission types in data
|
||||
it.skip('Admission type filter works', () => {
|
||||
runTestStepWithPreAndPostParts('admissionTypeFilter-header', () => {
|
||||
cy.selectFromDropdown('admissionTypeFilter-dropdown', 0)
|
||||
runTestStepWithPreAndPostParts('AdmissionTypeFilter', () => {
|
||||
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(5)
|
||||
cy.selectFromDropdown('admissionTypeFilter-dropdown', 2)
|
||||
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(2)
|
||||
checkFilteringResult(10)
|
||||
clearSingleDropdownSelection('admissionTypeFilter-dropdown')
|
||||
})
|
||||
@@ -135,31 +141,32 @@ describe('Population Statistics', () => {
|
||||
it.skip('Courses filter works', () => {
|
||||
// courses takes some time to load, wait for it to complete
|
||||
cy.wait(10000)
|
||||
runTestStepWithPreAndPostParts('courseFilter-header', () => {
|
||||
runTestStepWithPreAndPostParts('Courses', () => {
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
const courses = ['TKT20001', 'MAT11002']
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(courses[0]).click()
|
||||
checkFilteringResult(140)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0]}-dropdown`, 1)
|
||||
cy.cs(`courseFilter-${courses[0]}-dropdown`).selectFromDropdown(1)
|
||||
checkFilteringResult(131)
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(courses[1]).click()
|
||||
checkFilteringResult(56)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1]}-dropdown`, 2)
|
||||
cy.cs(`courseFilter-${courses[1]}-dropdown`).selectFromDropdown(2)
|
||||
checkFilteringResult(2)
|
||||
courses.forEach(course => cy.cs(`courseFilter-${course}-clear`).click())
|
||||
})
|
||||
})
|
||||
|
||||
it('Filter combinations work', () => {
|
||||
runTestStepWithPreAndPostParts('graduatedFromProgrammeFilter-header', () => {
|
||||
runTestStepWithPreAndPostParts('ageFilter-header', () => {
|
||||
cy.cs('graduatedFromProgrammeFilter-graduated-true').click()
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
const card = cy.cs('GraduatedFromProgramme-filter-card')
|
||||
card.cs('option-graduated-true').click()
|
||||
cy.cs('ageFilter-min').type('20')
|
||||
cy.cs('ageFilter-max').type('30')
|
||||
checkFilteringResult(11)
|
||||
cy.cs('ageFilter-min').find('input').clear()
|
||||
cy.cs('ageFilter-max').find('input').clear()
|
||||
cy.cs('graduatedFromProgrammeFilter-all').click()
|
||||
card.cs('option-all').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -176,7 +183,7 @@ describe('Course Statistics', () => {
|
||||
})
|
||||
|
||||
it('Grade filter works', () => {
|
||||
runTestStepWithPreAndPostParts('gradeFilter-header', () => {
|
||||
runTestStepWithPreAndPostParts('Grade', () => {
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteringResult(20)
|
||||
cy.cs('gradeFilter-3').click()
|
||||
@@ -187,43 +194,43 @@ describe('Course Statistics', () => {
|
||||
})
|
||||
|
||||
it('Age filter works', () => {
|
||||
runTestStepWithPreAndPostParts('ageFilter-header', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
cy.cs('ageFilter-min').type('20')
|
||||
cy.cs('ageFilter-max').type('40')
|
||||
checkFilteringResult(33)
|
||||
checkFilteringResult(32)
|
||||
cy.cs('ageFilter-min').find('input').clear()
|
||||
cy.cs('ageFilter-max').find('input').clear()
|
||||
})
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
runTestStepWithPreAndPostParts('genderFilter-header', () => {
|
||||
cy.selectFromDropdown('genderFilter-dropdown', 0)
|
||||
runTestStepWithPreAndPostParts('Gender', () => {
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(27)
|
||||
cy.selectFromDropdown('genderFilter-dropdown', 1)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(91)
|
||||
cy.selectFromDropdown('genderFilter-dropdown', 2)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
|
||||
checkFilteringResult(0)
|
||||
cy.selectFromDropdown('genderFilter-dropdown', 3)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
|
||||
checkFilteringResult(0)
|
||||
clearSingleDropdownSelection('genderFilter-dropdown')
|
||||
})
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
runTestStepWithPreAndPostParts('startYearAtUni-header', () => {
|
||||
cy.selectFromDropdown('startYearAtUni-dropdown', [0])
|
||||
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown([0])
|
||||
checkFilteringResult(1)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
cy.selectFromDropdown('startYearAtUni-dropdown', 12)
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(12)
|
||||
checkFilteringResult(29)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
})
|
||||
})
|
||||
|
||||
it('Filter combinations work', () => {
|
||||
runTestStepWithPreAndPostParts('gradeFilter-header', () => {
|
||||
runTestStepWithPreAndPostParts('ageFilter-header', () => {
|
||||
runTestStepWithPreAndPostParts('Grade', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
cy.cs('gradeFilter-3').click()
|
||||
cy.cs('ageFilter-min').type('20')
|
||||
cy.cs('ageFilter-max').type('30')
|
||||
@@ -245,13 +252,14 @@ describe('Custom Population Statistics', () => {
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
|
||||
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
|
||||
runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(students.length)
|
||||
cy.cs('student-no-input').click().type(students.join('/n'))
|
||||
cy.cs('student-no-input').click().type(students.join('\n'))
|
||||
cy.wait(10000)
|
||||
})
|
||||
cy.cs('search-button').click()
|
||||
})
|
||||
|
||||
it('Age filter works', () => {
|
||||
runTestStepWithPreAndPostParts('ageFilter-header', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
cy.cs('ageFilter-min').type('20')
|
||||
cy.cs('ageFilter-max').type('30')
|
||||
checkFilteringResult(1)
|
||||
@@ -261,35 +269,43 @@ describe('Custom Population Statistics', () => {
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter-dropdown', 0)
|
||||
checkFilteringResult(3)
|
||||
cy.selectFromDropdown('genderFilter-dropdown', 1)
|
||||
checkFilteringResult(2)
|
||||
cy.selectFromDropdown('genderFilter-dropdown', 2)
|
||||
checkFilteringResult(0)
|
||||
clearSingleDropdownSelection('genderFilter-dropdown')
|
||||
checkFilteringResult(5, true)
|
||||
runTestStepWithPreAndPostParts('Gender', () => {
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(3)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(2)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
|
||||
checkFilteringResult(0)
|
||||
clearSingleDropdownSelection('genderFilter-dropdown')
|
||||
checkFilteringResult(5, true)
|
||||
})
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
runTestStepWithPreAndPostParts('startYearAtUni-header', () => {
|
||||
cy.selectFromDropdown('startYearAtUni-dropdown', [0])
|
||||
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown([0])
|
||||
checkFilteringResult(1)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
cy.cs('startYearAtUni-dropdown').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('Courses filter works', () => {
|
||||
runTestStepWithPreAndPostParts('courseFilter-header', () => {
|
||||
runTestStepWithPreAndPostParts('Courses', () => {
|
||||
const courses = ['MAT11001', 'TKT20004']
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(courses[0]).click()
|
||||
cy.cs('courseFilter-course-dropdown')
|
||||
.click()
|
||||
.contains(courses[0] + ' ')
|
||||
.click()
|
||||
checkFilteringResult(5)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0]}-dropdown`, 1)
|
||||
cy.cs(`courseFilter-${courses[0]}-dropdown`).selectFromDropdown(1)
|
||||
checkFilteringResult(5)
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(courses[1]).click()
|
||||
cy.cs('courseFilter-course-dropdown')
|
||||
.click()
|
||||
.contains(courses[1] + ' ')
|
||||
.click()
|
||||
checkFilteringResult(3)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1]}-dropdown`, 3)
|
||||
cy.cs(`courseFilter-${courses[1]}-dropdown`).selectFromDropdown(3)
|
||||
checkFilteringResult(1)
|
||||
courses.forEach(course => cy.cs(`courseFilter-${course}-clear`).click())
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
const moment = require('moment')
|
||||
const _ = require('lodash')
|
||||
|
||||
const setPopStatsUntil = (until, includeSettings = []) => {
|
||||
cy.contains('Advanced settings').siblings().get('[data-cy=advanced-toggle]').click()
|
||||
includeSettings.forEach(setting => {
|
||||
@@ -13,6 +16,8 @@ const setPopStatsUntil = (until, includeSettings = []) => {
|
||||
describe('Population Statistics tests', () => {
|
||||
const pathToCSBach2017 =
|
||||
'/populations?months=36&semesters=FALL&semesters=SPRING&studyRights={%22programme%22%3A%22KH50_005%22}&tag&year=2017'
|
||||
const pathToCSMaster2019 =
|
||||
'/populations?months=27&semesters=FALL&semesters=SPRING&studyRights=%7B%22programme%22%3A%22MH50_009%22%7D&tag&year=2019'
|
||||
describe('when using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/populations')
|
||||
@@ -59,6 +64,7 @@ describe('Population Statistics tests', () => {
|
||||
|
||||
it('Population statistics is usable on general level', () => {
|
||||
cy.visit(pathToCSBach2017)
|
||||
cy.cs('filtered-students')
|
||||
setPopStatsUntil('toukokuu 2020')
|
||||
|
||||
cy.get('.card').within(() => {
|
||||
@@ -69,7 +75,8 @@ describe('Population Statistics tests', () => {
|
||||
cy.contains('Excludes students with non-degree study right')
|
||||
cy.contains('Excludes students who have transferred out of this programme')
|
||||
})
|
||||
cy.contains('Courses of population').click({ force: true })
|
||||
cy.cs('filtered-students')
|
||||
cy.contains('Courses of population').click()
|
||||
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
@@ -123,18 +130,62 @@ describe('Population Statistics tests', () => {
|
||||
cy.contains('Credit accumulation (for 170 students)')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Statistics pane works', () => {
|
||||
it('Credit Statistics, Credits Gained tab works', () => {
|
||||
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
|
||||
cy.contains('Credit statistics').click()
|
||||
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the population')
|
||||
|
||||
const months = Math.ceil(moment.duration(moment().diff(moment('2017-08-1'))).asMonths())
|
||||
|
||||
const limits = [1, ..._.range(1, 4).map(p => Math.ceil(months * ((p * 15) / 12))), null]
|
||||
const ranges = _.range(1, limits.length).map(i => _.slice(limits, i - 1, i + 1))
|
||||
|
||||
cy.get('.credits-gained-table').should('contain', '(n=149)')
|
||||
|
||||
for (const [start, end] of ranges) {
|
||||
let value = 'credits'
|
||||
|
||||
if (start !== null) {
|
||||
value = `${start} ≤ ${value}`
|
||||
}
|
||||
|
||||
if (end !== null) {
|
||||
value = `${value} < ${end}`
|
||||
}
|
||||
|
||||
cy.get('.credits-gained-table').should('contain', value)
|
||||
}
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-body'] td:nth-child(3)").then($els => {
|
||||
const sum = [...$els].map($el => parseInt($el.innerText)).reduce((a, b) => a + b, 0)
|
||||
|
||||
expect(sum).to.equal(149)
|
||||
})
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('not.exist')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Credits Gained tab shows stats by admissions', () => {
|
||||
cy.visit(pathToCSMaster2019)
|
||||
cy.cs('filtered-students')
|
||||
cy.contains('Credit statistics')
|
||||
cy.get("[data-cy='credit-statistics']").click()
|
||||
cy.get('.credits-gained-divider').click()
|
||||
cy.get("[data-cy='credits-gained-table-Avoin väylä']").should('exist')
|
||||
cy.get("[data-cy='credits-gained-table-Yhteispisteet']").should('exist')
|
||||
cy.get("[data-cy='credits-gained-table-Muu']").should('exist')
|
||||
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('exist')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Statistics pane works', () => {
|
||||
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
|
||||
cy.contains('Credit statistics').click({ force: true })
|
||||
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
|
||||
|
||||
cy.get("[data-cy='credit-stats-table-name-header']").should('contain', 'Statistic for n = 149 Students')
|
||||
cy.get("[data-cy='credit-stats-mean']").should('contain', '123.89')
|
||||
cy.get("[data-cy='credit-stats-stdev']").should('contain', '61.32')
|
||||
cy.get("[data-cy='credit-stats-min']").should('contain', '11')
|
||||
cy.get("[data-cy='credit-stats-q1']").should('contain', '70')
|
||||
cy.get("[data-cy='credit-stats-q2']").should('contain', '129')
|
||||
cy.get("[data-cy='credit-stats-q3']").should('contain', '168')
|
||||
cy.get("[data-cy='credit-stats-max']").should('contain', '314')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('Student Statistics tests', () => {
|
||||
studentnumber: '010654019',
|
||||
}
|
||||
|
||||
it.only('Student statistics search form is usable', () => {
|
||||
it('Student statistics search form is usable', () => {
|
||||
cy.contains('Show student names')
|
||||
cy.url().should('include', '/students')
|
||||
cy.get('.prompt').type(student.lastname)
|
||||
@@ -80,7 +80,7 @@ describe('Student Statistics tests', () => {
|
||||
cy.contains('Student number').should('not.exist')
|
||||
})
|
||||
|
||||
it.only('Can jump to population page', () => {
|
||||
it('Can jump to population page', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains(student.studentnumber).click()
|
||||
cy.get('i.level.up.alternate.icon').eq(0).click()
|
||||
@@ -88,3 +88,13 @@ describe('Student Statistics tests', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
|
||||
})
|
||||
})
|
||||
|
||||
// Use admin to see all students
|
||||
describe('Testing with admin rights', () => {
|
||||
it('Does not crash if student has no studyright or courses', () => {
|
||||
const studentNumber = '011825096'
|
||||
cy.init(`/students/${studentNumber}`, 'admin') // use admin to see all students
|
||||
cy.contains('Started: Unavailable')
|
||||
cy.contains('Credits: 0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,16 +4,16 @@ describe('Users tests', () => {
|
||||
describe('Using user with just grp-oodikone-user, no other rights', () => {
|
||||
it('shows only frontpage, trends feedback', () => {
|
||||
cy.init('', 'norights') // login with norights user
|
||||
cy.get('.navBar').contains('Trends').should('exist')
|
||||
cy.get('.navBar').contains('Give feedback').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Trends').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Give feedback').should('exist')
|
||||
|
||||
cy.get('.navBar').contains('Study programme').should('not.exist')
|
||||
cy.get('.navBar').contains('Student statistics').should('not.exist')
|
||||
cy.get('.navBar').contains('Course statistics').should('not.exist')
|
||||
cy.get('.navBar').contains('Teachers').should('not.exist')
|
||||
cy.get('.navBar').contains('Users').should('not.exist')
|
||||
cy.get('.navBar').contains('Faculty').should('not.exist')
|
||||
cy.get('.navBar').contains('Updater').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Study programme').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Student statistics').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Course statistics').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Teachers').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Users').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Faculty').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Updater').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,12 +29,12 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it('should see more stuff than others', () => {
|
||||
cy.get('.navBar').contains('Study programme').should('exist')
|
||||
cy.get('.navBar').contains('Student statistics').should('exist')
|
||||
cy.get('.navBar').contains('Course statistics').should('exist')
|
||||
// TODO: admin should probably see this as well, since can access this page: cy.get('.navBar').contains('Teachers').should('exist')
|
||||
cy.get('.navBar').contains('Users').should('exist')
|
||||
cy.get('.navBar').contains('Trends').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Study programme').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Student statistics').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Course statistics').should('exist')
|
||||
// TODO: admin should probably see this as well, since can access this page: cy.get('[data-cy="navBar"]').contains('Teachers').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Users').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Trends').should('exist')
|
||||
})
|
||||
|
||||
it("mocking normal user shows only the mocked user's programmes", () => {
|
||||
|
||||
@@ -54,8 +54,6 @@ Cypress.Commands.add('init', (path = '', userId = 'basic') => {
|
||||
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
|
||||
req.headers = headersToUse
|
||||
})
|
||||
// Hide SIS warning flag to avoid screen size problems when running headlessly
|
||||
window.localStorage.setItem('SIS_WARNING_FLAG', 'true')
|
||||
const baseUrl = Cypress.config().baseUrl
|
||||
cy.visit(baseUrl.concat(path))
|
||||
})
|
||||
@@ -63,24 +61,28 @@ Cypress.Commands.add('init', (path = '', userId = 'basic') => {
|
||||
/**
|
||||
* Shorthand for using "Cypress Selectors" (CS), i.e., `data-cy` attributes.
|
||||
*/
|
||||
Cypress.Commands.add('cs', name => {
|
||||
return cy.get(`[data-cy='${name}']`)
|
||||
Cypress.Commands.add('cs', { prevSubject: 'optional' }, (subject, name) => {
|
||||
const p = subject === undefined ? cy : cy.wrap(subject)
|
||||
|
||||
return p.get(`[data-cy='${name}']`)
|
||||
})
|
||||
|
||||
/**
|
||||
* Select item specified by `index` number from a semantic-ui dropdown with
|
||||
* `data-cy` attribute value `name`.
|
||||
*/
|
||||
Cypress.Commands.add('selectFromDropdown', (name, index) => {
|
||||
Cypress.Commands.add('selectFromDropdown', { prevSubject: true }, (s, index) => {
|
||||
const subject = cy.wrap(s)
|
||||
const indexes = Array.isArray(index) ? index : [index]
|
||||
|
||||
indexes.forEach(i => {
|
||||
cy.cs(name).click().children('.menu').children().eq(i).click({ force: true })
|
||||
subject.click()
|
||||
subject.children('.menu').children().eq(i).click({ force: true })
|
||||
})
|
||||
|
||||
// Close multiple selection so it does not block elements underneath it.
|
||||
if (Array.isArray(index)) {
|
||||
cy.cs(name).children('.icon').click({ force: true })
|
||||
cy.wrap(s).children('.icon').click({ force: true })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+55
-5
@@ -2,7 +2,6 @@ x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
|
||||
backend:
|
||||
<<: *defaults
|
||||
container_name: backend
|
||||
@@ -11,7 +10,7 @@ services:
|
||||
- redis
|
||||
- sis-db
|
||||
environment:
|
||||
CI: "true"
|
||||
CI: 'true'
|
||||
DB_URL_KONE: postgres://postgres@kone-db:5432/kone-db
|
||||
FRONT_URL: http://localhost:1337
|
||||
REDIS: redis
|
||||
@@ -31,11 +30,15 @@ services:
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
container_name: kone-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: registry.toska.cs.helsinki.fi/kone-db
|
||||
|
||||
redis:
|
||||
<<: *defaults
|
||||
command: "redis-server --appendonly yes"
|
||||
command: 'redis-server --appendonly yes'
|
||||
container_name: redis
|
||||
image: redis
|
||||
ports:
|
||||
@@ -45,10 +48,19 @@ services:
|
||||
<<: *defaults
|
||||
container_name: sis-db
|
||||
image: registry.toska.cs.helsinki.fi/sis-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
shm_size: 2gb
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
container_name: user-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: registry.toska.cs.helsinki.fi/user-db
|
||||
|
||||
userservice:
|
||||
@@ -57,7 +69,7 @@ services:
|
||||
depends_on:
|
||||
- user-db
|
||||
environment:
|
||||
CI: "true"
|
||||
CI: 'true'
|
||||
DB_URL: postgres://postgres@user-db:5432/user-db
|
||||
SECRET: Luukk4inenApprov3dVitunMinttuSalainenAvain666NurmivaaraPJ2019
|
||||
TOKEN_SECRET: IWannaBeTheVeryBest
|
||||
@@ -71,6 +83,44 @@ services:
|
||||
- backend
|
||||
- userservice
|
||||
ports:
|
||||
- 1337:80
|
||||
- 1337:80
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
|
||||
# Following services are used to run sis-updater-worker when anon sis-db needs to be updated. Could probably also use production version, but this certainly works.
|
||||
sis-importer-db:
|
||||
<<: *defaults
|
||||
container_name: sis-importer-db
|
||||
image: registry.toska.cs.helsinki.fi/sis-importer-db
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
sis-updater-worker:
|
||||
<<: *defaults
|
||||
build:
|
||||
target: development
|
||||
context: updater/sis-updater-worker
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-db
|
||||
- sis-importer-db
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_URI: //redis:6379
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
SIS_IMPORTER_USER: postgres
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
sis-updater-nats:
|
||||
<<: *defaults
|
||||
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
|
||||
container_name: sis-updater-nats
|
||||
image: nats-streaming:0.16.2
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
Generated
+34
-6593
File diff suppressed because it is too large
Load Diff
@@ -49,5 +49,9 @@
|
||||
"stylelint-config-prettier": "^8.0.2",
|
||||
"stylelint-config-standard": "^22.0.0",
|
||||
"stylelint-prettier": "^1.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,12 +110,54 @@ reset_all_real_data() {
|
||||
reset_databases "${DATABASES[@]}"
|
||||
}
|
||||
|
||||
reset_sis_importer_data() {
|
||||
infomsg "Downloading sis-importer-db dump"
|
||||
local database=$SIS_IMPORTER_DB_NAME
|
||||
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
|
||||
download_real_dump $database $url
|
||||
reset_databases $database
|
||||
reset_single_database() {
|
||||
# Define custom shell prompt for the nested interactive select loop
|
||||
PS3="Please enter which database to reset: "
|
||||
|
||||
options=(
|
||||
"kone-db"
|
||||
"sis-db"
|
||||
"sis-importer-db"
|
||||
"user-db"
|
||||
"Return to main menu."
|
||||
)
|
||||
|
||||
local database=""
|
||||
local url=""
|
||||
|
||||
while true; do
|
||||
select opt in "${options[@]}"; do
|
||||
case $opt in
|
||||
"kone-db")
|
||||
local database=$KONE_DB_NAME
|
||||
local url=$KONE_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"sis-db")
|
||||
local database=$SIS_DB_NAME
|
||||
local url=$SIS_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"sis-importer-db")
|
||||
local database=$SIS_IMPORTER_DB_NAME
|
||||
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"user-db")
|
||||
local database=$USER_DB_NAME
|
||||
local url=$USER_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"Return to main menu.")
|
||||
break 2;;
|
||||
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
|
||||
";;
|
||||
esac
|
||||
break
|
||||
done
|
||||
done
|
||||
|
||||
if [ -n "$database" ]; then
|
||||
infomsg "Downloading $database dump"
|
||||
download_real_dump $database $url
|
||||
reset_databases $database
|
||||
fi
|
||||
}
|
||||
|
||||
set_up_oodikone_from_scratch() {
|
||||
|
||||
Generated
+3
-3
@@ -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",
|
||||
|
||||
@@ -18,7 +18,6 @@ const pateToken = process.env.PATE_API_TOKEN || ''
|
||||
|
||||
// Importer client
|
||||
const importerToken = process.env.IMPORTER_DB_API_TOKEN || ''
|
||||
const importerUrl = process.env.IMPORTER_DB_API_URL || ''
|
||||
|
||||
// Networking: Urls & ports
|
||||
const baseUrl = isDev ? '/api' : ''
|
||||
@@ -62,8 +61,6 @@ const addSlashToEnd = url => (url.endsWith('/') ? url : url + '/')
|
||||
|
||||
const ACCESS_TOKEN_HEADER_KEY = 'x-access-token'
|
||||
|
||||
const PWD = process.env.PWD || '/usr/src/app'
|
||||
|
||||
module.exports = {
|
||||
DB_URL_KONE,
|
||||
DB_MAX_CONNECTIONS,
|
||||
@@ -91,6 +88,4 @@ module.exports = {
|
||||
frontUrl,
|
||||
backendPort,
|
||||
importerToken,
|
||||
importerUrl,
|
||||
PWD,
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ const initializeDatabaseConnection = async () => {
|
||||
const migrations = await migrator.up()
|
||||
logger.info({ message: 'Kone Migrations up to date', meta: migrations })
|
||||
} catch (e) {
|
||||
logger.error('Kone Migration error')
|
||||
logger.error({ message: 'Kone Migration error: ', meta: e })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
const { BIGINT, STRING, DATE } = require('sequelize')
|
||||
|
||||
module.exports = {
|
||||
up: async queryInterface => {
|
||||
await queryInterface.createTable(
|
||||
'study_guidance_group_tags',
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: BIGINT,
|
||||
autoIncrement: true,
|
||||
},
|
||||
study_guidance_group_id: {
|
||||
type: STRING,
|
||||
unique: true,
|
||||
},
|
||||
study_programme: {
|
||||
type: STRING,
|
||||
},
|
||||
year: {
|
||||
type: STRING,
|
||||
},
|
||||
created_at: {
|
||||
type: DATE,
|
||||
},
|
||||
updated_at: {
|
||||
type: DATE,
|
||||
},
|
||||
},
|
||||
{ underscored: true, timestamps: true }
|
||||
)
|
||||
},
|
||||
down: async () => {},
|
||||
}
|
||||
@@ -47,6 +47,9 @@ Course.init(
|
||||
substitutions: {
|
||||
type: JSONB,
|
||||
},
|
||||
course_unit_type: {
|
||||
type: STRING,
|
||||
},
|
||||
},
|
||||
{
|
||||
underscored: false,
|
||||
|
||||
@@ -75,6 +75,9 @@ Credit.init(
|
||||
updatedAt: {
|
||||
type: DATE,
|
||||
},
|
||||
language: {
|
||||
type: STRING,
|
||||
},
|
||||
},
|
||||
{
|
||||
underscored: false,
|
||||
|
||||
@@ -135,6 +135,28 @@ const CustomPopulationSearch = sequelizeKone.define('custom_population_searches'
|
||||
},
|
||||
})
|
||||
|
||||
const StudyGuidanceGroupTag = sequelizeKone.define(
|
||||
'study_guidance_group_tags',
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: Sequelize.BIGINT,
|
||||
autoIncrement: true,
|
||||
},
|
||||
studyGuidanceGroupId: {
|
||||
type: Sequelize.STRING,
|
||||
unique: true,
|
||||
},
|
||||
studyProgramme: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
year: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
},
|
||||
{ underscored: true, timestamps: true }
|
||||
)
|
||||
|
||||
MandatoryCourse.belongsTo(MandatoryCourseLabels, {
|
||||
foreignKey: 'label',
|
||||
sourceKey: 'id',
|
||||
@@ -157,4 +179,5 @@ module.exports = {
|
||||
CustomPopulationSearch,
|
||||
sequelizeKone,
|
||||
ExcludedCourse,
|
||||
StudyGuidanceGroupTag,
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ Student.init(
|
||||
dateofuniversityenrollment: { type: DATE },
|
||||
email: { type: STRING },
|
||||
national_student_number: { type: STRING },
|
||||
home_county_id: { type: INTEGER },
|
||||
country_fi: { type: STRING },
|
||||
country_sv: { type: STRING },
|
||||
country_en: { type: STRING },
|
||||
@@ -25,6 +24,7 @@ Student.init(
|
||||
home_country_sv: { type: STRING },
|
||||
home_country_en: { type: STRING },
|
||||
gender_code: { type: INTEGER },
|
||||
sis_person_id: { type: STRING },
|
||||
createdAt: {
|
||||
type: DATE,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@ const router = require('express').Router()
|
||||
const Student = require('../services/students')
|
||||
const userService = require('../services/userService')
|
||||
const Unit = require('../services/units')
|
||||
const { mismatchedStudents } = require('../services/student_credit_total_mismatches')
|
||||
const { getAllStudentsUserHasInGroups } = require('../services/studyGuidanceGroups')
|
||||
|
||||
const filterStudentTags = (student, userId) => {
|
||||
return {
|
||||
@@ -11,17 +11,10 @@ const filterStudentTags = (student, userId) => {
|
||||
}
|
||||
}
|
||||
|
||||
const creditTotalMismatch = student => {
|
||||
return {
|
||||
...student,
|
||||
mismatch: mismatchedStudents.has(student.studentNumber),
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/students', async (req, res) => {
|
||||
const {
|
||||
roles,
|
||||
decodedToken: { userId },
|
||||
decodedToken: { userId, sisPersonId },
|
||||
query: { searchTerm },
|
||||
} = req
|
||||
|
||||
@@ -45,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])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -54,14 +53,11 @@ router.get('/students/:id', async (req, res) => {
|
||||
const { id: studentId } = req.params
|
||||
const { roles, decodedToken } = req
|
||||
|
||||
if (roles && roles.includes('admin')) {
|
||||
if (roles?.includes('admin')) {
|
||||
const results = await Student.withId(studentId)
|
||||
return results.error
|
||||
? res.status(400).json({ error: 'error finding student' }).end()
|
||||
: res
|
||||
.status(200)
|
||||
.json(creditTotalMismatch(filterStudentTags(results, decodedToken.id)))
|
||||
.end()
|
||||
: res.status(200).json(filterStudentTags(results, decodedToken.id)).end()
|
||||
}
|
||||
|
||||
const uid = req.decodedToken.userId
|
||||
@@ -78,6 +74,13 @@ router.get('/students/:id', async (req, res) => {
|
||||
})
|
||||
)
|
||||
|
||||
if (roles?.includes('studyGuidanceGroups')) {
|
||||
const studentsUserCanAccess = await getAllStudentsUserHasInGroups(decodedToken.sisPersonId)
|
||||
if (studentsUserCanAccess.has(studentId)) {
|
||||
return res.status(200).json(filterStudentTags(student, decodedToken.id)).end()
|
||||
}
|
||||
}
|
||||
|
||||
if (rights.some(right => right !== null)) {
|
||||
res.status(200).json(filterStudentTags(student, decodedToken.id)).end()
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const router = require('express').Router()
|
||||
const { logger } = require('../util/logger')
|
||||
const { getAllGroupsAndStudents } = require('../services/studyGuidanceGroups')
|
||||
const { getAllGroupsAndStudents, changeGroupTags } = require('../services/studyGuidanceGroups')
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const {
|
||||
@@ -13,4 +13,27 @@ router.get('/', async (req, res) => {
|
||||
return res.json(await getAllGroupsAndStudents(sisPersonId))
|
||||
})
|
||||
|
||||
router.put('/:id/tags', async (req, res) => {
|
||||
const {
|
||||
decodedToken: { uid, sisPersonId },
|
||||
body: tags,
|
||||
params: { id: groupId },
|
||||
} = req
|
||||
|
||||
if (!sisPersonId) {
|
||||
logger.error(`User ${uid} tried to change study guidance group tags but personId was ${sisPersonId} in header`)
|
||||
return res.status(400).json({ error: 'Not possible to change groups without personId' })
|
||||
}
|
||||
|
||||
const groupsUserHasAccessTo = (await getAllGroupsAndStudents(sisPersonId)).map(group => group.id)
|
||||
|
||||
if (!groupsUserHasAccessTo.includes(groupId)) {
|
||||
logger.error(
|
||||
`User ${uid} tried to change study guidance group tags, but didn't have permission for the group ${groupId}`
|
||||
)
|
||||
return res.status(400).json({ error: 'Access denied' })
|
||||
}
|
||||
return res.status(200).json(await changeGroupTags({ groupId, tags }))
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
const router = require('express').Router()
|
||||
const {
|
||||
getBasicStatsForStudytrack,
|
||||
getCreditStatsForStudytrack,
|
||||
getGraduationStatsForStudytrack,
|
||||
} = require('../services/studyprogrammeStats')
|
||||
const {
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
getCreditStats,
|
||||
setCreditStats,
|
||||
getGraduationStats,
|
||||
setGraduationStats,
|
||||
} = require('../services/analyticsService')
|
||||
const logger = require('../util/logger')
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const yearType = req.query?.year_type
|
||||
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getBasicStats(code, yearType)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} basic stats: ${e}`)
|
||||
}
|
||||
if (!data) {
|
||||
try {
|
||||
const result = await getBasicStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
yearType,
|
||||
})
|
||||
data = await setBasicStats(result, yearType)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} basic stats: ${e}`)
|
||||
}
|
||||
}
|
||||
return res.json(data)
|
||||
} else {
|
||||
res.status(422).end()
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const yearType = req.query?.year_type
|
||||
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getCreditStats(code, yearType)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} credit stats`)
|
||||
}
|
||||
if (!data) {
|
||||
try {
|
||||
const result = await getCreditStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
yearType,
|
||||
})
|
||||
data = await setCreditStats(result, yearType)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} credit stats`)
|
||||
}
|
||||
}
|
||||
return res.json(data)
|
||||
} else {
|
||||
res.status(422).end()
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const yearType = req.query?.year_type
|
||||
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getGraduationStats(code, yearType)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} graduation stats`)
|
||||
}
|
||||
if (!data) {
|
||||
try {
|
||||
const result = await getGraduationStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
yearType,
|
||||
})
|
||||
data = await setGraduationStats(result, yearType)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} graduation stats`)
|
||||
}
|
||||
}
|
||||
return res.json(data)
|
||||
} else {
|
||||
res.status(422).end()
|
||||
}
|
||||
})
|
||||
|
||||
router.use('*', (req, res, next) => next())
|
||||
|
||||
module.exports = router
|
||||
@@ -3,6 +3,9 @@ const moment = require('moment')
|
||||
|
||||
const createRedisKeyForProductivity = id => `PRODUCTIVITY_${id}`
|
||||
const createRedisKeyForThroughput = id => `THROUGHPUT_${id}`
|
||||
const createRedisKeyForBasicStats = (id, yearType) => `BASIC_STATS_${id}_${yearType}`
|
||||
const createRedisKeyForCreditStats = (id, yearType) => `CREDIT_STATS_${id}_${yearType}`
|
||||
const createRedisKeyForGraduationStats = (id, yearType) => `GRADUATION_STATS_${id}_${yearType}`
|
||||
|
||||
const getProductivity = async id => {
|
||||
const redisKey = createRedisKeyForProductivity(id)
|
||||
@@ -87,6 +90,66 @@ const patchThroughput = async data => {
|
||||
}
|
||||
}
|
||||
|
||||
const getBasicStats = async (id, yearType) => {
|
||||
const redisKey = createRedisKeyForBasicStats(id, yearType)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setBasicStats = async (data, yearType) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForBasicStats(id, yearType)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
const getCreditStats = async (id, yearType) => {
|
||||
const redisKey = createRedisKeyForCreditStats(id, yearType)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setCreditStats = async (data, yearType) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForCreditStats(id, yearType)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
const getGraduationStats = async (id, yearType) => {
|
||||
const redisKey = createRedisKeyForGraduationStats(id, yearType)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setGraduationStats = async (data, yearType) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForGraduationStats(id, yearType)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProductivity,
|
||||
setProductivity,
|
||||
@@ -94,4 +157,10 @@ module.exports = {
|
||||
getThroughput,
|
||||
setThroughput,
|
||||
patchThroughput,
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
getCreditStats,
|
||||
setCreditStats,
|
||||
getGraduationStats,
|
||||
setGraduationStats,
|
||||
}
|
||||
|
||||
@@ -368,39 +368,9 @@ const byNameAndOrCodeLike = async (name, code) => {
|
||||
})
|
||||
|
||||
const courses = rawCourses
|
||||
.map(
|
||||
({
|
||||
id,
|
||||
code,
|
||||
name,
|
||||
latest_instance_date,
|
||||
is_study_module,
|
||||
coursetypecode,
|
||||
startdate,
|
||||
max_attainment_date,
|
||||
min_attainment_date,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
substitutions,
|
||||
organizations,
|
||||
}) => {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
name,
|
||||
latest_instance_date,
|
||||
is_study_module,
|
||||
coursetypecode,
|
||||
startdate,
|
||||
max_attainment_date,
|
||||
min_attainment_date,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
substitutions,
|
||||
organizations: organizations.map(o => o.id),
|
||||
}
|
||||
}
|
||||
)
|
||||
.map(course => {
|
||||
return { ...course.dataValues, organizations: course.organizations.map(o => o.id) }
|
||||
})
|
||||
.sort(a => (a.code.match(/^[A-Za-z]{3}[0-9]{1}/) ? -1 : 1))
|
||||
|
||||
let substitutionGroupIndex = 0
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
const sequelize = require('sequelize')
|
||||
const { Op } = sequelize
|
||||
const {
|
||||
Credit,
|
||||
Course,
|
||||
Organization,
|
||||
Studyright,
|
||||
StudyrightElement,
|
||||
ElementDetail,
|
||||
Transfer,
|
||||
Student,
|
||||
SemesterEnrollment,
|
||||
Semester,
|
||||
} = require('../models')
|
||||
const { formatStudyright } = require('./studyprogrammeHelpers')
|
||||
|
||||
const enrolledStudyrights = async studytrack => {
|
||||
const studyrights = await Studyright.findAll({
|
||||
attributes: ['studyrightid', 'studystartdate', 'enddate', 'prioritycode'],
|
||||
include: [
|
||||
{
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
where: { code: studytrack },
|
||||
},
|
||||
{
|
||||
model: Student,
|
||||
attributes: ['studentnumber'],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const studentnumbers = studyrights.map(s => s.student.studentnumber)
|
||||
|
||||
const students = await Student.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
include: [
|
||||
{
|
||||
model: Studyright,
|
||||
include: [
|
||||
{
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
where: {
|
||||
code: studytrack,
|
||||
},
|
||||
},
|
||||
],
|
||||
attributes: ['studyrightid'],
|
||||
},
|
||||
{
|
||||
model: SemesterEnrollment,
|
||||
attributes: ['semestercode'],
|
||||
include: [
|
||||
{
|
||||
model: Semester,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
where: {
|
||||
enrollmenttype: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentnumbers,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let enrollments = new Set()
|
||||
|
||||
students.forEach(student => {
|
||||
student.semester_enrollments.forEach(e => {
|
||||
const enrollmentStartDate = e.semester.startdate
|
||||
const enrollmentEndDate = e.semester.enddate
|
||||
const correctStudyRight = studyrights.find(
|
||||
s =>
|
||||
student.studentnumber === s.student.studentnumber &&
|
||||
s.studystartdate <= enrollmentStartDate && // Studying in the programme should have started on the same day or earlier than the enrollment
|
||||
s.enddate >= enrollmentEndDate && // Studying in the programme should have ended on the same day or later than the enrollment
|
||||
(!s.canceldate || s.canceldate > enrollmentStartDate) && // Studyright should not be canceled OR the possible canceldate should be after the enrollmentStartDate
|
||||
(s.prioritycode === 1 || s.prioritycode === 30) // Studyright should be primary or the student should have graduated
|
||||
)
|
||||
if (correctStudyRight) {
|
||||
enrollments.add({
|
||||
studentnumber: student.studentnumber,
|
||||
startdate: e.semester.startdate,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return enrollments
|
||||
}
|
||||
|
||||
const startedStudyrights = async (studytrack, since) =>
|
||||
await Studyright.findAll({
|
||||
include: {
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
include: {
|
||||
model: ElementDetail,
|
||||
required: true,
|
||||
where: {
|
||||
code: studytrack,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
studystartdate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const graduatedStudyRights = async (studytrack, since) =>
|
||||
await Studyright.findAll({
|
||||
include: {
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
include: {
|
||||
model: ElementDetail,
|
||||
required: true,
|
||||
where: {
|
||||
code: studytrack,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
graduated: 1,
|
||||
enddate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const cancelledStudyRights = async (studytrack, since) => {
|
||||
return await Studyright.findAll({
|
||||
include: {
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
where: {
|
||||
code: {
|
||||
[Op.eq]: studytrack,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
canceldate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const transfersAway = async (studytrack, since) => {
|
||||
return await Transfer.findAll({
|
||||
where: {
|
||||
transferdate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
sourcecode: studytrack,
|
||||
},
|
||||
distinct: true,
|
||||
col: 'studentnumber',
|
||||
})
|
||||
}
|
||||
|
||||
const transfersTo = async (studytrack, since) => {
|
||||
return await Transfer.findAll({
|
||||
where: {
|
||||
transferdate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
targetcode: studytrack,
|
||||
},
|
||||
distinct: true,
|
||||
col: 'studentnumber',
|
||||
})
|
||||
}
|
||||
|
||||
const getProgrammesStudents = async studyprogramme =>
|
||||
(
|
||||
await Studyright.findAll({
|
||||
attributes: ['studyrightid', 'studystartdate', 'enddate', 'graduated', 'prioritycode', 'extentcode'],
|
||||
include: [
|
||||
{
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: [studyprogramme],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: Student,
|
||||
attributes: ['studentnumber'],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
).map(formatStudyright)
|
||||
|
||||
const getCreditsForStudyProgramme = async (provider, since) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: ['code'],
|
||||
required: true,
|
||||
where: {
|
||||
is_study_module: false,
|
||||
},
|
||||
include: {
|
||||
model: Organization,
|
||||
required: true,
|
||||
where: {
|
||||
code: provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
credittypecode: {
|
||||
[Op.notIn]: [10, 9, 7],
|
||||
},
|
||||
isStudyModule: {
|
||||
[Op.not]: true,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getTransferredCredits = async (provider, since) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'credittypecode', 'student_studentnumber'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: ['code'],
|
||||
required: true,
|
||||
where: {
|
||||
is_study_module: false,
|
||||
},
|
||||
include: {
|
||||
model: Organization,
|
||||
required: true,
|
||||
where: {
|
||||
code: provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
credittypecode: {
|
||||
[Op.eq]: [9],
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getThesisCredits = async (provider, since, thesisType) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: ['code'],
|
||||
required: true,
|
||||
where: {
|
||||
course_unit_type: {
|
||||
[Op.in]: thesisType,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
model: Organization,
|
||||
required: true,
|
||||
where: {
|
||||
code: provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
credittypecode: {
|
||||
[Op.notIn]: [10, 9, 7],
|
||||
},
|
||||
isStudyModule: {
|
||||
[Op.not]: true,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
enrolledStudyrights,
|
||||
startedStudyrights,
|
||||
graduatedStudyRights,
|
||||
cancelledStudyRights,
|
||||
transfersAway,
|
||||
transfersTo,
|
||||
getProgrammesStudents,
|
||||
getCreditsForStudyProgramme,
|
||||
getTransferredCredits,
|
||||
getThesisCredits,
|
||||
}
|
||||
@@ -65,7 +65,7 @@ const formatStudentForPopulationStatistics = (
|
||||
startDateMoment,
|
||||
endDateMoment
|
||||
) => {
|
||||
const toCourse = ({ grade, attainment_date, credits, course_code, credittypecode, isStudyModule }) => {
|
||||
const toCourse = ({ grade, attainment_date, credits, course_code, credittypecode, isStudyModule, language }) => {
|
||||
const attainment_date_normailized =
|
||||
attainment_date < startDate ? startDateMoment.clone().add(1, 'day').toISOString() : attainment_date
|
||||
const passed = Credit.passed({ credittypecode })
|
||||
@@ -78,6 +78,7 @@ const formatStudentForPopulationStatistics = (
|
||||
credits,
|
||||
isStudyModuleCredit: isStudyModule,
|
||||
credittypecode,
|
||||
language,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +266,7 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
|
||||
'isStudyModule',
|
||||
'student_studentnumber',
|
||||
'course_code',
|
||||
'language',
|
||||
],
|
||||
where: creditsOfStudent,
|
||||
raw: true,
|
||||
@@ -494,6 +496,7 @@ const studentnumbersWithAllStudyrightElements = async (
|
||||
if (!newestStudytrack) return false
|
||||
return studyRights.includes(newestStudytrack.code)
|
||||
})
|
||||
|
||||
const studentnumberlist = filteredStudentnumbers.length > 0 ? filteredStudentnumbers : studentnumbers
|
||||
|
||||
// fetch students that have transferred out of the programme and filter out these studentnumbers
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,63 @@
|
||||
const { getImporterClient } = require('../util/importerClient')
|
||||
const importerClient = getImporterClient()
|
||||
const { StudyGuidanceGroupTag } = require('../models/models_kone')
|
||||
const logger = require('../util/logger')
|
||||
|
||||
const getAllStudentsUserHasInGroups = async sisPersonId => {
|
||||
if (!importerClient) {
|
||||
return new Set()
|
||||
}
|
||||
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
|
||||
return new Set(
|
||||
Object.values(data)
|
||||
.map(group => group.members)
|
||||
.flat()
|
||||
.map(member => member.personStudentNumber)
|
||||
)
|
||||
try {
|
||||
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
|
||||
return new Set(
|
||||
Object.values(data)
|
||||
.map(group => group.members)
|
||||
.flat()
|
||||
.map(member => member.personStudentNumber)
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error("Couldn't fetch users studyguidance groups")
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
const getAllGroupsAndStudents = async sisPersonId => {
|
||||
if (!importerClient) {
|
||||
return []
|
||||
}
|
||||
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
|
||||
return Object.values(data)
|
||||
try {
|
||||
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
|
||||
const tagsByGroupId = (await StudyGuidanceGroupTag.findAll()).reduce((acc, curr) => {
|
||||
const { studyProgramme, year } = curr
|
||||
return { ...acc, [curr.studyGuidanceGroupId]: { studyProgramme, year } }
|
||||
}, {})
|
||||
return Object.values(data).map(group => ({ ...group, tags: tagsByGroupId[group.id] }))
|
||||
} catch (error) {
|
||||
logger.error("Couldn't fetch users studyguidance groups")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const changeGroupTags = async ({ groupId, tags }) => {
|
||||
const { studyProgramme, year } = tags
|
||||
const tagToUpdate = studyProgramme ? { studyProgramme } : { year }
|
||||
|
||||
const [result] = await StudyGuidanceGroupTag.upsert(
|
||||
{
|
||||
studyGuidanceGroupId: groupId,
|
||||
...tagToUpdate,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
studyGuidanceGroupId: groupId,
|
||||
},
|
||||
}
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAllStudentsUserHasInGroups,
|
||||
getAllGroupsAndStudents,
|
||||
changeGroupTags,
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ const findProgrammeThesisCredits = async code => {
|
||||
},
|
||||
where: {
|
||||
credittypecode: {
|
||||
[Op.ne]: 10,
|
||||
[Op.eq]: 4,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
const { mean } = require('lodash')
|
||||
|
||||
// Helper functions
|
||||
const formatStudyright = studyright => {
|
||||
const { studyrightid, studystartdate, enddate, graduated, prioritycode, extentcode, student } = studyright
|
||||
return {
|
||||
studyrightid,
|
||||
studystartdate,
|
||||
enddate,
|
||||
graduated,
|
||||
prioritycode,
|
||||
extentcode,
|
||||
studentnumber: student.studentnumber,
|
||||
}
|
||||
}
|
||||
|
||||
const getYearsArray = (since, isAcademicYear) => {
|
||||
const years = []
|
||||
for (let i = since; i <= new Date().getFullYear(); i++) {
|
||||
const year = isAcademicYear ? `${i} - ${i + 1}` : i
|
||||
years.push(year)
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
const getYearsObject = (years, emptyArrays = false) => {
|
||||
let yearsObject = {}
|
||||
for (const year of years) {
|
||||
yearsObject = { ...yearsObject, [year]: emptyArrays ? [] : 0 }
|
||||
}
|
||||
return yearsObject
|
||||
}
|
||||
|
||||
const getStatsBasis = years => {
|
||||
return {
|
||||
graphStats: new Array(years.length).fill(0),
|
||||
tableStats: getYearsObject(years),
|
||||
}
|
||||
}
|
||||
|
||||
const isMajorStudentCredit = (studyright, attainment_date) =>
|
||||
studyright &&
|
||||
(studyright.prioritycode === 1 || studyright.prioritycode === 30) && // Is studyright state = MAIN or state = GRADUATED
|
||||
studyright.studystartdate <= attainment_date && // Has the credit been attained after studying in the programme started
|
||||
studyright.enddate >= attainment_date && // Has the credit been attained before the studyright ended
|
||||
(!studyright.canceldate || studyright.canceldate >= attainment_date) // If the studyright was cancelled, was the credit attained before it was cancelled
|
||||
|
||||
const getMedian = values => {
|
||||
if (values.length === 0) return 0
|
||||
values.sort((a, b) => a - b)
|
||||
const half = Math.floor(values.length / 2)
|
||||
if (values.length % 2) return values[half]
|
||||
return (values[half - 1] + values[half]) / 2.0
|
||||
}
|
||||
|
||||
const getMean = values => {
|
||||
if (values.length === 0) return 0
|
||||
return Math.round(mean(values))
|
||||
}
|
||||
|
||||
const defineYear = (date, isAcademicYear) => {
|
||||
if (!date) return ''
|
||||
const year = date.getFullYear()
|
||||
if (!isAcademicYear) return year
|
||||
if (date < new Date(`${year}-07-31`)) return `${year - 1} - ${year}`
|
||||
return `${year} - ${year + 1}`
|
||||
}
|
||||
|
||||
const getStartDate = (studyprogramme, isAcademicYear) => {
|
||||
if ((studyprogramme.includes('KH') || studyprogramme.includes('MH')) && isAcademicYear) return new Date('2017-08-01')
|
||||
if (studyprogramme.includes('KH') || studyprogramme.includes('MH')) return new Date('2017-01-01')
|
||||
if (isAcademicYear) return new Date('2000-08-01')
|
||||
return new Date('2000-01-01')
|
||||
}
|
||||
|
||||
// There are 9 course_unit_types
|
||||
// 1. urn:code:course-unit-type:regular
|
||||
// 2. urn:code:course-unit-type:bachelors-thesis
|
||||
// 3. urn:code:course-unit-type:masters-thesis
|
||||
// 4. urn:code:course-unit-type:doctors-thesis
|
||||
// 5. urn:code:course-unit-type:licentiate-thesis
|
||||
// 6. urn:code:course-unit-type:bachelors-maturity-examination
|
||||
// 7. urn:code:course-unit-type:masters-maturity-examination
|
||||
// 8. urn:code:course-unit-type:communication-and-linguistic-studies
|
||||
// 9. urn:code:course-unit-type:practical-training-homeland
|
||||
// Four of these are thesis types
|
||||
|
||||
const getThesisType = studyprogramme => {
|
||||
if (studyprogramme.includes('MH') || studyprogramme.includes('ma'))
|
||||
return ['urn:code:course-unit-type:masters-thesis']
|
||||
if (studyprogramme.includes('KH') || studyprogramme.includes('ba'))
|
||||
return ['urn:code:course-unit-type:bachelors-thesis']
|
||||
if (/^(T)[0-9]{6}$/.test(studyprogramme))
|
||||
return ['urn:code:course-unit-type:doctors-thesis', 'urn:code:course-unit-type:licentiate-thesis']
|
||||
return 'thesis-type-not-defined'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatStudyright,
|
||||
getYearsArray,
|
||||
getYearsObject,
|
||||
getStatsBasis,
|
||||
isMajorStudentCredit,
|
||||
getMedian,
|
||||
getMean,
|
||||
defineYear,
|
||||
getStartDate,
|
||||
getThesisType,
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
const moment = require('moment')
|
||||
const { indexOf } = require('lodash')
|
||||
|
||||
const { mapToProviders } = require('../util/utils')
|
||||
const {
|
||||
getStatsBasis,
|
||||
defineYear,
|
||||
isMajorStudentCredit,
|
||||
getYearsObject,
|
||||
getYearsArray,
|
||||
getMedian,
|
||||
getMean,
|
||||
getStartDate,
|
||||
getThesisType,
|
||||
} = require('./studyprogrammeHelpers')
|
||||
const {
|
||||
graduatedStudyRights,
|
||||
startedStudyrights,
|
||||
cancelledStudyRights,
|
||||
transfersAway,
|
||||
transfersTo,
|
||||
getProgrammesStudents,
|
||||
getCreditsForStudyProgramme,
|
||||
getTransferredCredits,
|
||||
getThesisCredits,
|
||||
enrolledStudyrights,
|
||||
} = require('./newStudyprogramme')
|
||||
|
||||
const getEnrolledStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const students = await enrolledStudyrights(studytrack)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
students.forEach(({ startdate }) => {
|
||||
if (startdate.getMonth() === 7) {
|
||||
const enrolledYear = defineYear(startdate, isAcademicYear)
|
||||
|
||||
graphStats[indexOf(years, enrolledYear)] += 1
|
||||
tableStats[enrolledYear] += 1
|
||||
}
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getGraduatedStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await graduatedStudyRights(studytrack, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ enddate }) => {
|
||||
const graduationYear = defineYear(enddate, isAcademicYear)
|
||||
graphStats[indexOf(years, graduationYear)] += 1
|
||||
tableStats[graduationYear] += 1
|
||||
})
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getStartedStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await startedStudyrights(studytrack, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ studystartdate }) => {
|
||||
const startYear = defineYear(studystartdate, isAcademicYear)
|
||||
graphStats[indexOf(years, startYear)] += 1
|
||||
tableStats[startYear] += 1
|
||||
})
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getCancelledStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await cancelledStudyRights(studytrack, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ canceldate }) => {
|
||||
const cancelYear = defineYear(canceldate, isAcademicYear)
|
||||
graphStats[indexOf(years, cancelYear)] += 1
|
||||
tableStats[cancelYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getTransferredAwayStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await transfersAway(studytrack, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ transferdate }) => {
|
||||
const transferYear = defineYear(transferdate, isAcademicYear)
|
||||
graphStats[indexOf(years, transferYear)] += 1
|
||||
tableStats[transferYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getTransferredToStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await transfersTo(studytrack, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ transferdate }) => {
|
||||
const transferYear = defineYear(transferdate, isAcademicYear)
|
||||
graphStats[indexOf(years, transferYear)] += 1
|
||||
tableStats[transferYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getRegularCreditStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const providercode = mapToProviders([studytrack])[0]
|
||||
const studyrights = await getProgrammesStudents(studytrack)
|
||||
const credits = await getCreditsForStudyProgramme(providercode, since)
|
||||
|
||||
let majors = getStatsBasis(years)
|
||||
let nonMajors = getStatsBasis(years)
|
||||
|
||||
credits.forEach(({ student_studentnumber, attainment_date, credits }) => {
|
||||
const studyright = studyrights.find(studyright => studyright.studentnumber == student_studentnumber)
|
||||
const attainmentYear = defineYear(attainment_date, isAcademicYear)
|
||||
|
||||
if (isMajorStudentCredit(studyright, attainment_date)) {
|
||||
majors.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
majors.tableStats[attainmentYear] += credits || 0
|
||||
} else {
|
||||
nonMajors.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
nonMajors.tableStats[attainmentYear] += credits || 0
|
||||
}
|
||||
})
|
||||
|
||||
return { majors, nonMajors }
|
||||
}
|
||||
|
||||
const getTransferredCreditStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const providercode = mapToProviders([studytrack])[0]
|
||||
const credits = await getTransferredCredits(providercode, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
credits.forEach(({ attainment_date, credits }) => {
|
||||
const attainmentYear = defineYear(attainment_date, isAcademicYear)
|
||||
graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
tableStats[attainmentYear] += credits || 0
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getThesisStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const providercode = mapToProviders([studytrack])[0]
|
||||
const thesisType = getThesisType(studytrack)
|
||||
const credits = await getThesisCredits(providercode, since, thesisType)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
credits.forEach(({ attainment_date }) => {
|
||||
const attainmentYear = defineYear(attainment_date, isAcademicYear)
|
||||
graphStats[indexOf(years, attainmentYear)] += 1
|
||||
tableStats[attainmentYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getGraduationTimeStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await graduatedStudyRights(studytrack, since)
|
||||
let graduationAmounts = getYearsObject(years)
|
||||
let graduationTimes = getYearsObject(years, true)
|
||||
|
||||
studyrights.forEach(({ enddate, studystartdate }) => {
|
||||
const graduationYear = defineYear(enddate, isAcademicYear)
|
||||
const timeToGraduation = moment(enddate).diff(moment(studystartdate), 'months')
|
||||
graduationAmounts[graduationYear] += 1
|
||||
graduationTimes[graduationYear] = [...graduationTimes[graduationYear], timeToGraduation]
|
||||
})
|
||||
|
||||
const medians = getYearsObject(years, true)
|
||||
const means = getYearsObject(years, true)
|
||||
|
||||
// The maximum amount of months in the graph depends on the studyprogramme intended graduation time
|
||||
const comparisonValue = studytrack.includes('KH') ? 72 : 48
|
||||
|
||||
// HighCharts graph require the data to have this format (ie. actual value, "empty value")
|
||||
years.forEach(year => {
|
||||
const median = getMedian(graduationTimes[year])
|
||||
const mean = getMean(graduationTimes[year])
|
||||
medians[year] = [
|
||||
['', median],
|
||||
['', comparisonValue - median],
|
||||
]
|
||||
means[year] = [
|
||||
['', mean],
|
||||
['', comparisonValue - mean],
|
||||
]
|
||||
})
|
||||
return { medians, means, graduationAmounts }
|
||||
}
|
||||
|
||||
const getBasicStatsForStudytrack = async ({ studyprogramme, yearType }) => {
|
||||
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
const started = await getStartedStats(studyprogramme, since, years, isAcademicYear)
|
||||
const graduated = await getGraduatedStats(studyprogramme, since, years, isAcademicYear)
|
||||
const cancelled = await getCancelledStats(studyprogramme, since, years, isAcademicYear)
|
||||
const transferredAway = await getTransferredAwayStats(studyprogramme, since, years, isAcademicYear)
|
||||
const transferredTo = await getTransferredToStats(studyprogramme, since, years, isAcademicYear)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
const tableStats = reversedYears.map(year => [
|
||||
year,
|
||||
started.tableStats[year],
|
||||
graduated.tableStats[year],
|
||||
cancelled.tableStats[year],
|
||||
transferredAway.tableStats[year],
|
||||
transferredTo.tableStats[year],
|
||||
])
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
graphStats: [
|
||||
{
|
||||
name: 'Started',
|
||||
data: started.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Graduated',
|
||||
data: graduated.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Cancelled',
|
||||
data: cancelled.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred away',
|
||||
data: transferredAway.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred To',
|
||||
data: transferredTo.graphStats,
|
||||
},
|
||||
],
|
||||
tableStats,
|
||||
}
|
||||
}
|
||||
|
||||
const getCreditStatsForStudytrack = async ({ studyprogramme, yearType }) => {
|
||||
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
const { majors, nonMajors } = await getRegularCreditStats(studyprogramme, since, years, isAcademicYear)
|
||||
const transferred = await getTransferredCreditStats(studyprogramme, since, years, isAcademicYear)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
const tableStats = reversedYears.map(year => [
|
||||
year,
|
||||
majors.tableStats[year] + nonMajors.tableStats[year] + transferred.tableStats[year],
|
||||
majors.tableStats[year],
|
||||
nonMajors.tableStats[year],
|
||||
transferred.tableStats[year],
|
||||
])
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
tableStats,
|
||||
graphStats: [
|
||||
{
|
||||
name: 'Major students credits',
|
||||
data: majors.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Non-major students credits',
|
||||
data: nonMajors.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred credits',
|
||||
data: transferred.graphStats,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType }) => {
|
||||
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
const enrolled = await getEnrolledStats(studyprogramme, since, years, isAcademicYear)
|
||||
const thesis = await getThesisStats(studyprogramme, since, years, isAcademicYear)
|
||||
const graduated = await getGraduatedStats(studyprogramme, since, years, isAcademicYear)
|
||||
const graduationTimeStats = await getGraduationTimeStats(studyprogramme, since, years, isAcademicYear)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
const tableStats = reversedYears.map(year => [
|
||||
year,
|
||||
enrolled.tableStats[year],
|
||||
graduated.tableStats[year],
|
||||
thesis.tableStats[year],
|
||||
])
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
tableStats,
|
||||
graphStats: [
|
||||
{
|
||||
name: 'Enrolled students',
|
||||
data: enrolled.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Graduated students',
|
||||
data: graduated.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Wrote thesis',
|
||||
data: thesis.graphStats,
|
||||
},
|
||||
],
|
||||
graduationMedianTime: graduationTimeStats.medians,
|
||||
graduationMeanTime: graduationTimeStats.means,
|
||||
graduationAmounts: graduationTimeStats.graduationAmounts,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBasicStatsForStudytrack,
|
||||
getCreditStatsForStudytrack,
|
||||
getGraduationStatsForStudytrack,
|
||||
}
|
||||
@@ -178,7 +178,7 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'su
|
||||
WHERE
|
||||
cr.attainment_date BETWEEN :a AND :b
|
||||
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
|
||||
AND cr.credittypecode IN (4, 9)
|
||||
AND cr.credittypecode IN (4, 9)
|
||||
GROUP BY co.id, o.code
|
||||
-- HAVING SUM(cr.credits) > 0
|
||||
`,
|
||||
@@ -189,13 +189,40 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'su
|
||||
)
|
||||
}
|
||||
|
||||
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2') => {
|
||||
const getTotalOpenUniCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'sum2') => {
|
||||
return sequelize.query(
|
||||
`
|
||||
SELECT SUM(cr.credits) AS ${alias}, COUNT(DISTINCT(cr.student_studentnumber)) AS ${alias2},
|
||||
o.code AS organizationcode, co.code, co.id, co.name
|
||||
FROM credit cr
|
||||
INNER JOIN course co ON cr.course_code = co.code
|
||||
INNER JOIN course_providers cp ON cp.coursecode = co.id
|
||||
INNER JOIN organization o ON o.id = cp.organizationcode
|
||||
AND cr.attainment_date BETWEEN :a AND :b
|
||||
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
|
||||
AND cr.credittypecode IN (4, 9) AND cp.organizationcode = 'hy-org-48645785'
|
||||
AND cr.student_studentnumber NOT IN
|
||||
(SELECT student_studentnumber FROM studyright
|
||||
WHERE studyright.student_studentnumber = cr.student_studentnumber
|
||||
AND cr.attainment_date BETWEEN studyright.startdate AND studyright.enddate)
|
||||
GROUP BY co.id, o.code -- HAVING SUM(cr.credits) > 0
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { a, b },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2', avoin) => {
|
||||
return years.map(
|
||||
year =>
|
||||
new Promise(async res => {
|
||||
const diff = currentYear - year
|
||||
const { from, to } = getRange(diff)
|
||||
const creditsByCourse = await getTotalCreditsOfCoursesBetween(from, to, alias, alias2)
|
||||
const creditsByCourse = avoin
|
||||
? await getTotalOpenUniCreditsOfCoursesBetween(from, to, alias, alias2)
|
||||
: await getTotalCreditsOfCoursesBetween(from, to, alias, alias2)
|
||||
res(
|
||||
creditsByCourse.map(c => {
|
||||
c['year'] = year
|
||||
@@ -218,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... */
|
||||
@@ -225,38 +273,83 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
const startYear = startDate.getFullYear()
|
||||
const startTime = startDate.getTime()
|
||||
const yearRange = _.range(2017, startYear + 1)
|
||||
const getRangeAcc = diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to:
|
||||
showByYear === 'true'
|
||||
? new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS)
|
||||
: new Date(unixMillis - diff * YEAR_TO_MILLISECONDS),
|
||||
})
|
||||
const getRangeTotal = diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
|
||||
})
|
||||
const yearlyAccCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange,
|
||||
diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to:
|
||||
showByYear === 'true'
|
||||
? new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS)
|
||||
: new Date(unixMillis - diff * YEAR_TO_MILLISECONDS),
|
||||
}),
|
||||
getRangeAcc,
|
||||
'acc',
|
||||
'students'
|
||||
'students',
|
||||
false
|
||||
)
|
||||
const yearlyOpenUniAccCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange,
|
||||
getRangeAcc,
|
||||
'acc',
|
||||
'students',
|
||||
true
|
||||
)
|
||||
|
||||
const yearlyTotalCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange.slice(0, -1),
|
||||
diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
|
||||
}),
|
||||
getRangeTotal,
|
||||
'total',
|
||||
'students'
|
||||
'students',
|
||||
false
|
||||
)
|
||||
|
||||
const yearlyOpenUniTotalCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange.slice(0, -1),
|
||||
getRangeTotal,
|
||||
'total',
|
||||
'students',
|
||||
true
|
||||
)
|
||||
|
||||
/* Gather all required data */
|
||||
const [yearlyAccCredits, yearlyTotalCredits, elementDetails, faculties] = await Promise.all([
|
||||
const [
|
||||
yearlyAccCredits,
|
||||
yearlyTotalCredits,
|
||||
yearlyOpenUniAccCredits,
|
||||
yearlyOpenUniTotalCredits,
|
||||
elementDetails,
|
||||
faculties,
|
||||
] = await Promise.all([
|
||||
Promise.all(yearlyAccCreditsPromises),
|
||||
Promise.all(yearlyTotalCreditsPromises),
|
||||
Promise.all(yearlyOpenUniAccCreditsPromises),
|
||||
Promise.all(yearlyOpenUniTotalCreditsPromises),
|
||||
ElementDetail.findAll(),
|
||||
Organization.findAll(),
|
||||
])
|
||||
|
||||
const isOpenUniCourseCode = code => code.match(/^AY?(.+?)(?:en|fi|sv)?$/)
|
||||
|
||||
const unifyOpenUniversity = code => {
|
||||
const regexresult = isOpenUniCourseCode(code)
|
||||
if (!regexresult) return code
|
||||
return regexresult[1]
|
||||
}
|
||||
|
||||
const fromAyToNormalCourses = [
|
||||
...new Set(_.flatten(yearlyOpenUniAccCredits).map(c => unifyOpenUniversity(c.code))),
|
||||
...new Set(_.flatten(yearlyOpenUniTotalCredits).map(c => unifyOpenUniversity(c.code))),
|
||||
]
|
||||
|
||||
const orgsOfOpenUniReqularCounterparts = await getOrgsOfOpenUniReqularCounterparts(fromAyToNormalCourses)
|
||||
|
||||
const facultyProgrammes = facultiesAndProgrammesForTrends
|
||||
|
||||
const yearlyOrgStatPromises = yearRange.map(async year => {
|
||||
@@ -322,6 +415,8 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
return res
|
||||
}, {})
|
||||
|
||||
programmeToFaculties['H906'] = ['H906']
|
||||
|
||||
const providerToProgramme = elementDetails.reduce((res, curr) => {
|
||||
const [p] = mapToProviders([curr.code])
|
||||
res[p] = {
|
||||
@@ -331,11 +426,43 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
return res
|
||||
}, {})
|
||||
|
||||
providerToProgramme['H906'] = {
|
||||
code: 'H906',
|
||||
name: { en: 'Language Centre', fi: 'Kielikeskus', sv: 'Språkcentrum' },
|
||||
}
|
||||
|
||||
providerToProgramme['H930'] = {
|
||||
code: 'H930',
|
||||
name: { en: 'Open University', fi: 'Avoin yliopisto', sv: 'Öppna universitetet' },
|
||||
}
|
||||
|
||||
const organizationCodeToOrganization = faculties.reduce((acc, org) => {
|
||||
acc[org.code] = org
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const sumMerge = (a, b) => {
|
||||
if (!a) return b
|
||||
if (!b) return a
|
||||
return a + b
|
||||
}
|
||||
|
||||
const mergele = (a, b) => {
|
||||
if (!a) return _.clone(b)
|
||||
if (!b) return _.clone(a)
|
||||
return _.mergeWith(a, b, sumMerge)
|
||||
}
|
||||
|
||||
const refactoredOpenUniCredits = [..._.flatten(yearlyOpenUniAccCredits), ..._.flatten(yearlyOpenUniTotalCredits)].map(
|
||||
ele => {
|
||||
const org = orgsOfOpenUniReqularCounterparts[unifyOpenUniversity(ele.code)]
|
||||
return {
|
||||
...ele,
|
||||
organizationcode: org ? org : 'H930',
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/* Calculate course level stats and group by providers */
|
||||
const coursesGroupedByProvider = Object.entries(
|
||||
_.groupBy([..._.flatten(yearlyAccCredits), ..._.flatten(yearlyTotalCredits)], 'organizationcode')
|
||||
@@ -355,8 +482,8 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
acc[courseCode]['yearly'][year] = {}
|
||||
acc[courseCode]['yearly'][year]['acc'] = 0
|
||||
acc[courseCode]['yearly'][year]['accStudents'] = 0
|
||||
acc[courseCode]['yearly'][year]['total'] = 0
|
||||
acc[courseCode]['yearly'][year]['totalStudents'] = 0
|
||||
acc[courseCode]['yearly'][year]['total'] = undefined
|
||||
acc[courseCode]['yearly'][year]['totalStudents'] = undefined
|
||||
})
|
||||
|
||||
yearlyInstances.forEach(instance => {
|
||||
@@ -370,6 +497,52 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
acc[courseCode]['yearly'][instance.year]['totalStudents'] = Number(instance.students)
|
||||
}
|
||||
})
|
||||
|
||||
acc[courseCode]['current'] = _.get(acc, [courseCode, 'yearly', startYear, 'acc']) || 0
|
||||
acc[courseCode]['previous'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'acc']) || 0
|
||||
acc[courseCode]['currentStudents'] = _.get(acc, [courseCode, 'yearly', startYear, 'accStudents']) || 0
|
||||
acc[courseCode]['previousStudents'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'accStudents']) || 0
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const openUniCoursesGroupedByProvider = Object.entries(
|
||||
_.groupBy(refactoredOpenUniCredits, 'organizationcode')
|
||||
).reduce((acc, [organizationcode, courseCredits]) => {
|
||||
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
|
||||
(acc, [courseCode, yearlyInstances]) => {
|
||||
acc[courseCode] = {
|
||||
yearly: {},
|
||||
name: yearlyInstances[0].name,
|
||||
type: 'course',
|
||||
code: courseCode,
|
||||
}
|
||||
|
||||
const array = _.range(2017, startYear + 1)
|
||||
|
||||
array.forEach(year => {
|
||||
acc[courseCode]['yearly'][year] = {}
|
||||
acc[courseCode]['yearly'][year]['acc'] = 0
|
||||
acc[courseCode]['yearly'][year]['accStudents'] = 0
|
||||
acc[courseCode]['yearly'][year]['total'] = undefined
|
||||
acc[courseCode]['yearly'][year]['totalStudents'] = undefined
|
||||
})
|
||||
|
||||
yearlyInstances.forEach(instance => {
|
||||
if (!acc[courseCode]['yearly'][instance.year]) acc[courseCode]['yearly'][instance.year] = {}
|
||||
|
||||
if (instance.acc !== undefined) {
|
||||
acc[courseCode]['yearly'][instance.year]['acc'] = instance.acc
|
||||
acc[courseCode]['yearly'][instance.year]['accStudents'] = Number(instance.students)
|
||||
} else {
|
||||
acc[courseCode]['yearly'][instance.year]['total'] = instance.total
|
||||
acc[courseCode]['yearly'][instance.year]['totalStudents'] = Number(instance.students)
|
||||
}
|
||||
})
|
||||
|
||||
acc[courseCode]['current'] = _.get(acc, [courseCode, 'yearly', startYear, 'acc']) || 0
|
||||
acc[courseCode]['previous'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'acc']) || 0
|
||||
acc[courseCode]['currentStudents'] = _.get(acc, [courseCode, 'yearly', startYear, 'accStudents']) || 0
|
||||
@@ -386,6 +559,25 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
const programme = providerToProgramme[organizationcode]
|
||||
|
||||
if (programme && programme.code) {
|
||||
if (programme.code === 'H906') {
|
||||
const courseValues = Object.values(courses)
|
||||
const yearlyValues = courseValues.map(c => c.yearly)
|
||||
|
||||
acc[programme.code] = {
|
||||
type: 'programme',
|
||||
code: programme.code,
|
||||
name: programme.name,
|
||||
drill: courses,
|
||||
yearly: _.mergeWith({}, ...yearlyValues, mergele),
|
||||
current: _.sumBy(courseValues, 'current') | undefined,
|
||||
previous: _.sumBy(courseValues, 'previous') | undefined,
|
||||
currentStudents: _.sumBy(courseValues, 'currentStudents') || 'undefined',
|
||||
previousStudents: _.sumBy(courseValues, 'previousStudents') | 'undefined',
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
const orgId = organizationCodeToOrganization[organizationcode]?.id
|
||||
|
||||
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated.aggregated
|
||||
@@ -401,20 +593,64 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
yearly,
|
||||
current,
|
||||
previous,
|
||||
currentStudents,
|
||||
previousStudents,
|
||||
currentStudents: currentStudents,
|
||||
previousStudents: previousStudents,
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const openUniGroupedByProgramme = Object.entries(openUniCoursesGroupedByProvider).reduce(
|
||||
(acc, [organizationcode, courses]) => {
|
||||
const programme = providerToProgramme[organizationcode]
|
||||
|
||||
if (programme && programme.code) {
|
||||
const courseValues = Object.values(courses)
|
||||
const yearlyValues = courseValues.map(c => c.yearly)
|
||||
|
||||
acc[programme.code] = {
|
||||
type: 'programme',
|
||||
code: programme.code,
|
||||
name: programme.name,
|
||||
drill: courses,
|
||||
yearly: _.mergeWith({}, ...yearlyValues, mergele),
|
||||
current: _.sumBy(courseValues, 'current') | undefined,
|
||||
previous: _.sumBy(courseValues, 'previous') | undefined,
|
||||
currentStudents: _.sumBy(courseValues, 'currentStudents') || 'undefined',
|
||||
previousStudents: _.sumBy(courseValues, 'previousStudents') | 'undefined',
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
/* Group programmes into faculties and calculate faculty level stats */
|
||||
const groupedByFaculty = Object.entries(groupedByProgramme).reduce((acc, [programmeCode, programmeStats]) => {
|
||||
const facultyCodes = programmeToFaculties[programmeCode]
|
||||
if (!facultyCodes) return acc
|
||||
facultyCodes.forEach(facultyCode => {
|
||||
if (!facultyCode) return
|
||||
if (facultyCode === 'H906' || facultyCode === 'H930') {
|
||||
if (!acc[facultyCode]) {
|
||||
acc[facultyCode] = {
|
||||
type: 'faculty',
|
||||
code: facultyCode,
|
||||
drill: {},
|
||||
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
|
||||
yearly: programmeStats.yearly,
|
||||
current: programmeStats.current,
|
||||
previous: programmeStats.previous,
|
||||
currentStudents: programmeStats.currentStudents,
|
||||
previousStudents: programmeStats.previousStudents,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!acc[facultyCode]) {
|
||||
const orgId = organizationCodeToOrganization[facultyCode].id
|
||||
|
||||
@@ -443,7 +679,31 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return groupedByFaculty
|
||||
const openUniGroupedByFaculty = Object.entries(openUniGroupedByProgramme).reduce(
|
||||
(acc, [programmeCode, programmeStats]) => {
|
||||
const facultyCode = 'H930'
|
||||
|
||||
if (!acc[facultyCode]) {
|
||||
acc[facultyCode] = {
|
||||
type: 'faculty',
|
||||
code: facultyCode,
|
||||
drill: {},
|
||||
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
|
||||
yearly: {},
|
||||
current: 0,
|
||||
previous: 0,
|
||||
}
|
||||
}
|
||||
|
||||
acc[facultyCode]['drill'][programmeCode] = programmeStats
|
||||
acc[facultyCode]['yearly'] = _.mergeWith(acc[facultyCode]['yearly'], programmeStats.yearly, mergele)
|
||||
acc[facultyCode]['current'] += programmeStats.current
|
||||
acc[facultyCode]['previous'] += programmeStats.previous
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
return { ...groupedByFaculty, ...openUniGroupedByFaculty }
|
||||
}
|
||||
|
||||
const getStatus = async (unixMillis, showByYear, doRefresh = false) => {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
const axios = require('axios')
|
||||
const { importerToken, importerUrl } = require('../conf-backend')
|
||||
const { importerToken } = require('../conf-backend')
|
||||
const logger = require('./logger')
|
||||
|
||||
const importerClient = axios.create({
|
||||
headers: {
|
||||
token: importerToken,
|
||||
},
|
||||
baseURL: importerUrl,
|
||||
baseURL: 'https://importer.cs.helsinki.fi/api/importer',
|
||||
})
|
||||
|
||||
const getImporterClient = () => {
|
||||
if (!importerToken || !importerUrl) {
|
||||
logger.error("Importer token or importer url not set, can't return client!")
|
||||
if (!importerToken) {
|
||||
logger.error("Importer token not set, can't return client!")
|
||||
return null
|
||||
}
|
||||
return importerClient
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
const winston = require('winston')
|
||||
const path = require('path')
|
||||
const { isProduction, PWD } = require('../conf-backend')
|
||||
|
||||
const { isDev } = require('../conf-backend')
|
||||
const { combine, timestamp, printf, splat } = winston.format
|
||||
|
||||
const transports = []
|
||||
// Write logfile to docker containers pwd
|
||||
const filename = path.join(PWD, 'debug.log')
|
||||
transports.push(new winston.transports.File({ filename, level: 'debug' }))
|
||||
let transports = []
|
||||
|
||||
if (!isProduction) {
|
||||
if (isDev) {
|
||||
const devFormat = printf(
|
||||
({ level, message, timestamp, ...rest }) => `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
)
|
||||
@@ -20,9 +15,7 @@ if (!isProduction) {
|
||||
format: combine(splat(), timestamp(), devFormat),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (isProduction) {
|
||||
} else {
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
@@ -40,9 +33,11 @@ if (isProduction) {
|
||||
})
|
||||
)
|
||||
|
||||
transports.push(new winston.transports.Console({ format: prodFormat, level: 'info' }))
|
||||
transports.push(new winston.transports.Console({ format: prodFormat }))
|
||||
}
|
||||
|
||||
const logger = winston.createLogger({ transports })
|
||||
|
||||
logger.on('error', e => console.error('Logging failed! Reason: ', e)) // eslint-disable-line no-console
|
||||
|
||||
module.exports = logger
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+129
-22568
File diff suppressed because it is too large
Load Diff
@@ -6,13 +6,16 @@
|
||||
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.6.2",
|
||||
"@sentry/browser": "^6.9.0",
|
||||
"@sentry/tracing": "^6.11.0",
|
||||
"axios": "^0.21.1",
|
||||
"axios": "^0.21.2",
|
||||
"formik": "^2.2.9",
|
||||
"highcharts": "^9.1.2",
|
||||
"highcharts-custom-events": "^3.0.10",
|
||||
"highcharts-exporting": "^0.1.7",
|
||||
"history": "^4.10.1",
|
||||
"jstat": "^1.9.5",
|
||||
"immer": "^9.0.7",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1",
|
||||
@@ -22,18 +25,14 @@
|
||||
"qs": "^6.10.1",
|
||||
"query-string": "^6.14.1",
|
||||
"react": "^16.14.0",
|
||||
"react-context-devtool": "^2.0.3",
|
||||
"react-datetime": "^3.0.4",
|
||||
"react-dom": "^16.14.0",
|
||||
"react-highcharts": "^16.1.0",
|
||||
"react-markdown": "^6.0.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"react-scroll-to-component": "^1.0.2",
|
||||
"redux": "^4.1.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"reselect": "^4.0.0",
|
||||
"semantic-ui-css": "^2.4.1",
|
||||
"semantic-ui-react": "^2.0.3",
|
||||
"unfuck-spa-shibboleth-session": "^3.1.3",
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import FilterContextProvider from './components/FilterTray/FilterContextProvider'
|
||||
import { LanguageProvider } from './components/LanguagePicker/useLanguage'
|
||||
|
||||
const CommonContext = ({ children }) => (
|
||||
<LanguageProvider>
|
||||
<FilterContextProvider>{children}</FilterContextProvider>
|
||||
</LanguageProvider>
|
||||
)
|
||||
|
||||
CommonContext.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
const CommonContext = ({ children }) => <LanguageProvider>{children}</LanguageProvider>
|
||||
|
||||
export default CommonContext
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import axios from 'axios'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
import { getMocked, setMocking } from '../common'
|
||||
import { apiBasePath, isDev } from '../conf'
|
||||
import { loginPrefix, logoutPrefix } from '../redux/auth'
|
||||
|
||||
const getHeaders = () => {
|
||||
export const getHeaders = () => {
|
||||
// Set up dev user for development environment, mimicking production admin user
|
||||
const devUserHeaders = {
|
||||
uid: 'mluukkai',
|
||||
@@ -19,16 +21,16 @@ const getHeaders = () => {
|
||||
|
||||
export const api = axios.create({ baseURL: apiBasePath, headers: getHeaders() })
|
||||
|
||||
const types = {
|
||||
attempt: prefix => `${prefix}ATTEMPT`,
|
||||
failure: prefix => `${prefix}FAILURE`,
|
||||
success: prefix => `${prefix}SUCCESS`,
|
||||
export const actionSuffixes = {
|
||||
attempt: `ATTEMPT`,
|
||||
failure: `FAILURE`,
|
||||
success: `SUCCESS`,
|
||||
}
|
||||
|
||||
export const actionTypes = prefix => ({
|
||||
attempt: types.attempt(prefix),
|
||||
failure: types.failure(prefix),
|
||||
success: types.success(prefix),
|
||||
attempt: `${prefix}${actionSuffixes.attempt}`,
|
||||
failure: `${prefix}${actionSuffixes.failure}`,
|
||||
success: `${prefix}${actionSuffixes.success}`,
|
||||
})
|
||||
|
||||
export const logout = async () => {
|
||||
@@ -69,7 +71,8 @@ export const callController = (route, prefix, data, method = 'get', query, param
|
||||
params,
|
||||
onProgress,
|
||||
}
|
||||
return { type: `${prefix}ATTEMPT`, requestSettings }
|
||||
const { attempt } = actionTypes(prefix)
|
||||
return { type: attempt, requestSettings }
|
||||
}
|
||||
|
||||
const handleError = (err, actionHistory = []) => {
|
||||
@@ -90,20 +93,24 @@ export const handleRequest = store => next => async action => {
|
||||
const { requestSettings } = action
|
||||
if (requestSettings) {
|
||||
const { route, method, data, prefix, query, params, onProgress } = requestSettings
|
||||
const { success, failure } = actionTypes(prefix)
|
||||
try {
|
||||
const res = await callApi(route, method, data, params, 0, onProgress)
|
||||
store.dispatch({ type: `${prefix}SUCCESS`, response: res.data, query })
|
||||
store.dispatch({ type: success, response: res.data, query })
|
||||
} catch (e) {
|
||||
store.dispatch({ type: `${prefix}FAILURE`, response: e, query })
|
||||
store.dispatch({ type: failure, response: e, query })
|
||||
handleError(e, store.getState().actionHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const handleAuth = store => next => async action => {
|
||||
const loginTypes = actionTypes(loginPrefix)
|
||||
const logoutTypes = actionTypes(logoutPrefix)
|
||||
next(action)
|
||||
const { type } = action
|
||||
if (type === 'LOGIN_ATTEMPT') {
|
||||
if (type === loginTypes.attempt) {
|
||||
const { success, failure } = loginTypes
|
||||
try {
|
||||
let token
|
||||
const mocked = getMocked()
|
||||
@@ -118,18 +125,39 @@ export const handleAuth = store => next => async action => {
|
||||
...api.defaults.headers.common,
|
||||
'x-access-token': token,
|
||||
}
|
||||
store.dispatch({ type: 'LOGIN_SUCCESS', token })
|
||||
store.dispatch({ type: success, token })
|
||||
} catch (err) {
|
||||
store.dispatch({ type: 'LOGIN_FAILURE' })
|
||||
store.dispatch({ type: failure })
|
||||
handleError(err, store.getState().actionHistory)
|
||||
}
|
||||
} else if (type === 'LOGOUT_ATTEMPT') {
|
||||
} else if (type === loginTypes.attempt) {
|
||||
const { success, failure } = logoutTypes
|
||||
try {
|
||||
await logout()
|
||||
store.dispatch({ type: 'LOGOUT_SUCCESSFUL' })
|
||||
store.dispatch({ type: success })
|
||||
} catch (err) {
|
||||
store.dispatch({ type: 'LOGOUT_FAILURE' })
|
||||
store.dispatch({ type: failure })
|
||||
handleError(err, store.getState().actionHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redux-toolkit query based API
|
||||
// Api can be extended with enhanceEndpoints method
|
||||
// All tags used for invalidating cache must be defined here
|
||||
export const RTKApi = createApi({
|
||||
reducerPath: 'api',
|
||||
tagTypes: ['StudyGuidanceGroups'],
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: apiBasePath,
|
||||
prepareHeaders: (headers, { getState }) => {
|
||||
// Get token from state and add it to headers
|
||||
const token = getState().auth.encodedToken
|
||||
if (token) headers.set('x-access-token', token)
|
||||
// Add possible default headers
|
||||
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
|
||||
return headers
|
||||
},
|
||||
}),
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
|
||||
@@ -50,41 +50,108 @@ export default {
|
||||
**Include attainments attained before the studyright start** laskee mukaan opiskelijan ennen opinto-oikeuden alkua saadut opintopisteet.
|
||||
Vakiona opintopisteet ennen opinto-oikeutta suodatetaan pois.
|
||||
`,
|
||||
status: `
|
||||
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien, ohjelmien ja kurssien tuottamat opintopisteet nykyisen lukuvuoden aikana
|
||||
verrattuna edelliseen lukuvuoteen.
|
||||
|
||||
Nuoli kuvaa muutosta, paljonko opintopisteitä on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
|
||||
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna. Vain suoritetut ja hyväksiluetut opintopisteet huomioidaan.
|
||||
|
||||
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä.
|
||||
|
||||
Valinta "Näytä edelliset vuodet" näyttää vuosittaisen opintopiste-kertymän tähän päivään
|
||||
mennessä vuonna X sekä koko lukuvuoden X opintopisteet muodossa "kerääntymä vuonna X / koko lukuvuoden X opintopisteet".
|
||||
|
||||
Valinta "Näytä kalenterivuosittain" laskee kertymän kalenterivuoden alusta,
|
||||
eikä lukuvuoden alusta.
|
||||
status: {
|
||||
general: `
|
||||
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien, ohjelmien ja kurssien tuottamat opintopisteet nykyisen lukuvuoden aikana
|
||||
verrattuna edelliseen lukuvuoteen.
|
||||
|
||||
Nuoli kuvaa muutosta, paljonko opintopisteitä on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
|
||||
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna. Vain suoritetut ja hyväksiluetut opintopisteet huomioidaan.
|
||||
|
||||
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä. Mahdolliset valinnat ovat selitettyinä alla.
|
||||
`,
|
||||
settings: {
|
||||
showByYear: {
|
||||
label: 'Näytä kalenterivuosittain',
|
||||
short: 'Näytä tilastot kalenterivuosittain lukuvuosien sijasta.',
|
||||
long: `
|
||||
Kun tämä valinta on käytössä, vuosittaiset ajanjaksot lasketaan kalenterivuoden alusta sen loppuun.
|
||||
Muulloin vuosittaiset ajanjaksot lasketaan lukukauden alusta seuraavan lukukauden alkuun.
|
||||
`,
|
||||
},
|
||||
showYearlyValues: {
|
||||
label: 'Näytä edelliset vuodet',
|
||||
short: 'Näytä tilastot vuosittain, alkaen vuodesta 2017.',
|
||||
long: `
|
||||
Näyttää tilastot vuodesta 2017 eteenpäin.
|
||||
Jokaiselta vuodelta näytetään kaksi tilastoa: ajanjakson kokonaistilasto ja ns. tähän mennessä "*kertynyt*" tilasto.
|
||||
Kokonaistilasto vastaa nimensä mukaisesti koko ajanjaksoa, kun taas *kertynyt* tilasto kattaa ajanjakson vuoden
|
||||
tai lukuvuoden alusta "Näytä päivänä"-valintaa vastaavaan päivämäärään tuona vuotena. Luvut näytetään muodossa *<kerynyt>*/*<kokonais>*.
|
||||
Kuluvalta vuodelta näytetään ainoastaan kertynyt tilasto.
|
||||
`,
|
||||
},
|
||||
showRelativeValues: {
|
||||
label: 'Näytä suhteutettuna opiskelijoiden määrään',
|
||||
short:
|
||||
'Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.',
|
||||
long: `
|
||||
Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.
|
||||
Opiskelijoiden määrä perustuu ajanjaksolla kyseisen organisaation alaisista kursseista suoritusmerkintöjä saaneiden opiskelijoiden määrään.
|
||||
Luku siis sisältää muutkin kuin kyseiseen ohjelman tai osaston opinto-oikeuden omaavat opiskelijat.
|
||||
`,
|
||||
},
|
||||
showCountingFrom: {
|
||||
label: 'Valitse päivämäärä',
|
||||
short: 'Valitse päivä johon asti kertyneet tilastot näytetään.',
|
||||
long: `
|
||||
Tämä valinta määrittää päivämäärän, jota käyttäen kertyneet tilastot lasketaan.
|
||||
Esimerkiksi "Näytä kalenterivuosittain" valinnan ollessa pois päältä,
|
||||
lasketaan kertyneet tilastot (vrt. lukuvuosien kokonaistilastot) kunkin lukuvuoden alusta
|
||||
tätä päivämäärää vastaavaan päivään kyseisenä lukuvuonna.
|
||||
`,
|
||||
},
|
||||
showStudentCounts: {
|
||||
label: 'Näytä kurssien opiskelijamäärät',
|
||||
short: 'Näyttää suoritettujen opintopisteiden sijasta opiskelijoiden määrät kurssitason näkymässä.',
|
||||
long: `
|
||||
Oletuksena kurssitason näkymässä näytetään organisaatio- ja ohjelmatason näkymien tapaan suoritettujen opintopisteiden
|
||||
kokonaismäärä. Kun tämä valinta on käytössä, tämän sijasta näytettävät luvut vastaavat kurssin suorittaneiden
|
||||
*yksilöityjen* opiskelijoiden määrää. Kukin opiskelija lasketaan siis vain kerran tähän tilastoon.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
statusGraduated: {
|
||||
general: `
|
||||
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien ja ohjelmien valmistumiset nykyisen lukuvuoden aikana
|
||||
verrattuna edelliseen lukuvuoteen.
|
||||
|
||||
Nuoli kuvaa muutosta, paljonko valmistuneita on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
|
||||
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna.
|
||||
|
||||
Tilannekuvassa voi porautua kortteja klikkaamalla ohjelmatasolle.
|
||||
Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä.
|
||||
`,
|
||||
settings: {
|
||||
showByYear: {
|
||||
label: 'Näytä kalenterivuosittain',
|
||||
short: 'Näytä tilastot kalenterivuosittain lukuvuosien sijasta.',
|
||||
long: `
|
||||
Kun tämä valinta on käytössä, vuosittaiset ajanjaksot lasketaan kalenterivuoden alusta sen loppuun.
|
||||
Muulloin vuosittaiset ajanjaksot lasketaan lukukauden alusta seuraavan lukukauden alkuun.
|
||||
`,
|
||||
},
|
||||
|
||||
Valinta "Näytä suhteutettuna opiskelijoiden määrään" näyttää opintopistemäärät suhteutettuna kyseisen ajanjakson aikana kyseisen organisaation alaisuuteen kuuluvia kursseja suorittaineiden opiskelijoiden määrään. Tämä luku sisältää myös avoimen yliopiston opiskelijat.
|
||||
|
||||
Kohdasta "Näytä päivänä" voi valita tarkastelupäivän, joka on oletusarvoisesti tämänhetkinen päivä.
|
||||
|
||||
Avoimen yliopiston suorituksia ei vielä lasketa mukaan.
|
||||
`,
|
||||
statusGraduated: `
|
||||
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien ja ohjelmien valmistumiset nykyisen lukuvuoden aikana
|
||||
verrattuna edelliseen lukuvuoteen.
|
||||
|
||||
Nuoli kuvaa muutosta, paljonko valmistuneita on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
|
||||
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna.
|
||||
|
||||
Tilannekuvassa voi porautua kortteja klikkaamalla ohjelmatasolle.
|
||||
Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä.
|
||||
|
||||
Valinta "Näytä edelliset vuodet" näyttää vuosittaisen valmistumiskertymän tähän päivään mennessä vuonna X sekä koko lukuvuoden X valmistuneet muodossa "kerääntymä vuonna X / koko lukuvuoden X valmistuneet".
|
||||
|
||||
Valinta "Näytä vuositasolla" laskee kertymän kalenterivuoden alusta, eikä lukuvuoden alusta.
|
||||
|
||||
Kohdasta "Näytä päivänä" voi valita tarkastelupäivän, joka on oletusarvoisesti tämänhetkinen päivä.
|
||||
`,
|
||||
showYearlyValues: {
|
||||
label: 'Näytä edelliset vuodet',
|
||||
short: 'Näytä tilastot vuosittain, alkaen vuodesta 2017.',
|
||||
long: `
|
||||
Näyttää vuosittaisen valmistumiskertymän tähän päivään mennessä vuonna X
|
||||
sekä koko lukuvuoden X valmistuneet muodossa *"kerääntymä vuonna X / koko
|
||||
lukuvuoden X valmistuneet"*.
|
||||
`,
|
||||
},
|
||||
|
||||
showCountingFrom: {
|
||||
label: 'Näytä päivänä',
|
||||
short: 'Valitse päivä johon asti kertyneet tilastot näytetään.',
|
||||
long: `
|
||||
Tämä valinta määrittää päivämäärän, jota käyttäen kertyneet tilastot lasketaan.
|
||||
Esimerkiksi "Näytä kalenterivuosittain" valinnan ollessa pois päältä,
|
||||
lasketaan kertyneet tilastot (vrt. lukuvuosien kokonaistilastot) kunkin lukuvuoden alusta
|
||||
tätä päivämäärää vastaavaan päivään kyseisenä lukuvuonna.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
enrollmentStatus: {
|
||||
label: 'Filter students present or absent',
|
||||
short:
|
||||
'Suodata opiskelijoita sen mukaan ovatko he ilmoittautuneet läsnä- tai poissaoleviksi kyseiselle lukukaudelle',
|
||||
},
|
||||
transferred: {
|
||||
label: 'Include and exclude students from this program',
|
||||
short: 'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet tutkinto-ohjelmaan',
|
||||
},
|
||||
courseCredits: {
|
||||
label: null,
|
||||
short: 'Rajaa kurssisuorituksia tietyn ajanjakson mukaan. Tämä rajaus ei muuta opiskelijoiden määrää',
|
||||
},
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import PopulationOverview from './populationOverview'
|
||||
import CoolDataScience from './coolDataScience'
|
||||
import CourseStatistics from './courseStatistics'
|
||||
import CoursePopulation from './coursePopulation'
|
||||
import Studyprogramme from './studyprogramme'
|
||||
|
||||
export default {
|
||||
PopulationStatistics,
|
||||
@@ -10,4 +11,5 @@ export default {
|
||||
CoolDataScience,
|
||||
CourseStatistics,
|
||||
CoursePopulation,
|
||||
Studyprogramme,
|
||||
}
|
||||
|
||||
@@ -19,16 +19,28 @@ export default {
|
||||
|
||||
X-akselin skaalaa voi säätää harmaista painikkeista ”1m”, ”3m”, ”6m”, ”YTD”, ”1y” ja ”ALL” tai kuvaajan alla olevan sinisen näkymän harmaita ”kahvoja” siirtämällä.
|
||||
Y-akselin skaalaa voi säätää harmaista painikkeista ”small” ”medium” ja "large”`,
|
||||
CreditStatistics: `**Opintopistekertymä**
|
||||
|
||||
Taulukon rivit skaalautuvat valitun tarkastelujakson mukaan.
|
||||
Jos tarkastelujaksoksi valitsee 12 kuukautta, on ylin kategoria 55 opintopistettä tai yli, eli opiskelija etenee tavoiteajassa.
|
||||
Taulukosta näkee silmäyksellä tavoiteajassa etenevät (ylin kategoria) ja esim. nollasuorittajat.
|
||||
|
||||
**Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.**
|
||||
CreditStatistics: `
|
||||
**Credits gained**
|
||||
|
||||
Taulukko kertoo opintopistekertymän valitulle opiskelijapopulaatiolle. Opintopisteluokat on suhteutettu tarkasteltavan populaation aloitusvuoteen.
|
||||
Ylin luokka kertoo siksi aina tavoiteajassa etenevien määrän ja suhteellisen osuuden.
|
||||
|
||||
Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.
|
||||
|
||||
Kategorioita voi myös itsessään käyttää suodattimina. Klikkaamalla rivin vasemmassa reunassa olevaa suodatinta jäävät näkyviin vain sen kategorian opiskelijat.
|
||||
Suodatin aukeaa samalla ikkunan vasempaan laitaan, josta sitä voi käyttää vapaavalintaisilla arvoilla.
|
||||
|
||||
**Credit statistics**
|
||||
|
||||
Taulukko kertoo kaikkien tarkasteluun valittujen opiskelijoiden opintopistemääristä tähän päivään mennessä. Statistiikka on suodatettu käytettyjen filttereiden mukaan.
|
||||
Esimerkiksi, jos mukaan on valittu vain naisopiskelijat, statistiikat koskevat vain heitä.
|
||||
|
||||
+ **Total** on kokonaisopintopistemäärä kyseiselle populaatiolle.
|
||||
+ **Mean** kuvaa opintopistemäärän opiskelijakohtaista keskiarvoa.
|
||||
+ **Keskihajonta** kuvaa vaihtelua opintopistemäärissä. Karkeasti, mitä suurempi keskihajonta, sitä enemmän opintopistemäärissä on vaihtelua opiskelijoiden kesken.
|
||||
+ **Min ja Max** kertovat pienimmän ja suurimman opintopistemäärän, jotka jollain opiskelijoilla kyseisessä populaatiossa on.
|
||||
|
||||
Mikäli valintatapatieto kyseiselle populaatiolle on saatavissa, näytetään myös samat statistiikat per valintatapa.
|
||||
`,
|
||||
CoursesOfPopulation: `**Courses of population**
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export default {
|
||||
StudentsOfTheStudyprogramme: `
|
||||
**Started**: Sisältää kyseisenä vuonna aloitetut opinto-oikeudet riippumatta siitä, minä vuonna kyseinen opiskeluoikeus on myönnetty. Opiskelija on myös voinut keskeyttää opintonsa tai siirtyä pois ohjelmasta.\n
|
||||
**Graduated**: Sisältää kyseisenä vuonna valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty. Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.
|
||||
**Cancelled**: Sisältää kyseisenä vuonna perutut opiskeluoikeudet riippumatta siitä, onko kyseinen opiskelija ikinä edes alkanut opiskelemaan ohjelmassa tai opiskelun aloitusvuodesta.
|
||||
|
||||
**Transferred away**: Sisältää kyseisenä vuonna pois ohjelmasta siirtyneet opinto-oikeudet. Sama opiskelija voi esiintyä tilastoissa useana vuonna. Kyseiset opiskelijat ovat saattaneet aloittaa ohjelmassa tai eivät koskaan ole aloittaneet ohjelmassa.
|
||||
|
||||
**Transferred to**: Sisältää kyseisenä vuonna ohjelmaan siirtyneet opinto-oikeudet.
|
||||
|
||||
HUOM! Ohjelmaan siirtyneiden määrä on erityisen suuri vuonna 2020, jolloin vanhoista koulutusohjelmista luovuttiin ja opiskelijat siirtyivät uusiin ohjelmiin.
|
||||
`,
|
||||
CreditsProducedByTheStudyprogramme: `
|
||||
Sisältää opintopisteet suoritusvuosittain (Sisussa näkyvä kurssin suorituspäivä)
|
||||
|
||||
**Major students' credits**: Sisältää ne opintopisteet, joiden suorittajalla on suoritushetkellä ollut ensisijainen opinto-oikeus kyseiseen koulutusohjelmaan.
|
||||
|
||||
**Non-major students' credits**: Sisältää koulutusohjelman tuottamat opintopisteet, joiden suorittajalla EI ole ollut suoritushetkellä ensisijaista opinto-oikeutta kyseiseen koulutusohjelmaan.
|
||||
|
||||
**Transferred credits**: Sisältää opintopisteet, jotka on suoritettu kyseisenä vuonna, ja hyväksiluettu tähän koulutusohjelmaan.
|
||||
`,
|
||||
GraduatedAndThesisWritersOfTheProgramme: `
|
||||
**Wrote thesis**: Sisältää kyseisenä vuonna kandidaatintutkielman hyväksytysti suorittaneet opiskelijat. Mukana ei ole mahdollisia arvosanan korotuksia.
|
||||
|
||||
**Graduated**: Sisältää kyseisenä vuonna valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty. Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.
|
||||
`,
|
||||
GraduationMedianTime: `Näyttää kyseisenä vuonna valmistuneiden opintojen keston **mediaanin** kuukausina.
|
||||
|
||||
Mukana ovat vain valmistuneet opiskelijat. Kandiohjelmissa tavoiteaika on 36 kk, maisteriohjelmissa 24 kk`,
|
||||
GraduationMeanTime: `Näyttää kyseisenä vuonna valmistuneiden opintojen keston **keskiarvon** kuukausina.
|
||||
|
||||
Mukana ovat vain valmistuneet opiskelijat. Kandiohjelmissa tavoiteaika on 36 kk, maisteriohjelmissa 24 kk`,
|
||||
}
|
||||
@@ -224,6 +224,7 @@ export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
|
||||
|
||||
export const isNewHYStudyProgramme = code => !!(code && code.match(/^[A-Z]*[0-9]*_[0-9]*$/))
|
||||
|
||||
// These are the new Bachelor's programmes in Matlu, that have BH possibility
|
||||
export const bachelorHonoursProgrammes = [
|
||||
'KH50_001',
|
||||
'KH50_002',
|
||||
@@ -235,6 +236,8 @@ export const bachelorHonoursProgrammes = [
|
||||
'KH50_008',
|
||||
]
|
||||
|
||||
// Basic modules from Sisu programme structures. All modules that can be found for the past years' structures
|
||||
// taken into account
|
||||
export const bachelorHonoursBasicModules = {
|
||||
KH50_001: ['MAT110', 'MAT120', 'MAT130', 'MAT120', 'TKT1'],
|
||||
KH50_002: ['FYS1100', 'FYS1200'],
|
||||
@@ -246,6 +249,8 @@ export const bachelorHonoursBasicModules = {
|
||||
KH50_008: ['BSCH1000', 'BSCS1000', 'BSMA1000', 'BSMS1000', 'BSPH1000'],
|
||||
}
|
||||
|
||||
// Similarly intermediate modules from Sisu programme structures. All modules that can be found for the past years' structures
|
||||
// taken into account
|
||||
export const bachelorHonoursIntermediateModules = {
|
||||
KH50_001: ['mat217', 'MAT210', 'MAT220', 'MAT240', 'MAT213', 'MAT230', 'MAT218', 'MAT011'],
|
||||
KH50_002: ['FYS2100', 'FYS2200', 'FYS2300', 'FYS2400', 'FYS2500', 'FYS2600'],
|
||||
@@ -256,3 +261,9 @@ export const bachelorHonoursIntermediateModules = {
|
||||
KH50_007: ['MAA-200'],
|
||||
KH50_008: ['BSCH2000', 'BSCS2000', 'BSMA2000', 'BSMS2000', 'BSPH2000'],
|
||||
}
|
||||
|
||||
export const languageAbbreviations = {
|
||||
fi: 'finnish',
|
||||
sv: 'swedish',
|
||||
en: 'english',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import { Breadcrumb, Icon } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import { getTextIn } from '../../common'
|
||||
import TSA from '../../common/tsa'
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Trends'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const getP = (a, b) => {
|
||||
if (a === 0 || b === 0) return 1
|
||||
return a / b
|
||||
}
|
||||
|
||||
const DrillStack = ({ data, renderCard, historyKey }) => {
|
||||
const history = useHistory()
|
||||
const location = useLocation()
|
||||
|
||||
const rootLabel = getTextIn({
|
||||
fi: 'Helsingin Yliopisto',
|
||||
en: 'University of Helsinki',
|
||||
se: 'Helsingfors Universitet',
|
||||
})
|
||||
|
||||
const sortBy = ({ currentValue, previousValue }) => 1 - currentValue / previousValue
|
||||
|
||||
const path = _.get(location.state, historyKey) ?? []
|
||||
|
||||
const breadcrumbSections = useMemo(() => {
|
||||
const stack = _.chain(path)
|
||||
.reduce(
|
||||
([stack, data], segment) => {
|
||||
let content = segment
|
||||
let next = null
|
||||
|
||||
if (data) {
|
||||
const entry = data.find(({ key }) => key === segment)
|
||||
|
||||
if (entry) {
|
||||
content = entry.label
|
||||
next = entry.children
|
||||
}
|
||||
}
|
||||
|
||||
const item = {
|
||||
key: segment,
|
||||
content,
|
||||
link: !!next,
|
||||
active: !next,
|
||||
onClick: () => {
|
||||
history.push(location.pathname, {
|
||||
[historyKey]: [...stack.map(({ key }) => key).splice(1), segment],
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return [[...stack, item], next]
|
||||
},
|
||||
[[], data]
|
||||
)
|
||||
.value()[0]
|
||||
|
||||
const root = {
|
||||
key: '',
|
||||
content: rootLabel,
|
||||
link: true,
|
||||
onClick: () =>
|
||||
history.push(location.pathname, {
|
||||
[historyKey]: [],
|
||||
}),
|
||||
}
|
||||
|
||||
return [root, ...stack]
|
||||
}, [data, path, history, location])
|
||||
|
||||
const drilledData = useMemo(
|
||||
() => _.reduce(path, (data, segment) => (!data ? null : data.find(item => item.key === segment)?.children), data),
|
||||
[data, path]
|
||||
)
|
||||
|
||||
const pushToDrillStack = segment => {
|
||||
history.push(location.pathname, {
|
||||
[historyKey]: [...path, segment],
|
||||
})
|
||||
}
|
||||
|
||||
const popFromDrillStack = () => {
|
||||
const newPath = [...path]
|
||||
newPath.pop()
|
||||
history.push(location.pathname, {
|
||||
[historyKey]: newPath,
|
||||
})
|
||||
|
||||
sendAnalytics('S Drillup clicked', 'Status')
|
||||
}
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const orderedAbsDiffs = _.chain(drilledData)
|
||||
.map(({ currentValue, previousValue }) =>
|
||||
Math.abs(Math.floor((getP(currentValue, previousValue) - 1) * 1000) / 10)
|
||||
)
|
||||
.orderBy()
|
||||
.value()
|
||||
|
||||
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
|
||||
|
||||
return { medianDiff }
|
||||
}, [drilledData])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px', alignItems: 'center' }}>
|
||||
<Breadcrumb icon="right angle" sections={breadcrumbSections} size="large" />
|
||||
{path.length > 0 && (
|
||||
<Icon
|
||||
onClick={popFromDrillStack}
|
||||
style={{ cursor: 'pointer', color: 'rgba(0,0,0,.28)', marginLeft: '0.4em', marginTop: '-0.17em' }}
|
||||
name="arrow left"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, 240px)',
|
||||
gridTemplateRows: 'repeat(auto-fill) 20px',
|
||||
gridGap: '20px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{_.sortBy(drilledData, sortBy).map(item => {
|
||||
const handleClick = () => pushToDrillStack(item.key)
|
||||
|
||||
return renderCard(item, handleClick, stats)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DrillStack
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Popup, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Loader } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Datetime from 'react-datetime'
|
||||
import DrillStack from './DrillStack'
|
||||
|
||||
import StatusCard from './StatusCard'
|
||||
import Toolbar from './Toolbar'
|
||||
import TSA from '../../common/tsa'
|
||||
import { getTextIn } from '../../common'
|
||||
import { useLocalStorage } from '../../common/hooks'
|
||||
@@ -16,25 +17,58 @@ import './status.css'
|
||||
const ANALYTICS_CATEGORY = 'Trends'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const getP = (a, b) => {
|
||||
if (a === 0 || b === 0) return 1
|
||||
return a / b
|
||||
}
|
||||
const settingDefinitions = _.map(
|
||||
[
|
||||
{
|
||||
key: 'showByYear',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'showYearlyValues',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'showRelativeValues',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'showStudentCounts',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'showCountingFrom',
|
||||
type: 'date',
|
||||
defaultValue: () => moment(),
|
||||
persist: false,
|
||||
},
|
||||
],
|
||||
setting => ({
|
||||
...setting,
|
||||
...InfoToolTips.CoolDataScience.status.settings[setting.key],
|
||||
})
|
||||
)
|
||||
|
||||
const mapValueToRange = (x, min1, max1, min2, max2) => {
|
||||
if (x < min1) return min2
|
||||
if (x > max1) return max2
|
||||
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
|
||||
}
|
||||
|
||||
const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, showRelativeValues, showByYear }) => {
|
||||
const StatusContainer = ({
|
||||
stats,
|
||||
handleClick,
|
||||
min1,
|
||||
max1,
|
||||
showYearlyValues,
|
||||
showRelativeValues,
|
||||
showByYear,
|
||||
showStudentCounts,
|
||||
clickable,
|
||||
}) => {
|
||||
const title = getTextIn(stats.name)
|
||||
const clickable = !!stats.drill
|
||||
|
||||
let current
|
||||
let previous
|
||||
|
||||
if (!stats.drill) {
|
||||
if (stats.type === 'course') {
|
||||
current = stats.currentStudents
|
||||
previous = stats.previousStudents
|
||||
} else {
|
||||
@@ -47,345 +81,155 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
}
|
||||
}
|
||||
|
||||
const diff = current - previous
|
||||
const p = getP(current, previous)
|
||||
const change = p - 1
|
||||
let yearlyValues = null
|
||||
|
||||
const getColor = v => {
|
||||
if (v > 0.025) return '#6ab04c'
|
||||
if (v < -0.025) return '#ff7979'
|
||||
return '#7B9FCF'
|
||||
}
|
||||
|
||||
const plussify = (x, decimals = 0) =>
|
||||
x.toLocaleString('fi', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
signDisplay: 'always',
|
||||
})
|
||||
|
||||
const getDisplayValue = (value, denominator) => {
|
||||
let displayValue
|
||||
|
||||
if (denominator === 0) {
|
||||
displayValue = value === 0 ? 0 : Infinity
|
||||
} else if (showRelativeValues) {
|
||||
displayValue = value / denominator
|
||||
} else {
|
||||
displayValue = value
|
||||
}
|
||||
|
||||
return displayValue.toLocaleString('fi', {
|
||||
minimumFractionDigits: showRelativeValues ? 2 : 0,
|
||||
maximumFractionDigits: showRelativeValues ? 2 : 0,
|
||||
})
|
||||
if (showYearlyValues) {
|
||||
yearlyValues = _.orderBy(Object.entries(stats.yearly), ([y]) => y, ['desc']).map(([year, yearStats]) => ({
|
||||
label: showByYear ? year : `${year}-${`${Number(year) + 1}`.slice(-2)}`,
|
||||
accumulated: showStudentCounts && stats.type === 'course' ? yearStats.accStudents : yearStats.acc,
|
||||
total: showStudentCounts && stats.type === 'course' ? yearStats.totalStudents : yearStats.total,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment
|
||||
className="status-card"
|
||||
onClick={clickable ? handleClick : null}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: '240px',
|
||||
minWidth: '240px',
|
||||
cursor: clickable ? 'pointer' : 'default',
|
||||
flex: 1,
|
||||
margin: 0,
|
||||
}}
|
||||
textAlign="center"
|
||||
compact
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
<Popup position="bottom center" size="tiny" trigger={<span className="status-title">{title}</span>}>
|
||||
{stats.code}
|
||||
</Popup>
|
||||
<div style={{ margin: '10px 0' }}>
|
||||
<Icon
|
||||
style={{
|
||||
color: getColor(change),
|
||||
transform: `rotateZ(${-mapValueToRange(change * 100, min1, max1, -45, 45)}deg)`,
|
||||
}}
|
||||
size="huge"
|
||||
name="arrow right"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(change) }}>
|
||||
{plussify(diff, showRelativeValues ? 2 : 0)}
|
||||
</span>
|
||||
<br />
|
||||
<span style={{ fontWeight: 'bold', color: getColor(change) }}>({plussify(change * 100, 1)}%)</span>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
{showYearlyValues && (
|
||||
<div className={`years item-type-${stats.type}`}>
|
||||
{_.orderBy(Object.entries(stats.yearly), ([y]) => y, ['desc']).map(([year, yearStats]) => {
|
||||
return (
|
||||
<span style={{ display: 'contents' }} className="year-row">
|
||||
<b className="year-label">
|
||||
{year}
|
||||
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
|
||||
</b>
|
||||
{stats.type === 'course' ? (
|
||||
<>
|
||||
<span className="year-value">{yearStats.accStudents}</span>
|
||||
<span className="unit">students</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="year-value accumulated">
|
||||
{getDisplayValue(yearStats.acc, yearStats.accStudents)}
|
||||
</span>
|
||||
<span style={{ color: '#AAA', margin: '0 0.3em' }}>{yearStats.total !== undefined && '/'}</span>
|
||||
<span className="year-value total">
|
||||
{yearStats.total !== undefined && getDisplayValue(yearStats.total, yearStats.totalStudents)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Segment>
|
||||
<StatusCard
|
||||
title={title}
|
||||
clickable={clickable}
|
||||
onClick={handleClick}
|
||||
changeRange={[min1, max1]}
|
||||
currentValue={current}
|
||||
previousValue={previous}
|
||||
precision={0}
|
||||
tooltip={stats.code}
|
||||
unit={stats.type === 'course' && showStudentCounts ? 'students' : null}
|
||||
yearlyValues={yearlyValues}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
|
||||
const StatusContent = ({ data, settings }) => {
|
||||
const { showByYear, showRelativeValues, showYearlyValues, showStudentCounts } = settings
|
||||
|
||||
const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', true)
|
||||
const [showByYear, setShowByYear] = useLocalStorage('showByYear', false)
|
||||
const [showRelativeValues, setShowRelativeValues] = useLocalStorage('showRelativeValues', false)
|
||||
const [drillStack, setDrillStack] = useState([])
|
||||
const [showSettings, setShowSettings] = useState(true)
|
||||
const [selectedDate, setSelectedDate] = useState(moment())
|
||||
const [codes, setCodes] = useState([])
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
|
||||
const isValidDate = d => moment.isMoment(d) && moment().diff(d) > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && isValidDate(selectedDate)) {
|
||||
getStatusDispatch({ date: selectedDate.valueOf(), showByYear })
|
||||
}
|
||||
}, [selectedDate, showByYear])
|
||||
|
||||
useEffect(() => {
|
||||
if (codes.length > 0) {
|
||||
const updatedDrillStack = codes.reduce((acc, code) => {
|
||||
const drilled = data[code]
|
||||
// check if the code is on first level of drilldown
|
||||
if (drilled) acc.push(drilled.drill)
|
||||
// if not on first level then use the previous object in array
|
||||
// this might be source of bugs but its the best I could come up with
|
||||
else if (acc.length > 0 && acc[0][code]) acc.push(acc[0][code].drill)
|
||||
return acc
|
||||
}, [])
|
||||
setDrillStack(updatedDrillStack)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const handleShowYearlyValuesToggled = () => {
|
||||
const yearlyValues = showYearlyValues
|
||||
setShowYearlyValues(!showYearlyValues)
|
||||
sendAnalytics(`S Show yearly values toggle ${!yearlyValues ? 'on' : 'off'}`, 'Status')
|
||||
}
|
||||
|
||||
const handleShowByYearToggled = () => {
|
||||
const byYear = showByYear
|
||||
setShowByYear(!showByYear)
|
||||
sendAnalytics(`S Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status')
|
||||
}
|
||||
|
||||
const handleShowRelativeValuesChanged = () => {
|
||||
sendAnalytics(`S Show relative values toggle ${!showRelativeValues ? 'on' : 'off'}`)
|
||||
setShowRelativeValues(!showRelativeValues)
|
||||
}
|
||||
|
||||
const pushToDrillStack = (values, code) => {
|
||||
const updatedCodes = [...codes].concat(code)
|
||||
const updatedDrillStack = [...drillStack].concat(values)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('S Drilldown clicked', 'Status')
|
||||
}
|
||||
|
||||
const popFromDrillStack = () => {
|
||||
const updatedDrillStack = _.dropRight([...drillStack], 1)
|
||||
const updatedCodes = _.dropRight([...codes], 1)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('S Drillup clicked', 'Status')
|
||||
}
|
||||
|
||||
const renderSettings = () => {
|
||||
return (
|
||||
<Accordion style={{ padding: 0, paddingTop: '10px', flex: 1 }}>
|
||||
<Accordion.Title style={{ padding: 0, cursor: 'default' }} active={showSettings}>
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => setShowSettings(!showSettings)}>
|
||||
<Icon name="setting" />
|
||||
<span>Asetukset</span>
|
||||
</span>
|
||||
</Accordion.Title>
|
||||
<Accordion.Content style={{ padding: 0, marginTop: '10px' }} active={showSettings}>
|
||||
<Segment style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
background: 'red',
|
||||
transform: 'rotateY(0deg) rotate(45deg)',
|
||||
position: 'absolute',
|
||||
top: '-6px',
|
||||
left: '35px',
|
||||
border: '1px solid #dededf',
|
||||
borderRight: 'none',
|
||||
borderBottom: 'none',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
/>
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä edelliset vuodet"
|
||||
onChange={handleShowYearlyValuesToggled}
|
||||
checked={showYearlyValues}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä kalenterivuosittain"
|
||||
onChange={handleShowByYearToggled}
|
||||
checked={showByYear}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä suhteutettuna opiskelijoiden määrään"
|
||||
onChange={handleShowRelativeValuesChanged}
|
||||
checked={showRelativeValues}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Form>
|
||||
<Form.Field error={!isValidDate(selectedDate)} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.9em' }}>Näytä päivänä:</span>
|
||||
<Datetime
|
||||
className="status-date-time-input"
|
||||
dateFormat={DATE_FORMAT}
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={selectedDate}
|
||||
locale="fi"
|
||||
isValidDate={isValidDate}
|
||||
onChange={setSelectedDate}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Segment>
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
const DrilldownMessage = () => (
|
||||
<Message
|
||||
color="blue"
|
||||
content="Klikkaamalla tiedekuntaa pystyt porautumaan koulutusohjelma tasolle ja ohjelmaa klikkaamalla pystyt porautumaan kurssitasolle.
|
||||
Vasemmassa yläkulmassa olevaa nuolta klikkaamalla pääset edelliseen näkymään."
|
||||
return (
|
||||
<DrillStack
|
||||
historyKey="Status"
|
||||
data={data}
|
||||
rootLabel="Helsingin Yliopisto"
|
||||
renderCard={(data, drill, { medianDiff }) => (
|
||||
<StatusContainer
|
||||
key={data.code}
|
||||
clickable={!!data.children}
|
||||
stats={data}
|
||||
handleClick={drill}
|
||||
showRelativeValues={showRelativeValues}
|
||||
showYearlyValues={showYearlyValues}
|
||||
showStudentCounts={showStudentCounts}
|
||||
min1={-2 * medianDiff}
|
||||
max1={2 * medianDiff}
|
||||
showByYear={showByYear}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || loading)
|
||||
return (
|
||||
<Segment style={{ padding: '40px' }} textAlign="center">
|
||||
<Dimmer inverted active />
|
||||
<Loader active={loading} />
|
||||
</Segment>
|
||||
)
|
||||
const getDefaultSettings = () =>
|
||||
_.chain(settingDefinitions)
|
||||
.map(({ key, defaultValue }) => (typeof defaultValue === 'function' ? [key, defaultValue()] : [key, defaultValue]))
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
const orderedAbsDiffs = _.orderBy(
|
||||
Object.values(_.last(drillStack) || data).map(({ current, currentStudents, previous, previousStudents }) => {
|
||||
return Math.abs(
|
||||
Math.floor(
|
||||
(getP(
|
||||
showRelativeValues ? current / currentStudents : current,
|
||||
showRelativeValues ? previous / previousStudents : previous
|
||||
) -
|
||||
1) *
|
||||
1000
|
||||
) / 10
|
||||
)
|
||||
})
|
||||
const createDrillData = (storeData, showRelativeValues) => {
|
||||
if (!storeData) return null
|
||||
|
||||
return Object.values(storeData).map(item => ({
|
||||
key: item.code,
|
||||
label: getTextIn(item.name),
|
||||
children: createDrillData(item.drill),
|
||||
currentValue: showRelativeValues ? item.current / item.currentStudents : item.current,
|
||||
previousValue: showRelativeValues ? item.previous / item.previousStudents : item.previous,
|
||||
..._.omit(item, 'drill'),
|
||||
}))
|
||||
}
|
||||
|
||||
const isValidDate = d => moment().diff(moment(d)) > 0
|
||||
|
||||
const Status = () => {
|
||||
const [explicitSettings, setSettings] = useLocalStorage('trendsStatusSettings', {})
|
||||
const [nonpersistentExplicitSettings, setNonpersistentSettings] = useState({})
|
||||
|
||||
const storeData = useSelector(state => state.coolDataScience.data.status)
|
||||
const loading = useSelector(state => state.coolDataScience.pending.status)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const settings = useMemo(
|
||||
() => _.defaults({ ...explicitSettings, ...nonpersistentExplicitSettings }, getDefaultSettings()),
|
||||
[explicitSettings, nonpersistentExplicitSettings]
|
||||
)
|
||||
|
||||
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
|
||||
const data = useMemo(
|
||||
() => createDrillData(storeData, settings.showRelativeValues),
|
||||
[storeData, settings.showRelativeValues]
|
||||
)
|
||||
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
|
||||
useEffect(() => {
|
||||
const { showCountingFrom, showByYear } = settings
|
||||
|
||||
let date
|
||||
|
||||
if (!showCountingFrom) {
|
||||
date = moment().valueOf()
|
||||
} else if (isValidDate(showCountingFrom)) {
|
||||
date = moment(showCountingFrom).valueOf()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(
|
||||
getStatus({
|
||||
date,
|
||||
showByYear,
|
||||
})
|
||||
)
|
||||
}, [settings.showCountingFrom, settings.showByYear])
|
||||
|
||||
const changeSetting = (property, value) => {
|
||||
sendAnalytics(`S Set setting "${property}" to ${value}`, 'Status')
|
||||
|
||||
if (settingDefinitions.find(s => s.key === property).persist === false) {
|
||||
setNonpersistentSettings({
|
||||
...nonpersistentExplicitSettings,
|
||||
[property]: value,
|
||||
})
|
||||
} else {
|
||||
setSettings({
|
||||
...settings,
|
||||
[property]: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Koulutusohjelmien tuottamat opintopisteet</h2>
|
||||
<DrilldownMessage />
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
|
||||
{drillStack.length > 0 && (
|
||||
<Icon onClick={popFromDrillStack} style={{ fontSize: '40px', cursor: 'pointer' }} name="arrow left" />
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h2 style={{ margin: 0, flexGrow: 1 }}>Koulutusohjelmien tuottamat opintopisteet</h2>
|
||||
<Toolbar
|
||||
value={settings}
|
||||
settings={settingDefinitions}
|
||||
generalHelp={CoolDataScience.status.general}
|
||||
changeSetting={changeSetting}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, 240px)',
|
||||
gridTemplateRows: 'repeat(auto-fill) 20px',
|
||||
gridGap: '20px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{_.orderBy(
|
||||
Object.entries(_.last(drillStack) || data),
|
||||
([, { current, previous, currentStudents, previousStudents }]) =>
|
||||
getP(current || currentStudents, previous || previousStudents), // oh god<r
|
||||
['desc']
|
||||
).map(([code, stats]) => {
|
||||
const handleClick = () => pushToDrillStack(stats.drill, code)
|
||||
|
||||
return (
|
||||
<StatusContainer
|
||||
key={code}
|
||||
stats={stats}
|
||||
handleClick={handleClick}
|
||||
showRelativeValues={showRelativeValues}
|
||||
showYearlyValues={showYearlyValues}
|
||||
min1={-medianDiff * 2}
|
||||
max1={medianDiff * 2}
|
||||
showByYear={showByYear}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{renderSettings()}
|
||||
<Message>
|
||||
{
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
|
||||
}
|
||||
</Message>
|
||||
{data && !loading ? (
|
||||
<StatusContent settings={settings} data={data} />
|
||||
) : (
|
||||
<Loader active={loading} inline="centered" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ coolDataScience }) => ({
|
||||
data: coolDataScience.data.status,
|
||||
loading: coolDataScience.pending.status,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, { getStatusDispatch: getStatus })(Status)
|
||||
export default Status
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react'
|
||||
import { Popup, Segment, Icon } from 'semantic-ui-react'
|
||||
|
||||
const getP = (a, b) => {
|
||||
if (a === 0 || b === 0) return 1
|
||||
return a / b
|
||||
}
|
||||
|
||||
const mapValueToRange = (x, min1, max1, min2, max2) => {
|
||||
if (x < min1) return min2
|
||||
if (x > max1) return max2
|
||||
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
|
||||
}
|
||||
|
||||
export default ({
|
||||
clickable,
|
||||
currentValue,
|
||||
changeRange,
|
||||
onClick,
|
||||
previousValue,
|
||||
title,
|
||||
tooltip,
|
||||
type,
|
||||
unit,
|
||||
yearlyValues,
|
||||
precision,
|
||||
}) => {
|
||||
const diff = currentValue - previousValue
|
||||
const p = getP(currentValue, previousValue)
|
||||
const change = p - 1
|
||||
|
||||
const getColor = v => {
|
||||
if (v > 0.025) return '#6ab04c'
|
||||
if (v < -0.025) return '#ff7979'
|
||||
return '#7B9FCF'
|
||||
}
|
||||
|
||||
const plussify = (x, decimals = 0) =>
|
||||
x.toLocaleString('fi', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
signDisplay: 'always',
|
||||
})
|
||||
|
||||
const getDisplayValue = number =>
|
||||
number.toLocaleString('fi', {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
})
|
||||
|
||||
let titleEl = <span className="status-title">{title}</span>
|
||||
|
||||
if (tooltip) {
|
||||
titleEl = (
|
||||
<Popup position="bottom center" size="tiny" trigger={titleEl}>
|
||||
{tooltip}
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
|
||||
const arrowAngle = -mapValueToRange(change * 100, changeRange[0], changeRange[1], -45, 45)
|
||||
|
||||
return (
|
||||
<Segment
|
||||
className="status-card"
|
||||
onClick={clickable ? onClick : null}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'start',
|
||||
maxWidth: '240px',
|
||||
minWidth: '240px',
|
||||
cursor: clickable ? 'pointer' : 'default',
|
||||
flex: 1,
|
||||
margin: 0,
|
||||
}}
|
||||
textAlign="center"
|
||||
compact
|
||||
>
|
||||
<div style={{ paddingTop: '100%', position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{titleEl}
|
||||
<div style={{ margin: '10px 0' }}>
|
||||
<Icon
|
||||
style={{
|
||||
color: getColor(change),
|
||||
transform: `rotateZ(${arrowAngle}deg)`,
|
||||
transitionProperty: 'transform',
|
||||
transitionDuration: '200ms',
|
||||
}}
|
||||
size="huge"
|
||||
name="arrow right"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(change) }}>
|
||||
{plussify(diff, precision)}
|
||||
</span>
|
||||
<br />
|
||||
<span style={{ fontWeight: 'bold', color: getColor(change) }}>({plussify(change * 100, 1)}%)</span>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{yearlyValues && (
|
||||
<div className={`years item-type-${type}`}>
|
||||
{yearlyValues.map(({ label, accumulated, total }) => (
|
||||
<span className="year-row">
|
||||
<b className="year-label">{label}:</b>
|
||||
<span className="year-value">{getDisplayValue(accumulated)}</span>
|
||||
<span className="separator">{typeof total === 'number' ? '/' : ''}</span>
|
||||
<span className="year-value">{typeof total === 'number' && getDisplayValue(total)}</span>
|
||||
<span className="unit">{unit && unit}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,41 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import PropTypes, { shape, bool, func } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
|
||||
import { Loader } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Datetime from 'react-datetime'
|
||||
|
||||
import StatusCard from './StatusCard'
|
||||
import Toolbar from './Toolbar'
|
||||
import TSA from '../../common/tsa'
|
||||
import { getTextIn } from '../../common'
|
||||
import { useLocalStorage } from '../../common/hooks'
|
||||
import InfoToolTips from '../../common/InfoToolTips'
|
||||
import DrillStack from './DrillStack'
|
||||
import { getStatusGraduated } from '../../redux/coolDataScience'
|
||||
import './status.css'
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Trends'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const getP = (a, b) => {
|
||||
if (a === 0 || b === 0) return 1
|
||||
return a / b
|
||||
}
|
||||
const settingDefinitions = [
|
||||
{
|
||||
key: 'showByYear',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'showYearlyValues',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'showCountingFrom',
|
||||
type: 'date',
|
||||
defaultValue: () => moment(),
|
||||
persist: false,
|
||||
},
|
||||
].map(setting => ({ ...setting, ...InfoToolTips.CoolDataScience.statusGraduated.settings[setting.key] }))
|
||||
|
||||
const StatusContainer = ({
|
||||
title,
|
||||
@@ -34,113 +49,29 @@ const StatusContainer = ({
|
||||
yearlyValues,
|
||||
showByYear,
|
||||
}) => {
|
||||
const mapValueToRange = (x, min1, max1, min2, max2) => {
|
||||
if (x < min1) return min2
|
||||
if (x > max1) return max2
|
||||
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
|
||||
let displayedYearlyValues = null
|
||||
|
||||
if (showYearlyValues) {
|
||||
displayedYearlyValues = Object.entries(yearlyValues)
|
||||
.sort(([b], [a]) => a - b)
|
||||
.map(([year, { acc, total }], i) => ({
|
||||
label: showByYear ? year : `${year}-${`${Number(year) + 1}`.slice(-2)}`,
|
||||
accumulated: acc,
|
||||
total: i > 0 ? total : null,
|
||||
}))
|
||||
}
|
||||
|
||||
const getColor = v => {
|
||||
if (v > 2.5) return '#6ab04c'
|
||||
if (v < -2.5) return '#ff7979'
|
||||
return '#7B9FCF'
|
||||
}
|
||||
|
||||
const plussify = x => {
|
||||
if (x > 0) return `+${x.toLocaleString('fi')}`
|
||||
return x.toLocaleString('fi')
|
||||
}
|
||||
|
||||
// TODO: Maybe just fix this in backend and add default value 0 for everything?
|
||||
|
||||
const getDiffAndChange = (current, previous, noTitleDataToShow, hasChangedButCantShowPercentage) => {
|
||||
if (noTitleDataToShow) return [0, 0]
|
||||
const diff = Math.round(current - previous)
|
||||
let change
|
||||
if (hasChangedButCantShowPercentage) {
|
||||
change = diff > 0 ? 100 : -100
|
||||
} else {
|
||||
change = Math.round((getP(current, previous) - 1) * 1000) / 10
|
||||
}
|
||||
return [diff, change]
|
||||
}
|
||||
|
||||
const noTitleDataToShow = current === null
|
||||
const hasChangedButCantShowPercentage = (previous === 0 || current === 0) && previous !== current
|
||||
const [diff, change] = getDiffAndChange(current, previous, noTitleDataToShow, hasChangedButCantShowPercentage)
|
||||
|
||||
return (
|
||||
<Segment
|
||||
className="status-card"
|
||||
onClick={clickable ? handleClick : null}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: '240px',
|
||||
minWidth: '240px',
|
||||
cursor: clickable ? 'pointer' : 'default',
|
||||
flex: 1,
|
||||
margin: 0,
|
||||
}}
|
||||
textAlign="center"
|
||||
compact
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
<span className="status-title">{title}</span>
|
||||
<div style={{ margin: '10px 0' }}>
|
||||
<Icon
|
||||
style={{
|
||||
color: getColor(change),
|
||||
transform: `rotateZ(${-mapValueToRange(change, min1, max1, -45, 45)}deg)`,
|
||||
}}
|
||||
size="huge"
|
||||
name="arrow right"
|
||||
/>
|
||||
</div>
|
||||
{noTitleDataToShow ? (
|
||||
<div>
|
||||
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(0) }}>
|
||||
Ei vielä dataa tältä lukuvuodelta.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(change) }}>{plussify(diff)}</span>
|
||||
{hasChangedButCantShowPercentage ? null : (
|
||||
<div>
|
||||
<br />
|
||||
<span style={{ fontWeight: 'bold', color: getColor(change) }}>({plussify(change)}%)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showYearlyValues && (
|
||||
<div style={{ marginTop: '10px', textAlign: 'start' }}>
|
||||
{_.orderBy(Object.entries(yearlyValues), ([y]) => y, ['desc']).map(([year, { acc, total }]) => {
|
||||
return (
|
||||
<div style={{ margin: '5px 0' }} key={`${title}-${year}`}>
|
||||
<span>
|
||||
<b>
|
||||
{year}
|
||||
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
|
||||
</b>{' '}
|
||||
{acc} {total > 0 ? `/ ${total}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Segment>
|
||||
<StatusCard
|
||||
title={title}
|
||||
changeRange={[min1, max1]}
|
||||
currentValue={current}
|
||||
previousValue={previous}
|
||||
clickable={clickable}
|
||||
onClick={handleClick}
|
||||
precision={0}
|
||||
yearlyValues={displayedYearlyValues}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -162,200 +93,104 @@ StatusContainer.defaultProps = {
|
||||
previous: 0,
|
||||
}
|
||||
|
||||
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
|
||||
const createDrillData = data => {
|
||||
if (!data) return null
|
||||
|
||||
return Object.entries(data).map(([key, item]) => ({
|
||||
key,
|
||||
label: getTextIn(item.name),
|
||||
currentValue: item.current,
|
||||
previousValue: item.previous,
|
||||
children: createDrillData(item.drill),
|
||||
..._.omit(item, 'drill'),
|
||||
}))
|
||||
}
|
||||
|
||||
const getDefaultSettings = () =>
|
||||
_.chain(settingDefinitions)
|
||||
.map(({ key, defaultValue }) => (typeof defaultValue === 'function' ? [key, defaultValue()] : [key, defaultValue]))
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
const Status = ({ getStatusGraduatedDispatch, data, loading }) => {
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', true)
|
||||
const [showByYear, setShowByYear] = useLocalStorage('showByYear', true)
|
||||
const [drillStack, setDrillStack] = useState([])
|
||||
const [showSettings, setShowSettings] = useState(true)
|
||||
const [selectedDate, setSelectedDate] = useState(moment())
|
||||
const [codes, setCodes] = useState([])
|
||||
const [explicitSettings, setSettings] = useLocalStorage('trendsGraduationStatusSettings', {})
|
||||
const [nonpersistentExplicitSettings, setNonpersistentSettings] = useState({})
|
||||
|
||||
const settings = useMemo(
|
||||
() => _.defaults({ ...explicitSettings, ...nonpersistentExplicitSettings }, getDefaultSettings()),
|
||||
[explicitSettings, nonpersistentExplicitSettings]
|
||||
)
|
||||
|
||||
if (typeof settings.showCountingFrom === 'string') {
|
||||
settings.showCountingFrom = moment(settings.showCountingFrom)
|
||||
} else if (settings.showCountingFrom === null) {
|
||||
settings.showCountingFrom = moment()
|
||||
}
|
||||
|
||||
const { showYearlyValues, showByYear, showCountingFrom } = settings
|
||||
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
const isValidDate = d => moment.isMoment(d) && moment().diff(d) > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && isValidDate(selectedDate)) {
|
||||
getStatusGraduatedDispatch({ date: selectedDate.valueOf(), showByYear })
|
||||
if (showCountingFrom && isValidDate(showCountingFrom)) {
|
||||
getStatusGraduatedDispatch({ date: showCountingFrom.valueOf(), showByYear })
|
||||
}
|
||||
}, [selectedDate, showByYear])
|
||||
}, [showCountingFrom, showByYear])
|
||||
|
||||
useEffect(() => {
|
||||
if (codes.length > 0) {
|
||||
const updatedDrillStack = codes.reduce((acc, code) => {
|
||||
const drilled = data[code]
|
||||
// check if the code is on first level of drilldown
|
||||
if (drilled) acc.push(drilled.drill)
|
||||
// if not on first level then use the previous object in array
|
||||
// this might be source of bugs but its the best I could come up with
|
||||
else if (acc.length > 0 && acc[0][code]) acc.push(acc[0][code].drill)
|
||||
return acc
|
||||
}, [])
|
||||
setDrillStack(updatedDrillStack)
|
||||
const drillData = useMemo(() => createDrillData(data), [data])
|
||||
|
||||
const changeSetting = (property, value) => {
|
||||
sendAnalytics(`S Set setting "${property}" to ${value}`, 'Status')
|
||||
|
||||
if (settingDefinitions.find(s => s.key === property).persist === false) {
|
||||
setNonpersistentSettings({
|
||||
...nonpersistentExplicitSettings,
|
||||
[property]: value,
|
||||
})
|
||||
} else {
|
||||
setSettings({
|
||||
...settings,
|
||||
[property]: value,
|
||||
})
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const handleShowYearlyValuesToggled = () => {
|
||||
const yearlyValues = showYearlyValues
|
||||
setShowYearlyValues(!showYearlyValues)
|
||||
sendAnalytics(`SG Show yearly values toggle ${!yearlyValues ? 'on' : 'off'}`, 'Status graduated')
|
||||
}
|
||||
|
||||
const handleShowByYearToggled = () => {
|
||||
const byYear = showByYear
|
||||
setShowByYear(!showByYear)
|
||||
sendAnalytics(`SG Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status graduated')
|
||||
}
|
||||
|
||||
const pushToDrillStack = (values, code) => {
|
||||
const updatedCodes = [...codes].concat(code)
|
||||
const updatedDrillStack = [...drillStack].concat(values)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('SG Drilldown clicked', 'Status graduated')
|
||||
}
|
||||
|
||||
const popFromDrillStack = () => {
|
||||
const updatedDrillStack = _.dropRight([...drillStack], 1)
|
||||
const updatedCodes = _.dropRight([...codes], 1)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('SG Drillup clicked', 'Status graduated')
|
||||
}
|
||||
|
||||
const renderSettings = () => {
|
||||
return (
|
||||
<Accordion style={{ padding: 0, paddingTop: '10px', flex: 1 }}>
|
||||
<Accordion.Title style={{ padding: 0, cursor: 'default' }} active={showSettings}>
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => setShowSettings(!showSettings)}>
|
||||
<Icon name="setting" />
|
||||
<span>Asetukset</span>
|
||||
</span>
|
||||
</Accordion.Title>
|
||||
<Accordion.Content style={{ padding: 0, marginTop: '10px' }} active={showSettings}>
|
||||
<Segment style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
background: 'red',
|
||||
transform: 'rotateY(0deg) rotate(45deg)',
|
||||
position: 'absolute',
|
||||
top: '-6px',
|
||||
left: '35px',
|
||||
border: '1px solid #dededf',
|
||||
borderRight: 'none',
|
||||
borderBottom: 'none',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
/>
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä edelliset vuodet"
|
||||
onChange={handleShowYearlyValuesToggled}
|
||||
checked={showYearlyValues}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä kalenterivuosittain"
|
||||
onChange={handleShowByYearToggled}
|
||||
checked={showByYear}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Form>
|
||||
<Form.Field error={!isValidDate(selectedDate)} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.9em' }}>Näytä päivänä:</span>
|
||||
<Datetime
|
||||
className="status-date-time-input"
|
||||
dateFormat={DATE_FORMAT}
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={selectedDate}
|
||||
locale="fi"
|
||||
isValidDate={isValidDate}
|
||||
onChange={setSelectedDate}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Segment>
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
const DrilldownMessage = () => (
|
||||
<Message
|
||||
color="blue"
|
||||
content="Klikkaamalla tiedekuntaa pystyt porautumaan koulutusohjelma tasolle.
|
||||
Vasemmassa yläkulmassa olevaa nuolta klikkaamalla pääset edelliseen näkymään."
|
||||
/>
|
||||
)
|
||||
|
||||
if (!data || loading)
|
||||
return (
|
||||
<Segment style={{ padding: '40px' }} textAlign="center">
|
||||
<Dimmer inverted active />
|
||||
<Loader active={loading} />
|
||||
</Segment>
|
||||
)
|
||||
|
||||
const orderedAbsDiffs = _.orderBy(
|
||||
Object.values(_.last(drillStack) || data).map(({ current, previous }) => {
|
||||
return Math.abs(Math.floor((getP(current, previous) - 1) * 1000) / 10)
|
||||
})
|
||||
)
|
||||
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
|
||||
return (
|
||||
<>
|
||||
<h2>Koulutusohjelmista valmistuneet</h2>
|
||||
<DrilldownMessage />
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
|
||||
{drillStack.length > 0 && (
|
||||
<Icon onClick={popFromDrillStack} style={{ fontSize: '40px', cursor: 'pointer' }} name="arrow left" />
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h2 style={{ margin: 0, flexGrow: 1 }}>Koulutusohjelmista valmistuneet</h2>
|
||||
<Toolbar
|
||||
value={settings}
|
||||
settings={settingDefinitions}
|
||||
generalHelp={CoolDataScience.statusGraduated.general}
|
||||
changeSetting={changeSetting}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, 240px)',
|
||||
gridTemplateRows: 'repeat(auto-fill) 20px',
|
||||
gridGap: '20px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{_.orderBy(
|
||||
Object.entries(_.last(drillStack) || data),
|
||||
([, { current, previous }]) => getP(current, previous), // oh god<r
|
||||
['desc']
|
||||
).map(([code, stats]) => {
|
||||
const handleClick = () => pushToDrillStack(stats.drill, code)
|
||||
|
||||
return (
|
||||
{data && !loading ? (
|
||||
<DrillStack
|
||||
historyKey="StatusGraduated"
|
||||
data={drillData}
|
||||
renderCard={(data, drill, { medianDiff }) => (
|
||||
<StatusContainer
|
||||
key={code}
|
||||
clickable={!!stats.drill}
|
||||
handleClick={handleClick}
|
||||
title={getTextIn(stats.name)}
|
||||
current={stats.current}
|
||||
previous={stats.previous}
|
||||
key={data.code}
|
||||
clickable={!!data.children}
|
||||
handleClick={drill}
|
||||
title={getTextIn(data.name)}
|
||||
current={data.current}
|
||||
previous={data.previous}
|
||||
showYearlyValues={showYearlyValues}
|
||||
min1={-medianDiff * 2}
|
||||
max1={medianDiff * 2}
|
||||
yearlyValues={stats.yearly}
|
||||
yearlyValues={data.yearly}
|
||||
showByYear={showByYear}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{renderSettings()}
|
||||
<Message>
|
||||
{
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
<ReactMarkdown children={CoolDataScience.statusGraduated} escapeHtml={false} />
|
||||
}
|
||||
</Message>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Loader active inline="centered" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,22 @@ import Graduated from './StatusGraduated'
|
||||
|
||||
const StatusPanel = () => {
|
||||
const panes = [
|
||||
{ menuItem: 'Attainments', render: () => <Status /> },
|
||||
{ menuItem: 'Graduations', render: () => <Graduated /> },
|
||||
{
|
||||
menuItem: 'Attainments',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<Status />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'Graduations',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<Graduated />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
]
|
||||
return <Tab panes={panes} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { Form, Checkbox, Divider, Popup, Button, Icon } from 'semantic-ui-react'
|
||||
import moment from 'moment'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Datetime from 'react-datetime'
|
||||
import WithHelpTooltip from './WithHelpTooltip'
|
||||
|
||||
const unindent = s => s.replace(/(^|\n)[ \t]+/g, '\n')
|
||||
|
||||
const isValidDate = d => moment().diff(moment(d)) > 0
|
||||
|
||||
const DateSetting = ({ onChange, value, onOpenDetails, tooltip, placeholder }) => {
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
|
||||
return (
|
||||
<div style={{ marginRight: '0.3rem' }}>
|
||||
<Form>
|
||||
<Form.Field error={value !== null && !isValidDate(value)} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<WithHelpTooltip
|
||||
tooltip={tooltip}
|
||||
onOpenDetails={onOpenDetails}
|
||||
iconStyle={{
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: '0.5rem',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
iconPosition={{
|
||||
top: '-0.2em',
|
||||
}}
|
||||
>
|
||||
<Datetime
|
||||
className="status-date-time-input"
|
||||
dateFormat={DATE_FORMAT}
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={moment(value)}
|
||||
locale="fi"
|
||||
isValidDate={isValidDate}
|
||||
inputProps={{ placeholder }}
|
||||
onChange={value => {
|
||||
if (value === '') {
|
||||
onChange(null)
|
||||
} else if (isValidDate(value)) {
|
||||
onChange(value.format(DATE_FORMAT) === moment().format(DATE_FORMAT) ? null : value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</WithHelpTooltip>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const settingWidgetFactories = {
|
||||
checkbox: (value, onChange, onOpenDetails, { short, label }) => (
|
||||
<WithHelpTooltip tooltip={short} onOpenDetails={onOpenDetails} iconPosition={{ top: '0.1em' }}>
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label={label}
|
||||
checked={value}
|
||||
onChange={() => onChange(!value)}
|
||||
/>
|
||||
</WithHelpTooltip>
|
||||
),
|
||||
}
|
||||
|
||||
const StatusSettings = ({ onSettingChange, value, settings, onOpenDetails }) => {
|
||||
const itemStyles = {
|
||||
margin: '0.5rem 1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const settingWidgets = settings.map(definition => {
|
||||
const { key, type } = definition
|
||||
const factory = settingWidgetFactories[type]
|
||||
|
||||
if (!factory) return null
|
||||
|
||||
const content = factory(
|
||||
value[key],
|
||||
value => onSettingChange(key, value),
|
||||
() => onOpenDetails(key),
|
||||
definition
|
||||
)
|
||||
|
||||
return <div style={itemStyles}>{content}</div>
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', padding: 0, flexDirection: 'column' }}>{settingWidgets}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ({ value, changeSetting, settings, generalHelp }) => {
|
||||
const [usageDetailsOpen, setUsageDetailsOpen] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const moreDetailsRef = useRef(null)
|
||||
const attentionSeekers = useRef({})
|
||||
|
||||
const scrollToAttentionSeeker = el => {
|
||||
el.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
const attentionSeekerRef = key => ref => {
|
||||
if (ref !== null && attentionSeekers.current[key] === true) {
|
||||
scrollToAttentionSeeker(ref)
|
||||
}
|
||||
|
||||
attentionSeekers.current[key] = ref
|
||||
}
|
||||
|
||||
const openDetails = key => {
|
||||
if (moreDetailsRef.current) {
|
||||
moreDetailsRef.current.scrollIntoView({
|
||||
block: 'start',
|
||||
inline: 'end',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
if (attentionSeekers.current[key]) {
|
||||
scrollToAttentionSeeker(attentionSeekers.current[key])
|
||||
} else {
|
||||
attentionSeekers.current[key] = true
|
||||
}
|
||||
|
||||
setUsageDetailsOpen(true)
|
||||
}
|
||||
|
||||
const dateSetting = settings.find(s => s.type === 'date')
|
||||
|
||||
return (
|
||||
<>
|
||||
<DateSetting
|
||||
value={value[dateSetting.key]}
|
||||
onChange={value => changeSetting(dateSetting.key, value)}
|
||||
onOpenDetails={() => openDetails(dateSetting.key)}
|
||||
tooltip={dateSetting.short}
|
||||
placeholder={dateSetting.label}
|
||||
/>
|
||||
|
||||
<Popup
|
||||
trigger={
|
||||
<Button>
|
||||
<Icon name="settings" /> Asetukset
|
||||
</Button>
|
||||
}
|
||||
position="bottom right"
|
||||
on="click"
|
||||
wide="very"
|
||||
open={settingsOpen}
|
||||
onOpen={() => setSettingsOpen(true)}
|
||||
onClose={() => {
|
||||
// Close the popup only after the event has had a chance to propagate.
|
||||
setTimeout(() => setSettingsOpen(false), 0)
|
||||
}}
|
||||
style={{ padding: '0.25em 0em', maxHeight: '80vh' }}
|
||||
>
|
||||
<StatusSettings value={value} settings={settings} onSettingChange={changeSetting} onOpenDetails={openDetails} />
|
||||
</Popup>
|
||||
<div ref={moreDetailsRef} />
|
||||
<Popup
|
||||
trigger={
|
||||
<Button>
|
||||
<Icon name="question circle" /> Käyttöohje
|
||||
</Button>
|
||||
}
|
||||
position="bottom right"
|
||||
on="click"
|
||||
wide="very"
|
||||
open={usageDetailsOpen}
|
||||
onOpen={() => setUsageDetailsOpen(true)}
|
||||
onClose={() => setUsageDetailsOpen(false)}
|
||||
style={{ padding: '0', paddingBottom: '0.5em', maxHeight: '80vh', overflowY: 'auto' }}
|
||||
>
|
||||
<div style={{ padding: '1em' }}>
|
||||
{
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
<ReactMarkdown children={unindent(generalHelp)} escapeHtml={false} />
|
||||
}
|
||||
</div>
|
||||
{settings.map(({ key, label, long }) => (
|
||||
<div key={key}>
|
||||
<Divider />
|
||||
<div style={{ padding: '0 1em' }} ref={attentionSeekerRef(key)}>
|
||||
<b>
|
||||
Valinta "<i>{label}</i>"
|
||||
</b>
|
||||
<div style={{ margin: '0.5em', fontSize: '0.9em' }}>
|
||||
<ReactMarkdown escapeHtml={false}>{unindent(long ?? '')}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Popup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { Icon, Popup } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
|
||||
const WithHelpTooltip = ({ children, tooltip, onOpenDetails, iconStyle = {}, iconPosition = {}, ...rest }) => {
|
||||
const popupContext = useRef()
|
||||
const [popupOpen, setPopupOpen] = useState(false)
|
||||
|
||||
const trigger = (
|
||||
<div>
|
||||
{children}
|
||||
<div
|
||||
ref={popupContext}
|
||||
style={{ marginLeft: '0.3em', display: 'inline-block', paddingTop: '0.2em', cursor: 'help', ...iconStyle }}
|
||||
>
|
||||
<Icon
|
||||
onClick={() => setPopupOpen(!popupOpen)}
|
||||
ref={popupContext}
|
||||
style={{ position: 'relative', color: '#888', ...iconPosition }}
|
||||
name="question circle outline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const popupProps = _.defaults(rest, {
|
||||
position: 'right center',
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup
|
||||
hoverable
|
||||
size="tiny"
|
||||
open={popupOpen}
|
||||
onOpen={() => setPopupOpen(true)}
|
||||
onClose={() => setPopupOpen(false)}
|
||||
trigger={trigger}
|
||||
context={popupContext}
|
||||
{...popupProps}
|
||||
on="hover"
|
||||
mouseEnterDelay={1000}
|
||||
>
|
||||
<div>{tooltip}</div>
|
||||
<span style={{ color: '#2185d0', cursor: 'pointer' }} onClick={onOpenDetails}>
|
||||
Lue lisää...
|
||||
</span>
|
||||
</Popup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WithHelpTooltip
|
||||
@@ -26,10 +26,6 @@
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
|
||||
.years.item-type-course {
|
||||
grid-template-columns: repeat(2, fit-content(0)) auto;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
white-space: nowrap;
|
||||
padding-right: 0.5em;
|
||||
@@ -43,7 +39,16 @@
|
||||
.year-row {
|
||||
position: relative;
|
||||
padding-left: 0.5em;
|
||||
display: inline-block;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.year-row .unit {
|
||||
grid-column: 5;
|
||||
}
|
||||
|
||||
.year-row .separator {
|
||||
color: #aaa;
|
||||
margin: 0 0.3em;
|
||||
}
|
||||
|
||||
.status-date-time-input {
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+11
-14
@@ -1,26 +1,23 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import useGradeFilter from '../../FilterTray/filters/Grade/useGradeFilter'
|
||||
import ExternalFilterToggle from '../../FilterTray/ExternalFilterToggle'
|
||||
import { contextKey } from '../../FilterTray/filters/Grade'
|
||||
import FilterToggle from '../../FilterView/FilterToggle'
|
||||
import useFilters from '../../FilterView/useFilters'
|
||||
import gradeFilter from '../../FilterView/filters/grade'
|
||||
|
||||
const ExternalGradeFilterToggle = ({ grade }) => {
|
||||
const { value, setValue } = useGradeFilter()
|
||||
const { useFilterSelector, filterDispatch } = useFilters()
|
||||
|
||||
const isActive = useFilterSelector(gradeFilter.selectors.isGradeSelected(grade))
|
||||
|
||||
return (
|
||||
<ExternalFilterToggle
|
||||
filterPanelContextKey={contextKey}
|
||||
applyFilter={() => setValue(value.concat(grade))}
|
||||
clearFilter={() => setValue(value.filter(val => val !== grade))}
|
||||
active={value.includes(grade)}
|
||||
<FilterToggle
|
||||
filter={gradeFilter}
|
||||
active={isActive}
|
||||
applyFilter={() => filterDispatch(gradeFilter.actions.selectGrade(grade))}
|
||||
clearFilter={() => filterDispatch(gradeFilter.actions.unselectGrade(grade))}
|
||||
filterName="Grade Filter"
|
||||
popupContent="Rajaa opiskelijat kurssin arvosanan perusteella."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
ExternalGradeFilterToggle.propTypes = {
|
||||
grade: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
export default ExternalGradeFilterToggle
|
||||
|
||||
@@ -4,12 +4,10 @@ import { Progress, Table } from 'semantic-ui-react'
|
||||
import { intersection, orderBy } from 'lodash'
|
||||
import { shape, bool, arrayOf, string, number } from 'prop-types'
|
||||
import { getHighestGradeOfCourseBetweenRange } from '../../../common'
|
||||
import useGradeFilter from '../../FilterTray/filters/Grade/useGradeFilter'
|
||||
import ExternalGradeFilterToggle from './ExternalGradeFilterToggle'
|
||||
|
||||
const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStudents, samples, codes, from, to }) => {
|
||||
const [courseGrades, setCourseGrades] = useState([])
|
||||
const { setGrades } = useGradeFilter()
|
||||
|
||||
useEffect(() => {
|
||||
if (samples && singleCourseStats.alternatives) {
|
||||
@@ -34,7 +32,6 @@ const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStuden
|
||||
})
|
||||
|
||||
setCourseGrades(filteredGradeArray)
|
||||
setGrades(grades)
|
||||
}
|
||||
}, [pending, selectedStudents])
|
||||
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Table, Progress } from 'semantic-ui-react'
|
||||
import { languageAbbreviations } from '../../../common'
|
||||
|
||||
const CoursePopulationLanguageDist = ({ samples, codes, from, to }) => {
|
||||
const [languages, setLanguages] = useState()
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (samples) {
|
||||
const lang = {}
|
||||
let students = 0
|
||||
|
||||
samples.forEach(student => {
|
||||
const courses = student.courses.filter(c => codes.includes(c.course_code))
|
||||
const filteredCourses = courses
|
||||
.filter(
|
||||
course =>
|
||||
new Date(from).getTime() <= new Date(course.date).getTime() &&
|
||||
new Date(course.date).getTime() <= new Date(to).getTime()
|
||||
)
|
||||
// .filter(course => course.passed === true)
|
||||
.sort((a, b) => (new Date(a.date) <= new Date(b.date) ? -1 : 1))
|
||||
|
||||
if (filteredCourses[0] && filteredCourses[0].language !== null) {
|
||||
if (!lang[filteredCourses[0].language]) {
|
||||
lang[filteredCourses[0].language] = 0
|
||||
}
|
||||
lang[filteredCourses[0].language] += 1
|
||||
students++
|
||||
}
|
||||
})
|
||||
setLanguages(lang)
|
||||
setTotal(students)
|
||||
}
|
||||
}, [samples])
|
||||
|
||||
const getLanguage = lang => (lang in languageAbbreviations ? languageAbbreviations[lang] : lang)
|
||||
|
||||
if (total === 0) return <p>No data available!</p>
|
||||
|
||||
return (
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Languages</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Language Distribution
|
||||
<br />
|
||||
<span style={{ fontWeight: 100 }}>(n={total})</span>
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{total !== 0 &&
|
||||
Object.entries(languages).map(language => (
|
||||
<Table.Row key={`language-table-row-${language[0]}`}>
|
||||
<Table.Cell>{getLanguage(language[0])}</Table.Cell>
|
||||
<Table.Cell>{language[1]}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{total !== 0 && (
|
||||
<Progress percent={Math.round((language[1] / total) * 100)} progress style={{ margin: 0 }} />
|
||||
)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoursePopulationLanguageDist
|
||||
@@ -4,13 +4,16 @@ import { connect } from 'react-redux'
|
||||
import { shape, func, bool } from 'prop-types'
|
||||
import { Segment, Header, Accordion } from 'semantic-ui-react'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
import InfoBox from 'components/Info/InfoBox'
|
||||
import { getCoursePopulation } from '../../redux/populations'
|
||||
import { getSingleCourseStats } from '../../redux/singleCourseStats'
|
||||
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
|
||||
import { useGetStudentListCourseStatisticsQuery } from '../../redux/populationCourses'
|
||||
import { getFaculties } from '../../redux/faculties'
|
||||
import { getSemesters } from '../../redux/semesters'
|
||||
import { getElementDetails } from '../../redux/elementdetails'
|
||||
import PopulationStudents from '../PopulationStudents'
|
||||
import CoursePopulationGradeDist from './CoursePopulationGradeDist'
|
||||
import CoursePopulationLanguageDist from './CoursePopulationLanguageDist'
|
||||
import CoursePopulationCreditGainTable from './CoursePopulationCreditGainTable'
|
||||
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
|
||||
@@ -18,30 +21,33 @@ import ProgressBar from '../ProgressBar'
|
||||
import { getStudentToTargetCourseDateMap, getUserIsAdmin, getTextIn } from '../../common'
|
||||
import { useProgress, useTitle } from '../../common/hooks'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
import InfoBox from '../InfoBox'
|
||||
import FilterTray from '../FilterTray'
|
||||
import useFilters from '../FilterTray/useFilters'
|
||||
import useProgrammeFilter from '../FilterTray/filters/Programmes/useProgrammeFilter'
|
||||
import { CoursePopulationFilters } from '../FilterTray/FilterSets'
|
||||
import {
|
||||
ageFilter,
|
||||
gradeFilter,
|
||||
genderFilter,
|
||||
courseFilter,
|
||||
creditsEarnedFilter,
|
||||
startYearAtUniFilter,
|
||||
programmeFilter,
|
||||
} from '../FilterView/filters'
|
||||
import FilterView from '../FilterView'
|
||||
import useLanguage from '../LanguagePicker/useLanguage'
|
||||
import { queryParamsFromUrl } from '../../common/query'
|
||||
|
||||
const CoursePopulation = ({
|
||||
getCoursePopulationDispatch,
|
||||
getSingleCourseStatsDispatch,
|
||||
getElementDetails,
|
||||
studentData,
|
||||
pending,
|
||||
elementDetails,
|
||||
history,
|
||||
courseData,
|
||||
getSemestersDispatch,
|
||||
semesters,
|
||||
getFacultiesDispatch,
|
||||
getCustomPopulationCoursesByStudentnumbers,
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const { setAllStudents, filteredStudents } = useFilters()
|
||||
const { setCourseCodes } = useProgrammeFilter()
|
||||
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
|
||||
|
||||
const [codes, setCodes] = useState([])
|
||||
const [headerYears, setYears] = useState('')
|
||||
@@ -51,6 +57,7 @@ const CoursePopulation = ({
|
||||
const [newestIndex, setNewest] = useState(null)
|
||||
|
||||
const gradeDistRef = useRef()
|
||||
const languageDistRef = useRef()
|
||||
const programmeRef = useRef()
|
||||
const creditGainRef = useRef()
|
||||
const studentRef = useRef()
|
||||
@@ -63,18 +70,12 @@ const CoursePopulation = ({
|
||||
[studentData.students, codes]
|
||||
)
|
||||
|
||||
// Pass students to filter context.
|
||||
useEffect(() => {
|
||||
setAllStudents(studentData.students || [])
|
||||
|
||||
// Data fetching for courses of population tab
|
||||
if (!studentData.students) return
|
||||
getCustomPopulationCoursesByStudentnumbers({
|
||||
studentnumberlist: studentData.students.map(student => student.studentNumber),
|
||||
})
|
||||
}, [studentData.students])
|
||||
const { data: courseStatistics } = useGetStudentListCourseStatisticsQuery({
|
||||
studentNumbers: studentData.students ? studentData.students.map(student => student.studentNumber) : [],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
getElementDetails()
|
||||
getSemestersDispatch()
|
||||
}, [])
|
||||
|
||||
@@ -108,7 +109,6 @@ const CoursePopulation = ({
|
||||
separate,
|
||||
})
|
||||
setCodes(parsedCourseCodes)
|
||||
setCourseCodes(parsedCourseCodes)
|
||||
if (years) {
|
||||
setYears(years)
|
||||
} else {
|
||||
@@ -152,7 +152,7 @@ const CoursePopulation = ({
|
||||
setIndex(indexes)
|
||||
}
|
||||
|
||||
const panels = [
|
||||
const panels = filtered => [
|
||||
{
|
||||
key: 0,
|
||||
title: {
|
||||
@@ -168,10 +168,10 @@ const CoursePopulation = ({
|
||||
<div ref={gradeDistRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.GradeDistributionCoursePopulation} />
|
||||
<CoursePopulationGradeDist
|
||||
selectedStudents={selectedStudents}
|
||||
selectedStudents={filtered.map(s => s.studentNumber)}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
samples={studentData.students}
|
||||
samples={filtered}
|
||||
codes={codes}
|
||||
/>
|
||||
</div>
|
||||
@@ -183,20 +183,16 @@ const CoursePopulation = ({
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Programme distribution
|
||||
Language distribution
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(1),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={programmeRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
samples={studentData.students}
|
||||
selectedStudents={selectedStudents}
|
||||
/>
|
||||
<div ref={languageDistRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.GradeDistributionCoursePopulation} />
|
||||
<CoursePopulationLanguageDist from={dateFrom} to={dateTo} samples={studentData.students} codes={codes} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -206,7 +202,7 @@ const CoursePopulation = ({
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Courses of population
|
||||
Programme distribution
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -214,7 +210,12 @@ const CoursePopulation = ({
|
||||
content: {
|
||||
content: (
|
||||
<div ref={programmeRef}>
|
||||
<CustomPopulationCourses selectedStudents={selectedStudents} showFilter={false} />
|
||||
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
samples={filtered}
|
||||
selectedStudents={filtered.map(s => s.studentNumber)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -224,22 +225,15 @@ const CoursePopulation = ({
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Credit gains
|
||||
Courses of population
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(3),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={creditGainRef}>
|
||||
<CoursePopulationCreditGainTable
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
selectedStudents={selectedStudents}
|
||||
samples={studentData.students}
|
||||
codes={codes}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
/>
|
||||
<div ref={programmeRef}>
|
||||
<CustomPopulationCoursesWrapper filteredStudents={filtered} showFilter={false} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -249,17 +243,43 @@ const CoursePopulation = ({
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Students ({selectedStudents.length})
|
||||
Credit gains
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(4),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={creditGainRef}>
|
||||
<CoursePopulationCreditGainTable
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
selectedStudents={filtered.map(s => s.studentNumber)}
|
||||
samples={filtered}
|
||||
codes={codes}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 5,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Students ({filtered.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(5),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={studentRef}>
|
||||
<PopulationStudents
|
||||
variant="coursePopulation"
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
filteredStudents={studentData.students.filter(stu => selectedStudents.includes(stu.studentNumber))}
|
||||
filteredStudents={filtered}
|
||||
coursePopulation
|
||||
language={language}
|
||||
coursecode={codes}
|
||||
@@ -270,23 +290,53 @@ const CoursePopulation = ({
|
||||
},
|
||||
]
|
||||
|
||||
const courses = JSON.parse(queryParamsFromUrl(history.location).coursecodes)
|
||||
|
||||
return (
|
||||
<FilterTray filterSet={<CoursePopulationFilters />}>
|
||||
<div className="segmentContainer">
|
||||
<Segment className="contentSegment">
|
||||
<Header className="segmentTitle" size="large" textAlign="center">
|
||||
Population of course {header}
|
||||
</Header>
|
||||
<Header className="segmentTitle" size="medium" textAlign="center">
|
||||
{subHeader}
|
||||
</Header>
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
|
||||
</Segment>
|
||||
</div>
|
||||
</FilterTray>
|
||||
<FilterView
|
||||
name="CoursePopulation"
|
||||
filters={[
|
||||
genderFilter,
|
||||
ageFilter,
|
||||
courseFilter({ courses: courseStatistics?.coursestatistics }),
|
||||
creditsEarnedFilter,
|
||||
startYearAtUniFilter,
|
||||
programmeFilter({ courses, elementDetails }),
|
||||
gradeFilter({
|
||||
courseCodes: courses,
|
||||
from: dateFrom,
|
||||
to: dateTo,
|
||||
}),
|
||||
]}
|
||||
students={studentData.students ?? []}
|
||||
>
|
||||
{filtered => (
|
||||
<div className="segmentContainer">
|
||||
<Segment className="contentSegment">
|
||||
<Header className="segmentTitle" size="large" textAlign="center">
|
||||
Population of course {header}
|
||||
</Header>
|
||||
<Header className="segmentTitle" size="medium" textAlign="center">
|
||||
{subHeader}
|
||||
</Header>
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels(filtered)} />
|
||||
</Segment>
|
||||
</div>
|
||||
)}
|
||||
</FilterView>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomPopulationCoursesWrapper = props => {
|
||||
const { data: courseStatistics, isLoading } = useGetStudentListCourseStatisticsQuery({
|
||||
studentNumbers: props.filteredStudents.map(student => student.studentNumber),
|
||||
})
|
||||
|
||||
if (isLoading) return null
|
||||
|
||||
return <CustomPopulationCourses {...props} courses={courseStatistics} />
|
||||
}
|
||||
|
||||
CoursePopulation.propTypes = {
|
||||
getCoursePopulationDispatch: func.isRequired,
|
||||
getSingleCourseStatsDispatch: func.isRequired,
|
||||
@@ -300,7 +350,6 @@ CoursePopulation.propTypes = {
|
||||
years: shape({}),
|
||||
}).isRequired,
|
||||
getFacultiesDispatch: func.isRequired,
|
||||
getCustomPopulationCoursesByStudentnumbers: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({
|
||||
@@ -316,6 +365,7 @@ const mapStateToProps = ({
|
||||
pending: populations.pending,
|
||||
courseData: singleCourseStats.stats || {},
|
||||
semesters: semesters.data,
|
||||
elementDetails: populations?.data?.elementdetails?.data,
|
||||
isAdmin: getUserIsAdmin(roles),
|
||||
}
|
||||
}
|
||||
@@ -326,6 +376,6 @@ export default withRouter(
|
||||
getSingleCourseStatsDispatch: getSingleCourseStats,
|
||||
getSemestersDispatch: getSemesters,
|
||||
getFacultiesDispatch: getFaculties,
|
||||
getCustomPopulationCoursesByStudentnumbers,
|
||||
getElementDetails,
|
||||
})(CoursePopulation)
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { dataSeriesType, viewModeNames } from './Panes/util'
|
||||
import PassRate from './Panes/passRate'
|
||||
import Distribution from './Panes/distribution'
|
||||
import Tables from './Panes/tables'
|
||||
import InfoBox from '../../InfoBox'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
import { useTabs } from '../../../common/hooks'
|
||||
|
||||
import './resultTabs.css'
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import React, { Component } from 'react'
|
||||
import React, { useState, useRef, useMemo } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { connect } from 'react-redux'
|
||||
import moment from 'moment'
|
||||
import { arrayOf, bool, object, string, func, number, shape } from 'prop-types'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import Highcharts from 'highcharts/highstock'
|
||||
import { Button } from 'semantic-ui-react'
|
||||
import boostcanvas from 'highcharts/modules/boost-canvas'
|
||||
import _ from 'lodash/fp'
|
||||
import boost from 'highcharts/modules/boost'
|
||||
import ReactHighstock from 'react-highcharts/ReactHighstock'
|
||||
import './creditAccumulationGraphHC.css'
|
||||
import CreditGraphTooltip from '../CreditGraphTooltip'
|
||||
import { clearLoading } from '../../redux/graphSpinner'
|
||||
import { setChartHeight } from '../../redux/settings'
|
||||
import { reformatDate, getTextIn } from '../../common'
|
||||
import useLanguage from '../LanguagePicker/useLanguage'
|
||||
import { DISPLAY_DATE_FORMAT, API_DATE_FORMAT } from '../../constants'
|
||||
|
||||
// boost canvas needed because tests break with large population
|
||||
@@ -23,313 +21,252 @@ boost(Highcharts)
|
||||
|
||||
const SINGLE_GRAPH_GOAL_SERIES_NAME = 'Goal'
|
||||
|
||||
class CreditAccumulationGraphHighCharts extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
options: [],
|
||||
updateGraph: false,
|
||||
}
|
||||
const createGraphOptions = ({
|
||||
singleStudent,
|
||||
seriesData,
|
||||
graphHeight,
|
||||
tooltipFormatter,
|
||||
onPointClicked,
|
||||
startDate,
|
||||
endDate,
|
||||
}) => {
|
||||
const tooltip = {
|
||||
shared: false,
|
||||
split: false,
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { students, selectedStudents, currentGraphSize, render } = this.props
|
||||
|
||||
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.students) {
|
||||
const nextStudents = nextProps.students.map(student => student.studentNumber)
|
||||
const oldStudents = this.props.students.map(student => student.studentNumber)
|
||||
const changedStudentAmount = nextProps.selectedStudents.length !== this.props.selectedStudents.length
|
||||
const changedGraphSize = nextProps.currentGraphSize !== this.props.currentGraphSize
|
||||
|
||||
const changed =
|
||||
nextStudents.some(student => !oldStudents.includes(student)) ||
|
||||
oldStudents.some(student => !nextStudents.includes(student))
|
||||
|
||||
if (changed || changedStudentAmount || changedGraphSize) {
|
||||
this.createGraphOptions(
|
||||
nextProps.students,
|
||||
nextProps.selectedStudents,
|
||||
nextProps.currentGraphSize,
|
||||
nextProps.render
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger updating the chart after filter tray transition has finished.
|
||||
* This hack remedies the problem that highcharts will not dynamically adapt to container width
|
||||
* if a CSS transition is used. The resize must fire only after the transition has completed to
|
||||
* ensure correct size.
|
||||
*/
|
||||
if (this.props.trayOpen !== nextProps.trayOpen) {
|
||||
setTimeout(() => {
|
||||
this.props.setChartHeight(this.props.currentGraphSize - 1)
|
||||
}, 700)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { updateGraph } = this.state
|
||||
const { students, selectedStudents, currentGraphSize, render } = this.props
|
||||
|
||||
if (updateGraph) {
|
||||
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
|
||||
}
|
||||
}
|
||||
|
||||
getXAxisMonth = (date, startDate) =>
|
||||
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
|
||||
|
||||
sortCoursesByDate = courses => courses.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
filterCoursesByDate = (courses, date) => courses.filter(c => moment(c.date).isSameOrAfter(moment(date)))
|
||||
|
||||
createTooltip = point => {
|
||||
const { students, language } = this.props
|
||||
|
||||
if (point.series.name === SINGLE_GRAPH_GOAL_SERIES_NAME) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetCourse = this.sortCoursesByDate(students[0].courses).find(
|
||||
c => point.key === c.course_code && point.x === new Date(c.date).getTime()
|
||||
)
|
||||
|
||||
if (!targetCourse.course) return null
|
||||
const payload = [
|
||||
{
|
||||
name: students[0].studentNumber,
|
||||
payload: {
|
||||
...targetCourse,
|
||||
date: reformatDate(targetCourse.date, DISPLAY_DATE_FORMAT),
|
||||
title: getTextIn(targetCourse.course.name, language),
|
||||
},
|
||||
if (singleStudent) {
|
||||
Object.assign(tooltip, {
|
||||
formatter() {
|
||||
tooltipFormatter(this)
|
||||
},
|
||||
]
|
||||
return renderToString(<CreditGraphTooltip payload={payload} />)
|
||||
useHTML: true,
|
||||
style: {
|
||||
all: 'unset',
|
||||
display: 'none',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
createGraphOptions = (students, selectedStudents, graphSize, render) => {
|
||||
const studentCreditLines = this.createStudentCreditLines(students)
|
||||
|
||||
const dataOfSelected = studentCreditLines.filter(line => selectedStudents.includes(line.name))
|
||||
|
||||
let lastCredits = null
|
||||
if (this.isSingleStudentGraph() && render) {
|
||||
const started = moment(students.minDate)
|
||||
const lastDate = moment(students.maxDate)
|
||||
const lastMonth = Math.ceil(this.getXAxisMonth(lastDate, started))
|
||||
|
||||
let totalAbsenceMonths = 0
|
||||
const absencePoints = this.props.absences
|
||||
.filter(a => a.startdate >= started || (a.startdate <= started && a.enddate >= started))
|
||||
.reduce((res, { startdate, enddate }) => {
|
||||
const targetCreditsBeforeAbsence =
|
||||
(Math.ceil(this.getXAxisMonth(moment(startdate), started)) - totalAbsenceMonths) * (55 / 12)
|
||||
if (enddate < students.maxDate) {
|
||||
res.push([startdate, targetCreditsBeforeAbsence])
|
||||
res.push([enddate, targetCreditsBeforeAbsence])
|
||||
totalAbsenceMonths += moment(enddate).diff(moment(startdate), 'months')
|
||||
}
|
||||
return res
|
||||
}, [])
|
||||
|
||||
const zoneStart = absencePoints.length ? [absencePoints[0][0]] : []
|
||||
const zones = absencePoints.reduce(
|
||||
(acc, [start], i) => [...acc, { value: start, color: i % 2 === 0 ? '#96d7c3' : 'red' }],
|
||||
zoneStart
|
||||
)
|
||||
|
||||
lastCredits = (lastMonth - totalAbsenceMonths) * (55 / 12)
|
||||
dataOfSelected.push({
|
||||
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
|
||||
data: [
|
||||
...[[students.minDate, 0], ...absencePoints, [students.maxDate, lastCredits]].sort((a, b) => a[0] - b[0]),
|
||||
],
|
||||
seriesThreshold: 150,
|
||||
color: '#96d7c3',
|
||||
marker: {
|
||||
enabled: false,
|
||||
},
|
||||
zones,
|
||||
zoneAxis: 'x',
|
||||
})
|
||||
}
|
||||
const self = this
|
||||
|
||||
const tooltipOptions = this.isSingleStudentGraph()
|
||||
? {
|
||||
formatter() {
|
||||
return self.createTooltip(this)
|
||||
},
|
||||
shared: false,
|
||||
useHTML: true,
|
||||
style: {
|
||||
all: 'unset',
|
||||
display: 'none',
|
||||
},
|
||||
split: false,
|
||||
}
|
||||
: {
|
||||
shared: false,
|
||||
split: false,
|
||||
}
|
||||
|
||||
const options = {
|
||||
plotOptions: {
|
||||
series: {
|
||||
findNearestPointBy: 'xy',
|
||||
point: {
|
||||
events: {
|
||||
click() {
|
||||
if (!self.isSingleStudentGraph()) {
|
||||
self.props.history.push(`/students/${this.series.name}`)
|
||||
}
|
||||
},
|
||||
mouseOver() {
|
||||
if (this.series.halo) {
|
||||
this.series.halo
|
||||
.attr({
|
||||
class: 'highcharts-tracker',
|
||||
})
|
||||
.toFront()
|
||||
}
|
||||
},
|
||||
hover: {
|
||||
halo: {
|
||||
size: 9,
|
||||
attributes: {
|
||||
fill: Highcharts.getOptions().colors[2],
|
||||
'stroke-width': 2,
|
||||
stroke: Highcharts.getOptions().colors[1],
|
||||
},
|
||||
return {
|
||||
plotOptions: {
|
||||
series: {
|
||||
findNearestPointBy: 'xy',
|
||||
point: {
|
||||
events: {
|
||||
click() {
|
||||
onPointClicked(this)
|
||||
},
|
||||
mouseOver() {
|
||||
if (this.series.halo) {
|
||||
this.series.halo
|
||||
.attr({
|
||||
class: 'highcharts-tracker',
|
||||
})
|
||||
.toFront()
|
||||
}
|
||||
},
|
||||
hover: {
|
||||
halo: {
|
||||
size: 9,
|
||||
attributes: {
|
||||
fill: Highcharts.getOptions().colors[2],
|
||||
'stroke-width': 2,
|
||||
stroke: Highcharts.getOptions().colors[1],
|
||||
},
|
||||
},
|
||||
},
|
||||
cursor: 'pointer',
|
||||
},
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
max: lastCredits && lastCredits > students.maxCredits ? lastCredits : students.maxCredits,
|
||||
title: { text: 'Credits' },
|
||||
},
|
||||
xAxis: {
|
||||
max: students.maxDate,
|
||||
min: students.minDateWithCredits || students.minDate,
|
||||
ordinal: false,
|
||||
},
|
||||
series: dataOfSelected,
|
||||
chart: {
|
||||
height: graphSize,
|
||||
},
|
||||
tooltip: {
|
||||
...tooltipOptions,
|
||||
},
|
||||
}
|
||||
this.setState({ options, updateGraph: false })
|
||||
},
|
||||
yAxis: {
|
||||
title: { text: 'Credits' },
|
||||
min: 0,
|
||||
max: undefined,
|
||||
},
|
||||
xAxis: {
|
||||
ordinal: false,
|
||||
min: startDate,
|
||||
max: endDate,
|
||||
},
|
||||
series: seriesData,
|
||||
chart: {
|
||||
height: graphHeight,
|
||||
},
|
||||
tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
const sortCoursesByDate = courses =>
|
||||
[...courses].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
const filterCoursesByDate = (courses, date) => courses.filter(c => moment(c.date).isSameOrAfter(moment(date)))
|
||||
|
||||
const getXAxisMonth = (date, startDate) =>
|
||||
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
|
||||
|
||||
const singleStudentTooltipFormatter = (point, student, language) => {
|
||||
if (point.series.name === SINGLE_GRAPH_GOAL_SERIES_NAME) {
|
||||
return null
|
||||
}
|
||||
|
||||
isSingleStudentGraph = () => !!this.props.singleStudent
|
||||
const targetCourse = sortCoursesByDate(student.courses).find(
|
||||
({ course_code: courseCode, date }) => point.key === courseCode && point.x === new Date(date).getTime()
|
||||
)
|
||||
|
||||
createStudentCreditLines = students =>
|
||||
students.map(student => {
|
||||
const { started, studyrightStart } = student
|
||||
if (!targetCourse.course) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startDate = this.props.selectedStudents.length === 1 ? started : studyrightStart
|
||||
let credits = 0
|
||||
let points = this.filterCoursesByDate(this.sortCoursesByDate(student.courses), startDate).map(course => {
|
||||
if (course.passed && !course.isStudyModuleCredit) {
|
||||
credits += course.credits
|
||||
}
|
||||
const defaultPointOptions = this.isSingleStudentGraph() ? { name: course.course_code } : {}
|
||||
return {
|
||||
...defaultPointOptions,
|
||||
x: new Date(course.date).getTime(),
|
||||
y: credits,
|
||||
}
|
||||
})
|
||||
if (points.length < 2) {
|
||||
points = [[students.minDate, 0], ...points]
|
||||
const payload = [
|
||||
{
|
||||
name: student.studentNumber,
|
||||
payload: {
|
||||
...targetCourse,
|
||||
date: reformatDate(targetCourse.date, DISPLAY_DATE_FORMAT),
|
||||
title: getTextIn(targetCourse.course.name, language),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return renderToString(<CreditGraphTooltip payload={payload} />)
|
||||
}
|
||||
|
||||
const createGoalSeries = (starting, ending, absences) => {
|
||||
const lastMonth = Math.ceil(getXAxisMonth(moment(ending), moment(starting)))
|
||||
|
||||
let totalAbsenceMonths = 0
|
||||
const absencePoints = absences
|
||||
.filter(a => a.startdate >= starting || (a.startdate <= starting && a.enddate >= starting))
|
||||
.reduce((res, { startdate, enddate }) => {
|
||||
const targetCreditsBeforeAbsence =
|
||||
(Math.ceil(getXAxisMonth(moment(startdate), moment(starting))) - totalAbsenceMonths) * (55 / 12)
|
||||
if (enddate < ending) {
|
||||
res.push([startdate, targetCreditsBeforeAbsence])
|
||||
res.push([enddate, targetCreditsBeforeAbsence])
|
||||
totalAbsenceMonths += moment(enddate).diff(moment(startdate), 'months')
|
||||
}
|
||||
return {
|
||||
name: student.studentNumber,
|
||||
data: points,
|
||||
seriesThreshold: 150,
|
||||
marker: {
|
||||
enabled: this.isSingleStudentGraph(),
|
||||
radius: 4,
|
||||
return res
|
||||
}, [])
|
||||
|
||||
const zoneStart = absencePoints.length ? [absencePoints[0][0]] : []
|
||||
const zones = absencePoints.reduce(
|
||||
(acc, [start], i) => [...acc, { value: start, color: i % 2 === 0 ? '#96d7c3' : 'red' }],
|
||||
zoneStart
|
||||
)
|
||||
|
||||
const lastCredits = (lastMonth - totalAbsenceMonths) * (55 / 12)
|
||||
const data = [...[[starting, 0], ...absencePoints, [ending, lastCredits]].sort((a, b) => a[0] - b[0])]
|
||||
|
||||
return {
|
||||
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
|
||||
data,
|
||||
seriesThreshold: 150,
|
||||
color: '#96d7c3',
|
||||
marker: {
|
||||
enabled: false,
|
||||
},
|
||||
zones,
|
||||
zoneAxis: 'x',
|
||||
}
|
||||
}
|
||||
|
||||
const createStudentCreditLines = (students, singleStudent) =>
|
||||
students.map(student => {
|
||||
const { started, studyrightStart } = student
|
||||
|
||||
const startDate = students.length === 1 ? started : studyrightStart
|
||||
|
||||
const { points } = _.flow(
|
||||
sortCoursesByDate,
|
||||
courses => filterCoursesByDate(courses, startDate),
|
||||
_.reduce(
|
||||
({ credits, points }, course) => {
|
||||
const gainedCredits = course.passed && !course.isStudyModuleCredit ? course.credits : 0
|
||||
|
||||
const point = {
|
||||
x: new Date(course.date).getTime(),
|
||||
y: credits + gainedCredits,
|
||||
}
|
||||
|
||||
if (singleStudent) {
|
||||
Object.assign(point, {
|
||||
name: course.course_code,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
points: [...points, point],
|
||||
credits: credits + gainedCredits,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
{ credits: 0, points: [] }
|
||||
)
|
||||
)(student.courses)
|
||||
|
||||
resizeChart = size => {
|
||||
if (this.chart) {
|
||||
this.props.setChartHeight(size)
|
||||
return {
|
||||
name: student.studentNumber,
|
||||
data: points,
|
||||
seriesThreshold: 150,
|
||||
marker: {
|
||||
enabled: !!singleStudent,
|
||||
radius: 4,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const CreditAccumulationGraphHighCharts = ({ students, singleStudent, absences, startDate, endDate }) => {
|
||||
const history = useHistory()
|
||||
const chartRef = useRef()
|
||||
const language = useLanguage()
|
||||
const [graphHeight, setGraphHeight] = useState(600)
|
||||
|
||||
const seriesData = useMemo(() => createStudentCreditLines(students, singleStudent), [students, singleStudent])
|
||||
|
||||
if (singleStudent) {
|
||||
const starting = _.chain(students[0].studyRights || students[0].courses)
|
||||
.map(elem => new Date(elem.startdate || elem.date))
|
||||
.sortBy()
|
||||
.head()
|
||||
.defaultTo(new Date())
|
||||
.value()
|
||||
.getTime()
|
||||
const ending = endDate ? new Date(endDate).getTime() : new Date().getTime()
|
||||
|
||||
seriesData.push(createGoalSeries(starting, ending, absences))
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div className="graphContainer">
|
||||
<div className="graphOptions">
|
||||
<Button
|
||||
active={this.props.currentGraphSize === 400}
|
||||
onClick={() => this.resizeChart(400)}
|
||||
content="Small"
|
||||
/>
|
||||
<Button
|
||||
active={this.props.currentGraphSize === 600}
|
||||
onClick={() => this.resizeChart(600)}
|
||||
content="Medium"
|
||||
/>
|
||||
<Button
|
||||
active={this.props.currentGraphSize === 1000}
|
||||
onClick={() => this.resizeChart(1000)}
|
||||
content="Large"
|
||||
/>
|
||||
</div>
|
||||
<ReactHighstock
|
||||
highcharts={Highcharts}
|
||||
ref={HighchartsReact => {
|
||||
this.chart = HighchartsReact
|
||||
}}
|
||||
constructorType="stockChart"
|
||||
config={this.state.options}
|
||||
/>
|
||||
</div>
|
||||
const options = createGraphOptions({
|
||||
singleStudent,
|
||||
seriesData,
|
||||
graphHeight,
|
||||
startDate: startDate ? new Date(startDate).getTime() : undefined,
|
||||
endDate,
|
||||
tooltipFormatter: point => singleStudent && singleStudentTooltipFormatter(point, students[0], language),
|
||||
onPointClicked: point => {
|
||||
if (!singleStudent) {
|
||||
history.push(`/students/${point.series.name}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const makeGraphSizeButton = (height, label) => (
|
||||
<Button active={graphHeight === height} onClick={() => setGraphHeight(height)} content={label} />
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="graphContainer">
|
||||
<div className="graphOptions">
|
||||
{makeGraphSizeButton(400, 'Small')}
|
||||
{makeGraphSizeButton(600, 'Medium')}
|
||||
{makeGraphSizeButton(1000, 'Large')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<ReactHighstock highcharts={Highcharts} ref={chartRef} constructorType="stockChart" config={options} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CreditAccumulationGraphHighCharts.defaultProps = {
|
||||
absences: [],
|
||||
singleStudent: false,
|
||||
render: true,
|
||||
trayOpen: false,
|
||||
}
|
||||
|
||||
CreditAccumulationGraphHighCharts.propTypes = {
|
||||
singleStudent: bool,
|
||||
students: arrayOf(object).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
setChartHeight: func.isRequired,
|
||||
currentGraphSize: number.isRequired,
|
||||
language: string.isRequired,
|
||||
absences: arrayOf(shape({})),
|
||||
render: bool,
|
||||
trayOpen: bool,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
spinner: state.graphSpinner,
|
||||
currentGraphSize: state.settings.chartHeight,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, { clearLoading, setChartHeight })(withRouter(CreditAccumulationGraphHighCharts))
|
||||
export default CreditAccumulationGraphHighCharts
|
||||
|
||||
+4
-42
@@ -1,53 +1,15 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, arrayOf, string, bool } from 'prop-types'
|
||||
import React from 'react'
|
||||
import PopulationCourseStatsFlat from '../../PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import useCourseFilter from '../../FilterTray/filters/Courses/useCourseFilter'
|
||||
|
||||
const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, error, showFilter }) => {
|
||||
const { setCourses, resetCourses } = useCourseFilter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending && !error && courses.coursestatistics) {
|
||||
setCourses(courses.coursestatistics)
|
||||
}
|
||||
}, [courses])
|
||||
|
||||
// Clear course filter data on unmount.
|
||||
useEffect(() => {
|
||||
return resetCourses
|
||||
}, [])
|
||||
|
||||
const CustomPopulationCourses = ({ courses, pending, filteredStudents, showFilter }) => {
|
||||
return (
|
||||
<PopulationCourseStatsFlat
|
||||
courses={courses}
|
||||
query={query}
|
||||
pending={pending}
|
||||
selectedStudents={selectedStudents}
|
||||
customPopulation
|
||||
filteredStudents={filteredStudents}
|
||||
showFilter={showFilter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
CustomPopulationCourses.propTypes = {
|
||||
pending: bool.isRequired,
|
||||
courses: shape([]).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
query: shape({}).isRequired,
|
||||
error: bool.isRequired,
|
||||
showFilter: bool,
|
||||
}
|
||||
|
||||
CustomPopulationCourses.defaultProps = {
|
||||
showFilter: false,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populationCourses }) => ({
|
||||
courses: populationCourses.data,
|
||||
pending: populationCourses.pending,
|
||||
query: populationCourses.query,
|
||||
error: populationCourses.error,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(CustomPopulationCourses)
|
||||
export default CustomPopulationCourses
|
||||
|
||||
+16
-13
@@ -5,7 +5,8 @@ import { arrayOf, string, shape } from 'prop-types'
|
||||
import SearchResultTable from '../../SearchResultTable'
|
||||
import { getNewestProgramme, getTextIn } from '../../../common'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
import useProgrammeFilter from '../../FilterTray/filters/Programmes/useProgrammeFilter'
|
||||
import useFilters from '../../FilterView/useFilters'
|
||||
import { isProgrammeSelected, toggleProgrammeSelection } from '../../FilterView/filters/programmes'
|
||||
import FilterToggleIcon from '../../FilterToggleIcon'
|
||||
|
||||
const CustomPopulationProgrammeDist = ({
|
||||
@@ -15,7 +16,7 @@ const CustomPopulationProgrammeDist = ({
|
||||
populationStatistics,
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const { selectedProgrammes, toggleFilterProgramme } = useProgrammeFilter()
|
||||
|
||||
const [tableRows, setRows] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,27 +62,29 @@ const CustomPopulationProgrammeDist = ({
|
||||
|
||||
const headers = ['Programme', 'Code', `Students (all=${selectedStudents.length})`, 'Percentage of population']
|
||||
|
||||
const handleFilterToggle = code => {
|
||||
toggleFilterProgramme(code)
|
||||
}
|
||||
|
||||
const isProgrammeFilterActive = code => selectedProgrammes.find(p => p.code === code) !== undefined
|
||||
|
||||
return (
|
||||
<SearchResultTable
|
||||
headers={headers}
|
||||
rows={tableRows}
|
||||
selectable
|
||||
noResultText="placeholder"
|
||||
actionTrigger={row => (
|
||||
<span style={{ display: 'inline-block', marginRight: '0.3em' }}>
|
||||
<FilterToggleIcon onClick={() => handleFilterToggle(row[1])} isActive={isProgrammeFilterActive(row[1])} />
|
||||
</span>
|
||||
)}
|
||||
actionTrigger={row => <ProgrammeFilterToggleCell programme={row[1]} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ProgrammeFilterToggleCell = ({ programme }) => {
|
||||
const { useFilterSelector, filterDispatch } = useFilters()
|
||||
|
||||
const isActive = useFilterSelector(isProgrammeSelected(programme))
|
||||
|
||||
return (
|
||||
<span style={{ display: 'inline-block', marginRight: '0.3em' }}>
|
||||
<FilterToggleIcon onClick={() => filterDispatch(toggleProgrammeSelection(programme))} isActive={isActive} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
CustomPopulationProgrammeDist.defaultProps = {
|
||||
studentToTargetCourseDateMap: null,
|
||||
}
|
||||
|
||||
@@ -3,17 +3,14 @@ import moment from 'moment'
|
||||
import xlsx from 'xlsx'
|
||||
import { Dropdown } from 'semantic-ui-react'
|
||||
import { getStudentGradeMean, getStudentGradeMeanWeightedByCredits, getStudentTotalCredits } from '../../common'
|
||||
import useFilters from '../FilterTray/useFilters'
|
||||
import sendEvent from '../../common/sendEvent'
|
||||
import DataExport from '../PopulationStatistics/DataExport'
|
||||
|
||||
const sendAnalytics = sendEvent.populationStudents
|
||||
|
||||
export default () => {
|
||||
const { filteredStudents } = useFilters()
|
||||
|
||||
export default ({ students }) => {
|
||||
const getXlsx = () => {
|
||||
const data = filteredStudents.map(student => ({
|
||||
const data = students.map(student => ({
|
||||
'Student Number': student.studentNumber,
|
||||
'Total Credits (no transferred credits)': getStudentTotalCredits(student, false),
|
||||
'Grade Mean (no transferred credits)': getStudentGradeMean(student, false),
|
||||
@@ -27,11 +24,11 @@ export default () => {
|
||||
return workbook
|
||||
}
|
||||
|
||||
const filename = `oodikone_export_${filteredStudents.length}_students_${moment().format('YYYYMMDD-hhmmss')}.xlsx`
|
||||
const filename = `oodikone_export_${students.length}_students_${moment().format('YYYYMMDD-hhmmss')}.xlsx`
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataExport />
|
||||
<DataExport students={students} />
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
xlsx.writeFile(getXlsx(), filename)
|
||||
|
||||
@@ -1,61 +1,115 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Segment, Header, Accordion, Message } from 'semantic-ui-react'
|
||||
import { shape, func, arrayOf, bool, string } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
import { useProgress, useTitle } from '../../common/hooks'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
import { getCustomPopulationSearches } from '../../redux/customPopulationSearch'
|
||||
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
|
||||
import { useGetStudentListCourseStatisticsQuery } from '../../redux/populationCourses'
|
||||
import { getSemesters } from '../../redux/semesters'
|
||||
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
|
||||
import PopulationStudents from '../PopulationStudents'
|
||||
import CustomPopulationCourses from './CustomPopulationCourses'
|
||||
import CustomPopulationProgrammeDist from './CustomPopulationProgrammeDist'
|
||||
import ProgressBar from '../ProgressBar'
|
||||
import InfoBox from '../InfoBox'
|
||||
import FilterTray from '../FilterTray'
|
||||
import useFilters from '../FilterTray/useFilters'
|
||||
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
|
||||
import useFilterTray from '../FilterTray/useFilterTray'
|
||||
import InfoBox from '../Info/InfoBox'
|
||||
import FilterView from '../FilterView'
|
||||
import {
|
||||
ageFilter,
|
||||
courseFilter,
|
||||
programmeFilter,
|
||||
creditsEarnedFilter,
|
||||
genderFilter,
|
||||
transferredToProgrammeFilter,
|
||||
startYearAtUniFilter,
|
||||
tagsFilter,
|
||||
creditDateFilter,
|
||||
enrollmentStatusFilter,
|
||||
} from '../FilterView/filters'
|
||||
import useLanguage from '../LanguagePicker/useLanguage'
|
||||
import CustomPopulationSearch from './CustomPopulationSearch'
|
||||
import UnihowDataExport from './UnihowDataExport'
|
||||
|
||||
const CustomPopulation = ({
|
||||
getCustomPopulationSearchesDispatch,
|
||||
custompop,
|
||||
customPopulationFlag,
|
||||
loading,
|
||||
customPopulationSearches,
|
||||
latestCreatedCustomPopulationSearchId,
|
||||
searchedCustomPopulationSearchId,
|
||||
}) => {
|
||||
const selectCustomPopulationData = createSelector(
|
||||
state => state.semesters.data,
|
||||
state => state.populations.data,
|
||||
(semesters, populations) => ({
|
||||
allSemesters: semesters?.semesters ?? [],
|
||||
elementDetails: populations?.elementDetails?.data ?? [],
|
||||
custompop: populations?.students ?? [],
|
||||
})
|
||||
)
|
||||
|
||||
const CustomPopulation = () => {
|
||||
const { language } = useLanguage()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const { allSemesters, elementDetails, custompop } = useSelector(selectCustomPopulationData)
|
||||
|
||||
const { data: courseStats } = useGetStudentListCourseStatisticsQuery({
|
||||
studentNumbers: custompop.map(s => s.studentNumber),
|
||||
})
|
||||
|
||||
useTitle('Custom population')
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getSemesters())
|
||||
dispatch(getCustomPopulationSearches())
|
||||
}, [])
|
||||
|
||||
const filters = useMemo(
|
||||
() => [
|
||||
genderFilter,
|
||||
ageFilter,
|
||||
courseFilter({ courses: courseStats?.coursestatistics ?? [] }),
|
||||
creditsEarnedFilter,
|
||||
transferredToProgrammeFilter,
|
||||
startYearAtUniFilter,
|
||||
tagsFilter,
|
||||
programmeFilter({
|
||||
courses: courseStats?.coursestatistics ? courseStats?.coursestatistics.map(c => c.course.code) : [],
|
||||
elementDetails,
|
||||
}),
|
||||
creditDateFilter,
|
||||
enrollmentStatusFilter({
|
||||
allSemesters: allSemesters ?? [],
|
||||
language,
|
||||
}),
|
||||
],
|
||||
[courseStats, elementDetails, allSemesters, language]
|
||||
)
|
||||
|
||||
return (
|
||||
<FilterView name="CustomPopulation" filters={filters} students={custompop} displayTray={custompop.length > 0}>
|
||||
{students => <CustomPopulationContent students={students} custompop={custompop} />}
|
||||
</FilterView>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomPopulationContent = ({ students, custompop }) => {
|
||||
const { language } = useLanguage()
|
||||
const [activeIndex, setIndex] = useState([])
|
||||
const [, setSelectedSearchId] = useState('')
|
||||
const [newestIndex, setNewest] = useState(null)
|
||||
const { setAllStudents, filteredStudents } = useFilters()
|
||||
const [trayOpen] = useFilterTray()
|
||||
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
|
||||
|
||||
const { progress } = useProgress(loading)
|
||||
|
||||
const creditGainRef = useRef()
|
||||
const programmeRef = useRef()
|
||||
const coursesRef = useRef()
|
||||
const studentRef = useRef()
|
||||
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
|
||||
|
||||
useTitle('Custom population')
|
||||
const { customPopulationSearches, searchedCustomPopulationSearchId } = useSelector(
|
||||
state => state.customPopulationSearch
|
||||
)
|
||||
|
||||
// Pass students to filter context.
|
||||
useEffect(() => {
|
||||
setAllStudents(custompop || [])
|
||||
}, [custompop])
|
||||
const populations = useSelector(state => state.populations)
|
||||
const { customPopulationFlag } = populations
|
||||
|
||||
useEffect(() => {
|
||||
getCustomPopulationSearchesDispatch()
|
||||
}, [])
|
||||
const { progress } = useProgress(populations.loading)
|
||||
|
||||
const { data: courseStats } = useGetStudentListCourseStatisticsQuery({
|
||||
studentNumbers: students.map(s => s.studentNumber),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (newestIndex) {
|
||||
@@ -63,12 +117,6 @@ const CustomPopulation = ({
|
||||
}
|
||||
}, [activeIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (latestCreatedCustomPopulationSearchId) {
|
||||
setSelectedSearchId(latestCreatedCustomPopulationSearchId)
|
||||
}
|
||||
}, [latestCreatedCustomPopulationSearchId])
|
||||
|
||||
const handleClick = index => {
|
||||
const indexes = [...activeIndex].sort()
|
||||
if (indexes.includes(index)) {
|
||||
@@ -83,14 +131,13 @@ const CustomPopulation = ({
|
||||
else setNewest(null)
|
||||
setIndex(indexes)
|
||||
}
|
||||
|
||||
const panels = [
|
||||
const createPanels = () => [
|
||||
{
|
||||
key: 0,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Credit accumulation (for {selectedStudents.length} students)
|
||||
Credit accumulation (for {students.length} students)
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -98,13 +145,7 @@ const CustomPopulation = ({
|
||||
content: {
|
||||
content: (
|
||||
<div ref={creditGainRef}>
|
||||
<CreditAccumulationGraphHighCharts
|
||||
students={custompop}
|
||||
selectedStudents={selectedStudents}
|
||||
render={false}
|
||||
trayOpen={trayOpen}
|
||||
language={language}
|
||||
/>
|
||||
<CreditAccumulationGraphHighCharts students={students} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -123,7 +164,7 @@ const CustomPopulation = ({
|
||||
content: (
|
||||
<div ref={programmeRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist samples={custompop} selectedStudents={selectedStudents} />
|
||||
<CustomPopulationProgrammeDist samples={students} selectedStudents={_.map(students, 'studentNumber')} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -141,7 +182,7 @@ const CustomPopulation = ({
|
||||
content: {
|
||||
content: (
|
||||
<div ref={coursesRef}>
|
||||
<CustomPopulationCourses selectedStudents={selectedStudents} />
|
||||
<CustomPopulationCourses filteredStudents={students} courses={courseStats} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -151,7 +192,7 @@ const CustomPopulation = ({
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Students ({selectedStudents.length})
|
||||
Students ({students.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -160,9 +201,10 @@ const CustomPopulation = ({
|
||||
content: (
|
||||
<div ref={studentRef}>
|
||||
<PopulationStudents
|
||||
variant="customPopulation"
|
||||
language={language}
|
||||
filteredStudents={filteredStudents}
|
||||
dataExport={<UnihowDataExport />}
|
||||
filteredStudents={students}
|
||||
dataExport={<UnihowDataExport students={students} />}
|
||||
customPopulation
|
||||
/>
|
||||
</div>
|
||||
@@ -170,6 +212,7 @@ const CustomPopulation = ({
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const renderCustomPopulation = () => (
|
||||
<div>
|
||||
{custompop && (
|
||||
@@ -180,62 +223,32 @@ const CustomPopulation = ({
|
||||
: ''}
|
||||
</Header>
|
||||
)}
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={createPanels()} />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<FilterTray filterSet={<CustomPopulationFilters />}>
|
||||
<div className="segmentContainer">
|
||||
<Message style={{ maxWidth: '800px' }}>
|
||||
<Message.Header>Custom population</Message.Header>
|
||||
<p>
|
||||
Here you can create custom population using a list of studentnumbers. Clicking the blue custom population
|
||||
button will open a modal where you can enter a list of studentnumbers. You can also save a custom population
|
||||
by giving it a name and clicking the save button in the modal. It will then appear in the saved populations
|
||||
list. These populations are personal meaning that they will only show to you. You can only search
|
||||
studentnumbers you have access rights to i.e. you have rights to the programme they are in.
|
||||
</p>
|
||||
</Message>
|
||||
<CustomPopulationSearch />
|
||||
{custompop.length > 0 && customPopulationFlag ? (
|
||||
<Segment className="contentSegment">{renderCustomPopulation()}</Segment>
|
||||
) : (
|
||||
<Segment className="contentSegment">
|
||||
<ProgressBar progress={progress} />
|
||||
</Segment>
|
||||
)}
|
||||
</div>
|
||||
</FilterTray>
|
||||
<div className="segmentContainer">
|
||||
<Message style={{ maxWidth: '800px' }}>
|
||||
<Message.Header>Custom population</Message.Header>
|
||||
<p>
|
||||
Here you can create custom population using a list of studentnumbers. Clicking the blue custom population
|
||||
button will open a modal where you can enter a list of studentnumbers. You can also save a custom population
|
||||
by giving it a name and clicking the save button in the modal. It will then appear in the saved populations
|
||||
list. These populations are personal meaning that they will only show to you. You can only search
|
||||
studentnumbers you have access rights to i.e. you have rights to the programme they are in.
|
||||
</p>
|
||||
</Message>
|
||||
<CustomPopulationSearch />
|
||||
{custompop.length > 0 && customPopulationFlag ? (
|
||||
<Segment className="contentSegment">{renderCustomPopulation(students)}</Segment>
|
||||
) : (
|
||||
<Segment className="contentSegment">
|
||||
<ProgressBar progress={progress} />
|
||||
</Segment>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CustomPopulation.defaultProps = {
|
||||
latestCreatedCustomPopulationSearchId: null,
|
||||
searchedCustomPopulationSearchId: null,
|
||||
}
|
||||
|
||||
CustomPopulation.propTypes = {
|
||||
custompop: arrayOf(shape({})).isRequired,
|
||||
customPopulationFlag: bool.isRequired,
|
||||
loading: bool.isRequired,
|
||||
customPopulationSearches: arrayOf(shape({})).isRequired,
|
||||
latestCreatedCustomPopulationSearchId: string,
|
||||
searchedCustomPopulationSearchId: string,
|
||||
getCustomPopulationSearchesDispatch: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations, populationCourses, customPopulationSearch }) => ({
|
||||
loading: populations.pending,
|
||||
custompop: populations.data.students || [],
|
||||
courses: populationCourses.data,
|
||||
customPopulationFlag: populations.customPopulationFlag,
|
||||
customPopulationSearches: customPopulationSearch.customPopulationSearches,
|
||||
latestCreatedCustomPopulationSearchId: customPopulationSearch.latestCreatedCustomPopulationSearchId,
|
||||
searchedCustomPopulationSearchId: customPopulationSearch.searchedCustomPopulationSearchId,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
getCustomPopulationCoursesByStudentnumbers,
|
||||
getCustomPopulationSearchesDispatch: getCustomPopulationSearches,
|
||||
})(CustomPopulation)
|
||||
export default CustomPopulation
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Popup, Button, Icon } from 'semantic-ui-react'
|
||||
import { contextKey as filterTrayContextKey } from './index'
|
||||
import useFilterTray from './useFilterTray'
|
||||
import useAnalytics from './useAnalytics'
|
||||
|
||||
const ExternalFilterToggle = ({
|
||||
filterPanelContextKey,
|
||||
applyFilter,
|
||||
clearFilter,
|
||||
filterName,
|
||||
active,
|
||||
popupContent,
|
||||
}) => {
|
||||
const [, setFilterTrayOpen] = useFilterTray(filterTrayContextKey)
|
||||
const [, setCreditFilterOpen] = useFilterTray(filterPanelContextKey)
|
||||
const filterAnalytics = useAnalytics()
|
||||
|
||||
const updateFilters = () => {
|
||||
if (active) {
|
||||
clearFilter()
|
||||
filterAnalytics.clearFilterViaTable(filterName)
|
||||
} else {
|
||||
applyFilter()
|
||||
filterAnalytics.setFilterViaTable(filterName)
|
||||
|
||||
if (filterPanelContextKey) {
|
||||
setFilterTrayOpen(true)
|
||||
setCreditFilterOpen(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Toggle = () => (
|
||||
<Button onClick={updateFilters} size="mini" icon basic={!active} primary={active}>
|
||||
<Icon name="filter" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
// Trigger must be wrapped in <div> for the tooltip to work.
|
||||
// See https://stackoverflow.com/questions/63611315/semantics-popup-does-not-show-up-when-passing-a-custom-component-as-trigger
|
||||
return popupContent ? (
|
||||
<Popup
|
||||
content={popupContent}
|
||||
size="mini"
|
||||
trigger={
|
||||
<div>
|
||||
<Toggle />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Toggle />
|
||||
)
|
||||
}
|
||||
|
||||
ExternalFilterToggle.propTypes = {
|
||||
/** Used to open the filter's panel, if given. If not given, the filter tray will not be opened either. */
|
||||
filterPanelContextKey: PropTypes.string,
|
||||
/** Function to apply the filter when toggled on. */
|
||||
applyFilter: PropTypes.func.isRequired,
|
||||
/** Function to clear the filter when toggled off. */
|
||||
clearFilter: PropTypes.func.isRequired,
|
||||
/** Name to display in analytics. */
|
||||
filterName: PropTypes.string.isRequired,
|
||||
/** Toggle state. Must be controlled in the parent. */
|
||||
active: PropTypes.bool.isRequired,
|
||||
/** Content to show in the tooltip, if given. Tooltip is not shown if omitted. */
|
||||
popupContent: PropTypes.node,
|
||||
}
|
||||
|
||||
ExternalFilterToggle.defaultProps = {
|
||||
filterPanelContextKey: undefined,
|
||||
popupContent: undefined,
|
||||
}
|
||||
|
||||
export default ExternalFilterToggle
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { CourseFilterProvider } from './filters/Courses/useCourseFilter'
|
||||
import { CreditFilterProvider } from './filters/CreditsEarned/useCreditFilter'
|
||||
import { AgeFilterProvider } from './filters/Age/useAgeFilter'
|
||||
import { FilterTrayProvider } from './useFilterTray'
|
||||
import { ProgrammeFilterProvider } from './filters/Programmes/useProgrammeFilter'
|
||||
import { FilterProvider } from './useFilters'
|
||||
import { FilterAnalyticsProvider } from './useAnalytics'
|
||||
import { GradeFilterProvider } from './filters/Grade/useGradeFilter'
|
||||
|
||||
const filterProviders = [
|
||||
FilterAnalyticsProvider,
|
||||
FilterTrayProvider,
|
||||
FilterProvider,
|
||||
CourseFilterProvider,
|
||||
CreditFilterProvider,
|
||||
AgeFilterProvider,
|
||||
GradeFilterProvider,
|
||||
ProgrammeFilterProvider,
|
||||
]
|
||||
|
||||
const FilterContextProvider = ({ children }) =>
|
||||
filterProviders.reduceRight((children, Provider) => <Provider>{children}</Provider>, children)
|
||||
|
||||
FilterContextProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default FilterContextProvider
|
||||
@@ -1,75 +0,0 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import GraduatedFromProgramme from './filters/GraduatedFromProgramme'
|
||||
import TransferredToProgramme from './filters/TransferredToProgramme'
|
||||
import EnrollmentStatus from './filters/EnrollmentStatus'
|
||||
import CreditsEarned from './filters/CreditsEarned'
|
||||
import Gender from './filters/Gender'
|
||||
import StartYearAtUni from './filters/StartYearAtUni'
|
||||
import Courses from './filters/Courses'
|
||||
import Programmes from './filters/Programmes'
|
||||
import useAnalytics from './useAnalytics'
|
||||
import Grade from './filters/Grade'
|
||||
import Date from './filters/Date'
|
||||
import Age from './filters/Age'
|
||||
import AdmissionType from './filters/AdmissionType'
|
||||
import Tags from './filters/Tags'
|
||||
|
||||
export const PopulationStatisticsFilters = ({ query }) => {
|
||||
const analytics = useAnalytics()
|
||||
useEffect(() => {
|
||||
analytics.setTarget('Population Statistics')
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<GraduatedFromProgramme />
|
||||
<TransferredToProgramme />
|
||||
<EnrollmentStatus />
|
||||
<CreditsEarned />
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
|
||||
<Courses />
|
||||
<Tags />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const CoursePopulationFilters = () => {
|
||||
const analytics = useAnalytics()
|
||||
|
||||
useEffect(() => {
|
||||
analytics.setTarget('Course Population')
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grade />
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
<Courses />
|
||||
<Programmes />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const CustomPopulationFilters = () => {
|
||||
const analytics = useAnalytics()
|
||||
|
||||
useEffect(() => {
|
||||
analytics.setTarget('Custom Population')
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<EnrollmentStatus />
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
<Courses />
|
||||
<Date />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Form, Dropdown } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import FilterCard from './common/FilterCard'
|
||||
import ClearFilterButton from './common/ClearFilterButton'
|
||||
import useFilters from '../useFilters'
|
||||
import useAnalytics from '../useAnalytics'
|
||||
|
||||
const AdmissionType = ({ code }) => {
|
||||
const { addFilter, removeFilter, activeFilters, withoutFilter } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
// Using undefined as default, since students can be filtered with null value
|
||||
const [value, setValue] = useState(undefined)
|
||||
const name = 'admissionTypeFilter'
|
||||
|
||||
// Naming follows convention from SIS API (e.g urn:code:admissiont-type:m for "Muu")
|
||||
const admissionTypes = {
|
||||
M: 'Muu',
|
||||
KM: 'Kilpailumenestys',
|
||||
TV: 'Todistusvalinta',
|
||||
AV: 'Avoin väylä',
|
||||
KP: 'Koepisteet',
|
||||
YP: 'Yhteispisteet',
|
||||
N: null,
|
||||
}
|
||||
|
||||
const filterFunction = value => student => {
|
||||
return value === 'Ei valintatapaa'
|
||||
? student.studyrights.some(sr => sr.studyright_elements.some(e => e.code === code) && !sr.admission_type)
|
||||
: student.studyrights.some(sr => sr.studyright_elements.some(e => e.code === code) && value === sr.admission_type)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
removeFilter(name)
|
||||
analytics.clearFilter(name)
|
||||
} else {
|
||||
addFilter(name, filterFunction(value))
|
||||
analytics.setFilter(name, value)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const count = admissionType => withoutFilter(name).filter(filterFunction(admissionType)).length
|
||||
|
||||
const options = Object.entries(admissionTypes).map(([key, admissionType]) => {
|
||||
const value = admissionType || 'Ei valintatapaa'
|
||||
return {
|
||||
key,
|
||||
text: `${value} (${count(admissionType)})`,
|
||||
value,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Admission type"
|
||||
contextKey={name}
|
||||
active={Object.keys(activeFilters).includes(name)}
|
||||
footer={<ClearFilterButton disabled={!value} onClick={() => setValue(null)} name={name} />}
|
||||
name={name}
|
||||
>
|
||||
<div className="card-content">
|
||||
<Form>
|
||||
<Dropdown
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(_, { value: inputValue }) => setValue(inputValue)}
|
||||
placeholder="Choose admission type"
|
||||
className="mini"
|
||||
selection
|
||||
selectOnBlur={false}
|
||||
fluid
|
||||
button
|
||||
clearable
|
||||
data-cy={`${name}-dropdown`}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
|
||||
AdmissionType.propTypes = {
|
||||
code: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations }) => ({
|
||||
code: populations.query ? populations.query.studyRights.programme : '',
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(AdmissionType)
|
||||
@@ -1,102 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Form, Label, Input } from 'semantic-ui-react'
|
||||
import FilterCard from '../common/FilterCard'
|
||||
import useAgeFilter from './useAgeFilter'
|
||||
import useFilters from '../../useFilters'
|
||||
import useAnalytics from '../../useAnalytics'
|
||||
|
||||
export const contextKey = 'ageFilter'
|
||||
|
||||
const getAge = toDate => {
|
||||
const today = new Date()
|
||||
const birthDate = new Date(toDate)
|
||||
let age = today.getFullYear() - birthDate.getFullYear()
|
||||
const m = today.getMonth() - birthDate.getMonth()
|
||||
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
|
||||
age--
|
||||
}
|
||||
return age
|
||||
}
|
||||
|
||||
const AgeFilter = () => {
|
||||
const { currentValue, setCurrentValue } = useAgeFilter()
|
||||
const { addFilter, removeFilter, activeFilters } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
const [updatedAt, setUpdatedAt] = useState({ min: null, max: null })
|
||||
const labels = { min: 'At Least', max: 'Less Than' }
|
||||
|
||||
const now = () => new Date().getTime()
|
||||
|
||||
const names = Object.fromEntries(Object.keys(currentValue).map(key => [key, `age${key}`]))
|
||||
|
||||
const filterFunctions = limit => ({
|
||||
min: student => getAge(student.birthdate) >= Number(limit),
|
||||
max: student => getAge(student.birthdate) < Number(limit),
|
||||
})
|
||||
|
||||
const updateFilters = key => {
|
||||
const name = names[key]
|
||||
|
||||
if (currentValue[key] !== '') {
|
||||
addFilter(name, filterFunctions(currentValue[key])[key])
|
||||
analytics.setFilter(name, currentValue[key])
|
||||
} else {
|
||||
removeFilter(name)
|
||||
analytics.clearFilter(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Update filters automatically 2 sec after value change.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
Object.keys(updatedAt).forEach(key => {
|
||||
if (updatedAt[key] && now() - updatedAt[key] > 1900) {
|
||||
updateFilters(key)
|
||||
setUpdatedAt(prev => ({ ...prev, [key]: null }))
|
||||
}
|
||||
})
|
||||
}, 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [updatedAt])
|
||||
|
||||
const onChange =
|
||||
key =>
|
||||
(_, { value }) => {
|
||||
setCurrentValue({ [key]: value })
|
||||
setUpdatedAt(prev => ({ ...prev, [key]: now() }))
|
||||
}
|
||||
|
||||
const onKeyDown = key => event => {
|
||||
if (event.keyCode === 13) {
|
||||
event.preventDefault()
|
||||
setUpdatedAt(prev => ({ ...prev, [key]: null }))
|
||||
updateFilters(key)
|
||||
}
|
||||
}
|
||||
|
||||
const active = Object.values(names).some(name => Object.keys(activeFilters).includes(name))
|
||||
|
||||
return (
|
||||
<FilterCard title="Age" active={active} className="total-age-filter" contextKey={contextKey} name="ageFilter">
|
||||
<Form>
|
||||
<div className="card-content">
|
||||
{Object.keys(currentValue).map(key => (
|
||||
<Form.Field key={`total-age-filter-${key}`}>
|
||||
<Label style={{ marginBottom: '0.5rem' }}>{labels[key]}</Label>
|
||||
<Input
|
||||
size="mini"
|
||||
onChange={onChange(key)}
|
||||
value={currentValue[key]}
|
||||
onKeyDown={onKeyDown(key)}
|
||||
data-cy={`ageFilter-${key}`}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
</Form.Field>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default AgeFilter
|
||||
@@ -1,37 +0,0 @@
|
||||
import React, { createContext, useState, useContext } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const defaultState = {
|
||||
currentValue: { min: '', max: '' },
|
||||
requestedValue: { min: null, max: null },
|
||||
}
|
||||
|
||||
const AgeFilterContext = createContext([[], () => {}])
|
||||
AgeFilterContext.displayName = 'Age Filter'
|
||||
|
||||
export const AgeFilterProvider = ({ children }) => {
|
||||
const [state, setState] = useState(defaultState)
|
||||
return <AgeFilterContext.Provider value={[state, setState]}>{children}</AgeFilterContext.Provider>
|
||||
}
|
||||
|
||||
AgeFilterProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
const useAgeFilter = () => {
|
||||
const [state, setState] = useContext(AgeFilterContext)
|
||||
const { currentValue } = state
|
||||
|
||||
const setCurrentValue = newValue =>
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentValue: {
|
||||
...prev.currentValue,
|
||||
...newValue,
|
||||
},
|
||||
}))
|
||||
|
||||
return { currentValue, setCurrentValue }
|
||||
}
|
||||
|
||||
export default useAgeFilter
|
||||
@@ -1,115 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Label, Dropdown, Button, Icon, Popup } from 'semantic-ui-react'
|
||||
import { getTextIn } from '../../../../common'
|
||||
import useCourseFilter from './useCourseFilter'
|
||||
import useFilters from '../../useFilters'
|
||||
import useAnalytics from '../../useAnalytics'
|
||||
import useLanguage from '../../../LanguagePicker/useLanguage'
|
||||
|
||||
const CourseCard = ({ courseStats }) => {
|
||||
const { language } = useLanguage()
|
||||
const { addFilter, removeFilter } = useFilters()
|
||||
const { course, students } = courseStats
|
||||
const { toggleCourseSelection } = useCourseFilter()
|
||||
const analytics = useAnalytics()
|
||||
const [selectedOption, setSelectedOption] = useState(0)
|
||||
const name = `courseFilter-${course.code}`
|
||||
|
||||
const subFilters = [
|
||||
{
|
||||
label: 'All',
|
||||
func: ({ studentNumber }) => Object.keys(students.all).includes(studentNumber),
|
||||
},
|
||||
{
|
||||
label: 'Passed',
|
||||
func: ({ studentNumber }) => Object.keys(students.passed).includes(studentNumber),
|
||||
},
|
||||
{
|
||||
label: 'Passed After Failure',
|
||||
func: ({ studentNumber }) => Object.keys(students.retryPassed).includes(studentNumber),
|
||||
info: 'Student passed the course after failing it at least once.',
|
||||
},
|
||||
{
|
||||
label: 'Failed',
|
||||
func: ({ studentNumber }) => Object.keys(students.failed).includes(studentNumber),
|
||||
},
|
||||
{
|
||||
label: 'Failed Many Times',
|
||||
func: ({ studentNumber }) => Object.keys(students.failedMany).includes(studentNumber),
|
||||
},
|
||||
{
|
||||
label: 'Not Participated',
|
||||
func: ({ studentNumber }) => !Object.keys(students.all).includes(studentNumber),
|
||||
},
|
||||
{
|
||||
label: "Didn't pass",
|
||||
func: ({ studentNumber }) =>
|
||||
!Object.keys(students.all).includes(studentNumber) || Object.keys(students.failed).includes(studentNumber),
|
||||
info: "Students that failed or didn't participate in the course",
|
||||
},
|
||||
]
|
||||
|
||||
// Apply filter on mount or selection change.
|
||||
useEffect(() => {
|
||||
addFilter(name, subFilters[selectedOption].func)
|
||||
}, [selectedOption])
|
||||
|
||||
// Remove filter on unmount.
|
||||
useEffect(() => {
|
||||
return () => removeFilter(name)
|
||||
}, [])
|
||||
|
||||
const onClick = (_, { value }) => setSelectedOption(value)
|
||||
|
||||
const clear = () => {
|
||||
toggleCourseSelection(course.code)
|
||||
analytics.clearFilter(course.code)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label style={{ marginTop: '0.5rem' }}>
|
||||
{getTextIn(course.name, language)}
|
||||
|
||||
<Dropdown
|
||||
text={subFilters[selectedOption].label}
|
||||
value={selectedOption}
|
||||
fluid
|
||||
className="mini"
|
||||
button
|
||||
data-cy={`${name}-dropdown`}
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
>
|
||||
<Dropdown.Menu>
|
||||
{subFilters.map((option, i) => {
|
||||
if (option.info) {
|
||||
return (
|
||||
<Popup
|
||||
key={option.label}
|
||||
basic
|
||||
trigger={<Dropdown.Item text={option.label} value={i} onClick={onClick} />}
|
||||
content={option.info}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <Dropdown.Item key={option.label} text={option.label} value={i} onClick={onClick} />
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
|
||||
<Button compact size="tiny" onClick={clear} icon data-cy={`${name}-clear`} style={{ marginTop: '0.5rem' }}>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
</Label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
CourseCard.propTypes = {
|
||||
courseStats: PropTypes.shape({
|
||||
course: PropTypes.object,
|
||||
students: PropTypes.object,
|
||||
}).isRequired,
|
||||
}
|
||||
export default CourseCard
|
||||
@@ -1,69 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Dropdown } from 'semantic-ui-react'
|
||||
import FilterCard from '../common/FilterCard'
|
||||
import CourseCard from './CourseCard'
|
||||
import { getTextIn } from '../../../../common'
|
||||
import useCourseFilter from './useCourseFilter'
|
||||
import './courseFilter.css'
|
||||
import useFilters from '../../useFilters'
|
||||
import useAnalytics from '../../useAnalytics'
|
||||
import useLanguage from '../../../LanguagePicker/useLanguage'
|
||||
|
||||
export const contextKey = 'coursesFilter'
|
||||
|
||||
const Courses = () => {
|
||||
const { language } = useLanguage()
|
||||
const { courses: courseStats, selectedCourses, toggleCourseSelection } = useCourseFilter()
|
||||
const { filteredStudents } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
const name = 'courseFilter'
|
||||
|
||||
// Wrestle course stats into something semantic-ui eats without throwing up.
|
||||
const makeLabel = cs => `${cs.course.code} - ${getTextIn(cs.course.name, language)}`
|
||||
const options = courseStats
|
||||
.filter(cs => cs.stats.students > Math.round(filteredStudents.length * 0.3))
|
||||
.filter(cs => !selectedCourses.some(c => c.course.code === cs.course.code))
|
||||
.sort((a, b) => makeLabel(a).localeCompare(makeLabel(b)))
|
||||
.map(cs => ({
|
||||
key: `course-filter-option-${cs.course.code}`,
|
||||
text: makeLabel(cs),
|
||||
value: cs.course.code,
|
||||
}))
|
||||
|
||||
const onChange = (_, { value }) => {
|
||||
const courseCode = value[0]
|
||||
toggleCourseSelection(courseCode)
|
||||
analytics.setFilter(name, courseCode)
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Courses"
|
||||
active={!!selectedCourses.length}
|
||||
className="courses-filter"
|
||||
contextKey={contextKey}
|
||||
name={name}
|
||||
>
|
||||
<Dropdown
|
||||
options={options}
|
||||
placeholder="Select Course"
|
||||
selection
|
||||
className="mini course-filter-selection"
|
||||
fluid
|
||||
button
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
multiple
|
||||
closeOnChange
|
||||
search
|
||||
name={name}
|
||||
data-cy={`${name}-course-dropdown`}
|
||||
/>
|
||||
{selectedCourses.map(course => (
|
||||
<CourseCard courseStats={course} key={`course-filter-selected-course-${course.course.code}`} />
|
||||
))}
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default Courses
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { getPopulationSelectedStudentCourses } from '../../../../redux/populationSelectedStudentCourses'
|
||||
|
||||
const defaultState = {
|
||||
courses: [],
|
||||
selectedCourses: [],
|
||||
dispatchCourseQuery: null,
|
||||
}
|
||||
|
||||
const CourseFilterContext = createContext([[], () => {}])
|
||||
CourseFilterContext.displayName = 'Course Filter'
|
||||
|
||||
const CourseFilterProvider = ({ children, getPopulationSelectedStudentCourses }) => {
|
||||
const [state, setState] = useState(defaultState)
|
||||
|
||||
// On load, extract the course query dispatch function from redux for further use.
|
||||
useEffect(() => {
|
||||
setState(prev => ({ ...prev, dispatchCourseQuery: getPopulationSelectedStudentCourses }))
|
||||
}, [])
|
||||
|
||||
return <CourseFilterContext.Provider value={[state, setState]}>{children}</CourseFilterContext.Provider>
|
||||
}
|
||||
|
||||
CourseFilterProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
getPopulationSelectedStudentCourses: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
const ConnectedProvider = connect(null, { getPopulationSelectedStudentCourses })(CourseFilterProvider)
|
||||
|
||||
export { ConnectedProvider as CourseFilterProvider }
|
||||
|
||||
// Acual hook functions.
|
||||
export default () => {
|
||||
const [state, setState] = useContext(CourseFilterContext)
|
||||
const { courses, selectedCourses, dispatchCourseQuery } = state
|
||||
|
||||
const setCourses = courses => setState(prev => ({ ...prev, courses }))
|
||||
|
||||
const setCoursesOnce = courses => {
|
||||
if (!state.courses.length) {
|
||||
setState(prev => ({ ...prev, courses }))
|
||||
}
|
||||
}
|
||||
|
||||
const resetCourses = () => setState(prev => ({ ...prev, courses: [] }))
|
||||
|
||||
const toggleCourseSelection = courseCode =>
|
||||
setState(prev => {
|
||||
const course = courses.find(course => course.course.code === courseCode)
|
||||
const isSelected = prev.selectedCourses.some(c => c.course.code === courseCode)
|
||||
|
||||
return {
|
||||
...prev,
|
||||
selectedCourses: isSelected
|
||||
? prev.selectedCourses.filter(c => c.course.code !== courseCode)
|
||||
: prev.selectedCourses.concat(course),
|
||||
}
|
||||
})
|
||||
|
||||
const courseIsSelected = courseCode => selectedCourses.some(course => course.course.code === courseCode)
|
||||
|
||||
const runCourseQuery = opts => {
|
||||
dispatchCourseQuery(opts)
|
||||
}
|
||||
|
||||
return {
|
||||
courses,
|
||||
selectedCourses,
|
||||
setCourses,
|
||||
setCoursesOnce,
|
||||
resetCourses,
|
||||
toggleCourseSelection,
|
||||
courseIsSelected,
|
||||
runCourseQuery,
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Form, Input, Label } from 'semantic-ui-react'
|
||||
import { getStudentTotalCredits } from '../../../../common'
|
||||
import FilterCard from '../common/FilterCard'
|
||||
import useCreditFilter from './useCreditFilter'
|
||||
import useFilters from '../../useFilters'
|
||||
import useAnalytics from '../../useAnalytics'
|
||||
|
||||
export const contextKey = 'creditFilter'
|
||||
|
||||
export default () => {
|
||||
const { currentValue, requestedValue, setCurrentValue } = useCreditFilter()
|
||||
const { addFilter, removeFilter, activeFilters } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
const [updatedAt, setUpdatedAt] = useState({ min: null, max: null })
|
||||
const labels = { min: 'At Least', max: 'Less Than' }
|
||||
|
||||
const now = () => new Date().getTime()
|
||||
|
||||
const names = Object.fromEntries(Object.keys(currentValue).map(key => [key, `totalCredits${key}`]))
|
||||
|
||||
const filterFunctions = limit => ({
|
||||
min: student => getStudentTotalCredits(student) >= Number(limit),
|
||||
max: student => getStudentTotalCredits(student) < Number(limit),
|
||||
})
|
||||
|
||||
const updateFilters = key => {
|
||||
const name = names[key]
|
||||
|
||||
if (currentValue[key] !== '') {
|
||||
addFilter(name, filterFunctions(currentValue[key])[key])
|
||||
analytics.setFilter(name, currentValue[key])
|
||||
} else {
|
||||
removeFilter(name)
|
||||
analytics.clearFilter(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Update filters automatically 2 sec after value change.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
Object.keys(updatedAt).forEach(key => {
|
||||
if (updatedAt[key] && now() - updatedAt[key] > 1900) {
|
||||
updateFilters(key)
|
||||
setUpdatedAt(prev => ({ ...prev, [key]: null }))
|
||||
}
|
||||
})
|
||||
}, 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [updatedAt])
|
||||
|
||||
// Listen for external filtering requests (from credit stats table rows).
|
||||
useEffect(() => {
|
||||
Object.keys(currentValue).forEach(key => {
|
||||
const newValue = requestedValue[key] === null ? '' : requestedValue[key]
|
||||
const name = names[key]
|
||||
setCurrentValue({ [key]: String(newValue) })
|
||||
|
||||
if (newValue === '') {
|
||||
removeFilter(name)
|
||||
} else {
|
||||
addFilter(name, filterFunctions(newValue)[key])
|
||||
}
|
||||
})
|
||||
}, [requestedValue])
|
||||
|
||||
const onChange =
|
||||
key =>
|
||||
(_, { value }) => {
|
||||
setCurrentValue({ [key]: value })
|
||||
setUpdatedAt(prev => ({ ...prev, [key]: now() }))
|
||||
}
|
||||
|
||||
const onKeyDown = key => event => {
|
||||
if (event.keyCode === 13) {
|
||||
event.preventDefault()
|
||||
setUpdatedAt(prev => ({ ...prev, [key]: null }))
|
||||
updateFilters(key)
|
||||
}
|
||||
}
|
||||
|
||||
const active = Object.values(names).some(name => Object.keys(activeFilters).includes(name))
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Credits"
|
||||
active={active}
|
||||
className="total-credits-filter"
|
||||
contextKey={contextKey}
|
||||
name="credit-filter"
|
||||
>
|
||||
<Form>
|
||||
<div className="card-content">
|
||||
{Object.keys(currentValue).map(key => (
|
||||
<Form.Field key={`total-credits-filter-${key}`}>
|
||||
<Label style={{ marginBottom: '0.5rem' }}>{labels[key]}</Label>
|
||||
<Input
|
||||
size="mini"
|
||||
onChange={onChange(key)}
|
||||
value={currentValue[key]}
|
||||
onKeyDown={onKeyDown(key)}
|
||||
data-cy={`credit-filter-${key}`}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
</Form.Field>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import React, { createContext, useState, useContext } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const defaultState = {
|
||||
currentValue: { min: '', max: '' },
|
||||
requestedValue: { min: null, max: null },
|
||||
}
|
||||
|
||||
const CreditFilterContext = createContext([[], () => {}])
|
||||
CreditFilterContext.displayName = 'Credit Filter'
|
||||
|
||||
export const CreditFilterProvider = ({ children }) => {
|
||||
const [state, setState] = useState(defaultState)
|
||||
return <CreditFilterContext.Provider value={[state, setState]}>{children}</CreditFilterContext.Provider>
|
||||
}
|
||||
|
||||
CreditFilterProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const [state, setState] = useContext(CreditFilterContext)
|
||||
const { currentValue, requestedValue } = state
|
||||
|
||||
const setCurrentValue = newValue =>
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentValue: {
|
||||
...prev.currentValue,
|
||||
...newValue,
|
||||
},
|
||||
}))
|
||||
|
||||
const setRequestedValue = newValue =>
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
requestedValue: {
|
||||
...prev.currentValue,
|
||||
...newValue,
|
||||
},
|
||||
}))
|
||||
|
||||
return { currentValue, requestedValue, setCurrentValue, setRequestedValue }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Button } from 'semantic-ui-react'
|
||||
import './creditDateFilter.css'
|
||||
|
||||
// FYI: I spent over an hour trying to make this button also close the date picker.
|
||||
// Do not try it, use the time to roast react-datetime devs instead...
|
||||
const DateButton = ({ value }, openCalendar) => {
|
||||
return <Button content={value} onClick={openCalendar} className="credit-date-filter-input" />
|
||||
}
|
||||
|
||||
DateButton.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
export default DateButton
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Datetime from 'react-datetime'
|
||||
import 'moment/locale/fi'
|
||||
import DateButton from './DateButton'
|
||||
|
||||
const DateTime = ({ value, onChange }) => {
|
||||
return (
|
||||
<Datetime value={value} onChange={onChange} timeFormat={false} locale="fi" closeOnSelect renderInput={DateButton} />
|
||||
)
|
||||
}
|
||||
|
||||
DateTime.propTypes = {
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.shape({})]).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default DateTime
|
||||
@@ -1,69 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Form } from 'semantic-ui-react'
|
||||
import 'moment/locale/fi'
|
||||
import ClearFilterButton from '../common/ClearFilterButton'
|
||||
import FilterCard from '../common/FilterCard'
|
||||
import useFilters from '../../useFilters'
|
||||
import DateTime from './DateTime'
|
||||
|
||||
/**
|
||||
* Filter courses according to the date when credits were earned.
|
||||
* Unlike other filters, this one does not filter out students from the population. Rather it
|
||||
* operates on the students' courses, filtering them according to the date range input.
|
||||
*/
|
||||
export default () => {
|
||||
const { setCreditDateFilter } = useFilters()
|
||||
const label = 'Select Date'
|
||||
const [startDate, setStartDate] = useState(label)
|
||||
const [endDate, setEndDate] = useState(label)
|
||||
const name = 'creditDateFilter'
|
||||
|
||||
useEffect(() => {
|
||||
const filterFn = course => {
|
||||
if (startDate !== label && startDate.isAfter(course.date)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (endDate !== label && endDate.isBefore(course.date)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
setCreditDateFilter(filterFn)
|
||||
}, [startDate, endDate])
|
||||
|
||||
const reset = () => {
|
||||
setStartDate(label)
|
||||
setEndDate(label)
|
||||
}
|
||||
|
||||
const clearButtonDisabled = startDate === label && endDate === label
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Date of Course Credits"
|
||||
contextKey={name}
|
||||
footer={<ClearFilterButton disabled={clearButtonDisabled} onClick={reset} name={name} />}
|
||||
active={!clearButtonDisabled}
|
||||
name={name}
|
||||
>
|
||||
<div className="description-text">
|
||||
Include course credits from the selected date range only. Does not filter out students.
|
||||
</div>
|
||||
<div className="card-content" style={{ marginTop: '0.5rem' }}>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>Start Date:</label>
|
||||
<DateTime value={startDate} onChange={date => setStartDate(date)} />
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>End Date:</label>
|
||||
<DateTime value={endDate} onChange={date => setEndDate(date)} />
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</div>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import lodash from 'lodash'
|
||||
import { Form, Dropdown } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import FilterCard from './common/FilterCard'
|
||||
import ClearFilterButton from './common/ClearFilterButton'
|
||||
import useFilters from '../useFilters'
|
||||
import useAnalytics from '../useAnalytics'
|
||||
import { getTextIn } from '../../../common'
|
||||
|
||||
const EnrollmentStatus = ({ allSemesters, language }) => {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [semesters, setSemesters] = useState([])
|
||||
const { allStudents, addFilter, removeFilter } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
const name = 'enrollmentStatusFilter'
|
||||
const active = !!status && !!semesters.length
|
||||
|
||||
const statusOptions = [
|
||||
{ key: 'enrl-status-present', text: 'Present', value: 1 },
|
||||
{ key: 'enrl-status-absent', text: 'Absent', value: 2 },
|
||||
]
|
||||
|
||||
const semesterCodes = lodash(allStudents.map(student => student.semesterenrollments.map(enrl => enrl.semestercode)))
|
||||
.flatten()
|
||||
.uniq()
|
||||
.value()
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
addFilter(name, student =>
|
||||
semesters.every(sem => {
|
||||
const enrollment = student.semesterenrollments.find(enrl => enrl.semestercode === sem)
|
||||
// If enrollment info not found, return false. This may or may not be what we want?
|
||||
return enrollment ? enrollment.enrollmenttype === status : false
|
||||
})
|
||||
)
|
||||
analytics.setFilter(name, `${statusOptions[status]} (${semesters.map(sem => allSemesters[sem].en).join(', ')})`)
|
||||
} else {
|
||||
removeFilter(name)
|
||||
analytics.clearFilter(name)
|
||||
}
|
||||
}, [status, semesters])
|
||||
|
||||
if (!Object.keys(allSemesters).length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const semesterOptions = semesterCodes.map(code => ({
|
||||
key: `semester-option-${code}`,
|
||||
text: getTextIn(allSemesters[code].name, language),
|
||||
value: code,
|
||||
}))
|
||||
|
||||
const clear = () => {
|
||||
setStatus(null)
|
||||
setSemesters([])
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Enrollment Status"
|
||||
contextKey="enrollmentStatus"
|
||||
active={active}
|
||||
footer={<ClearFilterButton disabled={!status && !semesters.length} onClick={clear} name={name} />}
|
||||
name={name}
|
||||
>
|
||||
<div className="card-content">
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<Dropdown
|
||||
options={statusOptions}
|
||||
value={status}
|
||||
onChange={(_, { value }) => setStatus(value)}
|
||||
placeholder="Choose Enrollment Status"
|
||||
className="mini"
|
||||
selection
|
||||
fluid
|
||||
button
|
||||
clearable
|
||||
data-cy={`${name}-status`}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Dropdown
|
||||
multiple
|
||||
selection
|
||||
fluid
|
||||
options={semesterOptions}
|
||||
button
|
||||
className="mini"
|
||||
placeholder="Choose Semesters"
|
||||
onChange={(_, { value }) => setSemesters(value)}
|
||||
value={semesters}
|
||||
data-cy={`${name}-semesters`}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</div>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
|
||||
EnrollmentStatus.propTypes = {
|
||||
allSemesters: PropTypes.shape({}),
|
||||
language: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
EnrollmentStatus.defaultProps = {
|
||||
allSemesters: {},
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ semesters, settings }) => ({
|
||||
allSemesters: semesters.data.semesters,
|
||||
language: settings.language,
|
||||
})
|
||||
|
||||
export default withRouter(connect(mapStateToProps)(EnrollmentStatus))
|
||||
@@ -1,70 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Form, Dropdown } from 'semantic-ui-react'
|
||||
import ClearFilterButton from './common/ClearFilterButton'
|
||||
import FilterCard from './common/FilterCard'
|
||||
import useFilters from '../useFilters'
|
||||
import useAnalytics from '../useAnalytics'
|
||||
|
||||
export default () => {
|
||||
const { addFilter, removeFilter, withoutFilter, activeFilters } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
const [value, setValue] = useState(null)
|
||||
const name = 'genderFilter'
|
||||
|
||||
const genderCodes = {
|
||||
female: { label: 'Female', value: 2 },
|
||||
male: { label: 'Male', value: 1 },
|
||||
other: { label: 'Other', value: 3 },
|
||||
unknown: { label: 'Unknown', value: 0 },
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!Object.values(genderCodes)
|
||||
.map(gc => gc.value)
|
||||
.includes(value)
|
||||
) {
|
||||
removeFilter(name)
|
||||
analytics.clearFilter(name)
|
||||
} else {
|
||||
addFilter(name, student => value === Number(student.gender_code))
|
||||
analytics.setFilter(name, value)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const count = genderCode => withoutFilter(name).filter(student => Number(student.gender_code) === genderCode).length
|
||||
|
||||
const options = Object.entries(genderCodes).map(([key, gender]) => ({
|
||||
key,
|
||||
text: `${gender.label} (${count(gender.value)})`,
|
||||
value: gender.value,
|
||||
}))
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Gender"
|
||||
contextKey="genderFilter"
|
||||
footer={<ClearFilterButton disabled={!value} onClick={() => setValue(null)} name={name} />}
|
||||
active={Object.keys(activeFilters).includes(name)}
|
||||
name={name}
|
||||
>
|
||||
<div className="card-content">
|
||||
<Form>
|
||||
<Dropdown
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(_, { value: inputValue }) => setValue(inputValue)}
|
||||
placeholder="Choose Gender"
|
||||
className="mini"
|
||||
selection
|
||||
selectOnBlur={false}
|
||||
fluid
|
||||
button
|
||||
clearable
|
||||
data-cy={`${name}-dropdown`}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// FIXME: Remove when feature is ready.
|
||||
/* eslint-disable no-unused-vars */
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Form, Checkbox } from 'semantic-ui-react'
|
||||
import useFilters from '../../useFilters'
|
||||
import useAnalytics from '../../useAnalytics'
|
||||
import FilterCard from '../common/FilterCard'
|
||||
import ClearFilterButton from '../common/ClearFilterButton'
|
||||
import useGradeFilter from './useGradeFilter'
|
||||
|
||||
export const contextKey = 'gradeFilter'
|
||||
|
||||
/**
|
||||
* Grade filter.
|
||||
* Only applicable to a single course.
|
||||
*/
|
||||
export default () => {
|
||||
const { addFilter, removeFilter, activeFilters } = useFilters()
|
||||
const { value, setValue, grades } = useGradeFilter()
|
||||
const analytics = useAnalytics()
|
||||
const name = 'gradeFilter'
|
||||
const options = Object.keys(grades).sort((a, b) => b - a)
|
||||
|
||||
// Filter function update hook.
|
||||
useEffect(() => {
|
||||
if (value.length) {
|
||||
const studentNumbers = value.reduce((a, b) => a.concat(grades[b]), [])
|
||||
addFilter(name, student => studentNumbers.includes(student.studentNumber))
|
||||
} else {
|
||||
removeFilter(name)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const checked = grade => value.includes(grade)
|
||||
|
||||
const onChange = grade => () => {
|
||||
if (checked(grade)) {
|
||||
setValue(value.filter(val => val !== grade))
|
||||
} else {
|
||||
setValue(value.concat(grade))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Grade"
|
||||
contextKey={contextKey}
|
||||
footer={<ClearFilterButton disabled={!value.length} onClick={() => setValue([])} name={name} />}
|
||||
active={Object.keys(activeFilters).includes(name)}
|
||||
name={name}
|
||||
>
|
||||
<div className="card-content">
|
||||
<Form>
|
||||
{options.map(grade => (
|
||||
<Form.Field key={`${name}-${grade}`}>
|
||||
<Checkbox
|
||||
label={
|
||||
<label data-cy={`${name}-${grade}`}>
|
||||
{grade}
|
||||
<span className="filter-option-count">{`(${grades[grade].length} students)`}</span>
|
||||
</label>
|
||||
}
|
||||
checked={checked(grade)}
|
||||
onChange={onChange(grade)}
|
||||
/>
|
||||
</Form.Field>
|
||||
))}
|
||||
</Form>
|
||||
</div>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React, { createContext, useState, useContext } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const defaultState = {
|
||||
value: [],
|
||||
grades: {},
|
||||
}
|
||||
|
||||
const GradeFilterContext = createContext([[], () => {}])
|
||||
GradeFilterContext.displayName = 'Grade Filter'
|
||||
|
||||
export const GradeFilterProvider = ({ children }) => {
|
||||
const [state, setState] = useState(defaultState)
|
||||
return <GradeFilterContext.Provider value={[state, setState]}>{children}</GradeFilterContext.Provider>
|
||||
}
|
||||
|
||||
GradeFilterProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const [state, setState] = useContext(GradeFilterContext)
|
||||
|
||||
return {
|
||||
value: state.value,
|
||||
setValue: value => setState(prev => ({ ...prev, value })),
|
||||
grades: state.grades,
|
||||
setGrades: grades => setState(prev => ({ ...prev, grades })),
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Form, Radio } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import FilterCard from './common/FilterCard'
|
||||
import ClearFilterButton from './common/ClearFilterButton'
|
||||
import useFilters from '../useFilters'
|
||||
import useAnalytics from '../useAnalytics'
|
||||
|
||||
const GraduatedFromProgramme = ({ code }) => {
|
||||
const { addFilter, removeFilter } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
const [value, setValue] = useState(null)
|
||||
const name = 'graduatedFromProgrammeFilter'
|
||||
const active = value !== null
|
||||
// Old-style study programmes need separation between bachelor's and master's.
|
||||
const combinedExtent = !code.includes('_')
|
||||
|
||||
const graduated = studyrights =>
|
||||
studyrights.some(sr =>
|
||||
sr.studyright_elements.some(sre => {
|
||||
const dateMatch = new Date(sre.enddate) >= new Date(sr.enddate)
|
||||
return sre.code === code && dateMatch && sr.graduated
|
||||
})
|
||||
)
|
||||
|
||||
const graduatedWithExtent = (studyrights, extent) => graduated(studyrights.filter(sr => sr.extentcode === extent))
|
||||
|
||||
const filterFn = wanted => student =>
|
||||
combinedExtent && wanted > 0
|
||||
? graduatedWithExtent(student.studyrights, wanted)
|
||||
: graduated(student.studyrights) === !!wanted
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
addFilter(name, filterFn(value))
|
||||
analytics.setFilter(name, value)
|
||||
} else {
|
||||
removeFilter(name)
|
||||
analytics.clearFilter(name)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const options = [{ key: 'graduated-false', text: `Not Graduated`, value: 0 }].concat(
|
||||
combinedExtent
|
||||
? [
|
||||
{ key: 'graduated-bachelor', text: `Graduated with Bachelor's`, value: 1 },
|
||||
{ key: 'graduated-master', text: `Graduated with Master's`, value: 2 },
|
||||
]
|
||||
: [{ key: 'graduated-true', text: `Graduated`, value: 1 }]
|
||||
)
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Graduation Status"
|
||||
contextKey={name}
|
||||
active={active}
|
||||
footer={<ClearFilterButton disabled={!active} onClick={() => setValue(null)} name={name} />}
|
||||
name={name}
|
||||
>
|
||||
<Form>
|
||||
<div className="card-content">
|
||||
<Form.Field>
|
||||
<Radio
|
||||
label="All"
|
||||
checked={value === null}
|
||||
onChange={() => setValue(null)}
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
data-cy={`${name}-all`}
|
||||
/>
|
||||
{options.map(option => (
|
||||
<Radio
|
||||
key={option.key}
|
||||
label={option.text}
|
||||
name="radioGroup"
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
checked={value === option.value}
|
||||
onChange={() => setValue(option.value)}
|
||||
data-cy={`${name}-${option.key}`}
|
||||
/>
|
||||
))}
|
||||
</Form.Field>
|
||||
</div>
|
||||
</Form>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
|
||||
GraduatedFromProgramme.propTypes = {
|
||||
code: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations }) => ({
|
||||
code: populations.query ? populations.query.studyRights.programme : '',
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(GraduatedFromProgramme)
|
||||
@@ -1,60 +0,0 @@
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { Dropdown } from 'semantic-ui-react'
|
||||
import FilterCard from '../common/FilterCard'
|
||||
import { getTextIn } from '../../../../common'
|
||||
import useLanguage from '../../../LanguagePicker/useLanguage'
|
||||
import useProgrammeFilter from './useProgrammeFilter'
|
||||
import useFilters from '../../useFilters'
|
||||
|
||||
const Programme = () => {
|
||||
const { programmes, selectedProgrammes, studentToProgrammeMap, selectFilterProgramme, removeFilterProgramme } =
|
||||
useProgrammeFilter()
|
||||
const { removeFilter, addFilter } = useFilters()
|
||||
const { language } = useLanguage()
|
||||
const name = 'programmeFilter'
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
programmes
|
||||
.map(program => ({
|
||||
key: `programme-filter-value-${program.code}`,
|
||||
text: getTextIn(program.name, language),
|
||||
value: program.code,
|
||||
}))
|
||||
.sort((a, b) => a.text.localeCompare(b.text)),
|
||||
[programmes, language]
|
||||
)
|
||||
|
||||
const handleChange = (_, { value }) => {
|
||||
value.filter(code => selectedProgrammes.findIndex(p => p.code === code) === -1).forEach(selectFilterProgramme)
|
||||
|
||||
selectedProgrammes.filter(({ code }) => value.indexOf(code) === -1).forEach(removeFilterProgramme)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProgrammes.length === 0) {
|
||||
removeFilter(name)
|
||||
} else {
|
||||
addFilter(name, ({ studentNumber }) => {
|
||||
return selectedProgrammes.findIndex(p => p.code === studentToProgrammeMap[studentNumber]) !== -1
|
||||
})
|
||||
}
|
||||
}, [selectedProgrammes, studentToProgrammeMap])
|
||||
|
||||
return (
|
||||
<FilterCard title="Programme" active={selectedProgrammes.length > 0} className="programmes-filter" name={name}>
|
||||
<Dropdown
|
||||
options={options}
|
||||
placeholder="Select Programme"
|
||||
onChange={handleChange}
|
||||
button
|
||||
value={selectedProgrammes.map(p => p.code)}
|
||||
name={name}
|
||||
closeOnChange
|
||||
multiple
|
||||
/>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default Programme
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
import React, { useState, createContext, useContext, useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { getStudentToTargetCourseDateMap, getNewestProgramme } from '../../../../common'
|
||||
import useFilters from '../../useFilters'
|
||||
|
||||
const ProgrammeFilterContext = createContext([[], () => {}])
|
||||
ProgrammeFilterContext.displayName = 'Programme Filter'
|
||||
|
||||
const defaultState = {
|
||||
selectedProgrammes: [],
|
||||
}
|
||||
|
||||
export const ProgrammeFilterProvider = ({ children }) => {
|
||||
const [state, setState] = useState(defaultState)
|
||||
const [courseCodes, setCourseCodes] = useState([])
|
||||
|
||||
const { selectedProgrammes } = state
|
||||
|
||||
const setSelectedProgrammes = programmes => {
|
||||
setState({
|
||||
selectedProgrammes: programmes,
|
||||
})
|
||||
}
|
||||
|
||||
const context = {
|
||||
selectedProgrammes,
|
||||
setSelectedProgrammes,
|
||||
courseCodes,
|
||||
setCourseCodes,
|
||||
}
|
||||
|
||||
return <ProgrammeFilterContext.Provider value={context}>{children}</ProgrammeFilterContext.Provider>
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const { selectedProgrammes, courseCodes, setCourseCodes, setSelectedProgrammes } = useContext(ProgrammeFilterContext)
|
||||
|
||||
const { allStudents } = useFilters()
|
||||
|
||||
const studentToTargetCourseDateMap = useMemo(
|
||||
() => getStudentToTargetCourseDateMap(allStudents, courseCodes),
|
||||
[allStudents]
|
||||
)
|
||||
|
||||
const elementDetails = useSelector(state => state?.populations?.data?.elementdetails?.data)
|
||||
|
||||
const { programmes, studentToProgrammeMap } = useMemo(() => {
|
||||
if (!elementDetails) {
|
||||
return {
|
||||
programmes: [],
|
||||
studentToProgrammeMap: {},
|
||||
}
|
||||
}
|
||||
|
||||
const programmeMap = {}
|
||||
const studentToProgrammeMap = {}
|
||||
|
||||
allStudents
|
||||
.map(student => [
|
||||
student,
|
||||
getNewestProgramme(student.studyrights, student.studentNumber, studentToTargetCourseDateMap, elementDetails),
|
||||
])
|
||||
.forEach(([{ studentNumber }, programme]) => {
|
||||
programmeMap[programme.code] = programme
|
||||
studentToProgrammeMap[studentNumber] = programme.code
|
||||
})
|
||||
|
||||
return {
|
||||
programmes: Object.values(programmeMap),
|
||||
studentToProgrammeMap,
|
||||
}
|
||||
}, [allStudents, elementDetails])
|
||||
|
||||
const selectFilterProgramme = code => {
|
||||
const index = selectedProgrammes.findIndex(p => p.code === code)
|
||||
|
||||
if (index !== -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const programme = programmes.find(p => p.code === code)
|
||||
|
||||
if (programme === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedProgrammes([...selectedProgrammes, programme])
|
||||
}
|
||||
|
||||
const removeFilterProgramme = code => {
|
||||
const index = selectedProgrammes.findIndex(p => p.code === code)
|
||||
const newSelection = [...selectedProgrammes]
|
||||
newSelection.splice(index, 1)
|
||||
setSelectedProgrammes(newSelection)
|
||||
}
|
||||
|
||||
const toggleFilterProgramme = code => {
|
||||
const index = selectedProgrammes.findIndex(p => p.code === code)
|
||||
|
||||
if (index === -1) {
|
||||
const programme = programmes.find(p => p.code === code)
|
||||
setSelectedProgrammes([...selectedProgrammes, programme])
|
||||
} else {
|
||||
const newSelection = [...selectedProgrammes]
|
||||
newSelection.splice(index, 1)
|
||||
setSelectedProgrammes(newSelection)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
programmes,
|
||||
selectedProgrammes,
|
||||
toggleFilterProgramme,
|
||||
selectFilterProgramme,
|
||||
removeFilterProgramme,
|
||||
setCourseCodes,
|
||||
studentToProgrammeMap,
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Form, Dropdown } from 'semantic-ui-react'
|
||||
import ClearFilterButton from './common/ClearFilterButton'
|
||||
import FilterCard from './common/FilterCard'
|
||||
import useFilters from '../useFilters'
|
||||
import useAnalytics from '../useAnalytics'
|
||||
|
||||
export default () => {
|
||||
const { addFilter, removeFilter, withoutFilter, activeFilters } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
|
||||
const [value, setValue] = useState([])
|
||||
const name = 'startYearAtUni'
|
||||
const isActive = () => value.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive()) {
|
||||
removeFilter(name)
|
||||
analytics.clearFilter(name)
|
||||
} else {
|
||||
addFilter(name, student => value.some(year => year === new Date(student.started).getFullYear()))
|
||||
analytics.setFilter(name, value.join(', '))
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const countsByYear = {}
|
||||
withoutFilter(name).forEach(student => {
|
||||
const year = new Date(student.started).getFullYear()
|
||||
countsByYear[year] = countsByYear[year] ? countsByYear[year] + 1 : 1
|
||||
})
|
||||
|
||||
const options = Object.keys(countsByYear).map(year => ({
|
||||
key: `year-${year}`,
|
||||
text: `${year} (${countsByYear[year]})`,
|
||||
value: Number(year),
|
||||
}))
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Starting Year"
|
||||
contextKey="startYearFilter"
|
||||
footer={<ClearFilterButton disabled={!isActive()} onClick={() => setValue([])} name={name} />}
|
||||
active={Object.keys(activeFilters).includes(name)}
|
||||
name={name}
|
||||
>
|
||||
<div className="card-content">
|
||||
<Form>
|
||||
<Dropdown
|
||||
multiple
|
||||
selection
|
||||
fluid
|
||||
options={options}
|
||||
button
|
||||
className="mini"
|
||||
placeholder="Choose Years to Include"
|
||||
onChange={(_, { value: inputValue }) => setValue(inputValue)}
|
||||
value={value}
|
||||
data-cy={`${name}-dropdown`}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Form, Dropdown } from 'semantic-ui-react'
|
||||
|
||||
import ClearFilterButton from './common/ClearFilterButton'
|
||||
import FilterCard from './common/FilterCard'
|
||||
import useFilters from '../useFilters'
|
||||
import useAnalytics from '../useAnalytics'
|
||||
|
||||
export default () => {
|
||||
const { addFilter, removeFilter, withoutFilter, activeFilters } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
|
||||
const [value, setValue] = useState([])
|
||||
const name = 'tagsFilter'
|
||||
const isActive = () => value.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive()) {
|
||||
removeFilter(name)
|
||||
analytics.clearFilter(name)
|
||||
} else {
|
||||
addFilter(name, student =>
|
||||
value.some(tag => {
|
||||
const studentTags = student.tags ? student.tags.map(studentTag => studentTag.tag.tagname) : []
|
||||
return studentTags.includes(tag)
|
||||
})
|
||||
)
|
||||
analytics.setFilter(name, value.join(', '))
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const countsByTag = {}
|
||||
withoutFilter(name).forEach(student => {
|
||||
student.tags.forEach(tag => {
|
||||
const name = tag.tag.tagname
|
||||
countsByTag[name] = countsByTag[name] ? countsByTag[name] + 1 : 1
|
||||
})
|
||||
})
|
||||
|
||||
const options = Object.keys(countsByTag).map(tag => ({
|
||||
key: `tag-${tag}`,
|
||||
text: `${tag} (${countsByTag[tag]})`,
|
||||
value: tag,
|
||||
}))
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Tags"
|
||||
contextKey="tagsFilter"
|
||||
footer={<ClearFilterButton disabled={!isActive()} onClick={() => setValue([])} name={name} />}
|
||||
active={Object.keys(activeFilters).includes(name)}
|
||||
name={name}
|
||||
>
|
||||
<div className="card-content">
|
||||
<Form>
|
||||
<Dropdown
|
||||
multiple
|
||||
selection
|
||||
fluid
|
||||
options={options}
|
||||
button
|
||||
className="mini"
|
||||
placeholder="Choose Tags to include"
|
||||
onChange={(_, { value: inputValue }) => setValue(inputValue)}
|
||||
value={value}
|
||||
data-cy={`${name}-dropdown`}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Form, Radio } from 'semantic-ui-react'
|
||||
import FilterCard from './common/FilterCard'
|
||||
import ClearFilterButton from './common/ClearFilterButton'
|
||||
import useFilters from '../useFilters'
|
||||
import useAnalytics from '../useAnalytics'
|
||||
import { usePrevious } from '../../../common/hooks'
|
||||
|
||||
export default () => {
|
||||
const { addFilter, removeFilter, activeFilters } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
const [value, setValue] = useState(0)
|
||||
const name = 'transferredToProgrammeFilter'
|
||||
const active = value !== null
|
||||
|
||||
const filterIsActive = name in activeFilters
|
||||
const prevValue = usePrevious(filterIsActive)
|
||||
|
||||
const filterFn = wanted => student => student.transferredStudyright === wanted
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
addFilter(name, filterFn(!!value))
|
||||
analytics.setFilter(name, value)
|
||||
} else {
|
||||
removeFilter(name)
|
||||
analytics.clearFilter(name)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
// bit of a hack but basically to keep visual of the filter
|
||||
// up to date with the actual state of the filter when controlled
|
||||
// from outside. Better solution would be to treat context as one
|
||||
// source of truth for filters.
|
||||
if (prevValue && !filterIsActive) setValue(null)
|
||||
}, [filterIsActive])
|
||||
|
||||
const toggle = buttonValue => () => setValue(prev => (prev === buttonValue ? null : buttonValue))
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Transfer Status"
|
||||
contextKey={name}
|
||||
active={active}
|
||||
footer={<ClearFilterButton disabled={!active} onClick={() => setValue(null)} name={name} />}
|
||||
name={name}
|
||||
>
|
||||
<Form>
|
||||
<div className="card-content">
|
||||
<Form.Field>
|
||||
<Radio
|
||||
label="All"
|
||||
name="radioGroup"
|
||||
checked={value === null}
|
||||
onChange={toggle(null)}
|
||||
data-cy={`${name}-all`}
|
||||
/>
|
||||
<Radio
|
||||
label="Transferred"
|
||||
name="radioGroup"
|
||||
checked={value === 1}
|
||||
onChange={toggle(1)}
|
||||
data-cy={`${name}-have`}
|
||||
style={{ margin: '0.5rem 0' }}
|
||||
/>
|
||||
<Radio
|
||||
label="Not Transferred"
|
||||
name="radioGroup"
|
||||
checked={value === 0}
|
||||
onChange={toggle(0)}
|
||||
data-cy={`${name}-havenot`}
|
||||
/>
|
||||
</Form.Field>
|
||||
</div>
|
||||
</Form>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user