mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1976d2914d | ||
|
|
52e6a49fd3 | ||
|
|
fc4e48aaa0 | ||
|
|
ae83dc3563 | ||
|
|
2109cfcf87 | ||
|
|
1a3facf680 | ||
|
|
d016cec63c | ||
|
|
74bfceaf72 | ||
|
|
a549ae5095 | ||
|
|
863cb9d134 | ||
|
|
eb2576bfad | ||
|
|
77831b3bcf | ||
|
|
99ff14b13f | ||
|
|
e3c2427885 | ||
|
|
a6e52a690f | ||
|
|
eac4a03ea8 | ||
|
|
def64d5612 | ||
|
|
9ddd42aced | ||
|
|
60aa43d520 | ||
|
|
49be4b4f54 | ||
|
|
2f5c68f439 | ||
|
|
54321f349c | ||
|
|
9aba7ab5c5 | ||
|
|
059ccbd42b | ||
|
|
733cfac1be | ||
|
|
bcf1bca28b | ||
|
|
025aca0c15 | ||
|
|
00c4a6b409 | ||
|
|
9be9509562 | ||
|
|
9e6a92c114 | ||
|
|
178ff11b48 | ||
|
|
e7a1cdf098 | ||
|
|
60f10faf9e | ||
|
|
b31b3543bd | ||
|
|
b4263f29c5 | ||
|
|
6a69a3b98b | ||
|
|
921fa2d1de | ||
|
|
c4724f8c07 | ||
|
|
44a7f8bcee | ||
|
|
a632f3757d | ||
|
|
a84ad67b76 | ||
|
|
29331e13d3 | ||
|
|
0358cb49a6 | ||
|
|
78719ee09a | ||
|
|
e66542fe89 | ||
|
|
ca9d43b4a9 | ||
|
|
286b4a90b4 | ||
|
|
cfa65da634 | ||
|
|
3c81a8cbcd | ||
|
|
444a261261 | ||
|
|
a5928b0dba | ||
|
|
27ab354a70 | ||
|
|
e293eb8471 | ||
|
|
4dcacbb2ce | ||
|
|
122eb29068 | ||
|
|
d5e6b52316 | ||
|
|
601d69cc3d | ||
|
|
e8f73adc81 | ||
|
|
c0187ad18e | ||
|
|
f877e1187d | ||
|
|
3d86be5b9a | ||
|
|
fe9aad8043 | ||
|
|
34d9855782 | ||
|
|
e1213bd768 | ||
|
|
5d6df13d8e | ||
|
|
bf08700807 | ||
|
|
c95b5a9cef | ||
|
|
cd7766982b | ||
|
|
02932036fe | ||
|
|
7b3bea1f9c | ||
|
|
09dec983aa | ||
|
|
3e8ac19af1 | ||
|
|
93aacd3e16 | ||
|
|
29108ca2b2 | ||
|
|
bfa14c9959 | ||
|
|
836f797da9 | ||
|
|
45ca6f0083 | ||
|
|
2b44529ed4 | ||
|
|
e27d162921 | ||
|
|
7ebddf076f | ||
|
|
d0283bbc34 | ||
|
|
facbb2da44 | ||
|
|
cafc09f805 | ||
|
|
c3a61ab7b3 | ||
|
|
2a0c39ab05 | ||
|
|
c83646512e | ||
|
|
82c6181cd5 | ||
|
|
cc5f5e01c6 | ||
|
|
7e04951e08 | ||
|
|
5cabca6311 | ||
|
|
96b181d3af | ||
|
|
72acdf9a3b | ||
|
|
0b29b6aa58 | ||
|
|
c272f1fde3 | ||
|
|
7174ed55c1 | ||
|
|
f958ba6701 | ||
|
|
93264fef4d | ||
|
|
a4baf8ae6d | ||
|
|
f3aa59fe1f | ||
|
|
322f19826a | ||
|
|
2a2388cbf6 | ||
|
|
d2c283c1b9 | ||
|
|
3d84fb6a74 | ||
|
|
6a53e30a94 | ||
|
|
f8616ba1c4 | ||
|
|
a87c35069d | ||
|
|
fe87b0b960 | ||
|
|
1a8b88dd0e | ||
|
|
b09cb8d574 | ||
|
|
630df92e9d | ||
|
|
948808ce11 | ||
|
|
30e822b3f6 | ||
|
|
2769d0999e | ||
|
|
86a1ef7b9f | ||
|
|
b4b6d45463 | ||
|
|
dc7ad404cb | ||
|
|
dec0087de0 | ||
|
|
61f91d1cfe | ||
|
|
c11f905a2e | ||
|
|
b2e433a79b | ||
|
|
1e7160dcf5 | ||
|
|
da973fe74a | ||
|
|
309d3128e4 | ||
|
|
4add189096 | ||
|
|
f5ddf6291b | ||
|
|
3989e0da94 | ||
|
|
3035b846a4 | ||
|
|
c88aa37b68 | ||
|
|
884191f352 | ||
|
|
35af223c32 | ||
|
|
71e639efa1 | ||
|
|
546c683c8b | ||
|
|
1c5c7781a5 | ||
|
|
99b08020db | ||
|
|
8c2efa9199 | ||
|
|
7c50c80b15 | ||
|
|
9290fbf32b | ||
|
|
ffaa3eb05b | ||
|
|
77b159739f | ||
|
|
0e63b970f7 | ||
|
|
7bedbe6451 | ||
|
|
dacf5cc51b | ||
|
|
de5fdb7eaf | ||
|
|
c35a632469 | ||
|
|
691b6d6cb3 | ||
|
|
c8a69a15b4 | ||
|
|
665b95a6f8 | ||
|
|
d6ab10bed2 | ||
|
|
a7fb7eda97 | ||
|
|
44ce8ac298 | ||
|
|
7312416e2b | ||
|
|
d967bce32b | ||
|
|
4716299771 | ||
|
|
1036b47523 | ||
|
|
317fb6f655 | ||
|
|
d6d804d288 | ||
|
|
8b3c408700 | ||
|
|
7a94c92181 | ||
|
|
2feeaddb8b | ||
|
|
6d9c4b0b0a | ||
|
|
c9abbf958a | ||
|
|
47692de1ce | ||
|
|
ad6260a313 | ||
|
|
298e43a89a | ||
|
|
055d3b4eac | ||
|
|
5a831cdc86 | ||
|
|
c23ee470d8 | ||
|
|
59b4aad1ab | ||
|
|
de3e0f5e6b | ||
|
|
7ede62760e | ||
|
|
d2fdeeb830 | ||
|
|
220952153f | ||
|
|
bace3c80af | ||
|
|
d9457bf855 | ||
|
|
2d6d12f95f | ||
|
|
93a3014209 | ||
|
|
3d6bc23658 | ||
|
|
1906827760 | ||
|
|
4b8f389dd3 | ||
|
|
5d69a9b53b | ||
|
|
5c0c267606 | ||
|
|
00c7e45672 | ||
|
|
b28d43c7f8 | ||
|
|
c0f15c9dd9 | ||
|
|
64240c9f6f | ||
|
|
cbbcdd0e40 | ||
|
|
dc7a6805a7 | ||
|
|
0321dbed62 | ||
|
|
88a46a8f23 | ||
|
|
5c26f04436 | ||
|
|
e80fa25fb3 | ||
|
|
12f11183cf | ||
|
|
abfefa8c3e | ||
|
|
eb82eee286 | ||
|
|
5ff9b3b788 | ||
|
|
f4d73b649f | ||
|
|
4136c0d3b5 | ||
|
|
2b8cf76053 | ||
|
|
e23c60cb38 | ||
|
|
676804b770 | ||
|
|
2785742934 | ||
|
|
f82638763f | ||
|
|
7253ba1e17 | ||
|
|
e2847a68ea | ||
|
|
687e231e65 | ||
|
|
0629614ed8 | ||
|
|
843e04f154 | ||
|
|
d7c3423a3f | ||
|
|
8be238d4cc | ||
|
|
ecbaa35802 | ||
|
|
732e14cb2d | ||
|
|
e98ef1b94f | ||
|
|
e254de4df3 | ||
|
|
1d169ee70a | ||
|
|
0380cab715 | ||
|
|
4391c06f00 | ||
|
|
36a3044b16 | ||
|
|
db08330cb9 | ||
|
|
0a071ee071 | ||
|
|
2354b696a5 | ||
|
|
f6db0f2fe8 | ||
|
|
e40c219e78 | ||
|
|
2e7ca4d960 | ||
|
|
c3fd874559 | ||
|
|
80768b4d15 | ||
|
|
7de955398c | ||
|
|
2510903d63 | ||
|
|
ef3aa1faf2 | ||
|
|
7444567d64 | ||
|
|
a559dab709 | ||
|
|
34feb51d7b | ||
|
|
a206a1a9b3 | ||
|
|
9a18de83f9 | ||
|
|
d1723e7ad6 | ||
|
|
e1adc24ccd | ||
|
|
1668f9581b | ||
|
|
58b0b97e3a | ||
|
|
90479e5fbf | ||
|
|
7461ace443 | ||
|
|
7490395f99 | ||
|
|
a16195d47a | ||
|
|
c4084faf89 | ||
|
|
09a9958272 | ||
|
|
e4325f67bd | ||
|
|
91145e3a88 | ||
|
|
f4920de7d2 | ||
|
|
d2f96b4657 | ||
|
|
21d252f1c7 | ||
|
|
06f63110b9 | ||
|
|
9bbd67f7a3 | ||
|
|
fb465ec17e | ||
|
|
76f06a6481 | ||
|
|
929c022bf8 | ||
|
|
d32aba88fc | ||
|
|
e9d482f99c | ||
|
|
496fd6bb15 | ||
|
|
016d8da641 | ||
|
|
37d12ac65f | ||
|
|
1b42b51e6e | ||
|
|
36230033ef | ||
|
|
dd1e143367 | ||
|
|
d44ee6738f | ||
|
|
1e8c6a2c79 | ||
|
|
3833720a22 | ||
|
|
90ae26903e | ||
|
|
b68806e6ed | ||
|
|
3a030a163d | ||
|
|
cd726716f5 | ||
|
|
627cd52b5e | ||
|
|
01afe1607c | ||
|
|
fd497422f8 | ||
|
|
fdcc445086 | ||
|
|
36d7b3f7f3 | ||
|
|
b8d8e7df87 | ||
|
|
7c2f2344b8 | ||
|
|
33691621d8 | ||
|
|
93ed56dd38 | ||
|
|
54f47b8607 | ||
|
|
db2649be6f | ||
|
|
17a07de1f0 | ||
|
|
3aa6892bff | ||
|
|
92405c3561 | ||
|
|
cfc2658078 | ||
|
|
209feebb5d | ||
|
|
0fb7a94eb1 | ||
|
|
e80c0aaf85 | ||
|
|
8dda45da48 | ||
|
|
e5a85500b3 | ||
|
|
a4b8844c77 | ||
|
|
7696e5bcd8 | ||
|
|
486561789b | ||
|
|
28fc185a2a | ||
|
|
db59d0e138 | ||
|
|
1687f0a0c6 | ||
|
|
6ac6ad687e | ||
|
|
5e1e71b559 | ||
|
|
66b5e9a65d | ||
|
|
30b1a31bb3 | ||
|
|
d6765bd7d6 | ||
|
|
5ce3337977 | ||
|
|
c5e24c7409 | ||
|
|
34f029da9e | ||
|
|
e95414806c | ||
|
|
456b420321 | ||
|
|
0eb87ea44a | ||
|
|
454fabeb1d | ||
|
|
d3f022adc5 | ||
|
|
8efe3d2aa3 | ||
|
|
d3003d5797 | ||
|
|
0dd5f32f96 | ||
|
|
85b307b306 | ||
|
|
73eadbd1b1 | ||
|
|
b294c560e4 | ||
|
|
455d9dc532 | ||
|
|
df1049a436 | ||
|
|
ac22df8ced | ||
|
|
1fd987221d | ||
|
|
45985f2c44 | ||
|
|
159e11a508 | ||
|
|
5ff68533d7 | ||
|
|
61f20173a3 | ||
|
|
987f1ec42b | ||
|
|
3f4c39600b | ||
|
|
88ef490b1b | ||
|
|
dbc8b684c2 | ||
|
|
7e135d4643 | ||
|
|
06a8760701 | ||
|
|
5eaa035f1d | ||
|
|
ae1f0ceeb7 | ||
|
|
3ca82286b4 | ||
|
|
63add43c0f | ||
|
|
72440609b4 | ||
|
|
c946cd2405 | ||
|
|
96d316ebf5 | ||
|
|
b3eb7dceac | ||
|
|
ee02d002ba | ||
|
|
34448d27d0 | ||
|
|
39ce64edce | ||
|
|
0d210a607b | ||
|
|
f1a6302928 | ||
|
|
359db8f365 | ||
|
|
387a3e0ef2 | ||
|
|
4c957b347b | ||
|
|
10d3d5aeed | ||
|
|
6e365d4a23 | ||
|
|
68292a45d0 | ||
|
|
f0088cf7a0 | ||
|
|
b8469a242e | ||
|
|
8c5d5f1ee9 | ||
|
|
80759cacee | ||
|
|
4df2cc78ff | ||
|
|
8b34f7a5f9 | ||
|
|
e81d6eb877 | ||
|
|
0c47d7c21c | ||
|
|
175996235d | ||
|
|
640dc741a4 |
@@ -41,8 +41,8 @@ jobs:
|
||||
spec: cypress/e2e/Students.js
|
||||
- name: 'Test completed courses'
|
||||
spec: cypress/e2e/Completed_courses.js
|
||||
- name: 'Test studyprogramme overview'
|
||||
spec: cypress/e2e/Studyprogramme_overview.js
|
||||
- name: 'Test study programme overview'
|
||||
spec: cypress/e2e/Study_programme_overview.js
|
||||
- name: 'Test teachers and users'
|
||||
spec: |
|
||||
cypress/e2e/Teachers.js
|
||||
|
||||
@@ -199,8 +199,8 @@ jobs:
|
||||
spec: cypress/e2e/Students.js
|
||||
- name: 'Test completed courses'
|
||||
spec: cypress/e2e/Completed_courses.js
|
||||
- name: 'Test studyprogramme overview'
|
||||
spec: cypress/e2e/Studyprogramme_overview.js
|
||||
- name: 'Test study_programme overview'
|
||||
spec: cypress/e2e/Study_programme_overview.js
|
||||
- name: 'Test teachers and users'
|
||||
spec: |
|
||||
cypress/e2e/Teachers.js
|
||||
|
||||
@@ -6,6 +6,8 @@ const relativeFilePaths = files => [...files.map(file => path.relative(cwd, file
|
||||
module.exports = {
|
||||
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
|
||||
`eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
|
||||
'services/backend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
|
||||
'services/frontend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
|
||||
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `prettier --write ${files.join(' ')}`,
|
||||
'*.css': files => `stylelint --fix ${files.join(' ')}`,
|
||||
Dockerfile: files =>
|
||||
|
||||
+660
-487
File diff suppressed because it is too large
Load Diff
@@ -56,11 +56,10 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Basic information')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Students by starting year')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Progress')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Update statistics')
|
||||
cy.cs('faculty-tabs').should('contain', 'Basic information')
|
||||
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
|
||||
cy.cs('faculty-tabs').should('contain', 'Graduation times')
|
||||
cy.cs('faculty-tabs').should('contain', 'Update statistics')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,67 +71,66 @@ describe('Faculty statistics', () => {
|
||||
|
||||
describe('Info boxes', () => {
|
||||
it('Students of the faculty', () => {
|
||||
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxContent]').contains('Taulukon luvut on')
|
||||
cy.cs('students-of-the-faculty-info-box-button').click()
|
||||
cy.cs('students-of-the-faculty-info-box-content').contains('Taulukon luvut on')
|
||||
})
|
||||
|
||||
it('Graduated of the faculty', () => {
|
||||
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxContent]').contains('Sisältää kyseisenä')
|
||||
cy.cs('graduated-of-the-faculty-info-box-button').click()
|
||||
cy.cs('graduated-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
|
||||
})
|
||||
|
||||
it('Thesis writers of the faculty', () => {
|
||||
cy.get('[data-cy=ThesisWritersOfTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=ThesisWritersOfTheFacultyInfoBoxContent]').contains('Sisältää kyseisenä')
|
||||
cy.cs('thesis-writers-of-the-faculty-info-box-button').click()
|
||||
cy.cs('thesis-writers-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
|
||||
})
|
||||
|
||||
it('Credits produced by the faculty', () => {
|
||||
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxContent]').contains('Sisältää opintopisteet')
|
||||
cy.cs('credits-produced-by-the-faculty-info-box-button').click()
|
||||
cy.cs('credits-produced-by-the-faculty-info-box-content').contains('Sisältää opintopisteet')
|
||||
})
|
||||
})
|
||||
|
||||
it('All graphs and tables are shown', () => {
|
||||
cy.get('[data-cy="StudentsOfTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
cy.cs('students-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('graduated-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('thesis-writers-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('credits-produced-by-the-faculty-stacked-bar-chart-section').should('be.visible')
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('be.visible')
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Basic information')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Students by starting year')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Progress')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('not.contain', 'Update statistics')
|
||||
cy.cs('faculty-tabs').should('contain', 'Basic information')
|
||||
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
|
||||
cy.cs('faculty-tabs').should('contain', 'Graduation times')
|
||||
cy.cs('faculty-tabs').should('not.contain', 'Update statistics')
|
||||
})
|
||||
|
||||
it('Toggle years works', () => {
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="YearToggle"]').click()
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('year-toggle').click()
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
})
|
||||
|
||||
it('Toggle programmes works', () => {
|
||||
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('not.exist')
|
||||
cy.get('[data-cy="ProgrammeToggle"]').click()
|
||||
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('be.visible')
|
||||
cy.cs('faculty-programmes-shown-info').should('not.exist')
|
||||
cy.cs('programme-toggle').click()
|
||||
cy.cs('faculty-programmes-shown-info').should('be.visible')
|
||||
})
|
||||
|
||||
it('Toggle study rights works', () => {
|
||||
cy.get('[data-cy="FacultyExcludeSpecialsInfo"]').should('not.exist')
|
||||
cy.get('[data-cy="StudentToggle"]').click()
|
||||
cy.get('[data-cy="FacultyExcludeSpecialsInfo"]').should('be.visible')
|
||||
cy.cs('faculty-exclude-specials-info').should('not.exist')
|
||||
cy.cs('study-right-toggle').click()
|
||||
cy.cs('faculty-exclude-specials-info').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Data can be exported to Excel files', { retries: 2 }, () => {
|
||||
@@ -144,7 +142,7 @@ describe('Faculty statistics', () => {
|
||||
'CreditsProducedByTheFaculty',
|
||||
]
|
||||
sections.forEach(section => {
|
||||
cy.get(`[data-cy="${section}Graph"]`).within(() => {
|
||||
cy.cs(`${section}Graph`).within(() => {
|
||||
cy.get('[aria-label="View chart menu, Chart"]').click({ force: true })
|
||||
cy.contains('li.highcharts-menu-item', 'Download XLS').click()
|
||||
const downloadedFile = `oodikone_${section}_H50_${timestamp}.xls`
|
||||
@@ -163,14 +161,14 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Export button', { retries: 2 }, () => {
|
||||
cy.get('[data-cy=FacultyStudentTableExportButton]').should('be.visible').and('not.be.disabled').click()
|
||||
cy.cs('faculty-student-table-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_programme_stats_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it('Info box', () => {
|
||||
cy.get('[data-cy=FacultyStudentTableInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyStudentTableInfoBoxContent]').contains('Opiskelijat, joiden')
|
||||
cy.cs('faculty-student-table-info-box-button').click()
|
||||
cy.cs('faculty-student-table-info-box-content').contains('Opiskelijat, joiden')
|
||||
})
|
||||
|
||||
it.skip('Study right toggle', () => {
|
||||
@@ -194,10 +192,10 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Percentage toggle works', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', '92.3 %')
|
||||
cy.get('[data-cy="HidePercentagesToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', '92.3 %')
|
||||
cy.cs('faculty-student-stats-table').should('be.visible')
|
||||
cy.cs('faculty-student-stats-table').should('not.contain', '92.3 %')
|
||||
cy.cs('percentage-toggle').click()
|
||||
cy.cs('faculty-student-stats-table').should('contain', '92.3 %')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -210,33 +208,33 @@ describe('Faculty statistics', () => {
|
||||
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.get('[data-cy=FacultyProgressExportButton]').should('be.visible').and('not.be.disabled').click()
|
||||
cy.cs('FacultyProgress-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_progress_tab_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it.skip('Info boxes', () => {
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxContent]').contains('Kuvaa tiedekuntaan')
|
||||
cy.cs('FacultyProgress-info-box-button').click()
|
||||
cy.cs('FacultyProgress-info-box-content').contains('Kuvaa tiedekuntaan')
|
||||
// TODO: Click open the accordion first
|
||||
cy.get('[data-cy=BachelorMastersProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=BachelorMastersProgressInfoBoxContent]').contains('The starting year is the')
|
||||
cy.cs('BachelorMastersProgress-info-box-button').click()
|
||||
cy.cs('BachelorMastersProgress-info-box-content').contains('The starting year is the')
|
||||
})
|
||||
|
||||
it.skip('Progress bar charts exist', () => {
|
||||
// TODO: Click open the accordion first
|
||||
cy.get('[data-cy="FacultyBachelorsProgressBarChart"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorMastersProgressBarChart"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyMastersProgressBarChart"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorsProgressBarChart"]').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyBachelorMastersProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyMastersProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Progress tables exist', () => {
|
||||
// TODO: Click open the accordion first
|
||||
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorMasterProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyMastersProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
|
||||
cy.cs('FacultyBachelorMasterProgressTable').should('be.visible')
|
||||
cy.cs('FacultyMastersProgressTable').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Graduated toggle', () => {
|
||||
@@ -257,14 +255,14 @@ describe('Faculty statistics', () => {
|
||||
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.get('[data-cy=AverageGraduationTimesExportButton]').should('be.visible').and('not.be.disabled').click()
|
||||
cy.cs('average-graduation-times-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_graduation_times_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it('Info boxes', () => {
|
||||
cy.get('[data-cy=AverageGraduationTimesInfoBoxButton]').click()
|
||||
cy.get('[data-cy=AverageGraduationTimesInfoBoxContent]').contains('Opiskelijoiden keskimääräiset')
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset')
|
||||
})
|
||||
|
||||
it.skip('Study times toggle', () => {
|
||||
@@ -280,10 +278,10 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Graphs are visible', () => {
|
||||
cy.get('[data-cy="bachelorGraduationTimes"]').should('be.visible')
|
||||
cy.cs('bachelor-graduation-times-section').should('be.visible')
|
||||
// ! No test data for bachelor + master
|
||||
cy.get('[data-cy="masterGraduationTimes"]').should('be.visible')
|
||||
cy.get('[data-cy="doctorGraduationTimes"]').should('be.visible')
|
||||
cy.cs('master-graduation-times-section').should('be.visible')
|
||||
cy.cs('doctor-graduation-times-section').should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+83
-35
@@ -48,23 +48,23 @@ const testRangeFilter = (parentEl, min, max, expected) => {
|
||||
// functions don't take any parameters and using global object for matching test step name seemed overcomplicated.
|
||||
const createRunTestStepWithPreAndPostPartsFunction = amountWithoutFiltering => {
|
||||
return (filterName, testStepFunctionToRun) => {
|
||||
const card = cy.cs(`${filterName}-filter-card`)
|
||||
cy.cs(`${filterName}-filter-card`)
|
||||
.invoke('attr', 'data-open')
|
||||
.then(open => {
|
||||
const getHeader = () => cy.cs(`${filterName}-header`)
|
||||
|
||||
card.invoke('attr', 'data-open').then(open => {
|
||||
const getHeader = () => cy.cs(`${filterName}-header`)
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
testStepFunctionToRun()
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
testStepFunctionToRun()
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
})
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +79,9 @@ describe("Population statistics with a master's programme", () => {
|
||||
|
||||
it('Study track filter works', () => {
|
||||
runTestStepWithPreAndPostParts('StudyTrack', () => {
|
||||
const card = cy.cs('StudyTrack-filter-card')
|
||||
const programmeDropdown = card.cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
|
||||
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(15)
|
||||
programmeDropdown.get('i.delete').click()
|
||||
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').get('i.delete').click()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,8 +92,7 @@ describe("Population statistics with a master's programme", () => {
|
||||
|
||||
it('is set to all by default', () => {
|
||||
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
|
||||
const card = cy.cs('studyRightTypeFilter-filter-card')
|
||||
card.get('[data-cy="all"] input').should('be.checked')
|
||||
cy.cs('studyRightTypeFilter-filter-card').get('[data-cy="all"] input').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -127,8 +125,7 @@ describe('Population Statistics', () => {
|
||||
cy.contains('By default only students who have not transferred to this study programme are shown.')
|
||||
|
||||
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
|
||||
const card = cy.cs('TransferredToProgramme-filter-card')
|
||||
card.get('[data-cy="option-havenot"] input').should('be.checked')
|
||||
cy.cs('TransferredToProgramme-filter-card').get('[data-cy="option-havenot"] input').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -219,15 +216,19 @@ describe('Population Statistics', () => {
|
||||
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
|
||||
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
|
||||
]
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
checkFilteringResult(10)
|
||||
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1) // Passed students
|
||||
checkFilteringResult(8)
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
checkFilteringResult(6)
|
||||
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(1)
|
||||
checkFilteringResult(5)
|
||||
courses.forEach(({ code }) => cy.cs(`courseFilter-${code}-clear`).click())
|
||||
courses.forEach(({ code }) => {
|
||||
cy.cs(`courseFilter-${code}-clear`).click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -259,6 +260,50 @@ describe('Population Statistics', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Population Statistics with Bachelor + Master', () => {
|
||||
const pathToMathBSc2017 =
|
||||
'/populations?months=91&semesters=FALL&semesters=SPRING&showBachelorAndMaster=true&studyRights=%7B%22programme%22%3A%22KH50_001%22%2C%22combinedProgramme%22%3A%22%22%7D&year=2017'
|
||||
const defaultAmountOfStudents = 47
|
||||
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
|
||||
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2017)
|
||||
})
|
||||
|
||||
it('Graduation filter works', () => {
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
|
||||
|
||||
getCard().cs('option-graduated-bachelor').click()
|
||||
checkFilteringResult(42)
|
||||
getCard().cs('option-graduated-master').click()
|
||||
checkFilteringResult(18)
|
||||
getCard().cs('option-not-graduated-bachelor').click()
|
||||
checkFilteringResult(5)
|
||||
getCard().cs('option-not-graduated-master').click()
|
||||
checkFilteringResult(29)
|
||||
getCard().cs('option-all').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('Study right status filter works', () => {
|
||||
runTestStepWithPreAndPostParts('studyRightStatusFilter', () => {
|
||||
const getCard = () => cy.cs('studyRightStatusFilter-filter-card')
|
||||
|
||||
// First two are 0 because there are no semester enrollments for the current semester
|
||||
getCard().cs('option-active').click()
|
||||
checkFilteringResult(0)
|
||||
getCard().cs('option-active-combined').click()
|
||||
checkFilteringResult(0)
|
||||
getCard().cs('option-inactive').click()
|
||||
checkFilteringResult(5)
|
||||
getCard().cs('option-inactive-combined').click()
|
||||
checkFilteringResult(24)
|
||||
getCard().cs('option-activity-status-all').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Course Statistics', () => {
|
||||
const pathToLimits2021 =
|
||||
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats&years=2021-2022'
|
||||
@@ -288,10 +333,9 @@ describe('Course Statistics', () => {
|
||||
|
||||
it('Programme filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
const card = cy.cs('Programme-filter-card')
|
||||
const programmeDropdown = card.cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(33)
|
||||
programmeDropdown.get('i.delete').click()
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -349,9 +393,10 @@ describe('Custom Population Statistics', () => {
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
|
||||
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
|
||||
runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(students.length)
|
||||
cy.cs('student-number-input').click().type(students.join('\n'))
|
||||
cy.cs('student-number-input').click()
|
||||
cy.cs('student-number-input').type(students.join('{enter}'))
|
||||
cy.cs('search-button').click()
|
||||
})
|
||||
cy.cs('search-button').click()
|
||||
})
|
||||
|
||||
it('Age filter works', () => {
|
||||
@@ -369,10 +414,9 @@ describe('Custom Population Statistics', () => {
|
||||
|
||||
it('Programme filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
const card = cy.cs('Programme-filter-card')
|
||||
const programmeDropdown = card.cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(3)
|
||||
programmeDropdown.get('i.delete').click()
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -402,15 +446,19 @@ describe('Custom Population Statistics', () => {
|
||||
{ code: 'MAT11005', name: 'Integraalilaskenta' },
|
||||
{ code: 'MAT21007', name: 'Mitta ja integraali' },
|
||||
]
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
checkFilteringResult(7)
|
||||
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1)
|
||||
checkFilteringResult(6)
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
checkFilteringResult(5)
|
||||
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(3)
|
||||
checkFilteringResult(2)
|
||||
courses.forEach(({ code }) => cy.cs(`courseFilter-${code}-clear`).click())
|
||||
courses.forEach(({ code }) => {
|
||||
cy.cs(`courseFilter-${code}-clear`).click()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,19 +3,20 @@
|
||||
|
||||
const selectStudyProgramme = programme => {
|
||||
cy.contains('Select study programme')
|
||||
cy.get('[data-cy=select-study-programme]').click().children().contains(programme).click()
|
||||
cy.get('[data-cy=select-study-programme]').click()
|
||||
cy.get('[data-cy=select-study-programme]').children().contains(programme).click()
|
||||
}
|
||||
|
||||
const selectStudyTrack = studyTrack => {
|
||||
cy.contains('Select study track')
|
||||
cy.get('[data-cy=select-study-track]').click().children().contains(studyTrack).click()
|
||||
cy.get('[data-cy=select-study-track]').click()
|
||||
cy.get('[data-cy=select-study-track]').children().contains(studyTrack).click()
|
||||
}
|
||||
|
||||
describe('Population statistics tests', () => {
|
||||
const getPath = programme => {
|
||||
return `/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"${programme}"%7D&year=2020`
|
||||
}
|
||||
|
||||
const pathToMathBSc2020 = getPath('KH50_001')
|
||||
const pathToMathMSc2020 = getPath('MH50_001')
|
||||
|
||||
@@ -90,90 +91,6 @@ describe('Population statistics tests', () => {
|
||||
cy.cs('filtered-students')
|
||||
})
|
||||
|
||||
it('Courses of class is displayed and link to individual course stats page works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
cy.get('[data-cy=toggle-group-module-MAT110]').click()
|
||||
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
|
||||
cy.wait('@coursePage')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT11001 Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('AYMAT11001 Avoin yo: Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('57033 Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('A57033 Avoin yo: Johdatus yliopistomatematiikkaan')
|
||||
})
|
||||
|
||||
it('Courses of class curriculum selection works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.get('[data-cy=curriculum-picker]').contains('2020–2023')
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
|
||||
})
|
||||
|
||||
it('New fetch of courses data is done when curriculum is changed', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v2/populationstatistics/courses').as('courseData')
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2020–2023').click({ force: true })
|
||||
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(true)
|
||||
})
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('New fetch of courses data is done when filtered students change', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v2/populationstatistics/courses').as('courseData')
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
})
|
||||
cy.get('[data-cy=GraduatedFromProgramme-filter-card]').within(() => {
|
||||
cy.get('[data-cy=GraduatedFromProgramme-header]').click()
|
||||
cy.get('[data-cy=option-graduated-true]').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(16)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("Empty 'tags' tab has a link to the page where tags can be created", { retries: 2 }, () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Students (27)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
|
||||
})
|
||||
cy.get('[data-cy=student-table-tabs]').within(() => {
|
||||
cy.contains('Tags').click()
|
||||
})
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?p_m_tab=0&p_tab=4')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Create tags for study programme')
|
||||
cy.contains('button', 'Create a new tag').should('be.disabled')
|
||||
})
|
||||
|
||||
// This test sometimes fails on headless mode. It seems that the click on the
|
||||
// 'Fetch class with new settings' button doesn't always trigger history.push()
|
||||
// so the page doesn't reload. This is why waiting also doesn't help.
|
||||
@@ -200,121 +117,248 @@ describe('Population statistics tests', () => {
|
||||
cy.url().should('include', 'semesters=FALL&semesters=SPRING')
|
||||
})
|
||||
|
||||
it("'Credits gained' tab of 'Credit statistics' shows correct statistics for all students of the class and also students grouped by admission type", () => {
|
||||
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
|
||||
cy.visit(pathToMathBSc2020)
|
||||
const totalStudents = 27
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.contains('Credits gained').click()
|
||||
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the class')
|
||||
const limits = [1, 45, 90, 135, 180, null]
|
||||
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
|
||||
|
||||
const getTableData = (selector, numbersOfStudents) => {
|
||||
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
|
||||
return ranges.map((range, index) => ({
|
||||
selector: `[data-cy='credits-gained-table-${selector}']`,
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
students: numbersOfStudents[index],
|
||||
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
|
||||
}))
|
||||
}
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-All students of the class'] table thead tr").within(() => {
|
||||
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024 (49 months)')
|
||||
cy.get('th').eq(2).contains('Number of students')
|
||||
cy.get('th').eq(2).contains(`(n = ${totalStudents})`)
|
||||
cy.get('th').eq(3).contains('Percentage of population')
|
||||
})
|
||||
|
||||
for (const category of [
|
||||
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
|
||||
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
|
||||
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
|
||||
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
|
||||
]) {
|
||||
category.forEach(({ selector, start, end, students, percentage }, index) => {
|
||||
let value
|
||||
if (start === null) {
|
||||
value = '0'
|
||||
} else if (end === null) {
|
||||
value = `${start} ≤ credits`
|
||||
} else {
|
||||
value = `${start} ≤ credits < ${end}`
|
||||
}
|
||||
cy.get(`${selector} [data-cy='credits-gained-table-body']`).within(() => {
|
||||
cy.get('tr')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').eq(1).contains(value)
|
||||
cy.get('td').eq(2).contains(students)
|
||||
cy.get('td').eq(3).contains(percentage)
|
||||
})
|
||||
describe('Credit statistics', () => {
|
||||
it("'Credits gained' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
|
||||
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
|
||||
cy.visit(pathToMathBSc2020)
|
||||
const totalStudents = 27
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.contains('Credits gained').click()
|
||||
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the class')
|
||||
const limits = [1, 45, 90, 135, 180, null]
|
||||
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
|
||||
|
||||
const getTableData = (selector, numbersOfStudents) => {
|
||||
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
|
||||
return ranges.map((range, index) => ({
|
||||
selector: `[data-cy='credits-gained-table-${selector}']`,
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
students: numbersOfStudents[index],
|
||||
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
|
||||
}))
|
||||
}
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-All students of the class'] table thead tr").within(() => {
|
||||
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024 (49 months)')
|
||||
cy.get('th').eq(2).contains('Number of students')
|
||||
cy.get('th').eq(2).contains(`(n = ${totalStudents})`)
|
||||
cy.get('th').eq(3).contains('Percentage of population')
|
||||
})
|
||||
}
|
||||
|
||||
cy.get(
|
||||
"[data-cy='credits-gained-table-All students of the class'] [data-cy='credits-gained-table-body'] td:nth-child(3)"
|
||||
).then(tds => {
|
||||
const sum = [...tds].reduce((acc, td) => acc + parseInt(td.innerText, 10), 0)
|
||||
expect(sum).to.equal(totalStudents)
|
||||
})
|
||||
})
|
||||
|
||||
it("'Statistics' tab of 'Credit statistics' shows correct statistics for all students of the class and also students grouped by admission type", () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
|
||||
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
|
||||
const categories = [
|
||||
{
|
||||
selector: 'All students of the population',
|
||||
data: ['3773.50', '139.76', '141.00', '60.07', '19', '268'],
|
||||
size: 27,
|
||||
},
|
||||
{
|
||||
selector: 'Muu',
|
||||
data: ['359.00', '119.67', '111.00', '43.71', '71', '177'],
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
selector: 'Todistusvalinta',
|
||||
data: ['2742.50', '152.36', '178.00', '65.75', '19', '268'],
|
||||
size: 18,
|
||||
},
|
||||
{
|
||||
selector: 'Avoin väylä',
|
||||
data: ['672.00', '112.00', '104.00', '29.40', '82', '169'],
|
||||
size: 6,
|
||||
},
|
||||
]
|
||||
|
||||
for (const { selector, data, size } of categories) {
|
||||
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
|
||||
cy.contains('h3', selector)
|
||||
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
|
||||
cy.get('table tbody').within(() => {
|
||||
rows.forEach((text, index) => {
|
||||
for (const category of [
|
||||
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
|
||||
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
|
||||
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
|
||||
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
|
||||
]) {
|
||||
category.forEach(({ selector, start, end, students, percentage }, index) => {
|
||||
let value
|
||||
if (start === null) {
|
||||
value = '0'
|
||||
} else if (end === null) {
|
||||
value = `${start} ≤ credits`
|
||||
} else {
|
||||
value = `${start} ≤ credits < ${end}`
|
||||
}
|
||||
cy.get(`${selector} [data-cy='credits-gained-table-body']`).within(() => {
|
||||
cy.get('tr')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains(text)
|
||||
cy.get('td').eq(1).contains(data[index])
|
||||
cy.get('td').eq(1).contains(value)
|
||||
cy.get('td').eq(2).contains(students)
|
||||
cy.get('td').eq(3).contains(percentage)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
cy.get(
|
||||
"[data-cy='credits-gained-table-All students of the class'] [data-cy='credits-gained-table-body'] td:nth-child(3)"
|
||||
).then(tds => {
|
||||
const sum = [...tds].reduce((acc, td) => acc + parseInt(td.innerText, 10), 0)
|
||||
expect(sum).to.equal(totalStudents)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it("'Statistics' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
|
||||
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
|
||||
const categories = [
|
||||
{
|
||||
selector: 'All students of the population',
|
||||
data: ['3773.50', '139.76', '141.00', '60.07', '19', '268'],
|
||||
size: 27,
|
||||
},
|
||||
{
|
||||
selector: 'Muu',
|
||||
data: ['359.00', '119.67', '111.00', '43.71', '71', '177'],
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
selector: 'Todistusvalinta',
|
||||
data: ['2742.50', '152.36', '178.00', '65.75', '19', '268'],
|
||||
size: 18,
|
||||
},
|
||||
{
|
||||
selector: 'Avoin väylä',
|
||||
data: ['672.00', '112.00', '104.00', '29.40', '82', '169'],
|
||||
size: 6,
|
||||
},
|
||||
]
|
||||
|
||||
for (const { selector, data, size } of categories) {
|
||||
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
|
||||
cy.contains('h3', selector)
|
||||
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
|
||||
cy.get('table tbody').within(() => {
|
||||
rows.forEach((text, index) => {
|
||||
cy.get('tr')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains(text)
|
||||
cy.get('td').eq(1).contains(data[index])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Courses of class', () => {
|
||||
it('Is displayed and link to individual course stats page works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
cy.get('[data-cy=toggle-group-module-MAT110]').click()
|
||||
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
|
||||
cy.wait('@coursePage')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('AYMAT11001 • Avoin yo: Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('57033 • Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('A57033 • Avoin yo: Johdatus yliopistomatematiikkaan')
|
||||
})
|
||||
|
||||
it('Curriculum selection works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.get('[data-cy=curriculum-picker]').contains('2020–2023')
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
|
||||
})
|
||||
|
||||
it('New fetch of courses data is done when curriculum is changed', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v2/populationstatistics/courses').as('courseData')
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2020–2023').click({ force: true })
|
||||
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(true)
|
||||
})
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('New fetch of courses data is done when filtered students change', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v2/populationstatistics/courses').as('courseData')
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
})
|
||||
cy.get('[data-cy=GraduatedFromProgramme-filter-card]').within(() => {
|
||||
cy.get('[data-cy=GraduatedFromProgramme-header]').click()
|
||||
cy.get('[data-cy=option-graduated-true]').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(16)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Students', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Students (27)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
|
||||
})
|
||||
})
|
||||
|
||||
it("'General tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('522142')
|
||||
cy.contains('Tilastotiede')
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('Teoreettisten ja laskennallisten menetelmien maisteriohjelma')
|
||||
cy.contains('Todistusvalinta')
|
||||
})
|
||||
})
|
||||
|
||||
it("'Courses tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Courses').click()
|
||||
cy.contains('MAT12001')
|
||||
cy.contains('MAT21001')
|
||||
})
|
||||
})
|
||||
|
||||
it("'Modules tab' Displays correct modules based on the selected programme", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Modules').click()
|
||||
cy.contains('MAT011')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('MAT011').should('not.exist')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
})
|
||||
|
||||
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Tags').click()
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
})
|
||||
cy.url().should('include', '/study-programme/KH50_001?tab=4')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Create tags for study programme')
|
||||
cy.contains('button', 'Create a new tag').should('be.disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -332,7 +376,9 @@ describe('Population statistics tests', () => {
|
||||
cy.contains(nonExisting).should('not.exist')
|
||||
cy.contains('button', 'Check student numbers').click()
|
||||
cy.contains('Check for student numbers')
|
||||
cy.get('textarea').type(existing).type('{enter}').type(nonExisting)
|
||||
cy.get('textarea').type(existing)
|
||||
cy.get('textarea').type('{enter}')
|
||||
cy.get('textarea').type(nonExisting)
|
||||
cy.contains('button', 'Check students').click()
|
||||
cy.contains('#checkstudentsresults', 'Results').within(() => {
|
||||
cy.contains('Student numbers in list and in Sisu').click()
|
||||
|
||||
+65
-48
@@ -1,15 +1,15 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const student = {
|
||||
firstnames: 'Varpu Roope',
|
||||
lastname: 'Mårtensson',
|
||||
studentnumber: '550003',
|
||||
sis_person_id: 'hy-hlo-115926826',
|
||||
firstNames: 'Varpu Roope',
|
||||
lastName: 'Mårtensson',
|
||||
studentNumber: '550003',
|
||||
sisPersonId: 'hy-hlo-115926826',
|
||||
email: 'sisutestidata134902@testisisudata.fi',
|
||||
}
|
||||
|
||||
const typeToSearch = text => {
|
||||
cy.get("input[placeholder='Search with a student number or name (surname firstname)']").type(text)
|
||||
cy.cs('student-search').type(text)
|
||||
}
|
||||
|
||||
const typeStudentNumberAndClick = studentNumber => {
|
||||
@@ -17,10 +17,6 @@ const typeStudentNumberAndClick = studentNumber => {
|
||||
cy.contains('td', studentNumber).click()
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
cy.get("input[placeholder='Search with a student number or name (surname firstname)']").clear()
|
||||
}
|
||||
|
||||
describe('Students tests', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
@@ -31,65 +27,84 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it('Students search form is usable', () => {
|
||||
typeToSearch(student.lastname)
|
||||
typeToSearch(student.lastName)
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name').should('not.exist')
|
||||
cy.contains('First names').should('not.exist')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.firstnames)
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name')
|
||||
cy.contains('First names')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains(student.lastName)
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name').should('not.exist')
|
||||
cy.contains('First names').should('not.exist')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
})
|
||||
|
||||
it('Search term must be at least 4 characters long', () => {
|
||||
typeToSearch(student.lastname.slice(0, 3))
|
||||
typeToSearch(student.lastName.slice(0, 3))
|
||||
cy.contains('Search term is not accurate enough')
|
||||
typeToSearch(student.lastname.slice(3))
|
||||
typeToSearch(student.lastName.slice(3))
|
||||
cy.get('table tbody tr').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('Can search with studentnumber too', () => {
|
||||
typeToSearch(student.studentnumber)
|
||||
cy.contains(student.studentnumber)
|
||||
it('Can search with student number too', () => {
|
||||
typeToSearch(student.studentNumber)
|
||||
cy.contains(student.studentNumber)
|
||||
})
|
||||
|
||||
it('Can get student specific page by clicking student', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma (01.08.2020–31.07.2027)')
|
||||
cy.contains(student.lastname).should('not.exist')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.email).should('not.exist')
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.lastname)
|
||||
cy.contains(student.firstnames)
|
||||
cy.contains(student.lastName)
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains(student.email)
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.lastname).should('not.exist')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.email).should('not.exist')
|
||||
})
|
||||
|
||||
it("'Update student' button is not shown", () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('[data-cy=student-info-card]').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('button', 'Update student').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('Can get back to search menu', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.go('back')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
cy.contains('Credits').should('not.exist')
|
||||
})
|
||||
|
||||
it('Can jump to course', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('Tilastollinen päättely I (MAT12004)')
|
||||
.parent()
|
||||
.siblings()
|
||||
@@ -98,26 +113,26 @@ describe('Students tests', () => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT12004 Tilastollinen päättely I')
|
||||
cy.contains('AYMAT12004 Avoin yo: Tilastollinen päättely I')
|
||||
cy.contains('57046 Johdatus tilastolliseen päättelyyn')
|
||||
cy.contains('MAT12004 • Tilastollinen päättely I')
|
||||
cy.contains('AYMAT12004 • Avoin yo: Tilastollinen päättely I')
|
||||
cy.contains('57046 • Johdatus tilastolliseen päättelyyn')
|
||||
})
|
||||
|
||||
it('Has correct Sisu link', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('[data-cy=sisu-link]')
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('sisu-link')
|
||||
.should('have.attr', 'href')
|
||||
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sis_person_id}/basic/basic-info`)
|
||||
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sisPersonId}/basic/basic-info`)
|
||||
})
|
||||
|
||||
it('Semester enrollments can be toggled', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
const programmes = [
|
||||
'Oikeustieteen tohtoriohjelma',
|
||||
'Matemaattisten tieteiden kandiohjelma',
|
||||
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
|
||||
]
|
||||
cy.get('[data-cy=student-info-card]').within(() => {
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('Enrollments').click()
|
||||
cy.get('table tbody tr')
|
||||
.should('have.length', 3)
|
||||
@@ -129,17 +144,19 @@ describe('Students tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("Searching with bad inputs doesn't yield results", () => {
|
||||
it("Searching with bad name doesn't yield results", () => {
|
||||
typeToSearch('SWAG LITTINEN')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
clearSearch()
|
||||
})
|
||||
|
||||
it("Searching with bad student number doesn't yield results", () => {
|
||||
typeToSearch('01114')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
})
|
||||
|
||||
it('Can jump to population page', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('[data-cy=study-rights-section]').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('study-rights-section').within(() => {
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
.parent()
|
||||
.within(() => {
|
||||
@@ -151,16 +168,16 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it('Grade graph works in all three different modes', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('button', 'Grade graph').click()
|
||||
cy.contains('button', 'Show group mean').click()
|
||||
cy.get('[data-cy=group-size-input]').within(() => {
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').should('have.value', '5')
|
||||
})
|
||||
cy.contains('.highcharts-container text', "Nov '22")
|
||||
cy.contains('.highcharts-container text', "Jul '22").should('not.exist')
|
||||
cy.get('[data-cy=group-size-input]').within(() => {
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').clear()
|
||||
cy.get('input').type('10')
|
||||
@@ -215,7 +232,7 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it("Shows 'Has not graduated' when the student has not graduated", () => {
|
||||
cy.visit(`students/${student.studentnumber}`)
|
||||
cy.visit(`students/${student.studentNumber}`)
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Has not graduated')
|
||||
@@ -229,14 +246,14 @@ describe('Students tests', () => {
|
||||
cy.init('/students', 'admin')
|
||||
})
|
||||
|
||||
it('Does not crash if student has no studyright or courses', () => {
|
||||
it('Does not crash if student has no study rights or courses', () => {
|
||||
typeStudentNumberAndClick('450730')
|
||||
cy.contains('Credits: 0')
|
||||
})
|
||||
|
||||
it("'Update student' button is shown", () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('[data-cy=student-info-card]').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('button', 'Update student')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('Study guidance group tests', () => {
|
||||
cy.cs('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
|
||||
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
|
||||
expect(tabNames).to.deep.equal(['General', 'Courses', 'Progress'])
|
||||
expect(tabNames).to.deep.equal(['General', 'Courses', 'Modules', 'Progress'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+125
-100
@@ -10,8 +10,34 @@ const deleteTag = name => {
|
||||
}
|
||||
|
||||
describe('Study programme overview', () => {
|
||||
/* Basic information overview -tests */
|
||||
describe('Basic information view works for basic user', () => {
|
||||
describe('Study programme selector', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme', 'admin')
|
||||
})
|
||||
|
||||
it('Correct categories', () => {
|
||||
cy.contains('Bachelor programmes')
|
||||
cy.contains('Master programmes')
|
||||
cy.contains('Combined programmes')
|
||||
cy.contains('Doctoral programmes')
|
||||
})
|
||||
|
||||
it('Study programme search filter', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').should('exist')
|
||||
cy.cs('study-programme-filter').type('Tietojenkäsittelytieteen')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').should('not.exist')
|
||||
})
|
||||
|
||||
it('Old and specialized programmes filter', () => {
|
||||
cy.contains('Biotekniikka (ylempi)').should('not.exist')
|
||||
cy.cs('filter-old-programmes-toggle').click()
|
||||
cy.contains('Biotekniikka (ylempi)').should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic information tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
@@ -19,11 +45,11 @@ describe('Study programme overview', () => {
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Basic information tab loads', () => {
|
||||
cy.get('[data-cy=Section-studentsOfTheStudyProgramme]')
|
||||
cy.get('[data-cy=Section-creditsProducedByTheStudyProgramme]')
|
||||
cy.get('[data-cy=Section-graduatedAndThesisWritersOfTheProgramme]')
|
||||
cy.get('[data-cy=Section-programmesBeforeOrAfter]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimes]')
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
|
||||
cy.cs('programmes-before-or-after-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it('Basic information contains correct students', () => {
|
||||
@@ -40,7 +66,7 @@ describe('Study programme overview', () => {
|
||||
[2017, 41, 47, 0, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudentsOfTheStudyProgramme')
|
||||
cy.checkTableStats(tableContents, 'students-of-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct credits', () => {
|
||||
@@ -56,11 +82,11 @@ describe('Study programme overview', () => {
|
||||
[2017, 1211, 1211, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it("Toggling 'Show special categories' on displays additional information", () => {
|
||||
cy.contains('Show special categories').click()
|
||||
cy.cs('special-categories-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
|
||||
@@ -73,7 +99,7 @@ describe('Study programme overview', () => {
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct thesis writers and graduates', () => {
|
||||
@@ -81,20 +107,20 @@ describe('Study programme overview', () => {
|
||||
const tableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 26, 0],
|
||||
[2022, 48, 0],
|
||||
[2021, 47, 66],
|
||||
[2020, 12, 18],
|
||||
[2019, 1, 15],
|
||||
[2023, 26, 5],
|
||||
[2022, 48, 13],
|
||||
[2021, 47, 76],
|
||||
[2020, 12, 23],
|
||||
[2019, 1, 19],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
cy.checkTableStats(tableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
it('Special study rights can be excluded and basic data changes accordingly', () => {
|
||||
cy.get('[data-cy=StudentToggle]').click()
|
||||
cy.cs('study-right-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated]
|
||||
@@ -108,25 +134,25 @@ describe('Study programme overview', () => {
|
||||
[2017, 41, 47, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const graduatedTableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 24, 0],
|
||||
[2022, 44, 0],
|
||||
[2021, 46, 60],
|
||||
[2020, 11, 17],
|
||||
[2019, 1, 15],
|
||||
[2023, 24, 5],
|
||||
[2022, 44, 12],
|
||||
[2021, 46, 69],
|
||||
[2020, 11, 22],
|
||||
[2019, 1, 18],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(graduatedTableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
cy.checkTableStats(graduatedTableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
it('Year can be changed to academic year, and data changes accordingly', () => {
|
||||
cy.get('[data-cy=YearToggle]').click({ force: true })
|
||||
cy.cs('year-toggle').click()
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const studentTableContents = [
|
||||
@@ -140,7 +166,7 @@ describe('Study programme overview', () => {
|
||||
['2018 - 2019', 40, 45, 0, 0, 0],
|
||||
['2017 - 2018', 41, 47, 0, 0, 0],
|
||||
]
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
@@ -153,33 +179,33 @@ describe('Study programme overview', () => {
|
||||
['2017 - 2018', 2350, 2345, 0, 5, 26],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
cy.get('[data-cy=YearToggle]').click()
|
||||
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
|
||||
cy.cs('year-toggle').click()
|
||||
})
|
||||
|
||||
it('Basic information graphs render', () => {
|
||||
cy.get('[data-cy=Graph-StudentsOfTheStudyProgramme')
|
||||
cy.cs('students-of-the-study-programme-line-graph-section')
|
||||
.should('contain', 'Started studying')
|
||||
.should('contain', 'Accepted')
|
||||
.should('contain', 'Graduated')
|
||||
.should('contain', 'Transferred away')
|
||||
.should('contain', 'Transferred to')
|
||||
|
||||
cy.get('[data-cy=Graph-CreditsProducedByTheStudyProgramme')
|
||||
cy.cs('credits-produced-by-the-study-programme-stacked-bar-chart-section')
|
||||
.should('contain', 'Degree students')
|
||||
.should('contain', 'Transferred')
|
||||
.should('contain', 5796)
|
||||
.should('contain', 428)
|
||||
|
||||
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
|
||||
.should('contain', 'Graduated students')
|
||||
.should('contain', 'Wrote thesis')
|
||||
.should('contain', 47)
|
||||
.should('contain', 66)
|
||||
.should('contain', 76)
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
|
||||
cy.cs('bachelor-breakdown-bar-chart-section')
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2022')
|
||||
cy.contains('48 graduated').trigger('mouseover')
|
||||
@@ -190,7 +216,7 @@ describe('Study programme overview', () => {
|
||||
cy.contains('14 graduated over year late')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter')
|
||||
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
|
||||
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
|
||||
.should('contain', 'Datatieteen maisteriohjelma')
|
||||
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
@@ -205,11 +231,11 @@ describe('Study programme overview', () => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownMaster]')
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
|
||||
cy.cs('bachelor-breakdown-bar-chart-section')
|
||||
cy.cs('master-breakdown-bar-chart-section')
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get('[data-cy=graduation-times-graphMaster]').within(() => {
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('master-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2021')
|
||||
@@ -221,7 +247,7 @@ describe('Study programme overview', () => {
|
||||
cy.contains('0 graduated over year late')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
|
||||
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Bachelor + master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2023')
|
||||
@@ -235,19 +261,18 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/* Study track overview tests */
|
||||
describe('Study track overview works for basic user', () => {
|
||||
describe('Study tracks and class statistics tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Study tracks and class statistics').click()
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Study tracks and class statistics -tab loads', () => {
|
||||
cy.get('[data-cy=Section-studyTrackOverview]')
|
||||
cy.get('[data-cy=Section-studyTrackProgress]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimesStudyTracks]')
|
||||
it('Study tracks and class statistics', () => {
|
||||
cy.cs('Section-studyTrackOverview')
|
||||
cy.cs('Section-studyTrackProgress')
|
||||
cy.cs('Section-averageGraduationTimesStudyTracks')
|
||||
})
|
||||
|
||||
it('Students of the study programme are shown correctly', () => {
|
||||
@@ -263,11 +288,11 @@ describe('Study programme overview', () => {
|
||||
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 224, 12],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudyTrackOverview')
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
it('Years in the students table can be expanded and study track data will be shown', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
@@ -302,7 +327,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('Links to class statistics page work', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
@@ -315,7 +340,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('Links to class statistics page with all years combined work', { retries: 2 }, () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('td.total-row-cell a').click()
|
||||
})
|
||||
|
||||
@@ -324,7 +349,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
@@ -357,20 +382,20 @@ describe('Study programme overview', () => {
|
||||
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudyTrackProgress')
|
||||
cy.checkTableStats(tableContents, 'study-track-progress-basic')
|
||||
})
|
||||
|
||||
it('Study track overview graphs render', () => {
|
||||
cy.get('[data-cy=Graph-StudyTrackProgress]')
|
||||
cy.cs('study-track-progress-bar-chart')
|
||||
.should('contain', 'Less than 30 credits')
|
||||
.should('contain', '30–60 credits')
|
||||
.should('contain', 'At least 180 credits')
|
||||
.should('contain', '49.8%') // The percentage for total, at least 180 credits, to check that the graph renders
|
||||
|
||||
cy.get('[data-cy=Graph-StudyTrackProgress]').contains('49.8%').trigger('mouseover', { force: true })
|
||||
cy.cs('study-track-progress-bar-chart').contains('49.8%').trigger('mouseover', { force: true })
|
||||
cy.contains('At least 180 credits: 114')
|
||||
|
||||
cy.get("[data-cy='Section-KH50_001']").within(() => {
|
||||
cy.cs('KH50_001-section').within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 15. On time."]').trigger('mouseover')
|
||||
@@ -385,9 +410,9 @@ describe('Study programme overview', () => {
|
||||
cy.contains('On time: 14')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
|
||||
cy.get("[data-cy='Section-KH50_001']").within(() => {
|
||||
cy.cs('KH50_001-section').within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('19 graduated').trigger('mouseover')
|
||||
@@ -406,9 +431,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('Students of the study track are shown correctly', () => {
|
||||
cy.get("[data-cy='Section-studyTrackOverview']").contains(
|
||||
'Students of the study track MAT-MAT by starting year'
|
||||
)
|
||||
cy.cs('Section-studyTrackOverview').contains('Students of the study track MAT-MAT by starting year')
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2022 - 2023', 3, 3, 0, 0, 1, 2, 2, 1, 0, 3, 0],
|
||||
@@ -419,11 +442,11 @@ describe('Study programme overview', () => {
|
||||
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 88, 4],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'StudyTrackOverview')
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('tbody tr.regular-row')
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
@@ -445,7 +468,7 @@ describe('Study programme overview', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('Average graduation times are displayed correctly', () => {
|
||||
it.skip('Average graduation times are displayed correctly', () => {
|
||||
cy.get("[data-cy='Section-averageGraduationTimesStudyTracks']")
|
||||
cy.get("[data-cy='graduation-times-graph-breakdownBachelor']").within(() => {
|
||||
cy.contains('Start year')
|
||||
@@ -454,8 +477,8 @@ describe('Study programme overview', () => {
|
||||
cy.contains('Graduated On time: 9 students')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get("[data-cy='graduation-times-graphBachelor']").within(() => {
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('bachelor-median-time-bar-chart').within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('12 graduated').trigger('mouseover')
|
||||
@@ -469,17 +492,16 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/* Programme courses -tests */
|
||||
describe('Programme courses works for basic user', () => {
|
||||
describe('Programme courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Programme courses').click()
|
||||
cy.cs('programme-courses-tab').click()
|
||||
})
|
||||
|
||||
it('content loads', () => {
|
||||
cy.get('[data-cy=CoursesYearFilter]')
|
||||
cy.get('[data-cy=CourseTabs]')
|
||||
cy.cs('CoursesYearFilter')
|
||||
cy.cs('CourseTabs')
|
||||
})
|
||||
|
||||
it('time range selection works', () => {
|
||||
@@ -492,8 +514,8 @@ describe('Study programme overview', () => {
|
||||
cy.get('td').eq(2).contains('75')
|
||||
})
|
||||
})
|
||||
cy.get('[data-cy=fromYear]').click().contains('2018').click()
|
||||
cy.get('[data-cy=toYear').click().contains('2019').click()
|
||||
cy.cs('fromYear').click().contains('2018').click()
|
||||
cy.cs('toYear').click().contains('2019').click()
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
@@ -507,9 +529,9 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it("'Calendar year/Academic year' toggle works", () => {
|
||||
cy.get('[data-cy=calendarAcademicYearToggle]').click()
|
||||
cy.get('[data-cy=fromYear]').contains('2017-2018')
|
||||
cy.get('[data-cy=toYear').contains('2023-2024')
|
||||
cy.cs('calendarAcademicYearToggle').click()
|
||||
cy.cs('fromYear').contains('2017-2018')
|
||||
cy.cs('toYear').contains('2023-2024')
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
@@ -522,14 +544,14 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('contains correct courses in alphabetical order', () => {
|
||||
cy.get('[data-cy=CoursesSortableTable]').within(() => {
|
||||
cy.cs('CoursesSortableTable').within(() => {
|
||||
cy.get('tr').eq(1).contains('Advanced calculus')
|
||||
cy.get('tr').eq(-1).contains('Äidinkielen opinnot')
|
||||
})
|
||||
})
|
||||
|
||||
it('different sorting options work', () => {
|
||||
cy.get('[data-cy=CoursesSortableTable]').within(() => {
|
||||
cy.cs('CoursesSortableTable').within(() => {
|
||||
// Course code
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
@@ -608,7 +630,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy=creditsStudentsToggle]').click()
|
||||
cy.cs('creditsStudentsToggle').click()
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] thead tr')
|
||||
.eq(0)
|
||||
@@ -657,16 +679,16 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Degree courses works for basic user', () => {
|
||||
describe('Degree courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Degree courses').click()
|
||||
cy.cs('degree-courses-tab').click()
|
||||
})
|
||||
|
||||
it('content loads', () => {
|
||||
cy.contains('h3', 'Select curriculum to edit:')
|
||||
cy.get('[data-cy=curriculum-picker]').contains('2023–2026')
|
||||
cy.cs('curriculum-picker').contains('2023–2026')
|
||||
cy.contains('Change visibility of degree courses and select criteria for academic years')
|
||||
cy.contains('form', 'First year (12 months) last set: 0')
|
||||
cy.get('table').within(() => {
|
||||
@@ -678,12 +700,11 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/* Tag-tests */
|
||||
describe('Tags view works for basic user', () => {
|
||||
describe('Tags tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.get('.attached').contains('Tags').click()
|
||||
cy.cs('tags-tab').click()
|
||||
})
|
||||
|
||||
it('can create and delete tags for population', () => {
|
||||
@@ -728,7 +749,9 @@ describe('Study programme overview', () => {
|
||||
|
||||
cy.contains('Successfully added tags to students.')
|
||||
|
||||
cy.contains('td', name).get('i.level.up.alternate.icon').click()
|
||||
cy.contains('td', name).within(() => {
|
||||
cy.get('i.level.up.alternate.icon').click()
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains(`Tagged with: ${name}`)
|
||||
@@ -749,7 +772,7 @@ describe('Study programme overview', () => {
|
||||
}
|
||||
|
||||
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?p_tab=4')
|
||||
cy.url().should('include', '/study-programme/KH50_001?tab=4')
|
||||
|
||||
deleteTag(name)
|
||||
})
|
||||
@@ -780,25 +803,27 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('can access basic information', () => {
|
||||
cy.contains('Basic information').click()
|
||||
cy.cs('basic-information-tab').click()
|
||||
|
||||
cy.get('[data-cy=Section-studentsOfTheStudyProgramme]')
|
||||
cy.get('[data-cy=Section-creditsProducedByTheStudyProgramme]')
|
||||
cy.get('[data-cy=Section-graduatedAndThesisWritersOfTheProgramme]')
|
||||
cy.get('[data-cy=Section-programmesBeforeOrAfter]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimes]')
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
cy.cs('programmes-before-or-after-section')
|
||||
})
|
||||
|
||||
it('can access study tracks', () => {
|
||||
cy.get('.attached').contains('Study tracks and class statistics').click()
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
|
||||
cy.get('[data-cy=Section-studyTrackOverview]')
|
||||
cy.get('[data-cy=Section-studyTrackProgress]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimesStudyTracks]')
|
||||
cy.cs('Section-studyTrackOverview')
|
||||
cy.cs('Section-studyTrackProgress')
|
||||
cy.cs('Section-averageGraduationTimesStudyTracks')
|
||||
})
|
||||
|
||||
it("doesn't see other tabs", () => {
|
||||
cy.get('div.ui.tabular.menu a').should('have.length', 2)
|
||||
cy.cs('degree-courses-tab').should('not.exist')
|
||||
cy.cs('tags-tab').should('not.exist')
|
||||
cy.cs('update-statistics-tab').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
+17
-17
@@ -1,26 +1,26 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const progressLevels = ['Bachelors', 'BachelorMasters', 'Masters', 'Doctoral']
|
||||
const progressLevels = ['bachelors', 'bachelor-masters', 'masters', 'doctoral']
|
||||
|
||||
const checkProgressBarCharts = () => {
|
||||
progressLevels.forEach(level => cy.get(`[data-cy=Faculty${level}ProgressBarChart]`))
|
||||
progressLevels.forEach(level => cy.cs(`faculty-${level}-progress-bar-chart-section`))
|
||||
}
|
||||
|
||||
const checkProgressTables = () => {
|
||||
progressLevels.forEach(level => cy.get(`[data-cy=Faculty${level}ProgressTable]`))
|
||||
progressLevels.forEach(level => cy.cs(`faculty-${level}-progress-table`))
|
||||
}
|
||||
|
||||
const graduationTimesLevels = ['bachelor', 'master', 'doctor'] // ? Bachelor + master seems to be missing in test data
|
||||
|
||||
const checkAverageGraduationTimesBreakdownBarCharts = () => {
|
||||
graduationTimesLevels.forEach(level => {
|
||||
cy.get(`[data-cy=${level}BreakdownBarChart]`)
|
||||
cy.cs(`${level}-breakdown-bar-chart`)
|
||||
})
|
||||
}
|
||||
|
||||
const checkAverageGraduationTimesMedianBarCharts = () => {
|
||||
graduationTimesLevels.forEach(level => {
|
||||
cy.get(`[data-cy=${level}MedianBarChart]`)
|
||||
cy.cs(`${level}-median-bar-chart`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,20 +40,20 @@ describe('University view', () => {
|
||||
})
|
||||
|
||||
it("'Graduated included / Graduated excluded' toggle works", () => {
|
||||
cy.get('[data-cy=GraduatedToggle]').click()
|
||||
cy.cs('graduated-toggle').click()
|
||||
checkProgressBarCharts()
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it("'All study rights / Special study rights excluded' toggle works", () => {
|
||||
cy.get('[data-cy=StudyRightToggle]').click()
|
||||
cy.cs('study-right-toggle').click()
|
||||
checkProgressBarCharts()
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it('years in the tables can be clicked to show faculty level breakdown', () => {
|
||||
cy.get('[data-cy=StudyRightToggle]').click()
|
||||
cy.get('[data-cy=FacultyBachelorsProgressTableShowButton3]').click()
|
||||
cy.cs('study-right-toggle').click()
|
||||
cy.cs('faculty-bachelors-progress-table-show-button3').click()
|
||||
cy.contains('29.5%').trigger('mouseover', { force: true })
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50')
|
||||
@@ -66,16 +66,16 @@ describe('University view', () => {
|
||||
})
|
||||
|
||||
it('info boxes contain correct information', () => {
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxContent]').contains('Kuvaa tiedekuntaan kuuluvien')
|
||||
cy.get('[data-cy=FacultyBachelorMastersProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyBachelorMastersProgressInfoBoxContent]').contains('The starting year is the')
|
||||
cy.cs('faculty-progress-info-box-button').click()
|
||||
cy.cs('faculty-progress-info-box-content').contains('Kuvaa tiedekuntaan kuuluvien')
|
||||
cy.cs('faculty-bachelor-masters-progress-info-box-button').click()
|
||||
cy.cs('faculty-bachelor-masters-progress-info-box-content').contains('The starting year is the')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Faculty graduations tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('[data-cy=FacultyGraduationsTab]').click()
|
||||
cy.cs('faculty-graduations-tab').click()
|
||||
})
|
||||
|
||||
it('has all the correct median time bar charts', () => {
|
||||
@@ -83,13 +83,13 @@ describe('University view', () => {
|
||||
})
|
||||
|
||||
it("'Breakdown/Median times' toggle works", () => {
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
checkAverageGraduationTimesMedianBarCharts()
|
||||
})
|
||||
|
||||
it('info boxes contain correct information', () => {
|
||||
cy.get('[data-cy=AverageGraduationTimesInfoBoxButton]').click()
|
||||
cy.get('[data-cy=AverageGraduationTimesInfoBoxContent]').contains('Opiskelijoiden keskimääräiset valmistumisajat')
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset valmistumisajat')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Progra
|
||||
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
|
||||
|
||||
const containsLinks = links => {
|
||||
cy.get('[data-cy=nav-bar]').within(() => {
|
||||
cy.cs('nav-bar').within(() => {
|
||||
for (const link of links) {
|
||||
cy.contains(link)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const containsLinks = links => {
|
||||
}
|
||||
|
||||
const userButtonWorks = (username, mocking = false) => {
|
||||
cy.get('[data-cy=nav-bar-user-button]').click()
|
||||
cy.cs('nav-bar-user-button').click()
|
||||
cy.contains(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)
|
||||
cy.contains('Language')
|
||||
cy.contains('suomi')
|
||||
@@ -61,10 +61,10 @@ describe('Users tests', () => {
|
||||
userButtonWorks('admin')
|
||||
})
|
||||
|
||||
describe('can mock as other users', () => {
|
||||
describe('can mock as other users', { retries: 3 }, () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('user-edit-button-basic').click()
|
||||
cy.get('i.spy').click()
|
||||
cy.cs('user-page-button-basic').click()
|
||||
cy.cs('mock-button').click()
|
||||
})
|
||||
|
||||
it('user button shows mocked user', () => {
|
||||
|
||||
@@ -128,7 +128,7 @@ Cypress.Commands.add('selectFromDropdown', { prevSubject: true }, (s, index) =>
|
||||
})
|
||||
|
||||
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
|
||||
cy.get(`[data-cy=Table-${tableName}] tbody`).within(() => {
|
||||
cy.get(`[data-cy=${tableName}-data-table] tbody`).within(() => {
|
||||
correctStats.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
.filter(':visible')
|
||||
|
||||
@@ -46,7 +46,7 @@ services:
|
||||
<<: *defaults
|
||||
command: >
|
||||
sh -c 'redis-server --appendonly yes && redis-cli flushall'
|
||||
image: redis:6.2.16
|
||||
image: redis:7.2.6
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
@@ -123,6 +123,7 @@ services:
|
||||
CI: 'true'
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
@@ -145,6 +146,7 @@ services:
|
||||
environment:
|
||||
CI: 'true'
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
SENTRY_DSN: ''
|
||||
SERVICE_PROVIDER: fd
|
||||
LANGUAGE_CENTER_VIEW_ENABLED: 'false' # currently no intention to be enabled
|
||||
SIS_URL: 'https://sis-qa.funidata.fi'
|
||||
NODE_ENV: production
|
||||
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/oodikone-frontend:${DOCKER_IMAGE_TAG:-dev}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ services:
|
||||
environment:
|
||||
REACT_APP_SERVICE_PROVIDER: fd
|
||||
REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED: 'false'
|
||||
REACT_APP_SIS_URL: 'https://sis-qa.funidata.fi'
|
||||
volumes:
|
||||
- ./services/frontend/src/:/opt/app-root/src/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
@@ -124,6 +125,7 @@ services:
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
|
||||
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
|
||||
@@ -155,6 +157,7 @@ services:
|
||||
environment:
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
|
||||
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
|
||||
SIS_IMPORTER_DATABASE: importer-db
|
||||
|
||||
+3
-1
@@ -76,7 +76,7 @@ services:
|
||||
<<: *defaults
|
||||
command: 'redis-server --appendonly yes'
|
||||
container_name: redis
|
||||
image: redis:6.2.16
|
||||
image: redis:7.2.6
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
@@ -123,6 +123,7 @@ services:
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
@@ -151,6 +152,7 @@ services:
|
||||
environment:
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# How to update the database schema
|
||||
|
||||
These instructions explain how to update the schemas for the following databases used by Oodikone:
|
||||
|
||||
- `sis-db`
|
||||
- `user-db`
|
||||
- `kone-db`
|
||||
|
||||
The steps to **add, remove, or modify columns** are mostly the same for each database, but the file paths and container names differ. Always **double-check** that you have the correct function name, column name, data type, and other relevant details in your migration file.
|
||||
|
||||
> [!NOTE]
|
||||
> If you need to change the `sis-importer-db` schema, see the instructions in the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips).
|
||||
|
||||
## General guidelines
|
||||
|
||||
### File names
|
||||
|
||||
- Name new migration files using the format `YYYYMMDD_XX_description.js`.
|
||||
- The `XX` part is a migration number (starting from `00` each day).
|
||||
- Example: `20250122_00_add_column_to_table.js`.
|
||||
|
||||
### Model vs. column names
|
||||
|
||||
- Use **camelCase** in your model definitions.
|
||||
- Use **snake_case** in the actual database columns.
|
||||
- If a model has `underscored: true`, Sequelize automatically converts camelCase to snake_case in all queries. Otherwise, specify the column name explicitly.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
|
||||
|
||||
### Verifying the migration locally
|
||||
|
||||
- Migrations are run automatically whenever the server restarts.
|
||||
- After creating a migration, ensure the relevant service is running:
|
||||
- For `sis-db`: `npm run both:real` or `npm run updater:real`
|
||||
- For `user-db`/`kone-db`: `npm run both:real` or `npm run oodikone:real`
|
||||
- **Connect to the database container** and check:
|
||||
|
||||
```bash
|
||||
docker exec -it <container-name> psql -U postgres <database-name>-real
|
||||
```
|
||||
|
||||
Replace `<container-name>` and `<database-name>` with the actual values (e.g., `sis-db`, `user-db`, or `kone-db`).
|
||||
|
||||
- In the psql prompt, verify your migration:
|
||||
|
||||
```sql
|
||||
SELECT * FROM migrations;
|
||||
```
|
||||
|
||||
Also confirm that the new (or updated) column exists in the appropriate table and has the correct type.
|
||||
|
||||
### Reverting a migration locally
|
||||
|
||||
If you need to **revert a migration locally**, follow these steps:
|
||||
|
||||
1. Locate the `migrator.up()` function in the code:
|
||||
- For `user-db` and `kone-db`: [`services/backend/src/database/connection.ts`](../services/backend/src/database/connection.ts)
|
||||
- For `sis-db`: [`updater/sis-updater-worker/src/db/connection.js`](../updater/sis-updater-worker/src/db/connection.js)
|
||||
1. Change `migrator.up()` to `migrator.down()` and save the file. The latest migration will be reverted automatically when the server reloads.
|
||||
1. **Important:** After the migration is reverted, change `migrator.down()` back to `migrator.up()` to ensure future migrations run correctly.
|
||||
1. For more instructions, see the [umzug documentation](https://github.com/sequelize/umzug?tab=readme-ov-file#usage).
|
||||
|
||||
### Deploying changes to production
|
||||
|
||||
- **Push changes** to the `master` branch and wait for the workflow to finish.
|
||||
- For **`sis-db`** migrations (in the updater):
|
||||
- You **do not** need to create a new release. Once tests pass, changes to the updater go directly to production.
|
||||
- For **`user-db`** and **`kone-db`** migrations (in the backend):
|
||||
- A new release must be made before the changes go to production.
|
||||
- After deployment, check logs (for example, in OpenShift) to confirm the migration ran successfully.
|
||||
- Optionally, use the `db-cli` tool to inspect the production database. See `how_to_db_cli.md` (in GitLab) for instructions.
|
||||
|
||||
## `sis-db`
|
||||
|
||||
> [!NOTE]
|
||||
> These instructions assume that needed data is already in the `sis-importer-db`. If not, you must add the data there first. See the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips) for more details.
|
||||
|
||||
1. **Update the updater model:**
|
||||
|
||||
- Go to `updater/sis-updater-worker/src/db/models/` and choose the model you want to change.
|
||||
- Add or modify the column (with its **type** and any other necessary fields).
|
||||
- Check the correct data type in the importer.
|
||||
- Use camelCase in the model, snake_case in the database.
|
||||
|
||||
1. **Update the backend model:**
|
||||
|
||||
- Go to `services/backend/src/models/`.
|
||||
- The models are in different formats (JavaScript in the updater, TypeScript in the backend), but follow existing examples.
|
||||
|
||||
1. **Create a migration file:**
|
||||
|
||||
- Go to `updater/sis-updater-worker/src/db/migrations/`.
|
||||
- Name your file using the format described above (e.g. `20250122_00_add_column_to_table.js`).
|
||||
- **Remember**: The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
|
||||
|
||||
1. **Run and verify the migration:**
|
||||
|
||||
- Ensure that the updater is running (`npm run both:real` or `npm run updater:real`).
|
||||
- Use the steps in [General guidelines](#general-guidelines) to verify.
|
||||
|
||||
1. **Push to master and deploy:**
|
||||
- Your migration will go to production automatically after tests pass.
|
||||
- Check logs in production (OpenShift) or use `db-cli` to confirm success.
|
||||
|
||||
## `user-db` and `kone-db`
|
||||
|
||||
1. **Update the model:**
|
||||
|
||||
- Go to `services/backend/src/models/kone` (for `kone-db`) or `services/backend/src/models/user.ts` (the only model for user-db)
|
||||
- Add or modify the column, type, and any other needed fields. Follow existing models for structure.
|
||||
|
||||
1. **Create a migration file:**
|
||||
|
||||
- Go to `services/backend/src/database/`.
|
||||
- Use `migrations_kone` (for `kone-db`) or `migrations_user` (for `user-db`).
|
||||
- Name the file using the same `YYYYMMDD_XX_description.js` format.
|
||||
- **Remember**: The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
|
||||
|
||||
1. **Run and verify the migration:**
|
||||
|
||||
- Make sure Oodikone is running (`npm run both:real` or `npm run oodikone:real`).
|
||||
- Follow the same verification steps as in [General Guidelines](#general-guidelines).
|
||||
|
||||
1. **Push to master and release:**
|
||||
- Wait for the CI workflow to pass.
|
||||
- **Create a new release**; then check the production logs (in OpenShift) to confirm the migrationor use `db-cli`.
|
||||
@@ -44,6 +44,7 @@ A collection of useful tools, documentation and other resources.
|
||||
|
||||
- [Material UI documentation](https://mui.com/material-ui/getting-started/)
|
||||
- [Material Icons](https://mui.com/material-ui/material-icons/)
|
||||
- [Material React Table documentation](https://www.material-react-table.com/docs)
|
||||
- [Semantic UI React documentation](https://react.semantic-ui.com)
|
||||
|
||||
<!--
|
||||
|
||||
Generated
+982
-628
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -38,27 +38,27 @@
|
||||
},
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"@cypress/code-coverage": "^3.13.9",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@cypress/code-coverage": "^3.13.11",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"cypress": "^13.16.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"cypress": "^14.0.3",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-import-resolver-typescript": "^3.8.1",
|
||||
"eslint-plugin-cypress": "^4.1.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"globals": "^15.13.0",
|
||||
"globals": "^15.15.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.2.11",
|
||||
"lint-staged": "^15.4.3",
|
||||
"node-actionlint": "^1.2.2",
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "3.4.2",
|
||||
"stylelint": "^16.11.0",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"typescript-eslint": "^8.18.0"
|
||||
"prettier": "3.5.1",
|
||||
"stylelint": "^16.14.1",
|
||||
"stylelint-config-standard": "^37.0.0",
|
||||
"typescript-eslint": "^8.24.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -7,19 +7,10 @@
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchCurrentVersion": "!/^0/",
|
||||
"automerge": true,
|
||||
"matchPackageNames": ["*"]
|
||||
},
|
||||
{
|
||||
"matchDatasources": ["docker"],
|
||||
"matchPackageNames": ["node"],
|
||||
"versionCompatibility": "^(?<version>[^-]+)(?<compatibility>-.*)?$",
|
||||
"versioning": "node"
|
||||
},
|
||||
{
|
||||
"matchDatasources": ["docker"],
|
||||
"matchPackageNames": ["node"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
|
||||
Generated
+267
-1011
File diff suppressed because it is too large
Load Diff
@@ -5,22 +5,21 @@
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/node": "^8.45.0",
|
||||
"@sentry/profiling-node": "^8.45.0",
|
||||
"axios": "^0.29.0",
|
||||
"bullmq": "^5.34.2",
|
||||
"compression": "^1.7.5",
|
||||
"@sentry/node": "^8.55.0",
|
||||
"@sentry/profiling-node": "^8.55.0",
|
||||
"axios": "^1.7.9",
|
||||
"bullmq": "^5.41.2",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^3.3.1",
|
||||
"cron": "^3.5.0",
|
||||
"express": "^4.21.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.46",
|
||||
"morgan": "^1.10.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"pg": "^8.13.1",
|
||||
"papaparse": "^5.5.2",
|
||||
"pg": "^8.13.3",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sequelize": "^6.37.5",
|
||||
@@ -34,16 +33,16 @@
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/validator": "^13.12.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"overrides": {
|
||||
"winston-gelf-transporter": {
|
||||
"gelf-pro": "^1.3.12"
|
||||
"gelf-pro": "^1.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ let DB_MAX_CRON_CONNECTIONS = DB_MAX_CONNECTIONS - 5
|
||||
if (DB_MAX_CRON_CONNECTIONS < 1) {
|
||||
DB_MAX_CRON_CONNECTIONS = 1
|
||||
}
|
||||
const CRON_SCHEDULE = process.env.CRON_SCHEDULE || '0 23 * * *' // Default to 23:00 daily
|
||||
|
||||
const redis = process.env.REDIS
|
||||
const DB_SCHEMA_KONE = process.env.DB_SCHEMA_KONE || 'public'
|
||||
@@ -70,6 +71,7 @@ module.exports = {
|
||||
DB_URL_KONE,
|
||||
DB_MAX_CONNECTIONS,
|
||||
DB_MAX_CRON_CONNECTIONS,
|
||||
CRON_SCHEDULE,
|
||||
redis,
|
||||
SECRET_TOKEN,
|
||||
DB_SCHEMA_KONE,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CronJob } from 'cron'
|
||||
|
||||
import { isProduction, runningInCI, languageCenterViewEnabled } from './config'
|
||||
import { isProduction, runningInCI, languageCenterViewEnabled, CRON_SCHEDULE } from './config'
|
||||
import { getDegreeProgrammesOfFaculty } from './services/faculty/faculty'
|
||||
import { getFaculties } from './services/faculty/facultyHelpers'
|
||||
import { updateFacultyOverview, updateFacultyProgressOverview } from './services/faculty/facultyUpdates'
|
||||
@@ -105,8 +105,8 @@ const dailyJobs = async () => {
|
||||
|
||||
export const startCron = () => {
|
||||
if (isProduction && !runningInCI) {
|
||||
logger.info('Cronjob for refreshing stats started: runs daily at 23:00')
|
||||
schedule('0 23 * * *', async () => {
|
||||
logger.info(`Cronjob for refreshing stats started: runs at "${CRON_SCHEDULE}"`)
|
||||
schedule(CRON_SCHEDULE, async () => {
|
||||
logger.info('Running daily jobs from cron')
|
||||
await dailyJobs()
|
||||
})
|
||||
|
||||
@@ -69,6 +69,9 @@ export class Student extends Model<InferAttributes<Student>> {
|
||||
@Column(DataType.BOOLEAN)
|
||||
hasPersonalIdentityCode!: boolean
|
||||
|
||||
@Column(DataType.STRING)
|
||||
preferredLanguage!: string
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
|
||||
@@ -43,6 +43,9 @@ export class Studyplan extends Model<InferAttributes<Studyplan>> {
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
included_courses!: string[]
|
||||
|
||||
@Column({ type: DataType.ARRAY(DataType.TEXT), field: 'included_modules' })
|
||||
includedModules!: string[]
|
||||
|
||||
@Column(DataType.STRING)
|
||||
sisu_id!: string
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import crypto from 'crypto'
|
||||
import { Request, Response, Router } from 'express'
|
||||
import moment from 'moment'
|
||||
|
||||
import { serviceProvider } from '../config'
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
|
||||
import { CourseWithSubsId } from '../types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -24,24 +22,45 @@ router.get('/v2/coursesmulti', async (req: GetCoursesRequest, res: Response) =>
|
||||
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
|
||||
}
|
||||
|
||||
let results: { courses: CourseWithSubsId[] } = { courses: [] }
|
||||
results = await getCoursesByNameAndOrCode(name, code)
|
||||
const courses = await getCoursesByNameAndOrCode(name, code)
|
||||
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = results.courses.map(course => course.code)
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
const substitutions = [
|
||||
...new Set(
|
||||
results.courses
|
||||
.flatMap(course => course.substitutions)
|
||||
.filter(code => code !== null && !courseCodes.includes(code))
|
||||
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
|
||||
),
|
||||
]
|
||||
const substitutionDetails = await getCoursesByCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
results.courses.push(substitution.dataValues)
|
||||
courses.push(substitution.toJSON())
|
||||
}
|
||||
}
|
||||
res.json(results)
|
||||
|
||||
const mergedCourses = {}
|
||||
|
||||
for (const course of courses) {
|
||||
const groupId = combineSubstitutions === 'true' ? course.subsId! : course.code
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) {
|
||||
continue
|
||||
}
|
||||
if (!mergedCourses[groupId]) {
|
||||
mergedCourses[groupId] = {
|
||||
...course,
|
||||
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
|
||||
}
|
||||
} else {
|
||||
const mergedCourse = mergedCourses[groupId]
|
||||
if (moment(mergedCourse.max_attainment_date).isBefore(course.max_attainment_date)) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (moment(mergedCourse.min_attainment_date).isAfter(course.min_attainment_date)) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ courses: Object.values(mergedCourses) })
|
||||
})
|
||||
|
||||
interface GetCourseYearlyStatsRequest extends Request {
|
||||
@@ -72,16 +91,11 @@ router.get('/v3/courseyearlystats', async (req: GetCourseYearlyStatsRequest, res
|
||||
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
|
||||
const separate = req.query.separate === 'true'
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info('Debugging c.y.s.: got through authorization and collected req query params.')
|
||||
|
||||
// Student numbers should be obfuscated to all other users except admins,
|
||||
// fullSisuAccess users, and users with rights to any specific study programmes
|
||||
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
|
||||
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
|
||||
const results = await getCourseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info('Debugging c.y.s.: getCourseYearlyStats returned and route should return result.')
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as auth from '../middleware/auth'
|
||||
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
|
||||
import * as userService from '../services/userService'
|
||||
import { LANGUAGE_CODES, Language } from '../shared/language'
|
||||
import { Role } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
@@ -14,7 +15,7 @@ router.get('/', auth.roles(['admin']), async (_req: Request, res: Response) => {
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
router.get('/access_groups', auth.roles(['admin']), (_req: Request, res: Response) => {
|
||||
router.get('/roles', auth.roles(['admin']), (_req: Request, res: Response) => {
|
||||
res.json(roles)
|
||||
})
|
||||
|
||||
@@ -34,17 +35,17 @@ router.get('/:uid', auth.roles(['admin']), async (req: UidRequest, res: Response
|
||||
}
|
||||
})
|
||||
|
||||
interface ModifyAccessRequest extends Request {
|
||||
interface ModifyRolesRequest extends Request {
|
||||
body: {
|
||||
username: string
|
||||
accessgroups: Record<string, boolean>
|
||||
roles: Record<Role, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/modifyaccess', auth.roles(['admin']), async (req: ModifyAccessRequest, res: Response) => {
|
||||
const { username, accessgroups } = req.body
|
||||
router.post('/modify-roles', auth.roles(['admin']), async (req: ModifyRolesRequest, res: Response) => {
|
||||
const { username, roles } = req.body
|
||||
try {
|
||||
await userService.modifyAccess(username, accessgroups)
|
||||
await userService.modifyAccess(username, roles)
|
||||
res.status(204).end()
|
||||
} catch (error) {
|
||||
res.status(400).json(error)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { col, fn, Op, where } from 'sequelize'
|
||||
|
||||
import { Course, Credit, Enrollment, Student } from '../models/index'
|
||||
import { Course, Credit, Enrollment, Student, Studyplan } from '../models/index'
|
||||
import { CreditTypeCode, EnrollmentState } from '../types'
|
||||
|
||||
type Courses = Array<Pick<Course, 'code' | 'name' | 'substitutions'>>
|
||||
@@ -23,9 +23,10 @@ interface StudentInfo {
|
||||
lastname: string
|
||||
email: string
|
||||
sis_person_id: string
|
||||
secondary_email: string | null
|
||||
coursesInStudyPlan: string[]
|
||||
secondaryEmail: string | null
|
||||
credits: StudentCredit[]
|
||||
enrollments: StudentEnrollment[]
|
||||
enrollments: Record<string, StudentEnrollment>
|
||||
}
|
||||
|
||||
type StudentCredits = Record<
|
||||
@@ -36,16 +37,18 @@ type StudentCredits = Record<
|
||||
}
|
||||
>
|
||||
|
||||
interface FormattedStudent {
|
||||
interface FormattedStudent extends StudentInfo {
|
||||
studentNumber: string
|
||||
sis_person_id: string
|
||||
credits: StudentCredit[]
|
||||
enrollments: Record<string, any>
|
||||
allEnrollments: StudentEnrollment[]
|
||||
firstnames: string
|
||||
lastname: string
|
||||
email: string
|
||||
secondaryEmail: string | null
|
||||
}
|
||||
|
||||
interface StudentWithStudyplanNested
|
||||
extends Pick<Student, 'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'> {
|
||||
studyplans: { included_courses: string[] }[]
|
||||
}
|
||||
|
||||
interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplans'> {
|
||||
coursesInStudyPlan: string[]
|
||||
}
|
||||
|
||||
const getCourses = async (courseCodes: string[]) => {
|
||||
@@ -122,19 +125,27 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
|
||||
}
|
||||
|
||||
const getStudents = async (studentNumbers: string[]) => {
|
||||
const students: Array<
|
||||
Pick<Student, 'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'>
|
||||
> = (
|
||||
await Student.findAll({
|
||||
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
const students = await Student.findAll({
|
||||
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
})
|
||||
).map(student => student.toJSON())
|
||||
return students
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Studyplan,
|
||||
as: 'studyplans',
|
||||
attributes: ['included_courses'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return students.map(student => {
|
||||
const { studyplans, ...rest } = student.toJSON() as StudentWithStudyplanNested
|
||||
const coursesInStudyPlan = studyplans.flatMap(studyplan => studyplan.included_courses)
|
||||
return { ...rest, coursesInStudyPlan } as StudentWithCourses
|
||||
})
|
||||
}
|
||||
|
||||
export const getCompletedCourses = async (studentNumbers: string[], courseCodes: string[]) => {
|
||||
@@ -159,7 +170,8 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
|
||||
lastname: student.lastname,
|
||||
email: student.email,
|
||||
sis_person_id: student.sis_person_id,
|
||||
secondary_email: student.secondary_email,
|
||||
secondaryEmail: student.secondary_email,
|
||||
coursesInStudyPlan: student.coursesInStudyPlan,
|
||||
credits: [],
|
||||
enrollments: [],
|
||||
}
|
||||
@@ -214,7 +226,8 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
|
||||
firstnames: student.firstnames,
|
||||
lastname: student.lastname,
|
||||
email: student.email,
|
||||
secondaryEmail: student.secondary_email,
|
||||
secondaryEmail: student.secondaryEmail,
|
||||
coursesInStudyPlan: student.coursesInStudyPlan,
|
||||
})
|
||||
return acc
|
||||
}, [] as FormattedStudent[])
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { orderBy } from 'lodash'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Course } from '../../models'
|
||||
@@ -52,7 +53,17 @@ const getRawCourses = async (name: string, code: string) => {
|
||||
const getCourses = async (name: string, code: string) => {
|
||||
const rawCourses = await getRawCourses(name, code)
|
||||
const courses: CourseWithSubsId[] = rawCourses
|
||||
.map(course => ({ ...course.dataValues }))
|
||||
.map(course => ({
|
||||
...course.toJSON(),
|
||||
substitutions: orderBy(course.substitutions, [
|
||||
substitution => {
|
||||
if (/^A/.exec(substitution)) return 4 // open university codes come last
|
||||
if (/^\d/.exec(substitution)) return 2 // old numeric codes come second
|
||||
if (/^[A-Za-z]/.exec(substitution)) return 1 // new letter based codes come first
|
||||
return 3 // unknown, comes before open uni?
|
||||
},
|
||||
]),
|
||||
}))
|
||||
.sort((a, b) => getSortRank(b.code) - getSortRank(a.code))
|
||||
return courses
|
||||
}
|
||||
@@ -87,7 +98,7 @@ export const getCoursesByNameAndOrCode = async (name: string, code: string) => {
|
||||
}
|
||||
})
|
||||
|
||||
return { courses }
|
||||
return courses
|
||||
}
|
||||
|
||||
export const getCoursesByCodes = (codes: string[]) => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { max, min } from 'lodash'
|
||||
|
||||
import { Name } from '../../shared/types'
|
||||
import { getSemesterNameByCode } from '../semesters'
|
||||
import { FormattedProgramme, OrganizationDetails } from './helpers'
|
||||
|
||||
type Programme = {
|
||||
@@ -284,14 +287,42 @@ export class CourseYearlyStatsCounter {
|
||||
return this.programmes
|
||||
}
|
||||
|
||||
private parseGroupStatistics(anonymizationSalt: string | null) {
|
||||
private insertEmptyRows = async () => {
|
||||
const first = min(Object.keys(this.groups).map(Number))
|
||||
const last = max(Object.keys(this.groups).map(Number))
|
||||
if (!(first && last)) {
|
||||
return
|
||||
}
|
||||
|
||||
const getNextYear = (name: string) => {
|
||||
const years = name.split('-')
|
||||
const startYear = parseInt(years[0], 10) + 1
|
||||
const endYear = parseInt(years[1], 10) + 1
|
||||
return `${startYear}-${endYear}`
|
||||
}
|
||||
|
||||
for (let i = first + 1; i <= last; i++) {
|
||||
if (this.groups[i]) {
|
||||
continue
|
||||
}
|
||||
|
||||
const previous = this.groups[i - 1]
|
||||
const semester = await getSemesterNameByCode(i)
|
||||
const name = typeof previous.name === 'string' ? getNextYear(previous.name) : semester.name
|
||||
this.initGroup(i, name, this.groups[i - 1].coursecode, i)
|
||||
}
|
||||
}
|
||||
|
||||
private async parseGroupStatistics(anonymizationSalt: string | null) {
|
||||
for (const [studentNumber, data] of this.students) {
|
||||
this.groups[data.code].students.studentNumbers.push(studentNumber)
|
||||
}
|
||||
|
||||
await this.insertEmptyRows()
|
||||
|
||||
const groupStatistics = Object.values(this.groups).map(({ ...rest }) => {
|
||||
const { students } = rest
|
||||
const grades = {}
|
||||
const grades = {} as Grades
|
||||
Object.keys(students.grades).forEach(student => {
|
||||
const { grade, passed } = students.grades[student]
|
||||
const parsedGrade = passed ? grade : '0'
|
||||
@@ -350,10 +381,10 @@ export class CourseYearlyStatsCounter {
|
||||
return this.facultyStats
|
||||
}
|
||||
|
||||
public getFinalStatistics(anonymizationSalt: string | null) {
|
||||
public async getFinalStatistics(anonymizationSalt: string | null) {
|
||||
return {
|
||||
programmes: this.parseProgrammeStatistics(anonymizationSalt),
|
||||
statistics: this.parseGroupStatistics(anonymizationSalt),
|
||||
statistics: await this.parseGroupStatistics(anonymizationSalt),
|
||||
facultyStats: this.parseFacultyStatistics(anonymizationSalt),
|
||||
obfuscated: this.obfuscated,
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
|
||||
const studyRightIds = Object.keys(studyRightMap)
|
||||
|
||||
const studyRightElements = await SISStudyRightElement.findAll({
|
||||
attributes: ['code', 'name', 'startDate', 'studyRightId'],
|
||||
attributes: ['code', 'name', 'startDate', 'endDate', 'studyRightId'],
|
||||
where: {
|
||||
studyRightId: {
|
||||
[Op.in]: studyRightIds,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import crypto from 'crypto'
|
||||
import moment from 'moment'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { serviceProvider } from '../../config'
|
||||
import { Course, Credit, Enrollment, Organization, SISStudyRightElement } from '../../models'
|
||||
import { Name } from '../../shared/types'
|
||||
import { EnrollmentState, Unification } from '../../types'
|
||||
import { isOpenUniCourseCode } from '../../util'
|
||||
import logger from '../../util/logger'
|
||||
import { getSortRank } from '../../util/sortRank'
|
||||
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
|
||||
import {
|
||||
@@ -67,11 +66,22 @@ const parseCredit = (
|
||||
credits,
|
||||
attainment_date: attainmentDate,
|
||||
student_studentnumber: studentNumber,
|
||||
studyright_id: studyRightId,
|
||||
} = credit
|
||||
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
|
||||
|
||||
const studyRightElements = studentNumberToSrElementsMap[studentNumber] || []
|
||||
const programmes = studyRightElements.map(formatStudyRightElement)
|
||||
let programmeOfCredit: SISStudyRightElement | undefined
|
||||
programmeOfCredit = studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId)
|
||||
if (!programmeOfCredit) {
|
||||
programmeOfCredit = studyRightElements
|
||||
.filter(studyRightElement =>
|
||||
moment(attainmentDate).isBetween(moment(studyRightElement.startDate), moment(studyRightElement.endDate))
|
||||
)
|
||||
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())[0] // The newest studyRightElement
|
||||
}
|
||||
|
||||
const programmes = programmeOfCredit ? [programmeOfCredit].map(formatStudyRightElement) : []
|
||||
|
||||
const formattedCredit: FormattedCredit = {
|
||||
yearCode,
|
||||
@@ -149,10 +159,6 @@ const getYearlyStatsOfNew = async (
|
||||
combineSubstitutions: boolean,
|
||||
studentNumberToSrElementsMap: Record<string, SISStudyRightElement[]>
|
||||
) => {
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s.: entered getYearlyStatsOfNew with courseCode ${courseCode} and unification ${unification}`
|
||||
)
|
||||
const courseForSubs = await Course.findOne({
|
||||
where: { code: courseCode },
|
||||
})
|
||||
@@ -176,11 +182,6 @@ const getYearlyStatsOfNew = async (
|
||||
}),
|
||||
])
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s., getYearlyStatsOfNew, courseCode ${courseCode} unification ${unification}: got credits ${credits.length}, enrollments ${enrollments.length} and course ${course ? 'yes' : 'no'}`
|
||||
)
|
||||
|
||||
const counter = new CourseYearlyStatsCounter()
|
||||
|
||||
for (const credit of credits) {
|
||||
@@ -238,17 +239,8 @@ const getYearlyStatsOfNew = async (
|
||||
|
||||
counter.markEnrollmentToGroup(studentNumber, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
|
||||
})
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: finished looping through credits.`
|
||||
)
|
||||
|
||||
const statistics = counter.getFinalStatistics(anonymizationSalt)
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: got final statistics.`
|
||||
)
|
||||
const statistics = await counter.getFinalStatistics(anonymizationSalt)
|
||||
|
||||
let substitutionCourses: Course[] | undefined
|
||||
if (combineSubstitutions && courseForSubs?.substitutions && courseForSubs.substitutions.length > 0) {
|
||||
@@ -262,11 +254,6 @@ const getYearlyStatsOfNew = async (
|
||||
})
|
||||
}
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: got substitutionCourses ${substitutionCourses?.length} items.`
|
||||
)
|
||||
|
||||
return {
|
||||
...statistics,
|
||||
coursecode: courseCode,
|
||||
@@ -351,9 +338,6 @@ export const getCourseYearlyStats = async (
|
||||
},
|
||||
})
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(`Debugging c.y.s.: got credits ${credits.length} items and enrollments ${enrollments.length} items`)
|
||||
|
||||
const studentNumbers = {}
|
||||
|
||||
credits.forEach(credit => {
|
||||
@@ -365,11 +349,6 @@ export const getCourseYearlyStats = async (
|
||||
|
||||
const studentNumberToSrElementsMap = await getStudentNumberToSrElementsMap(Object.keys(studentNumbers))
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s.: got a studentNumberToSrElementsMap of size ${Object.keys(studentNumberToSrElementsMap).length}`
|
||||
)
|
||||
|
||||
const statsRegular = await Promise.all(
|
||||
courseCodes.map(async courseCode => {
|
||||
const unifyStats = await getYearlyStatsOfNew(
|
||||
@@ -401,8 +380,6 @@ export const getCourseYearlyStats = async (
|
||||
})
|
||||
)
|
||||
|
||||
if (serviceProvider === 'fd') logger.info('Debugging c.y.s.: finished creating statsRegular')
|
||||
|
||||
return statsRegular
|
||||
}
|
||||
|
||||
|
||||
@@ -22,16 +22,21 @@ const mapCurriculumPeriodIdToYear = (curriculumPeriodId: string, curriculumPerio
|
||||
|
||||
type ProgrammeModuleWithRelevantAttributes = Pick<
|
||||
InferAttributes<ProgrammeModule>,
|
||||
'code' | 'name' | 'degreeProgrammeType' | 'curriculum_period_ids'
|
||||
> & { progId: string }
|
||||
'code' | 'name' | 'degreeProgrammeType'
|
||||
> & { curriculumPeriodIds: string[]; progId: string }
|
||||
|
||||
// Some programme modules are not directly associated to a faculty (organization).
|
||||
// Some have intermediate organizations, such as department, so the connection must be digged up
|
||||
export const getDegreeProgrammesOfOrganization = async (organizationId: string, onlyCurrentProgrammes: boolean) => {
|
||||
const programmesOfOrganization: Array<Omit<ProgrammeModuleWithRelevantAttributes, 'progId'> & { valid_from: Date }> =
|
||||
const programmesOfOrganization: Array<Omit<ProgrammeModuleWithRelevantAttributes, 'progId'> & { validFrom: Date }> =
|
||||
await sequelize.query(
|
||||
`
|
||||
SELECT code, name, degree_programme_type as "degreeProgrammeType", curriculum_period_ids, valid_from
|
||||
SELECT
|
||||
code,
|
||||
curriculum_period_ids as "curriculumPeriodIds",
|
||||
degree_programme_type as "degreeProgrammeType",
|
||||
name,
|
||||
valid_from as "validFrom"
|
||||
FROM programme_modules
|
||||
WHERE organization_id IN (
|
||||
WITH RECURSIVE cte AS (
|
||||
@@ -65,7 +70,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
|
||||
for (const programmeVersions of Object.values(programmesGroupedByCode)) {
|
||||
// Programmes are ordered by valid_from in descending order, so the first one whose valid_from date isn't in the future, is the newest version
|
||||
const newestProgrammeVersion = programmeVersions.find(prog => new Date() >= prog.valid_from)
|
||||
const newestProgrammeVersion = programmeVersions.find(prog => new Date() >= prog.validFrom)
|
||||
if (!newestProgrammeVersion) {
|
||||
continue
|
||||
}
|
||||
@@ -73,7 +78,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
|
||||
const yearsOfProgramme = programmeVersions
|
||||
.map(prog =>
|
||||
prog.curriculum_period_ids.map(curriculumPeriodId => {
|
||||
prog.curriculumPeriodIds.map(curriculumPeriodId => {
|
||||
return mapCurriculumPeriodIdToYear(curriculumPeriodId, curriculumPeriods)
|
||||
})
|
||||
)
|
||||
@@ -85,10 +90,10 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
if (isRelevantProgramme) {
|
||||
relevantProgrammes.push({
|
||||
code,
|
||||
name,
|
||||
curriculumPeriodIds: programmeVersions.map(prog => prog.curriculumPeriodIds).flat(),
|
||||
degreeProgrammeType,
|
||||
name,
|
||||
progId,
|
||||
curriculum_period_ids: programmeVersions.map(prog => prog.curriculum_period_ids).flat(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,39 +22,34 @@ const getStudentsAndCourses = async (
|
||||
studentNumbers: string[] | null,
|
||||
courseCodes: string[] | undefined
|
||||
): Promise<[number, CoursesQueryResult, EnrollmentsQueryResult]> => {
|
||||
if (!studentNumbers) {
|
||||
const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
|
||||
const studentnumbers =
|
||||
selectedStudents ??
|
||||
(await getStudentNumbersWithAllStudyRightElements({
|
||||
studyRights,
|
||||
startDate,
|
||||
endDate,
|
||||
exchangeStudents,
|
||||
nondegreeStudents,
|
||||
transferredOutStudents: transferredStudents,
|
||||
}))
|
||||
const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
|
||||
|
||||
const allStudents = studentnumbers.length
|
||||
const courses = await findCourses(studentnumbers, dateMonthsFromNow(startDate, months), courseCodes)
|
||||
const foundCourseCodes = Object.keys(keyBy(courses, 'code'))
|
||||
const filteredCourseCodes = courseCodes?.filter(code => !foundCourseCodes.includes(code))
|
||||
const courseEnrollments = await findCourseEnrollments(
|
||||
studentnumbers,
|
||||
dateMonthsFromNow(startDate, months),
|
||||
filteredCourseCodes
|
||||
)
|
||||
const studentnumbers =
|
||||
studentNumbers ??
|
||||
selectedStudents ??
|
||||
(await getStudentNumbersWithAllStudyRightElements({
|
||||
studyRights,
|
||||
startDate,
|
||||
endDate,
|
||||
exchangeStudents,
|
||||
nondegreeStudents,
|
||||
transferredOutStudents: transferredStudents,
|
||||
}))
|
||||
|
||||
return [allStudents, courses, courseEnrollments]
|
||||
}
|
||||
const allStudents = studentnumbers.length
|
||||
|
||||
const { months, startDate } = params
|
||||
const beforeDate = months && startDate ? dateMonthsFromNow(startDate, months) : new Date()
|
||||
const allStudents = studentNumbers.length
|
||||
const courses = await findCourses(studentNumbers, beforeDate, courseCodes)
|
||||
const foundCourseCodes = Object.keys(keyBy(courses, 'code'))
|
||||
const courses = !studentNumbers
|
||||
? await findCourses(studentnumbers, dateMonthsFromNow(startDate, months), courseCodes)
|
||||
: await findCourses(studentnumbers, beforeDate, courseCodes)
|
||||
|
||||
const foundCourseCodes = [...new Set(courses.map(({ code }) => code))].toSorted()
|
||||
const filteredCourseCodes = courseCodes?.filter(code => !foundCourseCodes.includes(code))
|
||||
const courseEnrollments = await findCourseEnrollments(studentNumbers, beforeDate, filteredCourseCodes)
|
||||
const courseEnrollments = filteredCourseCodes?.length
|
||||
? !studentNumbers
|
||||
? await findCourseEnrollments(studentnumbers, dateMonthsFromNow(startDate, months), filteredCourseCodes)
|
||||
: await findCourseEnrollments(studentnumbers, beforeDate, filteredCourseCodes)
|
||||
: []
|
||||
|
||||
return [allStudents, courses, courseEnrollments]
|
||||
}
|
||||
@@ -149,7 +144,7 @@ export const bottlenecksOf = async (query: Query, studentNumbers: string[] | nul
|
||||
})
|
||||
}
|
||||
if ('credits' in course) {
|
||||
course.credits.forEach(credit => {
|
||||
course.credits?.forEach(credit => {
|
||||
const { studentnumber, passingGrade, improvedGrade, failingGrade, grade, date } = parseCreditInfo(credit)
|
||||
if (query?.selectedStudents?.includes(studentnumber) || !query?.selectedStudents) {
|
||||
const semester = getPassingSemester(startYear, date)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Name } from '../../shared/types'
|
||||
import { CreditTypeCode, DegreeProgrammeType, EnrollmentType, ExtentCode, SemesterEnrollment } from '../../types'
|
||||
import { redisClient } from '../redis'
|
||||
import { getCurrentSemester } from '../semesters'
|
||||
import { getCurriculumVersion } from './shared'
|
||||
|
||||
export const CLOSE_TO_GRADUATION_REDIS_KEY = 'CLOSE_TO_GRADUATION_DATA'
|
||||
@@ -31,6 +32,7 @@ type AccumulatorType = {
|
||||
email: string
|
||||
phoneNumber: string
|
||||
secondaryEmail: string
|
||||
preferredLanguage: string
|
||||
}
|
||||
studyright: {
|
||||
startDate: Date
|
||||
@@ -52,6 +54,7 @@ type AccumulatorType = {
|
||||
faculty: Name
|
||||
attainmentDates: AttainmentDates
|
||||
numberOfAbsentSemesters: number
|
||||
numberOfUsedSemesters: number
|
||||
curriculumPeriod: string | null
|
||||
credits: {
|
||||
hops: number
|
||||
@@ -98,13 +101,18 @@ const findThesisAndLatestAndEarliestAttainments = (
|
||||
return { attainmentDates, thesisData }
|
||||
}
|
||||
|
||||
const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<string, Name>) => {
|
||||
const formatStudent = (
|
||||
student: InferAttributes<Student>,
|
||||
facultyMap: Record<string, Name>,
|
||||
currentSemesterCode: number
|
||||
) => {
|
||||
const {
|
||||
studentnumber: studentNumber,
|
||||
abbreviatedname: name,
|
||||
sis_person_id,
|
||||
email,
|
||||
phone_number: phoneNumber,
|
||||
preferredLanguage,
|
||||
secondary_email: secondaryEmail,
|
||||
} = student
|
||||
return student.studyplans.reduce<AccumulatorType[]>((acc, studyPlan) => {
|
||||
@@ -117,10 +125,20 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
|
||||
semesterEnrollments,
|
||||
} = studyRight
|
||||
|
||||
const numberOfAbsentSemesters = (semesterEnrollments ?? []).reduce(
|
||||
(acc, enrollment) => (enrollment.type === EnrollmentType.ABSENT ? acc + 1 : acc),
|
||||
0
|
||||
)
|
||||
let numberOfAbsentSemesters = 0
|
||||
let numberOfUsedSemesters = 0
|
||||
|
||||
if (semesterEnrollments != null) {
|
||||
for (const enrollment of semesterEnrollments) {
|
||||
if (enrollment.semester > currentSemesterCode) continue
|
||||
|
||||
if (enrollment.type === EnrollmentType.ABSENT) {
|
||||
numberOfAbsentSemesters += 1
|
||||
} else {
|
||||
numberOfUsedSemesters += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
code: programmeCode,
|
||||
@@ -137,7 +155,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
|
||||
)
|
||||
|
||||
acc.push({
|
||||
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail },
|
||||
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail, preferredLanguage },
|
||||
studyright: {
|
||||
startDate: startOfStudyright,
|
||||
semesterEnrollments,
|
||||
@@ -160,6 +178,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
|
||||
faculty: facultyMap[programmeCode],
|
||||
attainmentDates,
|
||||
numberOfAbsentSemesters,
|
||||
numberOfUsedSemesters,
|
||||
curriculumPeriod: getCurriculumVersion(studyPlan.curriculum_period_id),
|
||||
credits: {
|
||||
hops: studyPlan.completed_credits,
|
||||
@@ -178,6 +197,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
|
||||
'creditcount',
|
||||
'email',
|
||||
'phone_number',
|
||||
'preferredLanguage',
|
||||
'secondary_email',
|
||||
'sis_person_id',
|
||||
'studentnumber',
|
||||
@@ -294,8 +314,10 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
|
||||
facultyMap[programme.code] = programme['organization.name']
|
||||
}
|
||||
|
||||
const { semestercode: currentSemesterCode } = await getCurrentSemester()
|
||||
|
||||
return students
|
||||
.flatMap(student => formatStudent(student, facultyMap))
|
||||
.flatMap(student => formatStudent(student, facultyMap, currentSemesterCode))
|
||||
.reduce(
|
||||
(acc, student) => {
|
||||
if (student.programme.degreeProgrammeType === DegreeProgrammeType.BACHELOR) {
|
||||
|
||||
@@ -143,7 +143,7 @@ const getStudents = async (studentNumbers: string[]) => {
|
||||
model: Studyplan,
|
||||
attributes: [
|
||||
'included_courses',
|
||||
'programme_code',
|
||||
'includedModules',
|
||||
'completed_credits',
|
||||
'curriculum_period_id',
|
||||
'sis_study_right_id',
|
||||
|
||||
@@ -448,24 +448,6 @@ export const formatStudentsForApi = (
|
||||
}
|
||||
}
|
||||
|
||||
const getSubstitutions = async (codes: string[]) => {
|
||||
const courses = await Course.findAll({
|
||||
attributes: ['code', 'substitutions'],
|
||||
where: {
|
||||
code: {
|
||||
[Op.iLike]: { [Op.any]: codes },
|
||||
},
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const substitutions = [
|
||||
...new Set(courses.flatMap(({ code, substitutions }) => [code, ...Object.values(substitutions).flat()])),
|
||||
]
|
||||
|
||||
return substitutions
|
||||
}
|
||||
|
||||
export type EnrollmentsQueryResult = Array<{
|
||||
code: string
|
||||
name: Name
|
||||
@@ -482,12 +464,10 @@ export type CoursesQueryResult = Array<
|
||||
}
|
||||
>
|
||||
|
||||
// This duplicate code is added here to ensure that we get the enrollments in cases no credits found for the selected students
|
||||
export const findCourseEnrollments = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => {
|
||||
const courseCodes = courses.length === 0 ? ['DUMMY'] : await getSubstitutions(courses)
|
||||
const result: EnrollmentsQueryResult = await sequelize.query(
|
||||
`
|
||||
SELECT DISTINCT ON (course.id)
|
||||
SELECT DISTINCT
|
||||
course.code,
|
||||
course.name,
|
||||
course.substitutions,
|
||||
@@ -496,24 +476,32 @@ export const findCourseEnrollments = async (studentNumbers: string[], beforeDate
|
||||
FROM course
|
||||
INNER JOIN (
|
||||
SELECT
|
||||
course_id,
|
||||
ARRAY_AGG(JSON_BUILD_OBJECT(
|
||||
course_code,
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'studentnumber', studentnumber,
|
||||
'state', state,
|
||||
'enrollment_date_time', enrollment_date_time
|
||||
)) AS data
|
||||
FROM enrollment
|
||||
WHERE enrollment.studentnumber IN (:studentnumbers) AND enrollment.enrollment_date_time < :beforeDate AND enrollment.state = :enrollmentState
|
||||
GROUP BY enrollment.course_id
|
||||
) enrollment ON enrollment.course_id = course.id
|
||||
WHERE :skipCourseCodeFilter OR course.code IN (:courseCodes)
|
||||
WHERE studentnumber IN (:studentnumbers)
|
||||
AND enrollment_date_time < :beforeDate
|
||||
AND state = :enrollmentState
|
||||
GROUP BY course_code
|
||||
) AS enrollment
|
||||
ON enrollment.course_code = course.code
|
||||
WHERE course.code IN (:courseCodes)
|
||||
OR (
|
||||
SELECT
|
||||
JSONB_AGG(DISTINCT alt_code)
|
||||
FROM course, LATERAL JSONB_ARRAY_ELEMENTS(substitutions) as alt_code
|
||||
WHERE code IN (:courseCodes)
|
||||
) ? course.code
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
studentnumbers: studentNumbers.length > 0 ? studentNumbers : ['DUMMY'],
|
||||
beforeDate,
|
||||
courseCodes,
|
||||
skipCourseCodeFilter: courses.length === 0,
|
||||
courseCodes: courses.length ? courses : ['DUMMY'],
|
||||
enrollmentState: EnrollmentState.ENROLLED,
|
||||
},
|
||||
type: QueryTypes.SELECT,
|
||||
@@ -523,21 +511,35 @@ export const findCourseEnrollments = async (studentNumbers: string[], beforeDate
|
||||
}
|
||||
|
||||
export const findCourses = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => {
|
||||
const courseCodes = courses.length === 0 ? ['DUMMY'] : await getSubstitutions(courses)
|
||||
const result: CoursesQueryResult = await sequelize.query(
|
||||
return sequelize.query(
|
||||
`
|
||||
SELECT DISTINCT ON (course.id)
|
||||
SELECT
|
||||
course.code,
|
||||
course.name,
|
||||
course.substitutions,
|
||||
course.main_course_code,
|
||||
credit.data AS credits,
|
||||
enrollment.data AS enrollments
|
||||
enrollment.data AS enrollments,
|
||||
credit.data AS credits
|
||||
FROM course
|
||||
INNER JOIN (
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
course_code,
|
||||
ARRAY_AGG(JSON_BUILD_OBJECT(
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'studentnumber', studentnumber,
|
||||
'state', state,
|
||||
'enrollment_date_time', enrollment_date_time
|
||||
)) AS data
|
||||
FROM enrollment
|
||||
WHERE studentnumber IN (:studentnumbers)
|
||||
AND enrollment_date_time < :beforeDate
|
||||
AND state = :enrollmentState
|
||||
GROUP BY course_code
|
||||
) AS enrollment
|
||||
ON enrollment.course_code = course.code
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
course_code,
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'grade', grade,
|
||||
'student_studentnumber', student_studentnumber,
|
||||
'attainment_date', attainment_date,
|
||||
@@ -545,35 +547,31 @@ export const findCourses = async (studentNumbers: string[], beforeDate: Date, co
|
||||
'course_code', course_code
|
||||
)) AS data
|
||||
FROM credit
|
||||
WHERE student_studentnumber IN (:studentnumbers) AND attainment_date < :beforeDate
|
||||
GROUP BY credit.course_code
|
||||
) credit ON credit.course_code = course.code
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
course_id,
|
||||
ARRAY_AGG(JSON_BUILD_OBJECT(
|
||||
'studentnumber', studentnumber,
|
||||
'state', state,
|
||||
'enrollment_date_time', enrollment_date_time
|
||||
)) AS data
|
||||
FROM enrollment
|
||||
WHERE enrollment.studentnumber IN (:studentnumbers) AND enrollment.enrollment_date_time < :beforeDate AND enrollment.state = :enrollmentState
|
||||
GROUP BY enrollment.course_id
|
||||
) enrollment ON enrollment.course_id = course.id
|
||||
WHERE :skipCourseCodeFilter OR course.code IN (:courseCodes)
|
||||
WHERE student_studentnumber IN (:studentnumbers)
|
||||
AND attainment_date < :beforeDate
|
||||
GROUP BY course_code
|
||||
) AS credit
|
||||
ON credit.course_code = course.code
|
||||
WHERE :skipCourseCodeFilter
|
||||
OR course.code IN (:courseCodes)
|
||||
OR (
|
||||
SELECT
|
||||
JSONB_AGG(DISTINCT alt_code)
|
||||
FROM course, LATERAL JSONB_ARRAY_ELEMENTS(substitutions) as alt_code
|
||||
WHERE code IN (:courseCodes)
|
||||
) ? course.code
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
studentnumbers: studentNumbers.length > 0 ? studentNumbers : ['DUMMY'],
|
||||
beforeDate,
|
||||
courseCodes,
|
||||
courseCodes: courses.length ? courses : ['DUMMY'],
|
||||
skipCourseCodeFilter: courses.length === 0,
|
||||
enrollmentState: EnrollmentState.ENROLLED,
|
||||
},
|
||||
type: QueryTypes.SELECT,
|
||||
}
|
||||
)
|
||||
return result
|
||||
) as Promise<CoursesQueryResult>
|
||||
}
|
||||
|
||||
export const parseCreditInfo = (credit: CreditPick) => {
|
||||
|
||||
@@ -13,6 +13,16 @@ type SemestersAndYears = {
|
||||
}
|
||||
}
|
||||
|
||||
export const getSemesterNameByCode = async (semesterCode: number) => {
|
||||
const semester = await Semester.findOne({
|
||||
attributes: ['name'],
|
||||
where: {
|
||||
semestercode: semesterCode,
|
||||
},
|
||||
})
|
||||
return semester!
|
||||
}
|
||||
|
||||
export const getCurrentSemester = async () => {
|
||||
const today = new Date()
|
||||
const currentSemester = await Semester.findOne({
|
||||
|
||||
@@ -303,7 +303,7 @@ const nameLike = (terms: string[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
const studentnumberLike = (terms: string[]) => {
|
||||
const studentNumberLike = (terms: string[]) => {
|
||||
return {
|
||||
studentnumber: {
|
||||
[Op.iLike]: likefy(terms[0]),
|
||||
@@ -311,8 +311,26 @@ const studentnumberLike = (terms: string[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const bySearchTermAndStudentNumbers = async (searchterm: string, studentNumbers?: string[]) => {
|
||||
const terms = splitByEmptySpace(searchterm)
|
||||
const formatStudentForSearch = ({
|
||||
creditcount,
|
||||
dateofuniversityenrollment,
|
||||
firstnames,
|
||||
lastname,
|
||||
studentnumber,
|
||||
studyRights,
|
||||
}: InferAttributes<Student>) => {
|
||||
return {
|
||||
activeStudyRights: studyRights || [],
|
||||
credits: creditcount || 0,
|
||||
firstNames: firstnames,
|
||||
lastName: lastname,
|
||||
studentNumber: studentnumber,
|
||||
started: dateofuniversityenrollment,
|
||||
}
|
||||
}
|
||||
|
||||
export const bySearchTermAndStudentNumbers = async (searchTerm: string, studentNumbers?: string[]) => {
|
||||
const terms = splitByEmptySpace(searchTerm)
|
||||
return (
|
||||
await Student.findAll({
|
||||
attributes: ['studentnumber', 'firstnames', 'lastname', 'creditcount', 'dateofuniversityenrollment'],
|
||||
@@ -337,16 +355,16 @@ export const bySearchTermAndStudentNumbers = async (searchterm: string, studentN
|
||||
where: studentNumbers
|
||||
? {
|
||||
[Op.and]: {
|
||||
[Op.or]: [nameLike(terms), studentnumberLike(terms)],
|
||||
[Op.or]: [nameLike(terms), studentNumberLike(terms)],
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
},
|
||||
}
|
||||
: { [Op.or]: [nameLike(terms), studentnumberLike(terms)] },
|
||||
: { [Op.or]: [nameLike(terms), studentNumberLike(terms)] },
|
||||
order: [['dateofuniversityenrollment', 'DESC NULLS LAST']],
|
||||
})
|
||||
).map(formatSharedStudentData)
|
||||
).map(formatStudentForSearch)
|
||||
}
|
||||
|
||||
export const filterStudentnumbersByAccessrights = async (studentnumbers: string[], codes: string[]) =>
|
||||
|
||||
@@ -114,7 +114,30 @@ export const getTransferredCredits = async (provider: string, since: Date) =>
|
||||
},
|
||||
})
|
||||
|
||||
export const getThesisCredits = async (provider: string, thesisType: string[], studentnumbers: string[]) =>
|
||||
export const getStudyRightThesisCredits = async (thesisType: string[], studyRightIds: string[]) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['attainment_date', 'student_studentnumber'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: [],
|
||||
where: {
|
||||
course_unit_type: {
|
||||
[Op.in]: thesisType,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
credittypecode: CreditTypeCode.PASSED,
|
||||
isStudyModule: {
|
||||
[Op.not]: true,
|
||||
},
|
||||
studyright_id: {
|
||||
[Op.in]: studyRightIds,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const getOrganizationThesisCredits = async (provider: string, thesisType: string[], studentnumbers: string[]) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['attainment_date', 'student_studentnumber'],
|
||||
include: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DegreeProgrammeType, ExtentCode, Phase, SemesterEnrollment, StudyTrack
|
||||
import { getDegreeProgrammeType, getMinimumCreditsOfProgramme, sortByProgrammeCode } from '../../util'
|
||||
import { countTimeCategories } from '../graduationHelpers'
|
||||
import { getSemestersAndYears } from '../semesters'
|
||||
import { getThesisCredits } from './creditGetters'
|
||||
import { getStudyRightThesisCredits, getOrganizationThesisCredits } from './creditGetters'
|
||||
import { getGraduatedStats } from './studyProgrammeBasics'
|
||||
import {
|
||||
defineYear,
|
||||
@@ -101,19 +101,35 @@ const getGraduationTimeAndThesisWriterStats = async ({
|
||||
thesisTableStats: tableStats,
|
||||
}
|
||||
}
|
||||
|
||||
const doCombo = await shouldIncludeComboStats(studyProgramme)
|
||||
const graduationTimes: Record<string, number[]> = getYearsObject({ years, emptyArrays: true })
|
||||
const graduationTimesCombo: Record<string, number[]> = getYearsObject({ years, emptyArrays: true })
|
||||
const { semesters } = await getSemestersAndYears()
|
||||
|
||||
const studyRights = await getStudyRightsInProgramme(studyProgramme, false)
|
||||
const studentNumbers = studyRights.map(studyRight => studyRight.studentNumber)
|
||||
const thesisType = await getThesisType(studyProgramme)
|
||||
const thesisCredits = await getThesisCredits(mapToProviders([studyProgramme])[0], thesisType, studentNumbers)
|
||||
const thesisWriterMap = thesisCredits.reduce<Record<string, Date>>((acc, credit) => {
|
||||
acc[credit.student_studentnumber] = credit.attainment_date
|
||||
return acc
|
||||
}, {})
|
||||
const studyRights = await getStudyRightsInProgramme(studyProgramme, false)
|
||||
const studyRightIds = studyRights.map(studyRight => studyRight.id)
|
||||
const studentNumbers = studyRights.map(studyRight => studyRight.studentNumber)
|
||||
|
||||
const studyRightThesisCredits = await getStudyRightThesisCredits(thesisType, studyRightIds)
|
||||
|
||||
const studyRightThesisStudentNumbers = studyRightThesisCredits.map(item => item.student_studentnumber)
|
||||
const organizationStudentNumbers = studentNumbers.filter(student => !studyRightThesisStudentNumbers.includes(student))
|
||||
|
||||
const organizationThesisCredits = await getOrganizationThesisCredits(
|
||||
mapToProviders([studyProgramme])[0],
|
||||
thesisType,
|
||||
organizationStudentNumbers
|
||||
)
|
||||
|
||||
const thesisWriterMap = [...studyRightThesisCredits, ...organizationThesisCredits].reduce<Record<string, Date>>(
|
||||
(acc, credit) => {
|
||||
acc[credit.student_studentnumber] = credit.attainment_date
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
for (const studyRight of studyRights) {
|
||||
const correctStudyRightElement = studyRight.studyRightElements.find(element => element.code === studyProgramme)
|
||||
|
||||
@@ -6,8 +6,8 @@ import { serviceProvider } from '../config'
|
||||
import { roles } from '../config/roles'
|
||||
import { sequelizeUser } from '../database/connection'
|
||||
import { User } from '../models/user'
|
||||
import { Role } from '../shared/types'
|
||||
import { DetailedProgrammeRights, ExpandedUser, FormattedUser, IamAccess } from '../types'
|
||||
import { DetailedProgrammeRights, Role } from '../shared/types'
|
||||
import { ExpandedUser, FormattedUser, IamAccess } from '../types'
|
||||
|
||||
import { createLocaleComparator, getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
import * as jami from '../util/jami'
|
||||
@@ -34,7 +34,7 @@ const isRole = (role: string) => {
|
||||
return (roles as readonly string[]).includes(role)
|
||||
}
|
||||
|
||||
export const modifyAccess = async (username: string, rolesOfUser: Record<string, boolean>) => {
|
||||
export const modifyAccess = async (username: string, rolesOfUser: Record<Role, boolean>) => {
|
||||
const user = await User.findOne({ where: { username } })
|
||||
if (!user) {
|
||||
throw new Error(`User ${username} not found`)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { CourseWithSubsId, ParsedCourse } from './course'
|
||||
export { CreditTypeCode } from './creditTypeCode'
|
||||
export { Criteria } from './criteria'
|
||||
export { DetailedProgrammeRights } from './detailedProgrammeRights'
|
||||
export { DegreeProgrammeType } from './degreeProgrammeType'
|
||||
export { EnrollmentState } from './enrollmentState'
|
||||
export { EnrollmentType } from './enrollmentType'
|
||||
|
||||
@@ -2,8 +2,7 @@ import { InferAttributes } from 'sequelize'
|
||||
|
||||
import { User } from '../models/user'
|
||||
import { Language } from '../shared/language'
|
||||
import { Role } from '../shared/types'
|
||||
import { DetailedProgrammeRights } from './detailedProgrammeRights'
|
||||
import { DetailedProgrammeRights, Role } from '../shared/types'
|
||||
|
||||
export type ExpandedUser = InferAttributes<User> & {
|
||||
iamGroups: string[]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ProgrammeModule } from '../models'
|
||||
import { Role } from '../shared/types'
|
||||
import { DetailedProgrammeRights } from '../types'
|
||||
import { DetailedProgrammeRights, Role } from '../shared/types'
|
||||
|
||||
const isObjectWithKey = (obj: unknown, key: string): obj is Record<string, unknown> => {
|
||||
return typeof obj === 'object' && obj !== null && key in obj
|
||||
|
||||
@@ -32,6 +32,9 @@ ENV REACT_APP_SERVICE_PROVIDER=$SERVICE_PROVIDER
|
||||
ARG LANGUAGE_CENTER_VIEW_ENABLED
|
||||
ENV REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED=$LANGUAGE_CENTER_VIEW_ENABLED
|
||||
|
||||
ARG SIS_URL
|
||||
ENV REACT_APP_SIS_URL=$SIS_URL
|
||||
|
||||
COPY ./frontend/package* ./
|
||||
RUN npm ci --include=dev
|
||||
COPY ./frontend .
|
||||
|
||||
Generated
+456
-367
File diff suppressed because it is too large
Load Diff
@@ -11,16 +11,16 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^6.2.0",
|
||||
"@mui/material": "^6.2.0",
|
||||
"@mui/x-date-pickers": "^7.23.2",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@sentry/browser": "^8.45.0",
|
||||
"axios": "^0.29.0",
|
||||
"@mui/icons-material": "^6.4.4",
|
||||
"@mui/material": "^6.4.4",
|
||||
"@mui/x-date-pickers": "^7.27.0",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@sentry/browser": "^8.55.0",
|
||||
"axios": "^1.7.9",
|
||||
"highcharts": "^11.4.8",
|
||||
"immer": "^10.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"material-react-table": "^3.0.3",
|
||||
"material-react-table": "^3.2.0",
|
||||
"moment": "^2.30.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "^9.1.1",
|
||||
@@ -28,10 +28,10 @@
|
||||
"react-datetime": "^3.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highcharts": "^16.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-range": "^1.10.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.0.2",
|
||||
"react-router": "^7.1.5",
|
||||
"scheduler": "^0.25.0",
|
||||
"semantic-ui-css": "^2.5.0",
|
||||
"semantic-ui-react": "^3.0.0-beta.2",
|
||||
@@ -40,14 +40,14 @@
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.16",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-highcharts": "^16.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"react-highcharts": {
|
||||
|
||||
@@ -88,5 +88,5 @@ facultyToolTips.studentsOfTheFaculty =
|
||||
.replace('Transferred away', 'Transferred out of programme')
|
||||
.replace('Transferred to', 'Transferred into programme')
|
||||
facultyToolTips.graduatedToggle = studyProgrammeToolTips.graduatedToggle
|
||||
facultyToolTips.studentToggle = studyProgrammeToolTips.studentToggle
|
||||
facultyToolTips.studyRightToggle = studyProgrammeToolTips.studyRightToggle
|
||||
facultyToolTips.yearToggle = studyProgrammeToolTips.yearToggle
|
||||
|
||||
@@ -1,45 +1,23 @@
|
||||
export const filterToolTips = {
|
||||
enrollmentStatus: {
|
||||
label: 'Filter students present, absent or passive',
|
||||
short: `
|
||||
Suodata opiskelijoita sen mukaan ovatko he joko ilmoittautuneet läsnä- tai poissaoleviksi tai jääneet passiivisiksi kyseiselle lukukaudelle. Huom. osa passiivisista opiskelijoista on jo valmistunut ko. ohjelmasta.
|
||||
`,
|
||||
},
|
||||
studyRightStatus: {
|
||||
label: 'Rajaa opiskelijoita opiskeluoikeuden statuksen mukaan',
|
||||
short: `
|
||||
Opiskeluoikeus on **aktiivinen** (active), jos opiskelija on ilmoittautunut läsnä- tai poissaolevaksi käynnissä olevalle lukukaudelle.
|
||||
citizenship:
|
||||
'Rajaa opiskelijoita kansalaisuuden perusteella. Tulokseen sisältyvät opiskelijat, joilla on valitun valtion kansalaisuus, riippumatta siitä, onko heillä myös muita kansalaisuuksia.',
|
||||
courseCredits: 'Rajaa kurssisuorituksia tietyn ajanjakson mukaan. Tämä rajaus ei muuta opiskelijoiden määrää',
|
||||
enrollmentStatus: `
|
||||
Suodata opiskelijoita sen mukaan, ovatko he ilmoittautuneet **läsnäolevaksi** (present), **poissaolevaksi** (absent) vai **laiminlyöneet ilmoittautumisen** (passive) valituille lukukausille. Jos lukukausia on valittuna enemmän kuin yksi, opiskelijan ilmoittautumisen tulee täsmätä jokaisella valitulla lukukaudella.
|
||||
|
||||
Opiskeluoikeus on **passiivinen** (inactive), jos opiskelija ei ole ilmoittautunut läsnä- eikä poissaolevaksi käynnissä olevalle lukukaudelle.
|
||||
Jos opiskelijan opinto-oikeus ei ole ollut voimassa valitun lukukauden aikana, häntä ei lasketa mukaan mihinkään kategoriaan. Huomioi myös, että osa ilmoittautumisen laiminlyöneistä opiskelijoista on saattanut valmistua valitusta ohjelmasta.
|
||||
`,
|
||||
startingYear:
|
||||
'Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.',
|
||||
studentNumber:
|
||||
'Rajaa yksittäisiä opiskelijoita pois tilastoista (blocklist) tai näytä vain määritellyt opiskelijat (allowlist). Vain täsmälliset opiskelijanumerot toimivat.',
|
||||
studyRightStatus: `
|
||||
Opiskeluoikeus on **aktiivinen** (active), jos opiskelija on ilmoittautunut läsnä- tai poissaolevaksi käynnissä olevalle lukukaudelle.
|
||||
|
||||
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Valmistuneet opiskelijat on **suodatettu pois** molemmista kategorioista.
|
||||
`,
|
||||
},
|
||||
transferred: {
|
||||
label: 'Include and exclude students from this program',
|
||||
short: `
|
||||
Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta
|
||||
`,
|
||||
},
|
||||
courseCredits: {
|
||||
label: null,
|
||||
short: 'Rajaa kurssisuorituksia tietyn ajanjakson mukaan. Tämä rajaus ei muuta opiskelijoiden määrää',
|
||||
},
|
||||
studentNumber: {
|
||||
label: null,
|
||||
short: `
|
||||
Rajaa yksittäisiä opiskelijoita pois tilastoista (blocklist) tai näytä vain määritellyt opiskelijat (allowlist). Vain täsmälliset opiskelijanumerot toimivat.
|
||||
`,
|
||||
},
|
||||
startingYear: {
|
||||
label: null,
|
||||
short: `
|
||||
Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.
|
||||
`,
|
||||
},
|
||||
citizenship: {
|
||||
label: null,
|
||||
short:
|
||||
'Rajaa opiskelijoita kansalaisuuden perusteella. Tulokseen sisältyvät opiskelijat, joilla on valitun valtion kansalaisuus, riippumatta siitä, onko heillä myös muita kansalaisuuksia.',
|
||||
},
|
||||
Opiskeluoikeus on **passiivinen** (inactive), jos opiskelija ei ole ilmoittautunut läsnä- eikä poissaolevaksi käynnissä olevalle lukukaudelle.
|
||||
|
||||
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Valmistuneet opiskelijat on **suodatettu pois** molemmista kategorioista.
|
||||
`,
|
||||
transferred:
|
||||
'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta',
|
||||
}
|
||||
|
||||
@@ -172,6 +172,11 @@ export const populationStatisticsToolTips = {
|
||||
- ✓ Harmaa tarkastusmerkki = Opiskelija on suorittanut kurssin vastaavalla kurssikoodilla
|
||||
- — Keltainen viiva = Opiskelijalla on alle 6kk vanha ilmoittautuminen kurssille
|
||||
- — Harmaa viiva = Opiskelijalla on yli 6kk vanha ilmoittautuminen kurssille
|
||||
- □ Harmaa laatikko = Opiskelija on lisännyt kurssin opintosuunitelmaansa, mutta ei ole ilmoittautunut kurssille tai suorittanut sitä
|
||||
|
||||
**Modules**
|
||||
|
||||
Tällä välilehdellä näkyy, mitä opintokokonaisuuksia opiskelija on valinnut opintosuunnitelmaansa. Näkymä ei huomioi valinnaisia opintokokonaisuuksia.
|
||||
|
||||
**Tags**
|
||||
|
||||
@@ -208,7 +213,7 @@ export const populationStatisticsToolTips = {
|
||||
3. Jos suoritukseen tai ilmoittautumiseen ei liity opiskeluoikeutta eikä kurssia ole sijoitettu opintosuunnitelmaan, käytetään viimeisintä koulutusohjelmaa suorituksen tai ilmoittautumisen hetkellä
|
||||
`,
|
||||
programmeDistributionCustomPopulation: `
|
||||
'Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
|
||||
Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
|
||||
`,
|
||||
gradeDistributionCoursePopulation: 'Näyttää korkeimman arvosanan, jonka opiskelija on saanut valitulla aikavälillä.',
|
||||
creditDistributionCoursePopulation: 'Näyttää tiedekunta-/ohjelmankohtaisen opintopistejakauman.',
|
||||
|
||||
@@ -3,7 +3,7 @@ export const studyProgrammeToolTips: Record<string, string> = {
|
||||
**Calendar year**: Näyttää tilastot kalenterivuosille jaoteltuna (esim. 1.1.–31.12.2024)
|
||||
**Academic year**: Näyttää tilastot lukuvuosille jaoteltuna (esim. 1.8.2023–31.7.2024)
|
||||
`,
|
||||
studentToggle: `
|
||||
studyRightToggle: `
|
||||
**All study rights**: Valinnassa ovat mukana vaihto-opiskelijat, erillisoikeudella opiskelevat ja ohjelmaan siirtyneet opiskelijat sekä ohjelmasta pois siirtyneet opiskelijat.
|
||||
**Special study rights excluded**: Valinnasta ja kaikista luvuista on poissuljettu kaikki edellä mainitut erikoisryhmät.
|
||||
`,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export const updaterToolTips = {
|
||||
updaterSection: `
|
||||
**Update meta**: Updates organisations, study modules, course units, study levels, education types, credit types
|
||||
**Update students**: Updates 1000 students at one click in development and all in production environment. Takes about 5 hours in production.
|
||||
**Update curriculums**: Updates all study programmes and their curriculums. This takes a few minutes, and breaks the curriculum features for that time, so do not run in production unnecessarily.
|
||||
**Refresh updater redis**: Refreshes maps (COUNTRIES, EDUCATION_TYPES, ORGANISATION_ID_TO_CODE, etc.) stored in updater redis. Check \`loadMapsOnDemand\` function in updater for more information.
|
||||
`,
|
||||
refreshDataSection: `
|
||||
**Refresh all teacher leaderboards**: Refresh all teacher leaderboard statistics from 1951 until today. Might take some time.
|
||||
**Refresh teacher leaderboards of current and previous year**: Refresh teacher leaderboard statistics for current and previous academic year.
|
||||
**Refresh faculties**: Refresh data for all faculties for all tabs (time consuming).
|
||||
**Refresh study programmes**: Refresh data for new study programmes for basic and studytrack tabs (time consuming).
|
||||
**Refresh language center data**: Refresh data for language center view.
|
||||
**Refresh close to graduation data**: Refresh data for close to graduation view.
|
||||
`,
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
export const userToolTips = {
|
||||
iamGroupBasedAccess: `
|
||||
*Currently this description is more like how things should be, not how they are. In particular, full rights based on IAM groups are not implemented.*
|
||||
|
||||
The exclamation mark means that the user has **limited rights** to the study programme. The check mark means that the user has **full rights** to the study programme. Full rights behave exactly like manually added rights.
|
||||
|
||||
Both of these icons are displayed: the relevant one is green, the irrelevant one is greyed out.
|
||||
The orange exclamation mark means that the user has **limited rights** to the study programme.
|
||||
The green check mark means that the user has **full rights** to the study programme. Full rights behave exactly like manually added rights.
|
||||
|
||||
**Limited rights** are granted to the following people:
|
||||
- member of the steering group (johtoryhmä) of the study programme.
|
||||
- dean or vice dean (dekaani tai varadekaani) of the corresponding faculty.
|
||||
- The following people get limited rights to **all** study programmes:
|
||||
- member of the steering group (johtoryhmä) of the study programme
|
||||
- dean or vice dean (dekaani tai varadekaani) of the corresponding faculty
|
||||
- the following people get limited rights to **all** study programmes:
|
||||
- heads of academic affairs (opintoasiainpäälliköt)
|
||||
- employees of ospa (opetuksen strategiset palvelut)
|
||||
- rector, vice-rectors and director of administration (rehtori, vararehtorit ja hallintojohtaja)
|
||||
- members of the academic affairs council (opintoasiainneuvosto)
|
||||
|
||||
**Full rights** are granted to the following people:
|
||||
- education planning officer (koulutussuunnittelija) of the corresponding faculty.
|
||||
- vice dean responsible for education (opetusvaradekaani) of the corresponding faculty.
|
||||
- head **and** member of the steering group of the study programme (johtoryhmän jäsen ja koulutusohjelman johtaja).
|
||||
- vice dean responsible for education (opetusvaradekaani) of the corresponding faculty
|
||||
- head **and** member of the steering group of the study programme (johtoryhmän jäsen ja koulutusohjelman johtaja)
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
import { isEqual } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { SEARCH_HISTORY_VERSION } from '@/constants'
|
||||
import { useGetCurriculumPeriodsQuery } from '@/redux/curriculumPeriods'
|
||||
import { useGetProgrammesQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
|
||||
export const useTabChangeAnalytics = () => {
|
||||
const previousTabIndex = useRef(0)
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(_, data) => {
|
||||
const { activeIndex } = data
|
||||
|
||||
if (previousTabIndex.current !== activeIndex) {
|
||||
previousTabIndex.current = activeIndex
|
||||
}
|
||||
},
|
||||
[previousTabIndex]
|
||||
)
|
||||
|
||||
return { handleTabChange }
|
||||
}
|
||||
|
||||
// Use @/hooks/tabs for MUI components instead
|
||||
export const useTabs = (id, initialTab, { location, replace }) => {
|
||||
const [tab, setTab] = useState(-1)
|
||||
const [didMount, setDidMount] = useState(false)
|
||||
|
||||
const pushToUrl = newTab => {
|
||||
replace({
|
||||
pathname: location.pathname,
|
||||
search: qs.stringify({ ...qs.parse(location.search), [id]: newTab }),
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const params = qs.parse(location.search)
|
||||
const queryTab = params[id]
|
||||
setTab(queryTab === undefined ? initialTab : JSON.parse(queryTab))
|
||||
setDidMount(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== undefined && didMount) pushToUrl(tab)
|
||||
}, [tab])
|
||||
|
||||
return [
|
||||
tab,
|
||||
(_, { activeIndex }) => {
|
||||
setTab(activeIndex)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const useSearchHistory = (id, capacity = 5) => {
|
||||
const [searchHistory, setSearchHistory] = useState([])
|
||||
const [didMount, setDidMount] = useState(false)
|
||||
|
||||
const getSearchHistoryStore = () => JSON.parse(localStorage.getItem('searchHistoryStore')) || {}
|
||||
|
||||
const saveSearchHistoryStore = newStore => localStorage.setItem('searchHistoryStore', JSON.stringify(newStore))
|
||||
|
||||
const saveSearchHistory = () => {
|
||||
const searchHistoryStore = getSearchHistoryStore()
|
||||
searchHistoryStore[id] = searchHistory
|
||||
saveSearchHistoryStore(searchHistoryStore)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('searchHistoryVersion') !== SEARCH_HISTORY_VERSION) {
|
||||
saveSearchHistoryStore({})
|
||||
localStorage.setItem('searchHistoryVersion', SEARCH_HISTORY_VERSION)
|
||||
}
|
||||
|
||||
setSearchHistory(getSearchHistoryStore()[id] || [])
|
||||
setDidMount(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (didMount) {
|
||||
saveSearchHistory()
|
||||
}
|
||||
}, [searchHistory])
|
||||
|
||||
const addItem = item => {
|
||||
const filteredSearchHistory = searchHistory.filter(sh => sh.text !== item.text)
|
||||
setSearchHistory(
|
||||
filteredSearchHistory.concat({ ...item, timestamp: new Date(), id: new Date().getTime() }).slice(-capacity)
|
||||
)
|
||||
}
|
||||
|
||||
const updateItem = item => {
|
||||
const updatedSearchHistory = [{ ...item, timestamp: new Date() }].concat(
|
||||
searchHistory.filter(s => s.id !== item.id)
|
||||
)
|
||||
setSearchHistory(updatedSearchHistory)
|
||||
}
|
||||
|
||||
return [searchHistory, addItem, updateItem]
|
||||
}
|
||||
|
||||
const useInterval = (callback, delay) => {
|
||||
const savedCallback = useRef()
|
||||
const savedId = useRef()
|
||||
|
||||
const clear = () => {
|
||||
if (savedId.current) {
|
||||
clearInterval(savedId.current)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback
|
||||
}, [callback])
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => savedCallback.current()
|
||||
clear()
|
||||
if (delay !== null) {
|
||||
savedId.current = setInterval(tick, delay)
|
||||
}
|
||||
return clear
|
||||
}, [delay])
|
||||
}
|
||||
|
||||
export const useDidMount = () => {
|
||||
const [didMount, setDidMount] = useState(false)
|
||||
useEffect(() => {
|
||||
setDidMount(true)
|
||||
}, [])
|
||||
return didMount
|
||||
}
|
||||
|
||||
export const useProgress = loading => {
|
||||
const didMount = useDidMount()
|
||||
const [progress, setProgress] = useState(100)
|
||||
const [delay, setDelay] = useState(null)
|
||||
const amountToProgress = delay ? Math.ceil(Math.random() * 4) : 0
|
||||
|
||||
useInterval(() => {
|
||||
setProgress(progress + amountToProgress > 50 ? 50 : progress + amountToProgress)
|
||||
}, delay)
|
||||
|
||||
useEffect(() => {
|
||||
if (delay && progress >= 50) {
|
||||
setDelay(null)
|
||||
}
|
||||
}, [progress])
|
||||
|
||||
const restartProgress = () => {
|
||||
setProgress(0)
|
||||
setDelay(500)
|
||||
}
|
||||
|
||||
const finishProgress = () => {
|
||||
setDelay(null)
|
||||
setTimeout(() => setProgress(100), 0)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) restartProgress()
|
||||
else if (didMount) finishProgress()
|
||||
}, [loading])
|
||||
|
||||
const onProgress = progress => {
|
||||
if (progress > 0) {
|
||||
setProgress(50 + Math.floor(progress / 2))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
progress,
|
||||
onProgress,
|
||||
}
|
||||
}
|
||||
|
||||
export const useTitle = title => {
|
||||
useEffect(() => {
|
||||
document.title = title ? `${title} - Oodikone` : 'Oodikone'
|
||||
return () => {
|
||||
document.title = 'Oodikone'
|
||||
}
|
||||
}, [title])
|
||||
}
|
||||
|
||||
export const useLocalStorage = (key, initialValue) => {
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : initialValue
|
||||
} catch (error) {
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
const setValue = useCallback(
|
||||
value => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||
setStoredValue(valueToStore)
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||
} catch (error) {
|
||||
console.log(error) // eslint-disable-line no-console
|
||||
}
|
||||
},
|
||||
[key]
|
||||
)
|
||||
|
||||
return [storedValue, setValue]
|
||||
}
|
||||
|
||||
export const useDebounce = (value, timeout, onChange) => {
|
||||
const [innerValue, setInnerValue] = useState(value)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
const timeoutRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(value, innerValue)) {
|
||||
setInnerValue(value)
|
||||
setDirty(false)
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const setValue = useCallback(
|
||||
value => {
|
||||
setInnerValue(value)
|
||||
setDirty(true)
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
onChange(value)
|
||||
setDirty(false)
|
||||
}, timeout)
|
||||
},
|
||||
[innerValue, setInnerValue, timeoutRef, onChange, timeout]
|
||||
)
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
|
||||
onChange(innerValue)
|
||||
setDirty(false)
|
||||
}, [timeoutRef, onChange, innerValue])
|
||||
|
||||
return [innerValue, setValue, flush, dirty]
|
||||
}
|
||||
|
||||
export const useToggle = (initialValue = false) => {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const toggle = useCallback(() => {
|
||||
setValue(value => !value)
|
||||
}, [])
|
||||
return [value, toggle]
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook is similar to useMemo, but it does a deep comparison of the dependencies (using `isEqual` from Lodash).
|
||||
*/
|
||||
export const useDeepMemo = (factory, dependencies) => {
|
||||
const ref = useRef()
|
||||
|
||||
if (!ref.current || !isEqual(dependencies, ref.current.deps)) {
|
||||
ref.current = { deps: dependencies, result: factory() }
|
||||
}
|
||||
|
||||
return ref.current.result
|
||||
}
|
||||
|
||||
export const useCurrentSemester = () => {
|
||||
const { data: semesterData } = useGetSemestersQuery()
|
||||
if (!semesterData) return null
|
||||
return Object.values(semesterData.semesters).find(
|
||||
semester => new Date(semester.startdate) <= new Date() && new Date(semester.enddate) >= new Date()
|
||||
)
|
||||
}
|
||||
|
||||
export const useCurrentCurriculumPeriod = () => {
|
||||
const { data: curriculumPeriods = [] } = useGetCurriculumPeriodsQuery()
|
||||
return curriculumPeriods.find(
|
||||
curriculumPeriod =>
|
||||
new Date(curriculumPeriod.startDate) <= new Date() && new Date(curriculumPeriod.endDate) >= new Date()
|
||||
)
|
||||
}
|
||||
|
||||
export const useDegreeProgrammeTypes = programmeCodes => {
|
||||
const { data: degreeProgrammes } = useGetProgrammesQuery()
|
||||
if (!degreeProgrammes) return {}
|
||||
return programmeCodes.reduce((acc, programmeCode) => {
|
||||
acc[programmeCode] = degreeProgrammes[programmeCode]?.degreeProgrammeType ?? null
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { filter, intersection, maxBy, orderBy, range } from 'lodash'
|
||||
import { filter, maxBy, orderBy, range } from 'lodash'
|
||||
import moment from 'moment'
|
||||
|
||||
import irtomikko from '@/assets/irtomikko.png'
|
||||
@@ -17,30 +17,6 @@ export const images = {
|
||||
irtomikko,
|
||||
}
|
||||
|
||||
export const checkUserAccess = (requiredRoles, roles) => {
|
||||
return intersection(requiredRoles, roles).length > 0
|
||||
}
|
||||
|
||||
export const getGraduationGraphTitle = (studyProgramme, doCombo = false) => {
|
||||
if (!studyProgramme) return ''
|
||||
if (['MH30_001', 'MH30_003'].includes(studyProgramme)) return 'Licenciate study right'
|
||||
if (doCombo && studyProgramme === 'MH90_001') return 'Bachelor + licentiate study right'
|
||||
if (doCombo && studyProgramme.includes('MH')) return 'Bachelor + master study right'
|
||||
if (studyProgramme.includes('KH')) return 'Bachelor study right'
|
||||
if (studyProgramme.includes('MH')) return 'Master study right'
|
||||
return 'Doctoral study right'
|
||||
}
|
||||
|
||||
export const getUnifiedProgrammeName = (bachelor, masterLicentiate, language) => {
|
||||
if (language === 'fi')
|
||||
return `${bachelor} ja ${
|
||||
masterLicentiate?.includes('lisensiaatin') ? 'lisensiaatin koulutusohjelma' : 'maisterin koulutusohjelma'
|
||||
}`
|
||||
if (language === 'en') return `${bachelor?.split(' ')[0]} and ${masterLicentiate}`
|
||||
if (language === 'sv') return `${bachelor?.split('programmet')[0]}- och ${masterLicentiate}`
|
||||
return bachelor
|
||||
}
|
||||
|
||||
export const isFall = semester => semester % 2 === 1
|
||||
|
||||
export const getStudentTotalCredits = (student, includeTransferredCredits = true) => {
|
||||
@@ -288,7 +264,7 @@ export const getCreditCategories = (
|
||||
return range(0, limitBreaks.length + 1).map(i => [limitBreaks[i - 1], limitBreaks[i]])
|
||||
}
|
||||
|
||||
export const validateInputLength = (input, minLength) => input && input.trim().length >= minLength
|
||||
export const validateInputLength = (input, minLength) => input?.trim().length >= minLength
|
||||
|
||||
export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
|
||||
|
||||
@@ -299,33 +275,6 @@ export const getCurrentSemester = allSemesters => {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a sorting function that can be used to sort strings so that Finnish alphabetical order is respected.
|
||||
*
|
||||
* @param {string} field - The field to sort by (optional: if not given, the function will sort by the strings themselves)
|
||||
*/
|
||||
export const createLocaleComparator = (field = null) => {
|
||||
if (!field) {
|
||||
return (val1, val2) => val1.localeCompare(val2, 'fi', { sensitivity: 'accent' })
|
||||
}
|
||||
return (val1, val2) => val1[field]?.localeCompare(val2[field], 'fi', { sensitivity: 'accent' })
|
||||
}
|
||||
|
||||
export const createPinnedFirstComparator = pinnedProgrammes => {
|
||||
const localeComparator = createLocaleComparator('code')
|
||||
return (programmeA, programmeB) => {
|
||||
const pinnedA = pinnedProgrammes.includes(programmeA.code)
|
||||
const pinnedB = pinnedProgrammes.includes(programmeB.code)
|
||||
if (pinnedA && !pinnedB) {
|
||||
return -1
|
||||
}
|
||||
if (!pinnedA && pinnedB) {
|
||||
return 1
|
||||
}
|
||||
return localeComparator(programmeA, programmeB)
|
||||
}
|
||||
}
|
||||
|
||||
const interpolateColor = (color1, color2, factor) =>
|
||||
color1.map((component, index) => Math.round(component + factor * (color2[index] - component)))
|
||||
|
||||
@@ -352,9 +301,6 @@ export const generateGradientColors = steps => {
|
||||
return gradientColors
|
||||
}
|
||||
|
||||
export const getFullStudyProgrammeRights = programmeRights =>
|
||||
programmeRights ? programmeRights.filter(({ limited }) => !limited).map(({ code }) => code) : []
|
||||
|
||||
export const isNewStudyProgramme = programmeCode => ['MH', 'KH', 'T9'].includes(programmeCode.slice(0, 2))
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,9 +8,12 @@ import moment from 'moment'
|
||||
import { useEffect } from 'react'
|
||||
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
|
||||
|
||||
import { LanguageProvider } from '@/components/LanguagePicker/useLanguage'
|
||||
import { AccessDenied } from '@/components/material/AccessDenied'
|
||||
import { Footer } from '@/components/material/Footer'
|
||||
import { NavigationBar } from '@/components/material/NavigationBar'
|
||||
import { StatusNotification } from '@/components/material/StatusNotification'
|
||||
import { StatusNotificationProvider } from '@/components/material/StatusNotificationContext'
|
||||
import { Routes } from '@/components/Routes'
|
||||
import { SegmentDimmer } from '@/components/SegmentDimmer'
|
||||
import { isProduction } from '@/conf'
|
||||
@@ -19,11 +22,17 @@ import { theme } from '@/theme'
|
||||
|
||||
// TODO: What other options should be set globally?
|
||||
HighCharts.setOptions({
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
animation: false,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
})
|
||||
|
||||
moment.updateLocale('en', {
|
||||
@@ -47,14 +56,19 @@ const Layout = ({ children }) => (
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<LocalizationProvider dateAdapter={AdapterMoment}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<NavigationBar />
|
||||
<main style={{ flex: 1 }}>{children}</main>
|
||||
<Footer />
|
||||
</ThemeProvider>
|
||||
</LocalizationProvider>
|
||||
<LanguageProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterMoment}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<StatusNotificationProvider>
|
||||
<CssBaseline />
|
||||
<NavigationBar />
|
||||
<main style={{ flex: 1 }}>{children}</main>
|
||||
<StatusNotification />
|
||||
<Footer />
|
||||
</StatusNotificationProvider>
|
||||
</ThemeProvider>
|
||||
</LocalizationProvider>
|
||||
</LanguageProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Divider, Loader, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { getCurrentSemester } from '@/common'
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { ColorizedCoursesTableContext } from './common'
|
||||
import { FacultiesTab } from './FacultiesTab'
|
||||
|
||||
+1
-1
@@ -2,11 +2,11 @@ import { maxBy } from 'lodash'
|
||||
import moment from 'moment'
|
||||
import { Loader, Tab, Table } from 'semantic-ui-react'
|
||||
|
||||
import { useCurrentSemester } from '@/common/hooks'
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { findCorrectProgramme } from '@/components/CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import { InfoBox } from '@/components/InfoBox'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useCurrentSemester } from '@/hooks/currentSemester'
|
||||
import { useGetFacultiesQuery } from '@/redux/facultyStats'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
import { Form, Header, Input, Segment } from 'semantic-ui-react'
|
||||
import { getStudentToTargetCourseDateMap, getUnifyTextIn } from '@/common'
|
||||
import { useCurrentSemester, useProgress, useTitle } from '@/common/hooks'
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { PanelView } from '@/components/common/PanelView'
|
||||
import {
|
||||
@@ -26,6 +25,9 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import { PopulationStudentsContainer as PopulationStudents } from '@/components/PopulationStudents'
|
||||
import { ProgressBar } from '@/components/ProgressBar'
|
||||
import { useCurrentSemester } from '@/hooks/currentSemester'
|
||||
import { useProgress } from '@/hooks/progress'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourses'
|
||||
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
.courseNameCell {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.courseNameCell > span {
|
||||
font-weight: initial;
|
||||
font-size: smaller;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.courseRealisationCell {
|
||||
padding-left: 2em !important;
|
||||
}
|
||||
|
||||
.foldControlCell {
|
||||
cursor: pointer;
|
||||
padding-right: 0 !important;
|
||||
width: 1em !important;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { bool, func } from 'prop-types'
|
||||
import { useState } from 'react'
|
||||
import { Table, Icon } from 'semantic-ui-react'
|
||||
|
||||
import { courseDataWithRealisationsType } from '@/constants/types'
|
||||
import './foldableRow.css'
|
||||
|
||||
export const FoldableRow = ({ courseData, onClickFn, userHasAccessToAllStats }) => {
|
||||
const [isUnfolded, setIsUnfolded] = useState(true)
|
||||
const { id, category, realisations } = courseData
|
||||
|
||||
const hasRealisations = realisations.length && realisations.length > 0
|
||||
const showCourseRealisations = hasRealisations && isUnfolded
|
||||
|
||||
const getCell = (content, obfuscated) => (
|
||||
<Table.Cell content={obfuscated ? '5 or fewer students' : content} style={{ color: obfuscated && 'gray' }} />
|
||||
)
|
||||
|
||||
const getRow = (rowId, rowData, isMainRow = true) => {
|
||||
const { passed, failed, passrate, realisation, obfuscated } = rowData
|
||||
const showFoldIcon = isMainRow && hasRealisations
|
||||
return (
|
||||
<Table.Row className={!isMainRow ? 'subRow' : ''} key={rowId}>
|
||||
<Table.Cell
|
||||
className={showFoldIcon ? 'foldControlCell' : ''}
|
||||
onClick={() => isMainRow && setIsUnfolded(isUnfolded => !isUnfolded)}
|
||||
>
|
||||
{showFoldIcon ? <Icon name={`angle ${isUnfolded ? 'down' : 'right'}`} /> : null}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
className={isMainRow ? 'courseNameCell' : 'courseRealisationCell'}
|
||||
content={
|
||||
isMainRow ? (
|
||||
<>
|
||||
{category}
|
||||
{!userHasAccessToAllStats && <strong>*</strong>} <span>{id}</span>
|
||||
</>
|
||||
) : (
|
||||
realisation
|
||||
)
|
||||
}
|
||||
onClick={() => onClickFn(id)}
|
||||
style={{ color: obfuscated && 'gray' }}
|
||||
/>
|
||||
{getCell(passed, obfuscated)}
|
||||
{getCell(failed, obfuscated)}
|
||||
{getCell(`${passrate || 0} %`, obfuscated)}
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{getRow(id, courseData)}
|
||||
{showCourseRealisations
|
||||
? realisations.map(r => {
|
||||
const { realisation } = r
|
||||
const realisationId = `${id}-${realisation}`
|
||||
return getRow(realisationId, r, false)
|
||||
})
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
FoldableRow.propTypes = {
|
||||
courseData: courseDataWithRealisationsType.isRequired,
|
||||
onClickFn: func.isRequired,
|
||||
userHasAccessToAllStats: bool.isRequired,
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { arrayOf, bool, func, string } from 'prop-types'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { courseDataWithRealisationsType } from '@/constants/types'
|
||||
import { FoldableRow } from './foldableRow'
|
||||
|
||||
const getHeader = categoryName => {
|
||||
const getHeaderCell = content => <Table.HeaderCell key={content}>{content}</Table.HeaderCell>
|
||||
const headerLabels = [categoryName, 'Passed', 'Failed', 'Pass rate']
|
||||
return (
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell />
|
||||
{headerLabels.map(getHeaderCell)}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
)
|
||||
}
|
||||
|
||||
export const AttemptsTable = ({ categoryName, data, onClickCourse, userHasAccessToAllStats }) => (
|
||||
<Table className="fixed-header" selectable style={{ cursor: 'pointer' }}>
|
||||
{getHeader(categoryName)}
|
||||
<Table.Body>
|
||||
{data.map(course => (
|
||||
<FoldableRow
|
||||
courseData={course}
|
||||
key={course.id}
|
||||
onClickFn={onClickCourse}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
|
||||
AttemptsTable.propTypes = {
|
||||
categoryName: string.isRequired,
|
||||
data: arrayOf(courseDataWithRealisationsType).isRequired,
|
||||
onClickCourse: func.isRequired,
|
||||
userHasAccessToAllStats: bool.isRequired,
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.controlIcon,
|
||||
.controlIcon:active {
|
||||
box-shadow: none !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
#selected-table {
|
||||
padding: 0 !important;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { sortBy } from 'lodash'
|
||||
import { arrayOf, bool, func, shape, string } from 'prop-types'
|
||||
import { memo } from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
|
||||
import { getActiveYears } from '@/components/CourseStatistics/courseStatisticsUtils'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import './courseTable.css'
|
||||
|
||||
const CourseTable = ({ courses, onSelectCourse, hidden, title, controlIcon }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const noContent = courses.length === 0
|
||||
const sortCourses = courses => sortBy(courses, course => getTextIn(course.name))
|
||||
|
||||
const getEmptyListRow = () => (
|
||||
<Table.Row>
|
||||
<Table.Cell colSpan="3" content="No results." />
|
||||
</Table.Row>
|
||||
)
|
||||
|
||||
const toCourseRow = course => (
|
||||
<Table.Row
|
||||
key={course.id}
|
||||
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Table.Cell width={10}>
|
||||
<div>{getTextIn(course.name)}</div>
|
||||
<div>{getActiveYears(course)}</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell content={[course.code, ...course.substitutions].join(', ')} />
|
||||
{controlIcon ? <Table.Cell icon={controlIcon} /> : null}
|
||||
</Table.Row>
|
||||
)
|
||||
|
||||
return (
|
||||
!hidden && (
|
||||
<Table className="fixed-header" selectable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>{title}</Table.HeaderCell>
|
||||
<Table.HeaderCell>Code</Table.HeaderCell>
|
||||
{controlIcon ? <Table.HeaderCell /> : null}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{noContent ? getEmptyListRow() : sortCourses(courses).map(toCourseRow)}</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
CourseTable.propTypes = {
|
||||
courses: arrayOf(shape({ code: string, name: shape({}), seleted: bool })).isRequired,
|
||||
onSelectCourse: func.isRequired,
|
||||
hidden: bool.isRequired,
|
||||
title: string.isRequired,
|
||||
|
||||
controlIcon: string,
|
||||
}
|
||||
|
||||
const areEqual = (prevProps, nextProps) => {
|
||||
if (prevProps.courses.length !== nextProps.courses.length) {
|
||||
return false
|
||||
}
|
||||
return prevProps.courses.every(c1 => nextProps.courses.some(c2 => c1.code === c2.code))
|
||||
}
|
||||
|
||||
export const MemoizedCourseTable = memo(CourseTable, areEqual)
|
||||
@@ -1,118 +0,0 @@
|
||||
import { flatten, uniq } from 'lodash'
|
||||
import { number, string } from 'prop-types'
|
||||
import { useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Dropdown, Form, Header, Table } from 'semantic-ui-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
|
||||
const CourseTableRow = ({ facultyCode, students, credits, facultyName }) => (
|
||||
<Table.Row>
|
||||
<Table.Cell width={13}>
|
||||
{facultyCode}, {facultyName}
|
||||
</Table.Cell>
|
||||
<Table.Cell width={2}>{students}</Table.Cell>
|
||||
<Table.Cell width={1}>{credits}</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
|
||||
const CourseTable = ({ course, courseInstance }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const rows = courseInstance ? (
|
||||
[
|
||||
...Object.entries(courseInstance.faculties)
|
||||
.sort(([facultyCodeA], [facultyCodeB]) => facultyCodeA.localeCompare(facultyCodeB))
|
||||
.map(([facultyCode, instanceFaculty]) => (
|
||||
<CourseTableRow
|
||||
credits={instanceFaculty.credits}
|
||||
facultyCode={facultyCode}
|
||||
facultyName={getTextIn(instanceFaculty.name)}
|
||||
key={`${course.coursecode}-${facultyCode}`}
|
||||
students={instanceFaculty.students.length}
|
||||
/>
|
||||
)),
|
||||
<Table.Row key={`${course.coursecode}-total`} style={{ backgroundColor: '#f9fafb', fontWeight: 'bold' }}>
|
||||
<Table.Cell width={13}>Total</Table.Cell>
|
||||
<Table.Cell width={2}>{courseInstance.allStudents.length}</Table.Cell>
|
||||
<Table.Cell width={1}>{courseInstance.allCredits}</Table.Cell>
|
||||
</Table.Row>,
|
||||
]
|
||||
) : (
|
||||
<Table.Row>
|
||||
<Table.Cell width={13}>No data for selected year</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
{getTextIn(course.name)} ({course.coursecode})
|
||||
</Header>
|
||||
<Table compact>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell width={13}>Faculty</Table.HeaderCell>
|
||||
<Table.HeaderCell width={2}>Students</Table.HeaderCell>
|
||||
<Table.HeaderCell width={1}>Credits</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{rows}</Table.Body>
|
||||
</Table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const FacultyLevelStatistics = () => {
|
||||
const openOrRegular = useSelector(state => state.courseSearch.openOrRegular)
|
||||
const courseStats = useSelector(state => state.courseStats.data)
|
||||
|
||||
const yearCodes = uniq(
|
||||
flatten(Object.values(courseStats).map(course => Object.keys(course[openOrRegular].facultyStats)))
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
const [selectedYear, setSelectedYear] = useState(yearCodes[0])
|
||||
|
||||
const dropdownOptions = yearCodes.map(yearCode => ({
|
||||
key: yearCode,
|
||||
text: `${1949 + Number(yearCode)}-${1950 + Number(yearCode)}`,
|
||||
value: yearCode,
|
||||
}))
|
||||
|
||||
const yearsCourseStats = Object.values(courseStats)
|
||||
.map(course => ({
|
||||
course,
|
||||
courseInstance: course[openOrRegular].facultyStats[selectedYear],
|
||||
}))
|
||||
.sort((a, b) => (a.courseInstance == null) - (b.courseInstance == null))
|
||||
|
||||
const courseTables = yearsCourseStats.map(({ course, courseInstance }) => (
|
||||
<CourseTable course={course[openOrRegular]} courseInstance={courseInstance} key={course.unifyStats.coursecode} />
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>Select academic year</label>
|
||||
<Dropdown
|
||||
defaultValue={selectedYear}
|
||||
onChange={(_, { value }) => setSelectedYear(value)}
|
||||
options={dropdownOptions}
|
||||
selection
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
{courseTables}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
CourseTableRow.propTypes = {
|
||||
facultyCode: string.isRequired,
|
||||
students: number.isRequired,
|
||||
credits: number.isRequired,
|
||||
facultyName: string.isRequired,
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Label, Popup } from 'semantic-ui-react'
|
||||
|
||||
interface DropdownItemProps {
|
||||
code: string
|
||||
description: string
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export const DropdownItem = ({ code, description, name, size }: DropdownItemProps) => (
|
||||
<Popup
|
||||
content={description}
|
||||
disabled={!['ALL', 'OTHER', 'EXCLUDED'].includes(code)}
|
||||
size="small"
|
||||
trigger={
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<Label content={size} icon="user" size="tiny" />
|
||||
<span style={{ paddingLeft: '1rem' }}>{name}</span>
|
||||
</div>
|
||||
<div style={{ flexGrow: 0 }}>
|
||||
<Label content={code} size="tiny" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -1,49 +0,0 @@
|
||||
import { orderBy } from 'lodash'
|
||||
import { Form } from 'semantic-ui-react'
|
||||
|
||||
import { DropdownItem } from './DropdownItem'
|
||||
|
||||
type Option = {
|
||||
description: string
|
||||
key: string
|
||||
name: string
|
||||
size: number
|
||||
students: string[]
|
||||
text: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface ProgrammeDropdownProps {
|
||||
label: string
|
||||
name: string
|
||||
onChange: () => void
|
||||
options: Option[]
|
||||
placeholder?: string
|
||||
value: string[]
|
||||
}
|
||||
|
||||
export const ProgrammeDropdown = ({ label, name, onChange, options, placeholder, value }: ProgrammeDropdownProps) => {
|
||||
return (
|
||||
<Form.Dropdown
|
||||
fluid
|
||||
label={label}
|
||||
multiple
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
options={orderBy(options, ['size'], ['desc']).map(option => ({
|
||||
key: option.key,
|
||||
content: (
|
||||
<DropdownItem code={option.key} description={option.description} name={option.text} size={option.size} />
|
||||
),
|
||||
text: option.text,
|
||||
value: option.value,
|
||||
}))}
|
||||
placeholder={placeholder}
|
||||
search
|
||||
selectOnBlur={false}
|
||||
selectOnNavigation={false}
|
||||
selection
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import qs from 'query-string'
|
||||
import { useCallback } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import { Segment, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { useProgress, useTabs } from '@/common/hooks'
|
||||
import { getCourseStats } from '@/redux/coursestats'
|
||||
import { AttemptsPane } from './panes/AttemptsPane'
|
||||
import { StudentsPane } from './panes/StudentsPane'
|
||||
import './resultTabs.css'
|
||||
|
||||
export const ResultTabs = ({ primary, comparison, separate, availableStats }) => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const replace = useCallback(options => navigate(options, { replace: true }), [navigate])
|
||||
const [tab, setTab] = useTabs('cs_tab', 0, { location, replace })
|
||||
const { userHasAccessToAllStats } = primary
|
||||
const courseStats = useSelector(({ courseStats }) => courseStats)
|
||||
const { pending: loading } = courseStats
|
||||
const { onProgress } = useProgress(loading)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const handleTabChange = (...params) => {
|
||||
setTab(...params)
|
||||
}
|
||||
|
||||
const updateSeparate = separate => {
|
||||
// Only proceed if we're still on the coursestatistics page
|
||||
if (!location.pathname.includes('coursestatistics')) {
|
||||
return
|
||||
}
|
||||
|
||||
const { courseCodes, ...params } = qs.parse(location.search)
|
||||
const query = {
|
||||
...params,
|
||||
courseCodes: JSON.parse(courseCodes),
|
||||
separate,
|
||||
}
|
||||
dispatch(getCourseStats(query, onProgress))
|
||||
const queryToString = { ...query, courseCodes: JSON.stringify(query.courseCodes) }
|
||||
navigate({ search: qs.stringify(queryToString) }, { replace: true })
|
||||
}
|
||||
|
||||
const paneTypes = [
|
||||
{
|
||||
label: 'Students',
|
||||
icon: 'user',
|
||||
component: StudentsPane,
|
||||
},
|
||||
{
|
||||
label: 'Attempts',
|
||||
icon: 'redo',
|
||||
component: AttemptsPane,
|
||||
},
|
||||
]
|
||||
|
||||
const panes = paneTypes.map(({ icon, label, component: Component }) => ({
|
||||
menuItem: { icon, content: label, key: label },
|
||||
render: () => (
|
||||
<Component
|
||||
availableStats={availableStats}
|
||||
datasets={[primary, comparison]}
|
||||
separate={separate}
|
||||
updateQuery={updateSeparate}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<Segment loading={loading}>
|
||||
<Tab activeIndex={tab} id="CourseStatPanes" onTabChange={handleTabChange} panes={panes} />
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Pane } from './Pane'
|
||||
import { AttemptsTable } from './tables/AttemptsTable'
|
||||
|
||||
export const AttemptsPane = ({ availableStats, datasets, separate, userHasAccessToAllStats, updateQuery }) => {
|
||||
return (
|
||||
<Pane
|
||||
availableStats={availableStats}
|
||||
datasets={datasets}
|
||||
initialSettings={{ viewMode: 'ATTEMPTS', separate, splitDirection: 'row' }}
|
||||
tableComponent={AttemptsTable}
|
||||
updateQuery={updateQuery}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Segment, Tab } from 'semantic-ui-react'
|
||||
import { GradeDistributionChart } from './charts/GradeDistributionChart'
|
||||
import { PassRateChart } from './charts/PassRateChart'
|
||||
import { ChartSettings } from './settings/ChartSettings'
|
||||
import { TableSettings } from './settings/TableSettings'
|
||||
|
||||
const TableSection = ({
|
||||
availableStats,
|
||||
datasets,
|
||||
settings,
|
||||
setSettings,
|
||||
setSplitDirection,
|
||||
tableComponent: Table,
|
||||
toggleSeparate,
|
||||
styleContainer,
|
||||
styleData,
|
||||
userHasAccessToAllStats,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: '1em' }}>
|
||||
<TableSettings
|
||||
availableStats={availableStats}
|
||||
datasets={datasets}
|
||||
onChange={setSettings}
|
||||
onSeparateChange={toggleSeparate}
|
||||
setSplitDirection={setSplitDirection}
|
||||
value={settings}
|
||||
/>
|
||||
</div>
|
||||
<div style={styleContainer}>
|
||||
{datasets
|
||||
.filter(data => data)
|
||||
.map(data => (
|
||||
<div key={data.name} style={styleData}>
|
||||
<Table data={data} settings={settings} userHasAccessToAllStats={userHasAccessToAllStats} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PassRateChartSection = ({
|
||||
datasets,
|
||||
isRelative,
|
||||
setIsRelative,
|
||||
styleContainer,
|
||||
styleData,
|
||||
userHasAccessToAllStats,
|
||||
viewMode,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<ChartSettings isRelative={isRelative} setIsRelative={setIsRelative} tab="PassRate" viewMode={viewMode} />
|
||||
<div style={styleContainer}>
|
||||
{datasets
|
||||
.filter(data => data)
|
||||
.map(data => (
|
||||
<div key={data.name} style={styleData}>
|
||||
<PassRateChart
|
||||
data={data}
|
||||
isRelative={isRelative}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const GradeDistributionChartSection = ({
|
||||
datasets,
|
||||
isRelative,
|
||||
setIsRelative,
|
||||
styleContainer,
|
||||
styleData,
|
||||
userHasAccessToAllStats,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<ChartSettings isRelative={isRelative} setIsRelative={setIsRelative} tab="GradeDistribution" />
|
||||
<div style={styleContainer}>
|
||||
{datasets
|
||||
.filter(data => data)
|
||||
.map(data => (
|
||||
<div key={data.name} style={styleData}>
|
||||
<GradeDistributionChart
|
||||
data={data}
|
||||
isRelative={isRelative}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A generalized pane component that is used on the Students and the Attempts tabs.
|
||||
* Contains settings and table for the corresponding tab and the pass rate chart.
|
||||
*
|
||||
* @param tableComponent - Should be either StudentsTable or AttemptsTable
|
||||
*/
|
||||
export const Pane = ({
|
||||
availableStats,
|
||||
datasets,
|
||||
initialSettings,
|
||||
tableComponent,
|
||||
updateQuery,
|
||||
userHasAccessToAllStats,
|
||||
}) => {
|
||||
const [settings, setSettings] = useState(initialSettings)
|
||||
|
||||
const setSplitDirection = splitDirection => {
|
||||
setSettings({ ...settings, splitDirection })
|
||||
}
|
||||
|
||||
const toggleSeparate = separate => {
|
||||
setSettings({ ...settings, separate })
|
||||
updateQuery(separate)
|
||||
}
|
||||
|
||||
const halfWidth = datasets.filter(dataset => dataset).length > 1 && settings.splitDirection === 'row'
|
||||
const styleContainer = {
|
||||
display: 'flex',
|
||||
flexDirection: settings.splitDirection,
|
||||
justifyContent: 'space-between',
|
||||
}
|
||||
const styleData = {
|
||||
flexGrow: 1,
|
||||
flexBasis: 1,
|
||||
marginBottom: halfWidth ? '0px' : '20px',
|
||||
maxWidth: halfWidth ? '49%' : '100%',
|
||||
}
|
||||
|
||||
return (
|
||||
<Tab.Pane>
|
||||
<Segment basic>
|
||||
<TableSection
|
||||
availableStats={availableStats}
|
||||
datasets={datasets}
|
||||
setSettings={setSettings}
|
||||
setSplitDirection={setSplitDirection}
|
||||
settings={settings}
|
||||
styleContainer={styleContainer}
|
||||
styleData={styleData}
|
||||
tableComponent={tableComponent}
|
||||
toggleSeparate={toggleSeparate}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
{settings.showGrades ? (
|
||||
<GradeDistributionChartSection
|
||||
datasets={datasets}
|
||||
isRelative={settings.isRelative}
|
||||
setIsRelative={isRelative => setSettings({ ...settings, isRelative })}
|
||||
styleContainer={styleContainer}
|
||||
styleData={styleData}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
) : (
|
||||
<PassRateChartSection
|
||||
datasets={datasets}
|
||||
isRelative={settings.isRelative}
|
||||
setIsRelative={isRelative => setSettings({ ...settings, isRelative })}
|
||||
styleContainer={styleContainer}
|
||||
styleData={styleData}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
viewMode={settings.viewMode}
|
||||
/>
|
||||
)}
|
||||
</Segment>
|
||||
</Tab.Pane>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Pane } from './Pane'
|
||||
import { StudentsTable } from './tables/StudentsTable'
|
||||
|
||||
export const StudentsPane = ({ availableStats, datasets, separate, userHasAccessToAllStats, updateQuery }) => {
|
||||
return (
|
||||
<Pane
|
||||
availableStats={availableStats}
|
||||
datasets={datasets}
|
||||
initialSettings={{ viewMode: 'STUDENTS', separate, splitDirection: 'row' }}
|
||||
tableComponent={StudentsTable}
|
||||
updateQuery={updateQuery}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
)
|
||||
}
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import {
|
||||
absoluteToRelative,
|
||||
getDataObject,
|
||||
getGradeSpread,
|
||||
getMaxValueOfSeries,
|
||||
getSeriesType,
|
||||
getThesisGradeSpread,
|
||||
} from '@/components/CourseStatistics/ResultTabs/panes/util'
|
||||
import { chartColor, color } from '@/styles/colors'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
const getGradeGraphOptions = (
|
||||
colors: string[],
|
||||
maxGradeValue: number,
|
||||
isRelative: boolean,
|
||||
statYears: string[],
|
||||
title: string
|
||||
) => ({
|
||||
chart: {
|
||||
type: 'column',
|
||||
},
|
||||
colors,
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
title: {
|
||||
text: title,
|
||||
},
|
||||
legend: {
|
||||
enabled: false,
|
||||
},
|
||||
xAxis: {
|
||||
categories: statYears,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
title: {
|
||||
text: isRelative ? 'Share of students' : 'Number of students',
|
||||
},
|
||||
max: maxGradeValue,
|
||||
floor: -maxGradeValue,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal' as const,
|
||||
borderRadius: 2,
|
||||
},
|
||||
series: {
|
||||
tooltip: {
|
||||
valueSuffix: isRelative ? '%' : '',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const calculateSumAll = (newSeries: Record<string, number[]>) => {
|
||||
return Object.values(newSeries)[0].map((_, index) =>
|
||||
Object.values(newSeries)
|
||||
.map(series => series[index])
|
||||
.reduce((a, b) => a + b, 0)
|
||||
)
|
||||
}
|
||||
|
||||
const getGradeSeries = (series: Array<Record<string, number>>) => {
|
||||
const seriesType = getSeriesType(series)
|
||||
const newSeries =
|
||||
seriesType === 'thesis'
|
||||
? (getThesisGradeSpread(series) as Record<string, number[]>)
|
||||
: (getGradeSpread(series) as Record<string, number[]>)
|
||||
const sumAll = calculateSumAll(newSeries)
|
||||
|
||||
if (seriesType === 'thesis') {
|
||||
return {
|
||||
absolute: [
|
||||
getDataObject('I', newSeries.I, 'a'),
|
||||
getDataObject('A', newSeries.A, 'b'),
|
||||
getDataObject('NSLA', newSeries.NSLA, 'c'),
|
||||
getDataObject('LUB', newSeries.LUB, 'd'),
|
||||
getDataObject('CL', newSeries.CL, 'e'),
|
||||
getDataObject('MCLA', newSeries.MCLA, 'f'),
|
||||
getDataObject('ECLA', newSeries.ECLA, 'g'),
|
||||
getDataObject('L', newSeries.L, 'h'),
|
||||
],
|
||||
relative: [
|
||||
getDataObject('I', newSeries.I.map(absoluteToRelative(sumAll)), 'a'),
|
||||
getDataObject('A', newSeries.A.map(absoluteToRelative(sumAll)), 'b'),
|
||||
getDataObject('NSLA', newSeries.NSLA.map(absoluteToRelative(sumAll)), 'c'),
|
||||
getDataObject('LUB', newSeries.LUB.map(absoluteToRelative(sumAll)), 'd'),
|
||||
getDataObject('CL', newSeries.CL.map(absoluteToRelative(sumAll)), 'e'),
|
||||
getDataObject('MCLA', newSeries.MCLA.map(absoluteToRelative(sumAll)), 'f'),
|
||||
getDataObject('ECLA', newSeries.ECLA.map(absoluteToRelative(sumAll)), 'g'),
|
||||
getDataObject('L', newSeries.L.map(absoluteToRelative(sumAll)), 'h'),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (seriesType === 'second-national-language') {
|
||||
return {
|
||||
absolute: [
|
||||
getDataObject('0', newSeries[0], 'a'),
|
||||
getDataObject('TT', newSeries.TT, 'b'),
|
||||
getDataObject('HT', newSeries.HT, 'c'),
|
||||
getDataObject('Hyv.', newSeries['Hyv.'], 'd'),
|
||||
],
|
||||
relative: [
|
||||
getDataObject('0', newSeries[0].map(absoluteToRelative(sumAll)), 'a'),
|
||||
getDataObject('TT', newSeries.TT.map(absoluteToRelative(sumAll)), 'b'),
|
||||
getDataObject('HT', newSeries.HT.map(absoluteToRelative(sumAll)), 'c'),
|
||||
getDataObject('Hyv.', newSeries['Hyv.'].map(absoluteToRelative(sumAll)), 'd'),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (seriesType === 'pass-fail') {
|
||||
return {
|
||||
absolute: [getDataObject('0', newSeries[0], 'a'), getDataObject('Hyv.', newSeries['Hyv.'], 'b')],
|
||||
relative: [
|
||||
getDataObject('0', newSeries[0].map(absoluteToRelative(sumAll)), 'a'),
|
||||
getDataObject('Hyv.', newSeries['Hyv.'].map(absoluteToRelative(sumAll)), 'b'),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
absolute: [
|
||||
getDataObject('0', newSeries[0], 'a'),
|
||||
getDataObject('1', newSeries[1], 'b'),
|
||||
getDataObject('2', newSeries[2], 'c'),
|
||||
getDataObject('3', newSeries[3], 'd'),
|
||||
getDataObject('4', newSeries[4], 'e'),
|
||||
getDataObject('5', newSeries[5], 'f'),
|
||||
getDataObject('HT', newSeries.HT, 'g'),
|
||||
getDataObject('TT', newSeries.TT, 'h'),
|
||||
getDataObject('Hyv.', newSeries['Hyv.'], 'i'),
|
||||
],
|
||||
relative: [
|
||||
getDataObject('0', newSeries[0].map(absoluteToRelative(sumAll)), 'a'),
|
||||
getDataObject('1', newSeries[1].map(absoluteToRelative(sumAll)), 'b'),
|
||||
getDataObject('2', newSeries[2].map(absoluteToRelative(sumAll)), 'c'),
|
||||
getDataObject('3', newSeries[3].map(absoluteToRelative(sumAll)), 'd'),
|
||||
getDataObject('4', newSeries[4].map(absoluteToRelative(sumAll)), 'e'),
|
||||
getDataObject('5', newSeries[5].map(absoluteToRelative(sumAll)), 'f'),
|
||||
getDataObject('HT', newSeries.HT.map(absoluteToRelative(sumAll)), 'g'),
|
||||
getDataObject('TT', newSeries.TT.map(absoluteToRelative(sumAll)), 'h'),
|
||||
getDataObject('Hyv.', newSeries['Hyv.'].map(absoluteToRelative(sumAll)), 'i'),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const getGrades = students => {
|
||||
const grades = { ...students.grades }
|
||||
const enrolledWithNoGrade = students.enrolledStudentsWithNoGrade || 0
|
||||
grades[0] = (grades[0] || 0) + enrolledWithNoGrade
|
||||
return grades
|
||||
}
|
||||
|
||||
export const GradeDistributionChart = ({ data, isRelative, userHasAccessToAllStats }) => {
|
||||
const stats = data.stats.filter(stat => stat.name !== 'Total' || isRelative)
|
||||
|
||||
const statYears = stats.map(year => year.name)
|
||||
const grades = stats.map(year => getGrades(year.students))
|
||||
|
||||
const gradeGraphSeries = getGradeSeries(grades)
|
||||
const seriesType = getSeriesType(grades)
|
||||
|
||||
const maxGradeValue = isRelative ? 100 : getMaxValueOfSeries(gradeGraphSeries.absolute)
|
||||
const title = `Grades for group ${data.name}`
|
||||
|
||||
const colors = {
|
||||
other: [
|
||||
color.red,
|
||||
chartColor.blue,
|
||||
chartColor.blue,
|
||||
chartColor.blue,
|
||||
chartColor.blue,
|
||||
chartColor.blue,
|
||||
color.green,
|
||||
color.green,
|
||||
color.green,
|
||||
],
|
||||
'pass-fail': [color.red, color.green],
|
||||
'second-national-language': [color.red, color.green, color.green, color.green],
|
||||
thesis: [chartColor.blue],
|
||||
}
|
||||
|
||||
const primaryDistributionOptions = getGradeGraphOptions(
|
||||
colors[seriesType],
|
||||
maxGradeValue,
|
||||
isRelative,
|
||||
statYears,
|
||||
title
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactHighcharts
|
||||
config={{
|
||||
...primaryDistributionOptions,
|
||||
series: isRelative ? gradeGraphSeries.relative : gradeGraphSeries.absolute,
|
||||
}}
|
||||
/>
|
||||
{!userHasAccessToAllStats && (
|
||||
<span className="totalsDisclaimer">* Years with 5 students or fewer are shown as 0 in the chart</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-182
@@ -1,182 +0,0 @@
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import {
|
||||
absoluteToRelative,
|
||||
getDataObject,
|
||||
getMaxValueOfSeries,
|
||||
} from '@/components/CourseStatistics/ResultTabs/panes/util'
|
||||
import { chartColor, color } from '@/styles/colors'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
const colorsRelative = [color.green, color.red, chartColor.redLight]
|
||||
const colors = [chartColor.blue, ...colorsRelative]
|
||||
|
||||
const passRateAttemptGraphOptions = (isRelative: boolean, categories: string[], max: number, title: string) => ({
|
||||
chart: {
|
||||
type: 'column',
|
||||
},
|
||||
colors: isRelative ? colorsRelative : colors,
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
title: {
|
||||
text: title,
|
||||
},
|
||||
xAxis: {
|
||||
categories,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
title: {
|
||||
text: isRelative ? 'Share of students' : 'Number of students',
|
||||
},
|
||||
max,
|
||||
floor: -max,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal' as const,
|
||||
borderRadius: 3,
|
||||
},
|
||||
series: {
|
||||
tooltip: {
|
||||
valueSuffix: isRelative ? '%' : '',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const passRateStudentGraphOptions = (isRelative: boolean, categories: string[], max: number, title: string) => ({
|
||||
chart: {
|
||||
type: 'column',
|
||||
},
|
||||
colors: isRelative ? colorsRelative : colors,
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
title: {
|
||||
text: title,
|
||||
},
|
||||
xAxis: {
|
||||
categories,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
title: {
|
||||
text: isRelative ? 'Share of students' : 'Number of students',
|
||||
},
|
||||
max,
|
||||
floor: -max,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal' as const,
|
||||
borderRadius: 1,
|
||||
},
|
||||
series: {
|
||||
tooltip: {
|
||||
valueSuffix: isRelative ? '%' : '',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getPassRateAttemptSeriesFromStats = stats => {
|
||||
const all: number[] = []
|
||||
const passed: number[] = []
|
||||
const failed: number[] = []
|
||||
const enrolledNoGrade: number[] = []
|
||||
|
||||
stats.forEach(year => {
|
||||
const { passed: p, failed: f } = year.attempts.categories
|
||||
const { totalEnrollments } = year.attempts
|
||||
const enrolledWithNoGrade = totalEnrollments > 0 ? totalEnrollments - p - f : 0
|
||||
all.push(totalEnrollments || p + f)
|
||||
passed.push(p)
|
||||
failed.push(f)
|
||||
enrolledNoGrade.push(enrolledWithNoGrade)
|
||||
})
|
||||
|
||||
return {
|
||||
absolute: [
|
||||
getDataObject('all', all, 'a'),
|
||||
getDataObject('passed', passed, 'b'),
|
||||
getDataObject('failed', failed, 'c'),
|
||||
getDataObject('enrolled, no grade', enrolledNoGrade, 'c'),
|
||||
],
|
||||
relative: [
|
||||
getDataObject('passed', passed.map(absoluteToRelative(all)), 'a'),
|
||||
getDataObject('failed', failed.map(absoluteToRelative(all)), 'a'),
|
||||
getDataObject('enrolled, no grade', enrolledNoGrade.map(absoluteToRelative(all)), 'a'),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const getPassRateStudentSeriesFromStats = stats => {
|
||||
const all: number[] = []
|
||||
const passed: number[] = []
|
||||
const failed: number[] = []
|
||||
const enrolledNoGrade: number[] = []
|
||||
|
||||
stats.forEach(year => {
|
||||
all.push(year.students.total || 0)
|
||||
passed.push(year.students.totalPassed || 0)
|
||||
failed.push(year.students.totalFailed || 0)
|
||||
enrolledNoGrade.push(year.students.enrolledStudentsWithNoGrade || 0)
|
||||
})
|
||||
|
||||
return {
|
||||
absolute: [
|
||||
getDataObject('all', all, 'a'),
|
||||
getDataObject('passed', passed, 'b'),
|
||||
getDataObject('failed', failed, 'c'),
|
||||
getDataObject('enrolled, no grade', enrolledNoGrade, 'c'),
|
||||
],
|
||||
relative: [
|
||||
getDataObject('passed', passed.map(absoluteToRelative(all)), 'a'),
|
||||
getDataObject('failed', failed.map(absoluteToRelative(all)), 'a'),
|
||||
getDataObject('enrolled, no grade', enrolledNoGrade.map(absoluteToRelative(all)), 'a'),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
interface PassRateChartProps {
|
||||
data: any
|
||||
isRelative: boolean
|
||||
userHasAccessToAllStats: boolean
|
||||
viewMode: 'ATTEMPTS' | 'STUDENTS'
|
||||
}
|
||||
|
||||
export const PassRateChart = ({ data, isRelative, userHasAccessToAllStats, viewMode }: PassRateChartProps) => {
|
||||
const stats = data.stats.filter(stat => stat.name !== 'Total')
|
||||
const statYears = stats.map(year => year.name)
|
||||
|
||||
const isAttemptsMode = viewMode === 'ATTEMPTS'
|
||||
const passRateGraphSeries = isAttemptsMode
|
||||
? getPassRateAttemptSeriesFromStats(stats)
|
||||
: getPassRateStudentSeriesFromStats(stats)
|
||||
|
||||
const maxPassRateVal = isRelative ? 100 : getMaxValueOfSeries(passRateGraphSeries.absolute)
|
||||
const graphOptions = isAttemptsMode ? passRateAttemptGraphOptions : passRateStudentGraphOptions
|
||||
const primaryGraphOptions = graphOptions(isRelative, statYears, maxPassRateVal, `Pass rate for group ${data.name}`)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactHighcharts
|
||||
config={{
|
||||
...primaryGraphOptions,
|
||||
series: isRelative ? passRateGraphSeries.relative : passRateGraphSeries.absolute,
|
||||
}}
|
||||
/>
|
||||
{!userHasAccessToAllStats && (
|
||||
<span className="totalsDisclaimer">* Years with 5 students or fewer are shown as 0 in the chart</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import { Radio, Segment, SegmentGroup } from 'semantic-ui-react'
|
||||
|
||||
import { courseStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { InfoBox } from '@/components/InfoBox'
|
||||
import { Setting } from './common/Setting'
|
||||
|
||||
export const ChartSettings = ({ isRelative, setIsRelative, tab, viewMode }) => {
|
||||
return (
|
||||
<div style={{ alignItems: 'center', display: 'flex', marginTop: '20px' }}>
|
||||
<SegmentGroup horizontal>
|
||||
<Segment>
|
||||
<Setting labelText="Show relative">
|
||||
<Radio checked={isRelative} onChange={() => setIsRelative(!isRelative)} toggle />
|
||||
</Setting>
|
||||
</Segment>
|
||||
</SegmentGroup>
|
||||
<div style={{ marginLeft: '20px' }}>
|
||||
{tab === 'GradeDistribution' ? (
|
||||
<InfoBox content={courseStatisticsToolTips.gradeDistribution} popup />
|
||||
) : (
|
||||
<InfoBox content={courseStatisticsToolTips.passRate[viewMode]} popup />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
import { Radio, Segment, SegmentGroup } from 'semantic-ui-react'
|
||||
|
||||
import { courseStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { InfoBox } from '@/components/InfoBox'
|
||||
import { DirectionToggle } from './common/DirectionToggle'
|
||||
import { ProviderOrganization } from './common/ProviderOrganization'
|
||||
import { Setting } from './common/Setting'
|
||||
|
||||
export const TableSettings = ({ availableStats, datasets, onChange, onSeparateChange, setSplitDirection, value }) => {
|
||||
const { showGrades, separate, splitDirection, viewMode } = value
|
||||
|
||||
const settings = [
|
||||
<Setting key="gradeToggle" labelText="Show grades">
|
||||
<Radio
|
||||
checked={showGrades}
|
||||
data-cy="gradeToggle"
|
||||
onChange={() => onChange({ ...value, showGrades: !showGrades })}
|
||||
toggle
|
||||
/>
|
||||
</Setting>,
|
||||
<Setting key="separateToggle" labelText="Separate by semesters">
|
||||
<Radio checked={separate} data-cy="separateToggle" onChange={() => onSeparateChange(!separate)} toggle />
|
||||
</Setting>,
|
||||
<Setting key="providerOrganization" labelText="Provider organization(s)">
|
||||
<ProviderOrganization availableStats={availableStats} />
|
||||
</Setting>,
|
||||
]
|
||||
|
||||
if (datasets.filter(dataset => dataset).length > 1) {
|
||||
settings.push(
|
||||
<Setting key="splitDirection" labelText="Split direction">
|
||||
<DirectionToggle setSplitDirection={setSplitDirection} splitDirection={splitDirection} />
|
||||
</Setting>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ alignItems: 'center', display: 'flex' }}>
|
||||
<SegmentGroup horizontal>
|
||||
{settings.map(setting => (
|
||||
<Segment key={setting.key}>{setting}</Segment>
|
||||
))}
|
||||
</SegmentGroup>
|
||||
<div style={{ marginLeft: '20px' }}>
|
||||
<InfoBox content={courseStatisticsToolTips.tables[viewMode]} popup />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import { Icon, Menu } from 'semantic-ui-react'
|
||||
|
||||
export const DirectionToggle = ({ setSplitDirection, splitDirection }) => {
|
||||
return (
|
||||
<Menu>
|
||||
<Menu.Item active={splitDirection === 'row'} onClick={() => setSplitDirection('row')}>
|
||||
<Icon name="arrows alternate horizontal" />
|
||||
</Menu.Item>
|
||||
<Menu.Item active={splitDirection === 'column'} onClick={() => setSplitDirection('column')}>
|
||||
<Icon name="arrows alternate vertical" />
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Checkbox, Segment, SegmentGroup } from 'semantic-ui-react'
|
||||
|
||||
import { toggleOpenAndRegularCourses } from '@/redux/coursesearch'
|
||||
|
||||
export const ProviderOrganization = ({ availableStats }) => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const openOrRegular = useSelector(state => state.courseSearch.openOrRegular)
|
||||
|
||||
const toggleValue = (_event, { value }) => {
|
||||
const options = {
|
||||
regularStats: {
|
||||
regularStats: 'openStats',
|
||||
openStats: 'unifyStats',
|
||||
unifyStats: 'openStats',
|
||||
},
|
||||
openStats: {
|
||||
openStats: 'regularStats',
|
||||
regularStats: 'unifyStats',
|
||||
unifyStats: 'regularStats',
|
||||
},
|
||||
}
|
||||
return dispatch(toggleOpenAndRegularCourses(options[value][openOrRegular]))
|
||||
}
|
||||
|
||||
return (
|
||||
<SegmentGroup horizontal style={{ margin: 0, padding: 0 }}>
|
||||
<Segment>
|
||||
<Checkbox
|
||||
checked={openOrRegular === 'regularStats' || openOrRegular === 'unifyStats'}
|
||||
data-cy="providerCheckboxUniversity"
|
||||
disabled={!availableStats.university}
|
||||
label={availableStats.university ? 'University' : 'University (no data)'}
|
||||
name="radioGroup"
|
||||
onChange={toggleValue}
|
||||
value="regularStats"
|
||||
/>
|
||||
</Segment>
|
||||
<Segment>
|
||||
<Checkbox
|
||||
checked={openOrRegular === 'openStats' || openOrRegular === 'unifyStats'}
|
||||
data-cy="providerCheckboxOpenUni"
|
||||
disabled={!availableStats.open}
|
||||
label={availableStats.open ? 'Open uni' : 'Open uni (no data)'}
|
||||
name="radioGroup"
|
||||
onChange={toggleValue}
|
||||
value="openStats"
|
||||
/>
|
||||
</Segment>
|
||||
</SegmentGroup>
|
||||
)
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
export const Setting = ({ children, labelText }) => {
|
||||
return (
|
||||
<div style={{ alignItems: 'center', display: 'flex', flexDirection: 'column' }}>
|
||||
<label style={{ marginBottom: '10px' }}>{labelText}</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '60px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-187
@@ -1,187 +0,0 @@
|
||||
import { mapValues, uniq } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link } from 'react-router'
|
||||
import { Header, Icon, Item } from 'semantic-ui-react'
|
||||
|
||||
import {
|
||||
getGradeSpread,
|
||||
getSortableColumn,
|
||||
getThesisGradeSpread,
|
||||
isThesisGrades,
|
||||
resolveGrades,
|
||||
} from '@/components/CourseStatistics/ResultTabs/panes/util'
|
||||
import { SortableTable, row } from '@/components/SortableTable'
|
||||
import { getCourseAlternatives } from '@/selectors/courseStats'
|
||||
|
||||
const getTableData = (stats, useThesisGrades, isRelative) =>
|
||||
stats.map(stat => {
|
||||
const {
|
||||
name,
|
||||
code,
|
||||
attempts: { grades, totalEnrollments },
|
||||
coursecode,
|
||||
rowObfuscated,
|
||||
} = stat
|
||||
|
||||
const attemptsWithGrades = Object.values(grades).reduce((cur, acc) => acc + cur, 0)
|
||||
const attempts = totalEnrollments || attemptsWithGrades
|
||||
const gradeSpread = useThesisGrades
|
||||
? getThesisGradeSpread([grades], isRelative)
|
||||
: getGradeSpread([grades], isRelative)
|
||||
|
||||
const mapped = {
|
||||
name,
|
||||
code,
|
||||
coursecode,
|
||||
passed: stat.attempts.categories.passed,
|
||||
failed: stat.attempts.categories.failed,
|
||||
totalEnrollments: stat.attempts.totalEnrollments,
|
||||
passRate: stat.attempts.passRate,
|
||||
attempts: stat.attempts.totalAttempts || attempts,
|
||||
rowObfuscated,
|
||||
...mapValues(gradeSpread, x => x[0]),
|
||||
}
|
||||
|
||||
if (mapped.name === 'Total') {
|
||||
return row(mapped, { ignoreFilters: true })
|
||||
}
|
||||
|
||||
return mapped
|
||||
})
|
||||
|
||||
const getGradeColumns = grades => {
|
||||
return grades.map(({ key, title }) =>
|
||||
getSortableColumn({
|
||||
key,
|
||||
title,
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s[key] || 0),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export const AttemptsTable = ({
|
||||
data: { stats, name },
|
||||
settings: { showGrades, separate, isRelative },
|
||||
userHasAccessToAllStats,
|
||||
headerVisible = false,
|
||||
}) => {
|
||||
const {
|
||||
attempts: { grades },
|
||||
} = stats[0]
|
||||
const useThesisGrades = isThesisGrades(grades)
|
||||
const alternatives = useSelector(getCourseAlternatives)
|
||||
const unifyCourses = useSelector(state => state.courseSearch.openOrRegular)
|
||||
|
||||
const showPopulation = (yearCode, years, unifyCourses) => {
|
||||
const queryObject = {
|
||||
from: yearCode,
|
||||
to: yearCode,
|
||||
coursecodes: JSON.stringify(uniq(alternatives)),
|
||||
years,
|
||||
separate,
|
||||
unifyCourses,
|
||||
}
|
||||
const searchString = qs.stringify(queryObject)
|
||||
return `/coursepopulation?${searchString}`
|
||||
}
|
||||
|
||||
const timeColumn = {
|
||||
key: 'TIME-PARENT',
|
||||
merge: true,
|
||||
mergeHeader: true,
|
||||
children: [
|
||||
getSortableColumn({
|
||||
key: 'TIME',
|
||||
title: 'Time',
|
||||
filterType: 'range',
|
||||
cellProps: {},
|
||||
getRowVal: s => s.code + 1949,
|
||||
getRowExportVal: s => s.name,
|
||||
getRowContent: s => (
|
||||
<div>
|
||||
{s.name}
|
||||
{s.name === 'Total' && !userHasAccessToAllStats && <strong>*</strong>}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
{
|
||||
key: 'TIME-ICON',
|
||||
export: false,
|
||||
getRowContent: s =>
|
||||
s.name !== 'Total' &&
|
||||
userHasAccessToAllStats && (
|
||||
<Item as={Link} to={showPopulation(s.code, s.name, unifyCourses)}>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
let columns = [
|
||||
timeColumn,
|
||||
getSortableColumn({
|
||||
key: 'ATTEMPTS',
|
||||
title: 'Total\nattempts',
|
||||
getRowVal: s => (s.rowObfuscated ? '5 or fewer students' : s.attempts),
|
||||
}),
|
||||
getSortableColumn({
|
||||
key: 'PASSED',
|
||||
title: 'Passed',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.passed),
|
||||
}),
|
||||
getSortableColumn({
|
||||
key: 'FAILED',
|
||||
title: 'Failed',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.failed),
|
||||
}),
|
||||
getSortableColumn({
|
||||
key: 'PASSRATE',
|
||||
title: 'Pass rate',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.passRate),
|
||||
getRowContent: s => (s.rowObfuscated ? 'NA' : `${Number(s.passRate || 0).toFixed(2)} %`),
|
||||
}),
|
||||
getSortableColumn({
|
||||
key: 'ENROLLMENTS',
|
||||
title: 'Enrollments',
|
||||
helpText: 'All enrollments with enrolled state',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.totalEnrollments),
|
||||
}),
|
||||
]
|
||||
|
||||
if (showGrades) {
|
||||
columns = [
|
||||
timeColumn,
|
||||
getSortableColumn({
|
||||
key: 'ATTEMPTS',
|
||||
title: 'Total\nattempts',
|
||||
getRowVal: s => (s.rowObfuscated ? '5 or fewer students' : s.attempts),
|
||||
}),
|
||||
...getGradeColumns(resolveGrades(stats)),
|
||||
]
|
||||
}
|
||||
|
||||
const data = getTableData(stats, useThesisGrades, isRelative)
|
||||
|
||||
return (
|
||||
<>
|
||||
{headerVisible && (
|
||||
<Header as="h3" textAlign="center">
|
||||
{name}
|
||||
</Header>
|
||||
)}
|
||||
<SortableTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
defaultSort={['TIME', 'desc']}
|
||||
featureName="yearly_attempts"
|
||||
maxHeight="40vh"
|
||||
title={`Yearly attempt statistics for group ${name}`}
|
||||
/>
|
||||
{!userHasAccessToAllStats && (
|
||||
<span className="totalsDisclaimer">* Years with 5 students or fewer are NOT included in the total</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
-213
@@ -1,213 +0,0 @@
|
||||
import { cloneDeep, uniq } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link } from 'react-router'
|
||||
import { Header, Icon, Item } from 'semantic-ui-react'
|
||||
|
||||
import { isDefaultServiceProvider } from '@/common'
|
||||
import { defineCellColor, getSortableColumn, resolveGrades } from '@/components/CourseStatistics/ResultTabs/panes/util'
|
||||
import { SortableTable, row } from '@/components/SortableTable'
|
||||
import { getCourseAlternatives } from '@/selectors/courseStats'
|
||||
|
||||
const formatPercentage = passRate => (Number.isNaN(passRate) ? '–' : `${(passRate * 100).toFixed(2)} %`)
|
||||
|
||||
const getGradeColumns = grades => {
|
||||
return grades.map(({ key, title }) =>
|
||||
getSortableColumn({
|
||||
key,
|
||||
title,
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.grades[key] || 0),
|
||||
onlyInGradeView: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const getColumns = (stats, showGrades, userHasAccessToAllStats, alternatives, separate, unifyCourses) => {
|
||||
const showPopulation = (yearcode, years) => {
|
||||
const queryObject = {
|
||||
from: yearcode,
|
||||
to: yearcode,
|
||||
coursecodes: JSON.stringify(uniq(alternatives.map(course => course.code))),
|
||||
years,
|
||||
separate,
|
||||
unifyCourses,
|
||||
}
|
||||
const searchString = qs.stringify(queryObject)
|
||||
return `/coursepopulation?${searchString}`
|
||||
}
|
||||
const toskaColumns = [
|
||||
{
|
||||
key: 'TIME_PARENT',
|
||||
merge: true,
|
||||
mergeHeader: true,
|
||||
children: [
|
||||
{
|
||||
key: 'TIME',
|
||||
title: 'Time',
|
||||
filterType: 'range',
|
||||
getRowVal: s => s.code + 1949,
|
||||
getRowExportVal: s => s.name,
|
||||
getRowContent: s => (
|
||||
<div style={{ whiteSpace: 'nowrap' }}>
|
||||
{s.name}
|
||||
{s.name === 'Total' && !userHasAccessToAllStats && <strong>*</strong>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'TIME_ICON',
|
||||
export: false,
|
||||
getRowContent: s => {
|
||||
if (s.name !== 'Total' && userHasAccessToAllStats) {
|
||||
return (
|
||||
<Item as={Link} to={showPopulation(s.code, s.name, s)}>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'TOTAL_STUDENTS',
|
||||
title: 'Total\nstudents',
|
||||
helpText: 'Total count of students, including enrolled students with no grade',
|
||||
cellProps: s => ({
|
||||
style: {
|
||||
textAlign: 'right',
|
||||
color: s.rowObfuscated ? 'gray' : 'inherit',
|
||||
},
|
||||
}),
|
||||
filterType: 'range',
|
||||
getRowVal: s => (s.rowObfuscated ? 5 : s.students.total),
|
||||
getRowContent: s => (s.rowObfuscated ? '5 or fewer students' : s.students.total),
|
||||
getCellProps: s => defineCellColor(s.rowObfuscated),
|
||||
},
|
||||
{
|
||||
key: 'TOTAL_PASSED',
|
||||
title: 'Passed',
|
||||
filterType: 'range',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.totalPassed || 0),
|
||||
cellProps: s => ({
|
||||
style: {
|
||||
textAlign: 'right',
|
||||
color: s.rowObfuscated ? 'gray' : 'inherit',
|
||||
},
|
||||
}),
|
||||
hideWhenGradesVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'TOTAL_FAILED',
|
||||
title: 'Failed',
|
||||
filterType: 'range',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.totalFailed || 0),
|
||||
cellProps: s => ({
|
||||
style: {
|
||||
textAlign: 'right',
|
||||
color: s.rowObfuscated ? 'gray' : 'inherit',
|
||||
},
|
||||
}),
|
||||
hideWhenGradesVisible: true,
|
||||
},
|
||||
...getGradeColumns(resolveGrades(stats)),
|
||||
{
|
||||
key: 'ENROLLED_NO_GRADE',
|
||||
title: 'Enrolled,\nno grade',
|
||||
filterType: 'range',
|
||||
helpText: 'Total count of students with a valid enrollment and no passing or failing grade',
|
||||
getRowVal: s => (s.rowObfuscated ? 5 : s.students.enrolledStudentsWithNoGrade),
|
||||
getRowContent: s => (s.rowObfuscated ? '5 or fewer students' : s.students.enrolledStudentsWithNoGrade),
|
||||
cellProps: s => ({
|
||||
style: {
|
||||
textAlign: 'right',
|
||||
color: s.rowObfuscated ? 'gray' : 'inherit',
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'PASS_RATE',
|
||||
title: 'Pass rate',
|
||||
getRowVal: s => (s.rowObfuscated ? 0 : s.students.passRate * 100),
|
||||
getRowContent: s => (s.rowObfuscated ? '5 or fewer students' : formatPercentage(s.students.passRate)),
|
||||
filterType: 'range',
|
||||
cellProps: s => ({
|
||||
style: {
|
||||
textAlign: 'right',
|
||||
color: s.rowObfuscated ? 'gray' : 'inherit',
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'FAIL_RATE',
|
||||
title: 'Fail rate',
|
||||
filterType: 'range',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : (s.students.failRate || 0) * 100),
|
||||
getRowContent: s => (s.rowObfuscated ? 'NA' : formatPercentage(s.students.failRate || 0)),
|
||||
cellProps: s => ({
|
||||
style: {
|
||||
textAlign: 'right',
|
||||
color: s.rowObfuscated ? 'gray' : 'inherit',
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const fdColums = cloneDeep(toskaColumns)
|
||||
const index = fdColums.findIndex(o => o.key === 'TIME_PARENT')
|
||||
if (index !== -1) {
|
||||
fdColums[index].children = fdColums[index].children.filter(o => o.key === 'TIME')
|
||||
}
|
||||
|
||||
const columns = isDefaultServiceProvider() ? toskaColumns : fdColums
|
||||
return columns.filter(column => {
|
||||
if (showGrades && column.onlyInGradeView) return true
|
||||
if (showGrades && column.hideWhenGradesVisible) return false
|
||||
return !column.onlyInGradeView
|
||||
})
|
||||
}
|
||||
|
||||
export const StudentsTable = ({
|
||||
data: { name, stats },
|
||||
settings: { separate, showGrades },
|
||||
userHasAccessToAllStats,
|
||||
headerVisible = false,
|
||||
}) => {
|
||||
const alternatives = useSelector(getCourseAlternatives)
|
||||
const unifyCourses = useSelector(state => state.courseSearch.openOrRegular)
|
||||
|
||||
const columns = useMemo(
|
||||
() => getColumns(stats, showGrades, userHasAccessToAllStats, alternatives, separate, unifyCourses),
|
||||
[stats, showGrades, userHasAccessToAllStats, alternatives, separate, unifyCourses]
|
||||
)
|
||||
|
||||
const data = stats.map(stats => {
|
||||
if (stats.name === 'Total') {
|
||||
return row(stats, { ignoreFilters: true })
|
||||
}
|
||||
return stats
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{headerVisible && (
|
||||
<Header as="h3" textAlign="center">
|
||||
{name}
|
||||
</Header>
|
||||
)}
|
||||
<SortableTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
defaultSort={['TIME', 'desc']}
|
||||
featureName="group_statistics"
|
||||
maxHeight="40vh"
|
||||
title={`Student statistics for group ${name}`}
|
||||
/>
|
||||
{!userHasAccessToAllStats && (
|
||||
<span className="totalsDisclaimer">* Years with 5 students or fewer are NOT included in the total</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import { flatten } from 'lodash'
|
||||
|
||||
import { calculatePercentage } from '@/common'
|
||||
|
||||
type Series = {
|
||||
name: string
|
||||
data: number[]
|
||||
stack: string
|
||||
type: 'column'
|
||||
}
|
||||
|
||||
export const getDataObject = (name: string, data: number[], stack: string): Series => {
|
||||
return {
|
||||
name,
|
||||
data,
|
||||
stack,
|
||||
type: 'column' as const,
|
||||
}
|
||||
}
|
||||
|
||||
export const getMaxValueOfSeries = (series: Series[]) => {
|
||||
return Object.values(series).reduce((acc, cur) => {
|
||||
const curMax = Math.max(...cur.data.filter(n => !Number.isNaN(n)).map(Math.abs))
|
||||
return curMax >= acc ? curMax : acc
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const THESIS_GRADES = ['I', 'A', 'NSLA', 'LUB', 'CL', 'MCLA', 'ECLA', 'L']
|
||||
const SECOND_NATIONAL_LANGUAGE_GRADES = ['TT', 'HT']
|
||||
const PASS_FAIL_GRADES = ['0', 'Hyv.']
|
||||
const NUMERIC_GRADES = ['1', '2', '3', '4', '5']
|
||||
|
||||
export const isThesisGrades = (grades: Record<string, number>) => {
|
||||
return Object.keys(grades).some(grade => THESIS_GRADES.includes(grade))
|
||||
}
|
||||
|
||||
const isThesisSeries = (series: Array<Record<string, number>>) => {
|
||||
return series?.some(record => isThesisGrades(record))
|
||||
}
|
||||
|
||||
const isSecondNationalLanguageSeries = (series: Array<Record<string, number>>) => {
|
||||
return series?.every(record => {
|
||||
const grades = Object.keys(record)
|
||||
const hasPassFailGrades = grades.some(grade => PASS_FAIL_GRADES.includes(grade))
|
||||
const hasNumericGrades = grades.some(grade => NUMERIC_GRADES.includes(grade))
|
||||
const hasSecondNationalLanguageGrades = grades.some(grade => SECOND_NATIONAL_LANGUAGE_GRADES.includes(grade))
|
||||
return !hasNumericGrades && hasPassFailGrades && hasSecondNationalLanguageGrades
|
||||
})
|
||||
}
|
||||
|
||||
const isPassFailSeries = (series: Array<Record<string, number>>) => {
|
||||
return series?.every(record => {
|
||||
const grades = Object.keys(record)
|
||||
const hasPassFailGrades = grades.some(grade => PASS_FAIL_GRADES.includes(grade))
|
||||
const hasNumericGrades = grades.some(grade => NUMERIC_GRADES.includes(grade))
|
||||
const hasSecondNationalLanguageGrades = grades.some(grade => SECOND_NATIONAL_LANGUAGE_GRADES.includes(grade))
|
||||
return !hasNumericGrades && hasPassFailGrades && !hasSecondNationalLanguageGrades
|
||||
})
|
||||
}
|
||||
|
||||
export const getSeriesType = (series: Array<Record<string, number>>) => {
|
||||
if (isThesisSeries(series)) {
|
||||
return 'thesis'
|
||||
}
|
||||
if (isSecondNationalLanguageSeries(series)) {
|
||||
return 'second-national-language'
|
||||
}
|
||||
if (isPassFailSeries(series)) {
|
||||
return 'pass-fail'
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export const absoluteToRelative = (all: number[]) => (p: number, i: number) => {
|
||||
return parseFloat(calculatePercentage(p, all[i]).slice(0, -1))
|
||||
}
|
||||
|
||||
const gradesOrder = {
|
||||
0: 0,
|
||||
1: 10,
|
||||
2: 20,
|
||||
3: 30,
|
||||
4: 40,
|
||||
5: 50,
|
||||
'Hyv.': 60,
|
||||
I: 0,
|
||||
A: 1,
|
||||
LUB: 2,
|
||||
NSLA: 3,
|
||||
CL: 4,
|
||||
MCLA: 5,
|
||||
ECLA: 6,
|
||||
L: 7,
|
||||
} as const
|
||||
|
||||
const sortGrades = (a: string, b: string) => gradesOrder[a] - gradesOrder[b]
|
||||
|
||||
const FAILED_GRADES = ['eisa', 'hyl.', 'hyl', '0', 'luop']
|
||||
const OTHER_PASSED_GRADES = ['hyv.', 'hyv']
|
||||
|
||||
const getSortedGrades = (grades: string[]) => {
|
||||
return grades.sort(sortGrades).map(grade => {
|
||||
if (grade === '0') {
|
||||
return { key: grade, title: 'Failed' }
|
||||
}
|
||||
if (OTHER_PASSED_GRADES.includes(grade.toLowerCase())) {
|
||||
return { key: grade, title: 'Other\npassed' }
|
||||
}
|
||||
return { key: grade, title: grade.charAt(0).toUpperCase() + grade.slice(1) }
|
||||
})
|
||||
}
|
||||
|
||||
export const resolveGrades = stats => {
|
||||
const allGrades = [
|
||||
'0',
|
||||
...flatten(
|
||||
stats.map(({ students }) =>
|
||||
[...Object.keys(students.grades)].map(grade => {
|
||||
const parsedGrade = Number(grade) ? Math.round(Number(grade)).toString() : grade
|
||||
if (FAILED_GRADES.includes(parsedGrade.toLowerCase())) {
|
||||
return '0'
|
||||
}
|
||||
if (parsedGrade === 'LA') {
|
||||
return 'LUB'
|
||||
}
|
||||
return parsedGrade
|
||||
})
|
||||
)
|
||||
),
|
||||
] as string[]
|
||||
if (allGrades.filter(grade => NUMERIC_GRADES.includes(grade)).length) {
|
||||
allGrades.push(...['1', '2', '3', '4', '5'])
|
||||
}
|
||||
const grades = [...new Set(allGrades)]
|
||||
return getSortedGrades(grades)
|
||||
}
|
||||
|
||||
export const getSortableColumn = opts => ({
|
||||
filterType: 'range',
|
||||
cellProps: s => ({
|
||||
style: {
|
||||
textAlign: 'right',
|
||||
color: s.rowObfuscated ? 'gray' : 'inherit',
|
||||
},
|
||||
}),
|
||||
...opts,
|
||||
})
|
||||
|
||||
const accumulateGrades = (
|
||||
series: Array<Record<string, number>>,
|
||||
baseAccumulator: Record<string, number[]>,
|
||||
getMergedKey: (key: string) => string,
|
||||
failedKeys: string[] = []
|
||||
) => {
|
||||
return series.reduce(
|
||||
(acc, cur, i) => {
|
||||
const currentEntries = Object.entries(cur)
|
||||
let failed = 0
|
||||
|
||||
currentEntries.forEach(([k, v]) => {
|
||||
const mergedKey = getMergedKey(k)
|
||||
if (failedKeys.includes(mergedKey.toLowerCase())) {
|
||||
failed += v
|
||||
} else {
|
||||
acc[mergedKey].push(v)
|
||||
}
|
||||
})
|
||||
|
||||
if (failed > 0 && acc[0]) {
|
||||
acc[0].push(failed)
|
||||
}
|
||||
|
||||
Object.entries(acc).forEach(([k, v]) => {
|
||||
if (v.length < i + 1) {
|
||||
acc[k].push(0)
|
||||
}
|
||||
})
|
||||
|
||||
return acc
|
||||
},
|
||||
{ ...baseAccumulator } as Record<string, number[]>
|
||||
)
|
||||
}
|
||||
|
||||
const calculateTotal = (series: Record<string, number[]>) => {
|
||||
return Object.keys(series).reduce((acc, curr) => {
|
||||
const numOfGrades = series[curr][0]
|
||||
return acc + numOfGrades
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const calculateRelative = (series: Record<string, number[]>, total: number) => {
|
||||
return Object.keys(series).reduce(
|
||||
(acc, curr) => {
|
||||
acc[curr] = `${Math.round((series[curr][0] / total) * 10000) / 100}%`
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
}
|
||||
|
||||
export const getThesisGradeSpread = (series: Array<Record<string, number>>, isRelative?: boolean) => {
|
||||
const thesisGradeAccumulator = {
|
||||
L: [],
|
||||
ECLA: [],
|
||||
MCLA: [],
|
||||
CL: [],
|
||||
LUB: [],
|
||||
NSLA: [],
|
||||
A: [],
|
||||
I: [],
|
||||
0: [],
|
||||
1: [],
|
||||
2: [],
|
||||
3: [],
|
||||
4: [],
|
||||
5: [],
|
||||
'Hyv.': [],
|
||||
} as Record<string, number[]>
|
||||
|
||||
const getMergedKey = (key: string) => (key === 'LA' ? 'LUB' : key)
|
||||
|
||||
const newSeries = accumulateGrades(series, thesisGradeAccumulator, getMergedKey)
|
||||
const total = calculateTotal(newSeries)
|
||||
|
||||
return isRelative ? calculateRelative(newSeries, total) : newSeries
|
||||
}
|
||||
|
||||
export const getGradeSpread = (series: Array<Record<string, number>>, isRelative?: boolean) => {
|
||||
const gradeAccumulator = {
|
||||
0: [],
|
||||
1: [],
|
||||
2: [],
|
||||
3: [],
|
||||
4: [],
|
||||
5: [],
|
||||
HT: [],
|
||||
TT: [],
|
||||
'Hyv.': [],
|
||||
} as Record<string, number[]>
|
||||
|
||||
const failedKeys = ['eisa', 'hyl.', 'hyl', '0', 'luop']
|
||||
|
||||
const getMergedKey = (key: string) => (Number(key) ? Math.round(Number(key)).toString() : key)
|
||||
|
||||
const newSeries = accumulateGrades(series, gradeAccumulator, getMergedKey, failedKeys)
|
||||
const total = calculateTotal(newSeries)
|
||||
|
||||
return isRelative ? calculateRelative(newSeries, total) : newSeries
|
||||
}
|
||||
|
||||
export const defineCellColor = (rowObfuscated: boolean) => {
|
||||
return rowObfuscated && { style: { color: 'gray' } }
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
.modeSelectorRow {
|
||||
margin-top: 1em !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.modeSelectorContainer {
|
||||
margin-left: 2em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chartToggleContainer {
|
||||
display: flex;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.toggleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
cursor: pointer;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
.totalsDisclaimer {
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Input } from 'semantic-ui-react'
|
||||
|
||||
import { Timeout } from '@/components/Timeout'
|
||||
|
||||
enum Timeouts {
|
||||
FETCH = 'fetch',
|
||||
SEARCH = 'search',
|
||||
}
|
||||
|
||||
interface AutoSubmitSearchInputProps {
|
||||
cypress: string
|
||||
doSearch: (searchTerm: string) => void
|
||||
inputValue: string
|
||||
loading: boolean
|
||||
onChange: (searchTerm: string) => void
|
||||
setTimeout: (timeout: Timeouts, callback: () => void, latency: number) => void
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
const AutoSubmitSearchInput = ({
|
||||
cypress,
|
||||
doSearch,
|
||||
inputValue,
|
||||
loading,
|
||||
onChange,
|
||||
setTimeout,
|
||||
placeholder,
|
||||
}: AutoSubmitSearchInputProps) => {
|
||||
const [input, setInput] = useState(inputValue)
|
||||
const latency = 250
|
||||
const minSearchLength = 0
|
||||
|
||||
const executeSearch = (searchTerm: string) => {
|
||||
clearTimeout(Timeouts.FETCH)
|
||||
setTimeout(Timeouts.FETCH, () => doSearch(searchTerm), latency)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if ((input || input === '') && input.length >= minSearchLength) {
|
||||
executeSearch(input)
|
||||
}
|
||||
}, [input])
|
||||
|
||||
const handleSearchChange = (_event, { value }) => {
|
||||
clearTimeout(Timeouts.SEARCH)
|
||||
if (value.length >= 0) {
|
||||
onChange(value)
|
||||
setTimeout(Timeouts.SEARCH, () => setInput(value), latency)
|
||||
} else {
|
||||
onChange('')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
data-cy={cypress}
|
||||
fluid
|
||||
icon="search"
|
||||
loading={loading}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const TimeoutAutoSubmitSearchInput = Timeout(AutoSubmitSearchInput)
|
||||
@@ -1,363 +0,0 @@
|
||||
import { sortBy } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { Form, Header, Message, Popup, Radio, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { validateInputLength } from '@/common'
|
||||
import { useSearchHistory, useToggle } from '@/common/hooks'
|
||||
import { MemoizedCourseTable as CourseTable } from '@/components/CourseStatistics/CourseTable'
|
||||
import { TimeoutAutoSubmitSearchInput as AutoSubmitSearchInput } from '@/components/CourseStatistics/SearchForm/AutoSubmitSearchInput'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { SearchHistory } from '@/components/SearchHistory'
|
||||
import { clearCourses, findCoursesV2 } from '@/redux/coursesearch'
|
||||
import { getCourseStats, clearCourseStats } from '@/redux/coursestats'
|
||||
import { getCourseSearchResults } from '@/selectors/courses'
|
||||
|
||||
const INITIAL = {
|
||||
courseName: '',
|
||||
courseCode: '',
|
||||
selectedCourses: {},
|
||||
separate: false,
|
||||
}
|
||||
|
||||
// For now, let's allow more courses because it's necessary and doesn't necessarily fail if the courses
|
||||
// have small populations (this used to be limited to 40)
|
||||
const MAX_SELECTED_COURSES = 99999
|
||||
|
||||
const searchBoxStyle = {
|
||||
border: '2px solid black',
|
||||
borderRadius: '0.3em',
|
||||
padding: '12px',
|
||||
boxShadow: '0 2px 4px 0 rgb(34 36 38 / 12%), 0 2px 10px 0 rgb(34 36 38 / 15%)',
|
||||
}
|
||||
|
||||
export const SearchForm = ({ onProgress }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const dispatch = useDispatch()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const isLoading = useSelector(state => state.courseStats.pending)
|
||||
const [combineSubstitutions, toggleCombineSubstitutions] = useToggle(true)
|
||||
const [selectMultipleCoursesEnabled, toggleSelectMultipleCoursesEnabled] = useToggle(false)
|
||||
const matchingCourses = useSelector(state => getCourseSearchResults(state, combineSubstitutions))
|
||||
const [state, setState] = useState({ ...INITIAL })
|
||||
const { courseName, courseCode, selectedCourses, separate } = state
|
||||
const [searchHistory, addItemToSearchHistory, updateItemInSearchHistory] = useSearchHistory('courseSearch', 6)
|
||||
|
||||
const parseQueryFromUrl = () => {
|
||||
const {
|
||||
courseCodes,
|
||||
separate,
|
||||
unifyOpenUniCourses,
|
||||
combineSubstitutions: combineSubstitutionsFromUrl,
|
||||
...rest
|
||||
} = qs.parse(location.search)
|
||||
const query = {
|
||||
...INITIAL,
|
||||
...rest,
|
||||
courseCodes: JSON.parse(courseCodes),
|
||||
separate: JSON.parse(separate),
|
||||
combineSubstitutions: combineSubstitutionsFromUrl ?? JSON.parse(combineSubstitutions),
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
const fetchStatisticsFromUrlParams = () => {
|
||||
const query = parseQueryFromUrl()
|
||||
setState({ ...state, ...query })
|
||||
dispatch(getCourseStats(query, onProgress))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.search) {
|
||||
dispatch(clearCourses())
|
||||
dispatch(clearCourseStats())
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (location.search) {
|
||||
fetchStatisticsFromUrlParams()
|
||||
}
|
||||
}, [location.search])
|
||||
|
||||
const onSelectCourse = course => {
|
||||
course.selected = !course.selected
|
||||
const isSelected = !!selectedCourses[course.code]
|
||||
|
||||
if (isSelected) {
|
||||
const { [course.code]: omit, ...rest } = selectedCourses
|
||||
setState({
|
||||
...state,
|
||||
selectedCourses: rest,
|
||||
})
|
||||
} else {
|
||||
setState({
|
||||
...state,
|
||||
selectedCourses: {
|
||||
...selectedCourses,
|
||||
[course.code]: { ...course, selected: true },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const pushQueryToUrl = query => {
|
||||
const { courseCodes, ...rest } = query
|
||||
const queryObject = {
|
||||
...rest,
|
||||
courseCodes: JSON.stringify(courseCodes),
|
||||
combineSubstitutions: JSON.stringify(combineSubstitutions),
|
||||
}
|
||||
const searchString = qs.stringify(queryObject)
|
||||
navigate({ search: searchString })
|
||||
}
|
||||
|
||||
const onSearchHistorySelected = historyItem => {
|
||||
pushQueryToUrl(historyItem)
|
||||
}
|
||||
|
||||
const onSubmitFormClick = () => {
|
||||
const codes = sortBy(Object.keys(selectedCourses))
|
||||
const params = {
|
||||
courseCodes: codes,
|
||||
separate,
|
||||
combineSubstitutions,
|
||||
}
|
||||
const searchHistoryText = codes.map(code => `${getTextIn(selectedCourses[code].name)} ${code}`)
|
||||
addItemToSearchHistory({
|
||||
text: searchHistoryText.join(', '),
|
||||
params,
|
||||
})
|
||||
pushQueryToUrl(params)
|
||||
}
|
||||
|
||||
const fetchCourses = () => {
|
||||
const isValidName = validateInputLength(courseName, 5)
|
||||
const isValidCode = validateInputLength(courseCode, 2)
|
||||
|
||||
if (isValidName || isValidCode) {
|
||||
return dispatch(findCoursesV2({ name: courseName, code: courseCode, combineSubstitutions }))
|
||||
}
|
||||
if (courseName.length < 5 && courseCode.length < 2) {
|
||||
dispatch(clearCourses())
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const clearSelectedCourses = () => {
|
||||
setState({ ...state, selectedCourses: {} })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(selectedCourses).length === 0 || selectMultipleCoursesEnabled) return
|
||||
onSubmitFormClick()
|
||||
}, [selectedCourses])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
await fetchCourses()
|
||||
}
|
||||
fetchData()
|
||||
clearSelectedCourses()
|
||||
}, [combineSubstitutions])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectMultipleCoursesEnabled) return
|
||||
clearSelectedCourses()
|
||||
}, [selectMultipleCoursesEnabled])
|
||||
|
||||
const courses = matchingCourses.filter(course => !selectedCourses[course.code])
|
||||
|
||||
const selected = Object.values(selectedCourses).map(course => ({ ...course, selected: true }))
|
||||
const noSelectedCourses = selected.length === 0
|
||||
const disabled = isLoading || noSelectedCourses || selected.length > MAX_SELECTED_COURSES
|
||||
|
||||
const renderFetchStatisticsButton = () => {
|
||||
const FetchStatisticsButton = (
|
||||
<Form.Button
|
||||
basic
|
||||
content="Fetch statistics"
|
||||
data-cy="fetch-stats-button"
|
||||
disabled={disabled}
|
||||
fluid
|
||||
onClick={onSubmitFormClick}
|
||||
positive
|
||||
primary
|
||||
size="huge"
|
||||
type="button"
|
||||
/>
|
||||
)
|
||||
|
||||
if (noSelectedCourses) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<Popup
|
||||
content={getTextIn({
|
||||
fi: `Olet valinnut ${selected.length} kurssia. Voit valita tarkasteltavaksi enintään ${MAX_SELECTED_COURSES} kurssia kerrallaan. Tarkenna hakua tai poista valittuja kursseja.`,
|
||||
en: `You have selected ${selected.length} courses. You can select up to ${MAX_SELECTED_COURSES} courses at a time. Refine your search or remove selected courses.`,
|
||||
})}
|
||||
on="hover"
|
||||
position="top center"
|
||||
trigger={<span>{FetchStatisticsButton}</span>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return FetchStatisticsButton
|
||||
}
|
||||
|
||||
const addAllCourses = () => {
|
||||
courses.forEach(course => {
|
||||
course.selected = true
|
||||
})
|
||||
|
||||
const newSelectedCourses = courses.reduce((newSelected, course) => {
|
||||
newSelected[course.code] = { ...course }
|
||||
return newSelected
|
||||
}, {})
|
||||
|
||||
setState({
|
||||
...state,
|
||||
selectedCourses: {
|
||||
...selectedCourses,
|
||||
...newSelectedCourses,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Segment loading={isLoading}>
|
||||
<Form>
|
||||
<Header>Search for courses</Header>
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<Form.Group>
|
||||
<Form.Field width={7}>
|
||||
<label>Name</label>
|
||||
<AutoSubmitSearchInput
|
||||
cypress="course-name-input"
|
||||
doSearch={fetchCourses}
|
||||
inputValue={courseName}
|
||||
loading={isLoading}
|
||||
onChange={courseName => setState({ ...state, courseName })}
|
||||
placeholder="Search by course name"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field width={4}>
|
||||
<label>Code</label>
|
||||
<AutoSubmitSearchInput
|
||||
cypress="course-code-input"
|
||||
doSearch={fetchCourses}
|
||||
inputValue={courseCode}
|
||||
loading={isLoading}
|
||||
onChange={courseCode => setState({ ...state, courseCode })}
|
||||
placeholder="Search by course code"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
marginLeft: '10px',
|
||||
}}
|
||||
>
|
||||
<Popup
|
||||
content={getTextIn({
|
||||
fi: 'Jos "Combine substitutions" on valittuna (oletuksena), niin kurssi ja leikkaavat kurssit yhdistetään tilastoissa.',
|
||||
en: 'If "Combine substitutions" is on (default behavior), then course and its substitutions are combined in the statistics.',
|
||||
})}
|
||||
on="hover"
|
||||
position="top center"
|
||||
size="tiny"
|
||||
trigger={
|
||||
<Radio
|
||||
checked={combineSubstitutions}
|
||||
data-cy="combine-substitutions-toggle"
|
||||
label="Combine substitutions"
|
||||
onChange={toggleCombineSubstitutions}
|
||||
toggle
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Popup
|
||||
content={getTextIn({
|
||||
fi: 'Jos "Select multiple courses" on valittuna, voit valita tarkasteltavaksi useita kursseja.',
|
||||
en: 'If "Select multiple courses" is on, you can select multiple courses to view statistics for.',
|
||||
})}
|
||||
on="hover"
|
||||
position="top center"
|
||||
size="tiny"
|
||||
trigger={
|
||||
<Radio
|
||||
checked={selectMultipleCoursesEnabled}
|
||||
data-cy="select-multiple-courses-toggle"
|
||||
label="Select multiple courses"
|
||||
onChange={toggleSelectMultipleCoursesEnabled}
|
||||
toggle
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
{selectMultipleCoursesEnabled && (
|
||||
<Message>
|
||||
<b style={{ color: 'red', fontSize: '14pt' }}>Notice:</b> Selecting many courses may fail if there are
|
||||
too many students in total. If the feature does not work, try again with fewer courses. <br /> <br />
|
||||
<b>Currently selected: {Object.keys(state.selectedCourses).length || 0}</b>
|
||||
</Message>
|
||||
)}
|
||||
<div style={!noSelectedCourses ? searchBoxStyle : null}>
|
||||
<CourseTable
|
||||
controlIcon="trash alternate outline"
|
||||
courses={selected}
|
||||
hidden={noSelectedCourses}
|
||||
onSelectCourse={onSelectCourse}
|
||||
selectedTable
|
||||
title="Selected courses"
|
||||
/>
|
||||
{renderFetchStatisticsButton()}
|
||||
</div>
|
||||
<CourseTable
|
||||
courses={courses}
|
||||
hidden={isLoading || (Object.keys(courses).length === 0 && Object.keys(selectedCourses).length > 0)}
|
||||
onSelectCourse={onSelectCourse}
|
||||
title="Searched courses"
|
||||
/>
|
||||
{courses.length && selectMultipleCoursesEnabled ? (
|
||||
<div className="select-all-container">
|
||||
<Form.Button
|
||||
basic
|
||||
color="green"
|
||||
content="Select all search results"
|
||||
onClick={addAllCourses}
|
||||
size="large"
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Form>
|
||||
</Segment>
|
||||
<SearchHistory
|
||||
disabled={isLoading}
|
||||
handleSearch={onSearchHistorySelected}
|
||||
items={searchHistory.map(item => {
|
||||
item.timestamp = new Date(item.timestamp)
|
||||
return item
|
||||
})}
|
||||
updateItem={updateItemInSearchHistory}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { DropdownProps, Form } from 'semantic-ui-react'
|
||||
|
||||
interface YearFilterProps {
|
||||
years: Array<{ key: number; text: string; value: number }>
|
||||
fromYear: number
|
||||
toYear: number
|
||||
handleChange: (event: React.SyntheticEvent, data: DropdownProps) => void
|
||||
}
|
||||
|
||||
export const YearFilter = ({ years, fromYear, toYear, handleChange }: YearFilterProps) => (
|
||||
<Form>
|
||||
<Form.Group inline>
|
||||
<Form.Dropdown
|
||||
inline
|
||||
label="From:"
|
||||
name="fromYear"
|
||||
onChange={handleChange}
|
||||
options={toYear ? years.filter(({ value }) => value <= toYear) : years}
|
||||
placeholder="Select academic year"
|
||||
selectOnBlur={false}
|
||||
selectOnNavigation={false}
|
||||
selection
|
||||
value={fromYear}
|
||||
/>
|
||||
<Form.Dropdown
|
||||
inline
|
||||
label="To:"
|
||||
name="toYear"
|
||||
onChange={handleChange}
|
||||
options={fromYear ? years.filter(({ value }) => value >= fromYear) : years}
|
||||
placeholder="Select academic year"
|
||||
selectOnBlur={false}
|
||||
selectOnNavigation={false}
|
||||
selection
|
||||
value={toYear}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
)
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Divider, Form, Header, Label, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { SingleCourseStats } from '@/components/CourseStatistics/SingleCourseStats'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { setSelectedCourse } from '@/redux/singleCourseStats'
|
||||
import { getAvailableStats, getCourses, getCourseStats } from '@/selectors/courseStats'
|
||||
|
||||
const CourseSelector = ({ courses, selected, setSelected }) => {
|
||||
const dispatch = useDispatch()
|
||||
const onCourseChange = (_, { value }) => {
|
||||
setSelected(value)
|
||||
dispatch(setSelectedCourse(value))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header as="h4">Select course</Header>
|
||||
<Form>
|
||||
<Form.Select data-cy="course-selector" fluid onChange={onCourseChange} options={courses} value={selected} />
|
||||
</Form>
|
||||
<Divider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CourseLabel = ({ code, name, primary }) => (
|
||||
<Label color={primary ? 'blue' : undefined} size="large" style={{ marginBottom: 5, marginRight: 5 }}>
|
||||
{code} {name}
|
||||
</Label>
|
||||
)
|
||||
|
||||
export const SingleCourseTab = ({ selected, setSelected, userHasAccessToAllStats }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const stats = useSelector(getCourseStats)
|
||||
const availableStats = useSelector(getAvailableStats)
|
||||
const courses = useSelector(getCourses).map(({ code, name }) => ({
|
||||
key: code,
|
||||
value: code,
|
||||
text: `${getTextIn(name)} (${code})`,
|
||||
}))
|
||||
|
||||
if (!stats[selected]) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasSubstitutions = stats[selected].alternatives.length > 1
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Segment>
|
||||
<Header as="h4">{hasSubstitutions ? 'Selected courses' : 'Selected course'}</Header>
|
||||
{courses.length > 1 && <CourseSelector courses={courses} selected={selected} setSelected={setSelected} />}
|
||||
{hasSubstitutions && <Header as="h5">Course</Header>}
|
||||
<CourseLabel code={selected} key={selected} name={getTextIn(stats[selected].name)} primary />
|
||||
{hasSubstitutions && <Header as="h5">Substitutions</Header>}
|
||||
{stats[selected].alternatives
|
||||
.filter(course => course.code !== selected)
|
||||
.map(course => (
|
||||
<CourseLabel code={course.code} key={course.code} name={getTextIn(course.name)} />
|
||||
))}
|
||||
</Segment>
|
||||
<SingleCourseStats
|
||||
availableStats={availableStats[selected]}
|
||||
stats={stats[selected]}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Button, Icon } from 'semantic-ui-react'
|
||||
import { utils, writeFile } from 'xlsx'
|
||||
|
||||
import { getTimestamp } from '@/util/timeAndDate'
|
||||
|
||||
export const DataExport = ({ data }) => {
|
||||
const jsonItems = data.reduce((arr, cur) => {
|
||||
const { passed: Passed, failed: Failed, passrate: Passrate, category: Title } = cur
|
||||
const years = cur.realisations.map(r => ({
|
||||
Passed: r.passed,
|
||||
Failed: r.failed,
|
||||
Passrate: r.passrate,
|
||||
Title: r.realisation,
|
||||
obfuscated: r.obfuscated,
|
||||
}))
|
||||
const rows = [{ Title, Passed, Failed, Passrate }, ...years]
|
||||
const obfuscatedRows = rows.map(row => {
|
||||
if (row.obfuscated) {
|
||||
const obf = '5 or fewer students'
|
||||
return { Title: row.Title, Passed: obf, Failed: obf, Passrate: obf }
|
||||
}
|
||||
const { obfuscated, ...rest } = row
|
||||
return rest
|
||||
})
|
||||
arr.push(...obfuscatedRows)
|
||||
return arr
|
||||
}, [])
|
||||
const worksheet = utils.json_to_sheet(jsonItems)
|
||||
const workbook = utils.book_new()
|
||||
utils.book_append_sheet(workbook, worksheet)
|
||||
return (
|
||||
<Button onClick={() => writeFile(workbook, `oodikone_course_statistics_${getTimestamp()}.xlsx`)}>
|
||||
<Icon name="save" />
|
||||
Export to Excel
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { flatten } from 'lodash'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Form, Header, Label, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { getFullStudyProgrammeRights } from '@/common'
|
||||
import { AttemptsTable } from '@/components/CourseStatistics/AttemptsTable'
|
||||
import { userHasAccessToAllCourseStats } from '@/components/CourseStatistics/courseStatisticsUtils'
|
||||
import { ProgrammeDropdown } from '@/components/CourseStatistics/ProgrammeDropdown'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { setValue } from '@/redux/coursesSummaryForm'
|
||||
import { ALL, getAllStudyProgrammes, getQueryInfo, summaryStatistics } from '@/selectors/courseStats'
|
||||
import { DataExport } from './DataExport'
|
||||
|
||||
// Certified JavaScript moment but basically this was crashing
|
||||
// since sometimes object like {en: ..., fi: ...., sv: ....}
|
||||
// was being passed to React which is not legal but then again
|
||||
// it works sometimes? so doing this to make sure while fixing
|
||||
// the crash the realisations that worked will keep working
|
||||
const unObjectifyProperty = ({ obj, property }) => {
|
||||
const suspectField = obj[property]
|
||||
if (typeof suspectField === 'object' && suspectField !== null) {
|
||||
if (suspectField.en) {
|
||||
return { ...obj, [property]: suspectField.en }
|
||||
}
|
||||
throw Error(`Invalid object being tried to pass to React: ${JSON.stringify(suspectField)}`)
|
||||
}
|
||||
return { ...obj, [property]: suspectField }
|
||||
}
|
||||
|
||||
export const SummaryTab = ({ onClickCourse }) => {
|
||||
const { roles, programmeRights } = useGetAuthorizedUserQuery()
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const userHasAccessToAllStats = userHasAccessToAllCourseStats(roles, fullStudyProgrammeRights)
|
||||
const dispatch = useDispatch()
|
||||
const programmes = useSelector(state => getAllStudyProgrammes(state))
|
||||
const form = useSelector(({ courseSummaryForm }) => courseSummaryForm)
|
||||
const statistics = useSelector(state => summaryStatistics(state, userHasAccessToAllStats))
|
||||
const queryInfo = useSelector(state => getQueryInfo(state))
|
||||
|
||||
const { getTextIn } = useLanguage()
|
||||
const handleChange = (_, { name, value }) => {
|
||||
let selected = [...value].filter(v => v !== ALL.value)
|
||||
if ((!form.programmes.includes(ALL.value) && value.includes(ALL.value)) || value.length === 0) {
|
||||
selected = [ALL.value]
|
||||
}
|
||||
dispatch(setValue(name, selected))
|
||||
}
|
||||
|
||||
const data = statistics.map(stat => {
|
||||
const { coursecode, name, realisations, summary } = stat
|
||||
const { passed, failed, passrate } = summary
|
||||
return {
|
||||
id: coursecode,
|
||||
category: getTextIn(name),
|
||||
passed,
|
||||
failed,
|
||||
passrate,
|
||||
realisations: realisations.map(obj => {
|
||||
return unObjectifyProperty({ obj, property: 'realisation' })
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const options = programmes
|
||||
.map(programme => ({ ...programme, size: new Set(flatten(Object.values(programme.students))).size }))
|
||||
.filter(programme => programme.size > 0)
|
||||
.map(({ text, ...rest }) => ({ text: typeof text === 'string' ? text : getTextIn(text), ...rest }))
|
||||
.map(programme => ({ ...programme, name: programme.text }))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Segment>
|
||||
<Form>
|
||||
{userHasAccessToAllStats && (
|
||||
<>
|
||||
<Header as="h4">Filter statistics by study programmes</Header>
|
||||
<ProgrammeDropdown
|
||||
label="Study programmes"
|
||||
name="programmes"
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
value={form.programmes}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Form.Field>
|
||||
<label>Timeframe</label>
|
||||
<Label.Group>
|
||||
{queryInfo.timeframe.map(objBeforeUbObjectifying => {
|
||||
const obj = unObjectifyProperty({ obj: objBeforeUbObjectifying, property: 'name' })
|
||||
const { code, name } = obj
|
||||
return <Label content={name} key={code} />
|
||||
})}
|
||||
</Label.Group>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Segment>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<DataExport data={data} />
|
||||
</div>
|
||||
<AttemptsTable
|
||||
categoryName="Course"
|
||||
data={data}
|
||||
onClickCourse={onClickCourse}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
{!userHasAccessToAllStats && (
|
||||
<span className="totalsDisclaimer">* Years with 5 students or fewer are NOT included in the total</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.select-all-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import qs from 'query-string'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { Header, Message, Segment, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { checkUserAccess, getFullStudyProgrammeRights } from '@/common'
|
||||
import { useProgress, useTitle } from '@/common/hooks'
|
||||
import { ProgressBar } from '@/components/ProgressBar'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { getCourseStats } from '@/redux/coursestats'
|
||||
import { userHasAccessToAllCourseStats } from './courseStatisticsUtils'
|
||||
import { FacultyLevelStatistics } from './FacultyLevelStatistics'
|
||||
import { SearchForm } from './SearchForm'
|
||||
import { SingleCourseTab } from './SingleCourseTab'
|
||||
import { SummaryTab } from './SummaryTab'
|
||||
import './courseStatistics.css'
|
||||
|
||||
const MENU = {
|
||||
SUM: 'Summary',
|
||||
COURSE: 'Course',
|
||||
QUERY: 'New query',
|
||||
FACULTY: 'Faculty statistics',
|
||||
}
|
||||
|
||||
export const CourseStatistics = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dispatch = useDispatch()
|
||||
const { programmeRights, roles } = useGetAuthorizedUserQuery()
|
||||
const { pending: loading, data: courseStatsData } = useSelector(({ courseStats }) => courseStats)
|
||||
const courses = Object.keys(courseStatsData)
|
||||
const statsIsEmpty = courses.length === 0
|
||||
const singleCourseStats = courses.length === 1
|
||||
const initCourseCode = courses[0] || ''
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [selected, setSelected] = useState(initCourseCode)
|
||||
const { onProgress, progress } = useProgress(loading)
|
||||
useTitle('Course statistics')
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(initCourseCode)
|
||||
}, [initCourseCode])
|
||||
|
||||
useEffect(() => {
|
||||
const { courseCodes, ...params } = qs.parse(location.search)
|
||||
if (!courseCodes) return
|
||||
const query = {
|
||||
...params,
|
||||
courseCodes: JSON.parse(courseCodes),
|
||||
}
|
||||
dispatch(getCourseStats(query, onProgress))
|
||||
}, [location.search])
|
||||
|
||||
useEffect(() => {
|
||||
if (statsIsEmpty) {
|
||||
setSelected(initCourseCode)
|
||||
setActiveIndex(0)
|
||||
}
|
||||
}, [statsIsEmpty])
|
||||
|
||||
const switchToCourse = coursecode => {
|
||||
setActiveIndex(1)
|
||||
setSelected(coursecode)
|
||||
}
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
const userHasAccessToAllStats = userHasAccessToAllCourseStats(roles, fullStudyProgrammeRights)
|
||||
|
||||
const getPanes = () => {
|
||||
let panes = [
|
||||
{
|
||||
menuItem: MENU.SUM,
|
||||
render: () => <SummaryTab onClickCourse={switchToCourse} userHasAccessToAllStats={userHasAccessToAllStats} />,
|
||||
},
|
||||
{
|
||||
menuItem: MENU.COURSE,
|
||||
render: () => (
|
||||
<SingleCourseTab
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (userHasAccessToAllStats) {
|
||||
panes = [
|
||||
...panes,
|
||||
{
|
||||
menuItem: MENU.FACULTY,
|
||||
render: () => <FacultyLevelStatistics />,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
panes = [
|
||||
...panes,
|
||||
{
|
||||
menuItem: {
|
||||
key: 'query',
|
||||
content: MENU.QUERY,
|
||||
icon: 'search',
|
||||
position: 'right',
|
||||
onClick: () => navigate('/coursestatistics'),
|
||||
},
|
||||
render: () => null,
|
||||
},
|
||||
]
|
||||
|
||||
return !singleCourseStats ? panes : panes.filter(pane => pane.menuItem !== MENU.SUM)
|
||||
}
|
||||
|
||||
const handleTabChange = (_, { activeIndex, panes }) => {
|
||||
if (panes[activeIndex].menuItem.key !== 'query') {
|
||||
setActiveIndex(activeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkUserAccess(['courseStatistics', 'admin', 'fullSisuAccess'], roles) && fullStudyProgrammeRights.length < 1)
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Message
|
||||
color="red"
|
||||
error
|
||||
header="You have no rights to access any data. If you should have access please contact grp-toska@helsinki.fi"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const panes = getPanes()
|
||||
|
||||
const getContent = () => {
|
||||
if (statsIsEmpty || location.search === '') {
|
||||
return <SearchForm onProgress={onProgress} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Tab
|
||||
activeIndex={activeIndex}
|
||||
menu={{ attached: false, borderless: false }}
|
||||
onTabChange={handleTabChange}
|
||||
panes={panes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Header className="segmentTitle" size="large">
|
||||
Course statistics
|
||||
</Header>
|
||||
<Segment className="contentSegment">
|
||||
{getContent()}
|
||||
<ProgressBar fixed progress={progress} />
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import ReactHighstock from 'react-highcharts/ReactHighstock'
|
||||
import { Button, Radio } from 'semantic-ui-react'
|
||||
|
||||
import { getStudyRightElementTargetDates } from '@/common'
|
||||
import { useDeepMemo } from '@/common/hooks'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
|
||||
import { useDeepMemo } from '@/hooks/deepMemo'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { CreditGraphTooltip } from './CreditGraphTooltip'
|
||||
|
||||
@@ -50,9 +50,6 @@ const createGraphOptions = ({
|
||||
}
|
||||
|
||||
return {
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
findNearestPointBy: 'xy',
|
||||
@@ -313,8 +310,8 @@ const addGraduation = (points, graduationDate, notFirst) => {
|
||||
points.sort((a, b) => a.x - b.x)
|
||||
}
|
||||
|
||||
const findGraduationsByCodes = (student, programmeCodes, showFullStudyPath) => {
|
||||
if (showFullStudyPath) {
|
||||
const findGraduationsByCodes = (student, programmeCodes, showBachelorAndMaster) => {
|
||||
if (showBachelorAndMaster) {
|
||||
return (
|
||||
student.studyRights
|
||||
.find(studyRight => studyRight.studyRightElements.some(element => programmeCodes.includes(element.code)))
|
||||
@@ -340,12 +337,12 @@ const createStudentCreditLines = (
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showFullStudyPath
|
||||
showBachelorAndMaster
|
||||
) =>
|
||||
students.map(student => {
|
||||
const { studyrightStart } = student
|
||||
let startDate = singleStudent ? selectedStartDate : studyrightStart
|
||||
if (showFullStudyPath) {
|
||||
if (showBachelorAndMaster) {
|
||||
startDate =
|
||||
student.studyRights.find(studyRight =>
|
||||
studyRight.studyRightElements.some(element => element.code === programmeCodes[0])
|
||||
@@ -363,7 +360,7 @@ const createStudentCreditLines = (
|
||||
courses => courses.reduce(reduceCreditsToPoints, { credits: 0, points: [], singleStudent })
|
||||
)(student.courses)
|
||||
|
||||
const graduationDates = programmeCodes ? findGraduationsByCodes(student, programmeCodes, showFullStudyPath) : []
|
||||
const graduationDates = programmeCodes ? findGraduationsByCodes(student, programmeCodes, showBachelorAndMaster) : []
|
||||
|
||||
if (points?.length > 0) {
|
||||
if (!singleStudent && points[0].y !== 0 && students.length < 100) {
|
||||
@@ -414,7 +411,7 @@ export const CreditAccumulationGraphHighCharts = ({
|
||||
customPopulation = false,
|
||||
studyPlanFilterIsActive,
|
||||
selectedStudyPlan,
|
||||
showFullStudyPath,
|
||||
showBachelorAndMaster,
|
||||
}) => {
|
||||
const chartRef = useRef()
|
||||
const { getTextIn } = useLanguage()
|
||||
@@ -434,7 +431,7 @@ export const CreditAccumulationGraphHighCharts = ({
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showFullStudyPath
|
||||
showBachelorAndMaster
|
||||
),
|
||||
[
|
||||
students,
|
||||
@@ -445,7 +442,7 @@ export const CreditAccumulationGraphHighCharts = ({
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showFullStudyPath,
|
||||
showBachelorAndMaster,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import moment from 'moment'
|
||||
import { useState } from 'react'
|
||||
import { Message, Icon, Loader } from 'semantic-ui-react'
|
||||
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetSavedSearchesQuery } from '@/redux/openUniPopulations'
|
||||
import { CustomOpenUniSearch } from './CustomOpenUniSearch'
|
||||
import { OpenUniPopulationResults } from './OpenUniPopulationResults'
|
||||
|
||||
+1
-1
@@ -1,12 +1,12 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { getNewestProgrammeOfStudentAt } from '@/common'
|
||||
import { useCurrentSemester } from '@/common/hooks'
|
||||
import { FilterToggleIcon } from '@/components/common/FilterToggleIcon'
|
||||
import { ProgressBarWithLabel } from '@/components/common/ProgressBarWithLabel'
|
||||
import { isProgrammeSelected, toggleProgrammeSelection } from '@/components/FilterView/filters/programmes'
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useCurrentSemester } from '@/hooks/currentSemester'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { SearchResultTable } from './SearchResultTable'
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from 'react'
|
||||
import { Button, Form, Modal, TextArea } from 'semantic-ui-react'
|
||||
|
||||
import { extractItems, textAndDescriptionSearch } from '@/common'
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { SearchHistory } from '@/components/SearchHistory'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import {
|
||||
useCreateCustomPopulationSearchMutation,
|
||||
useDeleteCustomPopulationSearchMutation,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Form, Header, Input, Label, Message, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { useProgress, useTitle } from '@/common/hooks'
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { PanelView } from '@/components/common/PanelView'
|
||||
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
|
||||
@@ -24,6 +23,8 @@ import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/Po
|
||||
import { PopulationStudentsContainer as PopulationStudents } from '@/components/PopulationStudents'
|
||||
import { ProgressBar } from '@/components/ProgressBar'
|
||||
import { RightsNotification } from '@/components/RightsNotification'
|
||||
import { useProgress } from '@/hooks/progress'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourses'
|
||||
import { useGetCustomPopulationQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
|
||||
@@ -10,7 +10,6 @@ export const FilterTray = () => {
|
||||
allStudents,
|
||||
filters,
|
||||
withoutFilter,
|
||||
filterOptions,
|
||||
setFilterOptions,
|
||||
resetFilter,
|
||||
resetFilters,
|
||||
@@ -23,7 +22,7 @@ export const FilterTray = () => {
|
||||
const filterSet = filters
|
||||
.sort((a, b) => (a.title ?? a.key).localeCompare(b.title ?? b.key))
|
||||
.map(filter => {
|
||||
const { key, title, isActive, render, info } = filter
|
||||
const { key, render } = filter
|
||||
const ctx = getContextByKey(key)
|
||||
|
||||
const props = {
|
||||
@@ -36,14 +35,7 @@ export const FilterTray = () => {
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<FilterCard
|
||||
active={isActive(filterOptions[key])}
|
||||
filter={filter}
|
||||
info={info}
|
||||
onClear={() => resetFilter(key)}
|
||||
options={ctx.options}
|
||||
title={title ?? key}
|
||||
>
|
||||
<FilterCard filter={filter} onClear={() => resetFilter(key)} options={ctx.options}>
|
||||
{render(props, ctx)}
|
||||
</FilterCard>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user