mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e5c7fca90 | ||
|
|
2522c2030f | ||
|
|
8ede378faf | ||
|
|
0f968d073f | ||
|
|
cb19e81709 | ||
|
|
937d607237 | ||
|
|
503e540814 | ||
|
|
e92e3ccc31 | ||
|
|
e749dcfde2 | ||
|
|
bf35389164 | ||
|
|
94544d90c2 | ||
|
|
486e36476c | ||
|
|
6b3dd8dd57 | ||
|
|
831343ee4c | ||
|
|
0919101419 | ||
|
|
3a9ac1fc6b | ||
|
|
7dcbce6c09 | ||
|
|
3fef2d1a74 | ||
|
|
93ae07261c | ||
|
|
4b764b11cc | ||
|
|
23aea9bb06 | ||
|
|
2845dd99ba | ||
|
|
328748dd00 | ||
|
|
78c89589cd | ||
|
|
6e16f0a2f4 | ||
|
|
0507f20c56 | ||
|
|
def60b1a64 | ||
|
|
46f5817d2f | ||
|
|
65880ec63a | ||
|
|
017a754af7 | ||
|
|
bc4c4ab02b | ||
|
|
a31284586b | ||
|
|
8e3141680a | ||
|
|
dfc9bd2d88 | ||
|
|
504aa731ba | ||
|
|
59eeea02e8 | ||
|
|
c0730b2b5f | ||
|
|
b33c3a3376 | ||
|
|
44dda86745 | ||
|
|
f85661e128 | ||
|
|
b8051a59f1 | ||
|
|
a87e7abc0c | ||
|
|
646abf09cb | ||
|
|
5384a29acc | ||
|
|
d06739d35f | ||
|
|
6cdff731c9 | ||
|
|
2515ed6ea5 | ||
|
|
1b49b46e07 | ||
|
|
af3e6b44e9 | ||
|
|
d013216b72 | ||
|
|
9dacec810b | ||
|
|
33fd9587dc | ||
|
|
2107cc72a8 | ||
|
|
9ba2fbf774 | ||
|
|
f5798f6971 | ||
|
|
8138971ae5 | ||
|
|
77b2f2a7fc | ||
|
|
c8caf84072 | ||
|
|
1942c87d1f | ||
|
|
78011eaef1 | ||
|
|
0d2cf58289 | ||
|
|
fbe21bed1b | ||
|
|
12c585acde | ||
|
|
e829c35396 | ||
|
|
d4371d2e09 | ||
|
|
090dfb7665 | ||
|
|
3cc06c5a7b | ||
|
|
62bfbd884e | ||
|
|
2b243a0178 | ||
|
|
69bf9b60f4 | ||
|
|
2392c36c98 | ||
|
|
601ea18df8 | ||
|
|
b51fea8ca9 | ||
|
|
929ed9d8e8 | ||
|
|
436ff94a06 | ||
|
|
4ce1f9a45e | ||
|
|
5dd3022006 | ||
|
|
7b5715fca8 | ||
|
|
5f0034c4bf | ||
|
|
83ac1f6ff9 | ||
|
|
09d7ca36e8 | ||
|
|
09c47f2d1e | ||
|
|
1ae263d5c6 | ||
|
|
43f34a417d | ||
|
|
90975a6ae9 | ||
|
|
fefb37e510 | ||
|
|
6edccdd1a8 | ||
|
|
ea21d8bfd9 | ||
|
|
c048e5c1a7 | ||
|
|
07b2a665bc | ||
|
|
b33181d8cc | ||
|
|
0539306034 | ||
|
|
54300120fc | ||
|
|
0385a00bc7 | ||
|
|
7126943ccc | ||
|
|
f4e82c7d1e | ||
|
|
b6c0dbfa50 | ||
|
|
a78a19d1ce | ||
|
|
330c7a1290 | ||
|
|
1c452d498d | ||
|
|
3d92df6316 | ||
|
|
a822a4a33b | ||
|
|
7cd202065c | ||
|
|
18b5699cca | ||
|
|
a44b02cc88 | ||
|
|
a139e99c01 | ||
|
|
40f68d2f43 | ||
|
|
73c97b925f | ||
|
|
3420d6fc09 | ||
|
|
a200d5103b | ||
|
|
2f2cef2560 | ||
|
|
7cbe19b041 | ||
|
|
648ec2e7fe | ||
|
|
7a6dcd2520 | ||
|
|
d6bde89de8 | ||
|
|
4ad257f227 | ||
|
|
d3bdb75156 | ||
|
|
1e0364c6ef | ||
|
|
b5cd123fbb | ||
|
|
a572d387dc | ||
|
|
37b76cfd73 | ||
|
|
2fc8ca6e78 | ||
|
|
59983149e5 | ||
|
|
2f2a0728f8 | ||
|
|
d61ca334e5 | ||
|
|
29283e06fc | ||
|
|
0185d496ed | ||
|
|
06435ab251 | ||
|
|
1c4ad598c2 | ||
|
|
4cae824f1b | ||
|
|
975dcae051 | ||
|
|
dc75fdf09e | ||
|
|
cc76f77831 | ||
|
|
ff0383881c | ||
|
|
3470ff1892 | ||
|
|
11e3cc6064 | ||
|
|
c3262ae30b | ||
|
|
89b503ce31 | ||
|
|
d12176d2bf | ||
|
|
a3777e8666 | ||
|
|
67ec3ae395 | ||
|
|
383dedec0c | ||
|
|
621f9ab851 | ||
|
|
925009b671 | ||
|
|
ad8ff6b38d | ||
|
|
c641df4a3e | ||
|
|
54152e0128 | ||
|
|
445d25a830 | ||
|
|
9b750ddba6 | ||
|
|
02c3b19128 | ||
|
|
e4219ac039 | ||
|
|
449a7aa6d4 | ||
|
|
97b4e3bcb6 | ||
|
|
c8ff5eb0ae | ||
|
|
8ce83243ee | ||
|
|
24c9032293 | ||
|
|
51430fdb73 | ||
|
|
3249cb0544 | ||
|
|
bd3a920a6d | ||
|
|
029ac4ce4b | ||
|
|
38d12d9724 | ||
|
|
3b12eceb70 | ||
|
|
07eda34bd6 | ||
|
|
f76ab7357f | ||
|
|
9fc5545aa9 | ||
|
|
8c539882ec | ||
|
|
0646de00bc | ||
|
|
814d20d915 | ||
|
|
bb21d7c789 | ||
|
|
b25c10095b | ||
|
|
a6ddb2ee57 | ||
|
|
816ae576d7 | ||
|
|
b4d38ad3a1 | ||
|
|
e56426f7d6 | ||
|
|
8eaaf78cd9 | ||
|
|
c1a7eae149 | ||
|
|
b1da93e508 | ||
|
|
e8f56e266f | ||
|
|
d7ebc4ebbe | ||
|
|
39cd9b6ab3 | ||
|
|
a315c0f37c | ||
|
|
96a99481f7 | ||
|
|
68f3145716 | ||
|
|
2d80613c0d | ||
|
|
f40c27d8db | ||
|
|
15fc168855 | ||
|
|
175732fff8 | ||
|
|
8a08ec16e5 | ||
|
|
e0f78b8b83 | ||
|
|
534f828679 | ||
|
|
343ba5d629 | ||
|
|
8cd4d9fd1f | ||
|
|
a3767cca58 | ||
|
|
5ab92540c0 | ||
|
|
3a0399f3cc | ||
|
|
774d21c4cf | ||
|
|
4e36236ffd | ||
|
|
8d39dbb067 | ||
|
|
c0d8f9bf22 | ||
|
|
2c3514f992 | ||
|
|
be05cb834d | ||
|
|
98c13d35a0 | ||
|
|
1ab1d61818 | ||
|
|
0f53b6337e | ||
|
|
e00762d595 | ||
|
|
97ead4877a | ||
|
|
a0cc9f9030 | ||
|
|
4676c0a8cb | ||
|
|
4b085ee560 | ||
|
|
2bf74aa5ef | ||
|
|
e5c8b1dec8 | ||
|
|
e2be82aa8f | ||
|
|
6e597525f5 | ||
|
|
dc049bfea6 | ||
|
|
13ee8e00e2 | ||
|
|
1519806e07 | ||
|
|
16f71a30e6 | ||
|
|
d92bdd64d8 | ||
|
|
a2204d1723 | ||
|
|
5e0666db64 | ||
|
|
c49145ba42 | ||
|
|
9bf058d256 | ||
|
|
d7194d3873 | ||
|
|
8b026fd215 | ||
|
|
e1de28cb56 | ||
|
|
60792a80d8 | ||
|
|
38d0b92bea | ||
|
|
682193c742 | ||
|
|
2a14bedb0e | ||
|
|
e7aa6fb439 | ||
|
|
425b86d5a0 | ||
|
|
d2e850ebf5 | ||
|
|
917cc29286 | ||
|
|
a4fb790f13 | ||
|
|
6c29f8abc1 | ||
|
|
bf19a78202 | ||
|
|
f4e6d93d7c | ||
|
|
12b9e62ddc | ||
|
|
9fd4c8d610 | ||
|
|
436e28464d | ||
|
|
cf19ddbb4e | ||
|
|
a94b3693e9 | ||
|
|
2f6ae7e88d | ||
|
|
b59a41a766 | ||
|
|
652c31d97c | ||
|
|
dacb82b610 | ||
|
|
822ce8af6c | ||
|
|
e7bea0302c | ||
|
|
58b1886857 | ||
|
|
27fcff3075 | ||
|
|
a6da50cf19 | ||
|
|
50d0586db4 | ||
|
|
147864affc | ||
|
|
82aea61f12 | ||
|
|
4516e3d328 | ||
|
|
2913e01cff | ||
|
|
5d1c9feb87 | ||
|
|
f64349e695 | ||
|
|
dad9ebcada | ||
|
|
f38cfac57f | ||
|
|
a30e374246 | ||
|
|
8036fdd9ac | ||
|
|
25af5d47b7 | ||
|
|
a2d3cf083f | ||
|
|
42de728ce5 | ||
|
|
253fe616ac | ||
|
|
f1995e84a3 | ||
|
|
422c16bfa5 | ||
|
|
dc9c10df32 | ||
|
|
d7d7295647 | ||
|
|
6e63ea3363 | ||
|
|
78a04b6a46 | ||
|
|
3f2c41c185 | ||
|
|
c0e7c03f19 | ||
|
|
c799b0edbc | ||
|
|
5c478c1d63 | ||
|
|
e3bf53bce3 | ||
|
|
f0cbd6611e | ||
|
|
e304c21523 | ||
|
|
4c284a4483 | ||
|
|
cd30cc7a3a | ||
|
|
f56e3aa45a | ||
|
|
467238e87a | ||
|
|
08156b5b90 | ||
|
|
76b7e97dd3 | ||
|
|
3b0a6d2235 | ||
|
|
069ebc3ce9 | ||
|
|
8157010959 | ||
|
|
6e66336841 | ||
|
|
1ff98c5efd | ||
|
|
ebffacafdc | ||
|
|
5d791dc7f1 | ||
|
|
17172faa40 | ||
|
|
dc43625df5 | ||
|
|
93edc01e8f | ||
|
|
2443b611f2 | ||
|
|
b8dcf7f1c4 | ||
|
|
a0c03be52d | ||
|
|
748c9d30f8 | ||
|
|
52424a4069 | ||
|
|
15d6938883 | ||
|
|
e7ff8952ad | ||
|
|
a2e0c00087 | ||
|
|
77e853c1a1 | ||
|
|
0c039d877a | ||
|
|
df68e87d29 | ||
|
|
8a7aaca325 | ||
|
|
259fa10e9c | ||
|
|
cb437c7559 | ||
|
|
82d92aa0b5 | ||
|
|
aed9029c7c | ||
|
|
724a749e2c | ||
|
|
092bc11265 | ||
|
|
371f8ba5ab | ||
|
|
a916711106 | ||
|
|
dc1502de8d | ||
|
|
c570986161 | ||
|
|
8ff11df102 | ||
|
|
5cd20a8b63 | ||
|
|
29c39c68ea | ||
|
|
1ff6b7b31f | ||
|
|
20ab0bbfc9 | ||
|
|
fc096f5cb9 | ||
|
|
04ff187b5b | ||
|
|
b4c3e45323 | ||
|
|
f1c8c339b4 | ||
|
|
4f5f98329b | ||
|
|
4df1853525 | ||
|
|
678a4320de | ||
|
|
aa493e9f31 | ||
|
|
c19f99953b | ||
|
|
510fd8e5a2 | ||
|
|
c3eeea14d9 | ||
|
|
e0521e89d2 | ||
|
|
1c6268307e | ||
|
|
e734dbf6e2 | ||
|
|
32dd714b98 | ||
|
|
97555865d9 | ||
|
|
23aabaf959 | ||
|
|
c3ae5bf15a | ||
|
|
6d79dddd4d | ||
|
|
2a00ce59ad | ||
|
|
1a3b924ac9 | ||
|
|
9ed155b42b | ||
|
|
09189e34be | ||
|
|
2a8103443d | ||
|
|
3f3f295376 | ||
|
|
d38f3774ca | ||
|
|
5abcac8dc2 | ||
|
|
cbeca7fbf5 | ||
|
|
c0fae224f0 | ||
|
|
04f46c1828 | ||
|
|
a021a92ecc | ||
|
|
20d7fc2607 | ||
|
|
e5b3f00cf5 | ||
|
|
1faf4c8c83 | ||
|
|
2ea11e4724 | ||
|
|
c72cfc0699 | ||
|
|
8b436c7442 | ||
|
|
563d3c9459 | ||
|
|
85fbb02338 | ||
|
|
ff6252b889 | ||
|
|
d741b4741e | ||
|
|
60d1bcbf8d | ||
|
|
c0f4abc5f5 | ||
|
|
29a0c40e01 | ||
|
|
c7c0241a78 | ||
|
|
b059a2f276 | ||
|
|
52b819424b | ||
|
|
b74cbd8789 | ||
|
|
5702f0e49c | ||
|
|
cf88efde90 | ||
|
|
b63b845b02 | ||
|
|
4858084f24 | ||
|
|
e21b956483 | ||
|
|
74c0b2486d | ||
|
|
cbd84c5ffd | ||
|
|
ce8e7def89 | ||
|
|
1c2f36b158 | ||
|
|
e1d54e4c63 | ||
|
|
a95f96b96d | ||
|
|
1c6d45bfdc | ||
|
|
7cbff2c1b6 | ||
|
|
88bc3667fc | ||
|
|
0682c89aac | ||
|
|
96ff6c190a | ||
|
|
9f82cda4bf | ||
|
|
24d9ea947e | ||
|
|
d4e020669b | ||
|
|
29e6264660 | ||
|
|
30cc569236 | ||
|
|
b17688a06e | ||
|
|
c5dff7ac63 | ||
|
|
af6771cbd6 | ||
|
|
78205fd1b2 | ||
|
|
5094fb3bee | ||
|
|
03d28b0e6f | ||
|
|
48f8b38ccf | ||
|
|
0031c5f7f5 | ||
|
|
e150bede1c | ||
|
|
e48a9f2ff0 | ||
|
|
95e98746e4 | ||
|
|
8e6b4b1d11 | ||
|
|
ee58112c97 | ||
|
|
75e7b65a93 | ||
|
|
fe261c288c | ||
|
|
02a3a8531d | ||
|
|
62d0a10a03 | ||
|
|
4213ada22d | ||
|
|
722bcfb6ee | ||
|
|
22af3a19eb | ||
|
|
9ea3ebd9e4 | ||
|
|
57a0794651 | ||
|
|
436c2cc0d7 | ||
|
|
d7dc2d8ab6 | ||
|
|
d8e3215cc8 | ||
|
|
1b038abbf5 | ||
|
|
0fcfb727c0 | ||
|
|
51e413a134 | ||
|
|
b20e8a6e10 | ||
|
|
08f42b50e2 | ||
|
|
0434243491 | ||
|
|
ece9fdaff1 | ||
|
|
6cdab87f0f | ||
|
|
e8bd4b9d2c | ||
|
|
2ec18fa14c | ||
|
|
2f39975efd | ||
|
|
f4bcfa0539 | ||
|
|
e61abe4525 | ||
|
|
1cd7bb149f | ||
|
|
873e9609dd | ||
|
|
eec2000eaa | ||
|
|
87607165ce | ||
|
|
22ec9e4c79 | ||
|
|
deeec81efd | ||
|
|
22129188a9 | ||
|
|
c261b148c5 | ||
|
|
b9690808d8 | ||
|
|
f982fcfbf9 | ||
|
|
8adf0109f1 | ||
|
|
64e7414c10 | ||
|
|
cd8cd4e7f0 | ||
|
|
187af16f85 | ||
|
|
a4d57e51ea | ||
|
|
1531d77d5c | ||
|
|
ae40e59fa9 | ||
|
|
d532788e6a | ||
|
|
c4ac1a04c3 | ||
|
|
34e76883de | ||
|
|
427f729675 | ||
|
|
0efdc9dd42 | ||
|
|
d086d8fb09 | ||
|
|
7d03b58c3f | ||
|
|
5629f3af38 | ||
|
|
fc1b9f110e | ||
|
|
d1c117da2b | ||
|
|
780d9f7e69 | ||
|
|
34c00de187 | ||
|
|
4f8b700a12 | ||
|
|
7ed20990b9 | ||
|
|
8ca3cb6ca0 | ||
|
|
514ac093be | ||
|
|
eae5efdb62 | ||
|
|
4716fc0dd6 | ||
|
|
28c0519ca3 | ||
|
|
283303386a | ||
|
|
eb6c9d087b | ||
|
|
27fdcadd3f | ||
|
|
64f1398f1a | ||
|
|
9968ef1685 | ||
|
|
5577f3ad0c | ||
|
|
7b377d90d8 | ||
|
|
3e8046efaf | ||
|
|
bdda5fb048 | ||
|
|
fab8a653dc | ||
|
|
1bb1824331 | ||
|
|
a91ca60fad | ||
|
|
4e625c6ba8 | ||
|
|
d440ef18ff | ||
|
|
80275f793a | ||
|
|
ae1846e65b | ||
|
|
dcba353b2d | ||
|
|
0e5e470bcb | ||
|
|
52ea016995 | ||
|
|
3329225b50 | ||
|
|
204657bd7c | ||
|
|
82d651d8b2 | ||
|
|
f05d46cbbc | ||
|
|
6a478b1d8c | ||
|
|
10f068666c | ||
|
|
49cfd61c16 | ||
|
|
e9f47563b0 | ||
|
|
7b38aad1d8 | ||
|
|
2a4f5fad27 | ||
|
|
03e97ceb9a | ||
|
|
054fad76d7 | ||
|
|
acdcf8ce1a | ||
|
|
71593d26af | ||
|
|
72aa373bb6 | ||
|
|
f5b525a382 | ||
|
|
5ea94908ca | ||
|
|
508c365d42 | ||
|
|
d41f97f43a | ||
|
|
765de6f9d8 | ||
|
|
8b494b79a9 | ||
|
|
1b8bfcaac9 | ||
|
|
86f4c46ace | ||
|
|
faa528d01f | ||
|
|
e8ec56db00 | ||
|
|
8cc4802ca1 | ||
|
|
2dc47d3f37 | ||
|
|
e08776fc07 | ||
|
|
67d7438e09 | ||
|
|
f2a7846c26 | ||
|
|
05ddbde60b | ||
|
|
2df28be735 | ||
|
|
ec97274ad1 | ||
|
|
0dffe56060 | ||
|
|
bd2128626a | ||
|
|
59b46cbcc3 | ||
|
|
3bece7392c | ||
|
|
109fbb0b40 | ||
|
|
4d986484a0 | ||
|
|
d19cfc1378 | ||
|
|
752f589b04 | ||
|
|
f74a9900f4 | ||
|
|
a5047170b6 | ||
|
|
f3d97ab3c4 | ||
|
|
281658fc1b | ||
|
|
6a7f279844 | ||
|
|
e1b5c50367 | ||
|
|
ebc450a97b | ||
|
|
657438d695 | ||
|
|
bb567047d1 | ||
|
|
71adbcf36a | ||
|
|
ab363a59e8 | ||
|
|
3985d906db | ||
|
|
e609d04e14 | ||
|
|
50b2270f26 | ||
|
|
ec9a001a3b | ||
|
|
85b18406c2 | ||
|
|
cc4bb03560 | ||
|
|
8415aa6904 | ||
|
|
5e3039fee7 | ||
|
|
df2e038b70 | ||
|
|
fc75025ebc | ||
|
|
4b700a5c32 | ||
|
|
e2bdc7695f | ||
|
|
bc8e7de98a | ||
|
|
c15a003fa8 | ||
|
|
505043f0bb | ||
|
|
5bd3a9e6fc | ||
|
|
5a306be4b4 | ||
|
|
830845395a | ||
|
|
163b0396b6 | ||
|
|
889e193b01 | ||
|
|
dc84cae141 | ||
|
|
23fe77c27c | ||
|
|
a22d04bd25 | ||
|
|
34484d9e3d | ||
|
|
c5253ef5fd | ||
|
|
1fe0c2142a | ||
|
|
2d8249f6e8 |
@@ -10,9 +10,9 @@
|
||||
"env": {
|
||||
"development": {
|
||||
"plugins": [
|
||||
"react-hot-loader/babel",
|
||||
"transform-object-rest-spread",
|
||||
"transform-class-properties"
|
||||
"transform-class-properties",
|
||||
"react-hot-loader/babel"
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
|
||||
+29
-1
@@ -3,4 +3,32 @@ node_js:
|
||||
- '8'
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
- node_modules
|
||||
|
||||
addons:
|
||||
chrome: stable
|
||||
|
||||
sudo: required
|
||||
before_install:
|
||||
- "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16"
|
||||
- gem install superbara
|
||||
before_script:
|
||||
- "export DISPLAY=:99.0"
|
||||
- "sh -e /etc/init.d/xvfb start"
|
||||
- sleep 3 # give xvfb some time to start
|
||||
|
||||
|
||||
services:
|
||||
- docker
|
||||
- redis-server
|
||||
|
||||
after_success:
|
||||
- "[[ $TRAVIS_BRANCH =~ (trunk) ]] && docker build -t toska/oodikone2-frontend:testing ."
|
||||
- "[[ $TRAVIS_BRANCH =~ (trunk) ]] && docker login -u $DOCKER_USER -p $DOCKER_PASS"
|
||||
- "[[ $TRAVIS_BRANCH =~ (trunk) ]] && docker push toska/oodikone2-frontend:testing"
|
||||
- "[[ $TRAVIS_BRANCH =~ (^master) ]] && docker build -t toska/oodikone2-frontend:staging ."
|
||||
- "[[ $TRAVIS_BRANCH =~ (^master) ]] && docker login -u $DOCKER_USER -p $DOCKER_PASS"
|
||||
- "[[ $TRAVIS_BRANCH =~ (^master) ]] && docker push toska/oodikone2-frontend:staging"
|
||||
- "[[ $TRAVIS_TAG =~ ([0-1].+) ]] && docker build -t toska/oodikone2-frontend:latest ."
|
||||
- "[[ $TRAVIS_TAG =~ ([0-1].+) ]] && docker login -u $DOCKER_USER -p $DOCKER_PASS"
|
||||
- "[[ $TRAVIS_TAG =~ ([0-1].+) ]] && docker push toska/oodikone2-frontend:latest"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Master:
|
||||
[](https://travis-ci.org/UniversityOfHelsinkiCS/oodikone2-frontend)
|
||||
|
||||
Trunk:
|
||||
Drunk:
|
||||
[](https://travis-ci.org/UniversityOfHelsinkiCS/oodikone2-frontend)
|
||||
|
||||
|
||||
|
||||
Generated
+86
-62
@@ -2921,12 +2921,48 @@
|
||||
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
|
||||
"dev": true
|
||||
},
|
||||
"component-clone": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/component-clone/-/component-clone-0.2.2.tgz",
|
||||
"integrity": "sha1-x/WXmCKID62M+wliuikYbQYe4E8=",
|
||||
"requires": {
|
||||
"component-type": "1.1.0"
|
||||
}
|
||||
},
|
||||
"component-emitter": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
|
||||
"integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=",
|
||||
"dev": true
|
||||
},
|
||||
"component-raf": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/component-raf/-/component-raf-1.2.0.tgz",
|
||||
"integrity": "sha1-srxy1D8bAU/eeks8RHx2S8c8y6o="
|
||||
},
|
||||
"component-tween": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/component-tween/-/component-tween-1.2.0.tgz",
|
||||
"integrity": "sha1-zDnOXbqwW1KCX0HRlHY4oLAbK4o=",
|
||||
"requires": {
|
||||
"component-clone": "0.2.2",
|
||||
"component-emitter": "1.2.0",
|
||||
"component-type": "1.1.0",
|
||||
"ease-component": "1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"component-emitter": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.0.tgz",
|
||||
"integrity": "sha1-zNETqGOI0GSC0D3j/H35hSa6jv4="
|
||||
}
|
||||
}
|
||||
},
|
||||
"component-type": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/component-type/-/component-type-1.1.0.tgz",
|
||||
"integrity": "sha1-lbZmqtU+XI0fK+E1xFtdSZGXwMU="
|
||||
},
|
||||
"compressible": {
|
||||
"version": "2.0.13",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.13.tgz",
|
||||
@@ -4024,6 +4060,11 @@
|
||||
"stream-shift": "1.0.0"
|
||||
}
|
||||
},
|
||||
"ease-component": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ease-component/-/ease-component-1.0.0.tgz",
|
||||
"integrity": "sha1-s3VybbC1sEWVt3RAOW/sfapdd8k="
|
||||
},
|
||||
"editions": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/editions/-/editions-1.3.4.tgz",
|
||||
@@ -4158,14 +4199,6 @@
|
||||
"is-arrayish": "0.2.1"
|
||||
}
|
||||
},
|
||||
"error-stack-parser": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-1.3.6.tgz",
|
||||
"integrity": "sha1-4Oc7k+QXE40c18C3RrGkoUhUwpI=",
|
||||
"requires": {
|
||||
"stackframe": "0.3.1"
|
||||
}
|
||||
},
|
||||
"es-abstract": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.10.0.tgz",
|
||||
@@ -4875,8 +4908,7 @@
|
||||
"fast-levenshtein": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
|
||||
"dev": true
|
||||
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc="
|
||||
},
|
||||
"fastparse": {
|
||||
"version": "1.1.1",
|
||||
@@ -11777,11 +11809,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-deep-force-update": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-deep-force-update/-/react-deep-force-update-2.1.1.tgz",
|
||||
"integrity": "sha1-jqQmPNZFWgULN0RbPwj9g52G6Qk="
|
||||
},
|
||||
"react-dev-utils": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-4.2.1.tgz",
|
||||
@@ -11834,17 +11861,30 @@
|
||||
}
|
||||
},
|
||||
"react-hot-loader": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-3.1.3.tgz",
|
||||
"integrity": "sha512-d7nZf78irxoGN5PY4zd6CSgZiroOhvIWzRast3qwTn4sSnBwlt08kV8WMQ9mitmxEdlCTwZt+5ClrRSjxWguMQ==",
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.3.4.tgz",
|
||||
"integrity": "sha512-LlKjtHq+RhDq9xm6crXojbkzrEvli5F4/RaeJ//XtDWrwwsAHDjEqKfZZiPCxv7gWV2cxE3YE8TXeE9BDzLqOA==",
|
||||
"requires": {
|
||||
"fast-levenshtein": "2.0.6",
|
||||
"global": "4.3.2",
|
||||
"react-deep-force-update": "2.1.1",
|
||||
"react-proxy": "3.0.0-alpha.1",
|
||||
"redbox-react": "1.5.0",
|
||||
"source-map": "0.6.1"
|
||||
"hoist-non-react-statics": "2.5.5",
|
||||
"prop-types": "15.6.1",
|
||||
"react-lifecycles-compat": "3.0.4",
|
||||
"shallowequal": "1.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"hoist-non-react-statics": {
|
||||
"version": "2.5.5",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
|
||||
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
||||
},
|
||||
"react-localize-redux": {
|
||||
"version": "2.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-localize-redux/-/react-localize-redux-2.16.0.tgz",
|
||||
@@ -11859,14 +11899,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.7.1.tgz",
|
||||
"integrity": "sha512-p84kBqGaMoa7VYT0vZ/aOYRfJB+gw34yjpda1Z5KeLflg70HipZOT+MXQenEhdkPAABuE2Astq4zEPdMqUQxcg=="
|
||||
},
|
||||
"react-proxy": {
|
||||
"version": "3.0.0-alpha.1",
|
||||
"resolved": "https://registry.npmjs.org/react-proxy/-/react-proxy-3.0.0-alpha.1.tgz",
|
||||
"integrity": "sha1-RABCa8+oDKpnJMd1VpUxUgn6Swc=",
|
||||
"requires": {
|
||||
"lodash": "4.17.5"
|
||||
}
|
||||
},
|
||||
"react-redux": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.7.tgz",
|
||||
@@ -11927,6 +11959,14 @@
|
||||
"warning": "3.0.0"
|
||||
}
|
||||
},
|
||||
"react-scroll-to-component": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-scroll-to-component/-/react-scroll-to-component-1.0.2.tgz",
|
||||
"integrity": "sha1-8mDck2xipT53J4bXgy/giE4ZU1Q=",
|
||||
"requires": {
|
||||
"scroll-to": "0.0.2"
|
||||
}
|
||||
},
|
||||
"react-smooth": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-1.0.0.tgz",
|
||||
@@ -12116,17 +12156,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"redbox-react": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/redbox-react/-/redbox-react-1.5.0.tgz",
|
||||
"integrity": "sha512-mdxArOI3sF8K5Nay5NG+lv/VW516TbXjjd4h1wcV1Iy4IMDQPnCayjoQXBAycAFSME4nyXRUXCjHxsw2rYpVRw==",
|
||||
"requires": {
|
||||
"error-stack-parser": "1.3.6",
|
||||
"object-assign": "4.1.1",
|
||||
"prop-types": "15.6.1",
|
||||
"sourcemapped-stacktrace": "1.1.8"
|
||||
}
|
||||
},
|
||||
"redent": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz",
|
||||
@@ -12648,6 +12677,15 @@
|
||||
"integrity": "sha1-o0a7Gs1CB65wvXwMfKnlZra63bg=",
|
||||
"dev": true
|
||||
},
|
||||
"scroll-to": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/scroll-to/-/scroll-to-0.0.2.tgz",
|
||||
"integrity": "sha1-k205ipEzZgokkhRcLACB38sHKPM=",
|
||||
"requires": {
|
||||
"component-raf": "1.2.0",
|
||||
"component-tween": "1.2.0"
|
||||
}
|
||||
},
|
||||
"select-hose": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||
@@ -12814,6 +12852,11 @@
|
||||
"safe-buffer": "5.1.1"
|
||||
}
|
||||
},
|
||||
"shallowequal": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
|
||||
},
|
||||
"shebang-command": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
|
||||
@@ -13085,7 +13128,8 @@
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true
|
||||
},
|
||||
"source-map-resolve": {
|
||||
"version": "0.5.1",
|
||||
@@ -13123,21 +13167,6 @@
|
||||
"integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=",
|
||||
"dev": true
|
||||
},
|
||||
"sourcemapped-stacktrace": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/sourcemapped-stacktrace/-/sourcemapped-stacktrace-1.1.8.tgz",
|
||||
"integrity": "sha512-OkVoI7GQOLl/laR1qsSo1c87tS8kF2VXhQq2SrQCDdXufBAcm8FgXogWso96ciMYoDtTw1Dn70CVdwYzoYs6Pg==",
|
||||
"requires": {
|
||||
"source-map": "0.5.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"source-map": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
|
||||
"integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI="
|
||||
}
|
||||
}
|
||||
},
|
||||
"spawn-command": {
|
||||
"version": "0.0.2-1",
|
||||
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz",
|
||||
@@ -13224,11 +13253,6 @@
|
||||
"safe-buffer": "5.1.1"
|
||||
}
|
||||
},
|
||||
"stackframe": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-0.3.1.tgz",
|
||||
"integrity": "sha1-M6qE8Rd6VUjIk1Uzy/6zQgl19aQ="
|
||||
},
|
||||
"state-toggle": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.0.tgz",
|
||||
|
||||
+3
-2
@@ -16,7 +16,7 @@
|
||||
"stats": "webpack --json --config webpack.config.production.js > stats.json",
|
||||
"prepush": "npm run lint",
|
||||
"pretest": "npm run lint",
|
||||
"test": "echo 'no tests'"
|
||||
"test": ""
|
||||
},
|
||||
"licence": "",
|
||||
"main": "src/app.jsx",
|
||||
@@ -81,11 +81,12 @@
|
||||
"react-datetime": "^2.14.0",
|
||||
"react-dom": "^16.4.0",
|
||||
"react-highcharts": "^16.0.2",
|
||||
"react-hot-loader": "^3.1.3",
|
||||
"react-hot-loader": "^4.3.4",
|
||||
"react-localize-redux": "^2.16.0",
|
||||
"react-redux": "^5.0.7",
|
||||
"react-router": "^4.2.0",
|
||||
"react-router-dom": "^4.2.2",
|
||||
"react-scroll-to-component": "^1.0.2",
|
||||
"recharts": "^1.0.0-beta.10",
|
||||
"redux": "^3.7.2",
|
||||
"redux-logger": "^3.0.6",
|
||||
|
||||
+47
-12
@@ -1,20 +1,48 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { getToken, setToken } from '../common'
|
||||
import { API_BASE_PATH, TOKEN_NAME } from '../constants'
|
||||
import { API_BASE_PATH, TOKEN_NAME, BASE_PATH } from '../constants'
|
||||
|
||||
const getAxios = () => axios.create({ baseURL: API_BASE_PATH })
|
||||
const isTestEnv = BASE_PATH === '/testing/'
|
||||
const isDevEnv = process.env.NODE_ENV === 'development'
|
||||
const devOptions = {
|
||||
headers: {
|
||||
uid: 'tktl',
|
||||
displayName: 'Development Käyttäjä',
|
||||
displayName: 'Development Kayttaja',
|
||||
'shib-session-id': 'mock-session'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const testOptions = {
|
||||
headers: {
|
||||
uid: 'tester',
|
||||
displayName: 'Testing Käyttäjä',
|
||||
'shib-session-id': 'mock-session'
|
||||
}
|
||||
}
|
||||
|
||||
const types = {
|
||||
attempt: prefix => `${prefix}ATTEMPT`,
|
||||
failure: prefix => `${prefix}FAILURE`,
|
||||
success: prefix => `${prefix}SUCCESS`
|
||||
}
|
||||
|
||||
export const actionTypes = prefix => ({
|
||||
attempt: types.attempt(prefix),
|
||||
failure: types.failure(prefix),
|
||||
success: types.success(prefix)
|
||||
})
|
||||
|
||||
export const login = async () => {
|
||||
const options = isDevEnv ? devOptions : null
|
||||
let options = null
|
||||
if (isDevEnv) {
|
||||
options = devOptions
|
||||
}
|
||||
if (isTestEnv) {
|
||||
options = testOptions
|
||||
}
|
||||
const response = await getAxios().post('/login', null, options)
|
||||
return response.data.token
|
||||
}
|
||||
@@ -25,32 +53,39 @@ export const swapDevUser = async (newHeaders) => {
|
||||
setToken(token)
|
||||
}
|
||||
|
||||
const callApi = async (url, method = 'get', data) => {
|
||||
const options = isDevEnv ? devOptions : { headers: {} }
|
||||
export const callApi = async (url, method = 'get', data, params) => {
|
||||
let options = { headers: {} }
|
||||
if (isDevEnv) {
|
||||
options = devOptions
|
||||
}
|
||||
if (isTestEnv) {
|
||||
options = testOptions
|
||||
}
|
||||
const token = await getToken()
|
||||
options.headers['x-access-token'] = token
|
||||
|
||||
switch (method) {
|
||||
case 'get':
|
||||
return getAxios().get(url, options)
|
||||
return getAxios().get(url, { ...options, params })
|
||||
case 'post':
|
||||
return getAxios().post(url, data, options)
|
||||
case 'put':
|
||||
return getAxios().put(url, data, options)
|
||||
case 'delete':
|
||||
return getAxios().delete(url, options)
|
||||
return getAxios().delete(url, { headers: options.headers, data })
|
||||
default:
|
||||
return Promise.reject(new Error('Invalid http method'))
|
||||
}
|
||||
}
|
||||
|
||||
export const callController = (route, prefix, data, method = 'get', query) => {
|
||||
export const callController = (route, prefix, data, method = 'get', query, params) => {
|
||||
const requestSettings = {
|
||||
route,
|
||||
method,
|
||||
data,
|
||||
prefix,
|
||||
query
|
||||
query,
|
||||
params
|
||||
}
|
||||
return { type: `${prefix}ATTEMPT`, requestSettings }
|
||||
}
|
||||
@@ -60,16 +95,16 @@ export const handleRequest = store => next => async (action) => {
|
||||
const { requestSettings } = action
|
||||
if (requestSettings) {
|
||||
const {
|
||||
route, method, data, prefix, query
|
||||
route, method, data, prefix, query, params
|
||||
} = requestSettings
|
||||
try {
|
||||
const res = await callApi(route, method, data)
|
||||
const res = await callApi(route, method, data, params)
|
||||
store.dispatch({ type: `${prefix}SUCCESS`, response: res.data, query })
|
||||
} catch (e) {
|
||||
// Something failed. Assume it's the token and try again.
|
||||
try {
|
||||
await getToken(true)
|
||||
const res = await callApi(route, method, data)
|
||||
const res = await callApi(route, method, data, params)
|
||||
store.dispatch({ type: `${prefix}SUCCESS`, response: res.data, query })
|
||||
} catch (err) {
|
||||
store.dispatch({ type: `${prefix}FAILURE`, response: err, query })
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import { handleRequest } from './apiConnection'
|
||||
import Main from './components/Main'
|
||||
|
||||
try {
|
||||
Raven.config('http://02d07bd40f404cc0965f38f06183d9fb@toska.cs.helsinki.fi:8500/3').install() // eslint-disable-line
|
||||
Raven.config('https://02d07bd40f404cc0965f38f06183d9fb@toska.cs.helsinki.fi/3').install() // eslint-disable-line
|
||||
} catch (e) { } // eslint-disable-line
|
||||
|
||||
const translations = require('./i18n/translations.json')
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 344 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.89 425.28"><defs><style>.cls-1{fill:#aa4137;}.cls-2{fill:#ea9939;}</style></defs><title>toscalogo_color</title><path class="cls-1" d="M627.67,180.6a120.22,120.22,0,0,1,43.17-25.13c16.28-5.49,31.91-7.19,46.87-7.19q41.88,0,66.82,19.7A57.53,57.53,0,0,1,796,180.23V48H542.15V259Z"/><polygon class="cls-1" points="95.75 48.22 95.75 47.96 45.89 47.96 45.89 148.57 95.75 148.57 95.75 48.22"/><path class="cls-1" d="M125.67,148.57H281.91q26.41,0,48.61,9a114.28,114.28,0,0,1,38.4,25.18,111.19,111.19,0,0,1,9.82,11.44,50.92,50.92,0,0,1,3.82-9,64.26,64.26,0,0,1,18.2-20.7,78.87,78.87,0,0,1,25.18-12.21,102.94,102.94,0,0,1,28.42-4q28.42,0,49.13,10.47a48.64,48.64,0,0,1,8.75,5.76V48H125.67V148.57Z"/><path class="cls-2" d="M378.75,345.32a107.41,107.41,0,0,1-38.17,32h62.78C391.81,370.34,382.4,359.87,378.75,345.32Z"/><path class="cls-2" d="M635.45,365.47h0L542.15,263v56l0,.05c0,.11,0,.21,0,.32q0,17.94-6.74,31.16A64.36,64.36,0,0,1,517,372.68a79.42,79.42,0,0,1-6.82,4.64H649.59c-1.24-.78-2.5-1.54-3.69-2.39A62.55,62.55,0,0,1,635.45,365.47Z"/><path class="cls-2" d="M169.7,318.37q-9-22.19-9-48.62t9-48.62a116.3,116.3,0,0,1,24.94-38.4,104.39,104.39,0,0,1,7.93-7.23h-76.9V331.29q0,15.46,8.48,23.94c5.65,5.66,13.46,8.77,23.43,8.77h45a104.25,104.25,0,0,1-7.92-7.23A116.09,116.09,0,0,1,169.7,318.37Z"/><path class="cls-2" d="M348.72,203.18A87.93,87.93,0,0,0,319.81,183q-17-7.48-37.9-7.47T244,183a87.77,87.77,0,0,0-28.92,20.2,85.31,85.31,0,0,0-18.2,29.92,110.75,110.75,0,0,0,0,73.3,85.26,85.26,0,0,0,18.2,29.93A87.89,87.89,0,0,0,244,356.52q17,7.47,37.9,7.48t37.9-7.48a88.05,88.05,0,0,0,28.91-20.19,85.58,85.58,0,0,0,18.21-29.93,110.92,110.92,0,0,0,0-73.3A85.63,85.63,0,0,0,348.72,203.18Z"/><path class="cls-2" d="M99,354.48a79.45,79.45,0,0,1-3.25-23.19V175.5H45.89V309.77a67.54,67.54,0,0,0,67.54,67.55h0a41.19,41.19,0,0,1-4.21-4.14A49.71,49.71,0,0,1,99,354.48Z"/><path class="cls-2" d="M713,271.45a128.4,128.4,0,0,0-31.91,8.48q-14.2,6-22.69,16t-8.48,24.93a36.7,36.7,0,0,0,4.73,18.69A41.51,41.51,0,0,0,666.85,353a57.24,57.24,0,0,0,16.7,8,65.22,65.22,0,0,0,18.7,2.74q23.94,0,39.4-7t24.68-18.2a63.62,63.62,0,0,0,12.72-25.44,120.09,120.09,0,0,0,3.48-28.67V269H749.62A262.81,262.81,0,0,0,713,271.45Z"/><path class="cls-2" d="M476.81,284.91a303.81,303.81,0,0,0-32.91-7.72,141.27,141.27,0,0,1-32.92-10,78.92,78.92,0,0,1-8.2-4.36c.11,2.29.3,4.55.3,6.9A133.4,133.4,0,0,1,398,307c-7.54,24,10.15,40.46,22.43,46.94a82.32,82.32,0,0,0,38.9,9.72,62.51,62.51,0,0,0,18.95-3,58.11,58.11,0,0,0,17-8.47A46.14,46.14,0,0,0,507.47,339a33.16,33.16,0,0,0,4.74-17.71q0-15.94-10.22-23.93A76.17,76.17,0,0,0,476.81,284.91Z"/><path class="cls-2" d="M764.08,188.17q-18.44-12.94-46.37-13c-26.6,0-52.83,10.46-77.43,32.26L581.05,260l41.17,43.59c2.13-9,5.73-16.85,11.22-23.14a83.8,83.8,0,0,1,33.16-23.19,157.29,157.29,0,0,1,41.89-10Q730.66,245,747.63,245h34.9v-14Q782.53,201.16,764.08,188.17Z"/><path class="cls-2" d="M488,181.66c-9.33-3.87-16-6.45-33.64-6.45a60.78,60.78,0,0,0-16.7,2.49,59,59,0,0,0-15.71,7,40.61,40.61,0,0,0-11.71,11.47,27.68,27.68,0,0,0-4.74,15.95q0,15.48,10.22,23t25.18,11.72A306.07,306.07,0,0,0,473.81,254a133,133,0,0,1,32.92,9.72c1.91.86,3.71,1.86,5.51,2.87V210.9C512.24,195.71,494.06,184.18,488,181.66Z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
+54
-6
@@ -1,12 +1,19 @@
|
||||
import moment from 'moment'
|
||||
import jwtDecode from 'jwt-decode'
|
||||
import Datetime from 'react-datetime'
|
||||
import _ from 'lodash'
|
||||
import { API_DATE_FORMAT, DISPLAY_DATE_FORMAT, TOKEN_NAME } from '../constants'
|
||||
|
||||
import toskaLogo from '../assets/toska.png'
|
||||
import irtomikko from '../assets/irtomikko.png'
|
||||
import { sendLog, login } from '../apiConnection'
|
||||
|
||||
export const setToken = token => localStorage.setItem(TOKEN_NAME, token)
|
||||
|
||||
export const textAndDescriptionSearch = (dropDownOptions, param) =>
|
||||
_.filter(dropDownOptions, option =>
|
||||
option.text.toLowerCase().concat(option.description.toLowerCase())
|
||||
.includes(param.toLowerCase()))
|
||||
|
||||
export const decodeToken = (token) => {
|
||||
try {
|
||||
return jwtDecode(token)
|
||||
@@ -14,7 +21,10 @@ export const decodeToken = (token) => {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export const images = {
|
||||
toskaLogo,
|
||||
irtomikko
|
||||
}
|
||||
export const tokenAccessInvalid = (token) => {
|
||||
const decodedToken = decodeToken(token)
|
||||
// Expired
|
||||
@@ -43,6 +53,28 @@ export const userIsAdmin = async () => {
|
||||
const token = await getToken()
|
||||
return token ? decodeToken(token).admin : false
|
||||
}
|
||||
export const userIsCzar = async () => {
|
||||
const token = await getToken()
|
||||
return token ? decodeToken(token).czar : false
|
||||
}
|
||||
export const getUserName = async () => {
|
||||
const token = await getToken()
|
||||
return token ? decodeToken(token).userId : false
|
||||
}
|
||||
|
||||
export const setUserLanguage = (language) => {
|
||||
localStorage.setItem('language', language)
|
||||
}
|
||||
|
||||
export const getUserLanguage = async () => {
|
||||
let lang = localStorage.getItem('language')
|
||||
if (!lang) {
|
||||
const token = await getToken()
|
||||
lang = decodeToken(token).language
|
||||
setUserLanguage(lang)
|
||||
}
|
||||
return lang
|
||||
}
|
||||
|
||||
export const userIsEnabled = async () => {
|
||||
const token = await getToken()
|
||||
@@ -53,7 +85,7 @@ export const containsOnlyNumbers = str => str.match('^\\d+$')
|
||||
|
||||
export const momentFromFormat = (date, format) => moment(date, format)
|
||||
|
||||
export const reformatDate = (date, outputFormat) => moment(date).format(outputFormat)
|
||||
export const reformatDate = (date, outputFormat) => (!date ? 'Unavailable' : moment(date).format(outputFormat))
|
||||
|
||||
export const isInDateFormat = (date, format) => moment(date, format, true).isValid()
|
||||
export const isValidYear = year => (year.isSameOrBefore(Datetime.moment(), 'year')
|
||||
@@ -70,12 +102,15 @@ export const byName = (a, b) => a.name.localeCompare(b.name)
|
||||
|
||||
export const byCodeDesc = (a, b) => b.code.localeCompare(a.code)
|
||||
|
||||
export const studyRightRegex = new RegExp(/.*master|bachelor|doctor|licentiate|specialist.*/)
|
||||
|
||||
export const studyrightTypes = { degree: '10', programme: '20', speciality: '30' } // speciality???
|
||||
/* This should be done in backend */
|
||||
export const removeInvalidCreditsFromStudent = student => ({
|
||||
...student,
|
||||
courses: student.courses.map((course) => {
|
||||
if (course.credits >= 25) {
|
||||
course.credits = 0
|
||||
if (course.credits) {
|
||||
course.credits = course.credits // DOES NOTHING NOW, TOO AFRAID TO REMOVE
|
||||
}
|
||||
return course
|
||||
})
|
||||
@@ -84,7 +119,11 @@ export const removeInvalidCreditsFromStudent = student => ({
|
||||
export const removeInvalidCreditsFromStudents = students =>
|
||||
students.map(student => removeInvalidCreditsFromStudent(student))
|
||||
|
||||
export const getStudentTotalCredits = student => student.courses.reduce((a, b) => a + b.credits, 0)
|
||||
export const getStudentTotalCredits = student => student.courses
|
||||
.filter(c => c.passed && !c.isStudyModuleCredit).reduce((a, b) => a + b.credits, 0)
|
||||
|
||||
export const getTotalCreditsFromCourses = courses =>
|
||||
courses.filter(c => c.passed && !c.isStudyModuleCredit).reduce((a, b) => a + b.credits, 0)
|
||||
|
||||
export const log = async (msg, meta) => {
|
||||
const token = await getToken()
|
||||
@@ -92,3 +131,12 @@ export const log = async (msg, meta) => {
|
||||
const combinedMeta = { ...decoded, ...meta }
|
||||
sendLog({ message: msg, full_message: combinedMeta })
|
||||
}
|
||||
|
||||
export const copyToClipboard = (text) => {
|
||||
const textField = document.createElement('textarea')
|
||||
textField.innerText = text
|
||||
document.body.appendChild(textField)
|
||||
textField.select()
|
||||
document.execCommand('copy')
|
||||
textField.remove()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const infoTooltips = {
|
||||
PopulationStatistics: {
|
||||
Filters: {
|
||||
CreditsAtLeast: { en: 'Removes students that do not have at least given amount of credits' },
|
||||
CreditsLessThan: { en: 'Removes students that have more than the given amount of credits' },
|
||||
StartingThisSemester: { en: 'Switch the toggle to choose a filter that removes students that have studied before this population enrollment date or vice versa' },
|
||||
EnrollmentStatus: { en: 'Show students that we\'re present or absent during the chosen semesters' },
|
||||
CanceledStudyright: { en: 'Show students that officially canceled their studyright to the queried program' },
|
||||
DisciplineTypes: { en: 'Automatically gives course participation filters for the given course types of the given discipline. You can set a limit for participations to bring up only the popular courses.' },
|
||||
ExtentGraduated: { en: 'Build a filter to students that have or have not studied or graduated a given extent of studies. For example here you can show students that have graduated from a bachelor\'s degree' },
|
||||
TransferFilter: { en: 'You can choose students that transfered from a given programme to another. Transfer means a student that changed their program during his studyright (not a student getting another studyright at some other institution)' }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
export default infoTooltips
|
||||
@@ -1,7 +1,11 @@
|
||||
import React from 'react'
|
||||
import { Dimmer, Header, Icon, Container, Button } from 'semantic-ui-react'
|
||||
import { Dimmer, Header, Image, Container, Button } from 'semantic-ui-react'
|
||||
import { bool } from 'prop-types'
|
||||
import Highcharts from 'highcharts'
|
||||
import ReactHighchart from 'react-highcharts'
|
||||
import { logout } from '../../apiConnection'
|
||||
import { images } from '../../common'
|
||||
|
||||
|
||||
import MulticolorBarChart from '../MulticolorBarChart'
|
||||
|
||||
@@ -13,25 +17,73 @@ const dummyData = [
|
||||
]
|
||||
|
||||
const AccessDenied = ({ itWasError }) => {
|
||||
const header = itWasError ? 'Something broke' : 'You do not have access to Oodikone'
|
||||
const header = itWasError ? 'Something broke' : 'Welcome to Oodikone!'
|
||||
const guide = 'try refreshing your browser window, pressing log out or contacting grp-toska@helsinki.fi'
|
||||
const subheader = itWasError ? `If this was not intended ${guide}` : `If you should have access ${guide}`
|
||||
const subheader = itWasError ? `If this was not intended ${guide}` : 'You\'re currently not allowed to enter but you will get an email when you\'re authorized'
|
||||
|
||||
return (
|
||||
<div >
|
||||
<Container style={{ margin: '5%' }}>
|
||||
<MulticolorBarChart chartTitle="Past" chartData={dummyData} />
|
||||
|
||||
<ReactHighchart
|
||||
highcharts={Highcharts}
|
||||
constructorType="stockChart"
|
||||
config={{
|
||||
title: {
|
||||
text: 'Students of Computer Science 2018-2019'
|
||||
},
|
||||
|
||||
yAxis: {
|
||||
title: {
|
||||
text: 'Cumulative credits'
|
||||
}
|
||||
},
|
||||
|
||||
plotOptions: {
|
||||
series: {
|
||||
label: {
|
||||
connectorAllowed: false
|
||||
},
|
||||
pointStart: 2010
|
||||
}
|
||||
},
|
||||
|
||||
series: [{
|
||||
name: 'mluukkai',
|
||||
data: [18937, 29057, 33213, 40949, 55880, 60421, 77543, 87691]
|
||||
}, {
|
||||
name: 'jakousa',
|
||||
data: [15411, 17960, 34037, 58382, 56367, 63103, 79570, 83898]
|
||||
}, {
|
||||
name: 'totutotu',
|
||||
data: [12482, 26592, 32348, 69576, 78155, 80379, 83568, 83165]
|
||||
}, {
|
||||
name: 'sasumaki',
|
||||
data: [17358, 24823, 36578, 47617, 59341, 68391, 72326, 96022]
|
||||
}, {
|
||||
name: 'ikuisma',
|
||||
data: [8536, 21650, 35013, 45562, 65750, 68431, 74402, 83202]
|
||||
},
|
||||
{
|
||||
name: 'eero3',
|
||||
data: [18855, 24929, 38722, 45049, 51706, 68569, 66225, 72269]
|
||||
},
|
||||
{
|
||||
name: 'mitiaine',
|
||||
data: [500, 1000, 20000, 56000, 59000, 65425, 69800, 80000]
|
||||
}]
|
||||
}}
|
||||
/>
|
||||
|
||||
<MulticolorBarChart chartTitle="Your students future" chartData={dummyData.concat(dummyData)} />
|
||||
</Container>
|
||||
<Dimmer
|
||||
active
|
||||
page
|
||||
>
|
||||
<Header as="h2" icon inverted>
|
||||
<Icon name="heart" />
|
||||
Sorry
|
||||
<Header.Subheader>{header}</Header.Subheader>
|
||||
<br />
|
||||
<Image src={images.toskaLogo} size="medium" centered style={{ paddingTop: '2%' }} />
|
||||
<Header as="h2" inverted>
|
||||
<p>{header}</p>
|
||||
<Header.Subheader>{subheader}</Header.Subheader>
|
||||
<br />
|
||||
<Button onClick={logout} color="pink"> Log out </Button>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Input } from 'semantic-ui-react'
|
||||
import { func, string, number } from 'prop-types'
|
||||
import Timeout from '../Timeout'
|
||||
|
||||
const TIMEOUTS = {
|
||||
FETCH: 'fetch',
|
||||
SEARCH: 'search'
|
||||
}
|
||||
|
||||
class AutoSubmitSearchInput extends Component {
|
||||
state = {
|
||||
searchterm: '',
|
||||
loading: false
|
||||
}
|
||||
|
||||
resetComponent = () => {
|
||||
this.setState({ searchterm: '' })
|
||||
}
|
||||
|
||||
executeSearch = (searchterm) => {
|
||||
this.setState({ loading: true })
|
||||
this.props.setTimeout(TIMEOUTS.FETCH, () => {
|
||||
}, this.props.latency)
|
||||
this.props.doSearch(searchterm).then(() => {
|
||||
this.props.clearTimeout(TIMEOUTS.FETCH)
|
||||
this.setState({ loading: false })
|
||||
})
|
||||
}
|
||||
|
||||
handleSearchChange = (e, { value }) => {
|
||||
this.props.clearTimeout(TIMEOUTS.SEARCH)
|
||||
if (value.length > 0) {
|
||||
this.setState({ searchterm: value })
|
||||
if (value.length > this.props.minSearchLength) {
|
||||
this.props.setTimeout(TIMEOUTS.SEARCH, () => {
|
||||
this.executeSearch(value)
|
||||
}, this.props.latency)
|
||||
}
|
||||
} else {
|
||||
this.resetComponent()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Input
|
||||
fluid
|
||||
icon={this.props.icon}
|
||||
value={this.state.searchterm}
|
||||
onChange={this.handleSearchChange}
|
||||
placeholder={this.props.placeholder}
|
||||
loading={this.state.loading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AutoSubmitSearchInput.propTypes = {
|
||||
clearTimeout: func.isRequired,
|
||||
setTimeout: func.isRequired,
|
||||
doSearch: func.isRequired,
|
||||
placeholder: string,
|
||||
icon: string,
|
||||
latency: number,
|
||||
minSearchLength: number
|
||||
}
|
||||
|
||||
AutoSubmitSearchInput.defaultProps = {
|
||||
placeholder: 'Search...',
|
||||
icon: 'search',
|
||||
latency: 250,
|
||||
minSearchLength: 4
|
||||
}
|
||||
|
||||
export default Timeout(AutoSubmitSearchInput)
|
||||
@@ -0,0 +1,3 @@
|
||||
.button {
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { Component } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { Header, Button, Message, Table, Input, Segment, Icon, Loader } from 'semantic-ui-react'
|
||||
import { getDuplicates, addDuplicate, removeDuplicate } from '../../redux/coursecodeduplicates'
|
||||
|
||||
import CourseSearch from '../CourseSearch'
|
||||
import LanguageChooser from '../LanguageChooser'
|
||||
import sharedStyles from '../../styles/shared'
|
||||
import styles from './courseCodeMapper.css'
|
||||
|
||||
const { func, shape, string } = PropTypes
|
||||
|
||||
|
||||
class CourseCodeMapper extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
props.getDuplicates()
|
||||
}
|
||||
|
||||
state = {
|
||||
codeFilter: '',
|
||||
nameFilter: '',
|
||||
code1: '',
|
||||
code2: ''
|
||||
}
|
||||
|
||||
getName = (name) => {
|
||||
const { language } = this.props
|
||||
|
||||
const res = name[language] ? name[language] : name.fi
|
||||
return res
|
||||
}
|
||||
|
||||
getTableRows = () => {
|
||||
const { courseCodeDuplicates, language } = this.props
|
||||
const { data } = courseCodeDuplicates
|
||||
const filteredKeys = this.filterNames(data, this.filterCodes(data))
|
||||
const rows = filteredKeys.map((key) => {
|
||||
const course = courseCodeDuplicates.data[key]
|
||||
return (
|
||||
<Table.Row key={key + Object.keys(course.alt).map(altkey => altkey + course.alt[key])}>
|
||||
<Table.Cell>{key}</Table.Cell>
|
||||
<Table.Cell>{this.getName(course.name)}</Table.Cell>
|
||||
<Table.Cell>{course.main}</Table.Cell>
|
||||
<Table.Cell>{Object.keys(course.alt).map(altkey => (
|
||||
<React.Fragment key={course.alt[altkey] + altkey}>
|
||||
{altkey}
|
||||
<Icon color="red" name="remove circle" onClick={this.removeDuplicate(key, altkey)} />
|
||||
</React.Fragment>))}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{Object.keys(course.alt).map(altKey => course.alt[altKey][language]).toString()}
|
||||
</Table.Cell>
|
||||
</Table.Row>)
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
filterNames = (duplicates, keys) => {
|
||||
const filter = this.state.nameFilter.toLocaleLowerCase()
|
||||
return keys.filter((key) => {
|
||||
const course = duplicates[key]
|
||||
return (
|
||||
this.getName(course.name).toLocaleLowerCase().includes(filter) ||
|
||||
Object.values(course.alt).find(name =>
|
||||
this.getName(name).toLocaleLowerCase().includes(filter))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
filterCodes = (duplicates) => {
|
||||
const filter = this.state.codeFilter.toLocaleLowerCase()
|
||||
const keys = Object.keys(duplicates)
|
||||
return keys.filter((key) => {
|
||||
const course = duplicates[key]
|
||||
return (
|
||||
key.toLocaleLowerCase().includes(filter) ||
|
||||
Object.keys(course.alt).find(code =>
|
||||
code.toLocaleLowerCase().includes(filter))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
handleCodeFilterChange = (e) => {
|
||||
this.setState({ codeFilter: e.target.value })
|
||||
}
|
||||
handleNameFilterChange = (e) => {
|
||||
this.setState({ nameFilter: e.target.value })
|
||||
}
|
||||
|
||||
handleResultSelect1 = (e, { result }) => {
|
||||
this.setState({ code1: result.code })
|
||||
}
|
||||
|
||||
handleResultSelect2 = (e, { result }) => {
|
||||
this.setState({ code2: result.code })
|
||||
}
|
||||
|
||||
addDuplicate = (code1, code2) => () => {
|
||||
this.props.addDuplicate(code1, code2)
|
||||
}
|
||||
|
||||
removeDuplicate = (code1, code2) => () => {
|
||||
this.props.removeDuplicate(code1, code2)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { pending } = this.props.courseCodeDuplicates
|
||||
const disabled = !((this.state.code1 && this.state.code2) &&
|
||||
(this.state.code1 !== this.state.code2))
|
||||
return (
|
||||
<div className={sharedStyles.segmentContainer}>
|
||||
<Segment className={sharedStyles.contentSegment}>
|
||||
<Header className={sharedStyles.segmentTitle} size="large">Course Code Mapping</Header>
|
||||
<Message
|
||||
header="Map corresponding course codes to each other"
|
||||
content="By default courses with different codes are considered as separate courses.
|
||||
If this is not the case use this to combine old and new course codes to each other."
|
||||
/>
|
||||
<Loader active={pending} />
|
||||
<Segment.Group vertical>
|
||||
<Segment.Group horizontal>
|
||||
<Segment>
|
||||
<Header content="Filter course codes" />
|
||||
<Input
|
||||
fluid
|
||||
placeholder="By Code"
|
||||
onChange={this.handleCodeFilterChange}
|
||||
/>
|
||||
<Input
|
||||
fluid
|
||||
placeholder="By Name"
|
||||
onChange={this.handleNameFilterChange}
|
||||
/>
|
||||
</Segment>
|
||||
<Segment>
|
||||
<Header>Add new corresponding code</Header>
|
||||
<CourseSearch handleResultSelect={this.handleResultSelect1} />
|
||||
<CourseSearch handleResultSelect={this.handleResultSelect2} />
|
||||
<Button disabled={disabled} className={styles.button} content="Add" onClick={this.addDuplicate(this.state.code1, this.state.code2)} />
|
||||
</Segment>
|
||||
</Segment.Group>
|
||||
<Segment>
|
||||
<Header>Language</Header>
|
||||
<LanguageChooser />
|
||||
</Segment>
|
||||
</Segment.Group>
|
||||
<Table striped>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Code</Table.HeaderCell>
|
||||
<Table.HeaderCell>Name</Table.HeaderCell>
|
||||
<Table.HeaderCell>Main code</Table.HeaderCell>
|
||||
<Table.HeaderCell>Alternative code(s)</Table.HeaderCell>
|
||||
<Table.HeaderCell>Alternative name(s)</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{this.getTableRows()}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CourseCodeMapper.propTypes = {
|
||||
language: string.isRequired,
|
||||
getDuplicates: func.isRequired,
|
||||
addDuplicate: func.isRequired,
|
||||
removeDuplicate: func.isRequired,
|
||||
courseCodeDuplicates: shape({}).isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ courseCodeDuplicates, settings }) => ({
|
||||
courseCodeDuplicates,
|
||||
language: settings.language
|
||||
})
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
getDuplicates: () =>
|
||||
dispatch(getDuplicates()),
|
||||
addDuplicate: (code1, code2) =>
|
||||
dispatch(addDuplicate(code1, code2)),
|
||||
removeDuplicate: (code1, code2) =>
|
||||
dispatch(removeDuplicate(code1, code2))
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(CourseCodeMapper)
|
||||
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types'
|
||||
import { Dropdown, Header, List, Button } from 'semantic-ui-react'
|
||||
import CourseInstanceStatistics from '../CourseInstanceStatistics'
|
||||
import CourseSearch from '../CourseSearch'
|
||||
import LanguageChooser from '../LanguageChooser'
|
||||
import Timeout from '../Timeout'
|
||||
|
||||
import { reformatDate } from '../../common'
|
||||
@@ -75,6 +76,7 @@ class CourseInstances extends Component {
|
||||
<Header className={sharedStyles.segmentTitle} size="large">
|
||||
Course Statistics
|
||||
</Header>
|
||||
<LanguageChooser />
|
||||
<CourseSearch handleResultSelect={this.handleResultSelect} />
|
||||
|
||||
<Header as="h3">
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts'
|
||||
import { Header, Button } from 'semantic-ui-react'
|
||||
|
||||
import sharedStyles from '../../styles/shared'
|
||||
import styles from './coursePassRateChart.css'
|
||||
|
||||
import { red, green, turquoise } from '../../styles/variables/colors'
|
||||
|
||||
const { array, shape, string, func } = PropTypes
|
||||
|
||||
|
||||
const StackedBarChart = ({ stats, removeCourseStatistics }) => {
|
||||
const data = stats.stats.map(year => (
|
||||
{ name: year.time, passed: year.passed, failed: year.failed, all: year.failed + year.passed }))
|
||||
const { name, code, start, end, separate } = stats
|
||||
const query = { code, start, end, separate }
|
||||
if (data.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
<Header className={sharedStyles.segmentTitle} size="large">{name}, {code}</Header>
|
||||
<div className={styles.chartContainer}>
|
||||
<BarChart
|
||||
height={700}
|
||||
width={1200}
|
||||
data={data}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="all" stackId="a" fill={turquoise} />
|
||||
<Bar dataKey="passed" stackId="b" fill={green} />
|
||||
<Bar dataKey="failed" stackId="c" fill={red} />
|
||||
</BarChart>
|
||||
<Button className={styles.remove} onClick={removeCourseStatistics(query)}>Remove</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={sharedStyles.container}>
|
||||
<Header className={sharedStyles.segmentTitle} size="large">
|
||||
No statistics for course {name}, {code} during time {start}-{end}
|
||||
</Header>
|
||||
<Button onClick={removeCourseStatistics(query)}>Remove</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
StackedBarChart.propTypes = {
|
||||
removeCourseStatistics: func.isRequired,
|
||||
stats: shape({
|
||||
code: string.isRequired,
|
||||
stats: array.isRequired
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
export default StackedBarChart
|
||||
@@ -3,3 +3,10 @@
|
||||
width: 100%;
|
||||
max-width: 750px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -41,15 +41,21 @@ class CourseSearch extends Component {
|
||||
}
|
||||
|
||||
fetchCoursesList = (searchStr) => {
|
||||
const { language } = this.props
|
||||
if (searchStr.length >= 3) {
|
||||
this.setState({ isLoading: true })
|
||||
this.props.findCourses(searchStr)
|
||||
this.props.findCourses(searchStr, language)
|
||||
.then(() => this.setState({ isLoading: false }))
|
||||
} else {
|
||||
this.props.findCourses('')
|
||||
}
|
||||
}
|
||||
|
||||
selectCourse = (a, b) => {
|
||||
this.setState({ searchStr: `${b.result.name} ( ${b.result.code} )` })
|
||||
this.props.handleResultSelect(a, b)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading, searchStr } = this.state
|
||||
const { courseList } = this.props
|
||||
@@ -62,7 +68,7 @@ class CourseSearch extends Component {
|
||||
input={{ fluid: true }}
|
||||
loading={isLoading}
|
||||
placeholder="Search by entering a course code or name"
|
||||
onResultSelect={this.props.handleResultSelect}
|
||||
onResultSelect={this.selectCourse}
|
||||
onSearchChange={this.handleSearchChange}
|
||||
results={coursesToRender}
|
||||
resultRenderer={CourseListRenderer}
|
||||
@@ -73,6 +79,7 @@ class CourseSearch extends Component {
|
||||
}
|
||||
|
||||
CourseSearch.propTypes = {
|
||||
language: string.isRequired,
|
||||
findCourses: func.isRequired,
|
||||
courseList: arrayOf(object).isRequired,
|
||||
setTimeout: func.isRequired,
|
||||
@@ -82,14 +89,15 @@ CourseSearch.propTypes = {
|
||||
|
||||
const sortCourses = makeSortCourses()
|
||||
|
||||
const mapStateToProps = ({ locale, courses }) => ({
|
||||
const mapStateToProps = ({ locale, courses, settings }) => ({
|
||||
language: settings.language,
|
||||
courseList: sortCourses(courses),
|
||||
translate: getTranslate(locale),
|
||||
currentLanguage: getActiveLanguage(locale).value
|
||||
})
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
findCourses: query =>
|
||||
dispatch(findCourses(query))
|
||||
findCourses: (query, language) =>
|
||||
dispatch(findCourses(query, language))
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Timeout(CourseSearch))
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { string, arrayOf, shape, number, oneOfType } from 'prop-types'
|
||||
|
||||
const CumulativeTable = ({ categoryName, data }) => (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell content={categoryName} />
|
||||
<Table.HeaderCell content="Passed" />
|
||||
<Table.HeaderCell content="Failed" />
|
||||
<Table.HeaderCell content="Pass rate" />
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{ data.map(({ id, category, passed, failed, passrate }) => (
|
||||
<Table.Row key={id}>
|
||||
<Table.Cell content={category} />
|
||||
<Table.Cell content={passed} />
|
||||
<Table.Cell content={failed} />
|
||||
<Table.Cell content={`${passrate || 0} %`} />
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
|
||||
CumulativeTable.propTypes = {
|
||||
categoryName: string.isRequired,
|
||||
data: arrayOf(shape({
|
||||
id: oneOfType([number, string]),
|
||||
category: oneOfType([number, string]),
|
||||
passed: oneOfType([number, string]),
|
||||
failed: oneOfType([number, string]),
|
||||
passrate: oneOfType([number, string])
|
||||
})).isRequired
|
||||
}
|
||||
|
||||
export default CumulativeTable
|
||||
@@ -0,0 +1,402 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Tab, Table, Grid, Header, Radio, Form } from 'semantic-ui-react'
|
||||
import { shape, string, number, oneOfType, arrayOf, func, bool } from 'prop-types'
|
||||
|
||||
import _ from 'lodash'
|
||||
import StackedBarChart from '../../StackedBarChart'
|
||||
import { passRateCumGraphOptions, passRateStudGraphOptions, gradeGraphOptions } from '../../../constants'
|
||||
import { userIsAdmin } from '../../../common'
|
||||
|
||||
const StatsCumTable = ({ stats, name }) => (
|
||||
<div>
|
||||
<Header as="h3" content={name} textAlign="center" />
|
||||
<Table
|
||||
headerRow={['Time', 'Passed', 'Failed', 'Pass rate']}
|
||||
tableData={stats}
|
||||
renderBodyRow={stat => ({
|
||||
key: stat.code,
|
||||
cells: [
|
||||
stat.name,
|
||||
stat.cumulative.categories.passed || 0,
|
||||
stat.cumulative.categories.failed || 0,
|
||||
`${Number((100 * stat.cumulative.categories.passed) /
|
||||
(stat.cumulative.categories.failed + stat.cumulative.categories.passed)).toFixed(2)} %`
|
||||
]
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
StatsCumTable.propTypes = {
|
||||
stats: arrayOf(shape({})).isRequired,
|
||||
name: oneOfType([number, string]).isRequired
|
||||
}
|
||||
|
||||
const StatsStudTable = ({ stats, name }) => (
|
||||
<div>
|
||||
<Header as="h3" content={name} textAlign="center" />
|
||||
<Table
|
||||
headerRow={['Time', 'Passed on first try', 'Passed after retry', 'Failed on first try', 'Failed after retry']}
|
||||
tableData={stats}
|
||||
renderBodyRow={stat => ({
|
||||
key: stat.code,
|
||||
cells: [
|
||||
stat.name,
|
||||
stat.students.categories.passedFirst || 0,
|
||||
stat.students.categories.passedRetry || 0,
|
||||
stat.students.categories.failedFirst || 0,
|
||||
stat.students.categories.failedRetry || 0
|
||||
]
|
||||
})}
|
||||
/>
|
||||
</div>)
|
||||
|
||||
StatsStudTable.propTypes = {
|
||||
stats: arrayOf(shape({})).isRequired,
|
||||
name: oneOfType([number, string]).isRequired
|
||||
}
|
||||
|
||||
const getPassRateCumSeriesFromStats = (stats, multiplier = 1, name = '') => {
|
||||
const all = []
|
||||
const passed = []
|
||||
const failed = []
|
||||
stats.forEach((year) => {
|
||||
const { passed: p, failed: f } = year.cumulative.categories
|
||||
all.push((p * multiplier) + (f * multiplier))
|
||||
passed.push(p * multiplier)
|
||||
failed.push(f * multiplier)
|
||||
})
|
||||
const series = [
|
||||
{
|
||||
name: `${name} all`,
|
||||
data: all,
|
||||
stack: 'a'
|
||||
},
|
||||
{
|
||||
name: `${name} passed`,
|
||||
data: passed,
|
||||
stack: 'b'
|
||||
},
|
||||
{
|
||||
name: `${name} failed`,
|
||||
data: failed,
|
||||
stack: 'c'
|
||||
}
|
||||
]
|
||||
return series
|
||||
}
|
||||
|
||||
const getPassRateStudSeriesFromStats = (stats, multiplier = 1, name = '') => {
|
||||
const all = []
|
||||
const failedFirst = []
|
||||
const failedRetry = []
|
||||
const passedFirst = []
|
||||
const passedRetry = []
|
||||
stats.forEach((year) => {
|
||||
const {
|
||||
failedFirst: ff,
|
||||
failedRetry: fr,
|
||||
passedFirst: pf,
|
||||
passedRetry: pr
|
||||
} = year.students.categories
|
||||
|
||||
all.push((ff * multiplier) + (fr * multiplier) + (pf * multiplier) + (pr * multiplier))
|
||||
failedFirst.push(ff * multiplier)
|
||||
failedRetry.push(fr * multiplier)
|
||||
passedFirst.push(pf * multiplier)
|
||||
passedRetry.push(pr * multiplier)
|
||||
})
|
||||
const series = [
|
||||
{
|
||||
name: `${name} all`,
|
||||
data: all,
|
||||
stack: 'a'
|
||||
},
|
||||
{
|
||||
name: `${name} passed on first try`,
|
||||
data: passedFirst,
|
||||
stack: 'b'
|
||||
},
|
||||
{
|
||||
name: `${name} passed after retry`,
|
||||
data: passedRetry,
|
||||
stack: 'b'
|
||||
},
|
||||
{
|
||||
name: `${name} failed on first try`,
|
||||
data: failedFirst,
|
||||
stack: 'c'
|
||||
},
|
||||
{
|
||||
name: `${name} failed after retry`,
|
||||
data: failedRetry,
|
||||
stack: 'c'
|
||||
}
|
||||
]
|
||||
return series
|
||||
}
|
||||
const getGradeCumSeriesFromStats = (stats, multiplier = 1, name = '') => {
|
||||
const zeros = []
|
||||
const grades = {}
|
||||
|
||||
stats.forEach((year) => {
|
||||
const fails = []
|
||||
Object.entries(year.cumulative.grades).forEach(([key, value]) => {
|
||||
if (['Eisa', 'Hyl.', '0', 'Luop'].includes(key)) {
|
||||
fails.push(value)
|
||||
}
|
||||
if (!grades[key]) {
|
||||
grades[key] = []
|
||||
}
|
||||
grades[key].push(value * multiplier)
|
||||
})
|
||||
zeros.push(_.sum(fails) * multiplier)
|
||||
})
|
||||
const seriesData = { 0: zeros, ...grades }
|
||||
const series = [
|
||||
{
|
||||
name: `${name} 0`,
|
||||
data: seriesData['0'],
|
||||
stack: 'a'
|
||||
},
|
||||
{
|
||||
name: `${name} 1`,
|
||||
data: seriesData['1'],
|
||||
stack: 'b'
|
||||
},
|
||||
{
|
||||
name: `${name} 2`,
|
||||
data: seriesData['2'],
|
||||
stack: 'c'
|
||||
},
|
||||
{
|
||||
name: `${name} 3`,
|
||||
data: seriesData['3'],
|
||||
stack: 'd'
|
||||
},
|
||||
{
|
||||
name: `${name} 4`,
|
||||
data: seriesData['4'],
|
||||
stack: 'e'
|
||||
},
|
||||
{
|
||||
name: `${name} 5`,
|
||||
data: seriesData['5'],
|
||||
stack: 'f'
|
||||
},
|
||||
{
|
||||
name: `${name} Hyv.`,
|
||||
data: seriesData['Hyv.'],
|
||||
stack: 'g'
|
||||
}
|
||||
]
|
||||
return series
|
||||
}
|
||||
|
||||
const getGradeStudSeriesFromStats = (stats, multiplier = 1, name = '') => {
|
||||
const zeros = []
|
||||
const grades = {}
|
||||
|
||||
stats.forEach((year) => {
|
||||
const fails = []
|
||||
Object.entries(year.students.grades).forEach(([key, value]) => {
|
||||
if (['Eisa', 'Hyl.', '0', 'Luop'].includes(key)) {
|
||||
fails.push(value)
|
||||
}
|
||||
if (!grades[key]) {
|
||||
grades[key] = []
|
||||
}
|
||||
grades[key].push(value * multiplier)
|
||||
})
|
||||
zeros.push(_.sum(fails) * multiplier)
|
||||
})
|
||||
const seriesData = { 0: zeros, ...grades }
|
||||
const series = [
|
||||
{
|
||||
name: `${name} 0`,
|
||||
data: seriesData['0'],
|
||||
stack: 'a'
|
||||
},
|
||||
{
|
||||
name: `${name} 1`,
|
||||
data: seriesData['1'],
|
||||
stack: 'b'
|
||||
},
|
||||
{
|
||||
name: `${name} 2`,
|
||||
data: seriesData['2'],
|
||||
stack: 'c'
|
||||
},
|
||||
{
|
||||
name: `${name} 3`,
|
||||
data: seriesData['3'],
|
||||
stack: 'd'
|
||||
},
|
||||
{
|
||||
name: `${name} 4`,
|
||||
data: seriesData['4'],
|
||||
stack: 'e'
|
||||
},
|
||||
{
|
||||
name: `${name} 5`,
|
||||
data: seriesData['5'],
|
||||
stack: 'f'
|
||||
},
|
||||
{
|
||||
name: `${name} Hyv.`,
|
||||
data: seriesData['Hyv.'],
|
||||
stack: 'g'
|
||||
}
|
||||
]
|
||||
return series
|
||||
}
|
||||
|
||||
class ResultTabs extends Component {
|
||||
state = {}
|
||||
|
||||
|
||||
async componentDidMount() {
|
||||
const adminRights = await userIsAdmin()
|
||||
|
||||
this.setState({ adminRights })
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const { max, primary, comparison, changeMode, cumMode } = this.props
|
||||
const { maxPassRateVal, maxGradeVal } = max
|
||||
const passGraphSerie = cumMode ?
|
||||
getPassRateCumSeriesFromStats(primary.stats, 1, 'primary').concat(getPassRateCumSeriesFromStats(comparison ? comparison.stats : [], -1, 'comparison'))
|
||||
:
|
||||
getPassRateStudSeriesFromStats(primary.stats, 1, 'primary').concat(getPassRateStudSeriesFromStats(comparison ? comparison.stats : [], -1, 'comparison'))
|
||||
const graphOptions = cumMode ?
|
||||
passRateCumGraphOptions(primary.stats.map(year =>
|
||||
year.name), maxPassRateVal)
|
||||
:
|
||||
passRateStudGraphOptions(primary.stats.map(year =>
|
||||
year.name), maxPassRateVal)
|
||||
|
||||
const gradeGraphSerie = cumMode ?
|
||||
getGradeCumSeriesFromStats(primary.stats, 1, 'primary').concat(getGradeCumSeriesFromStats(comparison ? comparison.stats : [], -1, 'comparison'))
|
||||
:
|
||||
getGradeStudSeriesFromStats(primary.stats, 1, 'primary').concat(getGradeCumSeriesFromStats(comparison ? comparison.stats : [], -1, 'comparison'))
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: { key: 'Table', icon: 'table', content: 'Table' },
|
||||
render: () => (
|
||||
<Grid padded="vertically" columns="equal">
|
||||
<Grid.Row>
|
||||
{primary && (
|
||||
<Grid.Column>
|
||||
{cumMode ?
|
||||
<StatsCumTable name={primary.name} stats={primary.stats} />
|
||||
:
|
||||
<StatsStudTable name={primary.name} stats={primary.stats} />
|
||||
}
|
||||
</Grid.Column>
|
||||
)}
|
||||
{
|
||||
comparison && (
|
||||
<Grid.Column>
|
||||
{cumMode ?
|
||||
<StatsCumTable name={primary.name} stats={primary.stats} />
|
||||
:
|
||||
<StatsStudTable name={primary.name} stats={primary.stats} />
|
||||
}
|
||||
</Grid.Column>
|
||||
)
|
||||
}
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
)
|
||||
},
|
||||
{
|
||||
menuItem: { key: 'pass', icon: 'balance', content: 'Pass rate chart' },
|
||||
render: () => (
|
||||
<Grid padded="vertically" columns="equal">
|
||||
<Grid.Row>
|
||||
{primary && (
|
||||
<Grid.Column>
|
||||
<StackedBarChart
|
||||
options={graphOptions}
|
||||
series={passGraphSerie}
|
||||
/>
|
||||
</Grid.Column>
|
||||
)}
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
if (this.state.adminRights) {
|
||||
panes.push({
|
||||
menuItem: { key: 'grade', icon: 'chart bar', content: 'Grade distribution chart' },
|
||||
render: () => (
|
||||
<Grid padded="vertically" columns="equal">
|
||||
<Grid.Row>
|
||||
{primary && (
|
||||
<Grid.Column>
|
||||
<StackedBarChart
|
||||
options={gradeGraphOptions(primary.stats.map(year =>
|
||||
year.name), maxGradeVal)}
|
||||
series={gradeGraphSerie}
|
||||
/>
|
||||
</Grid.Column>
|
||||
)}
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form>
|
||||
<Form.Group inline >
|
||||
<Form.Field>
|
||||
<label>Cumulative</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Radio
|
||||
toggle
|
||||
onChange={changeMode}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Student</label>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<Tab
|
||||
panes={panes}
|
||||
/>
|
||||
</div>)
|
||||
}
|
||||
}
|
||||
|
||||
ResultTabs.propTypes = {
|
||||
primary: shape({
|
||||
name: string,
|
||||
code: oneOfType([string, number]),
|
||||
stats: arrayOf(shape({}))
|
||||
}).isRequired,
|
||||
comparison: shape({
|
||||
name: string,
|
||||
code: oneOfType([string, number]),
|
||||
stats: arrayOf(shape({}))
|
||||
}),
|
||||
max: shape({
|
||||
maxPassRateVal: number,
|
||||
maxGradeVal: number
|
||||
}).isRequired,
|
||||
changeMode: func.isRequired,
|
||||
cumMode: bool.isRequired
|
||||
}
|
||||
|
||||
ResultTabs.defaultProps = {
|
||||
comparison: undefined
|
||||
}
|
||||
|
||||
export default ResultTabs
|
||||
@@ -0,0 +1,318 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Segment, Header, Form, Table, Radio, Menu } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, arrayOf, shape, string, bool } from 'prop-types'
|
||||
import { getCourseTypes } from '../../../redux/coursetypes'
|
||||
import { getCourseDisciplines } from '../../../redux/coursedisciplines'
|
||||
import { getSemesters } from '../../../redux/semesters'
|
||||
import { findCourses, clearCourses } from '../../../redux/coursesearch'
|
||||
import { getCourseStats, clearCourseStats } from '../../../redux/coursestats'
|
||||
import AutoSubmitSearchInput from '../../AutoSubmitSearchInput'
|
||||
|
||||
const CourseTable = ({ courses, selected, onSelectCourse }) => (
|
||||
<Segment basic style={{ padding: '0' }} >
|
||||
<Table>
|
||||
{selected.length > 0 && (
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell content="Selected courses" />
|
||||
<Table.HeaderCell content="Code" />
|
||||
<Table.HeaderCell content="Select" />
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
)}
|
||||
{selected.length > 0 && (
|
||||
<Table.Body>
|
||||
{selected.map(course => (
|
||||
<Table.Row key={course.code}>
|
||||
<Table.Cell content={course.name} />
|
||||
<Table.Cell content={course.code} />
|
||||
<Table.Cell>
|
||||
<Radio toggle checked={course.selected} onChange={() => onSelectCourse(course)} />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
)}
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell content="Searched courses" />
|
||||
<Table.HeaderCell content="Code" />
|
||||
<Table.HeaderCell content="Select" />
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{courses.length === 0 ? (
|
||||
<Table.Row>
|
||||
<Table.Cell colSpan="3" content="No courses matched your query" />
|
||||
</Table.Row>
|
||||
) : courses.map(course => (
|
||||
<Table.Row key={course.code}>
|
||||
<Table.Cell content={course.name} />
|
||||
<Table.Cell content={course.code} />
|
||||
<Table.Cell>
|
||||
<Radio toggle checked={course.selected} onChange={() => onSelectCourse(course)} />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Segment>
|
||||
)
|
||||
|
||||
CourseTable.propTypes = {
|
||||
courses: arrayOf(shape({ code: string, name: string, seleted: bool })).isRequired,
|
||||
selected: arrayOf(shape({ code: string, name: string, seleted: bool })).isRequired,
|
||||
onSelectCourse: func.isRequired
|
||||
}
|
||||
|
||||
const INITIAL = {
|
||||
displaycourses: false,
|
||||
searchterm: undefined,
|
||||
selectedcourses: {},
|
||||
fromYear: undefined,
|
||||
toYear: undefined,
|
||||
separate: false,
|
||||
expanded: true,
|
||||
discipline: undefined,
|
||||
type: undefined,
|
||||
focus: false
|
||||
}
|
||||
|
||||
class SearchForm extends Component {
|
||||
state = { ...INITIAL }
|
||||
|
||||
componentDidMount() {
|
||||
this.props.clearCourses()
|
||||
this.props.getCourseTypes()
|
||||
this.props.getCourseDisciplines()
|
||||
this.props.getSemesters()
|
||||
}
|
||||
|
||||
toggleCourseView = () => {
|
||||
this.setState({ displaycourses: !this.state.displaycourses })
|
||||
}
|
||||
|
||||
searchOnEnter = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.fetchCourses(this.state.searchterm)
|
||||
}
|
||||
}
|
||||
|
||||
toggleCourse = (course) => {
|
||||
course.selected = !course.selected
|
||||
const { selectedcourses } = this.state
|
||||
const isSelected = !!selectedcourses[course.code]
|
||||
if (isSelected) {
|
||||
const { [course.code]: omit, ...rest } = selectedcourses
|
||||
this.setState({ selectedcourses: rest })
|
||||
} else {
|
||||
this.setState({
|
||||
selectedcourses: {
|
||||
...selectedcourses,
|
||||
[course.code]: { ...course, selected: true }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = (e, target) => {
|
||||
const { name, value } = target
|
||||
this.setState({ [name]: value })
|
||||
}
|
||||
|
||||
handleCourseFormChange = (e, target) => {
|
||||
const { name, value } = target
|
||||
const { type, discipline } = { ...this.state, [name]: value }
|
||||
this.setState({ type, discipline })
|
||||
if (type && discipline) {
|
||||
this.props.findCourses({ type, discipline })
|
||||
}
|
||||
}
|
||||
|
||||
toggleCheckbox = (e, target) => {
|
||||
const { name } = target
|
||||
this.setState({ [name]: !this.state[name] })
|
||||
}
|
||||
|
||||
toggleExpanded = () => {
|
||||
this.setState({ expanded: !this.state.expanded })
|
||||
}
|
||||
|
||||
fetchCourses = (name) => {
|
||||
const { type, discipline } = this.state
|
||||
return this.props.findCourses({ name, ...{ type, discipline } })
|
||||
}
|
||||
|
||||
handleSearchChange = (e, { value: searchterm }) => {
|
||||
this.setState({ searchterm })
|
||||
}
|
||||
|
||||
submitForm = async () => {
|
||||
const { fromYear, toYear, selectedcourses, separate } = this.state
|
||||
const params = {
|
||||
fromYear,
|
||||
toYear,
|
||||
courseCodes: Object.keys(selectedcourses),
|
||||
separate
|
||||
}
|
||||
await this.props.getCourseStats(params)
|
||||
this.setState({ ...INITIAL, expanded: false })
|
||||
}
|
||||
|
||||
expandForm = () => {
|
||||
this.props.clearCourses()
|
||||
this.props.clearCourseStats()
|
||||
this.toggleExpanded()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { disciplines, coursetypes, years, loading } = this.props
|
||||
const { selectedcourses, fromYear, toYear, separate, focus } = this.state
|
||||
const courses = this.props.matchingCourses.map(course => ({
|
||||
...course,
|
||||
selected: !!selectedcourses[course.code]
|
||||
}))
|
||||
const disabled = (!fromYear || Object.keys(selectedcourses).length === 0)
|
||||
const selected = Object.values(selectedcourses).map(course => ({ ...course, selected: true }))
|
||||
return !this.state.expanded
|
||||
? (
|
||||
<Menu>
|
||||
<Menu.Item icon="search" content="New query" onClick={this.expandForm} />
|
||||
</Menu>
|
||||
) : (
|
||||
<Segment loading={loading}>
|
||||
<Form>
|
||||
<Header content="Search parameters" onClick={this.toggleExpanded} />
|
||||
<Form.Group widths="equal">
|
||||
<Form.Dropdown
|
||||
label="From:"
|
||||
name="fromYear"
|
||||
options={years}
|
||||
selection
|
||||
placeholder="Select academic year"
|
||||
onChange={this.handleChange}
|
||||
value={fromYear}
|
||||
/>
|
||||
<Form.Dropdown
|
||||
label="To:"
|
||||
name="toYear"
|
||||
options={years}
|
||||
selection
|
||||
placeholder="Select academic year"
|
||||
onChange={this.handleChange}
|
||||
value={toYear}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Checkbox
|
||||
label="Separate statistics for Spring and Fall semesters"
|
||||
name="separate"
|
||||
onChange={this.toggleCheckbox}
|
||||
checked={separate}
|
||||
/>
|
||||
<Header content="Search for courses" />
|
||||
<Form.Group
|
||||
widths="equal"
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
<Form.Dropdown
|
||||
onChange={this.handleCourseFormChange}
|
||||
value={this.state.discipline}
|
||||
label="Discipline:"
|
||||
name="discipline"
|
||||
search
|
||||
options={disciplines}
|
||||
selection
|
||||
placeholder="Select a course discipline"
|
||||
/>
|
||||
<Form.Dropdown
|
||||
onChange={this.handleCourseFormChange}
|
||||
label="Type:"
|
||||
name="type"
|
||||
value={this.state.type}
|
||||
search
|
||||
options={coursetypes}
|
||||
selection
|
||||
placeholder="Select a course type"
|
||||
/>
|
||||
</Form.Group>
|
||||
<div
|
||||
style={{ marginBottom: '15px' }}
|
||||
onFocus={() => this.setState({ focus: true })}
|
||||
onBlur={() => this.setState({ focus: false })}
|
||||
>
|
||||
<Form.Field>
|
||||
<AutoSubmitSearchInput
|
||||
doSearch={this.fetchCourses}
|
||||
placeholder="Search by entering a course code or name"
|
||||
/>
|
||||
</Form.Field>
|
||||
{focus ?
|
||||
<CourseTable
|
||||
onFocus={() => this.setState({ focus: true })}
|
||||
loading={this.props.coursesLoading}
|
||||
selected={selected}
|
||||
courses={courses}
|
||||
onSelectCourse={this.toggleCourse}
|
||||
/>
|
||||
: null}
|
||||
</div>
|
||||
<Form.Button type="button" disabled={disabled} fluid basic positive content="Fetch statistics" onClick={this.submitForm} />
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SearchForm.propTypes = {
|
||||
getCourseTypes: func.isRequired,
|
||||
getCourseDisciplines: func.isRequired,
|
||||
findCourses: func.isRequired,
|
||||
getSemesters: func.isRequired,
|
||||
getCourseStats: func.isRequired,
|
||||
clearCourses: func.isRequired,
|
||||
clearCourseStats: func.isRequired,
|
||||
disciplines: arrayOf(shape({})).isRequired,
|
||||
coursetypes: arrayOf(shape({})).isRequired,
|
||||
matchingCourses: arrayOf(shape({})).isRequired,
|
||||
years: arrayOf(shape({})).isRequired,
|
||||
loading: bool.isRequired,
|
||||
coursesLoading: bool.isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const disciplines = state.courseDisciplines.data.map(({ discipline_id: id, name }) => ({
|
||||
key: id,
|
||||
value: id,
|
||||
text: name.fi || name.en
|
||||
}))
|
||||
const coursetypes = state.courseTypes.data.map(({ coursetypecode: code, name }) => ({
|
||||
key: code,
|
||||
value: code,
|
||||
text: name.fi || name.en
|
||||
}))
|
||||
const { years = [] } = state.semesters.data
|
||||
const { pending } = state.courseStats
|
||||
return {
|
||||
disciplines,
|
||||
coursetypes,
|
||||
matchingCourses: state.courseSearch.data,
|
||||
years: Object.values(years).map(({ yearcode, yearname }) => ({
|
||||
key: yearcode,
|
||||
text: yearname,
|
||||
value: yearcode
|
||||
})).reverse(),
|
||||
loading: pending,
|
||||
coursesLoading: state.courseSearch.pending
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
getCourseTypes,
|
||||
getCourseDisciplines,
|
||||
findCourses,
|
||||
getSemesters,
|
||||
getCourseStats,
|
||||
clearCourses,
|
||||
clearCourseStats
|
||||
})(SearchForm)
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Segment, Header, Label, Form, Divider } from 'semantic-ui-react'
|
||||
import { shape, string, arrayOf, objectOf, oneOfType, number } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
import ResultTabs from '../ResultTabs'
|
||||
|
||||
const ALL = {
|
||||
key: 'all',
|
||||
value: 'all',
|
||||
text: 'All'
|
||||
}
|
||||
|
||||
const label = count => ({ content: count, icon: 'user', basic: true })
|
||||
|
||||
const countFilteredStudents = (stat, filter) => Object.entries(stat).reduce((acc, entry) => {
|
||||
const [category, students] = entry
|
||||
return {
|
||||
...acc,
|
||||
[category]: students.filter(filter).length
|
||||
}
|
||||
}, {})
|
||||
|
||||
class SingleCourseStats extends Component {
|
||||
state = {
|
||||
primary: ALL.value,
|
||||
comparison: undefined,
|
||||
cumMode: true
|
||||
}
|
||||
|
||||
getMax = stats => ({
|
||||
maxPassRateVal: _.max(stats.map(year =>
|
||||
year.attempts.classes.passed.concat(year.attempts.classes.failed).length)),
|
||||
maxGradeVal: _.max(_.max(stats.map(year =>
|
||||
Object.values(Object.entries(year.attempts.grades).reduce((acc, [key, value]) => {
|
||||
if (['Eisa', 'Hyl.', '0', 'Luop'].includes(key)) {
|
||||
return { ...acc, 0: acc['0'] + value.length }
|
||||
}
|
||||
return { ...acc, [key]: value.length }
|
||||
}, { 0: 0 })))))
|
||||
})
|
||||
|
||||
getProgrammeName = (progcode) => {
|
||||
if (progcode === ALL.value) {
|
||||
return 'All'
|
||||
}
|
||||
const { name } = this.props.stats.programmes[progcode]
|
||||
return name.fi || name.en || name.sv
|
||||
}
|
||||
|
||||
programmeOptions = () => {
|
||||
const { programmes } = this.props.stats
|
||||
const options = Object.entries(programmes)
|
||||
.map(([code, { name, students }]) => ({
|
||||
key: code,
|
||||
value: code,
|
||||
text: name.fi || name.en || name.sv,
|
||||
students: students.length,
|
||||
label: label(students.length)
|
||||
}))
|
||||
.sort((op1, op2) => op2.students - op1.students)
|
||||
const total = options.reduce((acc, opt) => acc + opt.students, 0)
|
||||
return [
|
||||
{ ...ALL, label: label(total) },
|
||||
...options
|
||||
]
|
||||
}
|
||||
|
||||
belongsToProgramme = (code) => {
|
||||
if (code === ALL.value) {
|
||||
return () => true
|
||||
}
|
||||
const { programmes } = this.props.stats
|
||||
const programme = programmes[code]
|
||||
if (!programme) {
|
||||
return () => false
|
||||
}
|
||||
const numberset = new Set(programme.students)
|
||||
return studentnumber => numberset.has(studentnumber)
|
||||
}
|
||||
|
||||
validProgCode = (code) => {
|
||||
const { programmes } = this.props.stats
|
||||
return programmes[code] || (code === ALL.value)
|
||||
}
|
||||
|
||||
statsForProgramme = (progcode) => {
|
||||
const { statistics } = this.props.stats
|
||||
const filter = this.belongsToProgramme(progcode)
|
||||
const progstats = statistics.map(({ code, name, students: allstudents, attempts }) => {
|
||||
const cumulative = {
|
||||
grades: countFilteredStudents(attempts.grades, filter),
|
||||
categories: countFilteredStudents(attempts.classes, filter)
|
||||
}
|
||||
const students = {
|
||||
grades: countFilteredStudents(allstudents.grades, filter),
|
||||
categories: countFilteredStudents(allstudents.classes, filter)
|
||||
}
|
||||
return { code, name, cumulative, students }
|
||||
})
|
||||
return {
|
||||
code: progcode,
|
||||
name: this.getProgrammeName(progcode),
|
||||
stats: progstats
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = (e, { name, value }) => {
|
||||
this.setState({ [name]: value })
|
||||
}
|
||||
|
||||
filteredProgrammeStatistics = () => {
|
||||
const { primary, comparison } = this.state
|
||||
const pstats = !this.validProgCode(primary) ? undefined : this.statsForProgramme(primary)
|
||||
const cstats = !this.validProgCode(comparison)
|
||||
? undefined
|
||||
: this.statsForProgramme(comparison)
|
||||
return {
|
||||
primary: pstats,
|
||||
comparison: cstats
|
||||
}
|
||||
}
|
||||
|
||||
selectedProgrammes = () => {
|
||||
const { primary: p, comparison: c } = this.state
|
||||
const { programmes } = this.props.stats
|
||||
const primary = !programmes[p] ? ALL.value : p
|
||||
const comparison = !programmes[c] ? undefined : c
|
||||
return { primary, comparison }
|
||||
}
|
||||
changeMode = () => {
|
||||
this.setState({ cumMode: !this.state.cumMode })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { stats } = this.props
|
||||
const { primary, comparison } = this.selectedProgrammes()
|
||||
const options = this.programmeOptions()
|
||||
const statistics = this.filteredProgrammeStatistics()
|
||||
const max = this.getMax(stats.statistics)
|
||||
return (
|
||||
<div>
|
||||
<Segment>
|
||||
<Header content={stats.name} />
|
||||
<Label.Group>
|
||||
{stats.alternatives.map(code => <Label key={code} content={code} />)}
|
||||
</Label.Group>
|
||||
<Divider />
|
||||
<Form>
|
||||
<Header content="Filter statistics by study programme" as="h4" />
|
||||
<Form.Group widths="equal" >
|
||||
<Form.Dropdown
|
||||
name="primary"
|
||||
options={options}
|
||||
label="Primary group"
|
||||
placeholder="Select a study programme"
|
||||
value={primary}
|
||||
onChange={this.handleChange}
|
||||
search
|
||||
selection
|
||||
/>
|
||||
<Form.Dropdown
|
||||
name="comparison"
|
||||
options={options}
|
||||
label="Comparison group"
|
||||
placeholder="Optional"
|
||||
value={comparison}
|
||||
onChange={this.handleChange}
|
||||
selection
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
<ResultTabs
|
||||
max={max}
|
||||
primary={statistics.primary}
|
||||
comparison={statistics.comparison}
|
||||
changeMode={this.changeMode}
|
||||
cumMode={this.state.cumMode}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SingleCourseStats.propTypes = {
|
||||
stats: shape({
|
||||
alternatives: arrayOf(string),
|
||||
programmes: objectOf(shape({
|
||||
name: shape({}),
|
||||
students: arrayOf(string)
|
||||
})),
|
||||
statistics: arrayOf(shape({
|
||||
code: oneOfType([number, string]),
|
||||
name: string,
|
||||
attempts: objectOf(shape({
|
||||
failed: arrayOf(string),
|
||||
passed: arrayOf(string)
|
||||
}))
|
||||
})),
|
||||
name: string,
|
||||
coursecode: string
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
export default SingleCourseStats
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Table, Radio } from 'semantic-ui-react'
|
||||
import { shape } from 'prop-types'
|
||||
import SingleCourseStats from '../SingleCourseStats'
|
||||
|
||||
class SingleCourse extends Component {
|
||||
state={
|
||||
selected: undefined
|
||||
}
|
||||
|
||||
getStats = () => {
|
||||
const { stats } = this.props
|
||||
const statistics = Object.values(stats)
|
||||
let { selected } = this.state
|
||||
if (!selected && statistics.length > 0) {
|
||||
selected = statistics[0].coursecode
|
||||
}
|
||||
return {
|
||||
selected,
|
||||
statistics,
|
||||
selectedStatistic: stats[selected]
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selected, statistics, selectedStatistic } = this.getStats()
|
||||
return (
|
||||
<div>
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell content="Name" />
|
||||
<Table.HeaderCell content="Code" />
|
||||
<Table.HeaderCell content="Select" />
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{statistics.map(stat => (
|
||||
<Table.Row key={stat.coursecode}>
|
||||
<Table.Cell content={stat.name} />
|
||||
<Table.Cell content={stat.coursecode} />
|
||||
<Table.Cell>
|
||||
<Radio
|
||||
toggle
|
||||
checked={stat.coursecode === selected}
|
||||
onClick={() => this.setState({ selected: stat.coursecode })}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
{ selected && <SingleCourseStats stats={selectedStatistic} /> }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SingleCourse.propTypes = {
|
||||
stats: shape({}).isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const { data } = state.courseStats
|
||||
return {
|
||||
stats: data
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(SingleCourse)
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Form } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, arrayOf, func, oneOfType, number, string } from 'prop-types'
|
||||
import selectors from '../../../selectors/courseStats'
|
||||
import { fields, setValue } from '../../../redux/coursesSummaryForm'
|
||||
import CumulativeTable from '../CumulativeTable'
|
||||
|
||||
class SummaryTab extends Component {
|
||||
handleChange = (e, { name, value }) => this.props.setValue(name, value)
|
||||
|
||||
render() {
|
||||
const { statistics, programmes } = this.props
|
||||
const options = programmes.map(p => ({
|
||||
...p,
|
||||
label: {
|
||||
content: p.size,
|
||||
icon: 'user',
|
||||
basic: true
|
||||
}
|
||||
}))
|
||||
return (
|
||||
<div>
|
||||
<Form>
|
||||
<Form.Dropdown
|
||||
options={options}
|
||||
selection
|
||||
fluid
|
||||
label="Study programme"
|
||||
name={fields.programme}
|
||||
onChange={this.handleChange}
|
||||
value={this.props.form[fields.programme]}
|
||||
/>
|
||||
</Form>
|
||||
<CumulativeTable
|
||||
categoryName="Course"
|
||||
data={statistics.map(s => ({
|
||||
id: s.coursecode,
|
||||
category: s.name,
|
||||
passed: s.summary.passed,
|
||||
failed: s.summary.failed,
|
||||
passrate: s.summary.passrate
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SummaryTab.propTypes = {
|
||||
statistics: arrayOf(shape({
|
||||
coursecode: oneOfType([number, string]),
|
||||
name: string,
|
||||
summary: shape({
|
||||
failed: number,
|
||||
passed: number,
|
||||
passrate: oneOfType([number, string])
|
||||
})
|
||||
})).isRequired,
|
||||
programmes: arrayOf(shape({})).isRequired,
|
||||
form: shape({}).isRequired,
|
||||
setValue: func.isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const programmes = selectors.getAllStudyProgrammes(state)
|
||||
const programme = state.courseSummaryForm[fields.programme]
|
||||
return {
|
||||
form: state.courseSummaryForm,
|
||||
statistics: selectors.summaryStatistics(state, { programmes, programme }),
|
||||
programmes
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { setValue })(SummaryTab)
|
||||
@@ -1,234 +1,53 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Header, Segment, Tab } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Form, Button, Header, Checkbox, Message } from 'semantic-ui-react'
|
||||
import Datetime from 'react-datetime'
|
||||
import Timeout from '../Timeout'
|
||||
import CourseSearch from '../CourseSearch'
|
||||
import CoursePassRateChart from '../CoursePassRateChart'
|
||||
import { getCourseStatistics, removeCourseStatistics } from '../../redux/courseStatistics'
|
||||
import { isValidYear, isInDateFormat, reformatDate, momentFromFormat } from '../../common'
|
||||
|
||||
|
||||
import { bool } from 'prop-types'
|
||||
import style from './courseStatistics.css'
|
||||
import sharedStyles from '../../styles/shared'
|
||||
import SearchForm from './SearchForm'
|
||||
import SingleCourseTab from './SingleCourseTab'
|
||||
import SummaryTab from './SummaryTab'
|
||||
|
||||
const { shape, func, array } = PropTypes
|
||||
|
||||
const INITIAL_YEARS = {
|
||||
start: '2017',
|
||||
end: '2018'
|
||||
}
|
||||
const SearchResults = () => (
|
||||
<Tab
|
||||
menu={{ attached: false }}
|
||||
panes={[
|
||||
{
|
||||
menuItem: 'Summary',
|
||||
render: () => <SummaryTab />
|
||||
},
|
||||
{
|
||||
menuItem: 'Course',
|
||||
render: () => <SingleCourseTab />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
class CourseStatistics extends Component {
|
||||
state = {
|
||||
selectedCourse: { name: 'No course selected', code: '' },
|
||||
...INITIAL_YEARS,
|
||||
separate: false,
|
||||
validYear: true,
|
||||
error: '',
|
||||
isLoading: false
|
||||
}
|
||||
|
||||
handleResultSelect = (e, { result }) => {
|
||||
this.setState({ selectedCourse: result }, () => {
|
||||
this.fetchCourseStatistics()
|
||||
})
|
||||
}
|
||||
|
||||
addYear = change => () => {
|
||||
const year = this.state[change]
|
||||
const nextYear = momentFromFormat(year, 'YYYY').add(1, 'year')
|
||||
this.handleYearSelection(nextYear, change)
|
||||
}
|
||||
|
||||
subtractYear = change => () => {
|
||||
const year = this.state[change]
|
||||
const previousYear = momentFromFormat(year, 'YYYY').subtract(1, 'year')
|
||||
this.handleYearSelection(previousYear, change)
|
||||
}
|
||||
|
||||
fetchCourseStatistics = () => {
|
||||
const { code } = this.state.selectedCourse
|
||||
const { start, end, separate } = this.state
|
||||
const { selected } = this.props.courseStatistics
|
||||
const query = { code, start: Number(start), end: Number(end), separate: String(separate) }
|
||||
const aa = selected.find(olquery =>
|
||||
olquery.separate === query.separate &&
|
||||
olquery.end === query.end &&
|
||||
olquery.start === query.start &&
|
||||
olquery.code === query.code)
|
||||
if (!aa) {
|
||||
this.setState({ isLoading: true })
|
||||
this.props.getCourseStatistics(query)
|
||||
.then(() => {
|
||||
this.setState({ error: '', isLoading: false })
|
||||
})
|
||||
this.setState({ error: '' })
|
||||
} else this.setState({ error: 'Course with selected parameters already in analysis' })
|
||||
}
|
||||
|
||||
removeCourseStatistics = query => () => this.props.removeCourseStatistics(query)
|
||||
|
||||
startBeforeEnd = (year, change) => {
|
||||
if (change === 'start') {
|
||||
return this.state.end > reformatDate(year, 'YYYY')
|
||||
}
|
||||
return this.state.start < reformatDate(year, 'YYYY')
|
||||
}
|
||||
|
||||
handleYearSelection = (year, change) => {
|
||||
this.setState({ error: '' })
|
||||
const validYear = isInDateFormat(year, 'YYYY') && isValidYear(year) && this.startBeforeEnd(year, change)
|
||||
if (validYear) {
|
||||
this.setState({
|
||||
validYear,
|
||||
[change]: reformatDate(year, 'YYYY')
|
||||
})
|
||||
} else {
|
||||
this.setState({ validYear })
|
||||
}
|
||||
}
|
||||
|
||||
handleStartYearSelection = (year) => {
|
||||
this.handleYearSelection(year, 'start')
|
||||
}
|
||||
|
||||
handleEndYearSelection = (year) => {
|
||||
this.handleYearSelection(year, 'end')
|
||||
}
|
||||
|
||||
handleSemesterSeparate = () => {
|
||||
this.setState({ error: '' })
|
||||
const bool = this.state.separate
|
||||
this.setState({ separate: !bool })
|
||||
}
|
||||
|
||||
renderErrorMessage = () => {
|
||||
const { error } = this.state
|
||||
if (error) {
|
||||
return (<Message
|
||||
error
|
||||
color="red"
|
||||
header={error}
|
||||
/>)
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
renderYearSelector = () => {
|
||||
const { validYear, start, end, isLoading } = this.state
|
||||
|
||||
return (
|
||||
<Form loading={isLoading}>
|
||||
<Form.Group key="year" className={style.yearSelectorGroup}>
|
||||
<Form.Field error={!validYear} className={style.yearSelect}>
|
||||
<label>Start year</label>
|
||||
<Datetime
|
||||
className={style.yearSelectInput}
|
||||
control={Datetime}
|
||||
dateFormat="YYYY"
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={start}
|
||||
isValidDate={isValidYear}
|
||||
onChange={this.handleStartYearSelection}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field className={style.yearControl}>
|
||||
<Button.Group basic vertical className={style.yearControlButtonGroup}>
|
||||
<Button
|
||||
icon="plus"
|
||||
className={style.yearControlButton}
|
||||
onClick={this.addYear('start')}
|
||||
/>
|
||||
<Button
|
||||
icon="minus"
|
||||
className={style.yearControlButton}
|
||||
onClick={this.subtractYear('start')}
|
||||
/>
|
||||
</Button.Group>
|
||||
</Form.Field>
|
||||
<Form.Field error={!validYear} className={style.yearSelect}>
|
||||
<label>End year</label>
|
||||
<Datetime
|
||||
className={style.yearSelectInput}
|
||||
control={Datetime}
|
||||
dateFormat="YYYY"
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={end}
|
||||
isValidDate={isValidYear}
|
||||
onChange={this.handleEndYearSelection}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field className={style.yearControl}>
|
||||
<Button.Group basic vertical className={style.yearControlButtonGroup}>
|
||||
<Button
|
||||
icon="plus"
|
||||
className={style.yearControlButton}
|
||||
onClick={this.addYear('end')}
|
||||
/>
|
||||
<Button
|
||||
icon="minus"
|
||||
className={style.yearControlButton}
|
||||
onClick={this.subtractYear('end')}
|
||||
/>
|
||||
</Button.Group>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Checkbox
|
||||
label="Separate Spring/Fall"
|
||||
onChange={this.handleSemesterSeparate}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
state={}
|
||||
render() {
|
||||
const { data } = this.props.courseStatistics
|
||||
|
||||
const { statsIsEmpty } = this.props
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<Header className={sharedStyles.segmentTitle} size="large">
|
||||
Course Statistics
|
||||
</Header>
|
||||
{this.renderYearSelector()}
|
||||
{this.renderErrorMessage()}
|
||||
<CourseSearch handleResultSelect={this.handleResultSelect} />
|
||||
{data.map(course => (<CoursePassRateChart
|
||||
removeCourseStatistics={this.removeCourseStatistics}
|
||||
key={course.code}
|
||||
stats={course}
|
||||
/>))
|
||||
}
|
||||
<Segment className={sharedStyles.contentSegment} >
|
||||
<SearchForm />
|
||||
{!statsIsEmpty && <SearchResults />}
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CourseStatistics.propTypes = {
|
||||
getCourseStatistics: func.isRequired,
|
||||
removeCourseStatistics: func.isRequired,
|
||||
courseStatistics: shape({
|
||||
data: array.isRequired,
|
||||
selected: array.isRequired
|
||||
}).isRequired
|
||||
statsIsEmpty: bool.isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ courses, courseStatistics }) => ({
|
||||
courses,
|
||||
courseStatistics
|
||||
const mapStateToProps = ({ courseStats }) => ({
|
||||
statsIsEmpty: Object.keys(courseStats.data).length === 0
|
||||
})
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
getCourseStatistics: query =>
|
||||
dispatch(getCourseStatistics(query)),
|
||||
removeCourseStatistics: query =>
|
||||
dispatch(removeCourseStatistics(query))
|
||||
})
|
||||
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Timeout(CourseStatistics))
|
||||
export default connect(mapStateToProps)(CourseStatistics)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.yearSelectorGroup {
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.yearSelect {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.yearSelectInput input {
|
||||
border-bottom-right-radius: 0 !important;
|
||||
border-top-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
.yearControl {
|
||||
padding-top: 22px !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.yearControlButtonGroup {
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-top-left-radius: 0 !important;
|
||||
}
|
||||
|
||||
.yearControlButton {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { Component } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
import { Search, Dropdown, Form, Table, Checkbox, Button } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { getActiveLanguage, getTranslate } from 'react-localize-redux'
|
||||
import Timeout from '../Timeout'
|
||||
import { getCourseTypes, getCourseDisciplines, getMultipleCourseStatistics } from '../../redux/courseStatistics'
|
||||
import { findMultipleCourses, emptyCourseSearch } from '../../redux/courses'
|
||||
import { makeSortCourses } from '../../selectors/courses'
|
||||
import { reformatDate } from '../../common'
|
||||
|
||||
import style from './CourseStatisticsSearch.css'
|
||||
|
||||
const { func, string, arrayOf, object, shape } = PropTypes
|
||||
|
||||
|
||||
class CourseStatisticsSearch extends Component {
|
||||
state = {
|
||||
isLoading: false,
|
||||
searchStr: '',
|
||||
type: { value: 'All', key: null },
|
||||
discipline: { value: 'All', key: null }
|
||||
}
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
this.props.getCourseTypes()
|
||||
this.props.getCourseDisciplines()
|
||||
}
|
||||
|
||||
resetComponent = () => {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
searchStr: '',
|
||||
type: { value: 'All', key: null },
|
||||
discipline: { value: 'All', key: null }
|
||||
})
|
||||
}
|
||||
|
||||
handleTypeSelect = (e, { value, options }) => {
|
||||
const { key } = options.find(opt => opt.text === value)
|
||||
this.setState({ type: { value, key } }, () => this.fetchCoursesList())
|
||||
}
|
||||
|
||||
handleDisciplineSelect = (e, { value, options }) => {
|
||||
const { key } = options.find(opt => opt.text === value)
|
||||
|
||||
this.setState({ discipline: { value, key } }, () => this.fetchCoursesList())
|
||||
}
|
||||
|
||||
|
||||
handleSearchChange = (e, { value: searchStr }) => {
|
||||
const { type, discipline } = this.state
|
||||
this.props.clearTimeout('search')
|
||||
this.setState({ searchStr })
|
||||
if (!type.key && !discipline.key) {
|
||||
this.props.setTimeout('search', () => {
|
||||
this.fetchCoursesList()
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
|
||||
fetchCoursesList = () => {
|
||||
const { type, discipline, searchStr } = this.state
|
||||
const { language } = this.props
|
||||
if (searchStr.length >= 3 && !type.key && !discipline.key) {
|
||||
this.setState({ isLoading: true })
|
||||
this.props.findMultipleCourses({
|
||||
searchStr,
|
||||
type: type.key,
|
||||
discipline: discipline.key
|
||||
}, language)
|
||||
.then(() => this.setState({ isLoading: false }))
|
||||
} else if (searchStr.length >= 3 || type.key || discipline.key) {
|
||||
this.setState({ isLoading: true })
|
||||
this.props.findMultipleCourses({
|
||||
searchStr: null,
|
||||
type: type.key,
|
||||
discipline: discipline.key
|
||||
}, language)
|
||||
.then(() => this.setState({ isLoading: false }))
|
||||
}
|
||||
}
|
||||
|
||||
fetchSelectedCoursesStatistics = () => {
|
||||
this.props.fetchCourseStatistics()
|
||||
this.props.emptyCourseSearch()
|
||||
this.resetComponent()
|
||||
}
|
||||
|
||||
renderCourseTypesDropdown = types => (
|
||||
<Dropdown
|
||||
placeholder="All"
|
||||
search
|
||||
value={this.state.type.value}
|
||||
selection
|
||||
options={types}
|
||||
loading={types === null}
|
||||
onChange={this.handleTypeSelect}
|
||||
closeOnChange
|
||||
basic
|
||||
header="Select course type"
|
||||
/>
|
||||
)
|
||||
|
||||
renderDisciplinesDropdown = disciplines => (
|
||||
<Dropdown
|
||||
placeholder="All"
|
||||
search
|
||||
value={this.state.discipline.value}
|
||||
selection
|
||||
loading={disciplines === null}
|
||||
options={disciplines}
|
||||
onChange={this.handleDisciplineSelect}
|
||||
closeOnChange
|
||||
basic
|
||||
header="Select discipline"
|
||||
/>
|
||||
)
|
||||
|
||||
renderResultTable = () => {
|
||||
const { searchStr } = this.state
|
||||
const { courseList } = this.props
|
||||
const coursesToRender = courseList.filter(c => c.name)
|
||||
.filter(c => c.name.toLocaleLowerCase()
|
||||
.includes(searchStr.toLocaleLowerCase()) ||
|
||||
c.code.toLocaleLowerCase()
|
||||
.includes(searchStr.toLocaleLowerCase()))
|
||||
.slice(0, 20)
|
||||
.map(c =>
|
||||
({ values: [c.name, c.code, c.date ? reformatDate(c.date, 'DD.MM.YYYY') : null], selected: c.selected }))
|
||||
const headers = ['Name', 'Code', 'Latest instance held', 'Select']
|
||||
return (
|
||||
<Table
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '850px'
|
||||
}}
|
||||
unstackable
|
||||
selectable
|
||||
>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{headers.map(header => (
|
||||
<Table.HeaderCell key={`header-${header}`}>
|
||||
{header}
|
||||
</Table.HeaderCell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{coursesToRender.map(course => (
|
||||
<Table.Row key={`${course.values[0]}-${course.values[1]}`}>{course.values.map(c =>
|
||||
<Table.Cell key={c}>{c}</Table.Cell>)
|
||||
.concat( // eslint-disable-line function-paren-newline
|
||||
<Table.Cell key={`${course.code}-checkbox`}>
|
||||
<Checkbox
|
||||
checked={course.selected}
|
||||
key={course.code}
|
||||
value={course.values[1]}
|
||||
toggle
|
||||
onChange={this.props.handleResultSelect}
|
||||
/>
|
||||
</Table.Cell>)}
|
||||
</Table.Row>))}
|
||||
</Table.Body>
|
||||
</Table>)
|
||||
}
|
||||
|
||||
renderResultDropDown = () => { // unused, may delete
|
||||
const { courseList } = this.props
|
||||
const search = (options, query) =>
|
||||
options.filter(opt => opt.text.includes(query) || opt.value.includes(query))
|
||||
const coursesToRender = courseList.map(course => ({ key: course.code, value: course.code, text: course.name, description: course.code, associations: 'hello' }))
|
||||
return (
|
||||
<Dropdown
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '850px'
|
||||
}}
|
||||
placeholder="Select courses"
|
||||
fluid
|
||||
multiple
|
||||
selection
|
||||
options={coursesToRender}
|
||||
search={search}
|
||||
onSearchChange={this.handleSearchChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading, searchStr } = this.state
|
||||
const { language, courseList } = this.props
|
||||
const { courseTypes, courseDisciplines } = this.props.courseStatistics
|
||||
const text = { en: 'All', fi: 'Kaikki', sv: 'Allt' }
|
||||
const disciplineOptions = courseDisciplines ?
|
||||
[
|
||||
{ text: text[language], value: text[language] },
|
||||
..._.orderBy(courseDisciplines.map(disc => (
|
||||
{
|
||||
text: disc.name.fi ? disc.name.fi : `Name missing (${language})`, // only finnish names available
|
||||
value: disc.name.fi ? disc.name.fi : `Name missing (${language})`,
|
||||
key: disc.discipline_id
|
||||
}
|
||||
)), 'text', 'asc')] : null
|
||||
|
||||
const typeOptions = courseTypes ?
|
||||
[
|
||||
{ text: text[language], value: text[language] },
|
||||
..._.orderBy(courseTypes.map(ty => (
|
||||
{
|
||||
text: ty.name[language] ? ty.name[language] : `Name missing (${language})`,
|
||||
value: ty.name[language] ? ty.name[language] : `Name missing (${language})`,
|
||||
key: ty.coursetypecode
|
||||
}
|
||||
)), 'text', 'asc')] : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form>
|
||||
<Form.Group key="optionals" className={style.yearSelectorGroup}>
|
||||
<Form.Field>
|
||||
<label>Course discipline (optional)</label>
|
||||
{this.renderDisciplinesDropdown(disciplineOptions)}
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Course type (optional)</label>
|
||||
{this.renderCourseTypesDropdown(typeOptions)}
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<Search
|
||||
input={{ fluid: true }}
|
||||
loading={isLoading}
|
||||
placeholder="Search by entering a course code or name"
|
||||
onSearchChange={this.handleSearchChange}
|
||||
value={searchStr}
|
||||
showNoResults={false}
|
||||
/>
|
||||
<Button disabled={isLoading} style={{ marginTop: '14px' }} fluid onClick={this.fetchSelectedCoursesStatistics} content="Fetch statistics" />
|
||||
{courseList.length > 0 ? this.renderResultTable() : null}
|
||||
{/* this.renderResultDropDown() */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CourseStatisticsSearch.propTypes = {
|
||||
language: string.isRequired,
|
||||
courseList: arrayOf(object).isRequired,
|
||||
setTimeout: func.isRequired,
|
||||
clearTimeout: func.isRequired,
|
||||
handleResultSelect: func.isRequired,
|
||||
fetchCourseStatistics: func.isRequired,
|
||||
courseStatistics: shape({
|
||||
courseTypes: arrayOf(shape({})),
|
||||
courseDisciplines: arrayOf(shape({}))
|
||||
}).isRequired,
|
||||
getCourseTypes: func.isRequired,
|
||||
getCourseDisciplines: func.isRequired,
|
||||
findMultipleCourses: func.isRequired,
|
||||
emptyCourseSearch: func.isRequired
|
||||
}
|
||||
|
||||
const sortCourses = makeSortCourses()
|
||||
|
||||
const mapStateToProps = ({ locale, courses, settings, courseStatistics }) => ({
|
||||
language: settings.language,
|
||||
courseList: sortCourses(courses),
|
||||
translate: getTranslate(locale),
|
||||
currentLanguage: getActiveLanguage(locale).value,
|
||||
courseStatistics
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
findMultipleCourses,
|
||||
getMultipleCourseStatistics,
|
||||
getCourseDisciplines,
|
||||
getCourseTypes,
|
||||
emptyCourseSearch
|
||||
})(Timeout(CourseStatisticsSearch))
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
|
||||
const { shape, arrayOf } = PropTypes
|
||||
|
||||
|
||||
const CourseStatisticsTable = ({ stats }) =>
|
||||
((stats.some(year => year.c_passed > 0) || stats.some(year => year.c_failed > 0)) ?
|
||||
(
|
||||
<Table celled padded>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan={4}>Programme</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan={3}>Compared</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Time</Table.HeaderCell>
|
||||
<Table.HeaderCell>Passed Students</Table.HeaderCell>
|
||||
<Table.HeaderCell>Failed Students</Table.HeaderCell>
|
||||
<Table.HeaderCell>Pass rate</Table.HeaderCell>
|
||||
<Table.HeaderCell>Passed Students</Table.HeaderCell>
|
||||
<Table.HeaderCell>Failed Students</Table.HeaderCell>
|
||||
<Table.HeaderCell>Pass rate</Table.HeaderCell>
|
||||
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{stats.map(year => (
|
||||
<Table.Row key={year.time}>
|
||||
<Table.Cell>
|
||||
{year.time}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{year.passed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{year.failed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{`${year.passed + year.failed > 0 ? Math.round((year.passed /
|
||||
(year.passed + year.failed)) * 100) : 0} %`}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{year.c_passed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{year.c_failed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{`${year.c_passed + year.c_failed > 0 ? Math.round((year.c_passed /
|
||||
(year.c_passed + year.c_failed)) * 100) : 0} %`}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
:
|
||||
(
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Time</Table.HeaderCell>
|
||||
<Table.HeaderCell>Passed Students</Table.HeaderCell>
|
||||
<Table.HeaderCell>Failed Students</Table.HeaderCell>
|
||||
<Table.HeaderCell>Pass rate</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{stats.map(year => (
|
||||
<Table.Row key={year.time}>
|
||||
<Table.Cell>
|
||||
{year.time}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{year.passed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{year.failed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{`${year.passed + year.failed > 0 ? Math.round((year.passed /
|
||||
(year.passed + year.failed)) * 100) : 0} %`}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
|
||||
</Table.Body>
|
||||
</Table>
|
||||
))
|
||||
|
||||
|
||||
CourseStatisticsTable.propTypes = {
|
||||
stats: arrayOf(shape({})).isRequired
|
||||
}
|
||||
|
||||
export default CourseStatisticsTable
|
||||
@@ -8,7 +8,7 @@ import { withRouter } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import { clearLoading } from '../../redux/graphSpinner'
|
||||
|
||||
import { DISPLAY_DATE_FORMAT, CHART_COLORS, API_DATE_FORMAT } from '../../constants'
|
||||
import { DISPLAY_DATE_FORMAT, CHART_COLORS } from '../../constants'
|
||||
import { reformatDate, sortDatesWithFormat } from '../../common'
|
||||
import { turquoise } from '../../styles/variables/colors'
|
||||
|
||||
@@ -80,7 +80,7 @@ class CreditAccumulationGraph extends Component {
|
||||
}
|
||||
|
||||
getXAxisMonth = (date, startDate) =>
|
||||
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
|
||||
Math.max(moment.utc(date).diff(moment.utc(startDate), 'days') / 30, 0)
|
||||
|
||||
getReferenceLineForStudent = (student) => {
|
||||
try {
|
||||
@@ -119,6 +119,7 @@ class CreditAccumulationGraph extends Component {
|
||||
|
||||
getStudentCourseData = (student) => {
|
||||
const { studentNumber, courses, studyrightStart, started } = student
|
||||
const { language } = this.props
|
||||
|
||||
const startDate = this.props.selectedStudents.length === 1 ? started : studyrightStart
|
||||
|
||||
@@ -128,19 +129,20 @@ class CreditAccumulationGraph extends Component {
|
||||
let totalCredits = 0
|
||||
return filteredCourses.map((c) => {
|
||||
const {
|
||||
course, date, credits, grade, passed
|
||||
course, date, credits, grade, passed, isStudyModuleCredit
|
||||
} = c
|
||||
if (passed) {
|
||||
if (passed && !isStudyModuleCredit) {
|
||||
totalCredits += credits
|
||||
}
|
||||
return {
|
||||
title: `${course.name} (${course.code})`,
|
||||
title: `${course.name[language]} (${course.code})`,
|
||||
[studentNumber]: totalCredits,
|
||||
credits,
|
||||
date: reformatDate(date, DISPLAY_DATE_FORMAT),
|
||||
month: this.getXAxisMonth(date, startDate),
|
||||
grade,
|
||||
passed
|
||||
passed,
|
||||
isStudyModuleCredit
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -236,19 +238,25 @@ class CreditAccumulationGraph extends Component {
|
||||
return null
|
||||
}
|
||||
}
|
||||
xAxisFormatter = (startDate, month) => (moment(startDate).add(month, 'months').format('MMM YY'))
|
||||
|
||||
render() {
|
||||
const { students, translate, maxCredits } = this.props
|
||||
const { combinedStudentData } = this.state
|
||||
|
||||
const isSingleStudent = this.isSingleStudentGraph(students)
|
||||
if (!isSingleStudent) {
|
||||
return null
|
||||
}
|
||||
const minTick = combinedStudentData && combinedStudentData.length > 0 ?
|
||||
combinedStudentData[0].month : 0
|
||||
const maxTick = combinedStudentData && combinedStudentData.length > 0 ?
|
||||
Math.ceil(combinedStudentData[combinedStudentData.length - 1].month) : 8
|
||||
const referenceLine = isSingleStudent && this.getReferenceLine(translate('graphs.referenceCredits'))
|
||||
const toolTip = isSingleStudent && this.getTooltip(this.props)
|
||||
|
||||
let firstDate = moment()
|
||||
if (isSingleStudent) {
|
||||
firstDate = moment(students[0].started)
|
||||
}
|
||||
return (
|
||||
<div className={styles.graphContainer}>
|
||||
<Segment attached="bottom">
|
||||
@@ -262,10 +270,12 @@ class CreditAccumulationGraph extends Component {
|
||||
domain={[minTick, maxTick]}
|
||||
tick={{ fontSize: '15' }}
|
||||
tickCount={20}
|
||||
tickFormatter={tick => this.xAxisFormatter(firstDate, tick)}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
domain={[0, maxCredits]}
|
||||
tickFormatter={tick => Math.round(tick)}
|
||||
/>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
I {toolTip}
|
||||
@@ -280,6 +290,7 @@ class CreditAccumulationGraph extends Component {
|
||||
}
|
||||
|
||||
CreditAccumulationGraph.propTypes = {
|
||||
language: string.isRequired,
|
||||
translate: func.isRequired,
|
||||
students: arrayOf(object).isRequired,
|
||||
title: string.isRequired,
|
||||
@@ -291,6 +302,7 @@ CreditAccumulationGraph.propTypes = {
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
language: state.settings.language,
|
||||
spinner: state.graphSpinner
|
||||
})
|
||||
|
||||
|
||||
@@ -10,3 +10,7 @@
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.graphOptions {
|
||||
float: right;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import moment from 'moment'
|
||||
import { arrayOf, object, string } from 'prop-types'
|
||||
import { arrayOf, object, string, func, number } from 'prop-types'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import Highcharts from 'highcharts/highstock'
|
||||
import { Button } from 'semantic-ui-react'
|
||||
import boost from 'highcharts/modules/boost'
|
||||
import ReactHighstock from 'react-highcharts/ReactHighstock'
|
||||
import styles from './creditAccumulationGraphHC.css'
|
||||
import { clearLoading } from '../../redux/graphSpinner'
|
||||
import { setChartHeight } from '../../redux/settings'
|
||||
import { reformatDate, sortDatesWithFormat } from '../../common'
|
||||
import { DISPLAY_DATE_FORMAT, API_DATE_FORMAT } from '../../constants'
|
||||
|
||||
boost(Highcharts)
|
||||
|
||||
class CreditAccumulationGraphHighCharts extends Component {
|
||||
state = {
|
||||
studentCreditLines: [],
|
||||
timeout: undefined,
|
||||
options: [],
|
||||
initialLoad: true
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
studentCreditLines: [],
|
||||
options: []
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
async componentDidMount() {
|
||||
const { students } = this.props
|
||||
const timeout = setTimeout(() => this.getMoreCreditLines(students), 1000)
|
||||
await this.getMoreCreditLines(students)
|
||||
const self = this
|
||||
const dataOfSelected = this.createStudentCreditLines(students).filter(line =>
|
||||
this.props.selectedStudents.includes(line.name))
|
||||
|
||||
const options = {
|
||||
chart: {
|
||||
height: 1000
|
||||
height: this.props.currentGraphSize
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
@@ -62,49 +65,50 @@ class CreditAccumulationGraphHighCharts extends Component {
|
||||
}
|
||||
}
|
||||
},
|
||||
series: dataOfSelected
|
||||
series: dataOfSelected,
|
||||
xAxis: {
|
||||
ordinal: false,
|
||||
max: students.maxDate,
|
||||
min: students.minDate
|
||||
}
|
||||
}
|
||||
this.setState({ timeout, options })
|
||||
this.setState({ options })
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
async componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.students) {
|
||||
const nextStudents = nextProps.students.map(student => student.studentNumber)
|
||||
const oldStudents = this.props.students.map(student => student.studentNumber)
|
||||
|
||||
const changed =
|
||||
nextStudents.some(student => !oldStudents.includes(student)) ||
|
||||
oldStudents.some(student => !nextStudents.includes(student))
|
||||
const dataOfSelected = this.state.studentCreditLines.filter(line =>
|
||||
nextProps.selectedStudents.includes(line.name))
|
||||
const options = { ...this.state.options, yAxis: { max: nextProps.students.maxCredits, title: { text: 'Credits' } }, series: dataOfSelected }
|
||||
|
||||
const options = {
|
||||
...this.state.options,
|
||||
yAxis: {
|
||||
max: nextProps.students.maxCredits,
|
||||
title: { text: 'Credits' }
|
||||
},
|
||||
xAxis: {
|
||||
max: nextProps.students.maxDate,
|
||||
min: nextProps.students.minDate
|
||||
},
|
||||
series: dataOfSelected,
|
||||
chart: {
|
||||
height: nextProps.currentGraphSize
|
||||
}
|
||||
}
|
||||
this.setState({ options })
|
||||
|
||||
if (changed) {
|
||||
const { students } = nextProps
|
||||
const timeout = setTimeout(() => this.getMoreCreditLines(students), 1000)
|
||||
this.setState({ timeout })
|
||||
await this.getMoreCreditLines(students)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.state.initialLoad) {
|
||||
if (
|
||||
this.props.selectedStudents &&
|
||||
this.state.studentCreditLines.length === this.props.selectedStudents.length
|
||||
) {
|
||||
this.setState({ initialLoad: false }) // eslint-disable-line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.state.timeout) {
|
||||
clearInterval(this.state.timeout)
|
||||
}
|
||||
}
|
||||
getXAxisMonth = (date, startDate) =>
|
||||
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
|
||||
|
||||
@@ -146,7 +150,7 @@ class CreditAccumulationGraphHighCharts extends Component {
|
||||
})
|
||||
}
|
||||
|
||||
getMoreCreditLines = (students) => {
|
||||
getMoreCreditLines = async (students) => {
|
||||
const studentCreditLines = this.state.studentCreditLines
|
||||
.concat(this.createStudentCreditLines(students))
|
||||
this.setState({ studentCreditLines })
|
||||
@@ -166,38 +170,66 @@ class CreditAccumulationGraphHighCharts extends Component {
|
||||
students.map((student) => {
|
||||
let credits = 0
|
||||
const points = student.courses.map((course) => {
|
||||
if (course.grade !== 'Hyl.') {
|
||||
if (!['Luop', 'Hyl.', 'Eisa', '0', 'Fail'].includes(course.grade) && !course.isStudyModuleCredit) {
|
||||
credits += course.credits
|
||||
}
|
||||
return [new Date(course.date).getTime(), credits]
|
||||
})
|
||||
return { name: student.studentNumber, data: points, boostThreshold: 500 }
|
||||
return { name: student.studentNumber, data: points, seriesThreshold: 150 }
|
||||
})
|
||||
|
||||
resizeChart = (size) => {
|
||||
if (this.chart) {
|
||||
this.props.setChartHeight(size)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.graphContainer}>
|
||||
<ReactHighstock
|
||||
highcharts={Highcharts}
|
||||
ref={(HighchartsReact) => {
|
||||
this.chart = HighchartsReact
|
||||
}}
|
||||
constructorType="stockChart"
|
||||
config={this.state.options}
|
||||
/>
|
||||
<div>
|
||||
<div className={styles.graphContainer}>
|
||||
<div className={styles.graphOptions}>
|
||||
<Button
|
||||
active={this.props.currentGraphSize === 400}
|
||||
onClick={() => this.resizeChart(400)}
|
||||
content="Small"
|
||||
/>
|
||||
<Button
|
||||
active={this.props.currentGraphSize === 600}
|
||||
onClick={() => this.resizeChart(600)}
|
||||
content="Medium"
|
||||
/>
|
||||
<Button
|
||||
active={this.props.currentGraphSize === 1000}
|
||||
onClick={() => this.resizeChart(1000)}
|
||||
content="Large"
|
||||
/>
|
||||
</div>
|
||||
<ReactHighstock
|
||||
highcharts={Highcharts}
|
||||
ref={(HighchartsReact) => {
|
||||
this.chart = HighchartsReact
|
||||
}}
|
||||
constructorType="stockChart"
|
||||
config={this.state.options}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
CreditAccumulationGraphHighCharts.propTypes = {
|
||||
students: arrayOf(object).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
setChartHeight: func.isRequired,
|
||||
currentGraphSize: number.isRequired
|
||||
}
|
||||
const mapStateToProps = state => ({
|
||||
spinner: state.graphSpinner
|
||||
spinner: state.graphSpinner,
|
||||
currentGraphSize: state.settings.chartHeight
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ clearLoading }
|
||||
{ clearLoading, setChartHeight }
|
||||
)(withRouter(CreditAccumulationGraphHighCharts))
|
||||
|
||||
@@ -4,10 +4,15 @@ import { func, bool, arrayOf, object } from 'prop-types'
|
||||
|
||||
import styles from './creditGraphTooltip.css'
|
||||
|
||||
const getCardHeader = title => (
|
||||
<Card.Header className={styles.tooltipHeader}>
|
||||
{title}
|
||||
</Card.Header>
|
||||
const getCardHeader = (title, isStudyModuleCredit) => (
|
||||
isStudyModuleCredit ?
|
||||
<Card.Header className={styles.tooltipHeader}>
|
||||
{`${title} [Study Module]`}
|
||||
</Card.Header>
|
||||
:
|
||||
<Card.Header className={styles.tooltipHeader}>
|
||||
{title}
|
||||
</Card.Header>
|
||||
)
|
||||
|
||||
const getCardMeta = (name, date) => (
|
||||
@@ -26,7 +31,7 @@ const getCardMeta = (name, date) => (
|
||||
</Card.Meta>
|
||||
)
|
||||
|
||||
const getCardDescription = (translate, credits, grade, passed) => (
|
||||
const getCardDescription = (translate, credits, grade, passed, isStudyModuleCredit) => (
|
||||
<Card.Description className={styles.tooltipBody}>
|
||||
<div className={styles.tooltipBodyItem}>
|
||||
<div className={styles.tooltipBodyTitle}>{translate('common.credits')}</div>
|
||||
@@ -38,12 +43,14 @@ const getCardDescription = (translate, credits, grade, passed) => (
|
||||
<div className={styles.tooltipBodyValue}>{grade}</div>
|
||||
</div>
|
||||
<div className={styles.tooltipBodyItem}>
|
||||
<div className={styles.tooltipBodyTitle}>{translate('common.passed')}</div>
|
||||
<div className={styles.tooltipBodyTitle}>{isStudyModuleCredit ? 'module' : translate('common.passed')}</div>
|
||||
<div className={styles.tooltipBodyValue}>
|
||||
{
|
||||
passed
|
||||
? (<Icon name="check circle outline" color="green" />)
|
||||
: (<Icon name="remove circle outline" color="red" />)
|
||||
isStudyModuleCredit // eslint-disable-line
|
||||
? (<Icon name="certificate" color="purple" />)
|
||||
: passed
|
||||
? (<Icon name="check circle outline" color="green" />)
|
||||
: (<Icon name="remove circle outline" color="red" />)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,15 +63,14 @@ const CreditGraphTooltip = (props) => {
|
||||
const { payload } = props
|
||||
const { name } = payload[0]
|
||||
const {
|
||||
title, credits, date, grade, passed
|
||||
title, credits, date, grade, passed, isStudyModuleCredit
|
||||
} = payload[0].payload
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Content >
|
||||
{getCardHeader(title)}
|
||||
{getCardHeader(title, isStudyModuleCredit)}
|
||||
{getCardMeta(name, date)}
|
||||
{getCardDescription(translate, credits, grade, passed)}
|
||||
{getCardDescription(translate, credits, grade, passed, isStudyModuleCredit)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,131 +1,183 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Button, Dropdown, Grid, List } from 'semantic-ui-react'
|
||||
import { withRouter, Redirect } from 'react-router-dom'
|
||||
import { Button, Icon, Card, Header, Segment, Confirm } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, shape, string, number, bool, arrayOf } from 'prop-types'
|
||||
import { func, shape, string, bool, arrayOf } from 'prop-types'
|
||||
import { getTranslate, getActiveLanguage } from 'react-localize-redux'
|
||||
|
||||
import styles from './enableUsers.css'
|
||||
import { getUsers, enableUser, addUserUnit, removeUserUnit } from '../../redux/users'
|
||||
import { getUsers, enableUser, addUserUnit, removeUserUnit, sendEmail } from '../../redux/users'
|
||||
import { getUnits } from '../../redux/units'
|
||||
import { makeSortUsers } from '../../selectors/users'
|
||||
import { copyToClipboard } from '../../common'
|
||||
import sharedStyles from '../../styles/shared'
|
||||
import UserPageNew from '../UserPage'
|
||||
|
||||
class EnableUsers extends Component {
|
||||
state = {
|
||||
confirm: false,
|
||||
email: ''
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.getUsers()
|
||||
this.props.getUnits()
|
||||
}
|
||||
|
||||
enableUser = id => () => this.props.enableUser(id)
|
||||
|
||||
handleChange = user => (e, { value }) => {
|
||||
if (!user.units.find(unit => unit.id === value)) {
|
||||
this.props.addUserUnit(user.id, value)
|
||||
enableUser = user => () => {
|
||||
if (!user.is_enabled) {
|
||||
this.setState({ confirm: true, email: user.email })
|
||||
}
|
||||
this.props.enableUser(user.id)
|
||||
}
|
||||
|
||||
removeAccess = (uid, unit) => () => this.props.removeUserUnit(uid, unit)
|
||||
openEditUserPage = userid => () => {
|
||||
const { history } = this.props
|
||||
history.push(`users/${userid}`)
|
||||
}
|
||||
|
||||
renderUnitList = (units, user) => {
|
||||
if (!units) return null
|
||||
return (
|
||||
<List divided verticalAlign="middle">
|
||||
{units.map(unit => (
|
||||
<List.Item key={unit.id}>
|
||||
<List.Content floated="right">
|
||||
<Button onClick={this.removeAccess(user, unit.id)}>Remove</Button>
|
||||
</List.Content>
|
||||
<List.Content>
|
||||
{unit.name}
|
||||
</List.Content>
|
||||
</List.Item>
|
||||
openUsersPage = () => {
|
||||
const { history } = this.props
|
||||
history.push('/users')
|
||||
}
|
||||
|
||||
copyEmailsToClippoard = () => {
|
||||
const clipboardString = this.props.users
|
||||
.filter(u => u.is_enabled && u.email)
|
||||
.map(u => u.email).join('; ')
|
||||
copyToClipboard(clipboardString)
|
||||
}
|
||||
|
||||
renderUserPage = (userid) => {
|
||||
const { users } = this.props
|
||||
const user = users.find(u => u.id === userid)
|
||||
return !user
|
||||
? <Redirect to="/users" />
|
||||
: (
|
||||
<UserPageNew
|
||||
userid={userid}
|
||||
user={user}
|
||||
goBack={this.openUsersPage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
renderUserSearchList = () => {
|
||||
const { users, error } = this.props
|
||||
return error ? null : (
|
||||
<Card.Group itemsPerRow={4}>
|
||||
{this.state.confirm ? <Confirm
|
||||
style={{
|
||||
marginTop: 'auto !important',
|
||||
display: 'inline-block !important',
|
||||
position: 'relative',
|
||||
top: '20%',
|
||||
left: '33%'
|
||||
}}
|
||||
open={this.state.confirm}
|
||||
cancelButton="no"
|
||||
confirmButton="send"
|
||||
content="Do you want to notify this person by email?"
|
||||
onCancel={() => { this.setState({ confirm: false }) }}
|
||||
onConfirm={() => {
|
||||
this.setState({ confirm: false })
|
||||
this.props.sendEmail(this.state.email)
|
||||
}}
|
||||
size="small"
|
||||
/> : null}
|
||||
{users.map(user => (
|
||||
<Card raised key={user.id} color={user.is_enabled ? 'green' : 'red'}>
|
||||
<Card.Content>
|
||||
<Card.Header content={user.full_name} />
|
||||
<Card.Meta content={user.username} />
|
||||
<Card.Meta content={user.email} />
|
||||
<Card.Description>
|
||||
{`Access to oodikone: ${user.is_enabled ? 'En' : 'Dis'}abled`}
|
||||
</Card.Description>
|
||||
</Card.Content>
|
||||
<Card.Content extra>
|
||||
<Button.Group compact widths={8}>
|
||||
<Button animated basic size="mini" disabled={!user.is_enabled} onClick={this.openEditUserPage(user.id)}>
|
||||
<Button.Content hidden>Edit</Button.Content>
|
||||
<Button.Content visible>
|
||||
<Icon name="wrench" />
|
||||
</Button.Content>
|
||||
</Button>
|
||||
<Button animated basic onClick={this.enableUser(user)} size="mini">
|
||||
<Button.Content hidden>{user.is_enabled ? 'Disable' : 'Enable'}</Button.Content>
|
||||
<Button.Content visible>
|
||||
<Icon color={user.is_enabled ? 'green' : 'red'} name={user.is_enabled ? 'check' : 'remove'} />
|
||||
</Button.Content>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
</List>
|
||||
</Card.Group>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { users, error, units } = this.props
|
||||
const unitOptions = units.map(unit => ({ key: unit.id, value: unit.id, text: unit.name }))
|
||||
return error ? null : (
|
||||
<div styles={styles.container}>
|
||||
<h1>Enable or disable access to OodiKone</h1>
|
||||
{users.map(u => (
|
||||
<Grid key={u.id} divided="vertically">
|
||||
<Grid.Row columns={2}>
|
||||
<Grid.Column>
|
||||
<h3>{u.full_name}, {u.username} - Access to oodikone: {u.is_enabled ? 'En' : 'Dis'}abled</h3>
|
||||
<Dropdown
|
||||
placeholder="Select unit"
|
||||
options={unitOptions}
|
||||
onChange={this.handleChange(u)}
|
||||
fluid
|
||||
search
|
||||
selection
|
||||
/>
|
||||
</Grid.Column>
|
||||
<Grid.Column>
|
||||
<Button onClick={this.enableUser(u.id)}>Enable/Disable</Button>
|
||||
{this.renderUnitList(u.units, u.id)}
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
))}
|
||||
const { match } = this.props
|
||||
const { userid } = match.params
|
||||
return (
|
||||
<div className={sharedStyles.segmentContainer}>
|
||||
<Header className={sharedStyles.segmentTitle} size="large">
|
||||
Enable or disable access to Oodikone
|
||||
</Header>
|
||||
<Segment className={sharedStyles.contentSegment}>
|
||||
{!userid ? this.renderUserSearchList() : this.renderUserPage(userid)}
|
||||
</Segment>
|
||||
<Icon
|
||||
link
|
||||
name="envelope"
|
||||
onClick={this.copyEmailsToClippoard}
|
||||
style={{ float: 'right' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
EnableUsers.propTypes = {
|
||||
match: shape({
|
||||
params: shape({
|
||||
studentNumber: string
|
||||
})
|
||||
}).isRequired,
|
||||
getUsers: func.isRequired,
|
||||
enableUser: func.isRequired,
|
||||
addUserUnit: func.isRequired,
|
||||
removeUserUnit: func.isRequired,
|
||||
getUnits: func.isRequired,
|
||||
units: arrayOf(shape({
|
||||
id: number,
|
||||
name: string
|
||||
})).isRequired,
|
||||
sendEmail: func.isRequired,
|
||||
users: arrayOf(shape({
|
||||
id: number,
|
||||
id: string,
|
||||
full_name: string,
|
||||
is_enabled: bool,
|
||||
username: string,
|
||||
units: arrayOf(shape({
|
||||
id: number,
|
||||
name: string
|
||||
id: string,
|
||||
name: shape({}).isRequired
|
||||
}))
|
||||
})).isRequired,
|
||||
error: bool.isRequired
|
||||
error: bool.isRequired,
|
||||
history: shape({}).isRequired
|
||||
}
|
||||
|
||||
const sortUsers = makeSortUsers()
|
||||
|
||||
const mapStateToProps = ({ locale, users, units }) => ({
|
||||
const mapStateToProps = ({ locale, users, units, settings }) => ({
|
||||
language: settings.language,
|
||||
translate: getTranslate(locale),
|
||||
currentLanguage: getActiveLanguage(locale).value,
|
||||
units: units.data,
|
||||
users: sortUsers(users),
|
||||
pending: users.pending,
|
||||
pending: (typeof (users.pending) === 'boolean') ? users.pending : true,
|
||||
error: users.error || false
|
||||
})
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
getUsers: () => {
|
||||
dispatch(getUsers())
|
||||
},
|
||||
enableUser: (id) => {
|
||||
dispatch(enableUser(id))
|
||||
},
|
||||
addUserUnit: (uid, unit) => {
|
||||
dispatch(addUserUnit(uid, unit))
|
||||
},
|
||||
removeUserUnit: (uid, unit) => {
|
||||
dispatch(removeUserUnit(uid, unit))
|
||||
},
|
||||
getUnits: () => {
|
||||
dispatch(getUnits())
|
||||
}
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EnableUsers)
|
||||
export default withRouter(connect(mapStateToProps, {
|
||||
getUsers,
|
||||
enableUser,
|
||||
addUserUnit,
|
||||
removeUserUnit,
|
||||
getUnits,
|
||||
sendEmail
|
||||
})(EnableUsers))
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Dropdown } from 'semantic-ui-react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import { getUserName, setUserLanguage, getUserLanguage } from '../../common'
|
||||
import { switchLanguage, initLanguage } from '../../redux/settings'
|
||||
|
||||
const { func, shape, string } = PropTypes
|
||||
|
||||
const languages = [
|
||||
{ key: 'fi', text: 'fi', value: 'fi' },
|
||||
{ key: 'en', text: 'en', value: 'en' },
|
||||
{ key: 'sv', text: 'sv', value: 'sv' }
|
||||
]
|
||||
|
||||
class LanguageChooser extends Component {
|
||||
async componentDidMount() {
|
||||
const initialLanguage = await getUserLanguage()
|
||||
this.props.initLanguage(initialLanguage)
|
||||
}
|
||||
|
||||
change = () => async (e, { value }) => {
|
||||
const name = await getUserName()
|
||||
this.props.switchLanguage(name, value)
|
||||
await setUserLanguage(value)
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<Dropdown
|
||||
size="small"
|
||||
className="icon"
|
||||
floating
|
||||
icon="world"
|
||||
onChange={this.change()}
|
||||
text={this.props.settings.language}
|
||||
options={languages}
|
||||
button
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ settings }) => ({
|
||||
settings
|
||||
})
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
switchLanguage: (username, language) =>
|
||||
dispatch(switchLanguage(username, language)),
|
||||
initLanguage: language =>
|
||||
dispatch(initLanguage(language))
|
||||
})
|
||||
|
||||
LanguageChooser.propTypes = {
|
||||
settings: shape({ language: string.isRequired }).isRequired,
|
||||
switchLanguage: func.isRequired,
|
||||
initLanguage: func.isRequired
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(LanguageChooser)
|
||||
@@ -1,34 +1,41 @@
|
||||
import React, { Component } from 'react'
|
||||
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
|
||||
import { localize } from 'react-localize-redux'
|
||||
import { Loader } from 'semantic-ui-react'
|
||||
import { Loader, Image, Transition } from 'semantic-ui-react'
|
||||
|
||||
import Header from '../Header'
|
||||
import Populations from '../PopulationStatistics'
|
||||
import DepartmentSuccess from '../DepartmentSuccess'
|
||||
import WelcomePage from '../WelcomePage'
|
||||
import StudentStatistics from '../StudentStatistics'
|
||||
import CourseInstances from '../CourseInstances'
|
||||
import CourseStatistics from '../CourseStatistics'
|
||||
import EnableUsers from '../EnableUsers'
|
||||
import Settings from '../Settings'
|
||||
import ErrorContainer from '../ErrorContainer'
|
||||
import { routes, BASE_PATH } from '../../constants'
|
||||
import AccessDenied from '../AccessDenied'
|
||||
import UsageStatistics from '../UsageStatistics'
|
||||
import Teachers from '../Teachers'
|
||||
|
||||
import styles from './main.css'
|
||||
|
||||
import { userIsEnabled, log } from '../../common'
|
||||
import { userIsEnabled, log, images } from '../../common'
|
||||
|
||||
class Main extends Component {
|
||||
state = {
|
||||
enabled: false,
|
||||
hasError: false,
|
||||
loaded: false
|
||||
loaded: false,
|
||||
easterEgg: false
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const enabled = await userIsEnabled()
|
||||
if (!enabled) {
|
||||
log('Not enabled')
|
||||
setTimeout(
|
||||
() => this.setState({ easterEgg: true }),
|
||||
Math.floor(Math.random() * 1800000) + 600000
|
||||
)
|
||||
}
|
||||
this.setState({ enabled, loaded: true })
|
||||
}
|
||||
@@ -38,11 +45,19 @@ class Main extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log('Wed May 30 19:55:39 EEST 2018') // eslint-disable-line
|
||||
if (!this.state.loaded) {
|
||||
return <Loader active inline="centered" />
|
||||
}
|
||||
if (!this.state.enabled || this.state.hasError) {
|
||||
return <AccessDenied itWasError={this.state.hasError} />
|
||||
return (
|
||||
<div>
|
||||
<AccessDenied itWasError={this.state.hasError} />
|
||||
<Transition visible={this.state.easterEgg} animation="fly up" duration={10000}>
|
||||
<Image src={images.irtomikko} size="huge" verticalAlign="top" inline style={{ position: 'absolute', top: '350px', right: '10px' }} />
|
||||
</Transition>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={styles.appContainer}>
|
||||
@@ -51,12 +66,14 @@ class Main extends Component {
|
||||
<Header />
|
||||
<ErrorContainer />
|
||||
<Switch>
|
||||
<Route exact path={routes.index.route} component={DepartmentSuccess} />
|
||||
<Route exact path={routes.index.route} component={WelcomePage} />
|
||||
<Route exact path={routes.populations.route} component={Populations} />
|
||||
<Route exact path={routes.students.route} component={StudentStatistics} />
|
||||
<Route exact path={routes.courseInstances.route} component={CourseInstances} />
|
||||
<Route exact path={routes.courseStatistics.route} component={CourseStatistics} />
|
||||
<Route exact path={routes.settings.route} component={Settings} />
|
||||
<Route exact path={routes.users.route} component={EnableUsers} />
|
||||
<Route exact path={routes.teachers.route} component={Teachers} />
|
||||
<Route exact path={routes.usage.route} component={UsageStatistics} />
|
||||
</Switch>
|
||||
</main>
|
||||
</Router>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { func } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { routes } from '../../constants'
|
||||
import { userIsAdmin } from '../../common'
|
||||
import { userIsAdmin, userIsCzar } from '../../common'
|
||||
|
||||
import styles from './navigationBar.css'
|
||||
import { logout, swapDevUser } from '../../apiConnection'
|
||||
@@ -18,9 +18,12 @@ class NavigationBar extends Component {
|
||||
async componentDidMount() {
|
||||
const navigationRoutes = { ...routes }
|
||||
const adminRights = await userIsAdmin()
|
||||
const czarRights = await userIsCzar()
|
||||
Object.keys(navigationRoutes).forEach((key) => {
|
||||
if (navigationRoutes[key].admin && !adminRights) {
|
||||
delete navigationRoutes[key]
|
||||
} else if (navigationRoutes[key].czar && (!adminRights && !czarRights)) {
|
||||
delete navigationRoutes[key]
|
||||
}
|
||||
})
|
||||
this.setState({ navigationRoutes })
|
||||
@@ -39,7 +42,7 @@ class NavigationBar extends Component {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const testUsers = ['tktl']
|
||||
return (
|
||||
<Menu.Item as={Dropdown} style={{ backgroundColor: 'purple', color: 'white' }} text="Dev controls">
|
||||
<Menu.Item as={Dropdown} style={{ backgroundColor: 'purple', color: 'white' }} text="Dev controls" tabIndex="-1">
|
||||
<Dropdown.Menu>
|
||||
{testUsers.map(user => (
|
||||
<Dropdown.Item
|
||||
@@ -60,7 +63,7 @@ class NavigationBar extends Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.Item link onClick={logout} icon="log out">
|
||||
<Menu.Item link onClick={logout} icon="log out" tabIndex="-1">
|
||||
{translate('navigationBar.logout')}
|
||||
</Menu.Item>
|
||||
)
|
||||
@@ -70,12 +73,12 @@ class NavigationBar extends Component {
|
||||
const t = this.props.translate
|
||||
const { navigationRoutes } = this.state
|
||||
const menuWidth = Object.keys(navigationRoutes).length + 2
|
||||
|
||||
return (
|
||||
<Menu stackable fluid widths={menuWidth} className={styles.navBar}>
|
||||
<Menu.Item
|
||||
as={Link}
|
||||
to={navigationRoutes.index.route}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<span className={styles.logo}>
|
||||
<h2 className={styles.logoText}>oodikone</h2>
|
||||
@@ -84,6 +87,9 @@ class NavigationBar extends Component {
|
||||
{
|
||||
Object.values(navigationRoutes).map((value) => {
|
||||
const viewableRoute = this.checkForOptionalParams(value.route)
|
||||
if (value.route === '/') {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Menu.Item
|
||||
exact={viewableRoute === value.route}
|
||||
@@ -91,6 +97,7 @@ class NavigationBar extends Component {
|
||||
as={NavLink}
|
||||
key={`menu-item-${viewableRoute}`}
|
||||
to={this.checkForOptionalParams(viewableRoute)}
|
||||
tabIndex="-1"
|
||||
>
|
||||
{t(`navigationBar.${value.translateId}`)}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -1,42 +1,49 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Table, Form, Input } from 'semantic-ui-react'
|
||||
import { func, arrayOf, object, number } from 'prop-types'
|
||||
import { Table, Form, Input, Popup, Button, Icon } from 'semantic-ui-react'
|
||||
import { func, arrayOf, object, number, shape, string } from 'prop-types'
|
||||
import { getTranslate } from 'react-localize-redux'
|
||||
import { setPopulationLimit, clearPopulationLimit } from '../../redux/populationLimit'
|
||||
import { setLoading } from '../../redux/graphSpinner'
|
||||
import _ from 'lodash'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import moment from 'moment'
|
||||
|
||||
import { setPopulationFilter, removePopulationFilterOfCourse } from '../../redux/populationFilters'
|
||||
import { getMultipleCourseStatistics } from '../../redux/courseStatistics'
|
||||
import { courseParticipation } from '../../populationFilters'
|
||||
|
||||
const formatGradeDistribution = grades => _.replace(JSON.stringify(_.sortBy(Object.entries(grades).map(([key, value]) => ({ [key]: value.count })), o => -Object.keys(o)), null, 1), /\[\n|{\n*|{\s|}|\s*}|]|"|,/g, '')
|
||||
|
||||
class PopulationCourseStats extends Component {
|
||||
static propTypes = {
|
||||
courses: arrayOf(object).isRequired,
|
||||
courses: shape({
|
||||
coursestatistics: arrayOf(object).isRequired,
|
||||
coursetypes: shape({}).isRequired,
|
||||
disciplines: shape({}).isRequired
|
||||
}).isRequired,
|
||||
translate: func.isRequired,
|
||||
setPopulationLimit: func.isRequired,
|
||||
setLoading: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
populationSize: number.isRequired,
|
||||
selected: object // eslint-disable-line
|
||||
selectedCourses: arrayOf(object).isRequired,
|
||||
removePopulationFilterOfCourse: func.isRequired,
|
||||
history: shape({}).isRequired,
|
||||
getMultipleCourseStatistics: func.isRequired,
|
||||
language: string.isRequired,
|
||||
query: shape({}).isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
sortBy: 'students',
|
||||
reversed: false,
|
||||
limit: parseInt(this.props.populationSize * 0.15, 10)
|
||||
}
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
limitPopulationToCourse = course => () => {
|
||||
this.props.setLoading()
|
||||
this.props.setPopulationLimit(course, 'all')
|
||||
}
|
||||
|
||||
sortBy(criteria) {
|
||||
return () => {
|
||||
let { reversed } = this.state
|
||||
if (this.state.sortBy === 'percentage' && criteria === 'percentage') {
|
||||
reversed = !reversed
|
||||
}
|
||||
this.setState({ sortBy: criteria, reversed })
|
||||
this.state = {
|
||||
sortBy: 'students',
|
||||
reversed: false,
|
||||
limit: parseInt(this.props.populationSize * 0.15, 10),
|
||||
showGradeDistribution: false
|
||||
}
|
||||
}
|
||||
|
||||
getSelectionStyle = criteria => (this.state.sortBy === criteria ? ({ background: 'darkgray' }) : ({ background: '#f9fafb' }))
|
||||
|
||||
criteria = () => (c1, c2) => {
|
||||
const orderByCode = (code1, code2) =>
|
||||
(code1.course.code < code2.course.code ? -1 : 1)
|
||||
@@ -56,16 +63,316 @@ class PopulationCourseStats extends Component {
|
||||
return course.stats.students >= this.state.limit
|
||||
}
|
||||
|
||||
sortBy(criteria) {
|
||||
return () => {
|
||||
let { reversed } = this.state
|
||||
if (this.state.sortBy === criteria) {
|
||||
reversed = !reversed
|
||||
}
|
||||
this.setState({ sortBy: criteria, reversed })
|
||||
}
|
||||
}
|
||||
|
||||
limitPopulationToCourse = course => () => {
|
||||
if (!this.active(course.course)) {
|
||||
const params = { course, field: 'all' }
|
||||
this.props.setPopulationFilter(courseParticipation(params))
|
||||
} else {
|
||||
this.props.removePopulationFilterOfCourse(course.course)
|
||||
}
|
||||
}
|
||||
|
||||
active = course =>
|
||||
this.props.selectedCourses
|
||||
.find(c => course.name === c.name && course.code === c.code) !== undefined
|
||||
|
||||
renderSortArrow = (criteria) => {
|
||||
if (this.state.sortBy === criteria) {
|
||||
if (this.state.reversed) {
|
||||
return (<Icon name="angle up" />)
|
||||
}
|
||||
return (<Icon name="angle down" />)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
renderGradeDistributionTable = ({ translate, sortBy, courses, language }) => (
|
||||
<Table celled sortable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="2" >
|
||||
{translate('populationCourses.course')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>{translate('populationCourses.code')}</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'students' ? 'descending' : null}
|
||||
onClick={this.sortBy('students')}
|
||||
style={this.getSelectionStyle('students')}
|
||||
>
|
||||
Attempts
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
0
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
1
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
2
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
3
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
4
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
5
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Other passed
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{courses.coursestatistics.sort(this.criteria()).filter(this.limit()).map(course => (
|
||||
<Popup
|
||||
key={course.course.code}
|
||||
trigger={
|
||||
<Table.Row active={this.active(course.course)}>
|
||||
<Table.Cell onClick={this.limitPopulationToCourse(course)}>
|
||||
{course.course.name[language]}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
icon="level up alternate"
|
||||
onClick={() => {
|
||||
this.props.history.push('/coursestatistics/')
|
||||
this.props.getMultipleCourseStatistics({
|
||||
codes: [course.course.code],
|
||||
start: Number(this.props.query.year),
|
||||
end: Number(moment(moment(this.props.query.year, 'YYYY').add(this.props.query.months, 'months')).format('YYYY')),
|
||||
separate: false,
|
||||
language: this.props.language
|
||||
})
|
||||
}}
|
||||
style={{ borderLeft: '0px !important' }}
|
||||
/>
|
||||
<Table.Cell>{course.course.code}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades ? _.sum(Object.values(course.grades).map(g => g.count)) || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades ?
|
||||
_.sum(Object.values(course.grades).filter(g =>
|
||||
g.status.failingGrade).map(g => g.count))
|
||||
|| 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades[1] ? course.grades[1].count || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades[2] ? course.grades[2].count || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades[3] ? course.grades[3].count || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades[4] ? course.grades[4].count || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades[5] ? course.grades[5].count || 0 : 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.grades ?
|
||||
_.sum(Object.values(_.omit(course.grades, [1, 2, 3, 4, 5])).filter(g =>
|
||||
g.status.passingGrade || g.status.improvedGrade).map(g => g.count)) : 0}
|
||||
</Table.Cell>
|
||||
</Table.Row>}
|
||||
flowing
|
||||
hoverable
|
||||
inverted
|
||||
position="top right"
|
||||
hideOnScroll
|
||||
content={course.grades ? <pre>{formatGradeDistribution(course.grades)}</pre> : 'Nothing to see here'}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
renderBasicTable = ({ translate, sortBy, direction, courses, language }) => (
|
||||
<Table celled sortable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="2">
|
||||
{translate('populationCourses.course')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
rowSpan="2"
|
||||
sorted={sortBy === 'students' ? 'descending' : null}
|
||||
onClick={this.sortBy('students')}
|
||||
style={this.getSelectionStyle('students')}
|
||||
>
|
||||
{translate('populationCourses.students')}
|
||||
{this.renderSortArrow('students')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
colSpan="3"
|
||||
>
|
||||
{translate('populationCourses.passed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
colSpan="2"
|
||||
>
|
||||
{translate('populationCourses.failed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan="2">{translate('populationCourses.attempts')}</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan="2">{translate('populationCourses.percentageOfPopulation')}</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="1" >{translate('populationCourses.name')}</Table.HeaderCell>
|
||||
<Table.HeaderCell>{translate('populationCourses.code')}</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'passed' ? 'descending' : null}
|
||||
onClick={this.sortBy('passed')}
|
||||
style={this.getSelectionStyle('passed')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.number')}
|
||||
{this.renderSortArrow('passed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'retryPassed' ? 'descending' : null}
|
||||
onClick={this.sortBy('retryPassed')}
|
||||
style={this.getSelectionStyle('retryPassed')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.passedAfterRetry')}
|
||||
{this.renderSortArrow('retryPassed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'percentage' ? direction : null}
|
||||
onClick={this.sortBy('percentage')}
|
||||
style={this.getSelectionStyle('percentage')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.percentage')}
|
||||
{this.renderSortArrow('percentage')}
|
||||
</Table.HeaderCell>
|
||||
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'failed' ? 'descending' : null}
|
||||
onClick={this.sortBy('failed')}
|
||||
style={this.getSelectionStyle('failed')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.number')}
|
||||
{this.renderSortArrow('failed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
rowSpan="2"
|
||||
sorted={sortBy === 'failedMany' ? 'descending' : null}
|
||||
onClick={this.sortBy('failedMany')}
|
||||
style={this.getSelectionStyle('failedMany')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.failedManyTimes')}
|
||||
{this.renderSortArrow('failedMany')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'attempts' ? 'descending' : null}
|
||||
onClick={this.sortBy('attempts')}
|
||||
style={this.getSelectionStyle('attempts')}
|
||||
>
|
||||
{translate('populationCourses.number')}
|
||||
{this.renderSortArrow('attempts')}
|
||||
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'perStudent' ? 'descending' : null}
|
||||
onClick={this.sortBy('perStudent')}
|
||||
style={this.getSelectionStyle('perStudent')}
|
||||
>
|
||||
{translate('populationCourses.perStudent')}
|
||||
{this.renderSortArrow('perStudent')}
|
||||
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'passedOfPopulation' ? direction : null}
|
||||
onClick={this.sortBy('passedOfPopulation')}
|
||||
style={this.getSelectionStyle('passedOfPopulation')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.passed')}
|
||||
{this.renderSortArrow('passedOfPopulation')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'triedOfPopulation' ? direction : null}
|
||||
onClick={this.sortBy('triedOfPopulation')}
|
||||
style={this.getSelectionStyle('triedOfPopulation')}
|
||||
|
||||
>
|
||||
{translate('populationCourses.attempted')}
|
||||
{this.renderSortArrow('triedOfPopulation')}
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{courses.coursestatistics.sort(this.criteria()).filter(this.limit()).map(course => (
|
||||
<Table.Row key={course.course.code} active={this.active(course.course)}>
|
||||
<Table.Cell onClick={this.limitPopulationToCourse(course)}>
|
||||
{course.course.name[language]}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
icon="level up alternate"
|
||||
onClick={() => {
|
||||
this.props.history.push('/coursestatistics/')
|
||||
this.props.getMultipleCourseStatistics({
|
||||
codes: [course.course.code],
|
||||
start: Number(this.props.query.year),
|
||||
end: Number(moment(moment(this.props.query.year, 'YYYY').add(this.props.query.months, 'months')).format('YYYY')),
|
||||
separate: false,
|
||||
language: this.props.language
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
borderLeft: '0px !important',
|
||||
display: 'none'
|
||||
}}
|
||||
/>
|
||||
<Table.Cell>{course.course.code}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.passed + course.stats.failed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.passed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.retryPassed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{course.stats.percentage} %</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.failed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.failedMany}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{course.stats.attempts}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.perStudent.toFixed(2)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{course.stats.passedOfPopulation} %</Table.Cell>
|
||||
<Table.Cell>{course.stats.triedOfPopulation} %</Table.Cell>
|
||||
</Table.Row>))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
|
||||
render() {
|
||||
const { courses, translate, selected } = this.props
|
||||
const { sortBy, reversed } = this.state
|
||||
const { courses, translate } = this.props
|
||||
const { reversed } = this.state
|
||||
const direction = reversed ? 'descending' : 'ascending'
|
||||
|
||||
if (courses.length === 0) return null
|
||||
|
||||
const active = course =>
|
||||
selected && (course.name === selected.course.name && course.code === selected.course.code)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form>
|
||||
@@ -75,119 +382,35 @@ class PopulationCourseStats extends Component {
|
||||
value={this.state.limit}
|
||||
onChange={e => this.setState({ limit: e.target.value })}
|
||||
/>
|
||||
<Button icon floated="right" onClick={() => this.setState({ showGradeDistribution: !this.state.showGradeDistribution })}>
|
||||
<Icon color="black" size="big" name="chart bar" />
|
||||
</Button>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
<Table celled sortable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="2">{translate('populationCourses.course')}</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
rowSpan="2"
|
||||
sorted={sortBy === 'students' ? 'descending' : null}
|
||||
onClick={this.sortBy('students')}
|
||||
>
|
||||
{translate('populationCourses.students')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
colSpan="3"
|
||||
>
|
||||
{translate('populationCourses.passed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
colSpan="2"
|
||||
>
|
||||
{translate('populationCourses.failed')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan="2">{translate('populationCourses.attempts')}</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan="2">{translate('populationCourses.percentageOfPopulation')}</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>{translate('populationCourses.name')}</Table.HeaderCell>
|
||||
<Table.HeaderCell>{translate('populationCourses.code')}</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'passed' ? 'descending' : null}
|
||||
onClick={this.sortBy('passed')}
|
||||
>
|
||||
{translate('populationCourses.number')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'retryPassed' ? 'descending' : null}
|
||||
onClick={this.sortBy('retryPassed')}
|
||||
>
|
||||
{translate('populationCourses.passedAfterRetry')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'percentage' ? direction : null}
|
||||
onClick={this.sortBy('percentage')}
|
||||
>
|
||||
{translate('populationCourses.percentage')}
|
||||
</Table.HeaderCell>
|
||||
|
||||
<Table.HeaderCell
|
||||
sorted={sortBy === 'failed' ? 'descending' : null}
|
||||
onClick={this.sortBy('failed')}
|
||||
>
|
||||
{translate('populationCourses.number')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
rowSpan="2"
|
||||
sorted={sortBy === 'failedMany' ? 'descending' : null}
|
||||
onClick={this.sortBy('failedMany')}
|
||||
>
|
||||
{translate('populationCourses.failedManyTimes')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>{translate('populationCourses.number')}</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
{translate('populationCourses.perStudent')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>{translate('populationCourses.passed')}</Table.HeaderCell>
|
||||
<Table.HeaderCell>{translate('populationCourses.attempted')}</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{courses.sort(this.criteria()).filter(this.limit()).map(course => (
|
||||
<Table.Row key={course.course.code} active={active(course.course)}>
|
||||
<Table.Cell onClick={this.limitPopulationToCourse(course)}>
|
||||
{course.course.name}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{course.course.code}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.passed + course.stats.failed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.passed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.retryPassed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{course.stats.percentage} %</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.failed}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{course.stats.failedMany}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{course.stats.attempts}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{(course.stats.attempts / (course.stats.passed + course.stats.failed)).toFixed(2)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{course.stats.passedOfPopulation} %</Table.Cell>
|
||||
<Table.Cell>{course.stats.triedOfPopulation} %</Table.Cell>
|
||||
</Table.Row>))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
{this.state.showGradeDistribution ?
|
||||
this.renderGradeDistributionTable(this.props, this.state)
|
||||
:
|
||||
this.renderBasicTable(this.props, this.state, direction)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
translate: getTranslate(state.locale),
|
||||
selected: state.populationLimit ? state.populationLimit.course : null,
|
||||
populationSize: state.populations.length > 0 ? state.populations[0].data.length : 0
|
||||
})
|
||||
const mapStateToProps = (state) => {
|
||||
const courseFilters = state.populationFilters.filters.filter(f => f.type === 'CourseParticipation')
|
||||
const selectedCourses = courseFilters.map(f => f.params.course.course)
|
||||
return {
|
||||
language: state.settings.language,
|
||||
translate: getTranslate(state.locale),
|
||||
query: state.populations.query,
|
||||
selectedCourses,
|
||||
populationSize: state.populations.data.students.length > 0 ?
|
||||
state.populations.data.students.length : 0
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ setPopulationLimit, clearPopulationLimit, setLoading }
|
||||
)(PopulationCourseStats)
|
||||
{ setPopulationFilter, removePopulationFilterOfCourse, getMultipleCourseStatistics }
|
||||
)(withRouter(PopulationCourseStats))
|
||||
|
||||
@@ -3,6 +3,7 @@ import { connect } from 'react-redux'
|
||||
import { func, arrayOf, object } from 'prop-types'
|
||||
import { Segment, Header, Popup } from 'semantic-ui-react'
|
||||
import { getTranslate } from 'react-localize-redux'
|
||||
import SegmentDimmer from '../SegmentDimmer'
|
||||
import PopulationCourseStats from '../PopulationCourseStats'
|
||||
|
||||
class PopulationCourses extends Component {
|
||||
@@ -16,6 +17,7 @@ class PopulationCourses extends Component {
|
||||
return null
|
||||
}
|
||||
const { samples, translate } = this.props
|
||||
const { pending } = this.props.samples[0]
|
||||
return (
|
||||
<Segment>
|
||||
<Popup
|
||||
@@ -24,10 +26,11 @@ class PopulationCourses extends Component {
|
||||
wide
|
||||
position="top left"
|
||||
/>
|
||||
<SegmentDimmer translate={translate} isLoading={pending} />
|
||||
{samples.map(sample => (
|
||||
<PopulationCourseStats
|
||||
key={sample.query.uuid}
|
||||
courses={sample.data}
|
||||
courses={sample.data || {}}
|
||||
query={sample.query}
|
||||
/>))}
|
||||
</Segment>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, arrayOf, object, number } from 'prop-types'
|
||||
import { Progress } from 'semantic-ui-react'
|
||||
import SearchResultTable from '../SearchResultTable'
|
||||
import { getStudentTotalCredits } from '../../common'
|
||||
|
||||
import { creditsLessThan, creditsAtLeast } from '../../populationFilters'
|
||||
import { setPopulationFilter, removePopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
const getTotal = students => students.map(student => getStudentTotalCredits(student))
|
||||
|
||||
const expectedAmountOfCredits = months => ([
|
||||
[Math.ceil(months * (55 / 12))],
|
||||
[Math.ceil(months * (40 / 12)), (Math.ceil(months * (55 / 12)))],
|
||||
[Math.ceil(months * (30 / 12)), Math.ceil(months * (40 / 12))],
|
||||
[Math.ceil(months * (20 / 12)), Math.ceil(months * (30 / 12))],
|
||||
[Math.ceil(months * (10 / 12)), Math.ceil(months * (20 / 12))],
|
||||
[1, Math.ceil(months * (10 / 12))],
|
||||
[0, 0]
|
||||
])
|
||||
|
||||
const filterStudents = (students, minCredits, maxCredits = Infinity) => {
|
||||
if (minCredits === 0) {
|
||||
return {
|
||||
minCredits: '',
|
||||
maxCredits,
|
||||
amount: students.filter(s => s === minCredits).length
|
||||
}
|
||||
} else if (maxCredits === Infinity) {
|
||||
return {
|
||||
minCredits,
|
||||
maxCredits: '',
|
||||
amount: students.filter(s => s >= minCredits).length
|
||||
}
|
||||
}
|
||||
return {
|
||||
minCredits,
|
||||
maxCredits,
|
||||
amount: students.filter(s => s >= minCredits && s < maxCredits).length
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const PopulationCreditGainTable = (props) => {
|
||||
const setCreditFilter = (row) => {
|
||||
props.filters.map(filter => props.removePopulationFilter(filter.id))
|
||||
const credits = row[0].split('-').map(count => Number(count))
|
||||
if (credits[0]) {
|
||||
props.setPopulationFilter(creditsAtLeast({ credit: credits[0] }))
|
||||
if (credits[1]) {
|
||||
props.setPopulationFilter(creditsLessThan({ credit: credits[1] }))
|
||||
}
|
||||
} else {
|
||||
props.setPopulationFilter(creditsLessThan({ credit: 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
const { translate, sample, months } = props
|
||||
const stats = getTotal(sample)
|
||||
const limits = expectedAmountOfCredits(months)
|
||||
const arr = limits.map(l => filterStudents(stats, ...l))
|
||||
const rows = arr.map(a => [`${a.minCredits}-${a.maxCredits}`, a.amount, <Progress style={{ margin: '0px' }} percent={stats.length === 0 ? 0 : Math.round((a.amount / stats.length) * 100)} progress />])
|
||||
const headers = [
|
||||
`Credits gained during first ${months} months`,
|
||||
`Students (all=${stats.length})`,
|
||||
'Percentage of population'
|
||||
]
|
||||
|
||||
return (
|
||||
<SearchResultTable
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
selectable
|
||||
rowClickFn={(e, row) => setCreditFilter(row)}
|
||||
noResultText={translate('common.noResults')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
months: state.populations.query.months,
|
||||
filters: state.populationFilters.filters.filter(f => f.type === 'CreditsLessThan' || f.type === 'CreditsAtLeast')
|
||||
})
|
||||
|
||||
PopulationCreditGainTable.propTypes = {
|
||||
translate: func.isRequired,
|
||||
sample: arrayOf(object).isRequired,
|
||||
months: number.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
filters: arrayOf(object).isRequired
|
||||
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(PopulationCreditGainTable)
|
||||
@@ -1,49 +1,80 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, object, string, arrayOf } from 'prop-types'
|
||||
import { Segment, Header } from 'semantic-ui-react'
|
||||
import { func, object, string, arrayOf, bool } from 'prop-types'
|
||||
import { Segment, Header, Message, Button, Icon, Tab } from 'semantic-ui-react'
|
||||
import { getTranslate } from 'react-localize-redux'
|
||||
import _ from 'lodash'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
|
||||
|
||||
import { makePopulationsToData } from '../../selectors/populationDetails'
|
||||
import { setPopulationLimitField, clearPopulationLimit } from '../../redux/populationLimit'
|
||||
|
||||
import { getTotalCreditsFromCourses } from '../../common'
|
||||
import PopulationFilters from '../PopulationFilters'
|
||||
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
|
||||
import CourseQuarters from '../CourseQuarters'
|
||||
import PopulationLimiter from '../PopulationLimiter'
|
||||
import PopulationStudents from '../PopulationStudents'
|
||||
import PopulationCourses from '../PopulationCourses'
|
||||
import PopulationCreditGainTable from '../PopulationCreditGainTable'
|
||||
|
||||
class PopulationDetails extends Component {
|
||||
static propTypes = {
|
||||
translate: func.isRequired,
|
||||
samples: arrayOf(arrayOf(object)).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired
|
||||
samples: arrayOf(object).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
queryIsSet: bool.isRequired,
|
||||
isLoading: bool.isRequired
|
||||
}
|
||||
constructor() {
|
||||
super()
|
||||
this.chart = React.createRef()
|
||||
this.courses = React.createRef()
|
||||
this.students = React.createRef()
|
||||
this.filters = React.createRef()
|
||||
this.state = {
|
||||
navigationVisible: false
|
||||
}
|
||||
}
|
||||
|
||||
isSamplesRenderable = () => {
|
||||
const { samples } = this.props
|
||||
return samples && samples.length > 0
|
||||
}
|
||||
|
||||
renderCourseStatistics = () => {
|
||||
const { samples, translate } = this.props
|
||||
let statistics = []
|
||||
let statistics = null
|
||||
if (samples) {
|
||||
statistics = samples.map((sample, i) => (
|
||||
<CourseQuarters
|
||||
key={`course-quarters-${i}`} // eslint-disable-line react/no-array-index-key
|
||||
sample={sample.filter(s => this.props.selectedStudents.includes(s.studentNumber))}
|
||||
translate={translate}
|
||||
/>
|
||||
))
|
||||
statistics = (
|
||||
<Tab
|
||||
menu={{ pointing: true }}
|
||||
panes={[
|
||||
{
|
||||
menuItem: 'Credits gained',
|
||||
render: () => (
|
||||
<Tab.Pane attached={false}>
|
||||
<PopulationCreditGainTable
|
||||
sample={samples.filter(s =>
|
||||
this.props.selectedStudents.includes(s.studentNumber))}
|
||||
translate={translate}
|
||||
/>
|
||||
</Tab.Pane>)
|
||||
},
|
||||
{
|
||||
menuItem: 'Quarters',
|
||||
render: () => (
|
||||
<Tab.Pane attached={false}>
|
||||
<CourseQuarters
|
||||
sample={samples.filter(s =>
|
||||
this.props.selectedStudents.includes(s.studentNumber))}
|
||||
translate={translate}
|
||||
/>
|
||||
</Tab.Pane>
|
||||
)
|
||||
}]}
|
||||
/>)
|
||||
}
|
||||
return (
|
||||
<Segment>
|
||||
<Header size="medium" dividing>
|
||||
{translate('populationStatistics.creditStatisticsHeader')}
|
||||
</Header>
|
||||
<PopulationLimiter />
|
||||
{statistics}
|
||||
</Segment>
|
||||
)
|
||||
@@ -51,44 +82,85 @@ class PopulationDetails extends Component {
|
||||
|
||||
renderCreditGainGraphs = () => {
|
||||
const { samples, translate } = this.props
|
||||
const graphs = samples.map((sample, i) => (
|
||||
const graphs = (
|
||||
<CreditAccumulationGraphHighCharts
|
||||
key={`credit-graph-${i}`} // eslint-disable-line react/no-array-index-key
|
||||
students={sample}
|
||||
title={`${translate('populationStatistics.sampleId')}: ${i}`}
|
||||
students={samples}
|
||||
title={`${translate('populationStatistics.sampleId')}`}
|
||||
translate={translate}
|
||||
label={sample.label}
|
||||
maxCredits={sample.maxCredits}
|
||||
label={samples.label}
|
||||
maxCredits={samples.maxCredits}
|
||||
selectedStudents={this.props.selectedStudents}
|
||||
ref={this.chart}
|
||||
/>
|
||||
))
|
||||
|
||||
)
|
||||
return (
|
||||
<Segment>
|
||||
<Header size="medium" dividing>
|
||||
{translate('populationStatistics.graphSegmentHeader')}
|
||||
</Header>
|
||||
<PopulationLimiter />
|
||||
{graphs}
|
||||
{samples.length > 0 ? graphs : null}
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
renderNavigationPanel = () => (
|
||||
<div>
|
||||
<Segment className="navigationpanel" style={{ position: 'fixed', right: '2%', bottom: '2%' }}>
|
||||
<Header size="medium" textAlign="center" >
|
||||
Navigation
|
||||
<Button className="navigationbuttonclose" icon basic floated="right" onClick={() => this.setState({ navigationVisible: false })} >
|
||||
<Icon name="chevron right" />
|
||||
</Button>
|
||||
</Header>
|
||||
<Button.Group vertical >
|
||||
<Button onClick={() => scrollToComponent(this.filters.current, { align: 'top', offset: -40 })}>Go To Filters</Button>
|
||||
<Button onClick={() => scrollToComponent(this.chart.current, { align: 'middle' })}>Go To Chart</Button>
|
||||
<Button onClick={() => scrollToComponent(this.courses.current, { align: 'top', offset: -40 })}>Go To Course List</Button>
|
||||
<Button onClick={() => scrollToComponent(this.students.current, { align: 'top', offset: -40 })}>Go To Student List</Button>
|
||||
</Button.Group>
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
|
||||
renderPopulationDetailsContent = () => (
|
||||
<div>
|
||||
<PopulationFilters ref={this.filters} samples={this.props.samples} />
|
||||
{this.renderCreditGainGraphs()}
|
||||
{this.renderCourseStatistics()}
|
||||
<PopulationCourses ref={this.courses} />
|
||||
<PopulationStudents
|
||||
samples={this.props.samples}
|
||||
selectedStudents={this.props.selectedStudents}
|
||||
ref={this.students}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
render() {
|
||||
if (!this.isSamplesRenderable()) {
|
||||
const { samples, translate, queryIsSet, isLoading } = this.props
|
||||
if (isLoading || !queryIsSet) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (samples.length === 0) {
|
||||
return (
|
||||
<Message negative content={`${translate('populationStatistics.emptyQueryResult')}`} />
|
||||
)
|
||||
}
|
||||
if (this.state.navigationVisible) {
|
||||
return (
|
||||
<div>
|
||||
{this.renderPopulationDetailsContent()}
|
||||
{this.renderNavigationPanel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<PopulationFilters />
|
||||
{this.renderCourseStatistics()}
|
||||
{this.renderCreditGainGraphs()}
|
||||
<PopulationStudents
|
||||
samples={this.props.samples}
|
||||
selectedStudents={this.props.selectedStudents}
|
||||
/>
|
||||
<PopulationCourses />
|
||||
{this.renderPopulationDetailsContent()}
|
||||
<div>
|
||||
<Button className="navigationbuttonopen" icon basic onClick={() => this.setState({ navigationVisible: true })} style={{ position: 'fixed', right: '0.5%', bottom: '0.5%' }} >
|
||||
<Icon name="bars" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -97,35 +169,52 @@ class PopulationDetails extends Component {
|
||||
const populationsToData = makePopulationsToData()
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const allSamples = populationsToData(state)
|
||||
const samples = populationsToData(state)
|
||||
const { complemented } = state.populationFilters
|
||||
let selectedStudents = samples.length > 0 ? samples.map(s => s.studentNumber) : []
|
||||
|
||||
const allStudents = allSamples.length > 0 ? allSamples[0].map(s => s.studentNumber) : []
|
||||
// TODO refactor to more functional approach where the whole sample is not tested for each filter
|
||||
|
||||
let selectedStudents = state.populationLimit
|
||||
? state.populationLimit.course.students[state.populationLimit.field]
|
||||
: allStudents
|
||||
if (samples.length > 0 && state.populationFilters.filters.length > 0) {
|
||||
const studentsForFilter = (f) => {
|
||||
if (f.type === 'CourseParticipation') {
|
||||
return Object.keys(f.studentsOfSelectedField)
|
||||
}
|
||||
return samples.filter(f.filter).map(s => s.studentNumber)
|
||||
}
|
||||
|
||||
if (state.populationFilters.length > 0) {
|
||||
const { filter } = state.populationFilters[0]
|
||||
selectedStudents =
|
||||
allSamples.length > 0 ? allSamples[0].filter(filter).map(s => s.studentNumber) : []
|
||||
const matchingStudents = state.populationFilters.filters.map(studentsForFilter)
|
||||
selectedStudents = _.intersection(...matchingStudents)
|
||||
|
||||
if (complemented) {
|
||||
selectedStudents = _.difference(samples.map(s => s.studentNumber), selectedStudents)
|
||||
}
|
||||
}
|
||||
|
||||
// REFACTOR ??
|
||||
if (samples.length > 0) {
|
||||
const creditsAndDates = samples.map((s) => {
|
||||
const passedCourses = s.courses.filter(c => c.passed)
|
||||
const passedCredits = getTotalCreditsFromCourses(passedCourses)
|
||||
const dates = passedCourses.map(c => c.date)
|
||||
return { passedCredits, dates }
|
||||
})
|
||||
const credits = creditsAndDates.map(cd => cd.passedCredits)
|
||||
let dates = creditsAndDates.map(cd => cd.dates)
|
||||
dates = _.flattenDeep(dates).map(date => new Date(date).getTime())
|
||||
samples.maxCredits = Math.max(...credits)
|
||||
samples.maxDate = Math.max(...dates)
|
||||
samples.minDate = Math.min(...dates)
|
||||
}
|
||||
|
||||
return {
|
||||
samples: allSamples.map((sample) => {
|
||||
const credits = sample.map(s =>
|
||||
s.courses.filter(c => c.passed).reduce((sum, c) => c.credits + sum, 0))
|
||||
|
||||
sample.maxCredits = Math.round(Math.max(...credits) / 10) * 10
|
||||
return sample
|
||||
}),
|
||||
selected: state.populationLimit,
|
||||
samples,
|
||||
selectedStudents,
|
||||
translate: getTranslate(state.locale)
|
||||
complemented,
|
||||
translate: getTranslate(state.locale),
|
||||
queryIsSet: !!state.populations.query,
|
||||
isLoading: state.populations.pending === true
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
setPopulationLimitField,
|
||||
clearPopulationLimit
|
||||
})(PopulationDetails)
|
||||
export default connect(mapStateToProps)(PopulationDetails)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Dropdown, Icon, Form, Segment, Button, Popup } from 'semantic-ui-react'
|
||||
import { shape, func, arrayOf, string } from 'prop-types'
|
||||
import infoTooltips from '../../common/infotooltips'
|
||||
import { canceledStudyright } from '../../populationFilters'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
class CanceledStudyright extends Component {
|
||||
static propTypes = {
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
studyrights: arrayOf(string).isRequired,
|
||||
language: string.isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
cancel: 'true'
|
||||
}
|
||||
|
||||
options = [{ value: 'false', text: 'haven\'t' }, { value: 'true', text: 'have' }]
|
||||
|
||||
handleChange = (e, { value }) => {
|
||||
this.setState({ cancel: value })
|
||||
}
|
||||
|
||||
handleCancel = () => {
|
||||
this.props.setPopulationFilter(canceledStudyright({
|
||||
studyrights: this.props.studyrights,
|
||||
cancel: this.state.cancel
|
||||
}))
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filter, language } = this.props
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Popup content={infoTooltips.PopulationStatistics.Filters.CanceledStudyright[language]} trigger={<Icon style={{ float: 'right' }} name="info" />} />
|
||||
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Filter students that </label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Dropdown
|
||||
fluid
|
||||
placeholder="have/haven't"
|
||||
name="complemented"
|
||||
onChange={this.handleChange}
|
||||
options={this.options}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label> canceled this studyright </label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button
|
||||
onClick={this.handleCancel}
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
{filter.params.cancel === 'true' ? 'Showing students that have canceled this studyright' : <span><b>Excluded</b> students whose studyright is cancelled</span>}
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
const mapStateToProps = ({ populations, settings }) => ({
|
||||
studyrights: populations.query.studyRights,
|
||||
language: settings.language
|
||||
})
|
||||
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(CanceledStudyright)
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Popup } from 'semantic-ui-react'
|
||||
import { shape, func, string } from 'prop-types'
|
||||
|
||||
import { removePopulationFilter, alterPopulationCourseFilter } from '../../redux/populationFilters'
|
||||
|
||||
class CourseParticipation extends Component {
|
||||
static propTypes = {
|
||||
language: string.isRequired,
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
alterPopulationCourseFilter: func.isRequired
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
selectField = field => () => {
|
||||
const selectedField = this.props.filter.params.field
|
||||
if (field !== selectedField) {
|
||||
this.props.alterPopulationCourseFilter(this.props.filter.id, field)
|
||||
}
|
||||
}
|
||||
renderFilterSegment = (type, text, active) =>
|
||||
(
|
||||
<Segment
|
||||
inverted={active(type)}
|
||||
secondary={active(type)}
|
||||
onClick={this.selectField(type)}
|
||||
style={{ width: 30, height: 60, fontSize: 13, textAlign: 'center', verticalAlign: 'middle', lineHeight: 1, paddingTop: 15 }}
|
||||
>
|
||||
{text}
|
||||
</Segment>
|
||||
)
|
||||
|
||||
|
||||
render() {
|
||||
const { filter, language } = this.props
|
||||
if (filter.notSet) {
|
||||
return null
|
||||
}
|
||||
const { course, field } = filter.params
|
||||
const selectedField = field
|
||||
|
||||
const active = field2 =>
|
||||
(selectedField === field2)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Segment.Group horizontal size="small">
|
||||
<Popup
|
||||
trigger={
|
||||
<Segment
|
||||
style={{ width: '30%', height: 40 }}
|
||||
>
|
||||
<em style={{ float: 'left', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', width: '80%' }}>{course.course.name[language]}</em>
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>}
|
||||
content={course.course.name[language]}
|
||||
/>
|
||||
{this.renderFilterSegment('all', 'all', active)}
|
||||
{this.renderFilterSegment('passed', 'passed', active)}
|
||||
{this.renderFilterSegment('retryPassed', 'passed after fail', active)}
|
||||
{this.renderFilterSegment('failed', 'failed', active)}
|
||||
{this.renderFilterSegment('failedMany', 'failed many', active)}
|
||||
{this.renderFilterSegment('notParticipated', 'not participated', active)}
|
||||
{this.renderFilterSegment('notParticipatedOrFailed', 'not participated or failed', active)}
|
||||
</Segment.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
const mapStateToProps = ({ settings }) => ({
|
||||
language: settings.language
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
removePopulationFilter, alterPopulationCourseFilter
|
||||
})(CourseParticipation)
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { arrayOf, object } from 'prop-types'
|
||||
import CourseParticipation from './CourseParticipation'
|
||||
|
||||
const CourseParticipationFilters = ({ filters }) => {
|
||||
if (filters.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{filters.map(f => <CourseParticipation key={f.id} filter={f} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CourseParticipationFilters.propTypes = {
|
||||
filters: arrayOf(object).isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
filters: state.populationFilters.filters.filter(f => f.type === 'CourseParticipation')
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(CourseParticipationFilters)
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Form, Input, Button } from 'semantic-ui-react'
|
||||
import { shape, func } from 'prop-types'
|
||||
|
||||
import { courseParticipationNTimes } from '../../populationFilters'
|
||||
import { removePopulationFilter, alterPopulationCourseFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
class CourseParticipationNTimes extends Component {
|
||||
static propTypes = {
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
populationCourses: shape({}).isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
participationAmount: 0,
|
||||
code: '375063'
|
||||
}
|
||||
|
||||
handleChange = (e) => {
|
||||
this.setState({ participationAmount: e.target.value })
|
||||
}
|
||||
|
||||
handleLimit = () => {
|
||||
this.props.setPopulationFilter(courseParticipationNTimes({
|
||||
amount: this.state.participationAmount, course: this.state.code
|
||||
}))
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
courseInPopulation = () => {
|
||||
const { populationCourses } = this.props
|
||||
if (populationCourses[0]) {
|
||||
return populationCourses[0].query.studyRights.includes('MH30_001')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filter } = this.props
|
||||
const { participationAmount } = this.state
|
||||
if (!this.courseInPopulation()) {
|
||||
return null
|
||||
}
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>
|
||||
Show only students that have participated in International Progress Test
|
||||
</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Input
|
||||
type="number"
|
||||
onChange={this.handleChange}
|
||||
value={participationAmount}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>times</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button
|
||||
onClick={this.handleLimit}
|
||||
disabled={participationAmount === 0}
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
Participated in
|
||||
<i> International Progress Test </i>
|
||||
at least <b>{filter.params.amount}</b> times
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populationCourses }) => ({
|
||||
populationCourses
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
removePopulationFilter, alterPopulationCourseFilter, setPopulationFilter
|
||||
})(CourseParticipationNTimes)
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Input, Button, Form, Popup } from 'semantic-ui-react'
|
||||
import { shape, func, string } from 'prop-types'
|
||||
|
||||
import { creditsAtLeast } from '../../populationFilters'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
import infoTooltips from '../../common/infotooltips'
|
||||
|
||||
class CreditsAtLeast extends Component {
|
||||
static propTypes = {
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
language: string.isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
limit: ''
|
||||
}
|
||||
|
||||
handleChange = (e) => {
|
||||
this.setState({ limit: e.target.value })
|
||||
}
|
||||
|
||||
handleLimit = () => {
|
||||
this.props.setPopulationFilter(creditsAtLeast({ credit: this.state.limit }))
|
||||
this.setState({ limit: '' })
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filter } = this.props
|
||||
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Popup content={infoTooltips.PopulationStatistics.Filters.CreditsAtLeast[this.props.language]} trigger={<Icon style={{ float: 'right' }} name="info" />} />
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Show only students with credits at least</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Input
|
||||
type="number"
|
||||
onChange={this.handleChange}
|
||||
value={this.state.limit}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button
|
||||
onClick={this.handleLimit}
|
||||
disabled={this.state.limit.length === 0}
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
</Form.Field>
|
||||
|
||||
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
Credits at least {filter.params.credit}
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
const mapStateToProps = ({ settings }) => ({
|
||||
language: settings.language
|
||||
})
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(CreditsAtLeast)
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Input, Button, Form } from 'semantic-ui-react'
|
||||
import { shape, func } from 'prop-types'
|
||||
import { Segment, Icon, Input, Button, Form, Popup } from 'semantic-ui-react'
|
||||
import { shape, func, string } from 'prop-types'
|
||||
|
||||
import infoTooltips from '../../common/infotooltips'
|
||||
import { creditsLessThan } from '../../populationFilters'
|
||||
import { clearPopulationFilters, setPopulationFilter } from '../../redux/populationFilters'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
class CreditsLessThan extends Component {
|
||||
static propTypes = {
|
||||
filter: shape({}).isRequired,
|
||||
clearPopulationFilters: func.isRequired,
|
||||
setPopulationFilter: func.isRequired
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
language: string.isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
@@ -22,12 +24,12 @@ class CreditsLessThan extends Component {
|
||||
}
|
||||
|
||||
handleLimit = () => {
|
||||
this.props.setPopulationFilter(creditsLessThan(this.state.limit))
|
||||
this.props.setPopulationFilter(creditsLessThan({ credit: this.state.limit }))
|
||||
this.setState({ limit: '' })
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.clearPopulationFilters()
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -37,6 +39,7 @@ class CreditsLessThan extends Component {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Popup content={infoTooltips.PopulationStatistics.Filters.CreditsLessThan[this.props.language]} trigger={<Icon style={{ float: 'right' }} name="info" />} />
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Show only students with credits less than</label>
|
||||
@@ -55,6 +58,7 @@ class CreditsLessThan extends Component {
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
@@ -64,7 +68,7 @@ class CreditsLessThan extends Component {
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
Credits less than {filter.params[0]}
|
||||
Credits less than {filter.params.credit}
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
@@ -72,8 +76,10 @@ class CreditsLessThan extends Component {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ settings }) => ({
|
||||
language: settings.language
|
||||
})
|
||||
export default connect(
|
||||
null,
|
||||
{ setPopulationFilter, clearPopulationFilters }
|
||||
mapStateToProps,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(CreditsLessThan)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Dropdown, Button, Form, Input, Popup } from 'semantic-ui-react'
|
||||
import { shape, func, arrayOf, object, string } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
|
||||
import infoTooltips from '../../common/infotooltips'
|
||||
import { courseParticipation } from '../../populationFilters'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
class DisciplineTypes extends Component {
|
||||
static propTypes = {
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
courseTypes: shape({}).isRequired,
|
||||
disciplines: shape({}).isRequired,
|
||||
courses: arrayOf(object).isRequired,
|
||||
language: string.isRequired
|
||||
|
||||
}
|
||||
|
||||
state = {
|
||||
discipline: '',
|
||||
courseType: '',
|
||||
atleast: 0
|
||||
}
|
||||
|
||||
handleChange = (e, data) => {
|
||||
this.setState({ [data.name]: data.value })
|
||||
}
|
||||
|
||||
handleFilter = () => {
|
||||
const courses = this.props.courses.filter(({ course, stats }) =>
|
||||
course.disciplines[this.state.discipline] &&
|
||||
course.coursetypes[this.state.courseType] &&
|
||||
stats.students >= this.state.atleast)
|
||||
courses.forEach(course => this.props.setPopulationFilter(courseParticipation({ field: 'all', course })))
|
||||
this.setState({ discipline: '', courseType: '' })
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filter, courseTypes, disciplines } = this.props
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Popup content={infoTooltips.PopulationStatistics.Filters.DisciplineTypes[this.props.language]} trigger={<Icon style={{ float: 'right' }} name="info" />} />
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Filter by </label>
|
||||
</Form.Field>
|
||||
<Form.Field
|
||||
style={{ width: 135 }}
|
||||
>
|
||||
<Dropdown
|
||||
search
|
||||
fluid
|
||||
icon={null}
|
||||
name="courseType"
|
||||
placeholder="course type"
|
||||
onChange={this.handleChange}
|
||||
value={this.state.courseType}
|
||||
options={_.sortBy(Object.entries(courseTypes).map(([value, text]) => ({
|
||||
text: text.fi,
|
||||
value
|
||||
})), entry => entry.text)}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label> of </label>
|
||||
</Form.Field>
|
||||
<Form.Field
|
||||
style={{ width: 200 }}
|
||||
>
|
||||
<Dropdown
|
||||
search
|
||||
icon={null}
|
||||
fluid
|
||||
name="discipline"
|
||||
placeholder="discipline"
|
||||
onChange={this.handleChange}
|
||||
value={this.state.discipline}
|
||||
options={_.sortBy(Object.entries(disciplines).map(([value, text]) => ({
|
||||
text: text.fi,
|
||||
value
|
||||
})), entry => entry.text)}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label> and at least </label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Input
|
||||
transparent
|
||||
maxLength="3"
|
||||
style={{ width: 30 }}
|
||||
name="atleast"
|
||||
onChange={this.handleChange}
|
||||
value={this.state.atleast}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label> participants</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button
|
||||
onClick={this.handleFilter}
|
||||
disabled={this.state.discipline.length === 0 ||
|
||||
this.state.courseType.length === 0 ||
|
||||
!String(this.state.atleast).match(/^[0-9]+$/)}
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
Participants of {filter.params.discipline} {filter.params.courseType}
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populationCourses, settings }) => ({
|
||||
courseTypes: populationCourses[0].data.coursetypes,
|
||||
disciplines: populationCourses[0].data.disciplines,
|
||||
courses: populationCourses[0].data.coursestatistics,
|
||||
language: settings.language
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(DisciplineTypes)
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Button, Form, Dropdown, Popup } from 'semantic-ui-react'
|
||||
import { shape, func, object, arrayOf, string } from 'prop-types'
|
||||
import moment from 'moment'
|
||||
import _ from 'lodash'
|
||||
|
||||
import infoTooltips from '../../common/infotooltips'
|
||||
import { enrollmentStatus } from '../../populationFilters'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
class EnrollmentStatus extends Component {
|
||||
static propTypes = {
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
samples: arrayOf(object).isRequired,
|
||||
language: string.isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
semesters: [],
|
||||
enrolled: true
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.filter.notSet) {
|
||||
const options = this.createPossibleOptions(this.props.samples)
|
||||
this.setState({ options })
|
||||
}
|
||||
}
|
||||
|
||||
formatSemesterCodeToText = (semester) => {
|
||||
const text = moment(moment('1950', 'YYYY').add((semester * 6), 'months')).format('YYYY.MM')
|
||||
const split = text.split('.')
|
||||
const formatted = split[1] > 6 ? `Fall ${split[0]}` : `Spring ${split[0]}`
|
||||
return formatted
|
||||
}
|
||||
|
||||
createPossibleOptions = ({ minDate, maxDate }) => {
|
||||
const firstSemester = Math.floor(moment(minDate).diff(moment('1950', 'YYYY'), 'months') / 6)
|
||||
const lastSemester = Math.floor(moment(maxDate).diff(moment('1950', 'YYYY'), 'months') / 6)
|
||||
const allSemesters = _.range(firstSemester, lastSemester + 1)
|
||||
const options = allSemesters.map(semester =>
|
||||
({ key: semester, text: this.formatSemesterCodeToText(semester), value: semester }))
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
handleTime = (e, data) => {
|
||||
this.setState({ semesters: data.value })
|
||||
}
|
||||
|
||||
handleFilter = () => {
|
||||
this.props.setPopulationFilter(enrollmentStatus({
|
||||
semesters: this.state.semesters, enrolled: this.state.enrolled
|
||||
}))
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const { filter } = this.props
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Popup content={infoTooltips.PopulationStatistics.Filters.EnrollmentStatus[this.props.language]} trigger={<Icon style={{ float: 'right' }} name="info" />} />
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Select students that were </label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Dropdown
|
||||
placeholder="select status"
|
||||
onChange={(e, data) => this.setState({ enrolled: data.value })}
|
||||
options={[{ key: 1, text: 'present', value: 1 }, { key: 2, text: 'absent', value: 2 }]}
|
||||
/>
|
||||
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label> during </label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Dropdown
|
||||
placeholder="select semesters"
|
||||
fluid
|
||||
multiple
|
||||
options={this.state.options}
|
||||
onChange={this.handleTime}
|
||||
/>
|
||||
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button
|
||||
onClick={this.handleFilter}
|
||||
disabled={this.state.semesters.length === 0}
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
Students that were {filter.params.enrolled === 1 ? 'present' : 'absent'} during {filter.params.semesters.map(semester => this.formatSemesterCodeToText(semester)).join(', ')}
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
const mapStateToProps = ({ settings }) => ({
|
||||
language: settings.language
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(EnrollmentStatus)
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Button, Form, Dropdown, Popup } from 'semantic-ui-react'
|
||||
import { shape, func, arrayOf, object, string } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
import infoTooltips from '../../common/infotooltips'
|
||||
import { extentGraduated } from '../../populationFilters'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
class ExtentGraduated extends Component {
|
||||
static propTypes = {
|
||||
language: string.isRequired,
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
extents: arrayOf(object).isRequired
|
||||
}
|
||||
state = {
|
||||
extentcode: undefined,
|
||||
graduated: undefined,
|
||||
complemented: 'false'
|
||||
}
|
||||
graduationOptions = [
|
||||
{ value: 'grad', text: 'graduated' },
|
||||
{ value: 'either', text: 'studying' }
|
||||
] // illegal to pass boolean values as Dropdown options value :(
|
||||
|
||||
complementedOptions = [{ value: 'false', text: 'are' }, { value: 'true', text: 'are not' }]
|
||||
|
||||
handleChange = (e, data) => {
|
||||
this.setState({ [data.name]: data.value })
|
||||
}
|
||||
handleLimit = () => {
|
||||
const { extentcode, graduated, complemented } = this.state
|
||||
this.props.setPopulationFilter(extentGraduated({ extentcode, graduated, complemented }))
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
renderSetText = (extents, filter) => {
|
||||
const { language } = this.props
|
||||
const { extentcode, graduated, complemented } = filter.params
|
||||
let returnText = ''
|
||||
if (complemented === 'true') {
|
||||
returnText = returnText.concat('Not')
|
||||
}
|
||||
if (graduated === 'grad') {
|
||||
returnText = returnText.concat(' graduated from')
|
||||
} else if (graduated === 'either') {
|
||||
returnText = returnText.concat(' studying')
|
||||
}
|
||||
returnText = returnText.concat(` ${extents.find(extent => extent.extentcode === extentcode).name[language]} `)
|
||||
|
||||
if (returnText === 'Not studying Määräaikaiset ulkomaalaiset opiskelijat ') {
|
||||
return <span><b>Excluded</b> exchange students</span>
|
||||
}
|
||||
|
||||
return returnText
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filter, extents, language } = this.props
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Popup content={infoTooltips.PopulationStatistics.Filters.ExtentGraduated[language]} trigger={<Icon style={{ float: 'right' }} name="info" />} />
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
Students that
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Dropdown
|
||||
fluid
|
||||
placeholder="are/not"
|
||||
name="complemented"
|
||||
onChange={this.handleChange}
|
||||
options={this.complementedOptions}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Dropdown
|
||||
search
|
||||
fluid
|
||||
name="graduated"
|
||||
placeholder="select graduation"
|
||||
onChange={this.handleChange}
|
||||
options={this.graduationOptions}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Dropdown
|
||||
search
|
||||
fluid
|
||||
name="extentcode"
|
||||
placeholder="select extent"
|
||||
onChange={this.handleChange}
|
||||
options={_.sortBy(
|
||||
Object.values(extents).map(({
|
||||
extentcode, name
|
||||
}) =>
|
||||
({ value: extentcode, text: name[language] })),
|
||||
entry => entry.text
|
||||
)}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button
|
||||
onClick={this.handleLimit}
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<label>
|
||||
{this.renderSetText(extents, filter)}
|
||||
</label>
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ settings }) => ({ language: settings.language })
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(ExtentGraduated)
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Radio, Icon, Form, Segment, Button } from 'semantic-ui-react'
|
||||
import { shape, func } from 'prop-types'
|
||||
|
||||
import { matriculationFilter } from '../../populationFilters'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
class MatriculationFilter extends Component {
|
||||
static propTypes = {
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
matr: undefined
|
||||
}
|
||||
|
||||
handleChange = (e, { value }) => {
|
||||
this.setState({ matr: value })
|
||||
}
|
||||
|
||||
handleMatr = () => {
|
||||
this.props.setPopulationFilter(matriculationFilter(this.state.matr))
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filter } = this.props
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Filter by matriculation examination</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Radio name="matr" onChange={this.handleChange} value label="Yes" checked={this.state.matr === true} />
|
||||
<Radio name="matr" onChange={this.handleChange} value={false} label="No" checked={this.state.matr === false} />
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button
|
||||
onClick={this.handleMatr}
|
||||
disabled={this.state.matr === undefined}
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
{filter.params.matriculationexamination ? 'Showing students that have done matriculation examination' : 'Showing students that have not done matriculation examination'}
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
null,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(MatriculationFilter)
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Icon, Form, Segment, Button, Confirm, Grid } from 'semantic-ui-react'
|
||||
import { shape, func } from 'prop-types'
|
||||
import { removePopulationFilter, setPopulationFilter, deletePopulationFilter } from '../../redux/populationFilters'
|
||||
import { presetFilter } from '../../populationFilters'
|
||||
|
||||
|
||||
class Preset extends Component {
|
||||
static propTypes = {
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
deletePopulationFilter: func.isRequired,
|
||||
destroy: func.isRequired
|
||||
}
|
||||
state = { open: false }
|
||||
|
||||
handleSetFilter = (filter) => {
|
||||
this.props.filter.notSet = false
|
||||
this.props.setPopulationFilter(presetFilter(filter))
|
||||
}
|
||||
clearFilter = (destroy = false) => {
|
||||
this.props.filter.notSet = true
|
||||
if (destroy) {
|
||||
this.props.destroy(this.props.filter.id)
|
||||
}
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
render() {
|
||||
const { filter } = this.props
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>{filter.name}</label>
|
||||
<em>
|
||||
{filter.description}
|
||||
</em>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button onClick={() => this.handleSetFilter(filter)}>
|
||||
set filter
|
||||
</Button>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment >
|
||||
<Grid>
|
||||
<Grid.Column floated="left" width={12}>
|
||||
<Form>
|
||||
<Form.Field inline>
|
||||
<label>
|
||||
{filter.name}
|
||||
</label>
|
||||
<em>
|
||||
{filter.description}
|
||||
</em>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Grid.Column>
|
||||
<Grid.Column floated="right">
|
||||
<Form style={{ float: 'right' }}>
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<Icon
|
||||
name="trash"
|
||||
onClick={() => this.setState({ open: true })}
|
||||
/>
|
||||
<Confirm
|
||||
style={{
|
||||
marginTop: 'auto !important',
|
||||
display: 'inline-block !important',
|
||||
position: 'relative',
|
||||
top: '20%',
|
||||
left: '33%'
|
||||
}}
|
||||
open={this.state.open}
|
||||
cancelButton="Just remove from use"
|
||||
confirmButton="Delete for good"
|
||||
content="Are you sure you want to delete this filter?"
|
||||
onCancel={() => { this.setState({ open: false }); this.clearFilter() }}
|
||||
onConfirm={() => {
|
||||
this.clearFilter(true)
|
||||
this.props.deletePopulationFilter(filter)
|
||||
this.setState({ open: false })
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field >
|
||||
<Icon name="remove" onClick={() => this.clearFilter(false)} />
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
</Segment >
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
null,
|
||||
{ removePopulationFilter, setPopulationFilter, deletePopulationFilter }
|
||||
)(Preset)
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Button, Form, Dropdown, Popup } from 'semantic-ui-react'
|
||||
import { shape, func, arrayOf, string } from 'prop-types'
|
||||
import infoTooltips from '../../common/infotooltips'
|
||||
import { priorityStudyright } from '../../populationFilters'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
class PriorityStudyright extends Component {
|
||||
static propTypes = {
|
||||
language: string.isRequired,
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
studyrights: arrayOf({}).isRequired
|
||||
}
|
||||
state = {
|
||||
prioritycode: undefined,
|
||||
degree: undefined,
|
||||
programme: undefined
|
||||
}
|
||||
graduationOptions = [
|
||||
{ value: 'grad', text: 'graduated' },
|
||||
{ value: 'either', text: 'studying' }
|
||||
] // illegal to pass boolean values as Dropdown options value :(
|
||||
|
||||
priorityOptions = [{ value: 1, text: 'primary studies' }, { value: 2, text: 'non-primary studies' }]
|
||||
|
||||
handleChange = (e, data) => {
|
||||
this.setState({ [data.name]: data.value })
|
||||
}
|
||||
handleLimit = () => {
|
||||
const { prioritycode, degree, programme } = this.state
|
||||
this.props.setPopulationFilter(priorityStudyright({
|
||||
prioritycode, degree, programme
|
||||
}))
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
renderSetText = (filter, studyrights) => {
|
||||
const { language } = this.props
|
||||
const { prioritycode, degree, programme } = filter.params
|
||||
let returnText = 'Showing students that has '
|
||||
if (degree && programme) {
|
||||
const degreeText = studyrights.degrees.find(sr => sr.code === degree)
|
||||
const programmeText = studyrights.programmes.find(sr => sr.code === programme)
|
||||
returnText = returnText.concat(`${degreeText ? degreeText.name[language] : ''} and ${programmeText ? programmeText.name[language] : ''}`)
|
||||
} else if (degree && !programme) {
|
||||
const degreeText = studyrights.degrees.find(sr => sr.code === degree)
|
||||
returnText = returnText.concat(`${degreeText ? degreeText.name[language] : ''}`)
|
||||
} else if (!degree && programme) {
|
||||
const programmeText = studyrights.programmes.find(sr => sr.code === programme)
|
||||
returnText = returnText.concat(`${programmeText ? programmeText.name[language] : ''}`)
|
||||
}
|
||||
returnText = returnText.concat(` as ${this.priorityOptions.find(option => option.value === prioritycode).text} `)
|
||||
return returnText
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const { filter, language, studyrights } = this.props
|
||||
|
||||
let degreeOptions = studyrights.degrees.map(sr =>
|
||||
({ value: sr.code, text: sr.name[language] }))
|
||||
degreeOptions = [{ value: 'anyDegree', text: 'any degree' }, ...degreeOptions]
|
||||
|
||||
let programmeOptions = studyrights.programmes.map(sr =>
|
||||
({ value: sr.code, text: sr.name[language] }))
|
||||
programmeOptions = [{ value: 'anyProgramme', text: 'any programme' }, ...programmeOptions]
|
||||
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Popup content={infoTooltips.PopulationStatistics.Filters.ExtentGraduated[language]} trigger={<Icon style={{ float: 'right' }} name="info" />} />
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Students that has</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Dropdown
|
||||
search
|
||||
fluid
|
||||
name="degree"
|
||||
placeholder="degree"
|
||||
onChange={this.handleChange}
|
||||
options={degreeOptions}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>
|
||||
and
|
||||
</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Dropdown
|
||||
search
|
||||
fluid
|
||||
name="programme"
|
||||
placeholder="programme"
|
||||
onChange={this.handleChange}
|
||||
options={programmeOptions}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>as</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Dropdown
|
||||
fluid
|
||||
placeholder="priority"
|
||||
name="prioritycode"
|
||||
onChange={this.handleChange}
|
||||
options={this.priorityOptions}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button
|
||||
onClick={this.handleLimit}
|
||||
disabled={this.state.prioritycode === undefined ||
|
||||
(this.state.degree === undefined && this.state.programme === undefined)}
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<label>
|
||||
{this.renderSetText(filter, studyrights)}
|
||||
</label>
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ settings, populations }) => ({
|
||||
language: settings.language,
|
||||
studyrights: populations.data.studyrights
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(PriorityStudyright)
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Radio, Icon, Form, Segment, Button } from 'semantic-ui-react'
|
||||
import { shape, func } from 'prop-types'
|
||||
|
||||
import { sexFilter } from '../../populationFilters'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
class SexFilter extends Component {
|
||||
static propTypes = {
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
sex: ''
|
||||
}
|
||||
|
||||
handleChange = (e, { value }) => {
|
||||
this.setState({ sex: value })
|
||||
}
|
||||
|
||||
handleSex = () => {
|
||||
this.props.setPopulationFilter(sexFilter({ gender: this.state.sex }))
|
||||
this.setState({ sex: '' })
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filter } = this.props
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Filter by gender</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Radio name="sex" onChange={this.handleChange} value="male" label="Male" checked={this.state.sex === 'male'} />
|
||||
<Radio name="sex" onChange={this.handleChange} value="female" label="Female" checked={this.state.sex === 'female'} />
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button
|
||||
onClick={this.handleSex}
|
||||
disabled={this.state.sex === ''}
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
{`Showing only ${filter.params.sex} students.`}
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
null,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(SexFilter)
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Radio, Button, Form, Popup } from 'semantic-ui-react'
|
||||
import { shape, func, string } from 'prop-types'
|
||||
|
||||
import infoTooltips from '../../common/infotooltips'
|
||||
import { startingThisSemester } from '../../populationFilters'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
class StartingThisSemester extends Component {
|
||||
static propTypes = {
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
language: string.isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
starting: true
|
||||
}
|
||||
|
||||
handleRadio = () => {
|
||||
this.props.setPopulationFilter(startingThisSemester({ starting: this.state.starting }))
|
||||
this.setState({ starting: true })
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filter } = this.props
|
||||
const toggleLabel = this.state.starting
|
||||
? 'started this semester'
|
||||
: 'had started before this semester'
|
||||
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Popup content={infoTooltips.PopulationStatistics.Filters.StartingThisSemester[this.props.language]} trigger={<Icon style={{ float: 'right' }} name="info" />} />
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Show only students that</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Radio
|
||||
toggle
|
||||
label={toggleLabel}
|
||||
checked={this.state.starting}
|
||||
onChange={() => this.setState({ starting: !this.state.starting })}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button
|
||||
onClick={this.handleRadio}
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
{filter.params.starting ? 'Started this semester' : 'Had started before this semester'}
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
const mapStateToProps = ({ settings }) => ({
|
||||
language: settings.language
|
||||
})
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(StartingThisSemester)
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Dropdown, Button, Form, Popup } from 'semantic-ui-react'
|
||||
import { shape, func, string } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
|
||||
import infoTooltips from '../../common/infotooltips'
|
||||
import { transferFilter } from '../../populationFilters'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
|
||||
class TransferFilter extends Component {
|
||||
static propTypes = {
|
||||
filter: shape({}).isRequired,
|
||||
removePopulationFilter: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
transfers: shape({}).isRequired,
|
||||
language: string.isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
selectedSource: '',
|
||||
selectedTarget: ''
|
||||
}
|
||||
getName = (code) => {
|
||||
const { transfers } = this.props
|
||||
const mergedTransfers = { ...Object.values(transfers)[0], ...Object.values(transfers)[1] }
|
||||
return mergedTransfers[code].name.fi
|
||||
}
|
||||
|
||||
handleChange = (e, data) => {
|
||||
this.setState({ [data.name]: data.value })
|
||||
}
|
||||
|
||||
handleFilter = () => {
|
||||
const { selectedSource, selectedTarget } = this.state
|
||||
this.props.setPopulationFilter(transferFilter({
|
||||
source: selectedSource,
|
||||
target: selectedTarget
|
||||
}))
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.props.removePopulationFilter(this.props.filter.id)
|
||||
}
|
||||
renderFilterText = (filter) => {
|
||||
const sourceName = this.getName(filter.params.source)
|
||||
const targetName = this.getName(filter.params.target)
|
||||
let returnText = 'Showing students that transferred'
|
||||
if (sourceName !== 'Anywhere') {
|
||||
returnText = returnText.concat(` from ${sourceName}`)
|
||||
}
|
||||
if (targetName !== 'Anywhere') {
|
||||
returnText = returnText.concat(` to ${targetName}`)
|
||||
}
|
||||
return returnText
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filter, transfers } = this.props
|
||||
const { sources, targets } = transfers
|
||||
sources.anywhere = { name: { en: 'Anywhere', fi: 'Anywhere' }, targets }
|
||||
targets.anywhere = { name: { en: 'Anywhere', fi: 'Anywhere' }, sources }
|
||||
let filteredTargets = targets
|
||||
let filteredSources = sources
|
||||
if (this.state.selectedSource !== '') {
|
||||
filteredTargets = { ...sources[this.state.selectedSource].targets, anywhere: { name: { en: 'Anywhere', fi: 'Anywhere' } } }
|
||||
}
|
||||
if (this.state.selectedTarget !== '') {
|
||||
filteredSources = { ...targets[this.state.selectedTarget].sources, anywhere: { name: { en: 'Anywhere', fi: 'Anywhere' } } }
|
||||
}
|
||||
if (filter.notSet) {
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Popup content={infoTooltips.PopulationStatistics.Filters.TransferFilter[this.props.language]} trigger={<Icon style={{ float: 'right' }} name="info" />} />
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Students that transferred from </label>
|
||||
</Form.Field>
|
||||
<Form.Field
|
||||
style={{ width: 300 }}
|
||||
>
|
||||
<Dropdown
|
||||
search
|
||||
fluid
|
||||
icon={null}
|
||||
name="selectedSource"
|
||||
placeholder="select source"
|
||||
onChange={this.handleChange}
|
||||
value={this.state.courseType}
|
||||
options={_.sortBy(Object.entries(filteredSources).map(([value, text]) => ({
|
||||
value,
|
||||
text: text.name.fi
|
||||
})), entry => entry.text)}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label> to </label>
|
||||
</Form.Field>
|
||||
<Form.Field
|
||||
style={{ width: 300 }}
|
||||
>
|
||||
<Dropdown
|
||||
search
|
||||
icon={null}
|
||||
fluid
|
||||
name="selectedTarget"
|
||||
placeholder="select target"
|
||||
onChange={this.handleChange}
|
||||
value={this.state.discipline}
|
||||
options={_.sortBy(Object.entries(filteredTargets).map(([value, text]) => ({
|
||||
value,
|
||||
text: text.name.fi
|
||||
})), entry => entry.text)}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Button
|
||||
onClick={this.handleFilter}
|
||||
disabled={this.state.selectedSource === '' ||
|
||||
this.state.selectedTarget === ''
|
||||
}
|
||||
>
|
||||
set filter
|
||||
</Button>
|
||||
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
{this.renderFilterText(filter)}
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={this.clearFilter} />
|
||||
</span>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populationCourses, settings }) => ({
|
||||
courseTypes: populationCourses[0].data.coursetypes,
|
||||
disciplines: populationCourses[0].data.disciplines,
|
||||
courses: populationCourses[0].data.coursestatistics,
|
||||
language: settings.language
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ setPopulationFilter, removePopulationFilter }
|
||||
)(TransferFilter)
|
||||
@@ -1,61 +1,332 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Header, Button } from 'semantic-ui-react'
|
||||
import { arrayOf, object } from 'prop-types'
|
||||
import { Segment, Header, Button, Form, Radio, Modal, Icon, TextArea, Input } from 'semantic-ui-react'
|
||||
import { object, func, arrayOf, bool, string } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
import uuidv4 from 'uuid/v4'
|
||||
|
||||
import { getTranslate } from 'react-localize-redux'
|
||||
import CreditsLessThan from './CreditsLessThan'
|
||||
import CreditsAtLeast from './CreditsAtLeast'
|
||||
import StartingThisSemester from './StartingThisSemester'
|
||||
import CourseParticipation from './CourseParticipation'
|
||||
import CourseParticipationNTimes from './CourseParticipationNTimes'
|
||||
import ExtentGraduated from './ExtentGraduated'
|
||||
import Preset from './Preset'
|
||||
import DisciplineTypes from './DisciplineTypes'
|
||||
import EnrollmentStatus from './EnrollmentStatus'
|
||||
import TransferFilter from './TransferFilter'
|
||||
import CanceledStudyright from './CanceledStudyright'
|
||||
import PriorityStudyright from './PriorityStudyright'
|
||||
import { clearPopulationFilters, setComplementFilter, savePopulationFilters, setPopulationFilter } from '../../redux/populationFilters'
|
||||
import { presetFilter, getFilterFunction } from '../../populationFilters'
|
||||
|
||||
|
||||
const componentFor = {
|
||||
CreditsAtLeast,
|
||||
CreditsLessThan,
|
||||
StartingThisSemester,
|
||||
CourseParticipationNTimes,
|
||||
DisciplineTypes,
|
||||
EnrollmentStatus,
|
||||
CourseParticipation,
|
||||
ExtentGraduated,
|
||||
TransferFilter,
|
||||
CanceledStudyright,
|
||||
PriorityStudyright
|
||||
}
|
||||
const advancedFilters = { // Filters that are too hard to use for common folk
|
||||
DisciplineTypes,
|
||||
TransferFilter,
|
||||
ExtentGraduated,
|
||||
PriorityStudyright
|
||||
|
||||
}
|
||||
const persistantFilters = { // Filters that can be duplicated with different values
|
||||
ExtentGraduated,
|
||||
TransferFilter
|
||||
}
|
||||
|
||||
|
||||
class PopulationFilters extends Component {
|
||||
static propTypes = {
|
||||
populationFilters: arrayOf(object).isRequired
|
||||
filters: arrayOf(object).isRequired,
|
||||
complemented: bool.isRequired,
|
||||
clearPopulationFilters: func.isRequired,
|
||||
setComplementFilter: func.isRequired,
|
||||
savePopulationFilters: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
studyRights: arrayOf(string).isRequired,
|
||||
populationFilters: object.isRequired, //eslint-disable-line
|
||||
populationCourses: object.isRequired //eslint-disable-line
|
||||
}
|
||||
|
||||
state = {
|
||||
visible: false
|
||||
visible: false,
|
||||
presetName: '',
|
||||
presetDescription: '',
|
||||
presetFilters: [],
|
||||
firstRenderKludge: true,
|
||||
advancedUser: false,
|
||||
modalOpen: false
|
||||
}
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.state.firstRenderKludge || (this.props.populationCourses.pending === false
|
||||
&& prevProps.populationCourses.pending === true)
|
||||
|| (this.props.populationFilters.filtersFromBackend.length
|
||||
!== prevProps.populationFilters.filtersFromBackend.length)) {
|
||||
this.updateFilterList(this.props.populationFilters.filtersFromBackend)
|
||||
this.setState({ firstRenderKludge: !this.state.firstRenderKludge }) // eslint-disable-line react/no-did-update-set-state,max-len
|
||||
/* You may call setState() immediately in componentDidUpdate()
|
||||
but note that it must be wrapped in a condition */
|
||||
}
|
||||
}
|
||||
formatFilter = (filter) => {
|
||||
let filterToSave = {}
|
||||
if (filter.type === 'Preset') {
|
||||
filterToSave = {
|
||||
...filter,
|
||||
filters: filter.filters.map(f => this.formatFilter(f))
|
||||
}
|
||||
} else {
|
||||
filterToSave = {
|
||||
...filter,
|
||||
params: filter.type === 'CourseParticipation' ?
|
||||
{
|
||||
field: filter.params.field,
|
||||
course: {
|
||||
course: {
|
||||
name: filter.params.course.course.name,
|
||||
code: filter.params.course.course.code
|
||||
}
|
||||
}
|
||||
}
|
||||
:
|
||||
filter.params
|
||||
}
|
||||
}
|
||||
return filterToSave
|
||||
}
|
||||
handleSavePopulationFilters = () => {
|
||||
const preset = {
|
||||
id: uuidv4(),
|
||||
name: this.state.presetName,
|
||||
description: this.state.presetDescription,
|
||||
population: this.props.studyRights,
|
||||
filters: this.props.filters
|
||||
}
|
||||
this.setState({ presetName: '', presetDescription: '' })
|
||||
const presetToSave = {
|
||||
...preset,
|
||||
filters: preset.filters.map(filter => this.formatFilter(filter))
|
||||
}
|
||||
this.props.savePopulationFilters(presetToSave)
|
||||
this.updateFilterList([preset])
|
||||
this.props.clearPopulationFilters()
|
||||
this.props.setPopulationFilter(presetFilter(preset))
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.populationFilters.length === 0 && !this.state.visible) {
|
||||
|
||||
updateFilterList(filtersToCreate) {
|
||||
// sorry for the uglyness but it kinda works (I think)
|
||||
const regenerateFilterFunctions = filters => /* eslint-disable */
|
||||
filters.map(f => f.type === 'Preset' ?
|
||||
getFilterFunction(f.type, { ...f, filters: regenerateFilterFunctions(f.filters) },
|
||||
this.props.populationCourses.data)
|
||||
:
|
||||
getFilterFunction(f.type, f.params, this.props.populationCourses.data))
|
||||
|
||||
if (filtersToCreate) {
|
||||
const newFilters = filtersToCreate.map(newFilter =>
|
||||
({
|
||||
...newFilter,
|
||||
filters: regenerateFilterFunctions(newFilter.filters)
|
||||
}))
|
||||
this.setState({ presetFilters: this.state.presetFilters.concat(newFilters) })
|
||||
}
|
||||
}
|
||||
destroyFromAllFilters = id => this.setState({ presetFilters: this.state.presetFilters.filter(filter => filter.id !== id) })
|
||||
|
||||
|
||||
renderAddFilters() {
|
||||
const { extents, transfers } = this.props
|
||||
|
||||
const allFilters = _.union(Object.keys(componentFor).filter(f =>
|
||||
!(Object.keys(advancedFilters).includes(f) && !this.state.advancedUser)).map(f =>
|
||||
String(f)), this.state.presetFilters.map(f => f.id).filter(f => this.state.advancedUser))
|
||||
|
||||
const setFilters = _.union(
|
||||
this.props.filters.map(f => f.type),
|
||||
this.props.filters.filter(f => f.type === 'Preset').map(f => f.id),
|
||||
|
||||
)
|
||||
const unsetFilters = _.uniq(_.difference(allFilters, setFilters.filter(setFilter => !Object.keys(persistantFilters).includes(setFilter))))
|
||||
if (unsetFilters.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (!this.state.visible) {
|
||||
return (
|
||||
<Segment>
|
||||
<Header>Filters</Header>
|
||||
<Header>Add filters</Header>
|
||||
<Button onClick={() => this.setState({ visible: true })}>add</Button>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
if (this.props.populationFilters.length === 0) {
|
||||
return (
|
||||
<Segment>
|
||||
<Header>Filters</Header>
|
||||
<div>
|
||||
<em>
|
||||
Note that filters does not work yet when population is limited by students
|
||||
that have participated a specific course
|
||||
</em>
|
||||
</div>
|
||||
<CreditsLessThan filter={{ notSet: true }} />
|
||||
<Button onClick={() => this.setState({ visible: false })}>cancel</Button>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Segment>
|
||||
<Header>Add filters</Header>
|
||||
<div>
|
||||
<Radio
|
||||
toggle
|
||||
label="Advanced filters"
|
||||
checked={this.state.advancedUser}
|
||||
onChange={() => this.setState({ advancedUser: !this.state.advancedUser })}
|
||||
/>
|
||||
</div>
|
||||
{unsetFilters.map(filterName => {//eslint-disable-line
|
||||
if (componentFor[filterName]) { // THIS IS KINDA HACKED SOLUTION PLS FIX
|
||||
return React.createElement(componentFor[filterName], {
|
||||
filter: { notSet: true }, key: filterName, samples: this.props.samples, transfers, extents
|
||||
})
|
||||
}
|
||||
else {
|
||||
return React.createElement(Preset, {
|
||||
filter: {
|
||||
...this.state.presetFilters.find(f => f.id === filterName),
|
||||
notSet: true
|
||||
},
|
||||
key: filterName,
|
||||
destroy: this.destroyFromAllFilters
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
<Button onClick={() => this.setState({ visible: false })}>cancel</Button>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
const filter = this.props.populationFilters[0]
|
||||
|
||||
renderSetFilters(handleSave) {
|
||||
const setFilters = this.props.filters.map(f => f.type)
|
||||
if (setFilters.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<Header>Filters</Header>
|
||||
<CreditsLessThan filter={filter} />
|
||||
<Form>
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Showing students that</label>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Radio
|
||||
toggle
|
||||
checked={this.props.complemented}
|
||||
onClick={this.props.setComplementFilter}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>{!this.props.complemented ? ' are included in the filters.' : ' are in excluded by the filters.'}</label>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
{this.props.filters.map(filter => {
|
||||
|
||||
if (filter.type !== 'Preset') {
|
||||
return React.createElement(componentFor[filter.type], { filter, key: filter.id, samples: this.props.samples, transfers: this.props.transfers, extents: this.props.extents })
|
||||
}
|
||||
return React.createElement(Preset, {
|
||||
filter, key: filter.id, destroy: this.destroyFromAllFilters
|
||||
})
|
||||
})}
|
||||
|
||||
<Button onClick={this.props.clearPopulationFilters}>clear all filters</Button>
|
||||
{this.state.advancedUser ?
|
||||
<Modal
|
||||
style={{
|
||||
marginTop: 'auto !important',
|
||||
display: 'inline-block !important',
|
||||
position: 'relative',
|
||||
top: '20%',
|
||||
left: '33%'
|
||||
}}
|
||||
trigger={<Button onClick={() => this.setState({ modalOpen: true })}>Save filters as preset</Button>}
|
||||
open={this.state.modalOpen}
|
||||
onClose={() => this.setState({ modalOpen: false })}
|
||||
size='small'
|
||||
>
|
||||
<Header />
|
||||
<Modal.Content>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<h2> Save current filters as preset </h2>
|
||||
<em> This filter is saved in this population for future use </em>
|
||||
<Input placeholder="Name..." maxLength={40} onChange={e => this.setState({ presetName: e.target.value })} />
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<em> explain what your filter is doing here </em>
|
||||
<TextArea placeholder="Description..." maxLength={160} onChange={e => this.setState({ presetDescription: e.target.value })} />
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
negative
|
||||
onClick={() => this.setState({ modalOpen: false })}
|
||||
>Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={this.state.presetName === ''}
|
||||
color='green'
|
||||
onClick={() => {
|
||||
handleSave()
|
||||
this.setState({ modalOpen: false })
|
||||
}}
|
||||
inverted>
|
||||
<Icon name='checkmark' /> Save
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
: null
|
||||
}
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderAddFilters()}
|
||||
{this.renderSetFilters(this.handleSavePopulationFilters)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populationFilters, locale, graphSpinner }) => ({
|
||||
|
||||
const mapStateToProps = ({
|
||||
populationFilters,
|
||||
locale,
|
||||
graphSpinner,
|
||||
populations,
|
||||
populationCourses
|
||||
}) => ({
|
||||
|
||||
populationCourses: populationCourses[0],
|
||||
populationFilters,
|
||||
filters: populationFilters.filters,
|
||||
complemented: populationFilters.complemented,
|
||||
translate: getTranslate(locale),
|
||||
loading: graphSpinner
|
||||
loading: graphSpinner,
|
||||
studyRights: populations.query.studyRights,
|
||||
extents: populations.data.extents,
|
||||
transfers: populations.data.transfers
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(PopulationFilters)
|
||||
export default connect(mapStateToProps, {
|
||||
clearPopulationFilters, setComplementFilter, savePopulationFilters, setPopulationFilter
|
||||
})(PopulationFilters)
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, object } from 'prop-types'
|
||||
import { Segment, Icon } from 'semantic-ui-react'
|
||||
import { setPopulationLimitField, clearPopulationLimit } from '../../redux/populationLimit'
|
||||
import { setLoading } from '../../redux/graphSpinner'
|
||||
|
||||
class PopulationLimiter extends Component {
|
||||
static propTypes = {
|
||||
clearPopulationLimit: func.isRequired,
|
||||
setPopulationLimitField: func.isRequired,
|
||||
setLoading: func.isRequired,
|
||||
selected: object // eslint-disable-line
|
||||
}
|
||||
|
||||
selectField = field => () => {
|
||||
if (field !== this.props.selected.field) {
|
||||
this.props.setLoading()
|
||||
setTimeout(() => {
|
||||
this.props.setPopulationLimitField(field)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selected } = this.props
|
||||
|
||||
const active = field =>
|
||||
(selected && selected.field === field)
|
||||
|
||||
if (!selected) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Segment.Group horizontal>
|
||||
<Segment>
|
||||
<em>{selected.course.course.name}</em>
|
||||
<span style={{ float: 'right' }}>
|
||||
<Icon name="remove" onClick={() => this.props.clearPopulationLimit()} />
|
||||
</span>
|
||||
</Segment>
|
||||
<Segment
|
||||
inverted={active('all')}
|
||||
secondary={active('all')}
|
||||
onClick={this.selectField('all')}
|
||||
>
|
||||
all
|
||||
</Segment>
|
||||
<Segment
|
||||
inverted={active('passed')}
|
||||
secondary={active('passed')}
|
||||
onClick={this.selectField('passed')}
|
||||
>
|
||||
passed
|
||||
</Segment>
|
||||
<Segment
|
||||
inverted={active('retryPassed')}
|
||||
secondary={active('retryPassed')}
|
||||
onClick={this.selectField('retryPassed')}
|
||||
>
|
||||
passed after fail
|
||||
</Segment>
|
||||
<Segment
|
||||
inverted={active('failed')}
|
||||
secondary={active('failed')}
|
||||
onClick={this.selectField('failed')}
|
||||
>
|
||||
failed
|
||||
</Segment>
|
||||
<Segment
|
||||
inverted={active('failedMany')}
|
||||
secondary={active('failedMany')}
|
||||
onClick={this.selectField('failedMany')}
|
||||
>
|
||||
failed many
|
||||
</Segment>
|
||||
</Segment.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
selected: state.populationLimit
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
setPopulationLimitField, clearPopulationLimit, setLoading
|
||||
})(PopulationLimiter)
|
||||
@@ -1,39 +1,96 @@
|
||||
import React from 'react'
|
||||
import { func, arrayOf, object, shape, string } from 'prop-types'
|
||||
import { Card, Icon } from 'semantic-ui-react'
|
||||
|
||||
import { connect } from 'react-redux'
|
||||
import { func, arrayOf, object, shape, string, bool } from 'prop-types'
|
||||
import { Card, Icon, Button } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import styles from './populationQueryCard.css'
|
||||
import { DISPLAY_DATE_FORMAT } from '../../constants'
|
||||
import { reformatDate } from '../../common'
|
||||
|
||||
const PopulationQueryCard = ({ translate, population, query, removeSampleFn, unit }) => {
|
||||
const { uuid, year, semester } = query
|
||||
|
||||
return (
|
||||
<Card className={styles.cardContainer}>
|
||||
<Card.Header className={styles.cardHeader}>
|
||||
<div>
|
||||
{unit.name}
|
||||
</div>
|
||||
<Icon
|
||||
name="remove"
|
||||
className={styles.controlIcon}
|
||||
onClick={() => removeSampleFn(uuid)}
|
||||
/>
|
||||
</Card.Header>
|
||||
<Card.Meta>
|
||||
<div className={styles.dateItem}>
|
||||
<Icon name="calendar" size="small" /> {`${translate(`populationStatistics.${semester}`)}/${year}`}
|
||||
</div>
|
||||
<div>
|
||||
{`${translate('populationStatistics.sampleSize', { amount: population.length })} `}
|
||||
</div>
|
||||
</Card.Meta>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
const PopulationQueryCard =
|
||||
({ translate,
|
||||
population,
|
||||
query,
|
||||
removeSampleFn,
|
||||
units,
|
||||
updateStudentsFn,
|
||||
updating,
|
||||
language
|
||||
}) => {
|
||||
const { uuid, year, semesters, months } = query
|
||||
const { students } = population
|
||||
if (students.length > 0) {
|
||||
return (
|
||||
<Card className={styles.cardContainer}>
|
||||
<Card.Header className={styles.cardHeader}>
|
||||
<div>{Object.values(units).map(u => u.name[language]).join(', ')}</div>
|
||||
<Icon
|
||||
name="remove"
|
||||
className={styles.controlIcon}
|
||||
onClick={() => removeSampleFn(uuid)}
|
||||
/>
|
||||
</Card.Header>
|
||||
<Card.Meta>
|
||||
<div className={styles.dateItem}>
|
||||
<Icon name="calendar" size="small" /> {`${semesters.map(s => translate(`populationStatistics.${s}`))}/${year}-${Number(year) + 1}, showing ${months} months.`}
|
||||
</div>
|
||||
<div>
|
||||
{`${translate('populationStatistics.sampleSize', { amount: students.length })} `}
|
||||
</div>
|
||||
<div>
|
||||
{`Updated at ${reformatDate(_.minBy(students, 'updatedAt').updatedAt, DISPLAY_DATE_FORMAT)} `}
|
||||
</div>
|
||||
{updating ?
|
||||
<Button disabled compact floated="left" size="medium" labelPosition="left" onClick={updateStudentsFn} >
|
||||
<Icon loading name="refresh" />
|
||||
update population
|
||||
</Button>
|
||||
:
|
||||
<Button compact floated="left" size="medium" labelPosition="left" onClick={updateStudentsFn} >
|
||||
<Icon name="refresh" />
|
||||
update population
|
||||
</Button>
|
||||
}
|
||||
</Card.Meta>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Card className={styles.cardContainer}>
|
||||
<Card.Header className={styles.cardHeader}>
|
||||
<div>{Object.values(units).map(u => u.name[language]).join(', ')}</div>
|
||||
<Icon
|
||||
name="remove"
|
||||
className={styles.controlIcon}
|
||||
onClick={() => removeSampleFn(uuid)}
|
||||
/>
|
||||
</Card.Header>
|
||||
<Card.Meta>
|
||||
<div className={styles.dateItem}>
|
||||
<Icon name="calendar" size="small" /> {`${semesters.map(s => translate(`populationStatistics.${s}`))}/${year}-${Number(year) + 1}, showing ${months} months.`}
|
||||
</div>
|
||||
<div>
|
||||
{`${translate('populationStatistics.sampleSize', { amount: students.length })} `}
|
||||
</div>
|
||||
{updating ?
|
||||
<Button disabled compact floated="left" size="medium" labelPosition="left" onClick={updateStudentsFn} >
|
||||
<Icon loading name="refresh" />
|
||||
update population
|
||||
</Button>
|
||||
:
|
||||
<Button compact floated="left" size="medium" labelPosition="left" onClick={updateStudentsFn} >
|
||||
<Icon name="refresh" />
|
||||
update population
|
||||
</Button>
|
||||
}
|
||||
</Card.Meta>
|
||||
</Card>)
|
||||
}
|
||||
|
||||
PopulationQueryCard.propTypes = {
|
||||
language: string.isRequired,
|
||||
translate: func.isRequired,
|
||||
population: arrayOf(object).isRequired,
|
||||
population: shape({ students: arrayOf(object), extents: arrayOf(object) }).isRequired,
|
||||
query: shape({
|
||||
year: string,
|
||||
semester: string,
|
||||
@@ -41,7 +98,12 @@ PopulationQueryCard.propTypes = {
|
||||
uuid: string
|
||||
}).isRequired,
|
||||
removeSampleFn: func.isRequired,
|
||||
unit: object // eslint-disable-line
|
||||
units: arrayOf(object).isRequired,
|
||||
unit: object, // eslint-disable-line
|
||||
updateStudentsFn: func.isRequired,
|
||||
updating: bool.isRequired
|
||||
}
|
||||
|
||||
export default PopulationQueryCard
|
||||
const mapStateToProps = ({ settings }) => ({ language: settings.language })
|
||||
|
||||
export default connect(mapStateToProps, null)(PopulationQueryCard)
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, arrayOf, object } from 'prop-types'
|
||||
import { Form, Button, Message, Radio, Dropdown } from 'semantic-ui-react'
|
||||
import { func, arrayOf, shape, bool, string, object } from 'prop-types'
|
||||
import { Form, Button, Message, Dropdown, Icon, Checkbox } from 'semantic-ui-react'
|
||||
import { getTranslate } from 'react-localize-redux'
|
||||
import uuidv4 from 'uuid/v4'
|
||||
import Datetime from 'react-datetime'
|
||||
import { isEqual } from 'lodash'
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
|
||||
import { getPopulationStatistics, clearPopulations } from '../../redux/populations'
|
||||
import { getPopulationCourses } from '../../redux/populationCourses'
|
||||
import { getUnits } from '../../redux/units'
|
||||
import { isInDateFormat, momentFromFormat, reformatDate, isValidYear } from '../../common'
|
||||
import { makeMapRightsToDropDown } from '../../selectors/populationSearchForm'
|
||||
import { setLoading } from '../../redux/graphSpinner'
|
||||
import { getPopulationFilters, setPopulationFilter } from '../../redux/populationFilters'
|
||||
import { extentGraduated, canceledStudyright } from '../../populationFilters'
|
||||
|
||||
import { getDegreesAndProgrammes } from '../../redux/populationDegreesAndProgrammes'
|
||||
import { isInDateFormat, momentFromFormat, reformatDate, isValidYear, textAndDescriptionSearch } from '../../common'
|
||||
import { setLoading } from '../../redux/graphSpinner'
|
||||
import LanguageChooser from '../LanguageChooser'
|
||||
import style from './populationSearchForm.css'
|
||||
import { dropdownType } from '../../constants/types'
|
||||
|
||||
@@ -22,14 +24,21 @@ const YEAR_DATE_FORMAT = 'YYYY'
|
||||
|
||||
class PopulationSearchForm extends Component {
|
||||
static propTypes = {
|
||||
language: string.isRequired,
|
||||
translate: func.isRequired,
|
||||
getUnits: func.isRequired,
|
||||
getDegreesAndProgrammes: func.isRequired,
|
||||
getPopulationStatistics: func.isRequired,
|
||||
getPopulationCourses: func.isRequired,
|
||||
getPopulationFilters: func.isRequired,
|
||||
setPopulationFilter: func.isRequired,
|
||||
clearPopulations: func.isRequired,
|
||||
queries: arrayOf(object).isRequired,
|
||||
studyProgrammes: arrayOf(dropdownType).isRequired,
|
||||
setLoading: func.isRequired
|
||||
queries: shape({}).isRequired,
|
||||
studyProgrammes: shape({}), //eslint-disable-line
|
||||
degrees: arrayOf(dropdownType), //eslint-disable-line
|
||||
studyTracks: arrayOf(dropdownType), //eslint-disable-line
|
||||
setLoading: func.isRequired,
|
||||
extents: arrayOf(object).isRequired,
|
||||
pending: bool //eslint-disable-line
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@@ -37,7 +46,7 @@ class PopulationSearchForm extends Component {
|
||||
|
||||
const INITIAL_QUERY = {
|
||||
year: '2017',
|
||||
semester: 'FALL',
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
studyRights: [],
|
||||
months: this.months('2017', 'FALL')
|
||||
}
|
||||
@@ -51,38 +60,50 @@ class PopulationSearchForm extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
const { studyProgrammes } = this.props
|
||||
if (studyProgrammes.length === 0) {
|
||||
this.props.getUnits()
|
||||
if (!studyProgrammes || studyProgrammes.length === 0) {
|
||||
this.props.getDegreesAndProgrammes()
|
||||
}
|
||||
}
|
||||
|
||||
months(year, term) { // eslint-disable-line
|
||||
const start = term === 'FALL' ? `${year}-08-01` : `${year}-01-01`
|
||||
months(year, term) { // eslint-disable-line
|
||||
const start = term === 'FALL' ? `${year}-08-01` : moment(`${year}-01-01`).add(1, 'years')
|
||||
return Math.ceil(moment.duration(moment().diff(moment(start))).asMonths())
|
||||
}
|
||||
|
||||
validateQuery = () => {
|
||||
const { queries } = this.props
|
||||
const { query } = this.state
|
||||
return queries.some((q) => {
|
||||
const compare = { ...q }
|
||||
delete compare.uuid
|
||||
return isEqual(compare, query)
|
||||
})
|
||||
const compare = { ...queries }
|
||||
delete compare.uuid
|
||||
return _.isEqual(compare, query)
|
||||
}
|
||||
|
||||
clearPopulations = () => this.props.clearPopulations()
|
||||
|
||||
fetchPopulation = () => {
|
||||
const { query } = this.state
|
||||
let queryCodes = []
|
||||
queryCodes = [...Object.values(query.studyRights)]
|
||||
|
||||
const backendQuery = { ...query, studyRights: queryCodes }
|
||||
const uuid = uuidv4()
|
||||
const request = { ...query, uuid }
|
||||
const request = { ...backendQuery, uuid }
|
||||
this.setState({ isLoading: true })
|
||||
this.props.setLoading()
|
||||
Promise.all([
|
||||
this.props.getPopulationStatistics(request),
|
||||
this.props.getPopulationCourses(request)
|
||||
]).then(() => this.setState({ isLoading: false }))
|
||||
this.props.getPopulationCourses(request),
|
||||
this.props.getPopulationFilters(request)
|
||||
]).then(() => {
|
||||
if (this.props.extents.map(e => e.extentcode).includes(7)) {
|
||||
this.props.setPopulationFilter(extentGraduated({ extentcode: 7, graduated: 'either', complemented: 'true' }))
|
||||
}
|
||||
if (this.props.extents.map(e => e.extentcode).includes(34)) {
|
||||
this.props.setPopulationFilter(extentGraduated({ extentcode: 34, graduated: 'either', complemented: 'true' }))
|
||||
}
|
||||
this.props.setPopulationFilter(canceledStudyright({ studyrights: queryCodes, canceled: 'false' }))
|
||||
this.setState({ isLoading: false })
|
||||
})
|
||||
}
|
||||
|
||||
handleYearSelection = (year) => {
|
||||
@@ -94,7 +115,7 @@ class PopulationSearchForm extends Component {
|
||||
query: {
|
||||
...query,
|
||||
year: reformatDate(year, YEAR_DATE_FORMAT),
|
||||
months: this.months(reformatDate(year, YEAR_DATE_FORMAT), this.state.query.semester)
|
||||
months: this.months(reformatDate(year, YEAR_DATE_FORMAT), this.state.query.semesters.includes('FALL') ? 'FALL' : 'SPRING')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -116,118 +137,357 @@ class PopulationSearchForm extends Component {
|
||||
|
||||
handleSemesterSelection = (e, { value }) => {
|
||||
const { query } = this.state
|
||||
const semesters = query.semesters.includes(value) ?
|
||||
query.semesters.filter(s => s !== value) : [...query.semesters, value]
|
||||
this.setState({
|
||||
query: {
|
||||
...query,
|
||||
semester: value,
|
||||
months: this.months(this.state.query.year, value)
|
||||
semesters,
|
||||
months: this.months(this.state.query.year, semesters.includes('FALL') ? 'FALL' : 'SPRING')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleStudyRightChange = (e, { value }) => {
|
||||
handleDegreeChange = (e, { value }) => {
|
||||
const { query } = this.state
|
||||
const studyRights = value.length > 0 ? [value[0]] : []
|
||||
const degree = value
|
||||
this.setState({
|
||||
query: {
|
||||
...query,
|
||||
studyRights
|
||||
studyRights: {
|
||||
...query.studyRights,
|
||||
degree
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
handleProgrammeChange = (e, { value }) => {
|
||||
const { query } = this.state
|
||||
const programme = value
|
||||
|
||||
this.setState({
|
||||
query: {
|
||||
...query,
|
||||
studyRights: {
|
||||
programme
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
handleStudyTrackChange = (e, { value }) => {
|
||||
const { query } = this.state
|
||||
const studyTrack = value
|
||||
|
||||
this.setState({
|
||||
query: {
|
||||
...query,
|
||||
studyRights: {
|
||||
...query.studyRights,
|
||||
studyTrack
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleMonthsChange = (e, { value }) => {
|
||||
handleMonthsChange = (value) => {
|
||||
const { query } = this.state
|
||||
const months = this.getMonths(query.year, value, this.state.query.semesters.includes('FALL') ? 'FALL' : 'SPRING')
|
||||
this.setState({
|
||||
query: {
|
||||
...query,
|
||||
months
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleClear = (type) => {
|
||||
const { query } = this.state
|
||||
this.setState({
|
||||
query: {
|
||||
...query,
|
||||
months: value
|
||||
studyRights: {
|
||||
...query.studyRights,
|
||||
[type]: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// (Potential?) issue with using Math.ceil with months.
|
||||
getMonths = (year, end, term) => {
|
||||
const lastDayOfMonth = moment(end).endOf('month')
|
||||
const start = term === 'FALL' ? `${year}-08-01` : `${year}-01-01`
|
||||
return Math.ceil(moment.duration(moment(lastDayOfMonth).diff(moment(start))).asMonths())
|
||||
}
|
||||
|
||||
getMinSelection = (year, semester) => (semester === 'FALL' ? `${year}-08-01` : `${year}-01-01`)
|
||||
|
||||
|
||||
renderableList = (list) => {
|
||||
const { language } = this.props
|
||||
return list.map((sp) => {
|
||||
const shh = {}
|
||||
Object.assign(shh, sp)
|
||||
shh.text = sp.name[language]
|
||||
shh.description = sp.code
|
||||
shh.value = sp.code
|
||||
return shh
|
||||
})
|
||||
}
|
||||
|
||||
renderEnrollmentDateSelector = () => {
|
||||
const { translate } = this.props
|
||||
const { query, validYear } = this.state
|
||||
const { semester, year, months } = query
|
||||
|
||||
const semesters = ['FALL', 'SPRING']
|
||||
|
||||
const { semesters, year } = query
|
||||
return (
|
||||
<Form.Group key="year" className={style.enrollmentSelectorGroup}>
|
||||
<Form.Field error={!validYear} className={style.yearSelect}>
|
||||
<label>{translate('populationStatistics.enrollmentYear')}</label>
|
||||
<label>Enrollment</label>
|
||||
<Datetime
|
||||
className={style.yearSelectInput}
|
||||
control={Datetime}
|
||||
dateFormat={YEAR_DATE_FORMAT}
|
||||
timeFormat={false}
|
||||
renderYear={(props, selectableYear) => <td {...props}>{`${selectableYear}-${selectableYear + 1}`}</td>}
|
||||
closeOnSelect
|
||||
value={year}
|
||||
value={`${year}-${moment(year).add(1, 'years').format('YYYY')}`}
|
||||
isValidDate={isValidYear}
|
||||
onChange={this.handleYearSelection}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field className={style.yearControl}>
|
||||
<Button.Group basic vertical className={style.yearControlButtonGroup}>
|
||||
<Button icon="plus" className={style.yearControlButton} onClick={this.addYear} />
|
||||
<Button icon="minus" className={style.yearControlButton} onClick={this.subtractYear} />
|
||||
<Button icon="plus" className={style.yearControlButton} onClick={this.addYear} tabIndex="-1" />
|
||||
<Button icon="minus" className={style.yearControlButton} onClick={this.subtractYear} tabIndex="-1" />
|
||||
</Button.Group>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>{translate('populationStatistics.semester')}</label>
|
||||
{semesters.map(s => (
|
||||
<Radio
|
||||
className={style.semesterRadio}
|
||||
key={s}
|
||||
label={translate(`populationStatistics.${s}`)}
|
||||
value={s}
|
||||
name="semesterGroup"
|
||||
checked={semester === s}
|
||||
onChange={this.handleSemesterSelection}
|
||||
/>
|
||||
))}
|
||||
<label>Semesters</label>
|
||||
<Checkbox
|
||||
className={style.semesterRadio}
|
||||
key="FALL"
|
||||
label={translate(`populationStatistics.${'FALL'}`)}
|
||||
value="FALL"
|
||||
name="semesterGroup"
|
||||
checked={semesters.includes('FALL')}
|
||||
onChange={this.handleSemesterSelection}
|
||||
/>
|
||||
<Checkbox
|
||||
className={style.semesterRadio}
|
||||
key="SPRING"
|
||||
label={translate(`populationStatistics.${'SPRING'}`)}
|
||||
value="SPRING"
|
||||
name="semesterGroup"
|
||||
checked={semesters.includes('SPRING')}
|
||||
onChange={this.handleSemesterSelection}
|
||||
/>
|
||||
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>{translate('populationStatistics.months')}</label>
|
||||
<Form.Input type="number" onChange={this.handleMonthsChange} value={months} />
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
)
|
||||
}
|
||||
|
||||
renderStudyGroupSelector = () => {
|
||||
const { studyProgrammes, translate } = this.props
|
||||
const { studyRights } = this.state.query
|
||||
|
||||
return (
|
||||
<Form.Group id="rightGroup">
|
||||
<Form.Field width={8}>
|
||||
<label htmlFor="rightGroup">{translate('populationStatistics.studyRights')}</label>
|
||||
<Dropdown
|
||||
placeholder={translate('populationStatistics.selectStudyRights')}
|
||||
multiple
|
||||
search
|
||||
selection
|
||||
noResultsMessage={translate('populationStatistics.noSelectableStudyRights')}
|
||||
value={studyRights}
|
||||
options={studyProgrammes}
|
||||
onChange={this.handleStudyRightChange}
|
||||
closeOnChange
|
||||
<label>Statistics until</label>
|
||||
<Datetime
|
||||
dateFormat="MMMM YYYY"
|
||||
defaultValue={moment()}
|
||||
onChange={value => this.handleMonthsChange(value)}
|
||||
isValidDate={current => current.isBefore(moment()) &&
|
||||
current.isAfter(this.getMinSelection(year, semesters[1] || semesters[0]))}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
)
|
||||
}
|
||||
renderStudyProgrammeDropdown = (studyRights, programmesToRender) => (
|
||||
<Form.Field
|
||||
width={6}
|
||||
style={{
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<label>Study programme</label>
|
||||
|
||||
<Icon
|
||||
link
|
||||
name="close"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 35,
|
||||
bottom: 0,
|
||||
right: '2.5em',
|
||||
lineHeight: 1,
|
||||
zIndex: 1
|
||||
}}
|
||||
onClick={() => this.handleClear('programme')}
|
||||
/>
|
||||
<Dropdown
|
||||
placeholder="Select study programme"
|
||||
search={textAndDescriptionSearch}
|
||||
selection
|
||||
noResultsMessage="No selectable study programmes"
|
||||
value={studyRights.programme}
|
||||
options={programmesToRender}
|
||||
onChange={this.handleProgrammeChange}
|
||||
closeOnChange
|
||||
/>
|
||||
</Form.Field>
|
||||
)
|
||||
renderAdditionalDegreeOrStudyTrackDropdown = (studyRights, studyTracksToRender, degreesToRender) => { //eslint-disable-line
|
||||
const renderableDegrees = (
|
||||
<Form.Field
|
||||
style={{
|
||||
position: 'relative',
|
||||
minWidth: '22em',
|
||||
width: 'auto'
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
link
|
||||
name="close"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 35,
|
||||
bottom: 0,
|
||||
right: '2.5em',
|
||||
lineHeight: 1,
|
||||
zIndex: 1
|
||||
}}
|
||||
onClick={() => this.handleClear('degree')}
|
||||
/>
|
||||
<label>Degree (Optional)</label>
|
||||
<Dropdown
|
||||
placeholder="Select degree"
|
||||
search={textAndDescriptionSearch}
|
||||
floating
|
||||
selection
|
||||
noResultsMessage="No selectable degrees"
|
||||
value={studyRights.degree}
|
||||
options={degreesToRender}
|
||||
onChange={this.handleDegreeChange}
|
||||
closeOnChange
|
||||
/>
|
||||
</Form.Field>)
|
||||
const renderableTracks = (
|
||||
<Form.Field
|
||||
style={{
|
||||
position: 'relative',
|
||||
minWidth: '22em',
|
||||
width: 'auto'
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
link
|
||||
name="close"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 35,
|
||||
bottom: 0,
|
||||
right: '2.5em',
|
||||
lineHeight: 1,
|
||||
zIndex: 1
|
||||
}}
|
||||
onClick={() => this.handleClear('studyTrack')}
|
||||
/>
|
||||
<label>Study Track (Optional)</label>
|
||||
<Dropdown
|
||||
placeholder="Select study track"
|
||||
search={textAndDescriptionSearch}
|
||||
floating
|
||||
selection
|
||||
noResultsMessage="No selectable study track"
|
||||
value={studyRights.studyTrack}
|
||||
options={studyTracksToRender}
|
||||
onChange={this.handleStudyTrackChange}
|
||||
closeOnChange
|
||||
/>
|
||||
</Form.Field>)
|
||||
if (studyRights.programme && degreesToRender.length > 1 && studyTracksToRender.length > 1) {
|
||||
return (
|
||||
<Form.Group>
|
||||
{renderableDegrees}
|
||||
{renderableTracks}
|
||||
</Form.Group>
|
||||
)
|
||||
} else if (studyRights.programme && degreesToRender.length > 1) {
|
||||
return (
|
||||
<div>
|
||||
{renderableDegrees}
|
||||
</div>
|
||||
)
|
||||
} else if (studyRights.programme && studyTracksToRender.length > 1) {
|
||||
return (
|
||||
<div>
|
||||
{renderableTracks}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
renderStudyGroupSelector = () => {
|
||||
const { studyProgrammes, language } = this.props
|
||||
const { studyRights } = this.state.query
|
||||
if (this.props.pending) {
|
||||
return (
|
||||
<Icon name="spinner" loading size="big" color="black" style={{ marginLeft: '45%' }} />
|
||||
)
|
||||
}
|
||||
if (!studyProgrammes && !this.props.pending) {
|
||||
return <Message error color="red" header="You have no rights to access any data. If you should have access please contact grp-toska@helsinki.fi" />
|
||||
}
|
||||
|
||||
let sortedStudyProgrammes = studyProgrammes
|
||||
let programmesToRender
|
||||
if (studyProgrammes) {
|
||||
sortedStudyProgrammes = _.sortBy(sortedStudyProgrammes, s => s.name[language])
|
||||
programmesToRender = this.renderableList(sortedStudyProgrammes)
|
||||
}
|
||||
let degreesToRender
|
||||
if (studyRights.programme) {
|
||||
const sortedStudyDegrees = _.sortBy(studyProgrammes[studyRights.programme].associations['10'], s => s.name[language])
|
||||
degreesToRender = this.renderableList(sortedStudyDegrees)
|
||||
}
|
||||
|
||||
let studyTracksToRender
|
||||
if (studyRights.programme) {
|
||||
const sortedStudyTracks = _.sortBy(studyProgrammes[studyRights.programme].associations['30'], s => s.name[language])
|
||||
studyTracksToRender = this.renderableList(sortedStudyTracks)
|
||||
}
|
||||
return (
|
||||
<Form.Group horizontal="true" widths={2} >
|
||||
|
||||
<Form.Field
|
||||
width={1}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block'
|
||||
}}
|
||||
>
|
||||
<label>Language</label>
|
||||
<LanguageChooser />
|
||||
</Form.Field>
|
||||
{this.renderStudyProgrammeDropdown(studyRights, programmesToRender)}
|
||||
{this.renderAdditionalDegreeOrStudyTrackDropdown(
|
||||
studyRights,
|
||||
studyTracksToRender,
|
||||
degreesToRender
|
||||
)}
|
||||
</Form.Group>
|
||||
)
|
||||
}
|
||||
|
||||
shouldRenderSearchForm = () => {
|
||||
const queryIsEmpty = Object.getOwnPropertyNames(this.props.queries).length > 0
|
||||
return !queryIsEmpty
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.queries.length > 0) {
|
||||
if (!this.shouldRenderSearchForm()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { translate } = this.props
|
||||
const { isLoading, validYear, query } = this.state
|
||||
|
||||
let errorText = translate('populationStatistics.alreadyFetched')
|
||||
let isQueryInvalid = this.validateQuery()
|
||||
|
||||
@@ -240,7 +500,6 @@ class PopulationSearchForm extends Component {
|
||||
isQueryInvalid = true
|
||||
errorText = translate('populationStatistics.selectStudyRights')
|
||||
}
|
||||
|
||||
return (
|
||||
<Form error={isQueryInvalid} loading={isLoading}>
|
||||
{this.renderEnrollmentDateSelector()}
|
||||
@@ -256,18 +515,26 @@ class PopulationSearchForm extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
const mapRightsToDropdown = makeMapRightsToDropDown()
|
||||
|
||||
const mapStateToProps = ({ populations, units, locale }) => ({
|
||||
queries: populations.map(population => population.query),
|
||||
translate: getTranslate(locale),
|
||||
studyProgrammes: mapRightsToDropdown(units)
|
||||
})
|
||||
const mapStateToProps = ({ settings, populations, populationDegreesAndProgrammes, locale }) => {
|
||||
const studyRights = populationDegreesAndProgrammes.data || {}
|
||||
const { pending } = populationDegreesAndProgrammes
|
||||
return ({
|
||||
language: settings.language,
|
||||
queries: populations.query || {},
|
||||
translate: getTranslate(locale),
|
||||
studyProgrammes: studyRights['20'],
|
||||
pending,
|
||||
extents: populations.data.extents || []
|
||||
})
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
getPopulationStatistics: request => dispatch(getPopulationStatistics(request)),
|
||||
getPopulationCourses: request => dispatch(getPopulationCourses(request)),
|
||||
getUnits: () => dispatch(getUnits()),
|
||||
getPopulationFilters: request => dispatch(getPopulationFilters(request)),
|
||||
setPopulationFilter: filter => dispatch(setPopulationFilter(filter)),
|
||||
getDegreesAndProgrammes: () => dispatch(getDegreesAndProgrammes()),
|
||||
clearPopulations: () => dispatch(clearPopulations()),
|
||||
setLoading: () => dispatch(setLoading())
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, shape, arrayOf, object, bool } from 'prop-types'
|
||||
import { func, shape, object, bool } from 'prop-types'
|
||||
import { getTranslate } from 'react-localize-redux'
|
||||
import _ from 'lodash'
|
||||
|
||||
import PopulationQueryCard from '../PopulationQueryCard'
|
||||
import { removePopulation } from '../../redux/populations'
|
||||
import { removePopulation, updatePopulationStudents } from '../../redux/populations'
|
||||
import { clearPopulationCourses } from '../../redux/populationCourses'
|
||||
import { clearPopulationLimit } from '../../redux/populationLimit'
|
||||
import { clearPopulationFilters } from '../../redux/populationFilters'
|
||||
|
||||
import styles from './populationSearchHistory.css'
|
||||
@@ -15,31 +15,37 @@ class PopulationSearchHistory extends Component {
|
||||
static propTypes = {
|
||||
translate: func.isRequired,
|
||||
removePopulation: func.isRequired,
|
||||
populations: arrayOf(shape({
|
||||
populations: shape({
|
||||
pending: bool,
|
||||
error: bool,
|
||||
data: arrayOf(object),
|
||||
data: shape({}),
|
||||
query: object
|
||||
})).isRequired,
|
||||
units: object // eslint-disable-line
|
||||
}).isRequired,
|
||||
units: object, // eslint-disable-line
|
||||
updatePopulationStudents: func.isRequired
|
||||
}
|
||||
|
||||
removePopulation = uuid => this.props.removePopulation(uuid)
|
||||
|
||||
renderQueryCards = () => {
|
||||
const { populations, translate, units } = this.props
|
||||
return populations.map((population, i) => (
|
||||
population.query ?
|
||||
<PopulationQueryCard
|
||||
key={`population-${population.query.uuid}`}
|
||||
translate={translate}
|
||||
population={population.data}
|
||||
query={population.query}
|
||||
queryId={i}
|
||||
unit={units.data.find(u => u.id === population.query.studyRights[0])}
|
||||
removeSampleFn={this.removePopulation}
|
||||
/> : null
|
||||
))
|
||||
if (!populations.data.students) {
|
||||
return null
|
||||
}
|
||||
const studentNumberList = (populations.data.students.map(s => s.studentNumber))
|
||||
return populations.query ? (<PopulationQueryCard
|
||||
key={`population-${populations.query.uuid}`}
|
||||
translate={translate}
|
||||
population={populations.data}
|
||||
query={populations.query}
|
||||
queryId={0}
|
||||
unit={units.data[20][populations.query.studyRights[0]]} // Possibly deprecated
|
||||
units={_.flattenDeep(Object.values(units.data).map(u =>
|
||||
Object.values(u))).filter(u => populations.query.studyRights.includes(u.code))}
|
||||
removeSampleFn={this.removePopulation}
|
||||
updateStudentsFn={() => this.props.updatePopulationStudents(studentNumberList)}
|
||||
updating={populations.updating}
|
||||
/>) : null
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -51,9 +57,9 @@ class PopulationSearchHistory extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations, units, locale }) => ({
|
||||
const mapStateToProps = ({ populations, populationDegreesAndProgrammes, locale }) => ({
|
||||
populations,
|
||||
units,
|
||||
units: populationDegreesAndProgrammes,
|
||||
translate: getTranslate(locale)
|
||||
})
|
||||
|
||||
@@ -61,9 +67,10 @@ const mapDispatchToProps = dispatch => ({
|
||||
removePopulation: (uuid) => {
|
||||
dispatch(removePopulation(uuid))
|
||||
dispatch(clearPopulationCourses())
|
||||
dispatch(clearPopulationLimit())
|
||||
dispatch(clearPopulationFilters())
|
||||
}
|
||||
},
|
||||
updatePopulationStudents: students => dispatch(updatePopulationStudents(students))
|
||||
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(PopulationSearchHistory)
|
||||
|
||||
@@ -54,7 +54,7 @@ const mapStateToProps = ({ locale, populations }) => ({
|
||||
translate: getTranslate(locale),
|
||||
currentLanguage: getActiveLanguage(locale).value,
|
||||
populationFound: populations.length > 0,
|
||||
loading: populations.length > 0 && populations[0].pending
|
||||
loading: populations.pending
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(PopulationStatistics)
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { string, arrayOf, object, func, bool, shape } from 'prop-types'
|
||||
import { Header, Segment, Table, Button } from 'semantic-ui-react'
|
||||
import { Header, Segment, Table, Button, Icon, Popup } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { getStudentTotalCredits, copyToClipboard } from '../../common'
|
||||
|
||||
import { toggleStudentListVisibility } from '../../redux/settings'
|
||||
|
||||
import StudentNameVisibilityToggle from '../StudentNameVisibilityToggle'
|
||||
|
||||
const popupTimeoutLength = 1000
|
||||
|
||||
|
||||
class PopulationStudents extends Component {
|
||||
state = {}
|
||||
|
||||
handlePopupOpen = (id) => {
|
||||
this.setState({ [id]: true })
|
||||
|
||||
this.timeout = setTimeout(() => {
|
||||
this.setState({ [id]: false })
|
||||
}, popupTimeoutLength)
|
||||
}
|
||||
|
||||
handlePopupClose = (id) => {
|
||||
this.setState({ [id]: false })
|
||||
clearTimeout(this.timeout)
|
||||
}
|
||||
|
||||
renderStudentTable() {
|
||||
if (!this.props.showList) {
|
||||
return null
|
||||
}
|
||||
|
||||
const students = this.props.samples[0].reduce((obj, s) => {
|
||||
const students = this.props.samples.reduce((obj, s) => {
|
||||
obj[s.studentNumber] = s
|
||||
return obj
|
||||
}, {})
|
||||
@@ -23,12 +41,17 @@ class PopulationStudents extends Component {
|
||||
const byName = (s1, s2) =>
|
||||
(students[s1].lastname < students[s2].lastname ? -1 : 1)
|
||||
|
||||
const creditsSinceStart = studentNumber => students[studentNumber].courses
|
||||
.filter(c => c.passed)
|
||||
.reduce((s, c) => s + c.credits, 0)
|
||||
const creditsSinceStart = studentNumber => getStudentTotalCredits(students[studentNumber])
|
||||
|
||||
const pushToHistoryFn = studentNumber => this.props.history.push(`/students/${studentNumber}`)
|
||||
|
||||
const copyToClipboardAll = () => {
|
||||
const studentsInfo = this.props.selectedStudents.map(number => students[number])
|
||||
const emails = studentsInfo.filter(s => s.email).map(s => s.email)
|
||||
const clipboardString = emails.join('; ')
|
||||
copyToClipboard(clipboardString)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StudentNameVisibilityToggle />
|
||||
@@ -54,30 +77,72 @@ class PopulationStudents extends Component {
|
||||
<Table.HeaderCell>
|
||||
all credits
|
||||
</Table.HeaderCell>
|
||||
{this.props.showNames ? (
|
||||
<Table.HeaderCell>
|
||||
email
|
||||
<Popup
|
||||
trigger={
|
||||
<Icon
|
||||
link
|
||||
name="copy"
|
||||
onClick={copyToClipboardAll}
|
||||
style={{ float: 'right' }}
|
||||
/>}
|
||||
content="Copied email list!"
|
||||
on="click"
|
||||
open={this.state['0']}
|
||||
onClose={() => this.handlePopupClose('0')}
|
||||
onOpen={() => this.handlePopupOpen('0')}
|
||||
position="top right"
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
) : null}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{this.props.selectedStudents.sort(byName).map(studentNumber => (
|
||||
<Table.Row key={studentNumber} onClick={() => pushToHistoryFn(studentNumber)}>
|
||||
<Table.Cell>
|
||||
<Table.Row key={studentNumber} >
|
||||
<Table.Cell onClick={() => pushToHistoryFn(studentNumber)}>
|
||||
{studentNumber}
|
||||
</Table.Cell>
|
||||
{ this.props.showNames ? (
|
||||
<Table.Cell>
|
||||
{this.props.showNames ? (
|
||||
<Table.Cell onClick={() => pushToHistoryFn(studentNumber)}>
|
||||
{students[studentNumber].lastname}
|
||||
</Table.Cell>
|
||||
) : null }
|
||||
) : null}
|
||||
{this.props.showNames ? (
|
||||
<Table.Cell>
|
||||
<Table.Cell onClick={() => pushToHistoryFn(studentNumber)}>
|
||||
{students[studentNumber].firstnames}
|
||||
</Table.Cell>
|
||||
) : null}
|
||||
<Table.Cell>
|
||||
<Table.Cell onClick={() => pushToHistoryFn(studentNumber)}>
|
||||
{creditsSinceStart(studentNumber)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{students[studentNumber].credits}
|
||||
</Table.Cell>
|
||||
{this.props.showNames ? (
|
||||
<Table.Cell>
|
||||
{students[studentNumber].email}
|
||||
{students[studentNumber].email
|
||||
? <Popup
|
||||
trigger={
|
||||
<Icon
|
||||
link
|
||||
name="copy outline"
|
||||
onClick={() => copyToClipboard(students[studentNumber].email)}
|
||||
style={{ float: 'right' }}
|
||||
/>}
|
||||
content="Email copied!"
|
||||
on="click"
|
||||
open={this.state[studentNumber]}
|
||||
onClose={() => this.handlePopupClose(studentNumber)}
|
||||
onOpen={() => this.handlePopupOpen(studentNumber)}
|
||||
position="top right"
|
||||
/>
|
||||
: null}
|
||||
</Table.Cell>
|
||||
) : null}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
@@ -107,7 +172,7 @@ class PopulationStudents extends Component {
|
||||
}
|
||||
|
||||
PopulationStudents.propTypes = {
|
||||
samples: arrayOf(arrayOf(object)).isRequired,
|
||||
samples: arrayOf(object).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
toggleStudentListVisibility: func.isRequired,
|
||||
showNames: bool.isRequired,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { Table, Icon } from 'semantic-ui-react'
|
||||
import { arrayOf, string, object, func, bool, oneOfType, array } from 'prop-types'
|
||||
|
||||
import styles from './searchResultsTable.css'
|
||||
@@ -8,12 +8,12 @@ const getHeaderRow = headers => (
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{
|
||||
headers.map(header => (
|
||||
<Table.HeaderCell key={`header-${header}`}>
|
||||
{header}
|
||||
</Table.HeaderCell>
|
||||
))
|
||||
}
|
||||
headers.map(header => (
|
||||
<Table.HeaderCell key={`header-${header}`}>
|
||||
{header}
|
||||
</Table.HeaderCell>
|
||||
))
|
||||
}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
)
|
||||
@@ -21,20 +21,35 @@ const getHeaderRow = headers => (
|
||||
const getTableBody = (rows, rowClickFn, selectable) => (
|
||||
<Table.Body>
|
||||
{
|
||||
rows.map((row, i) => (
|
||||
<Table.Row
|
||||
className={(selectable ? styles.selectableRow : '')}
|
||||
key={`row-${i}`} // eslint-disable-line react/no-array-index-key
|
||||
onClick={e => rowClickFn(e, row)}
|
||||
>
|
||||
{
|
||||
Object.values(row).map((value, index) => (
|
||||
<Table.Cell key={`cell-${index}`}>{value}</Table.Cell> // eslint-disable-line react/no-array-index-key
|
||||
))
|
||||
rows.map((row, i) => (
|
||||
<Table.Row
|
||||
className={(selectable ? styles.selectableRow : '')}
|
||||
key={`row-${i}`} // eslint-disable-line react/no-array-index-key
|
||||
onClick={e => rowClickFn(e, row)}
|
||||
>
|
||||
{
|
||||
Object.values(row).map((value, index) => {
|
||||
if (index === 2 && row.date) {
|
||||
return (
|
||||
<Table.Cell
|
||||
key={`cell-${index}`} // eslint-disable-line react/no-array-index-key
|
||||
>
|
||||
{row.passed || value.includes('Yes') ?
|
||||
<Icon name="check circle outline" color="green" />
|
||||
:
|
||||
<Icon name="remove circle outline" color="red" />
|
||||
}
|
||||
{value}
|
||||
</Table.Cell>
|
||||
)
|
||||
}
|
||||
</Table.Row>
|
||||
))
|
||||
}
|
||||
return <Table.Cell key={`cell-${index}`}>{value}</Table.Cell> // eslint-disable-line react/no-array-index-key
|
||||
})
|
||||
|
||||
}
|
||||
</Table.Row>
|
||||
))
|
||||
}
|
||||
</Table.Body>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Header, Button, Message } from 'semantic-ui-react'
|
||||
import CourseCodeMapper from '../CourseCodeMapper'
|
||||
|
||||
import sharedStyles from '../../styles/shared'
|
||||
|
||||
class Settings extends Component {
|
||||
state = {
|
||||
selected: 1
|
||||
}
|
||||
|
||||
getSetting = () => {
|
||||
if (this.state.selected === 0) {
|
||||
return <Header>None</Header>
|
||||
} else if (this.state.selected === 1) {
|
||||
return <CourseCodeMapper />
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
select = (which) => {
|
||||
this.setState({ selected: which })
|
||||
}
|
||||
|
||||
render() {
|
||||
const selectedComponent = this.getSetting()
|
||||
return (
|
||||
<div className={sharedStyles.segmentContainer} >
|
||||
<Message content="Visible only for admins for now" />
|
||||
<Header className={sharedStyles.segmentTitle} size="large">Settings</Header>
|
||||
<Button.Group >
|
||||
<Button onClick={() => this.select(1)}>Course code Mapping</Button>
|
||||
</Button.Group>
|
||||
{selectedComponent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Settings
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { Component } from 'react'
|
||||
import Highcharts from 'highcharts'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { shape, arrayOf, object } from 'prop-types'
|
||||
|
||||
class StackedBarChart extends Component {
|
||||
state = {
|
||||
graphOptions: {}
|
||||
}
|
||||
componentDidMount() {
|
||||
const { options, series } = this.props
|
||||
this.setState({ graphOptions: { ...options, series } })
|
||||
}
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.series !== prevProps.series) {
|
||||
const { options, series } = this.props
|
||||
this.setState({ graphOptions: { ...options, series } }) //eslint-disable-line
|
||||
}
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<ReactHighcharts
|
||||
highcharts={Highcharts}
|
||||
config={this.state.graphOptions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
StackedBarChart.propTypes = {
|
||||
options: shape({}).isRequired,
|
||||
series: arrayOf(object).isRequired
|
||||
}
|
||||
|
||||
|
||||
export default StackedBarChart
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { Component } from 'react'
|
||||
import { func, shape, string, boolean, arrayOf, integer } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment } from 'semantic-ui-react'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { Segment, Table, Icon } from 'semantic-ui-react'
|
||||
import { isEmpty, sortBy } from 'lodash'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { getStudent, removeStudentSelection, resetStudent } from '../../redux/students'
|
||||
import StudentInfoCard from '../StudentInfoCard'
|
||||
import CreditAccumulationGraph from '../CreditAccumulationGraph'
|
||||
import SearchResultTable from '../SearchResultTable'
|
||||
@@ -12,6 +13,20 @@ import { removeInvalidCreditsFromStudent, byDateDesc, reformatDate } from '../..
|
||||
import sharedStyles from '../../styles/shared'
|
||||
|
||||
class StudentDetails extends Component {
|
||||
componentDidMount() {
|
||||
this.props.history.listen((location, action) => {
|
||||
if (action === 'POP' || location.pathname !== '/students') {
|
||||
this.props.resetStudent()
|
||||
this.props.removeStudentSelection()
|
||||
}
|
||||
})
|
||||
}
|
||||
componentDidUpdate() {
|
||||
if (isEmpty(this.props.student) && this.props.studentNumber) {
|
||||
this.props.getStudent(this.props.studentNumber)
|
||||
}
|
||||
}
|
||||
|
||||
renderCreditsGraph = () => {
|
||||
const { translate, student } = this.props
|
||||
|
||||
@@ -28,7 +43,7 @@ class StudentDetails extends Component {
|
||||
}
|
||||
|
||||
renderCourseParticipation = () => {
|
||||
const { translate, student } = this.props
|
||||
const { translate, student, language } = this.props
|
||||
|
||||
const courseHeaders = [
|
||||
translate('common.date'),
|
||||
@@ -38,10 +53,10 @@ class StudentDetails extends Component {
|
||||
]
|
||||
const courseRows = student.courses.map((c) => {
|
||||
const {
|
||||
date, grade, credits, course
|
||||
date, grade, credits, course, isStudyModuleCredit, passed
|
||||
} = c
|
||||
return {
|
||||
date, course: `${course.name} (${course.code})`, grade, credits
|
||||
date, course: `${isStudyModuleCredit ? `${course.name[language]} [Study Module]` : course.name[language]} (${course.code})`, grade, credits, passed
|
||||
}
|
||||
})
|
||||
return (
|
||||
@@ -56,8 +71,81 @@ class StudentDetails extends Component {
|
||||
)
|
||||
}
|
||||
|
||||
renderStudyRights = () => {
|
||||
const { student, language } = this.props
|
||||
const studyRightHeaders = ['Degree', 'Programme', 'Graduated']
|
||||
const studyRightRows = student.studyrights.map((studyright) => {
|
||||
const degrees = sortBy(studyright.studyrightElements, 'enddate').filter(e => e.element_detail.type === 10)
|
||||
.map(degree => ({
|
||||
startdate: degree.startdate,
|
||||
enddate: degree.enddate,
|
||||
name: degree.element_detail.name[language],
|
||||
graduateionDate: degree.graduationDate,
|
||||
canceldate: degree.canceldate
|
||||
}))
|
||||
const programmes = sortBy(studyright.studyrightElements, 'enddate').filter(e => e.element_detail.type === 20)
|
||||
.map(programme => ({
|
||||
startdate: programme.startdate,
|
||||
enddate: programme.enddate,
|
||||
name: programme.element_detail.name[language]
|
||||
}))
|
||||
return {
|
||||
studyrightid: studyright.studyrightid,
|
||||
graduated: studyright.graduated,
|
||||
canceldate: studyright.canceldate,
|
||||
enddate: studyright.enddate,
|
||||
elements: { degrees, programmes }
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{studyRightHeaders.map((header, index) => (
|
||||
<Table.HeaderCell key={index}/* eslint-disable-line */>
|
||||
{header}
|
||||
</Table.HeaderCell>
|
||||
))
|
||||
}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{studyRightRows.map(c => (
|
||||
<Table.Row key={c.studyrightid}>
|
||||
<Table.Cell verticalAlign="middle">
|
||||
{c.elements.degrees.map(degree => (
|
||||
<p key={degree.name}>{`${degree.name} (${reformatDate(degree.startdate, 'DD.MM.YYYY')} - ${reformatDate(degree.enddate, 'DD.MM.YYYY')})`} <br /> </p>
|
||||
))}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{c.elements.programmes.map(programme => (
|
||||
<p key={programme.name}>{`${programme.name} (${reformatDate(programme.startdate, 'DD.MM.YYYY')} - ${reformatDate(programme.enddate, 'DD.MM.YYYY')})`}<br /> </p>
|
||||
))}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{c.canceldate ? // eslint-disable-line
|
||||
<div><p style={{ color: 'red', fontWeight: 'bold' }}>CANCELED</p></div>
|
||||
:
|
||||
c.graduated ?
|
||||
<div><Icon name="check circle outline" color="green" /><p>{reformatDate(c.enddate, 'DD.MM.YYYY')}</p></div>
|
||||
:
|
||||
<div><Icon name="remove circle outline" color="red" /><p>{reformatDate(c.enddate, 'DD.MM.YYYY')}</p></div>
|
||||
}
|
||||
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { translate, student } = this.props
|
||||
const { translate, student, studentNumber } = this.props
|
||||
if (!studentNumber) {
|
||||
return null
|
||||
}
|
||||
if (isEmpty(student)) {
|
||||
return null
|
||||
}
|
||||
@@ -68,6 +156,7 @@ class StudentDetails extends Component {
|
||||
translate={translate}
|
||||
/>
|
||||
{this.renderCreditsGraph()}
|
||||
{this.renderStudyRights()}
|
||||
{this.renderCourseParticipation()}
|
||||
</Segment>
|
||||
)
|
||||
@@ -75,12 +164,18 @@ class StudentDetails extends Component {
|
||||
}
|
||||
|
||||
StudentDetails.propTypes = {
|
||||
language: string.isRequired,
|
||||
getStudent: func.isRequired,
|
||||
history: shape({}).isRequired,
|
||||
resetStudent: func.isRequired,
|
||||
removeStudentSelection: func.isRequired,
|
||||
studentNumber: string,
|
||||
translate: func.isRequired,
|
||||
student: shape({
|
||||
courses: arrayOf(shape({
|
||||
course: shape({
|
||||
code: string,
|
||||
name: string
|
||||
name: Object
|
||||
}),
|
||||
credits: integer,
|
||||
date: string,
|
||||
@@ -96,12 +191,20 @@ StudentDetails.propTypes = {
|
||||
}
|
||||
|
||||
StudentDetails.defaultProps = {
|
||||
student: {}
|
||||
student: {},
|
||||
studentNumber: ''
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ students }) => ({
|
||||
const mapStateToProps = ({ students, settings }) => ({
|
||||
language: settings.language,
|
||||
student: students.data.find(student =>
|
||||
student.studentNumber === students.selected)
|
||||
})
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
removeStudentSelection: () => dispatch(removeStudentSelection()),
|
||||
resetStudent: () => dispatch(resetStudent()),
|
||||
getStudent: studentNumber =>
|
||||
dispatch(getStudent(studentNumber))
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(StudentDetails)
|
||||
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(StudentDetails))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import { func, bool } from 'prop-types'
|
||||
import { Card, Icon } from 'semantic-ui-react'
|
||||
import { func, bool, shape } from 'prop-types'
|
||||
import { Card, Icon, Button } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
|
||||
import { reformatDate } from '../../common'
|
||||
import { studentDetailsType } from '../../constants/types'
|
||||
@@ -10,16 +11,20 @@ import { DISPLAY_DATE_FORMAT } from '../../constants'
|
||||
import styles from './studentInfoCard.css'
|
||||
|
||||
import { removeStudentSelection, resetStudent } from '../../redux/students'
|
||||
import { updatePopulationStudents } from '../../redux/populations'
|
||||
|
||||
|
||||
const StudentInfoCard = (props) => {
|
||||
const { student, translate, showName } = props
|
||||
const { student, translate, showName, updating } = props
|
||||
const name = showName ? `${student.name}, ` : ''
|
||||
|
||||
const email = showName && student.email ? `${student.email}` : ''
|
||||
const onRemove = () => {
|
||||
props.history.push('/students')
|
||||
props.resetStudent()
|
||||
props.removeStudentSelection()
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Card fluid>
|
||||
<Card.Content>
|
||||
@@ -30,15 +35,33 @@ const StudentInfoCard = (props) => {
|
||||
className={styles.controlIcon}
|
||||
onClick={onRemove}
|
||||
/>
|
||||
|
||||
</Card.Header>
|
||||
<Card.Meta>
|
||||
<div className={styles.startDate}>
|
||||
{`${translate('common.started')}: ${reformatDate(student.started, DISPLAY_DATE_FORMAT)}`}
|
||||
</div>
|
||||
<div className={styles.startDate}>
|
||||
{email}
|
||||
</div>
|
||||
</Card.Meta>
|
||||
<Card.Description>
|
||||
{`${translate('common.credits')}: ${student.credits || 0}`}
|
||||
<p style={{ fontSize: 14 }}>{`Updated at ${reformatDate(student.updatedAt, DISPLAY_DATE_FORMAT)}`}</p>
|
||||
</Card.Description>
|
||||
<div style={{ paddingTop: '4px' }}>
|
||||
{updating ?
|
||||
<Button disabled compact size="medium" labelPosition="left" onClick={() => props.updatePopulationStudents([student.studentNumber])} >
|
||||
<Icon loading name="refresh" />
|
||||
update student
|
||||
</Button>
|
||||
:
|
||||
<Button compact floated="left" size="medium" labelPosition="left" onClick={() => props.updatePopulationStudents([student.studentNumber])} >
|
||||
<Icon name="refresh" />
|
||||
update student
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
@@ -49,12 +72,18 @@ StudentInfoCard.propTypes = {
|
||||
translate: func.isRequired,
|
||||
showName: bool.isRequired,
|
||||
removeStudentSelection: func.isRequired,
|
||||
resetStudent: func.isRequired
|
||||
resetStudent: func.isRequired,
|
||||
history: shape({}).isRequired,
|
||||
updating: bool.isRequired,
|
||||
updatePopulationStudents: func.isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
showName: state.settings.namesVisible
|
||||
showName: state.settings.namesVisible,
|
||||
updating: state.populations.updating
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, { removeStudentSelection, resetStudent })(StudentInfoCard)
|
||||
export default withRouter(connect(mapStateToProps, {
|
||||
removeStudentSelection, resetStudent, updatePopulationStudents
|
||||
})(StudentInfoCard))
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { Component } from 'react'
|
||||
import { func, string, arrayOf, object, bool } from 'prop-types'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { func, string, arrayOf, object, bool, shape } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { Search, Segment } from 'semantic-ui-react'
|
||||
|
||||
@@ -25,6 +26,7 @@ class StudentSearch extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
const { studentNumber } = this.props
|
||||
|
||||
if (studentNumber && containsOnlyNumbers(studentNumber)) {
|
||||
this.setState({ isLoading: true })
|
||||
this.props.getStudent(studentNumber).then(() => this.resetComponent())
|
||||
@@ -49,6 +51,7 @@ class StudentSearch extends Component {
|
||||
|
||||
handleSearchSelect = (e, student) => {
|
||||
const { studentNumber } = student
|
||||
this.props.history.push(`/students/${studentNumber}`, { selected: studentNumber })
|
||||
const studentObject = this.props.students.find(person =>
|
||||
person.studentNumber === studentNumber)
|
||||
const fetched = studentObject ? studentObject.fetched : false
|
||||
@@ -62,6 +65,9 @@ class StudentSearch extends Component {
|
||||
}
|
||||
|
||||
fetchStudentList = (searchStr) => {
|
||||
if (searchStr.length < 4 || (Number(searchStr) && searchStr.length < 6)) {
|
||||
return
|
||||
}
|
||||
this.props.setTimeout('fetch', () => {
|
||||
this.setState({ isLoading: true })
|
||||
}, 250)
|
||||
@@ -88,7 +94,8 @@ class StudentSearch extends Component {
|
||||
}
|
||||
|
||||
if (!showResults) {
|
||||
return null
|
||||
// so that the loading spinner doesn't go on top of the search box
|
||||
return <div style={{ margin: 100 }} />
|
||||
}
|
||||
const headers = [
|
||||
translate('common.studentNumber'),
|
||||
@@ -112,11 +119,12 @@ class StudentSearch extends Component {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const { translate, selected } = this.props
|
||||
const { translate, studentNumber } = this.props
|
||||
|
||||
|
||||
if (selected !== null) {
|
||||
if (studentNumber) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -151,11 +159,10 @@ StudentSearch.propTypes = {
|
||||
setTimeout: func.isRequired,
|
||||
clearTimeout: func.isRequired,
|
||||
showNames: bool.isRequired,
|
||||
selected: string
|
||||
history: shape({}).isRequired
|
||||
}
|
||||
StudentSearch.defaultProps = {
|
||||
studentNumber: undefined,
|
||||
selected: null
|
||||
studentNumber: undefined
|
||||
}
|
||||
|
||||
const formatStudentRows = makeFormatStudentRows()
|
||||
@@ -176,4 +183,4 @@ const mapDispatchToProps = dispatch => ({
|
||||
})
|
||||
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Timeout(StudentSearch))
|
||||
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Timeout(StudentSearch)))
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import React, { PureComponent } from 'react'
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, shape, string } from 'prop-types'
|
||||
import { getTranslate, getActiveLanguage } from 'react-localize-redux'
|
||||
import { Segment, Header } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
|
||||
import { findStudents, getStudent, selectStudent } from '../../redux/students'
|
||||
import StudentSearch from '../StudentSearch'
|
||||
import StudentDetails from '../StudentDetails'
|
||||
import StudentNameVisibilityToggle from '../StudentNameVisibilityToggle'
|
||||
import LanguageChooser from '../LanguageChooser'
|
||||
|
||||
import { toggleStudentNameVisibility } from '../../redux/settings'
|
||||
|
||||
import sharedStyles from '../../styles/shared'
|
||||
|
||||
class StudentStatistics extends PureComponent {
|
||||
class StudentStatistics extends Component { //eslint-disable-line
|
||||
render() {
|
||||
const { translate, match } = this.props
|
||||
const { studentNumber } = match.params
|
||||
@@ -21,10 +25,11 @@ class StudentStatistics extends PureComponent {
|
||||
<Header className={sharedStyles.segmentTitle} size="large">
|
||||
{translate('studentStatistics.header')}
|
||||
</Header>
|
||||
<LanguageChooser />
|
||||
<StudentNameVisibilityToggle />
|
||||
<Segment className={sharedStyles.contentSegment}>
|
||||
<StudentSearch translate={translate} studentNumber={studentNumber} />
|
||||
<StudentDetails translate={translate} />
|
||||
<StudentDetails translate={translate} studentNumber={studentNumber} />
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
@@ -46,9 +51,21 @@ StudentStatistics.defaultProps = {
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ locale }) => ({
|
||||
const mapStateToProps = ({ locale, students }) => ({
|
||||
translate: getTranslate(locale),
|
||||
currentLanguage: getActiveLanguage(locale).value
|
||||
currentLanguage: getActiveLanguage(locale).value,
|
||||
student: students.data.find(student =>
|
||||
student.studentNumber === students.selected)
|
||||
})
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
toggleStudentNameVisibility,
|
||||
findStudents: searchStr =>
|
||||
dispatch(findStudents(searchStr)),
|
||||
getStudent: studentNumber =>
|
||||
dispatch(getStudent(studentNumber)),
|
||||
selectStudent: studentNumber =>
|
||||
dispatch(selectStudent(studentNumber))
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, { toggleStudentNameVisibility })(StudentStatistics)
|
||||
|
||||
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(StudentStatistics))
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { PureComponent } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { getActiveLanguage, getTranslate } from 'react-localize-redux'
|
||||
import { func, bool } from 'prop-types'
|
||||
import { Header, Segment, Divider } from 'semantic-ui-react'
|
||||
|
||||
import PopulationSearchForm from '../PopulationSearchForm'
|
||||
import PopulationSearchHistory from '../PopulationSearchHistory'
|
||||
import SegmentDimmer from '../SegmentDimmer'
|
||||
|
||||
import sharedStyles from '../../styles/shared'
|
||||
|
||||
class StudyrightStatistics extends PureComponent {
|
||||
static propTypes = {
|
||||
translate: func.isRequired,
|
||||
loading: bool.isRequired
|
||||
}
|
||||
|
||||
renderPopulationSearch = () => {
|
||||
const { translate, loading } = this.props
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<PopulationSearchForm />
|
||||
<Divider />
|
||||
<PopulationSearchHistory />
|
||||
<SegmentDimmer translate={translate} isLoading={loading} />
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { translate } = this.props
|
||||
return (
|
||||
<div className={sharedStyles.segmentContainer}>
|
||||
<Header className={sharedStyles.segmentTitle} size="large">{translate('studyrightStatistics.header')}</Header>
|
||||
<Segment className={sharedStyles.contentSegment}>
|
||||
{ this.renderPopulationSearch() }
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ locale, populations }) => ({
|
||||
translate: getTranslate(locale),
|
||||
currentLanguage: getActiveLanguage(locale).value,
|
||||
populationFound: populations.length > 0,
|
||||
loading: populations.pending
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(StudyrightStatistics)
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Tab, Form } from 'semantic-ui-react'
|
||||
import { shape, string, arrayOf, func, number, oneOfType } from 'prop-types'
|
||||
import TeacherStatisticsTable from '../TeacherStatisticsTable'
|
||||
|
||||
const CourseStatsTab = ({ statistics, options, doSelect, selected }) => (
|
||||
<div>
|
||||
<Form>
|
||||
<Form.Dropdown
|
||||
options={options}
|
||||
placeholder="Select..."
|
||||
selection
|
||||
search
|
||||
value={selected}
|
||||
onChange={(_, { value }) => doSelect(value)}
|
||||
/>
|
||||
</Form>
|
||||
{selected && <TeacherStatisticsTable statistics={statistics} onClickFn={null} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
CourseStatsTab.propTypes = {
|
||||
options: arrayOf(shape({})).isRequired,
|
||||
statistics: arrayOf(shape({})).isRequired,
|
||||
doSelect: func.isRequired,
|
||||
selected: oneOfType([string, number])
|
||||
}
|
||||
|
||||
CourseStatsTab.defaultProps = {
|
||||
selected: null
|
||||
}
|
||||
|
||||
class CoursesTab extends Component {
|
||||
state={
|
||||
selectedSemester: null,
|
||||
selectedCourse: null,
|
||||
semesterOptions: [],
|
||||
courseOptions: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { courses } = this.props
|
||||
const semesterOptions = this.semesterOptions()
|
||||
const courseOptions = this.courseOptions()
|
||||
const courseWithMostCredits = Object.values(courses)
|
||||
.reduce((c1, c2) => (c1.stats.credits > c2.stats.credits ? c1 : c2))
|
||||
this.setState({
|
||||
semesterOptions,
|
||||
courseOptions,
|
||||
selectedSemester: semesterOptions[0].value,
|
||||
selectedCourse: courseWithMostCredits.id
|
||||
})
|
||||
}
|
||||
|
||||
setCourse = selectedCourse => this.setState({ selectedCourse })
|
||||
setSemester = selectedSemester => this.setState({ selectedSemester })
|
||||
|
||||
getCourseStats(courseid) {
|
||||
if (!courseid) {
|
||||
return []
|
||||
}
|
||||
const { courses, semesters } = this.props
|
||||
const course = courses[courseid]
|
||||
return Object.entries(course.semesters).map(([semesterid, stats]) => ({
|
||||
id: semesterid,
|
||||
name: semesters[semesterid].name.fi,
|
||||
...stats
|
||||
}))
|
||||
}
|
||||
|
||||
getSemesterStats(semesterid) {
|
||||
if (!semesterid) {
|
||||
return []
|
||||
}
|
||||
const { courses } = this.props
|
||||
return Object.values(courses)
|
||||
.filter(course => !!course.semesters[semesterid])
|
||||
.map(({ id, name, semesters }) => ({
|
||||
id,
|
||||
name: name.fi,
|
||||
...semesters[semesterid]
|
||||
}))
|
||||
}
|
||||
|
||||
semesterOptions() {
|
||||
const { semesters } = this.props
|
||||
return Object.values(semesters)
|
||||
.map(({ name, id }) => ({
|
||||
key: id,
|
||||
value: id,
|
||||
text: name.fi
|
||||
}))
|
||||
.sort((s1, s2) => s2.value - s1.value)
|
||||
}
|
||||
|
||||
courseOptions() {
|
||||
const courses = Object.values(this.props.courses)
|
||||
return courses.map(({ name, id }) => ({
|
||||
key: id,
|
||||
value: id,
|
||||
description: id,
|
||||
text: name.fi
|
||||
}))
|
||||
}
|
||||
|
||||
dropdownOptions = () => ({
|
||||
courses: this.courseOptions(),
|
||||
semesters: this.semesterOptions()
|
||||
})
|
||||
|
||||
render = () => {
|
||||
const { selectedCourse, selectedSemester, courseOptions, semesterOptions } = this.state
|
||||
return (
|
||||
<Tab
|
||||
menu={{ secondary: true, pointing: true }}
|
||||
panes={[
|
||||
{
|
||||
menuItem: 'Semester',
|
||||
render: () => (
|
||||
<CourseStatsTab
|
||||
options={semesterOptions}
|
||||
doSelect={this.setSemester}
|
||||
selected={selectedSemester}
|
||||
statistics={this.getSemesterStats(selectedSemester)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
menuItem: 'Course',
|
||||
render: () => (
|
||||
<CourseStatsTab
|
||||
options={courseOptions}
|
||||
statistics={this.getCourseStats(selectedCourse)}
|
||||
doSelect={this.setCourse}
|
||||
selected={selectedCourse}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CoursesTab.propTypes = {
|
||||
courses: shape({}).isRequired,
|
||||
semesters: shape({}).isRequired
|
||||
}
|
||||
|
||||
export default CoursesTab
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { Component } from 'react'
|
||||
import { shape } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
import { Card, Tab, Icon } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router'
|
||||
import TeacherStatisticsTable from '../TeacherStatisticsTable'
|
||||
import CoursesTab from './CoursesTab'
|
||||
import styles from '../PopulationQueryCard/populationQueryCard.css'
|
||||
|
||||
const statisticsTableTab = (title, statistics) => ({
|
||||
menuItem: title,
|
||||
render: () => <TeacherStatisticsTable statistics={statistics} onClickFn={null} />
|
||||
})
|
||||
|
||||
const formatStatisticsForTable = (statistics) => {
|
||||
if (!statistics) {
|
||||
return []
|
||||
}
|
||||
return Object.values(statistics).map(({ name, stats, ...rest }) => ({
|
||||
...rest,
|
||||
...stats,
|
||||
name: _.isString(name) ? name : name.fi
|
||||
}))
|
||||
}
|
||||
|
||||
class TeacherDetails extends Component {
|
||||
state={}
|
||||
|
||||
render() {
|
||||
const { teacher } = this.props
|
||||
const { courses, years, semesters } = teacher.statistics
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: 'Courses',
|
||||
render: () => <CoursesTab courses={courses} semesters={semesters} />
|
||||
},
|
||||
statisticsTableTab('Semesters', formatStatisticsForTable(semesters)),
|
||||
statisticsTableTab('Years', formatStatisticsForTable(years))
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card fluid className={styles.cardContainer}>
|
||||
<Card.Content>
|
||||
<Card.Header className={styles.cardHeader}>
|
||||
{teacher.name}
|
||||
<Icon
|
||||
name="remove"
|
||||
className={styles.controlIcon}
|
||||
onClick={() => this.props.history.goBack()}
|
||||
/>
|
||||
</Card.Header>
|
||||
<Card.Meta content={teacher.code} />
|
||||
<Card.Meta content={teacher.id} />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
<Tab panes={panes} style={{ paddingTop: '0.5rem' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TeacherDetails.propTypes = {
|
||||
teacher: shape({}).isRequired,
|
||||
history: shape({}).isRequired
|
||||
}
|
||||
|
||||
export default withRouter(TeacherDetails)
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Segment, Form } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, arrayOf, shape, string, any } from 'prop-types'
|
||||
import { getTopTeachers } from '../../redux/teachersTop'
|
||||
|
||||
class LeaderForm extends Component {
|
||||
state={
|
||||
selectedyear: null,
|
||||
selectedcategory: null
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { year, category } = this.defaultValues()
|
||||
if (year && category) {
|
||||
this.updateAndSubmitForm({
|
||||
selectedyear: year,
|
||||
selectedcategory: category
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
updateAndSubmitForm = (args) => {
|
||||
this.setState(args)
|
||||
const { selectedyear, selectedcategory } = { ...this.state, ...args }
|
||||
this.props.getTopTeachers(selectedyear, selectedcategory)
|
||||
}
|
||||
|
||||
defaultValues = () => {
|
||||
const { yearoptions, categoryoptions } = this.props
|
||||
const [defaultyear = {}] = yearoptions
|
||||
const [defaultcategory = {}] = categoryoptions
|
||||
return {
|
||||
year: defaultyear.value,
|
||||
category: defaultcategory.value
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = (e, { value, name }) => this.updateAndSubmitForm({ [name]: value })
|
||||
|
||||
render() {
|
||||
const { yearoptions, categoryoptions } = this.props
|
||||
return (
|
||||
<Segment>
|
||||
<Form>
|
||||
<Form.Group widths="equal">
|
||||
<Form.Dropdown
|
||||
name="selectedyear"
|
||||
label="Academic year"
|
||||
placeholder="Academic year"
|
||||
options={yearoptions}
|
||||
selection
|
||||
search
|
||||
value={this.state.selectedyear}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
<Form.Dropdown
|
||||
name="selectedcategory"
|
||||
label="Category"
|
||||
placeholder="Category"
|
||||
options={categoryoptions}
|
||||
selection
|
||||
search
|
||||
value={this.state.selectedcategory}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LeaderForm.propTypes = {
|
||||
yearoptions: arrayOf(shape({})).isRequired,
|
||||
categoryoptions: arrayOf(shape({ key: any, value: any, text: string })).isRequired,
|
||||
getTopTeachers: func.isRequired
|
||||
}
|
||||
|
||||
export default connect(null, { getTopTeachers })(LeaderForm)
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Segment, Message } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, arrayOf, bool, shape, any, string } from 'prop-types'
|
||||
import { getTopTeachersCategories } from '../../redux/teachersTopCategories'
|
||||
import TeacherStatisticsTable from '../TeacherStatisticsTable'
|
||||
import LeaderForm from './LeaderForm'
|
||||
|
||||
class TeacherLeaderBoard extends Component {
|
||||
state={}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.getTopTeachersCategories()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { statistics, isLoading, yearoptions, categoryoptions } = this.props
|
||||
return (
|
||||
<div>
|
||||
{ isLoading
|
||||
? <Segment basic loading={isLoading} />
|
||||
: (
|
||||
<div>
|
||||
<Message
|
||||
header="Teacher leaderboard"
|
||||
content="Teachers who have produced the most credits from all departments."
|
||||
/>
|
||||
<LeaderForm yearoptions={yearoptions} categoryoptions={categoryoptions} />
|
||||
<Segment>
|
||||
<TeacherStatisticsTable
|
||||
statistics={statistics}
|
||||
onClickFn={e => this.props.history.push(`/teachers/${e.target.innerText}`)}
|
||||
/>
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TeacherLeaderBoard.propTypes = {
|
||||
isLoading: bool.isRequired,
|
||||
statistics: arrayOf(shape({})).isRequired,
|
||||
yearoptions: arrayOf(shape({})).isRequired,
|
||||
history: shape({}).isRequired,
|
||||
getTopTeachersCategories: func.isRequired,
|
||||
categoryoptions: arrayOf(shape({ key: any, text: string, value: any })).isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ teachersTop, teachersTopCategories }) => {
|
||||
const { data } = teachersTop
|
||||
const { pending, data: yearsAndCategories } = teachersTopCategories
|
||||
const { years = {}, categories = {} } = yearsAndCategories
|
||||
return {
|
||||
isLoading: pending,
|
||||
statistics: data,
|
||||
yearoptions: Object.values(years)
|
||||
.map(({ yearcode, yearname }) => ({
|
||||
key: yearcode,
|
||||
value: yearcode,
|
||||
text: yearname
|
||||
})).sort((y1, y2) => y2.value - y1.value),
|
||||
categoryoptions: Object.values(categories).map(({ id, name }) => ({
|
||||
key: id,
|
||||
value: id,
|
||||
text: name
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
getTopTeachersCategories
|
||||
})(withRouter(TeacherLeaderBoard))
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Redirect } from 'react-router-dom'
|
||||
import { shape, string, func, bool } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment } from 'semantic-ui-react'
|
||||
import { getTeacher } from '../../redux/teachers'
|
||||
import TeacherDetails from '../TeacherDetails'
|
||||
|
||||
class TeacherPage extends Component {
|
||||
state={
|
||||
initialized: false
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const { teacher, teacherid } = this.props
|
||||
if (!teacher) {
|
||||
await this.props.getTeacher(teacherid)
|
||||
}
|
||||
this.setState({ initialized: true })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { teacher, isLoading } = this.props
|
||||
if (!this.state.initialized || isLoading) {
|
||||
return <Segment basic loading={isLoading} />
|
||||
} else if (!teacher) {
|
||||
return <Redirect to="/teachers" />
|
||||
}
|
||||
return (
|
||||
<TeacherDetails teacher={teacher} />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TeacherPage.propTypes = {
|
||||
teacher: shape({}),
|
||||
teacherid: string.isRequired,
|
||||
getTeacher: func.isRequired,
|
||||
isLoading: bool.isRequired
|
||||
}
|
||||
|
||||
TeacherPage.defaultProps = {
|
||||
teacher: undefined
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const { teacherid } = props
|
||||
const { items, pending } = state.teachers
|
||||
return {
|
||||
teacher: items[teacherid],
|
||||
isLoading: pending
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { getTeacher })(TeacherPage)
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Search, Segment, Message } from 'semantic-ui-react'
|
||||
import { func, arrayOf, object, shape } from 'prop-types'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import styles from './teacherSearch.css'
|
||||
import sharedStyles from '../../styles/shared'
|
||||
import Timeout from '../Timeout'
|
||||
import { findTeachers } from '../../redux/teachers'
|
||||
import SearchResultTable from '../SearchResultTable'
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
searchterm: '',
|
||||
displayResults: false
|
||||
}
|
||||
|
||||
class TeacherSearch extends Component {
|
||||
state=DEFAULT_STATE
|
||||
|
||||
resetComponent = () => {
|
||||
this.setState(DEFAULT_STATE)
|
||||
}
|
||||
|
||||
fetchTeachers = (searchterm) => {
|
||||
this.props.setTimeout('fetch', () => {
|
||||
}, 250)
|
||||
this.props.findTeachers(searchterm).then(() => {
|
||||
this.setState({ displayResults: true })
|
||||
this.props.clearTimeout('fetch')
|
||||
})
|
||||
}
|
||||
|
||||
handleSearchChange = (e, { value }) => {
|
||||
this.props.clearTimeout('search')
|
||||
if (value.length > 0) {
|
||||
this.setState({ searchterm: value })
|
||||
this.props.setTimeout('search', () => {
|
||||
this.fetchTeachers(value)
|
||||
}, 250)
|
||||
} else {
|
||||
this.resetComponent()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div >
|
||||
<Message
|
||||
header="Teacher search"
|
||||
content="Search for a teacher and click the search result to view their individual statistics from their entire career. "
|
||||
/>
|
||||
<div className={styles.searchContainer}>
|
||||
<Search
|
||||
className={styles.searchInput}
|
||||
input={{ fluid: true }}
|
||||
placeholder="Search by entering a username, id or name"
|
||||
value={this.state.searchterm}
|
||||
onSearchChange={this.handleSearchChange}
|
||||
showNoResults={false}
|
||||
/>
|
||||
{ this.state.displayResults && (
|
||||
<Segment className={sharedStyles.contentSegment}>
|
||||
<SearchResultTable
|
||||
headers={['Teacher ID', 'Username', 'Name']}
|
||||
rows={this.props.teachers}
|
||||
rowClickFn={(_, teacher) => this.props.history.push(`/teachers/${teacher.id}`)}
|
||||
noResultText="No teachers matched your search"
|
||||
/>
|
||||
</Segment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TeacherSearch.propTypes = {
|
||||
setTimeout: func.isRequired,
|
||||
clearTimeout: func.isRequired,
|
||||
teachers: arrayOf(object).isRequired,
|
||||
findTeachers: func.isRequired,
|
||||
history: shape({}).isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ teachers }) => {
|
||||
const { list } = teachers
|
||||
return {
|
||||
teachers: list
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(connect(mapStateToProps, { findTeachers })(Timeout(TeacherSearch)))
|
||||
@@ -0,0 +1,13 @@
|
||||
.searchContainer {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
max-width: 750px;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { Component } from 'react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, func, arrayOf, bool } from 'prop-types'
|
||||
import { Form, Segment, Dropdown, Button, Message } from 'semantic-ui-react'
|
||||
import { getProviders } from '../../redux/providers'
|
||||
import { getSemesters } from '../../redux/semesters'
|
||||
import { getTeacherStatistics } from '../../redux/teacherStatistics'
|
||||
import TeacherStatisticsTable from '../TeacherStatisticsTable'
|
||||
|
||||
const initial = {
|
||||
semesterStart: null,
|
||||
semesterEnd: null,
|
||||
providers: [],
|
||||
display: false
|
||||
}
|
||||
|
||||
class TeacherStatistics extends Component {
|
||||
state=initial
|
||||
|
||||
componentDidMount() {
|
||||
this.props.getProviders()
|
||||
this.props.getSemesters()
|
||||
}
|
||||
|
||||
setStartSemester = (_, { value }) => {
|
||||
const { semesterEnd } = this.state
|
||||
this.setState({ semesterStart: value })
|
||||
if (semesterEnd <= value) {
|
||||
this.setState({
|
||||
semesterEnd: value + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = (_, { name, value }) => {
|
||||
this.setState({ [name]: value })
|
||||
}
|
||||
|
||||
handleSubmit = async () => {
|
||||
const { semesterStart, semesterEnd, providers } = this.state
|
||||
await this.props.getTeacherStatistics(semesterStart, semesterEnd, providers)
|
||||
this.setState({ display: true })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { semesters, providers, statistics, pending } = this.props
|
||||
const { display, semesterStart, semesterEnd } = this.state
|
||||
const invalidQueryParams = this.state.providers.length === 0 || !semesterStart
|
||||
return (
|
||||
<div>
|
||||
<Message header="Teacher statistics by course providers" content="Statistics for teachers that admitted credits during and between the given semesters for one of the given course providers." />
|
||||
<Segment>
|
||||
<Form loading={pending}>
|
||||
<Form.Group widths="equal">
|
||||
<Form.Dropdown
|
||||
name="semesterStart"
|
||||
placeholder="Semester"
|
||||
label="Start semester"
|
||||
selection
|
||||
search
|
||||
options={semesters}
|
||||
value={semesterStart}
|
||||
onChange={this.setStartSemester}
|
||||
/>
|
||||
<Form.Dropdown
|
||||
name="semesterEnd"
|
||||
placeholder="Semester"
|
||||
label="End semester"
|
||||
selection
|
||||
search
|
||||
options={semesters.filter(semester => semester.value > semesterStart)}
|
||||
disabled={!semesterStart}
|
||||
value={semesterEnd}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Field>
|
||||
<label>Course providers</label>
|
||||
<Dropdown
|
||||
name="providers"
|
||||
placeholder="Providers"
|
||||
multiple
|
||||
selection
|
||||
search
|
||||
options={providers}
|
||||
value={this.state.providers}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Button fluid content="Search" onClick={this.handleSubmit} disabled={invalidQueryParams} />
|
||||
</Form>
|
||||
</Segment>
|
||||
{ display && !pending && (
|
||||
<Segment>
|
||||
<TeacherStatisticsTable statistics={statistics} onClickFn={e => this.props.history.push(`/teachers/${e.target.innerText}`)} />
|
||||
</Segment>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TeacherStatistics.propTypes = {
|
||||
providers: arrayOf(shape({})).isRequired,
|
||||
semesters: arrayOf(shape({})).isRequired,
|
||||
statistics: arrayOf(shape({})).isRequired,
|
||||
getSemesters: func.isRequired,
|
||||
getProviders: func.isRequired,
|
||||
getTeacherStatistics: func.isRequired,
|
||||
pending: bool.isRequired,
|
||||
history: shape({}).isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const { providers, teacherStatistics } = state
|
||||
const { semesters } = state.semesters.data
|
||||
const providerOptions = providers.data.map(p => ({
|
||||
key: p.providercode,
|
||||
value: p.providercode,
|
||||
text: p.name.fi || p.name.en
|
||||
}))
|
||||
const semesterOptions = !semesters
|
||||
? []
|
||||
: Object.values(semesters).reverse().map(({ semestercode, name }, idx) => ({
|
||||
key: idx,
|
||||
value: semestercode,
|
||||
text: name.en
|
||||
}))
|
||||
const statistics = Object.values(teacherStatistics.data).map(teacher => ({
|
||||
id: teacher.id,
|
||||
name: teacher.name,
|
||||
credits: teacher.stats.credits,
|
||||
passed: teacher.stats.passed,
|
||||
failed: teacher.stats.failed
|
||||
}))
|
||||
return {
|
||||
providers: providerOptions,
|
||||
semesters: semesterOptions,
|
||||
statistics,
|
||||
pending: teacherStatistics.pending,
|
||||
error: teacherStatistics.error
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
getProviders,
|
||||
getSemesters,
|
||||
getTeacherStatistics
|
||||
})(withRouter(TeacherStatistics))
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Table, Segment } from 'semantic-ui-react'
|
||||
import { shape, arrayOf, any, string, number } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
|
||||
const calculatePassrate = (pass, fail) => (100 * pass) / (pass + fail)
|
||||
|
||||
class TeacherStatisticsTable extends Component {
|
||||
state={
|
||||
selected: 'credits',
|
||||
direction: 'descending'
|
||||
}
|
||||
|
||||
handleSort = column => () => {
|
||||
const { selected, direction } = this.state
|
||||
if (selected === column) {
|
||||
this.setState({
|
||||
direction: direction === 'ascending' ? 'descending' : 'ascending'
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
selected: column,
|
||||
direction: 'descending'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sortStatistics = (statistics) => {
|
||||
const { selected, direction } = this.state
|
||||
const formatted = statistics.map(stat => ({
|
||||
...stat,
|
||||
passrate: calculatePassrate(stat.passed, stat.failed)
|
||||
}))
|
||||
const sorted = _.sortBy(formatted, selected)
|
||||
return direction === 'ascending' ? sorted : sorted.reverse()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { statistics } = this.props
|
||||
const { selected, direction } = this.state
|
||||
|
||||
const sortDirection = name => (selected === name ? direction : null)
|
||||
|
||||
return (statistics.length === 0 ? <Segment content="No statistics found for the given query." /> :
|
||||
<Table structured celled textAlign="center" sortable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell
|
||||
content="ID"
|
||||
onClick={this.handleSort('id')}
|
||||
sorted={sortDirection('id')}
|
||||
/>
|
||||
<Table.HeaderCell
|
||||
content="Name"
|
||||
textAlign="left"
|
||||
onClick={this.handleSort('name')}
|
||||
sorted={sortDirection('name')}
|
||||
/>
|
||||
<Table.HeaderCell
|
||||
content="Credits"
|
||||
onClick={this.handleSort('credits')}
|
||||
sorted={sortDirection('credits')}
|
||||
/>
|
||||
<Table.HeaderCell
|
||||
content="Passed"
|
||||
onClick={this.handleSort('passrate')}
|
||||
sorted={sortDirection('passrate')}
|
||||
/>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{ this.sortStatistics(statistics).map(({ id, name, credits, passrate }) => (
|
||||
<Table.Row key={id}>
|
||||
<Table.Cell content={id} width={1} onClick={this.props.onClickFn} /> {/* eslint-disable-line*/}
|
||||
<Table.Cell content={name} textAlign="left" />
|
||||
<Table.Cell content={credits} width={2} />
|
||||
<Table.Cell content={`${parseFloat(passrate).toFixed(2)} %`} width={2} />
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TeacherStatisticsTable.propTypes = {
|
||||
statistics: arrayOf(shape({
|
||||
id: any,
|
||||
name: string,
|
||||
credits: any,
|
||||
failed: number,
|
||||
passed: number
|
||||
})).isRequired
|
||||
}
|
||||
|
||||
export default TeacherStatisticsTable
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { Component } from 'react'
|
||||
import { shape, string } from 'prop-types'
|
||||
import { Header, Segment, Tab } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import sharedStyles from '../../styles/shared'
|
||||
import TeacherSearch from '../TeacherSearch'
|
||||
import TeacherPage from '../TeacherPage'
|
||||
import TeacherStatistics from '../TeacherStatistics'
|
||||
import TeacherLeaderBoard from '../TeacherLeaderBoard'
|
||||
|
||||
const pane = (title, Content, icon) => ({
|
||||
menuItem: { key: title, content: title, icon },
|
||||
render: () => (
|
||||
<Tab.Pane style={{ borderWidth: '0' }}>
|
||||
<Content />
|
||||
</Tab.Pane>
|
||||
)
|
||||
})
|
||||
|
||||
const TeachersTabs = () => (
|
||||
<Tab
|
||||
menu={{ attached: false, borderless: true, tabular: true }}
|
||||
panes={[
|
||||
pane('Statistics', TeacherStatistics, 'table'),
|
||||
pane('Leaderboard', TeacherLeaderBoard, 'trophy'),
|
||||
pane('Search', TeacherSearch, 'user')
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
class Teachers extends Component {
|
||||
state = {}
|
||||
|
||||
render() {
|
||||
const { match } = this.props
|
||||
const { teacherid } = match.params
|
||||
return (
|
||||
<div className={sharedStyles.segmentContainer}>
|
||||
<Header className={sharedStyles.segmentTitle} size="large" content="Teacher statistics" />
|
||||
<Segment className={sharedStyles.contentSegment}>
|
||||
{ teacherid
|
||||
? <TeacherPage teacherid={teacherid} />
|
||||
: <TeachersTabs />
|
||||
}
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Teachers.propTypes = {
|
||||
match: shape({
|
||||
params: shape({
|
||||
teacherid: string
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Teachers.defaultProps = {
|
||||
match: {
|
||||
params: { teacherid: undefined }
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Teachers)
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Container, Header, Table, Icon } from 'semantic-ui-react'
|
||||
import { callApi } from '../../apiConnection'
|
||||
|
||||
import styles from '../StudentInfoCard/studentInfoCard.css'
|
||||
|
||||
class UsageStatistics extends Component { //eslint-disable-line
|
||||
state = null
|
||||
|
||||
componentWillMount() {
|
||||
callApi('/usage').then(({ data }) => {
|
||||
this.setState(data)
|
||||
})
|
||||
}
|
||||
|
||||
renderStats() {
|
||||
if (this.state === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const byCount = (x, y) => y.count - x.count
|
||||
|
||||
const users = Object.keys(this.state.byUser).map(user => ({
|
||||
name: user,
|
||||
count: this.state.byUser[user].length
|
||||
}))
|
||||
|
||||
const endpoints = Object.keys(this.state.byEndpoint).map(endpoint => ({
|
||||
name: endpoint,
|
||||
count: this.state.byEndpoint[endpoint].length
|
||||
}))
|
||||
|
||||
if (this.state.user) {
|
||||
const entries = this.state.all
|
||||
.filter(e => e.username === this.state.user)
|
||||
.sort((e1, e2) => e2.time - e1.time)
|
||||
|
||||
const usersName = entries[0].name
|
||||
|
||||
const toTime = (stamp) => {
|
||||
const zeroed = v => (v < 10 ? `0${v}` : v)
|
||||
|
||||
const date = new Date(stamp * 1000)
|
||||
return `${date.getFullYear()}${zeroed(date.getUTCMonth() + 1)}${zeroed(date.getDate())} ${zeroed(date.getHours())}:${zeroed(date.getMinutes())}:${zeroed(date.getSeconds())}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>
|
||||
{usersName} ({this.state.user})
|
||||
<Icon
|
||||
onClick={() => this.setState({ user: null })}
|
||||
name="remove"
|
||||
className={styles.controlIcon}
|
||||
/>
|
||||
</h3>
|
||||
<Table celled>
|
||||
<Table.Body>
|
||||
{entries.map(entry => (
|
||||
<Table.Row key={entry.id}>
|
||||
<Table.Cell>
|
||||
{entry.method}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{entry.URL}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{toTime(entry.time)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>by user</h3>
|
||||
|
||||
<Table celled>
|
||||
<Table.Body>
|
||||
{users.sort(byCount).map(user => (
|
||||
<Table.Row key={user.name}>
|
||||
<Table.Cell onClick={() => this.setState({ user: user.name })}>
|
||||
{user.name}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{user.count}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
|
||||
<h3>by endpoint</h3>
|
||||
|
||||
<Table celled>
|
||||
<Table.Body>
|
||||
{endpoints.sort(byCount).map(endpoint => (
|
||||
<Table.Row key={endpoint}>
|
||||
<Table.Cell>
|
||||
{endpoint.name}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{endpoint.count}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Container text style={{ paddingTop: 50 }}>
|
||||
<Header as="h1" textAlign="center">Usage statistics</Header>
|
||||
{this.renderStats()}
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
export default connect()(UsageStatistics)
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { Component } from 'react'
|
||||
import { Button, Card, Divider, Image, Form, List, Icon } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import _ from 'lodash'
|
||||
import { string, number, shape, bool, arrayOf, func } from 'prop-types'
|
||||
import { textAndDescriptionSearch } from '../../common'
|
||||
import LanguageChooser from '../LanguageChooser'
|
||||
import { toggleCzar, addUserUnits, removeUserUnit } from '../../redux/users'
|
||||
import { getStudyrightElements } from '../../redux/studyrightElements'
|
||||
|
||||
const formatToDropdown = elements => Object.values(elements).map(e => ({
|
||||
associations: {
|
||||
20: e.associations[20] ? Object.keys(e.associations[20]) : []
|
||||
},
|
||||
key: e.code,
|
||||
value: e.code,
|
||||
description: e.code,
|
||||
text: e.name.fi || e.name.en || e.name.sv
|
||||
}))
|
||||
|
||||
class UserPage extends Component {
|
||||
state={
|
||||
degree: undefined,
|
||||
programme: undefined,
|
||||
specializations: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { studyrightElements } = this.props
|
||||
if (Object.keys(studyrightElements).length === 0) {
|
||||
this.props.getStudyrightElements()
|
||||
}
|
||||
}
|
||||
|
||||
getDisabledUnits = (units, enabled) => {
|
||||
const enabledIds = new Set(enabled.map(element => element.code))
|
||||
return units.filter(u => !enabledIds.has(u.id))
|
||||
}
|
||||
|
||||
handleChange = (e, { name, value }) => this.setState({ [name]: value })
|
||||
|
||||
enableAccessRightToUser = userid => async () => {
|
||||
const { degree, programme, specializations } = this.state
|
||||
const codes = [degree, programme, ...specializations].filter(e => !!e)
|
||||
await this.props.addUserUnits(userid, codes)
|
||||
this.setState({
|
||||
degree: undefined,
|
||||
programme: undefined,
|
||||
specializations: []
|
||||
})
|
||||
}
|
||||
|
||||
handleCoronation = user => async () => {
|
||||
await this.props.toggleCzar(user.id)
|
||||
}
|
||||
|
||||
removeAccess = (uid, unit) => () => this.props.removeUserUnit(uid, unit)
|
||||
|
||||
degreeOptions = () => {
|
||||
const { 10: deg } = this.props.studyrightElements
|
||||
const degrees = !deg ? [] : formatToDropdown(deg)
|
||||
return degrees
|
||||
}
|
||||
|
||||
programmeOptions = () => {
|
||||
const { 10: deg, 20: prog } = this.props.studyrightElements
|
||||
const { degree } = this.state
|
||||
const all = !prog ? [] : formatToDropdown(prog)
|
||||
if (deg && degree) {
|
||||
const data = deg[degree]
|
||||
const assocs = data.associations[20] || {}
|
||||
const filtered = all.filter(({ key }) => !!assocs[key])
|
||||
return filtered
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
specializationOptions = () => {
|
||||
const { 20: prog, 30: specs } = this.props.studyrightElements
|
||||
const { programme } = this.state
|
||||
const all = !specs ? [] : formatToDropdown(specs)
|
||||
if (prog && programme) {
|
||||
const data = prog[programme]
|
||||
const assocs = data.associations[30] || {}
|
||||
const filtered = all.filter(({ key }) => !!assocs[key])
|
||||
return filtered
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
studyelementOptions = () => ({
|
||||
degrees: this.degreeOptions(),
|
||||
programmes: this.programmeOptions(),
|
||||
specializations: this.specializationOptions()
|
||||
})
|
||||
|
||||
renderUnitList = (elementdetails, user) => {
|
||||
const { language } = this.props
|
||||
|
||||
const nameInLanguage = element =>
|
||||
element.name[language]
|
||||
|| element.name.fi
|
||||
|| element.name.en
|
||||
|| element.name.sv
|
||||
|
||||
const byCode = (a, b) => {
|
||||
const codeA = a.type === 30 && a.associations ? `${a.associations[0]}${nameInLanguage(a)}` : a.code
|
||||
const codeB = b.type === 30 && b.associations ? `${b.associations[0]}${nameInLanguage(b)}` : b.code
|
||||
return codeA < codeB ? -1 : 1
|
||||
}
|
||||
|
||||
if (!elementdetails) return null
|
||||
|
||||
return (
|
||||
<List divided>
|
||||
{elementdetails.sort(byCode).map(element => (
|
||||
<List.Item key={element.code}>
|
||||
<List.Content floated="right">
|
||||
<Button basic negative floated="right" onClick={this.removeAccess(user.id, element.code)} content="Remove" size="tiny" />
|
||||
</List.Content>
|
||||
<List.Content>
|
||||
{element.type === 30 ? <Icon name="minus" /> : null} {`${nameInLanguage(element)} (${element.code})`}
|
||||
</List.Content>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, pending, studyrightElements } = this.props
|
||||
|
||||
// ugly trick to add associations to study tracks, should be moved to backend
|
||||
if (studyrightElements[30]) {
|
||||
const programmes = user.elementdetails.filter(e => e.type === 20).map(e => e.code)
|
||||
user.elementdetails = user.elementdetails.map((element) => {
|
||||
const e = Object.assign(element)
|
||||
e.associations = studyrightElements && (element.type === 30) ?
|
||||
_.intersection(
|
||||
programmes,
|
||||
Object.keys(studyrightElements[30][element.code].associations[20])
|
||||
) :
|
||||
[]
|
||||
return e
|
||||
})
|
||||
}
|
||||
|
||||
const options = this.studyelementOptions()
|
||||
return (
|
||||
<div>
|
||||
<Button icon="arrow circle left" content="Back" onClick={this.props.goBack} />
|
||||
<LanguageChooser />
|
||||
<Divider />
|
||||
<Card.Group>
|
||||
<Card fluid>
|
||||
<Card.Content>
|
||||
<Card.Header>
|
||||
<Image onClick={this.handleCoronation(user)} src={user.czar ? 'https://i.pinimg.com/originals/06/7a/20/067a20e4ae1edcee790601ce9b9927df.jpg' : 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS6uJPJLxePjb5u1omdG2kOLfE0BwNjvvJ9accK922xSVwKlR8_'} avatar />
|
||||
{user.full_name}
|
||||
</Card.Header>
|
||||
<Card.Meta content={user.czar ? `tsaari ${user.username}` : `${user.username}`} />
|
||||
<Card.Meta content={user.email} />
|
||||
<Card.Description>
|
||||
{`Access to oodikone: ${user.is_enabled ? 'En' : 'Dis'}abled`} <br />
|
||||
</Card.Description>
|
||||
<Divider />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
<Card fluid>
|
||||
<Card.Content>
|
||||
<Card.Header content="Enable access" />
|
||||
<Card.Description>
|
||||
<Form loading={pending}>
|
||||
<Form.Dropdown
|
||||
name="degree"
|
||||
label="Degree (optional)"
|
||||
placeholder="Select specialization"
|
||||
options={options.degrees}
|
||||
value={this.state.degree}
|
||||
onChange={this.handleChange}
|
||||
fluid
|
||||
search={textAndDescriptionSearch}
|
||||
selection
|
||||
/>
|
||||
<Divider />
|
||||
<Form.Group widths="equal">
|
||||
<Form.Dropdown
|
||||
name="programme"
|
||||
label="Study programme"
|
||||
placeholder="Select unit"
|
||||
options={options.programmes}
|
||||
value={this.state.programmes}
|
||||
onChange={this.handleChange}
|
||||
fluid
|
||||
search={textAndDescriptionSearch}
|
||||
selection
|
||||
/>
|
||||
<Form.Dropdown
|
||||
label="Specialization"
|
||||
name="specializations"
|
||||
placeholder="Select specialization"
|
||||
options={options.specializations}
|
||||
value={this.state.specializations}
|
||||
onChange={this.handleChange}
|
||||
fluid
|
||||
search={textAndDescriptionSearch}
|
||||
multiple
|
||||
selection
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button
|
||||
basic
|
||||
fluid
|
||||
positive
|
||||
content="Enable"
|
||||
onClick={this.enableAccessRightToUser(user.id)}
|
||||
/>
|
||||
</Form>
|
||||
</Card.Description>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
<Card fluid>
|
||||
<Card.Content>
|
||||
<Card.Header content="Access rights" />
|
||||
<Card.Description>
|
||||
{user.czar ?
|
||||
<p style={{
|
||||
fontSize: '34px',
|
||||
fontFamily: 'Comic Sans',
|
||||
color: 'darkred',
|
||||
border: '1px'
|
||||
}}
|
||||
>everything!
|
||||
</p> : this.renderUnitList(user.elementdetails, user)}
|
||||
</Card.Description>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</Card.Group>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
UserPage.propTypes = {
|
||||
user: shape({
|
||||
id: string,
|
||||
full_name: string,
|
||||
is_enabled: bool,
|
||||
elementdetails: arrayOf(shape({
|
||||
code: string,
|
||||
name: shape({}),
|
||||
type: number
|
||||
}))
|
||||
}).isRequired,
|
||||
toggleCzar: func.isRequired,
|
||||
addUserUnits: func.isRequired,
|
||||
removeUserUnit: func.isRequired,
|
||||
language: string.isRequired,
|
||||
goBack: func.isRequired,
|
||||
getStudyrightElements: func.isRequired,
|
||||
studyrightElements: shape({}).isRequired,
|
||||
pending: bool.isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
language: state.settings.language,
|
||||
units: state.units.data,
|
||||
studyrightElements: state.studyrightElements.data,
|
||||
pending: state.studyrightElements.pending
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
toggleCzar,
|
||||
addUserUnits,
|
||||
removeUserUnit,
|
||||
getStudyrightElements
|
||||
})(UserPage)
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Container, Header, Image, Divider } from 'semantic-ui-react'
|
||||
import { images } from '../../common'
|
||||
|
||||
class WelcomePage extends Component { //eslint-disable-line
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Container text style={{ paddingTop: 50 }}>
|
||||
<Header as="h1" textAlign="center">oodikone</Header>
|
||||
<h3 style={{ textAlign: 'center' }}>
|
||||
a tool for explorative research on student data
|
||||
</h3>
|
||||
<Divider section />
|
||||
<h4>
|
||||
Population Statistics
|
||||
</h4>
|
||||
<p>
|
||||
Query a student population specified by starting year and studyright.
|
||||
Oodikone will give you study statistics and visualizations of the population,
|
||||
which you can interactively filter and explore.
|
||||
</p>
|
||||
<Divider section />
|
||||
|
||||
<h4>
|
||||
Student Statistics
|
||||
</h4>
|
||||
<p>
|
||||
Shows detailed information and visualizations of a queried student.
|
||||
</p>
|
||||
<Divider section />
|
||||
|
||||
<h4>
|
||||
Course Statistics
|
||||
</h4>
|
||||
<p>
|
||||
Shows student results of a course over specified years.
|
||||
</p>
|
||||
<Divider section />
|
||||
|
||||
{/* <h4>
|
||||
Settings
|
||||
</h4>
|
||||
<p>
|
||||
Here you can modify settings of oodikone.
|
||||
For example you can create custom course code correspondences.
|
||||
</p>
|
||||
<Divider section /> */}
|
||||
|
||||
</Container>
|
||||
<Image src={images.toskaLogo} size="medium" centered style={{ bottom: 0 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
export default connect()(WelcomePage)
|
||||
+109
-5
@@ -36,18 +36,29 @@ import {
|
||||
chart35
|
||||
|
||||
} from '../styles/variables'
|
||||
/*
|
||||
lightgreen: '#90EE90',
|
||||
chartblue: '#178aa5',
|
||||
chartdarkg: '#367a1c',
|
||||
chartlgreen: '#80e061',
|
||||
chartdarkred: '#a03530',
|
||||
chartlred: '#e2726d'
|
||||
*/
|
||||
import { chartblue, red, green, chartdarkg, chartlgreen, chartdarkred, chartlred } from '../styles/variables/colors'
|
||||
|
||||
export const routes = {
|
||||
index: { route: '/', translateId: 'departmentSuccess' },
|
||||
index: { route: '/' },
|
||||
populations: { route: '/populations', translateId: 'populations' },
|
||||
courseInstances: { route: '/courseinstances', translateId: 'courseinstances' },
|
||||
students: { route: '/students/:studentNumber?', translateId: 'students' },
|
||||
courseStatistics: { route: '/coursestatistics', translateId: 'courseStatistics' },
|
||||
users: { route: '/users', translateId: 'users', admin: true }
|
||||
teachers: { route: '/teachers/:teacherid?', translateId: 'teachers', czar: true },
|
||||
users: { route: '/users/:userid?', translateId: 'users', admin: true },
|
||||
settings: { route: '/settings', translateId: 'settings', admin: true },
|
||||
usage: { route: '/usage', translateId: 'usage', admin: true }
|
||||
}
|
||||
|
||||
const assumeBasename = () => {
|
||||
const POSSIBLE_BASENAMES = ['staging']
|
||||
const POSSIBLE_BASENAMES = ['staging', 'testing']
|
||||
const haystack = window.location.pathname.split('/')
|
||||
const needle = haystack.find(path => POSSIBLE_BASENAMES.includes(path))
|
||||
return needle ? `/${needle}/` : '/'
|
||||
@@ -63,7 +74,100 @@ export const DEFAULT_LANG = 'en'
|
||||
export const DISPLAY_DATE_FORMAT = 'DD.MM.YYYY'
|
||||
export const API_DATE_FORMAT = 'YYYY.MM.DD'
|
||||
|
||||
export const TOKEN_NAME = window.location.pathname.includes('/staging') ? 'staging_token' : 'token'
|
||||
export const TOKEN_NAME = window.location.pathname.includes('/staging') ? 'staging_token' : window.location.pathname.includes('/testing') ? 'testing_token' : 'token' //eslint-disable-line
|
||||
|
||||
export const passRateCumGraphOptions = (categories, max) => ({
|
||||
chart: {
|
||||
type: 'column'
|
||||
},
|
||||
colors: [chartblue, green, red],
|
||||
|
||||
title: {
|
||||
text: 'Pass rate chart'
|
||||
},
|
||||
|
||||
xAxis: {
|
||||
categories
|
||||
},
|
||||
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
title: {
|
||||
text: 'Number of Students'
|
||||
},
|
||||
max,
|
||||
floor: -max
|
||||
},
|
||||
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
borderRadius: 3
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const passRateStudGraphOptions = (categories, max) => ({
|
||||
chart: {
|
||||
type: 'column'
|
||||
},
|
||||
colors: [chartblue, chartlgreen, chartdarkg, chartlred, chartdarkred],
|
||||
|
||||
title: {
|
||||
text: 'Pass rate chart'
|
||||
},
|
||||
|
||||
xAxis: {
|
||||
categories
|
||||
},
|
||||
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
title: {
|
||||
text: 'Number of Students'
|
||||
},
|
||||
max,
|
||||
floor: -max
|
||||
},
|
||||
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
borderRadius: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const gradeGraphOptions = (categories, max) => ({
|
||||
chart: {
|
||||
type: 'column'
|
||||
},
|
||||
colors: [red, chartblue, chartblue, chartblue, chartblue, chartblue, chartblue],
|
||||
|
||||
title: {
|
||||
text: 'Grades'
|
||||
},
|
||||
|
||||
xAxis: {
|
||||
categories
|
||||
},
|
||||
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
title: {
|
||||
text: 'Number of Students'
|
||||
},
|
||||
max,
|
||||
floor: -max
|
||||
},
|
||||
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
borderRadius: 2
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const CHART_COLORS = [
|
||||
chart1,
|
||||
|
||||
@@ -19,12 +19,12 @@ export const displayableDateType = shape({
|
||||
|
||||
export const studentDetailsType = shape({
|
||||
studentNumber: string.isRequired,
|
||||
started: string.isRequired,
|
||||
started: string,
|
||||
credits: number
|
||||
})
|
||||
|
||||
export const dropdownType = shape({
|
||||
key: oneOfType([string, number]).isRequired,
|
||||
value: oneOfType([string, number]).isRequired,
|
||||
text: string.isRequired
|
||||
text: shape({}).isRequired
|
||||
})
|
||||
|
||||
+250
-77
@@ -1,100 +1,273 @@
|
||||
{
|
||||
"common": {
|
||||
"ok": ["Ok"],
|
||||
"cancel": ["Cancel"],
|
||||
"example" : ["react-localize-redux example text"],
|
||||
"loading": ["Loading"],
|
||||
"noResults": ["No search results or search term is not accurate enough"],
|
||||
"studentNumber": ["Student number"],
|
||||
"started": ["Started"],
|
||||
"credits": ["Credits"],
|
||||
"date": ["Date"],
|
||||
"course": ["Course"],
|
||||
"grade": ["Grade"],
|
||||
"passed": ["Passed"]
|
||||
"ok": [
|
||||
"Ok"
|
||||
],
|
||||
"cancel": [
|
||||
"Cancel"
|
||||
],
|
||||
"example": [
|
||||
"react-localize-redux example text"
|
||||
],
|
||||
"loading": [
|
||||
"Loading"
|
||||
],
|
||||
"noResults": [
|
||||
"No search results or search term is not accurate enough"
|
||||
],
|
||||
"studentNumber": [
|
||||
"Student number"
|
||||
],
|
||||
"started": [
|
||||
"Started"
|
||||
],
|
||||
"credits": [
|
||||
"Credits"
|
||||
],
|
||||
"date": [
|
||||
"Date"
|
||||
],
|
||||
"course": [
|
||||
"Course"
|
||||
],
|
||||
"grade": [
|
||||
"Grade"
|
||||
],
|
||||
"passed": [
|
||||
"Passed"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"connectionError" : ["Connection error"],
|
||||
"errorInResponseTo" : ["Error for API call"]
|
||||
"connectionError": [
|
||||
"Connection error"
|
||||
],
|
||||
"errorInResponseTo": [
|
||||
"Error for API call"
|
||||
]
|
||||
},
|
||||
"graphs": {
|
||||
"referenceCredits": ["Reference credits"]
|
||||
"referenceCredits": [
|
||||
"Reference credits"
|
||||
]
|
||||
},
|
||||
"navigationBar" : {
|
||||
"departmentSuccess" : ["Department success"],
|
||||
"populations" : ["Population statistics"],
|
||||
"students" : ["Student statistics"],
|
||||
"courseinstances" : ["Course Instances"],
|
||||
"teachers" : ["Teachers"],
|
||||
"courseStatistics" : ["Course statistics"],
|
||||
"users" : ["Users"],
|
||||
"logout": ["Logout"]
|
||||
"navigationBar": {
|
||||
"departmentSuccess": [
|
||||
"Department success"
|
||||
],
|
||||
"welcome": [
|
||||
"Starting page"
|
||||
],
|
||||
"populations": [
|
||||
"Population statistics"
|
||||
],
|
||||
"students": [
|
||||
"Student statistics"
|
||||
],
|
||||
"courseinstances": [
|
||||
"Course Instances"
|
||||
],
|
||||
"teachers": [
|
||||
"Teachers"
|
||||
],
|
||||
"courseStatistics": [
|
||||
"Course statistics"
|
||||
],
|
||||
"users": [
|
||||
"Users"
|
||||
],
|
||||
"settings": [
|
||||
"Settings"
|
||||
],
|
||||
"logout": [
|
||||
"Logout"
|
||||
],
|
||||
"usage" : [
|
||||
"Usage"
|
||||
]
|
||||
},
|
||||
"populationCourses": {
|
||||
"header": ["Courses of Population"],
|
||||
"course": ["Course"],
|
||||
"students": ["Students"],
|
||||
"passed": ["Passed"],
|
||||
"failed": ["Failed"],
|
||||
"passedAfterRetry": ["after retry"],
|
||||
"failedManyTimes": ["many times"],
|
||||
"attempts": ["Attempts"],
|
||||
"percentageOfPopulation": ["percentage of population"],
|
||||
"name": ["name"],
|
||||
"code": ["code"],
|
||||
"number": ["n"],
|
||||
"limit": ["Limit to courses where student number at least"],
|
||||
"percentage": ["percentage"],
|
||||
"perStudent": ["per student"],
|
||||
"attempted": ["attempted"]
|
||||
"header": [
|
||||
"Courses of Population"
|
||||
],
|
||||
"course": [
|
||||
"Course"
|
||||
],
|
||||
"students": [
|
||||
"Students"
|
||||
],
|
||||
"passed": [
|
||||
"Passed"
|
||||
],
|
||||
"failed": [
|
||||
"Failed"
|
||||
],
|
||||
"passedAfterRetry": [
|
||||
"after retry"
|
||||
],
|
||||
"failedManyTimes": [
|
||||
"many times"
|
||||
],
|
||||
"attempts": [
|
||||
"Attempts"
|
||||
],
|
||||
"percentageOfPopulation": [
|
||||
"percentage of population"
|
||||
],
|
||||
"name": [
|
||||
"name"
|
||||
],
|
||||
"code": [
|
||||
"code"
|
||||
],
|
||||
"number": [
|
||||
"n"
|
||||
],
|
||||
"limit": [
|
||||
"Limit to courses where student number at least"
|
||||
],
|
||||
"percentage": [
|
||||
"percentage"
|
||||
],
|
||||
"perStudent": [
|
||||
"per student"
|
||||
],
|
||||
"attempted": [
|
||||
"attempted"
|
||||
]
|
||||
},
|
||||
"populationStatistics" : {
|
||||
"header": ["Population statistics"],
|
||||
"months": ["Months from beginning"],
|
||||
"graphSegmentHeader" : ["Credit accumulation"],
|
||||
"creditStatisticsHeader" : ["Credit statistics"],
|
||||
"sampleId": ["Id"],
|
||||
"sampleSize" : ["Sample size: ${ amount } students"],
|
||||
"allPopulations" : ["All populations"],
|
||||
"alreadyFetched": ["Selected population already in analysis"],
|
||||
"selectValidYear": ["Select valid year."],
|
||||
"addPopulation": ["See population"],
|
||||
"clearPopulations": ["Remove population"],
|
||||
"searchTitle": ["Search for population"],
|
||||
"foundTitle": ["Population"],
|
||||
"studyRights": ["Study rights"],
|
||||
"enrollmentYear": ["Enrollment year"],
|
||||
"semester" : ["Semester"],
|
||||
"SPRING" : ["Spring"],
|
||||
"FALL" : ["Fall"],
|
||||
"selectStudyRights": ["Select study rights"],
|
||||
"noSelectableStudyRights": ["No selectable study rights"]
|
||||
"welcome": {
|
||||
"header": [
|
||||
"Welcome Page"
|
||||
]
|
||||
},
|
||||
"populationStatistics": {
|
||||
"header": [
|
||||
"Population statistics"
|
||||
],
|
||||
"months": [
|
||||
"End of search"
|
||||
],
|
||||
"graphSegmentHeader": [
|
||||
"Credit accumulation"
|
||||
],
|
||||
"creditStatisticsHeader": [
|
||||
"Credit statistics"
|
||||
],
|
||||
"sampleId": [
|
||||
"Id"
|
||||
],
|
||||
"sampleSize": [
|
||||
"Sample size: ${ amount } students"
|
||||
],
|
||||
"allPopulations": [
|
||||
"All populations"
|
||||
],
|
||||
"alreadyFetched": [
|
||||
"Selected population already in analysis"
|
||||
],
|
||||
"selectValidYear": [
|
||||
"Select valid year."
|
||||
],
|
||||
"addPopulation": [
|
||||
"See population"
|
||||
],
|
||||
"clearPopulations": [
|
||||
"Remove population"
|
||||
],
|
||||
"searchTitle": [
|
||||
"Search for population"
|
||||
],
|
||||
"foundTitle": [
|
||||
"Population"
|
||||
],
|
||||
"studyRights": [
|
||||
"Study rights"
|
||||
],
|
||||
"enrollmentYear": [
|
||||
"Enrollment year"
|
||||
],
|
||||
"semester": [
|
||||
"Semester"
|
||||
],
|
||||
"SPRING": [
|
||||
"Spring"
|
||||
],
|
||||
"FALL": [
|
||||
"Fall"
|
||||
],
|
||||
"selectStudyRights": [
|
||||
"Select study rights"
|
||||
],
|
||||
"noSelectableStudyRights": [
|
||||
"No selectable study rights"
|
||||
],
|
||||
"emptyQueryResult": [
|
||||
"No statistics found for the given query. "
|
||||
]
|
||||
},
|
||||
"departmentSuccess": {
|
||||
"header": ["Department Success"],
|
||||
"chartTitle": ["Average credit gains after 13 months for BSc students starting"]
|
||||
"header": [
|
||||
"Department Success"
|
||||
],
|
||||
"chartTitle": [
|
||||
"Average credit gains after 13 months for BSc students starting"
|
||||
]
|
||||
},
|
||||
"studentStatistics": {
|
||||
"header" : ["Student statistics"],
|
||||
"searchPlaceholder": ["Search with a student number or name (surename firstname)"],
|
||||
"chartTitle" : ["Credit Accumulation"]
|
||||
"header": [
|
||||
"Student statistics"
|
||||
],
|
||||
"searchPlaceholder": [
|
||||
"Search with a student number or name (surname firstname)"
|
||||
],
|
||||
"chartTitle": [
|
||||
"Credit Accumulation"
|
||||
]
|
||||
},
|
||||
"studyrightStatistis": {
|
||||
"header": [
|
||||
"Studyright statistics"
|
||||
],
|
||||
"navigationBar": [
|
||||
"Studyright statistics"
|
||||
]
|
||||
},
|
||||
"courseStatistics": {
|
||||
"header" : ["Student statistics"],
|
||||
"searchPlaceholder": ["Search with a student number or name (surename firstname)"],
|
||||
"chartTitle" : ["Credit Accumulation"]
|
||||
"header": [
|
||||
"Student statistics"
|
||||
],
|
||||
"searchPlaceholder": [
|
||||
"Search with a student number or name (surname firstname)"
|
||||
],
|
||||
"chartTitle": [
|
||||
"Credit Accumulation"
|
||||
]
|
||||
},
|
||||
"tags": {
|
||||
"addTag": ["Add tag"],
|
||||
"searchAndAdd": ["Search and select a new tag"],
|
||||
"confirmDelete" : ["Are you sure you want to remove tag"]
|
||||
"addTag": [
|
||||
"Add tag"
|
||||
],
|
||||
"searchAndAdd": [
|
||||
"Search and select a new tag"
|
||||
],
|
||||
"confirmDelete": [
|
||||
"Are you sure you want to remove tag"
|
||||
]
|
||||
},
|
||||
"studyrights": {
|
||||
"studyright": ["Studyright"],
|
||||
"searchPlaceholder": ["Search with a studyright name"]
|
||||
"studyright": [
|
||||
"Studyright"
|
||||
],
|
||||
"searchPlaceholder": [
|
||||
"Search with a studyright name"
|
||||
]
|
||||
},
|
||||
"teacherStatistics": {
|
||||
"header" : ["Teacher statistics"],
|
||||
"searchTitle" : ["Search for teachers"]
|
||||
"header": [
|
||||
"Teacher statistics"
|
||||
],
|
||||
"searchTitle": [
|
||||
"Search for teachers"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
-12
@@ -1,24 +1,234 @@
|
||||
import uuidv4 from 'uuid/v4'
|
||||
import { getStudentTotalCredits } from '../common'
|
||||
|
||||
export const creditsLessThan = credit =>
|
||||
({
|
||||
export const creditsLessThan = (params) => {
|
||||
const { credit } = params
|
||||
|
||||
return ({
|
||||
id: uuidv4(),
|
||||
type: 'creditsLessThan',
|
||||
params: [credit],
|
||||
type: 'CreditsLessThan',
|
||||
params: {
|
||||
credit
|
||||
},
|
||||
filter: (student) => {
|
||||
const creditsOfStudent = student.courses
|
||||
.filter(c => c.passed)
|
||||
.reduce((s, c) => s + c.credits, 0)
|
||||
const creditsOfStudent = getStudentTotalCredits(student)
|
||||
return credit > creditsOfStudent
|
||||
}
|
||||
})
|
||||
}
|
||||
export const sexFilter = (params) => {
|
||||
const { gender } = params
|
||||
return ({
|
||||
id: uuidv4(),
|
||||
type: 'SexFilter',
|
||||
params: {
|
||||
gender
|
||||
},
|
||||
filter: student => student.gender === gender
|
||||
})
|
||||
}
|
||||
export const matriculationFilter = (params) => {
|
||||
const { matriculationexamination } = params
|
||||
return ({
|
||||
id: uuidv4(),
|
||||
type: 'HasMatriculation',
|
||||
params: {
|
||||
matriculationexamination
|
||||
},
|
||||
filter: student => student.matriculationexamination === matriculationexamination
|
||||
})
|
||||
}
|
||||
export const creditsAtLeast = (params) => {
|
||||
const { credit } = params
|
||||
return ({
|
||||
id: uuidv4(),
|
||||
type: 'CreditsAtLeast',
|
||||
params: {
|
||||
credit
|
||||
},
|
||||
filter: (student) => {
|
||||
const creditsOfStudent = getStudentTotalCredits(student)
|
||||
return credit <= creditsOfStudent
|
||||
}
|
||||
})
|
||||
}
|
||||
export const enrollmentStatus = (params) => {
|
||||
const { semesters, enrolled } = params
|
||||
return ({
|
||||
id: uuidv4(),
|
||||
type: 'EnrollmentStatus',
|
||||
params: {
|
||||
semesters,
|
||||
enrolled
|
||||
},
|
||||
filter: student => student.semesterenrollments.filter(({ semestercode }) =>
|
||||
semesters.includes(semestercode)).map(({ enrollmenttype }) =>
|
||||
enrollmenttype === enrolled).every(b => b === true)
|
||||
})
|
||||
}
|
||||
|
||||
export const transferFilter = (params) => {
|
||||
const { source, target } = params
|
||||
return ({
|
||||
id: uuidv4(),
|
||||
type: 'TransferFilter',
|
||||
params: {
|
||||
source,
|
||||
target
|
||||
},
|
||||
filter: student => student.transfers.map(transfer =>
|
||||
(source === 'anywhere' || transfer.source.code === source) && (target === 'anywhere' || transfer.target.code === target)).some(b => b === true)
|
||||
|
||||
export const placeHolder = () => () =>
|
||||
})
|
||||
}
|
||||
|
||||
export const startingThisSemester = (params) => {
|
||||
const { starting } = params
|
||||
return ({
|
||||
id: uuidv4(),
|
||||
type: 'StartingThisSemester',
|
||||
params: {
|
||||
starting
|
||||
},
|
||||
filter: student =>
|
||||
student.starting === starting
|
||||
})
|
||||
}
|
||||
export const courseParticipation = ({ field, course }) =>
|
||||
({
|
||||
id: uuidv4(),
|
||||
type: 'placeHolder',
|
||||
params: [],
|
||||
filter: () =>
|
||||
null
|
||||
type: 'CourseParticipation',
|
||||
params: {
|
||||
field,
|
||||
course
|
||||
},
|
||||
studentsOfSelectedField: course.students[field],
|
||||
filter: student =>
|
||||
course.students[field][student.studentNumber] === true
|
||||
})
|
||||
|
||||
export const extentGraduated = (params) => {
|
||||
const { extentcode, graduated, complemented } = params
|
||||
return ({
|
||||
id: uuidv4(),
|
||||
type: 'ExtentGraduated',
|
||||
params: {
|
||||
extentcode,
|
||||
graduated,
|
||||
complemented
|
||||
},
|
||||
filter: (student) => {
|
||||
if (complemented === 'true' && graduated === 'either') {
|
||||
return !student.studyrights.map(sr =>
|
||||
sr.extentcode).includes(extentcode)
|
||||
} else if (complemented === 'true' && graduated === 'grad') {
|
||||
return !student.studyrights.filter(sr =>
|
||||
sr.extentcode === extentcode && sr.graduated).map(sr =>
|
||||
sr.extentcode).includes(extentcode)
|
||||
} else if (complemented === 'false' && graduated === 'either') {
|
||||
return student.studyrights.map(sr => sr.extentcode).includes(extentcode)
|
||||
}
|
||||
return student.studyrights.filter(sr =>
|
||||
sr.extentcode === extentcode && sr.graduated).map(sr =>
|
||||
sr.extentcode).includes(extentcode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const courseParticipationNTimes = (params) => {
|
||||
const { amount, course } = params
|
||||
return ({
|
||||
id: uuidv4(),
|
||||
type: 'CourseParticipationNTimes',
|
||||
params: {
|
||||
amount,
|
||||
course
|
||||
},
|
||||
filter: student => student.courses.filter(cr => cr.course.code === course).length >= amount
|
||||
})
|
||||
}
|
||||
|
||||
export const canceledStudyright = (params) => {
|
||||
const { studyrights, cancel } = params
|
||||
return ({
|
||||
id: uuidv4(),
|
||||
type: 'CanceledStudyright',
|
||||
params: {
|
||||
studyrights,
|
||||
cancel
|
||||
},
|
||||
filter: (student) => {
|
||||
if (cancel === 'true') {
|
||||
return student.studyrights.filter(sr =>
|
||||
sr.studyrightElements.some(e => studyrights.includes(e.code))).every(sr => sr.canceldate)
|
||||
}
|
||||
return !student.studyrights.filter(sr =>
|
||||
sr.studyrightElements.some(e => studyrights.includes(e.code))).every(sr => sr.canceldate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const priorityStudyright = (params) => {
|
||||
const { prioritycode, degree, programme } = params
|
||||
return ({
|
||||
id: uuidv4(),
|
||||
type: 'PriorityStudyright',
|
||||
params: {
|
||||
prioritycode,
|
||||
degree,
|
||||
programme
|
||||
},
|
||||
filter: student =>
|
||||
student.studyrights.some((sr) => {
|
||||
if (sr.prioritycode === prioritycode) {
|
||||
const elements = sr.studyrightElements.map(e => e.code)
|
||||
let bools = []
|
||||
if (degree) {
|
||||
bools = bools.concat(elements.includes(degree) || degree === 'anyDegree')
|
||||
}
|
||||
if (programme) {
|
||||
bools = bools.concat(elements.includes(programme) || programme === 'anyProgramme')
|
||||
}
|
||||
return bools.length > 0 ? bools.every(b => b === true) : false
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const presetFilter = preset => ({
|
||||
id: preset.id,
|
||||
type: 'Preset',
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
filters: preset.filters,
|
||||
filter: student => preset.filters.map(f => f.filter(student)).every(b => b === true)
|
||||
})
|
||||
const typeList = {
|
||||
CreditsLessThan: creditsLessThan,
|
||||
CreditsAtLeast: creditsAtLeast,
|
||||
HasMatriculation: matriculationFilter,
|
||||
SexFilter: sexFilter,
|
||||
CourseParticipation: courseParticipation,
|
||||
StartingThisSemester: startingThisSemester,
|
||||
ExtentGraduated: extentGraduated,
|
||||
EnrollmentStatus: enrollmentStatus,
|
||||
TransferFilter: transferFilter,
|
||||
CourseParticipationNTimes: courseParticipationNTimes,
|
||||
CanceledStudyright: canceledStudyright,
|
||||
Preset: presetFilter,
|
||||
PriorityStudyright: priorityStudyright
|
||||
}
|
||||
export const getFilterFunction = (type, params, populationCourses) => {
|
||||
switch (type) {
|
||||
case 'CourseParticipation':
|
||||
return courseParticipation({
|
||||
field: params.field,
|
||||
course: populationCourses.coursestatistics.filter(c =>
|
||||
c.course.code === params.course.course.code)[0]
|
||||
})
|
||||
default:
|
||||
return typeList[type](params)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
const formreducer = (prefix, initvals = {}) => {
|
||||
const initial = { ...initvals }
|
||||
|
||||
const actions = {
|
||||
CLEAR: `${prefix}_CLEAR`,
|
||||
SET: `${prefix}_SET_VALUE`
|
||||
}
|
||||
|
||||
const reducer = (state = { ...initial }, action) => {
|
||||
switch (action.type) {
|
||||
case actions.SET:
|
||||
return {
|
||||
...state,
|
||||
[action.name]: action.value
|
||||
}
|
||||
case actions.CLEAR:
|
||||
return { ...initial }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const setValue = (name, value) => ({ type: actions.SET, name, value })
|
||||
const clear = () => ({ type: actions.CLEAR })
|
||||
return {
|
||||
reducer,
|
||||
setValue,
|
||||
clear
|
||||
}
|
||||
}
|
||||
|
||||
export default formreducer
|
||||
@@ -0,0 +1,57 @@
|
||||
import { actionTypes } from '../../apiConnection'
|
||||
|
||||
const additionalTypes = prefix => ({
|
||||
clear: `${prefix}_CLEAR`
|
||||
})
|
||||
|
||||
const itemreducer = (prefix, initial) => {
|
||||
const types = {
|
||||
...actionTypes(prefix),
|
||||
...additionalTypes(prefix)
|
||||
}
|
||||
const initialState = {
|
||||
pending: false,
|
||||
error: false,
|
||||
data: [],
|
||||
...initial
|
||||
}
|
||||
return (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case types.attempt:
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
pending: true
|
||||
}
|
||||
case types.failure:
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: true
|
||||
}
|
||||
case types.success:
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: false,
|
||||
data: action.response
|
||||
}
|
||||
case types.clear:
|
||||
return {
|
||||
...state,
|
||||
data: initialState.data
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = (prefix) => {
|
||||
const types = additionalTypes(prefix)
|
||||
return {
|
||||
clear: () => ({ type: types.clear })
|
||||
}
|
||||
}
|
||||
|
||||
export default itemreducer
|
||||
@@ -0,0 +1,63 @@
|
||||
import { actionTypes } from '../../apiConnection'
|
||||
|
||||
const additionalTypes = prefix => ({
|
||||
reset: `${prefix}_CLEAR`
|
||||
})
|
||||
|
||||
const listreducer = (prefix, resToObject, rewriteData = true) => {
|
||||
const types = {
|
||||
...actionTypes(prefix),
|
||||
...additionalTypes(prefix)
|
||||
}
|
||||
const initialState = {
|
||||
pending: false,
|
||||
error: false,
|
||||
data: {},
|
||||
query: undefined
|
||||
}
|
||||
return (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case types.attempt:
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
pending: true,
|
||||
query: undefined
|
||||
}
|
||||
case types.failure:
|
||||
return {
|
||||
...state,
|
||||
error: true,
|
||||
pending: false,
|
||||
query: undefined
|
||||
}
|
||||
case types.success: {
|
||||
const additions = resToObject ? resToObject(action.response) : action.response
|
||||
const data = !rewriteData ? { ...state.data, ...additions } : additions
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
pending: false,
|
||||
data,
|
||||
query: action.query
|
||||
}
|
||||
}
|
||||
case types.reset: {
|
||||
return {
|
||||
...initialState
|
||||
}
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = (prefix) => {
|
||||
const types = additionalTypes(prefix)
|
||||
return {
|
||||
reset: () => ({ type: types.reset })
|
||||
}
|
||||
}
|
||||
|
||||
export default listreducer
|
||||
@@ -1,11 +1,30 @@
|
||||
import { callController } from '../apiConnection'
|
||||
|
||||
export const getCourseStatistics = ({ code, start, end, separate }) => {
|
||||
const route = `/courseyearlystats/?start=${start}&code=${code}&end=${end}&separate=${separate}`
|
||||
export const getCourseStatistics = ({ code, start, end, separate, language }) => {
|
||||
const route = `/courseyearlystats/?start=${start}&code=${code}&end=${end}&separate=${separate}&language=${language}`
|
||||
const prefix = 'FIND_COURSE_STATISTICS_'
|
||||
return callController(route, prefix)
|
||||
}
|
||||
|
||||
export const getMultipleCourseStatistics = ({ codes, start, end, separate, language }) => {
|
||||
const route = `/v2/courseyearlystats/?start=${start}&codes=${JSON.stringify(codes)}&end=${end}&separate=${separate}&language=${language}`
|
||||
const prefix = 'FIND_COURSE_STATISTICS_'
|
||||
return callController(route, prefix)
|
||||
}
|
||||
|
||||
export const getCourseTypes = () => {
|
||||
const route = '/coursetypes'
|
||||
const prefix = 'FIND_COURSE_TYPES_'
|
||||
return callController(route, prefix)
|
||||
}
|
||||
|
||||
export const getCourseDisciplines = () => {
|
||||
const route = '/courseDisciplines'
|
||||
const prefix = 'FIND_COURSE_DISCIPLINES_'
|
||||
return callController(route, prefix)
|
||||
}
|
||||
|
||||
|
||||
export const clearCouresStatistics = () => ({
|
||||
type: 'CLEAR_COURSE_STATISTICS'
|
||||
})
|
||||
@@ -22,12 +41,14 @@ const reducer = (state = { data: [], selected: [] }, action) => {
|
||||
switch (action.type) {
|
||||
case 'FIND_COURSE_STATISTICS_ATTEMPT':
|
||||
return {
|
||||
...state,
|
||||
pending: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
}
|
||||
case 'FIND_COURSE_STATISTICS_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: true,
|
||||
selected: state.selected,
|
||||
@@ -35,19 +56,21 @@ const reducer = (state = { data: [], selected: [] }, action) => {
|
||||
}
|
||||
case 'FIND_COURSE_STATISTICS_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: false,
|
||||
selected: [...state.selected, {
|
||||
code: action.response.code,
|
||||
start: action.response.start,
|
||||
end: action.response.end,
|
||||
separate: action.response.separate,
|
||||
name: action.response.name
|
||||
}],
|
||||
data: [...state.data, action.response]
|
||||
selected: [...state.selected, ...action.response.map(res => ({
|
||||
code: res.code,
|
||||
start: res.start,
|
||||
end: res.end,
|
||||
separate: res.separate,
|
||||
name: res.name
|
||||
}))],
|
||||
data: [...action.response, ...state.data]
|
||||
}
|
||||
case 'REMOVE_COURSE_STATISTICS':
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: false,
|
||||
selected: [...state.selected.filter(query => query.code !== action.code ||
|
||||
@@ -59,8 +82,52 @@ const reducer = (state = { data: [], selected: [] }, action) => {
|
||||
query.end !== action.end ||
|
||||
query.separate !== action.separate)]
|
||||
}
|
||||
case 'FIND_COURSE_TYPES_ATTEMPT':
|
||||
return {
|
||||
...state,
|
||||
types_pending: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
}
|
||||
case 'FIND_COURSE_TYPES_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
types_pending: false,
|
||||
error: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
}
|
||||
case 'FIND_COURSE_TYPES_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
types_pending: false,
|
||||
error: false,
|
||||
courseTypes: action.response
|
||||
}
|
||||
case 'FIND_COURSE_DISCIPLINES_ATTEMPT':
|
||||
return {
|
||||
...state,
|
||||
disciplines_pending: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
}
|
||||
case 'FIND_COURSE_DISCIPLINES_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
disciplines_pending: false,
|
||||
error: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
}
|
||||
case 'FIND_COURSE_DISCIPLINES_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
disciplines_pending: false,
|
||||
error: false,
|
||||
courseDisciplines: action.response
|
||||
}
|
||||
case 'CLEAR_COURSE_STATISTICS':
|
||||
return { data: [], selected: [] }
|
||||
return { ...state, data: [], selected: [] }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { callController } from '../apiConnection'
|
||||
|
||||
export const getDuplicates = () => {
|
||||
const route = '/courses/duplicatecodes/all'
|
||||
const prefix = 'GET_ALL_DUPLICATES_'
|
||||
return callController(route, prefix)
|
||||
}
|
||||
|
||||
export const addDuplicate = (code1, code2) => {
|
||||
const route = `/courses/duplicatecodes/${code1}/${code2}`
|
||||
const prefix = 'ADD_DUPLICATE_'
|
||||
const data = { code1, code2 }
|
||||
const method = 'post'
|
||||
return callController(route, prefix, data, method)
|
||||
}
|
||||
|
||||
export const removeDuplicate = (code1, code2) => {
|
||||
const route = `/courses/duplicatecodes/${code1}/${code2}`
|
||||
const prefix = 'REMOVE_DUPLICATE_'
|
||||
const method = 'delete'
|
||||
return callController(route, prefix, {}, method)
|
||||
}
|
||||
|
||||
const reducer = (state = { data: [] }, action) => {
|
||||
switch (action.type) {
|
||||
case 'GET_ALL_DUPLICATES_ATTEMPT':
|
||||
return {
|
||||
pending: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
}
|
||||
case 'GET_ALL_DUPLICATES_FAILURE':
|
||||
return {
|
||||
pending: false,
|
||||
error: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
}
|
||||
case 'GET_ALL_DUPLICATES_SUCCESS':
|
||||
return {
|
||||
pending: false,
|
||||
error: false,
|
||||
selected: action.response.code,
|
||||
data: action.response
|
||||
}
|
||||
case 'ADD_DUPLICATE_ATTEMPT':
|
||||
return {
|
||||
pending: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
}
|
||||
case 'ADD_DUPLICATE_FAILURE':
|
||||
return {
|
||||
pending: false,
|
||||
error: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
}
|
||||
case 'ADD_DUPLICATE_SUCCESS':
|
||||
return {
|
||||
pending: false,
|
||||
error: false,
|
||||
selected: action.response.code,
|
||||
data: action.response
|
||||
}
|
||||
case 'REMOVE_DUPLICATE_ATTEMPT':
|
||||
return {
|
||||
pending: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
}
|
||||
case 'REMOVE_DUPLICATE_FAILURE':
|
||||
return {
|
||||
pending: false,
|
||||
error: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
}
|
||||
case 'REMOVE_DUPLICATE_SUCCESS':
|
||||
return {
|
||||
pending: false,
|
||||
error: false,
|
||||
selected: action.response.code,
|
||||
data: action.response
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export default reducer
|
||||
@@ -0,0 +1,10 @@
|
||||
import itemreducer from './common/itemreducer'
|
||||
import { callController } from '../apiConnection/index'
|
||||
|
||||
const prefix = 'GET_COURSE_DISCIPLINES_'
|
||||
|
||||
export const getCourseDisciplines = () => callController('/courseDisciplines', prefix)
|
||||
|
||||
const reducer = itemreducer(prefix)
|
||||
|
||||
export default reducer
|
||||
+54
-11
@@ -1,34 +1,77 @@
|
||||
import { callController } from '../apiConnection'
|
||||
|
||||
export const findCourses = (searchStr) => {
|
||||
const route = `/courses/?name=${searchStr}`
|
||||
export const findCourses = (searchStr, language) => {
|
||||
const route = `/courses/?name=${searchStr}&language=${language}`
|
||||
const prefix = 'FIND_COURSES_'
|
||||
return callController(route, prefix)
|
||||
}
|
||||
|
||||
const reducer = (state = { data: [] }, action) => {
|
||||
export const findMultipleCourses = ({ searchStr, type, discipline }, language = 'fi') => {
|
||||
const route = `/coursesmulti/?${searchStr ? `name=${searchStr}` : ''}${type ? `&type=${type}` : ''}${discipline ? `&discipline=${discipline}` : ''}&language=${language}`
|
||||
const prefix = 'FIND_COURSES_MULTI_'
|
||||
return callController(route, prefix)
|
||||
}
|
||||
|
||||
export const emptyCourseSearch = () => ({
|
||||
type: 'CLEAR_COURSE_SEARCH'
|
||||
})
|
||||
|
||||
export const toggleCourseSelect = code => ({
|
||||
type: 'TOGGLE_COURSE_SELECT',
|
||||
code
|
||||
})
|
||||
|
||||
const reducer = (state = { data: [], selected: [] }, action) => {
|
||||
switch (action.type) {
|
||||
case 'FIND_COURSES_ATTEMPT':
|
||||
return {
|
||||
pending: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
...state,
|
||||
pending: true
|
||||
}
|
||||
case 'FIND_COURSES_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: true,
|
||||
selected: state.selected,
|
||||
data: state.data
|
||||
error: true
|
||||
}
|
||||
case 'FIND_COURSES_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: false,
|
||||
selected: action.response.code,
|
||||
data: action.response
|
||||
}
|
||||
|
||||
case 'FIND_COURSES_MULTI_ATTEMPT':
|
||||
return {
|
||||
...state,
|
||||
pending: true
|
||||
}
|
||||
case 'FIND_COURSES_MULTI_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: true
|
||||
}
|
||||
case 'FIND_COURSES_MULTI_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: false,
|
||||
data: action.response
|
||||
}
|
||||
case 'CLEAR_COURSE_SEARCH':
|
||||
return {
|
||||
pending: false,
|
||||
data: [],
|
||||
selected: []
|
||||
}
|
||||
case 'TOGGLE_COURSE_SELECT':
|
||||
return { // eslint-disable-line no-return-assign
|
||||
...state,
|
||||
selected: state.selected.find(course => course.code === action.code) ?
|
||||
state.selected.filter(cr => cr.code !== action.code) :
|
||||
[...state.selected, state.data.find(course => course.code === action.code)]
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import formreducer from './common/formreducer'
|
||||
import selector from '../selectors/courseStats'
|
||||
|
||||
const { ALL } = selector
|
||||
|
||||
const prefix = 'COURSE_SUMMARY_FORM'
|
||||
|
||||
export const fields = {
|
||||
programme: 'programme'
|
||||
}
|
||||
|
||||
const reducer = formreducer(prefix, { [fields.programme]: ALL.value })
|
||||
|
||||
export const { setValue, clear } = reducer
|
||||
|
||||
export default reducer.reducer
|
||||
@@ -0,0 +1,18 @@
|
||||
import itemreducer, { actions } from './common/itemreducer'
|
||||
import { callController } from '../apiConnection/index'
|
||||
|
||||
const prefix = 'GET_COURSE_SEARCH_RESULT_'
|
||||
|
||||
const storeActions = actions(prefix)
|
||||
|
||||
export const clearCourses = storeActions.clear
|
||||
|
||||
export const findCourses = ({ name, type, discipline }, language = 'fi') => {
|
||||
const route = '/coursesmulti'
|
||||
const params = { name, type, discipline, language }
|
||||
return callController(route, prefix, [], 'get', params, params)
|
||||
}
|
||||
|
||||
const reducer = itemreducer(prefix)
|
||||
|
||||
export default reducer
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user