mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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 | ||
|
|
42409d555e | ||
|
|
23b1a02d42 | ||
|
|
1be53368c6 | ||
|
|
52e0d28a4d | ||
|
|
83114a26f9 | ||
|
|
bdc09f86e6 | ||
|
|
6dd9679267 | ||
|
|
5fb7168d17 | ||
|
|
6ac4b6fb3d | ||
|
|
1a8de4344c | ||
|
|
1493725699 | ||
|
|
6cff94e2ca | ||
|
|
d51a70e259 | ||
|
|
7f84c0b9f0 | ||
|
|
df555ca93f | ||
|
|
b68b98b62c | ||
|
|
640dc741a4 | ||
|
|
5d02b839ee | ||
|
|
ae60a38445 | ||
|
|
83d4bd26c4 | ||
|
|
481b351276 | ||
|
|
23cb8c8ee5 | ||
|
|
9bcdd81772 | ||
|
|
53e443142d | ||
|
|
da72107eb9 | ||
|
|
d2cbb89eb9 | ||
|
|
3bf0c60378 | ||
|
|
59a9854ed5 | ||
|
|
b70bc09359 | ||
|
|
8ef82d01b1 | ||
|
|
ac683175a8 | ||
|
|
5e86967627 | ||
|
|
d971bb993f | ||
|
|
2328f2deff | ||
|
|
ddeb65abef | ||
|
|
0871d8ecbd | ||
|
|
3b8aec2fef | ||
|
|
71dc06728f | ||
|
|
9484ecb456 | ||
|
|
25319d1c8f | ||
|
|
e7fd54b443 | ||
|
|
aed2bc154b | ||
|
|
09a991669c | ||
|
|
41325bfa12 | ||
|
|
17a1d3718f | ||
|
|
8b9c5b8789 | ||
|
|
e11609c335 | ||
|
|
3a5b3c2d65 | ||
|
|
33916312b5 | ||
|
|
dab8a2679f | ||
|
|
390334c820 | ||
|
|
356ffc953e | ||
|
|
a2f3a5de61 | ||
|
|
635a3fbcbc | ||
|
|
7ebcb86924 | ||
|
|
eb1781e6af | ||
|
|
6df983642b | ||
|
|
634c4c9a97 | ||
|
|
9371c7d2ab | ||
|
|
f6678739ad | ||
|
|
2882dd25c6 | ||
|
|
ed3166d08f | ||
|
|
39340108f2 | ||
|
|
94f037b6af | ||
|
|
090e81a432 | ||
|
|
6e8ae9b320 | ||
|
|
0db094655e | ||
|
|
c437ed4e96 | ||
|
|
ed5a778e50 | ||
|
|
13633ce11a | ||
|
|
eab47657e6 | ||
|
|
35267e8b6d | ||
|
|
15cc9cef4a | ||
|
|
f5e1e7267f | ||
|
|
c6eaa18a1f | ||
|
|
63e16e4332 | ||
|
|
074e41de59 | ||
|
|
46b6686f4b | ||
|
|
ede94708d5 | ||
|
|
f20953c8d2 | ||
|
|
1e728593ea | ||
|
|
21f7fcc6d2 | ||
|
|
20c026aa56 | ||
|
|
24eb824163 | ||
|
|
2065afe852 | ||
|
|
d60480d1ae | ||
|
|
1be0f9090e | ||
|
|
f9dad0a183 | ||
|
|
597f1f0c0b | ||
|
|
c26728d24a | ||
|
|
4c1e11692a | ||
|
|
132a21e113 | ||
|
|
324fcb71a9 | ||
|
|
55111201e6 | ||
|
|
067040d92f | ||
|
|
0d857d716a | ||
|
|
ec9b0b22b0 | ||
|
|
32cf9ed185 | ||
|
|
1702e086a3 | ||
|
|
8246818a94 | ||
|
|
e1ee00a331 | ||
|
|
6e22cb5e58 | ||
|
|
f4ebb3d547 | ||
|
|
5683fbc3cb | ||
|
|
0c78f1c7e2 |
@@ -70,3 +70,4 @@ environments/
|
||||
|
||||
# Target of shared symlink
|
||||
services/**/src/shared
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
shm_size: 2gb
|
||||
|
||||
sis-importer-db:
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-importer-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
@@ -37,4 +37,4 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const openCompletedCoursesModal = () => {
|
||||
cy.get('button').contains('Search completed courses of students').click()
|
||||
cy.contains('button', 'Search completed courses of students').click()
|
||||
}
|
||||
|
||||
const hasLanded = () => {
|
||||
@@ -15,22 +15,40 @@ const openSearch = () => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
}
|
||||
|
||||
const selectSavedCourselist = name => {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(name).type('{enter}')
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
cy.get('[data-cy=history-search]').type(name)
|
||||
cy.get('[data-cy=history-search]').type('{downarrow}')
|
||||
cy.get('[data-cy=history-search]').type('{enter}')
|
||||
}
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
cy.contains('Saved courselists')
|
||||
.get('.dropdown')
|
||||
.then(dropdown => {
|
||||
const searchItems = dropdown.find('div[role=option] > span[class=text]')
|
||||
for (let i = 0; i < searchItems.length; i++) {
|
||||
if (searchItems[i].textContent.includes('TEST-')) {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(searchItems[i].textContent).type('{enter}')
|
||||
cy.get('button').contains('Delete').click()
|
||||
const createCourseList = (courseCodes, courseListName) => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy=course-list-input]').type(courseCodes.join('\n'))
|
||||
cy.get('[data-cy=search-name]').type(courseListName)
|
||||
cy.get('[data-cy=save-courselist]').click()
|
||||
}
|
||||
|
||||
const generateCourseListName = () => `TEST-course-list-${new Date().getTime()}`
|
||||
|
||||
const deleteAllPreviousSearches = () => {
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
|
||||
cy.get('.MuiAutocomplete-popper li').then($options => {
|
||||
if ($options.length > 0) {
|
||||
cy.wrap($options).each(($option, index, $list) => {
|
||||
cy.wrap($option).click()
|
||||
cy.get('[data-cy=delete-courselist]').click()
|
||||
if (index < $list.length - 1) {
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
cy.contains('You have no previous searches.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('When search modal is opened', () => {
|
||||
@@ -39,8 +57,7 @@ describe('When search modal is opened', () => {
|
||||
hasLanded()
|
||||
cy.url().should('include', '/completedcoursessearch')
|
||||
cy.contains('Search completed courses of students')
|
||||
cy.get('[data-cy="open-completed-courses-modal-button"]').click()
|
||||
deleteAllSearches()
|
||||
cy.get('[data-cy=open-completed-courses-modal-button]').click()
|
||||
})
|
||||
|
||||
it('Modal opens correctly', () => {
|
||||
@@ -49,26 +66,33 @@ describe('When search modal is opened', () => {
|
||||
openCompletedCoursesModal()
|
||||
cy.contains('Search completed courses of students')
|
||||
cy.contains('Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.')
|
||||
cy.contains('Insert one or more courses, separated by a space, a newline, or a comma.')
|
||||
cy.contains('Insert one or more courses, separated by a space, a newline, a comma, or a semicolon.')
|
||||
cy.contains('Insert name for this course list if you wish to save it')
|
||||
})
|
||||
|
||||
it('Modal gets the correct course codes and student numbers from the URL', () => {
|
||||
cy.init('/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.contains('[data-cy=student-no-input]', '433237, 457144')
|
||||
cy.contains('[data-cy=course-list-input]', 'TKT10001, TKT10002')
|
||||
})
|
||||
|
||||
describe('When a search is executed with invalid input', () => {
|
||||
it('Returns empty table and notification', () => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy="student-no-input"]').type('1')
|
||||
cy.get('[data-cy="course-list-input"]').type('1')
|
||||
cy.get('[data-cy="completed-courses-search-button"]').click()
|
||||
cy.get('[data-cy="rights-notification"]').should(
|
||||
cy.get('[data-cy=student-no-input]').type('1')
|
||||
cy.get('[data-cy=course-list-input]').type('1')
|
||||
cy.get('[data-cy=completed-courses-search-button]').click()
|
||||
cy.get('[data-cy=rights-notification]').should(
|
||||
'contain.text',
|
||||
'The following students information could not be displayed'
|
||||
'The information for the following students could not be displayed'
|
||||
)
|
||||
cy.get('[data-cy="rights-notification"]').should('contain.text', '1')
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', 'Completed courses search')
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', 'Student number')
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('not.contain', '1')
|
||||
cy.contains('[data-cy=rights-notification]', '1')
|
||||
cy.contains('[data-cy=completed-courses-table-div]', 'Student number')
|
||||
cy.contains('[data-cy=completed-courses-table-div]', '1').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,9 +102,9 @@ describe('When search modal is opened', () => {
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
cy.get('[data-cy="student-no-input"]').type(studentSet1.join('\n'))
|
||||
cy.get('[data-cy="course-list-input"]').type(coursesSet1.join('\n'))
|
||||
cy.get('[data-cy="completed-courses-search-button"]').click()
|
||||
cy.get('[data-cy=student-no-input]').type(studentSet1.join('\n'))
|
||||
cy.get('[data-cy=course-list-input]').type(coursesSet1.join('\n'))
|
||||
cy.get('[data-cy=completed-courses-search-button]').click()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,63 +119,61 @@ describe('When search modal is opened', () => {
|
||||
|
||||
it('Finds correct students and courses', () => {
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[0])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[1])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[2])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[3])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[4])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[0])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[1])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[2])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[3])
|
||||
for (const studentNumber of studentSet1) {
|
||||
cy.contains('[data-cy=completed-courses-table-div]', studentNumber)
|
||||
}
|
||||
for (const courseCode of coursesSet1) {
|
||||
cy.contains('[data-cy=completed-courses-table-div]', courseCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const courseListNames = ['TEST-courselist1', 'TEST-courselist2']
|
||||
|
||||
describe('Courselist saving-related functions work', () => {
|
||||
beforeEach(() => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy="course-list-input"]').type('CSM14204\nTKT10004')
|
||||
cy.get('[data-cy="search-name"]').type(courseListNames[0])
|
||||
cy.get('button').contains('Save').click()
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-cy=history-search]').length > 0) {
|
||||
deleteAllPreviousSearches()
|
||||
} else {
|
||||
cy.contains('You have no previous searches.')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('Course list can be saved', () => {
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
const courseList = generateCourseListName()
|
||||
const courses = ['CSM14204', 'TKT10004']
|
||||
createCourseList(courses, courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
cy.get('[data-cy="course-list-input"]').contains('TKT10004').contains('CSM14204')
|
||||
selectSavedCourselist(courseList)
|
||||
cy.contains('[data-cy=course-list-input]', courses.join(', '))
|
||||
})
|
||||
|
||||
it('Course list can be deleted', () => {
|
||||
const courseList = generateCourseListName()
|
||||
createCourseList(['CSM14204', 'TKT10004'], courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy="search-name"]').type(courseListNames[1])
|
||||
cy.get('button').contains('Save').click()
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[1])
|
||||
cy.get('button').contains('Delete').click()
|
||||
cy.get('[data-cy="history-search"]')
|
||||
.type(courseListNames[1])
|
||||
.type('{enter}')
|
||||
.should('not.contain', courseListNames[1])
|
||||
selectSavedCourselist(courseList)
|
||||
cy.get('[data-cy=delete-courselist]').click()
|
||||
cy.contains('You have no previous searches.')
|
||||
})
|
||||
|
||||
it('Course list can be updated', () => {
|
||||
const courseList = generateCourseListName()
|
||||
createCourseList(['CSM14204', 'TKT10004'], courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
cy.get('[data-cy="course-list-input"]').type(',TKT10001')
|
||||
cy.get('button').contains('Save').click()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.get('[data-cy=course-list-input]').type(',TKT10001')
|
||||
cy.get('[data-cy=save-courselist]').click()
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
cy.get('[data-cy="course-list-input"]').contains('TKT10001')
|
||||
selectSavedCourselist(courseList)
|
||||
cy.contains('[data-cy=course-list-input]', 'CSM14204, TKT10004, TKT10001')
|
||||
})
|
||||
})
|
||||
|
||||
+318
-150
@@ -1,7 +1,7 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const checkGradeTable = gradesTableContents => {
|
||||
cy.get('[data-cy="Grade distribution"]')
|
||||
cy.cs('Grade distribution')
|
||||
.parent()
|
||||
.siblings('.content.active')
|
||||
.get('table > tbody')
|
||||
@@ -22,7 +22,7 @@ const checkGradeTable = gradesTableContents => {
|
||||
}
|
||||
|
||||
const checkTableContents = contents => {
|
||||
cy.get('#CourseStatPanes table>tbody').within(() => {
|
||||
cy.get('table>tbody').within(() => {
|
||||
contents.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
.eq(trIndex)
|
||||
@@ -38,15 +38,19 @@ const checkTableContents = contents => {
|
||||
}
|
||||
|
||||
const toggleShowGrades = () => {
|
||||
cy.get('[data-cy=gradeToggle]', { force: true }).click({ force: true })
|
||||
cy.cs('gradeToggle', { force: true }).click({ force: true })
|
||||
}
|
||||
|
||||
const toggleSeparateBySemesters = () => {
|
||||
cy.get('[data-cy=separateToggle]', { force: true }).click({ force: true })
|
||||
cy.cs('separateToggle', { force: true }).click({ force: true })
|
||||
}
|
||||
|
||||
const openStudentsTab = () => {
|
||||
cy.cs('StudentsTab').click()
|
||||
}
|
||||
|
||||
const openAttemptsTab = () => {
|
||||
cy.contains('#CourseStatPanes a.item', 'Attempts').click()
|
||||
cy.cs('AttemptsTab').click()
|
||||
}
|
||||
|
||||
const searchByCourseName = courseName => {
|
||||
@@ -57,6 +61,23 @@ const searchByCourseCode = courseCode => {
|
||||
cy.get("input[placeholder='Search by course code']").type(courseCode)
|
||||
}
|
||||
|
||||
const clickNewQueryButton = () => {
|
||||
cy.cs('NewQueryButton').click()
|
||||
}
|
||||
|
||||
const selectFromYear = year => {
|
||||
cy.cs('FromYearSelector').click()
|
||||
cy.cs(`FromYearSelectorOption${year}`).click()
|
||||
}
|
||||
|
||||
const openSummaryTab = () => {
|
||||
cy.cs('SummaryTab').click()
|
||||
}
|
||||
|
||||
const clickAway = () => {
|
||||
cy.get('body').click(0, 0)
|
||||
}
|
||||
|
||||
describe('Course Statistics tests', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
@@ -76,21 +97,20 @@ describe('Course Statistics tests', () => {
|
||||
searchByCourseCode('KK-RUKIRJ')
|
||||
cy.contains('td', /^KK-RUKIRJ,/).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('KK-RUKIRJ Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
|
||||
cy.contains('AYKK-RUKIRJ Avoin yo: Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
|
||||
cy.contains('KK-RULAAK2 Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
|
||||
cy.contains('KK-RUKIRJ • Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
|
||||
cy.contains('AYKK-RUKIRJ • Avoin yo: Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
|
||||
cy.contains('KK-RULAAK2 • Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
|
||||
cy.contains(
|
||||
'KK-RUHYK Helsingin yliopistossa toiseen tutkintoon suoritettu toisen kotimaisen kielen kirjallinen taito, ruotsi'
|
||||
'KK-RUHYK • Helsingin yliopistossa toiseen tutkintoon suoritettu toisen kotimaisen kielen kirjallinen taito, ruotsi'
|
||||
)
|
||||
cy.contains('992912 Toisen kotimaisen kielen kirjallinen taito, ruotsi')
|
||||
cy.contains('A992912 Avoin yo: Toisen kotimaisen kielen kirjallinen taito, ruotsi')
|
||||
cy.contains('992912 • Toisen kotimaisen kielen kirjallinen taito, ruotsi')
|
||||
cy.contains('A992912 • Avoin yo: Toisen kotimaisen kielen kirjallinen taito, ruotsi')
|
||||
cy.contains('2004-2005')
|
||||
cy.contains('Show population').should('be.disabled')
|
||||
cy.contains('Show population').trigger('mouseover', { force: true })
|
||||
cy.contains('The maximum time range to generate a population for this course is 17 years')
|
||||
cy.contains('Show population').trigger('mouseleave', { force: true })
|
||||
cy.contains('div', '2004-2005').click()
|
||||
cy.contains('2019-2020').click()
|
||||
selectFromYear('2019-2020')
|
||||
cy.contains('Show population').click()
|
||||
cy.contains(
|
||||
'Population of course Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1) 2019-2024 (open and normal)'
|
||||
@@ -110,7 +130,7 @@ describe('Course Statistics tests', () => {
|
||||
searchByCourseCode('50131')
|
||||
cy.contains('td', '50131').click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('50131 Pro gradu -tutkielma tietojenkäsittelytieteessä')
|
||||
cy.contains('50131 • Pro gradu -tutkielma tietojenkäsittelytieteessä')
|
||||
cy.contains('Show population').should('be.enabled').click()
|
||||
cy.contains('Population of course Pro gradu -tutkielma tietojenkäsittelytieteessä 2007-2020 (open and normal)')
|
||||
cy.contains('10 students out of 10 shown')
|
||||
@@ -127,9 +147,9 @@ describe('Course Statistics tests', () => {
|
||||
cy.contains('td', '200012').click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains(
|
||||
'ON-310 Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot'
|
||||
'ON-310 • Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot'
|
||||
)
|
||||
cy.contains('200012 Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot')
|
||||
cy.contains('200012 • Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot')
|
||||
cy.contains('Show population').should('be.enabled').click()
|
||||
cy.contains(
|
||||
'Population of course Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot 2011-2018 (open and normal)'
|
||||
@@ -149,41 +169,40 @@ describe('Course Statistics tests', () => {
|
||||
cy.contains('TKT20001')
|
||||
cy.contains('58131')
|
||||
|
||||
cy.contains('.tabular.menu a', 'Students').click()
|
||||
cy.contains('All')
|
||||
openStudentsTab()
|
||||
cy.contains('svg', 'Pass rate')
|
||||
|
||||
cy.contains('.tabular.menu a', 'Attempts').click()
|
||||
cy.contains('All')
|
||||
openAttemptsTab()
|
||||
cy.contains('svg', 'Pass rate')
|
||||
|
||||
cy.contains('a', 'New query').click()
|
||||
clickNewQueryButton()
|
||||
cy.contains('Search for courses')
|
||||
})
|
||||
|
||||
it('Searching multiple courses having substitution mappings shows course statistics', () => {
|
||||
cy.contains('Search for courses')
|
||||
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'checked').click()
|
||||
cy.cs('select-multiple-courses-toggle').should('have.class', 'checked')
|
||||
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked').click()
|
||||
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
|
||||
searchByCourseCode('TKT')
|
||||
cy.contains('td', /^TKT20001/).click()
|
||||
cy.contains('td', /^TKT10002/).click()
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.contains('.courseNameCell', 'Tietorakenteet ja algoritmit').contains('TKT20001').click()
|
||||
cy.contains('.courseNameCell', 'Ohjelmoinnin perusteet').should('not.exist')
|
||||
openSummaryTab()
|
||||
cy.contains('Tietorakenteet ja algoritmit').click()
|
||||
cy.contains('Ohjelmoinnin perusteet').should('not.exist')
|
||||
cy.contains('TKT20001')
|
||||
cy.contains('58131')
|
||||
cy.contains('Summary').click()
|
||||
|
||||
cy.contains('.courseNameCell', 'Ohjelmoinnin perusteet').contains('TKT10002').click()
|
||||
cy.contains('.courseNameCell', 'Käyttöjärjestelmät').should('not.exist')
|
||||
openSummaryTab()
|
||||
cy.contains('Ohjelmoinnin perusteet').click()
|
||||
cy.contains('Käyttöjärjestelmät').should('not.exist')
|
||||
cy.contains('TKT10002')
|
||||
cy.contains('581325')
|
||||
cy.contains('Summary').click()
|
||||
openSummaryTab()
|
||||
|
||||
cy.contains('a', 'New query').click()
|
||||
clickNewQueryButton()
|
||||
cy.contains('Search for courses')
|
||||
})
|
||||
|
||||
@@ -195,16 +214,13 @@ describe('Course Statistics tests', () => {
|
||||
cy.contains('Fetch statistics').click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.get('.ui.contentSegment .ui.menu').contains('Course').click()
|
||||
cy.cs('course-selector').get('.active.selected.item').contains('TKT10002')
|
||||
cy.get('#CourseStatPanes table a.item:first').click()
|
||||
cy.get('table a.item:first').click()
|
||||
cy.contains('Population of course Introduction to Programming 2023-2024 (open and normal)')
|
||||
|
||||
cy.go('back')
|
||||
cy.get('.ui.contentSegment .ui.menu').contains('Course').click()
|
||||
cy.cs('course-selector').click()
|
||||
cy.cs('course-selector').contains('TKT20001').click()
|
||||
cy.get('#CourseStatPanes table a.item:first').click()
|
||||
cy.cs('CourseSelector').click()
|
||||
cy.cs('CourseSelectorOptionTKT20001').click()
|
||||
cy.get('table a.item:first').click()
|
||||
cy.contains('Population of course Tietorakenteet ja algoritmit')
|
||||
})
|
||||
|
||||
@@ -232,43 +248,54 @@ describe('Course Statistics tests', () => {
|
||||
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.contains('TKT10004 Tietokantojen perusteet')
|
||||
cy.contains('AYTKT10004 Avoin yo: Tietokantojen perusteet')
|
||||
cy.contains('BSCS2001 Introduction to Databases')
|
||||
cy.contains('581328 Tietokantojen perusteet')
|
||||
cy.contains('A581328 Avoin yo: Tietokantojen perusteet')
|
||||
cy.get('.right').click()
|
||||
cy.contains('No results')
|
||||
cy.contains('TKT10004 • Tietokantojen perusteet')
|
||||
cy.contains('AYTKT10004 • Avoin yo: Tietokantojen perusteet')
|
||||
cy.contains('BSCS2001 • Introduction to Databases')
|
||||
cy.contains('581328 • Tietokantojen perusteet')
|
||||
cy.contains('A581328 • Avoin yo: Tietokantojen perusteet')
|
||||
clickNewQueryButton()
|
||||
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
|
||||
})
|
||||
|
||||
it('"Select all search results" button is not showing unless "Select multiple courses" toggle is on', () => {
|
||||
cy.contains('Search for courses')
|
||||
searchByCourseCode('TKT')
|
||||
cy.get('[data-cy="select-multiple-courses-toggle"]').should('not.have.class', 'checked')
|
||||
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
|
||||
cy.contains('TKT10004, BSCS2001, 581328, A581328, AYTKT10004')
|
||||
cy.contains('Select all search results').should('not.exist')
|
||||
cy.get('[data-cy="select-multiple-courses-toggle"]').click()
|
||||
cy.get('[data-cy="select-multiple-courses-toggle"]').should('have.class', 'checked')
|
||||
cy.cs('select-multiple-courses-toggle').click()
|
||||
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
|
||||
cy.contains('Select all search results')
|
||||
})
|
||||
|
||||
it('Provider organization toggle works', () => {
|
||||
it('Provider organization select works', () => {
|
||||
cy.contains('Search for courses')
|
||||
searchByCourseName('tietokantojen perusteet')
|
||||
|
||||
cy.contains('td', /^TKT10004/).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.contains('AYTKT10004 Avoin yo: Tietokantojen perusteet')
|
||||
cy.contains('A581328 Avoin yo: Tietokantojen perusteet')
|
||||
cy.contains('TKT10004 Tietokantojen perusteet')
|
||||
cy.contains('BSCS2001 Introduction to Databases')
|
||||
cy.contains('581328 Tietokantojen perusteet')
|
||||
cy.cs('providerCheckboxUniversity').should('have.class', 'checked').click()
|
||||
cy.cs('providerCheckboxOpenUni').should('have.class', 'checked').click()
|
||||
cy.contains('TKT10004 Tietokantojen perusteet')
|
||||
cy.contains('BSCS2001 Introduction to Databases')
|
||||
cy.contains('581328 Tietokantojen perusteet')
|
||||
cy.contains('TKT10004 • Tietokantojen perusteet')
|
||||
cy.contains('AYTKT10004 • Avoin yo: Tietokantojen perusteet')
|
||||
cy.contains('BSCS2001 • Introduction to Databases')
|
||||
cy.contains('581328 • Tietokantojen perusteet')
|
||||
cy.contains('A581328 • Avoin yo: Tietokantojen perusteet')
|
||||
|
||||
cy.cs('ProviderOrganizationSelect').click()
|
||||
cy.cs('ProviderOrganizationSelectOptionBoth').should('have.class', 'Mui-selected')
|
||||
cy.cs('ProviderOrganizationSelectOptionRegular').should('not.have.class', 'Mui-selected')
|
||||
cy.cs('ProviderOrganizationSelectOptionOpen').should('not.have.class', 'Mui-selected')
|
||||
|
||||
cy.cs('ProviderOrganizationSelectOptionRegular').click()
|
||||
cy.cs('ProviderOrganizationSelectOptionBoth').should('not.have.class', 'Mui-selected')
|
||||
cy.cs('ProviderOrganizationSelectOptionRegular').should('have.class', 'Mui-selected')
|
||||
cy.cs('ProviderOrganizationSelectOptionOpen').should('not.have.class', 'Mui-selected')
|
||||
|
||||
cy.cs('ProviderOrganizationSelect').click()
|
||||
cy.cs('ProviderOrganizationSelectOptionOpen').click()
|
||||
cy.cs('ProviderOrganizationSelectOptionBoth').should('not.have.class', 'Mui-selected')
|
||||
cy.cs('ProviderOrganizationSelectOptionRegular').should('not.have.class', 'Mui-selected')
|
||||
cy.cs('ProviderOrganizationSelectOptionOpen').should('have.class', 'Mui-selected')
|
||||
})
|
||||
|
||||
it('Searching course by name displays right courses, 10 credit courses', { retries: 2 }, () => {
|
||||
@@ -278,28 +305,28 @@ describe('Course Statistics tests', () => {
|
||||
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.contains('TKT20001 Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 Data Structures and Algorithms')
|
||||
cy.contains('58131 Tietorakenteet')
|
||||
cy.get('.right').click()
|
||||
cy.contains('No results')
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 • Data Structures and Algorithms')
|
||||
cy.contains('58131 • Tietorakenteet')
|
||||
clickNewQueryButton()
|
||||
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
|
||||
searchByCourseName('tietorakenteet ja algoritmit')
|
||||
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
|
||||
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('TKT20001 Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 Data Structures and Algorithms')
|
||||
cy.contains('58131 Tietorakenteet')
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 • Data Structures and Algorithms')
|
||||
cy.contains('58131 • Tietorakenteet')
|
||||
})
|
||||
|
||||
it('Can find course population', () => {
|
||||
cy.contains('Search for courses')
|
||||
searchByCourseCode('TKT20003')
|
||||
cy.contains('tr', 'TKT20003').click()
|
||||
cy.contains('TKT20003 Käyttöjärjestelmät')
|
||||
cy.contains('582219 Käyttöjärjestelmät')
|
||||
cy.contains('TKT20003 • Käyttöjärjestelmät')
|
||||
cy.contains('582219 • Käyttöjärjestelmät')
|
||||
cy.get('tbody > :nth-child(4) > :nth-child(2) .level').click()
|
||||
cy.contains('Population of course Käyttöjärjestelmät 2020-2021 (open and normal')
|
||||
cy.contains('TKT20003')
|
||||
@@ -312,10 +339,10 @@ describe('Course Statistics tests', () => {
|
||||
it('Population of course shows grades for each student', () => {
|
||||
searchByCourseCode('TKT20001')
|
||||
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
|
||||
cy.contains('TKT20001 Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 Data Structures and Algorithms')
|
||||
cy.contains('58131 Tietorakenteet')
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 • Data Structures and Algorithms')
|
||||
cy.contains('58131 • Tietorakenteet')
|
||||
cy.get('tbody >:nth-child(5) > :nth-child(2) .level').click()
|
||||
cy.contains('Population of course Tietorakenteet ja algoritmit 2019-2020 (open and normal)')
|
||||
cy.contains('Students (33)').click()
|
||||
@@ -326,10 +353,10 @@ describe('Course Statistics tests', () => {
|
||||
it("In 'Course population' view, student numbers of students that the user isn't allowed to see are hidden", () => {
|
||||
searchByCourseCode('TKT20001')
|
||||
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
|
||||
cy.contains('TKT20001 Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 Data Structures and Algorithms')
|
||||
cy.contains('58131 Tietorakenteet')
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 • Data Structures and Algorithms')
|
||||
cy.contains('58131 • Tietorakenteet')
|
||||
cy.get('tbody >:nth-child(5) > :nth-child(2) .level').click()
|
||||
cy.contains('Population of course Tietorakenteet ja algoritmit 2019-2020 (open and normal)')
|
||||
cy.contains('Students (33)').click()
|
||||
@@ -339,9 +366,8 @@ describe('Course Statistics tests', () => {
|
||||
it('Language distribution is correct', () => {
|
||||
searchByCourseCode('TKT20003')
|
||||
cy.contains('td', 'TKT20003, 582219').click()
|
||||
cy.contains('TKT20003 Käyttöjärjestelmät')
|
||||
cy.contains('582219 Käyttöjärjestelmät')
|
||||
cy.cs('providerCheckboxOpenUni').click()
|
||||
cy.contains('TKT20003 • Käyttöjärjestelmät')
|
||||
cy.contains('582219 • Käyttöjärjestelmät')
|
||||
cy.get('tbody > :nth-child(3) > :nth-child(2) .level').click()
|
||||
cy.contains('Population of course Käyttöjärjestelmät 2021-2022 (open and normal)')
|
||||
cy.contains('Language distribution').click()
|
||||
@@ -354,32 +380,47 @@ describe('Course Statistics tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('Search for courses')
|
||||
cy.get('[data-cy="combine-substitutions-toggle"]').should('have.class', 'checked').click()
|
||||
cy.get('[data-cy="combine-substitutions-toggle"]').should('not.have.class', 'checked')
|
||||
cy.cs('combine-substitutions-toggle').should('have.class', 'Mui-checked').click()
|
||||
cy.cs('combine-substitutions-toggle').should('not.have.class', 'Mui-checked')
|
||||
searchByCourseCode('TKT10002')
|
||||
cy.contains('td', /^TKT10002$/).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('TKT10002 Ohjelmoinnin perusteet')
|
||||
cy.contains('TKT10002 • Ohjelmoinnin perusteet')
|
||||
})
|
||||
|
||||
it('Time range', () => {
|
||||
const yearRange = { from: '2016-2017', to: '2023-2024' }
|
||||
cy.get("div[name='fromYear']").within(() => {
|
||||
cy.get("div[role='option']").first().should('have.text', yearRange.to)
|
||||
cy.contains("div[role='option']", yearRange.from).should('have.class', 'selected')
|
||||
cy.get("div[role='option']").last().should('have.text', '2016-2017')
|
||||
cy.get("div[role='option']").should('have.length', 8)
|
||||
})
|
||||
cy.get("div[name='toYear']").within(() => {
|
||||
cy.get("div[role='option']").first().should('have.text', '2023-2024')
|
||||
cy.contains("div[role='option']", yearRange.to).should('have.class', 'selected')
|
||||
cy.get("div[role='option']").last().should('have.text', yearRange.from)
|
||||
cy.get("div[role='option']").should('have.length', 8)
|
||||
})
|
||||
cy.cs('FromYearSelector').click()
|
||||
cy.cs('FromYearSelectorOption2016-2017').should('have.class', 'Mui-selected')
|
||||
cy.get('[data-cy^="FromYearSelectorOption"]').should('have.length', 8)
|
||||
clickAway()
|
||||
|
||||
cy.cs('ToYearSelector').click()
|
||||
cy.cs('ToYearSelectorOption2023-2024').should('have.class', 'Mui-selected')
|
||||
cy.get('[data-cy^="ToYearSelectorOption"]').should('have.length', 8)
|
||||
clickAway()
|
||||
|
||||
cy.contains('Show population').should('be.enabled')
|
||||
})
|
||||
|
||||
describe('Students tab', () => {
|
||||
describe('Info boxes', () => {
|
||||
it('Student statistics table', () => {
|
||||
cy.cs('StudentStatisticsInfoBoxButton').click()
|
||||
cy.cs('StudentStatisticsInfoBoxContent').contains('Table - Students')
|
||||
})
|
||||
|
||||
it('Pass rate', () => {
|
||||
cy.cs('PassRateStudentsInfoBoxButton').click()
|
||||
cy.cs('PassRateStudentsInfoBoxContent').contains('Pass rate - Students')
|
||||
})
|
||||
|
||||
it('Grade distribution', () => {
|
||||
toggleShowGrades()
|
||||
cy.cs('GradeDistributionInfoBoxButton').click()
|
||||
cy.cs('GradeDistributionInfoBoxContent').contains('Grade distribution')
|
||||
})
|
||||
})
|
||||
|
||||
it('Show grades off, Separate by semesters off', () => {
|
||||
const tableContents = [
|
||||
// [Time, --, Total students, Passed, Failed, Enrolled no grade, Pass rate, Fail rate]
|
||||
@@ -413,6 +454,7 @@ describe('Course Statistics tests', () => {
|
||||
['Syksy 2018', null, 22, 18, 4, null, '81.82 %', '18.18 %'],
|
||||
['Kevät 2018', null, 19, 12, 7, null, '63.16 %', '36.84 %'],
|
||||
['Syksy 2017', null, 8, 7, 1, null, '87.50 %', '12.50 %'],
|
||||
['Kevät 2017', null, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2016', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
]
|
||||
toggleSeparateBySemesters()
|
||||
@@ -453,6 +495,7 @@ describe('Course Statistics tests', () => {
|
||||
['Syksy 2018', null, 22, 4, 2, 1, 1, 2, 12, 0, null, '81.82 %', '18.18 %'],
|
||||
['Kevät 2018', null, 19, 7, 0, 1, 0, 3, 8, 0, null, '63.16 %', '36.84 %'],
|
||||
['Syksy 2017', null, 8, 1, 0, 0, 2, 2, 3, 0, null, '87.50 %', '12.50 %'],
|
||||
['Kevät 2017', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2016', null, 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
|
||||
]
|
||||
toggleShowGrades()
|
||||
@@ -466,6 +509,24 @@ describe('Course Statistics tests', () => {
|
||||
openAttemptsTab()
|
||||
})
|
||||
|
||||
describe('Info boxes', () => {
|
||||
it('Attempt statistics table', () => {
|
||||
cy.cs('AttemptStatisticsInfoBoxButton').click()
|
||||
cy.cs('AttemptStatisticsInfoBoxContent').contains('Table - Attempts')
|
||||
})
|
||||
|
||||
it('Pass rate', () => {
|
||||
cy.cs('PassRateAttemptsInfoBoxButton').click()
|
||||
cy.cs('PassRateAttemptsInfoBoxContent').contains('Pass rate - Attempts')
|
||||
})
|
||||
|
||||
it('Grade distribution', () => {
|
||||
toggleShowGrades()
|
||||
cy.cs('GradeDistributionInfoBoxButton').click()
|
||||
cy.cs('GradeDistributionInfoBoxContent').contains('Grade distribution')
|
||||
})
|
||||
})
|
||||
|
||||
it('Show grades off, Separate by semesters off', () => {
|
||||
const tableContents = [
|
||||
// [Time, --, Total attempts, Passed, Failed, Pass rate, Enrollments]
|
||||
@@ -499,6 +560,7 @@ describe('Course Statistics tests', () => {
|
||||
['Syksy 2018', null, 24, 18, 6, '75.00 %', null],
|
||||
['Kevät 2018', null, 20, 12, 8, '60.00 %', null],
|
||||
['Syksy 2017', null, 8, 7, 1, '87.50 %', null],
|
||||
['Kevät 2017', null, 0, 0, 0, '–', 0],
|
||||
['Syksy 2016', null, 1, 1, 0, '100.00 %', null],
|
||||
]
|
||||
toggleSeparateBySemesters()
|
||||
@@ -539,6 +601,7 @@ describe('Course Statistics tests', () => {
|
||||
['Syksy 2018', null, 24, 6, 2, 1, 1, 2, 12, 0],
|
||||
['Kevät 2018', null, 20, 8, 0, 1, 0, 3, 8, 0],
|
||||
['Syksy 2017', null, 8, 1, 0, 0, 2, 2, 3, 0],
|
||||
['Kevät 2017', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2016', null, 1, 0, 0, 0, 0, 1, 0, 0],
|
||||
]
|
||||
toggleShowGrades()
|
||||
@@ -555,28 +618,24 @@ describe('Course Statistics tests', () => {
|
||||
searchByCourseCode('TKT10002')
|
||||
cy.contains('td', /^TKT10002, BSCS1001, 581325, A581325, AYTKT10002$/).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('TKT10002 Ohjelmoinnin perusteet')
|
||||
cy.contains('AYTKT10002 Avoin yo: Ohjelmoinnin perusteet')
|
||||
cy.contains('BSCS1001 Introduction to Programming')
|
||||
cy.contains('581325 Ohjelmoinnin perusteet')
|
||||
cy.contains('A581325 Avoin yo: Ohjelmoinnin perusteet')
|
||||
cy.contains('TKT10002 • Ohjelmoinnin perusteet')
|
||||
cy.contains('AYTKT10002 • Avoin yo: Ohjelmoinnin perusteet')
|
||||
cy.contains('BSCS1001 • Introduction to Programming')
|
||||
cy.contains('581325 • Ohjelmoinnin perusteet')
|
||||
cy.contains('A581325 • Avoin yo: Ohjelmoinnin perusteet')
|
||||
})
|
||||
|
||||
it('Time range', () => {
|
||||
const yearRange = { from: '1999-2000', to: '2023-2024' }
|
||||
cy.contains('Statistics by time range')
|
||||
cy.get("div[name='fromYear']").within(() => {
|
||||
cy.get("div[role='option']").first().should('have.text', yearRange.to)
|
||||
cy.contains("div[role='option']", yearRange.from).should('have.class', 'selected')
|
||||
cy.get("div[role='option']").last().should('have.text', '1999-2000')
|
||||
cy.get("div[role='option']").should('have.length', 25)
|
||||
})
|
||||
cy.get("div[name='toYear']").within(() => {
|
||||
cy.get("div[role='option']").first().should('have.text', '2023-2024')
|
||||
cy.contains("div[role='option']", yearRange.to).should('have.class', 'selected')
|
||||
cy.get("div[role='option']").last().should('have.text', yearRange.from)
|
||||
cy.get("div[role='option']").should('have.length', 25)
|
||||
})
|
||||
cy.cs('FromYearSelector').click()
|
||||
cy.cs('FromYearSelectorOption1999-2000').should('have.class', 'Mui-selected')
|
||||
cy.get('[data-cy^="FromYearSelectorOption"]').should('have.length', 25)
|
||||
clickAway()
|
||||
|
||||
cy.cs('ToYearSelector').click()
|
||||
cy.cs('ToYearSelectorOption2023-2024').should('have.class', 'Mui-selected')
|
||||
cy.get('[data-cy^="ToYearSelectorOption"]').should('have.length', 25)
|
||||
clickAway()
|
||||
|
||||
cy.contains('Show population').should('be.enabled')
|
||||
})
|
||||
|
||||
@@ -599,11 +658,16 @@ describe('Course Statistics tests', () => {
|
||||
['2012-2013', null, 4, 4, 0, null, '100.00 %', '0.00 %'],
|
||||
['2011-2012', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2010-2011', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2009-2010', null, 0, 0, 0, null, '–', '–'],
|
||||
['2008-2009', null, 1, 0, 1, null, '0.00 %', '100.00 %'],
|
||||
['2007-2008', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2006-2007', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2005-2006', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2004-2005', null, 0, 0, 0, null, '–', '–'],
|
||||
['2003-2004', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2002-2003', null, 0, 0, 0, null, '–', '–'],
|
||||
['2001-2002', null, 0, 0, 0, null, '–', '–'],
|
||||
['2000-2001', null, 0, 0, 0, null, '–', '–'],
|
||||
['1999-2000', null, 2, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
]
|
||||
checkTableContents(tableContents)
|
||||
@@ -634,14 +698,33 @@ describe('Course Statistics tests', () => {
|
||||
['Syksy 2014', null, 3, 3, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2014', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2013', null, 1, 0, 1, null, '0.00 %', '100.00 %'],
|
||||
['Kevät 2013', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 2012', null, 4, 4, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2012', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2011', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Kevät 2011', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2010', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Kevät 2010', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 2009', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Kevät 2009', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 2008', null, 1, 0, 1, null, '0.00 %', '100.00 %'],
|
||||
['Kevät 2008', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2007', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Kevät 2007', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2006', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Kevät 2006', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 2005', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2005', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 2004', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Kevät 2004', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2003', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Kevät 2003', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 2002', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Kevät 2002', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 2001', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Kevät 2001', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 2000', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Kevät 2000', null, 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 1999', null, 2, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
]
|
||||
toggleSeparateBySemesters()
|
||||
@@ -666,11 +749,16 @@ describe('Course Statistics tests', () => {
|
||||
['2012-2013', null, 4, 0, 0, 0, 0, 3, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2011-2012', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2010-2011', null, 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
|
||||
['2009-2010', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['2008-2009', null, 1, 1, 0, 0, 0, 0, 0, 0, null, '0.00 %', '100.00 %'],
|
||||
['2007-2008', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2006-2007', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2005-2006', null, 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
|
||||
['2004-2005', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['2003-2004', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2002-2003', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['2001-2002', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['2000-2001', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['1999-2000', null, 2, 0, 0, 0, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
]
|
||||
toggleShowGrades()
|
||||
@@ -702,14 +790,33 @@ describe('Course Statistics tests', () => {
|
||||
['Syksy 2014', null, 3, 0, 0, 1, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2014', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2013', null, 1, 1, 0, 0, 0, 0, 0, 0, null, '0.00 %', '100.00 %'],
|
||||
['Kevät 2013', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2012', null, 4, 0, 0, 0, 0, 3, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2012', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2011', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Kevät 2011', null, 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2010', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Kevät 2010', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2009', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Kevät 2009', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2008', null, 1, 1, 0, 0, 0, 0, 0, 0, null, '0.00 %', '100.00 %'],
|
||||
['Kevät 2008', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2007', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Kevät 2007', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2006', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Kevät 2006', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2005', null, 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2005', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2004', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Kevät 2004', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2003', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Kevät 2003', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2002', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Kevät 2002', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2001', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Kevät 2001', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2000', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Kevät 2000', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 1999', null, 2, 0, 0, 0, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
]
|
||||
toggleShowGrades()
|
||||
@@ -741,11 +848,16 @@ describe('Course Statistics tests', () => {
|
||||
['2012-2013', null, 4, 4, 0, '100.00 %', null],
|
||||
['2011-2012', null, 1, 1, 0, '100.00 %', null],
|
||||
['2010-2011', null, 1, 1, 0, '100.00 %', null],
|
||||
['2009-2010', null, 0, 0, 0, '–', null],
|
||||
['2008-2009', null, 1, 0, 1, '0.00 %', null],
|
||||
['2007-2008', null, 1, 1, 0, '100.00 %', null],
|
||||
['2006-2007', null, 1, 1, 0, '100.00 %', null],
|
||||
['2005-2006', null, 1, 1, 0, '100.00 %', null],
|
||||
['2004-2005', null, 0, 0, 0, '–', null],
|
||||
['2003-2004', null, 1, 1, 0, '100.00 %', null],
|
||||
['2002-2003', null, 0, 0, 0, '–', null],
|
||||
['2001-2002', null, 0, 0, 0, '–', null],
|
||||
['2000-2001', null, 0, 0, 0, '–', null],
|
||||
['1999-2000', null, 2, 2, 0, '100.00 %', null],
|
||||
]
|
||||
checkTableContents(tableContents)
|
||||
@@ -776,14 +888,33 @@ describe('Course Statistics tests', () => {
|
||||
['Syksy 2014', null, 3, 3, 0, '100.00 %', null],
|
||||
['Kevät 2014', null, 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2013', null, 1, 0, 1, '0.00 %', null],
|
||||
['Kevät 2013', null, 0, 0, 0, '–', 0],
|
||||
['Syksy 2012', null, 4, 4, 0, '100.00 %', null],
|
||||
['Kevät 2012', null, 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2011', null, 0, 0, 0, '–', 0],
|
||||
['Kevät 2011', null, 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2010', null, 0, 0, 0, '–', 0],
|
||||
['Kevät 2010', null, 0, 0, 0, '–', 0],
|
||||
['Syksy 2009', null, 0, 0, 0, '–', 0],
|
||||
['Kevät 2009', null, 0, 0, 0, '–', 0],
|
||||
['Syksy 2008', null, 1, 0, 1, '0.00 %', null],
|
||||
['Kevät 2008', null, 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2007', null, 0, 0, 0, '–', 0],
|
||||
['Kevät 2007', null, 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2006', null, 0, 0, 0, '–', 0],
|
||||
['Kevät 2006', null, 0, 0, 0, '–', 0],
|
||||
['Syksy 2005', null, 1, 1, 0, '100.00 %', null],
|
||||
['Kevät 2005', null, 0, 0, 0, '–', 0],
|
||||
['Syksy 2004', null, 0, 0, 0, '–', 0],
|
||||
['Kevät 2004', null, 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2003', null, 0, 0, 0, '–', 0],
|
||||
['Kevät 2003', null, 0, 0, 0, '–', 0],
|
||||
['Syksy 2002', null, 0, 0, 0, '–', 0],
|
||||
['Kevät 2002', null, 0, 0, 0, '–', 0],
|
||||
['Syksy 2001', null, 0, 0, 0, '–', 0],
|
||||
['Kevät 2001', null, 0, 0, 0, '–', 0],
|
||||
['Syksy 2000', null, 0, 0, 0, '–', 0],
|
||||
['Kevät 2000', null, 0, 0, 0, '–', 0],
|
||||
['Syksy 1999', null, 2, 2, 0, '100.00 %', null],
|
||||
]
|
||||
toggleSeparateBySemesters()
|
||||
@@ -808,11 +939,16 @@ describe('Course Statistics tests', () => {
|
||||
['2012-2013', null, 4, 0, 0, 0, 0, 3, 1, 0],
|
||||
['2011-2012', null, 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['2010-2011', null, 1, 0, 0, 0, 0, 1, 0, 0],
|
||||
['2009-2010', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2008-2009', null, 1, 1, 0, 0, 0, 0, 0, 0],
|
||||
['2007-2008', null, 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['2006-2007', null, 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['2005-2006', null, 1, 0, 0, 0, 0, 1, 0, 0],
|
||||
['2004-2005', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2003-2004', null, 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['2002-2003', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2001-2002', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2000-2001', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['1999-2000', null, 2, 0, 0, 0, 0, 0, 2, 0],
|
||||
]
|
||||
toggleShowGrades()
|
||||
@@ -844,14 +980,33 @@ describe('Course Statistics tests', () => {
|
||||
['Syksy 2014', null, 3, 0, 0, 1, 0, 0, 2, 0],
|
||||
['Kevät 2014', null, 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['Syksy 2013', null, 1, 1, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2013', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2012', null, 4, 0, 0, 0, 0, 3, 1, 0],
|
||||
['Kevät 2012', null, 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['Syksy 2011', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2011', null, 1, 0, 0, 0, 0, 1, 0, 0],
|
||||
['Syksy 2010', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2010', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2009', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2009', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2008', null, 1, 1, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2008', null, 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['Syksy 2007', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2007', null, 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['Syksy 2006', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2006', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2005', null, 1, 0, 0, 0, 0, 1, 0, 0],
|
||||
['Kevät 2005', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2004', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2004', null, 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['Syksy 2003', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2003', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2002', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2002', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2001', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2001', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2000', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Kevät 2000', null, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 1999', null, 2, 0, 0, 0, 0, 0, 2, 0],
|
||||
]
|
||||
toggleShowGrades()
|
||||
@@ -860,44 +1015,52 @@ describe('Course Statistics tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('After changing time range shows same stats', () => {
|
||||
const newYearRange = { from: '2016-2017', to: '2019-2020' }
|
||||
cy.get("div[name='fromYear']")
|
||||
.click()
|
||||
.within(() => {
|
||||
cy.contains(newYearRange.from).click()
|
||||
})
|
||||
cy.get("div[name='toYear']")
|
||||
.click()
|
||||
.within(() => {
|
||||
cy.contains(newYearRange.to).click()
|
||||
})
|
||||
it('After changing time range shows correct options', () => {
|
||||
cy.cs('FromYearSelector').click()
|
||||
cy.cs('FromYearSelectorOption2016-2017').click()
|
||||
cy.cs('FromYearSelectorOption2016-2017').should('have.class', 'Mui-selected')
|
||||
clickAway()
|
||||
|
||||
cy.cs('ToYearSelector').click()
|
||||
cy.cs('ToYearSelectorOption2019-2020').click()
|
||||
cy.cs('ToYearSelectorOption2019-2020').should('have.class', 'Mui-selected')
|
||||
clickAway()
|
||||
|
||||
cy.cs('FromYearSelector').click()
|
||||
cy.get('[data-cy^="FromYearSelectorOption"]').should('have.length', 21)
|
||||
clickAway()
|
||||
|
||||
cy.cs('ToYearSelector').click()
|
||||
cy.get('[data-cy^="ToYearSelectorOption"]').should('have.length', 8)
|
||||
clickAway()
|
||||
|
||||
// Time range
|
||||
cy.get("div[name='fromYear']").within(() => {
|
||||
cy.get("div[role='option']").first().should('have.text', newYearRange.to)
|
||||
cy.contains("div[role='option']", newYearRange.from).should('have.class', 'selected')
|
||||
cy.get("div[role='option']").last().should('have.text', '1999-2000')
|
||||
cy.get("div[role='option']").should('have.length', 21)
|
||||
})
|
||||
cy.get("div[name='toYear']").within(() => {
|
||||
cy.get("div[role='option']").first().should('have.text', '2023-2024')
|
||||
cy.contains("div[role='option']", newYearRange.to).should('have.class', 'selected')
|
||||
cy.get("div[role='option']").last().should('have.text', newYearRange.from)
|
||||
cy.get("div[role='option']").should('have.length', 8)
|
||||
})
|
||||
cy.contains('Show population').should('be.enabled')
|
||||
})
|
||||
})
|
||||
|
||||
it('If no data available, provider organization(s) toggle is disabled', () => {
|
||||
it('If no data available, provider organization(s) select is disabled', () => {
|
||||
searchByCourseCode('TKT20014')
|
||||
cy.contains('TKT20014').click()
|
||||
cy.contains('TKT20014 Kypsyysnäyte LuK')
|
||||
cy.contains('50036 Suomenkielinen kypsyysnäyte LuK')
|
||||
cy.contains('50037 Ruotsinkielinen kypsyysnäyte LuK')
|
||||
cy.cs('providerCheckboxUniversity').find('input').should('not.be.disabled')
|
||||
cy.cs('providerCheckboxOpenUni').find('input').should('be.disabled')
|
||||
|
||||
cy.contains('TKT20014 • Kypsyysnäyte LuK')
|
||||
cy.contains('50037 • Ruotsinkielinen kypsyysnäyte LuK')
|
||||
cy.contains('50036 • Suomenkielinen kypsyysnäyte LuK')
|
||||
|
||||
cy.cs('ProviderOrganizationSelect').click()
|
||||
|
||||
cy.cs('ProviderOrganizationSelectOptionBoth').should('have.class', 'Mui-selected')
|
||||
cy.cs('ProviderOrganizationSelectOptionBoth').should('not.have.class', 'Mui-disabled')
|
||||
cy.cs('ProviderOrganizationSelectOptionBoth').should('have.text', 'Both')
|
||||
|
||||
cy.cs('ProviderOrganizationSelectOptionRegular').should('not.have.class', 'Mui-selected')
|
||||
cy.cs('ProviderOrganizationSelectOptionRegular').should('not.have.class', 'Mui-disabled')
|
||||
cy.cs('ProviderOrganizationSelectOptionRegular').should('have.text', 'University')
|
||||
cy.cs('ProviderOrganizationSelectOptionRegular').should('not.have.text', 'University (not available)')
|
||||
|
||||
cy.cs('ProviderOrganizationSelectOptionOpen').should('not.have.class', 'Mui-selected')
|
||||
cy.cs('ProviderOrganizationSelectOptionOpen').should('have.class', 'Mui-disabled')
|
||||
cy.cs('ProviderOrganizationSelectOptionOpen').should('have.text', 'Open university (not available)')
|
||||
cy.cs('ProviderOrganizationSelectOptionOpen').should('not.have.text', 'University')
|
||||
})
|
||||
|
||||
it('Has right to see all the students, because course provider is TKT', () => {
|
||||
@@ -912,13 +1075,13 @@ describe('Course Statistics tests', () => {
|
||||
|
||||
it('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
|
||||
cy.init('/coursestatistics', 'onlycoursestatistics')
|
||||
cy.get('[data-cy=nav-bar-button-courseStatistics]').click()
|
||||
cy.get('[data-cy=course-code-input]').type('TKT10002')
|
||||
cy.cs('nav-bar-button-courseStatistics').click()
|
||||
cy.cs('course-code-input').type('TKT10002')
|
||||
cy.contains('tr', 'TKT10002').click()
|
||||
cy.contains('Filter statistics by study programmes').should('not.exist')
|
||||
cy.contains('Faculty statistics').should('not.exist')
|
||||
cy.contains('Show population').should('not.exist')
|
||||
cy.contains('.tabular.menu a', 'Attempts').click()
|
||||
openAttemptsTab()
|
||||
|
||||
const emptyYear = year => [year, null, '5 or fewer students', 'NA', 'NA', 'NA', 'NA']
|
||||
|
||||
@@ -939,11 +1102,16 @@ describe('Course Statistics tests', () => {
|
||||
emptyYear('2012-2013'),
|
||||
emptyYear('2011-2012'),
|
||||
emptyYear('2010-2011'),
|
||||
emptyYear('2009-2010'),
|
||||
emptyYear('2008-2009'),
|
||||
emptyYear('2007-2008'),
|
||||
emptyYear('2006-2007'),
|
||||
emptyYear('2005-2006'),
|
||||
emptyYear('2004-2005'),
|
||||
emptyYear('2003-2004'),
|
||||
emptyYear('2002-2003'),
|
||||
emptyYear('2001-2002'),
|
||||
emptyYear('2000-2001'),
|
||||
emptyYear('1999-2000'),
|
||||
]
|
||||
|
||||
|
||||
+236
-284
@@ -5,331 +5,283 @@ const path = require('path')
|
||||
const timestamp = new Date().toLocaleDateString('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' }) // YYYY-MM-DD, taking the local time zone into account
|
||||
const downloadsFolder = Cypress.config('downloadsFolder')
|
||||
|
||||
describe('Faculty overview', () => {
|
||||
describe('Faculty can be selected', () => {
|
||||
it('Faculties are listed and one can be chosen', () => {
|
||||
describe('Faculty statistics', () => {
|
||||
describe('Faculty list', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.get('[data-cy=select-faculty]').contains('a', 'Teologinen tiedekunta')
|
||||
cy.contains('a', 'H99').should('not.exist')
|
||||
})
|
||||
|
||||
it('contains faculty names and faculty codes', () => {
|
||||
cy.contains('Faculties')
|
||||
cy.contains('Teologinen tiedekunta')
|
||||
cy.contains('H10')
|
||||
cy.contains('Oikeustieteellinen tiedekunta')
|
||||
cy.contains('H20')
|
||||
cy.contains('Lääketieteellinen tiedekunta')
|
||||
cy.contains('H30')
|
||||
cy.contains('Humanistinen tiedekunta')
|
||||
cy.contains('H40')
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50')
|
||||
cy.contains('Farmasian tiedekunta')
|
||||
cy.contains('H55')
|
||||
cy.contains('Bio- ja ympäristötieteellinen tiedekunta')
|
||||
cy.contains('H57')
|
||||
cy.contains('Kasvatustieteellinen tiedekunta')
|
||||
cy.contains('H60')
|
||||
cy.contains('Valtiotieteellinen tiedekunta')
|
||||
cy.contains('H70')
|
||||
cy.contains('Svenska social- och kommunalhögskolan')
|
||||
cy.contains('H74')
|
||||
cy.contains('Maatalous-metsätieteellinen tiedekunta')
|
||||
cy.contains('H80')
|
||||
cy.contains('Eläinlääketieteellinen tiedekunta')
|
||||
cy.contains('H90')
|
||||
})
|
||||
|
||||
it('contains a working link to faculty page', () => {
|
||||
cy.contains('Faculties')
|
||||
cy.contains('a', 'Teologinen tiedekunta').click()
|
||||
cy.contains('.header', 'Teologinen tiedekunta')
|
||||
cy.location('pathname').should('eq', '/faculties/hy-org-1000000580')
|
||||
cy.contains('Teologinen tiedekunta')
|
||||
cy.contains('H10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic information: admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties', 'admin')
|
||||
cy.contains('a', 'Eläinlääketieteellinen tiedekunta').click()
|
||||
describe('Basic information tab', () => {
|
||||
describe('Admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties', 'admin')
|
||||
cy.contains('a', 'Eläinlääketieteellinen tiedekunta').click()
|
||||
})
|
||||
|
||||
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', 'Graduation times')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Update statistics')
|
||||
})
|
||||
})
|
||||
|
||||
it('Credits produced by faculty are shown', () => {
|
||||
cy.get('[data-cy="Section-creditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
})
|
||||
describe('Basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
})
|
||||
|
||||
it('Update statistics tab is shown', () => {
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Update statistics')
|
||||
describe('Info boxes', () => {
|
||||
it('Students of the faculty', () => {
|
||||
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxContent]').contains('Taulukon luvut on')
|
||||
})
|
||||
|
||||
it('Graduated of the faculty', () => {
|
||||
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxContent]').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ä')
|
||||
})
|
||||
|
||||
it('Credits produced by the faculty', () => {
|
||||
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxContent]').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')
|
||||
})
|
||||
|
||||
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', 'Graduation times')
|
||||
cy.get('[data-cy="FacultyTabs"]').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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it.skip('Data can be exported to Excel files', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
const sections = [
|
||||
'StudentsOfTheFaculty',
|
||||
'GraduatedOfTheFaculty',
|
||||
'ThesisWritersOfTheFaculty',
|
||||
'CreditsProducedByTheFaculty',
|
||||
]
|
||||
sections.forEach(section => {
|
||||
cy.get(`[data-cy="${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`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic information: basic user', () => {
|
||||
describe('Students by starting year tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Students by starting year').click()
|
||||
})
|
||||
|
||||
it('Basic information tab show all graphs and tables', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-ThesisWritersOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-StudentsOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-GraduatedOfTheFaculty"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Basic information')
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Graduation times')
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Programmes and student populations')
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('not.contain', 'Update statistics')
|
||||
})
|
||||
|
||||
it('Toggle years works', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="YearToggle"]').click()
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').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')
|
||||
})
|
||||
|
||||
it('Students of the faculty infobox works', () => {
|
||||
cy.get('[data-cy="studentsOfTheFaculty-info-content"]').should('not.exist')
|
||||
cy.get('[data-cy="studentsOfTheFaculty-open-info"]').click()
|
||||
cy.get('[data-cy="studentsOfTheFaculty-info-content"]').should('be.visible')
|
||||
cy.get('[data-cy="studentsOfTheFaculty-close-info"]').click()
|
||||
cy.get('[data-cy="studentsOfTheFaculty-info-content"]').should('not.exist')
|
||||
})
|
||||
|
||||
it('Data can be exported to Excel files', { retries: 3 }, () => {
|
||||
const sections = [
|
||||
'StudentsOfTheFaculty',
|
||||
'GraduatedOfTheFaculty',
|
||||
'ThesisWritersOfTheFaculty',
|
||||
'CreditsProducedByTheFaculty',
|
||||
]
|
||||
sections.forEach(section => {
|
||||
cy.get(`[data-cy="Graph-${section}"]`).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`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Study programme information: users with access', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Maatalous-metsätieteellinen tiedekunta').click()
|
||||
})
|
||||
|
||||
it('Study programme credit information is not visible in the beginning', () => {
|
||||
cy.get('table[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
})
|
||||
|
||||
it('Study programme credit information can be toggled', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
|
||||
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-0"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-3"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Button-Hide-CreditsProducedByTheFaculty-0"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
})
|
||||
|
||||
it('Graph stays open when sorted', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-0"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
cy.get('[data-cy="Menu-CreditsProducedByTheFaculty-Total"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Average graduation times', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Graduation times').click()
|
||||
})
|
||||
|
||||
it('User can view graduation graphs', () => {
|
||||
cy.get('[data-cy="Section-AverageGraduationTimes"]').should('be.visible')
|
||||
cy.get('[data-cy="Section-bachelor"]').should('be.visible')
|
||||
cy.get('[data-cy="Section-master"]').should('be.visible')
|
||||
cy.get('[data-cy="Section-master"]').within(() => {
|
||||
cy.contains('text.highcharts-axis-title', 'Graduation year')
|
||||
cy.contains('.message', "Click a bar to view that year's programme level breakdown").should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
it('Graphs display data', () => {
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('div[class="faculty-breakdown-graph"]')
|
||||
})
|
||||
|
||||
cy.get('[data-cy="GraduationTimeToggle"]').click()
|
||||
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('div[class="faculty-graph"]')
|
||||
cy.contains('44 graduated').trigger('mouseover')
|
||||
cy.contains('44 students graduated in year 2023')
|
||||
cy.contains('median study time: 37.5 months')
|
||||
cy.contains('21 graduated on time')
|
||||
cy.contains('11 graduated max year overtime')
|
||||
cy.contains('12 graduated over year late')
|
||||
|
||||
cy.contains('44 graduated').click()
|
||||
cy.contains('Year 2023 by graduation year')
|
||||
cy.get('div[class="programmes-graph"]').should('be.visible')
|
||||
cy.get('div[class="programmes-graph"]').within(() => {
|
||||
cy.contains('MAT')
|
||||
cy.get('[aria-label="MAT, 36."]').trigger('mouseover')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('KH50_001')
|
||||
cy.contains('26 students graduated in year 2023')
|
||||
cy.contains('median study time: 36 months')
|
||||
cy.contains('14 graduated on time')
|
||||
cy.contains('6 graduated max year overtime')
|
||||
cy.contains('6 graduated over year late')
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy="Section-master"]').within(() => {
|
||||
cy.get('div[class="faculty-graph"]')
|
||||
cy.contains('4 graduated').trigger('mouseover')
|
||||
cy.contains('4 students graduated in year 2023')
|
||||
cy.contains('median study time: 23 months')
|
||||
cy.contains('3 graduated on time')
|
||||
cy.contains('0 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
|
||||
cy.contains('4 graduated').click()
|
||||
cy.contains('Year 2023 by graduation year')
|
||||
cy.get('div[class="programmes-graph"]').should('be.visible')
|
||||
cy.get('div[class="programmes-graph"]').within(() => {
|
||||
cy.contains('MAST')
|
||||
cy.get('[aria-label="MAST, 23."]').trigger('mouseover')
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.contains('MH50_001')
|
||||
cy.contains('3 students graduated in year 2023')
|
||||
cy.contains('median study time: 23 months')
|
||||
cy.contains('2 graduated on time')
|
||||
cy.contains('0 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Graduation times grouping and time types can be toggled', () => {
|
||||
cy.get('[data-cy="GraduationTimeToggle"]').click()
|
||||
cy.get('[data-cy="GroupByToggle"]').click()
|
||||
cy.get('[data-cy="Section-master"]').should('be.visible')
|
||||
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('div[class="faculty-graph"]')
|
||||
cy.contains('24 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 24/37 students have graduated')
|
||||
})
|
||||
|
||||
cy.get('[data-cy="GraduationTimeToggle"]').click()
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('[aria-label="2018 - 2019, 29. On time."]').click()
|
||||
cy.get('div[class="programmes-breakdown-graph"]').should('be.visible')
|
||||
cy.get('div[class="programmes-breakdown-graph"]').within(() => {
|
||||
cy.contains('Year 2018 - 2019 by start year')
|
||||
cy.contains('MAT')
|
||||
cy.get('[aria-label="MAT, 20. Max. year overtime."]').trigger('mouseover')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('KH50_001')
|
||||
cy.contains('Max. year overtime: 20')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Data can be exported to an Excel file', () => {
|
||||
cy.get('[data-cy="DownloadButton-AverageGraduationTimes"]').click()
|
||||
const downloadedFile = `oodikone_H50_graduation_times_${timestamp}.xlsx`
|
||||
it('Export button', { retries: 2 }, () => {
|
||||
cy.get('[data-cy=FacultyStudentTableExportButton]').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')
|
||||
})
|
||||
|
||||
it.skip('Study right toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Graduated toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Hoverable country list', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Expandable rows', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Population links', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
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 %')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Faculty Progress and student population tab', () => {
|
||||
describe('Progress tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Programmes and student populations').click()
|
||||
cy.contains('Progress').click()
|
||||
})
|
||||
|
||||
it('User can view studentdata table without %', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', '0 %')
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.get('[data-cy=FacultyProgressExportButton]').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_progress_tab_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it('User can view studentdata table with %', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="HidePercentagesToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', '0 %')
|
||||
it.skip('Info boxes', () => {
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxContent]').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')
|
||||
})
|
||||
|
||||
it('Percentage Graphs exists', () => {
|
||||
cy.get('[data-cy="Graph-FacultyBachelorsProgress"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-FacultyBachelorMastersProgress"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-FacultyMastersProgress"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-FacultyBachelorsProgress"]').should('be.visible')
|
||||
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')
|
||||
})
|
||||
|
||||
it('Graduations can be excluded', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="StudentToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('Transfers can be excluded', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="GraduatedToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('Progress tables exists', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
it('Progress tables can be toggled', () => {
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Button-Show-FacultyBachelorsProgressTable-5"]').click()
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-0"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-3"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('be.visible')
|
||||
cy.get('[data-cy="Button-Hide-FacultyBachelorsProgressTable-5"]').click()
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('not.be.visible')
|
||||
it.skip('Graduated toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('Studentstats table can be toggled', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', 'TKT')
|
||||
cy.get('[data-cy="Button-FacultyStudentStatsTable-6"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', 'TKT')
|
||||
cy.get('[data-cy="Button-FacultyStudentStatsTable-6"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', 'TKT')
|
||||
it.skip('Study right toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graduation times tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Graduation times').click()
|
||||
})
|
||||
|
||||
it('Progress infobox can be toggled', () => {
|
||||
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('not.exist')
|
||||
cy.get('[data-cy="InfoFacultyProgress-open-info"]').click()
|
||||
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('be.visible')
|
||||
cy.get('[data-cy="InfoFacultyProgress-close-info"]').click()
|
||||
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('not.exist')
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.get('[data-cy=AverageGraduationTimesExportButton]').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')
|
||||
})
|
||||
|
||||
it.skip('Study times toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Year toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Study programme toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('Graphs are visible', () => {
|
||||
cy.get('[data-cy="bachelorGraduationTimes"]').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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('When language center is opened', () => {
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').should('have.length', 15)
|
||||
cy.get('td').eq(0).contains('All courses total')
|
||||
const numbers = [36, 9, 34, 755, 15, 40, 6, 48, 1, 24, 1059, 49, 0, 2076]
|
||||
const numbers = [36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0, 2076]
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + 1)
|
||||
@@ -76,12 +76,12 @@ describe('When language center is opened', () => {
|
||||
|
||||
it('Faculties tab "exceeding" button works', () => {
|
||||
cy.get('[data-cy="difference-button"]').click()
|
||||
checkNumbers([...new Array(12).fill(0), 1, 227], 15)
|
||||
checkNumbers([...new Array(12).fill(0), 1, 221], 15)
|
||||
})
|
||||
|
||||
it('Faculties tab semester selector changes numbers', () => {
|
||||
chooseSemester('Syksy 2020', 'from')
|
||||
checkNumbers([28, 6, 28, 495, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
|
||||
checkNumbers([28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -98,10 +98,10 @@ describe('Population statistics tests', () => {
|
||||
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')
|
||||
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', () => {
|
||||
@@ -109,7 +109,8 @@ describe('Population statistics tests', () => {
|
||||
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().contains('2023–2026').click()
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
|
||||
})
|
||||
|
||||
@@ -117,14 +118,17 @@ describe('Population statistics tests', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v2/populationstatistics/courses').as('courseData')
|
||||
cy.get('[data-cy=curriculum-picker]').click().contains('2020–2023').click()
|
||||
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().contains('2023–2026').click()
|
||||
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')
|
||||
|
||||
+66
-54
@@ -8,13 +8,21 @@ const student = {
|
||||
email: 'sisutestidata134902@testisisudata.fi',
|
||||
}
|
||||
|
||||
const typeToSearch = text => {
|
||||
cy.get("input[placeholder='Search with a student number or name (surname firstname)']").type(text)
|
||||
}
|
||||
|
||||
const typeStudentNumberAndClick = studentNumber => {
|
||||
cy.get('.prompt').type(studentNumber)
|
||||
cy.contains('td a', studentNumber).click()
|
||||
typeToSearch(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', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init()
|
||||
cy.contains('Students').click()
|
||||
@@ -23,7 +31,7 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it('Students search form is usable', () => {
|
||||
cy.get('.prompt').type(student.lastname)
|
||||
typeToSearch(student.lastname)
|
||||
cy.contains('Student number')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
@@ -37,20 +45,19 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it('Search term must be at least 4 characters long', () => {
|
||||
cy.get('.prompt').type(student.lastname.slice(0, 3))
|
||||
cy.contains('No search results or search term is not accurate enough')
|
||||
cy.get('.prompt').type(student.lastname.slice(3))
|
||||
typeToSearch(student.lastname.slice(0, 3))
|
||||
cy.contains('Search term is not accurate enough')
|
||||
typeToSearch(student.lastname.slice(3))
|
||||
cy.get('table tbody tr').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('can search with studentnumber too', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
it('Can search with studentnumber too', () => {
|
||||
typeToSearch(student.studentnumber)
|
||||
cy.contains(student.studentnumber)
|
||||
})
|
||||
|
||||
it('Can get student specific page by clicking student', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
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')
|
||||
@@ -69,7 +76,7 @@ describe('Students tests', () => {
|
||||
|
||||
it("'Update student' button is not shown", () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('div.ui.fluid.card').within(() => {
|
||||
cy.get('[data-cy=student-info-card]').within(() => {
|
||||
cy.contains('button', 'Update student').should('not.exist')
|
||||
})
|
||||
})
|
||||
@@ -88,17 +95,17 @@ describe('Students tests', () => {
|
||||
.siblings()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.get('.level').click()
|
||||
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=sisulink] > a')
|
||||
cy.get('[data-cy=sisu-link]')
|
||||
.should('have.attr', 'href')
|
||||
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sis_person_id}/basic/basic-info`)
|
||||
})
|
||||
@@ -110,7 +117,7 @@ describe('Students tests', () => {
|
||||
'Matemaattisten tieteiden kandiohjelma',
|
||||
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
|
||||
]
|
||||
cy.get('div.ui.fluid.card').within(() => {
|
||||
cy.get('[data-cy=student-info-card]').within(() => {
|
||||
cy.contains('Enrollments').click()
|
||||
cy.get('table tbody tr')
|
||||
.should('have.length', 3)
|
||||
@@ -122,18 +129,22 @@ describe('Students tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Searching with bad inputs doesnt yield results', () => {
|
||||
cy.get('.prompt').type('SWAG LITTINEN')
|
||||
it("Searching with bad inputs doesn't yield results", () => {
|
||||
typeToSearch('SWAG LITTINEN')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
|
||||
cy.get('.prompt').clear().type('01114')
|
||||
clearSearch()
|
||||
typeToSearch('01114')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
})
|
||||
|
||||
it('Can jump to population page', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.contains('.ui.table', 'Completed').within(() => {
|
||||
cy.get('i.level.up.alternate.icon').click()
|
||||
cy.get('[data-cy=study-rights-section]').within(() => {
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
})
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('class size 30 students')
|
||||
@@ -141,33 +152,32 @@ describe('Students tests', () => {
|
||||
|
||||
it('Grade graph works in all three different modes', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.contains('a.item', 'Grade graph').click()
|
||||
cy.contains('text.highcharts-title', 'Grade plot')
|
||||
cy.contains('a.item', 'Show group mean').click()
|
||||
cy.get('.labeled.input').within(() => {
|
||||
cy.contains('.label', 'Group size')
|
||||
cy.contains('button', 'Grade graph').click()
|
||||
cy.contains('button', 'Show group mean').click()
|
||||
cy.get('[data-cy=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.contains('text.highcharts-title', 'Grade plot')
|
||||
cy.get('.labeled.input').within(() => {
|
||||
cy.contains('.label', 'Group size')
|
||||
cy.get('input').clear().type('10')
|
||||
cy.get('[data-cy=group-size-input]').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').clear()
|
||||
cy.get('input').type('10')
|
||||
cy.get('input').should('have.value', '10')
|
||||
})
|
||||
cy.contains('.highcharts-container text', "Jul '22")
|
||||
cy.contains('.highcharts-container text', "Nov '22").should('not.exist')
|
||||
cy.contains('a.item', 'Show semester mean').click()
|
||||
cy.contains('button', 'Show semester mean').click()
|
||||
cy.contains('.highcharts-container text', "Jan '22")
|
||||
})
|
||||
|
||||
describe('Bachelor Honours section', () => {
|
||||
it("Shows 'Qualified for Honours' tag and main modules info when the student is qualified", () => {
|
||||
cy.visit('/students/495976')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.green.tag.label', 'Qualified for Honours')
|
||||
cy.contains('Main courses and other modules').click()
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-qualified]', 'Qualified for Honours')
|
||||
cy.contains('Study modules').click()
|
||||
cy.get('[data-cy=main-modules] tbody tr')
|
||||
.should('have.length', 3)
|
||||
.each(($tr, index) => {
|
||||
@@ -186,35 +196,35 @@ describe('Students tests', () => {
|
||||
|
||||
it("Shows 'Did not graduate in time' when the student has graduated but not in time", () => {
|
||||
cy.visit('/students/540355')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.red.tag.label', 'Not qualified for Honours')
|
||||
cy.contains('.red.tag.label', 'Did not graduate in time')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Did not graduate in time')
|
||||
})
|
||||
|
||||
it("Shows 'Module grades too low' when the student has graduated in time but has too low grades", () => {
|
||||
cy.visit('/students/547934')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.red.tag.label', 'Not qualified for Honours')
|
||||
cy.contains('.red.tag.label', 'Module grades too low')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Module grades too low')
|
||||
})
|
||||
|
||||
it("Shows 'Might need further inspection' when the student has graduated in time but has more than four main modules", () => {
|
||||
cy.visit('/students/478837')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.blue.tag.label', 'Might need further inspection')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-inspection]', 'Might need further inspection')
|
||||
})
|
||||
|
||||
it("Shows 'Has not graduated' when the student has not graduated", () => {
|
||||
cy.visit(`students/${student.studentnumber}`)
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.red.tag.label', 'Not qualified for Honours')
|
||||
cy.contains('.red.tag.label', 'Has not graduated')
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Use admin to see all students
|
||||
describe('when using admin user', () => {
|
||||
describe('When using admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/students', 'admin')
|
||||
})
|
||||
@@ -226,14 +236,14 @@ describe('Students tests', () => {
|
||||
|
||||
it("'Update student' button is shown", () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('div.ui.fluid.card').within(() => {
|
||||
cy.get('[data-cy=student-info-card]').within(() => {
|
||||
cy.contains('button', 'Update student')
|
||||
})
|
||||
})
|
||||
|
||||
it('Bachelor Honours section is not shown for students outside of Faculty of Science', () => {
|
||||
cy.visit('/students/453146')
|
||||
cy.contains('.divider h4', 'Bachelor Honours').should('not.exist')
|
||||
cy.contains('h2', 'Bachelor Honours').should('not.exist')
|
||||
})
|
||||
|
||||
it('When a study plan is selected, courses included in the study plan are highlighted with a blue background', () => {
|
||||
@@ -241,9 +251,11 @@ describe('Students tests', () => {
|
||||
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
|
||||
cy.get('td').eq(0).click()
|
||||
})
|
||||
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)')
|
||||
.should('have.attr', 'style')
|
||||
.and('equal', 'background-color: rgb(232, 244, 255);')
|
||||
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)').should(
|
||||
'have.css',
|
||||
'background-color',
|
||||
'rgb(232, 244, 255)'
|
||||
)
|
||||
})
|
||||
|
||||
it('When a study plan is selected, the time frame of the credit graph is updated', () => {
|
||||
|
||||
@@ -141,7 +141,7 @@ describe('Study guidance group tests', () => {
|
||||
'Transferred from',
|
||||
'Admission type',
|
||||
'Gender',
|
||||
'Citizenship',
|
||||
'Citizenships',
|
||||
'Curriculum period',
|
||||
'Latest attainment date',
|
||||
'Tags',
|
||||
|
||||
@@ -81,11 +81,11 @@ 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],
|
||||
]
|
||||
@@ -113,11 +113,11 @@ describe('Study programme overview', () => {
|
||||
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],
|
||||
]
|
||||
@@ -175,7 +175,7 @@ describe('Study programme overview', () => {
|
||||
.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()
|
||||
@@ -253,14 +253,14 @@ describe('Study programme overview', () => {
|
||||
it('Students of the study programme are shown correctly', () => {
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 0],
|
||||
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
|
||||
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
|
||||
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 35, 3],
|
||||
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 1],
|
||||
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 34, 1],
|
||||
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
|
||||
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 3],
|
||||
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 35, 2],
|
||||
['2018 - 2019', 45, 40, 0, 0, 6, 39, 26, 19, 0, 44, 1],
|
||||
['2017 - 2018', 47, 41, 0, 0, 5, 42, 31, 16, 0, 47, 0],
|
||||
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 222, 7],
|
||||
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 224, 12],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudyTrackOverview')
|
||||
@@ -276,7 +276,7 @@ describe('Study programme overview', () => {
|
||||
|
||||
const dataForStudyTracks = [
|
||||
['Ekonometria, MAT-EKO', 2, 2, 0, 0, 0, 2, 1, 1, 0, 2, 0],
|
||||
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 1],
|
||||
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
['Tietojenkäsittelyteoria, MAT-TIE', 2, 1, 0, 0, 1, 1, 2, 0, 0, 2, 0],
|
||||
['Tilastotiede, MAT-TIL', 4, 4, 0, 0, 0, 4, 1, 3, 0, 4, 0],
|
||||
]
|
||||
@@ -413,11 +413,11 @@ describe('Study programme overview', () => {
|
||||
// [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],
|
||||
['2021 - 2022', 5, 1, 0, 0, 1, 4, 3, 2, 0, 5, 0],
|
||||
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 1],
|
||||
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 16, 1],
|
||||
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 17, 1],
|
||||
['2018 - 2019', 23, 21, 0, 0, 2, 21, 11, 12, 0, 23, 0],
|
||||
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 87, 2],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 88, 4],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'StudyTrackOverview')
|
||||
})
|
||||
@@ -742,16 +742,23 @@ describe('Study programme overview', () => {
|
||||
cy.contains(studentNumber)
|
||||
}
|
||||
|
||||
cy.go('back')
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags')
|
||||
cy.contains(name)
|
||||
}
|
||||
|
||||
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?p_tab=4')
|
||||
|
||||
deleteTag(name)
|
||||
})
|
||||
|
||||
it('deleting a tag from tag view also removes it from students', () => {
|
||||
cy.contains(name).should('not.exist')
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.contains('Students').click()
|
||||
cy.get('.prompt').type(studentNumber)
|
||||
cy.contains('a', studentNumber).click()
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags').should('not.exist')
|
||||
cy.contains(name).should('not.exist')
|
||||
}
|
||||
})
|
||||
|
||||
+4
-13
@@ -8,12 +8,8 @@ const visibleLinks = {
|
||||
visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Programmes', 'Students']
|
||||
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
|
||||
|
||||
const hasVisibleItems = visibleItems => {
|
||||
cy.get('[data-cy=nav-bar]').children().should('have.length', visibleItems)
|
||||
}
|
||||
|
||||
const containsLinks = links => {
|
||||
cy.get('[data-cy=nav-bar]').within(() => {
|
||||
cy.cs('nav-bar').within(() => {
|
||||
for (const link of links) {
|
||||
cy.contains(link)
|
||||
}
|
||||
@@ -21,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')
|
||||
@@ -34,7 +30,6 @@ describe('Users tests', () => {
|
||||
describe('Using as user with just grp-oodikone-user, no other rights', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('', 'norights')
|
||||
hasVisibleItems(7)
|
||||
containsLinks(visibleLinks.norights)
|
||||
userButtonWorks('norights')
|
||||
})
|
||||
@@ -43,7 +38,6 @@ describe('Users tests', () => {
|
||||
describe('Using as coursestatistics user', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('', 'onlycoursestatistics')
|
||||
hasVisibleItems(7)
|
||||
containsLinks(visibleLinks.onlycoursestatistics)
|
||||
userButtonWorks('onlycoursestatistics')
|
||||
})
|
||||
@@ -52,7 +46,6 @@ describe('Users tests', () => {
|
||||
describe('Using as basic user', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('')
|
||||
hasVisibleItems(10)
|
||||
containsLinks(visibleLinks.basic)
|
||||
userButtonWorks('basic')
|
||||
})
|
||||
@@ -64,12 +57,11 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it('should see more stuff than others', () => {
|
||||
hasVisibleItems(13)
|
||||
containsLinks(visibleLinks.admin)
|
||||
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()
|
||||
@@ -80,8 +72,7 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it("only the mocked user's programmes are visible", () => {
|
||||
cy.get('[data-cy=nav-bar-button-studyProgramme]').click()
|
||||
cy.get('[data-cy=nav-bar-button-class]').click()
|
||||
cy.visit('/populations')
|
||||
cy.contains('label', 'Study programme')
|
||||
.siblings()
|
||||
.within(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,7 +83,7 @@ services:
|
||||
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
|
||||
@@ -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
|
||||
@@ -75,7 +76,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
ports:
|
||||
- 12349:5432
|
||||
volumes:
|
||||
@@ -98,7 +99,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
volumes:
|
||||
- sis-db-data:/data
|
||||
shm_size: 2gb
|
||||
@@ -175,7 +176,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
volumes:
|
||||
- user-db-data:/data
|
||||
ports:
|
||||
|
||||
+2
-2
@@ -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:
|
||||
@@ -189,7 +189,7 @@ services:
|
||||
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
|
||||
@@ -12,15 +12,12 @@ Roles are defined [here](../services/backend/src/config/roles.ts). Criteria for
|
||||
- studyGuidanceGroups
|
||||
- teachers
|
||||
|
||||
The roles `openUniSearch` and `studyGuidanceGroups` simply enable certain views (_Open uni student population_ and _Guidance groups_ respectively).
|
||||
The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply enable certain views (_Faculties_, _Open uni student population_, and _Guidance groups_ respectively).
|
||||
|
||||
### courseStatistics
|
||||
|
||||
- Can see statistics of every course but year statistics of ≤ 5 students are hidden
|
||||
|
||||
### facultyStatistics
|
||||
|
||||
- Can see statistics of every course and year
|
||||
- If the user has [full rights](#programmes) to **any** programme, they can also see exact statistics even if there are ≤ 5 students (the same behavior as with `admin` or `fullSisuAccess` roles)
|
||||
|
||||
### teachers
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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.
|
||||
|
||||
### 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 migration.
|
||||
- Optionally, use `db-cli` to check the production database state (see `how_to_db_cli.md` in GitLab).
|
||||
@@ -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)
|
||||
|
||||
<!--
|
||||
|
||||
+8
-1
@@ -90,6 +90,7 @@ export default [
|
||||
'error',
|
||||
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
|
||||
],
|
||||
'react/jsx-boolean-value': ['error', 'never'],
|
||||
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
|
||||
'react/jsx-sort-props': ['error', { reservedFirst: false }],
|
||||
'react/no-array-index-key': 'error',
|
||||
@@ -99,7 +100,7 @@ export default [
|
||||
'react/prefer-stateless-function': 'error',
|
||||
'react/prop-types': 'off',
|
||||
...pluginReactHooks.configs.recommended.rules,
|
||||
'react-hooks/exhaustive-deps': 'off', // TODO: Delete this override
|
||||
'react-hooks/exhaustive-deps': 'warn', // TODO: Delete this override
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
'@typescript-eslint/consistent-indexed-object-style': 'off',
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
@@ -132,6 +133,12 @@ export default [
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['services/**/shared/**/*'],
|
||||
rules: {
|
||||
'import/no-unused-modules': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['cypress/**/*.js', 'services/backend/**/*.js', 'updater/**/*.js'],
|
||||
languageOptions: {
|
||||
|
||||
Generated
+974
-679
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.6",
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@cypress/code-coverage": "^3.13.11",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"cypress": "^13.15.2",
|
||||
"eslint": "^9.14.0",
|
||||
"cypress": "^14.0.1",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.3",
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"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-react-hooks": "^5.0.0",
|
||||
"globals": "^15.12.0",
|
||||
"husky": "^9.1.6",
|
||||
"lint-staged": "^15.2.10",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"globals": "^15.14.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.4.3",
|
||||
"node-actionlint": "^1.2.2",
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "3.3.3",
|
||||
"stylelint": "^16.10.0",
|
||||
"prettier": "3.4.2",
|
||||
"stylelint": "^16.14.1",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"typescript-eslint": "^8.13.0"
|
||||
"typescript-eslint": "^8.22.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
+1
-12
@@ -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
|
||||
}
|
||||
],
|
||||
@@ -28,7 +19,5 @@
|
||||
"labels": ["security"]
|
||||
},
|
||||
"addLabels": ["dependencies"],
|
||||
"rangeStrategy": "bump",
|
||||
"schedule": ["before 5:00am"],
|
||||
"timezone": "Europe/Helsinki"
|
||||
"rangeStrategy": "bump"
|
||||
}
|
||||
|
||||
Generated
+297
-726
File diff suppressed because it is too large
Load Diff
@@ -5,22 +5,21 @@
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/node": "^8.37.1",
|
||||
"@sentry/profiling-node": "^8.37.1",
|
||||
"axios": "^0.28.1",
|
||||
"bullmq": "^5.25.4",
|
||||
"@sentry/node": "^8.53.0",
|
||||
"@sentry/profiling-node": "^8.53.0",
|
||||
"axios": "^1.7.9",
|
||||
"bullmq": "^5.39.1",
|
||||
"compression": "^1.7.5",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^3.1.9",
|
||||
"express": "^4.21.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",
|
||||
"oodikone-shared": "file:../oodikone-shared",
|
||||
"papaparse": "^5.4.1",
|
||||
"papaparse": "^5.5.2",
|
||||
"pg": "^8.13.1",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -35,16 +34,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.9.0",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/validator": "^13.12.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"overrides": {
|
||||
"winston-gelf-transporter": {
|
||||
"gelf-pro": "^1.3.12"
|
||||
"gelf-pro": "^1.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { GenderCode } from '../types'
|
||||
import { Credit, Enrollment, SISStudyRight, Studyplan } from '.'
|
||||
|
||||
@@ -56,23 +57,8 @@ export class Student extends Model<InferAttributes<Student>> {
|
||||
@Column(DataType.STRING)
|
||||
phone_number!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
country_fi!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
country_sv!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
country_en!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
home_country_fi!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
home_country_sv!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
home_country_en!: string
|
||||
@Column(DataType.JSONB)
|
||||
citizenships!: Name[]
|
||||
|
||||
@Column(DataType.STRING)
|
||||
gender_code!: GenderCode
|
||||
@@ -83,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
|
||||
|
||||
|
||||
@@ -16,18 +16,21 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
const fakeRelease: Release[] = [
|
||||
{
|
||||
description: '**Feature 1**\n- Added a fancy new feature \n\n**Feature 2**\n- Fixed a bug\n- Fixed another bug',
|
||||
title: 'Release 1',
|
||||
title: 'Release 3',
|
||||
time: new Date().toISOString(),
|
||||
version: '0.0.3',
|
||||
},
|
||||
{
|
||||
description: "Let's not spam the GitHub API in development!",
|
||||
title: 'Release 2',
|
||||
time: new Date().toISOString(),
|
||||
version: '0.0.2',
|
||||
},
|
||||
{
|
||||
description: 'This release should not be visible on the frontpage',
|
||||
title: 'Release 3',
|
||||
title: 'Release 1',
|
||||
time: new Date().toISOString(),
|
||||
version: '0.0.1',
|
||||
},
|
||||
]
|
||||
return res.status(200).json(fakeRelease)
|
||||
@@ -37,6 +40,7 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
description: release.body,
|
||||
time: release.published_at,
|
||||
title: release.name,
|
||||
version: release.tag_name,
|
||||
}))
|
||||
changelog.data = releasesFromAPI
|
||||
res.status(200).json(releasesFromAPI)
|
||||
|
||||
@@ -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,7 +5,7 @@ import { getDegreeProgrammesOfFaculty, getFacultyCodeById } from '../services/fa
|
||||
import { combineFacultyBasics } from '../services/faculty/facultyBasics'
|
||||
import { getFacultyCredits } from '../services/faculty/facultyCredits'
|
||||
import { countGraduationTimes } from '../services/faculty/facultyGraduationTimes'
|
||||
import { getSortedFaculties } from '../services/faculty/facultyHelpers'
|
||||
import { getFacultiesForFacultyList } from '../services/faculty/facultyHelpers'
|
||||
import {
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
@@ -22,7 +22,7 @@ import { combineFacultyStudentProgress } from '../services/faculty/facultyStuden
|
||||
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
|
||||
import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWriters'
|
||||
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../types'
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
// Faculty uses a lot of tools designed for Study programme.
|
||||
@@ -31,7 +31,7 @@ import logger from '../util/logger'
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const faculties = await getSortedFaculties()
|
||||
const faculties = await getFacultiesForFacultyList()
|
||||
res.json(faculties)
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { getGraduationStatsForStudyTrack } from '../services/studyProgramme/stud
|
||||
import { updateBasicView, updateStudyTrackView } from '../services/studyProgramme/studyProgrammeUpdates'
|
||||
import { getStudyRightsInProgramme, getStudyTracksForProgramme } from '../services/studyProgramme/studyRightFinders'
|
||||
import { getStudyTrackStatsForStudyProgramme } from '../services/studyProgramme/studyTrackStats'
|
||||
import { Graduated, SpecialGroups, YearType } from '../types'
|
||||
import { Graduated, SpecialGroups, YearType } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
import { logInfoForGrafana } from '../util/logInfoForGrafana'
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
} from '../services/faculty/facultyService'
|
||||
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
|
||||
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
|
||||
import { NameWithCode } from '../shared/types'
|
||||
import { Graduated } from '../types'
|
||||
import { Graduated, NameWithCode } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
@@ -33,7 +32,7 @@ const getProgrammeNames = (faculties: Organization[]) => {
|
||||
|
||||
interface GetProgressStatsRequest extends Request {
|
||||
query: {
|
||||
specialsIncluded: 'true' | undefined
|
||||
specialsIncluded: 'true' | 'false'
|
||||
graduated: Graduated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
|
||||
import { Graduated, SpecialGroups, YearType } from '../types'
|
||||
import { Graduated, SpecialGroups, YearType } from '../shared/types'
|
||||
import { redisClient } from './redis'
|
||||
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
import { NameWithCode } from '../../shared/types'
|
||||
import { DegreeProgrammeType } from '../../types'
|
||||
import { getBasicStats, setBasicStats } from '../analyticsService'
|
||||
import { getBasicStatsForStudytrack } from '../studyProgramme/studyProgrammeBasics'
|
||||
import type { ProgrammesOfOrganization } from './faculty'
|
||||
|
||||
type ProgrammeName = { code: string; en?: string; fi?: string; sv?: string }
|
||||
|
||||
type StudyTrackBasicStats = Awaited<ReturnType<typeof getBasicStatsForStudytrack>>
|
||||
|
||||
const calculateCombinedStats = async (
|
||||
@@ -19,12 +18,12 @@ const calculateCombinedStats = async (
|
||||
graphStats: [] as StudyTrackBasicStats['graphStats'],
|
||||
programmeTableStats: {} as Record<string, StudyTrackBasicStats['tableStats']>,
|
||||
tableStats: [] as StudyTrackBasicStats['tableStats'],
|
||||
titles: ['', 'Started\nstudying', 'Accepted', 'Graduated'],
|
||||
titles: ['Code', 'Started\nstudying', 'Accepted', 'Graduated'],
|
||||
}
|
||||
if (includeAllSpecials) {
|
||||
studentInfo.titles.push('Transferred out\nof programme', 'Transferred into\nprogramme')
|
||||
}
|
||||
const programmeNames: Record<string, ProgrammeName> = {}
|
||||
const programmeNames: Record<string, NameWithCode> = {}
|
||||
let years: Array<string | number> = []
|
||||
|
||||
for (const programme of facultyProgrammes) {
|
||||
@@ -143,7 +142,7 @@ const calculateCombinedStats = async (
|
||||
graphStats: combinedGraphStats,
|
||||
programmeTableStats: combinedProgrammeTableStats,
|
||||
tableStats: combinedTableStats,
|
||||
titles: ['', 'All', 'Bachelors', 'Masters', 'Doctors', 'Others'],
|
||||
titles: ['Code', 'All', 'Bachelors', 'Masters', 'Doctors', 'Others'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { ignoredFacultyCodes } from '../../config/organizationConstants'
|
||||
import { Organization } from '../../models'
|
||||
import { getOrganizations } from '../organizations'
|
||||
|
||||
export const getFaculties = async (): Promise<Organization[]> => {
|
||||
export const getFaculties = async () => {
|
||||
const faculties = (await getOrganizations()).filter(faculty => !ignoredFacultyCodes.includes(faculty.code))
|
||||
return faculties
|
||||
}
|
||||
|
||||
export const getSortedFaculties = async (): Promise<Organization[]> => {
|
||||
export const getSortedFaculties = async () => {
|
||||
const faculties = await getFaculties()
|
||||
return faculties.sort((a, b) => {
|
||||
const nameA = a.name.fi ?? ''
|
||||
@@ -16,8 +15,11 @@ export const getSortedFaculties = async (): Promise<Organization[]> => {
|
||||
})
|
||||
}
|
||||
|
||||
const commissionedProgrammes = ['KH50_009', 'MH50_015', 'T923103-N']
|
||||
|
||||
export const checkCommissioned = (studyRight: any): boolean => {
|
||||
return studyRight.studyrightElements.some(element => commissionedProgrammes.includes(element.code))
|
||||
export const getFacultiesForFacultyList = async () => {
|
||||
const faculties = await getSortedFaculties()
|
||||
return faculties.map(faculty => ({
|
||||
code: faculty.code,
|
||||
id: faculty.id,
|
||||
name: faculty.name,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { Name } from '../../shared/types'
|
||||
import { DegreeProgrammeType, Graduated, ProgrammeFilter, SpecialGroups, YearType } from '../../types'
|
||||
import { Graduated, Name, ProgrammeFilter, SpecialGroups, YearType } from '../../shared/types'
|
||||
import { DegreeProgrammeType } from '../../types'
|
||||
import { redisClient } from '../redis'
|
||||
import { FacultyProgressData } from './facultyStudentProgress'
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { cloneDeep } from 'lodash'
|
||||
import moment from 'moment'
|
||||
|
||||
import { rootOrgId } from '../../config'
|
||||
import { Graduated, SpecialGroups, Unarray } from '../../types'
|
||||
import { Graduated, SpecialGroups } from '../../shared/types'
|
||||
import { Unarray } from '../../types'
|
||||
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
|
||||
import { getYearsArray } from '../studyProgramme/studyProgrammeHelpers'
|
||||
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
|
||||
|
||||
@@ -133,7 +133,7 @@ export const combineFacultyThesisWriters = async (
|
||||
tableStats,
|
||||
graphStats,
|
||||
programmeTableStats,
|
||||
titles: ['', 'All', 'Bachelors', 'Masters'],
|
||||
titles: ['Code', 'All', 'Bachelors', 'Masters'],
|
||||
programmeNames,
|
||||
status: 'Done',
|
||||
lastUpdated: '',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { StatsType } from '../../types'
|
||||
import { StatsType } from '../../shared/types'
|
||||
import { setCreditStats } from '../analyticsService'
|
||||
import { computeCreditsProduced } from '../providerCredits'
|
||||
import { getDegreeProgrammesOfFaculty } from './faculty'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -97,9 +97,7 @@ const getStudents = async (studentNumbers: string[]) => {
|
||||
'firstnames',
|
||||
'lastname',
|
||||
'studentnumber',
|
||||
'home_country_en',
|
||||
'home_country_fi',
|
||||
'home_country_sv',
|
||||
'citizenships',
|
||||
'dateofuniversityenrollment',
|
||||
'creditcount',
|
||||
'abbreviatedname',
|
||||
|
||||
@@ -159,9 +159,7 @@ const formatStudentForPopulationStatistics = (
|
||||
tags,
|
||||
birthdate,
|
||||
sis_person_id,
|
||||
home_country_fi,
|
||||
home_country_sv,
|
||||
home_country_en,
|
||||
citizenships,
|
||||
} = student
|
||||
|
||||
const criteriaCoursesBySubstitutions = criteria?.allCourses
|
||||
@@ -271,9 +269,7 @@ const formatStudentForPopulationStatistics = (
|
||||
birthdate,
|
||||
studyplans,
|
||||
sis_person_id,
|
||||
home_country_en,
|
||||
home_country_fi,
|
||||
home_country_sv,
|
||||
citizenships,
|
||||
criteriaProgress: toProgressCriteria(),
|
||||
curriculumVersion: getCurriculumVersion(correctStudyplan[0]?.curriculum_period_id),
|
||||
transferredStudyright,
|
||||
@@ -452,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
|
||||
@@ -486,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,
|
||||
@@ -500,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,
|
||||
@@ -527,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,
|
||||
@@ -549,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({
|
||||
|
||||
@@ -344,6 +344,7 @@ export const bySearchTermAndStudentNumbers = async (searchterm: string, studentN
|
||||
},
|
||||
}
|
||||
: { [Op.or]: [nameLike(terms), studentnumberLike(terms)] },
|
||||
order: [['dateofuniversityenrollment', 'DESC NULLS LAST']],
|
||||
})
|
||||
).map(formatSharedStudentData)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,7 +35,7 @@ export const getStudyRightsInProgramme = async (
|
||||
if (includeStudentsAndCredits) {
|
||||
include.push({
|
||||
model: Student,
|
||||
attributes: ['gender_code', 'home_country_en'],
|
||||
attributes: ['gender_code', 'citizenships'],
|
||||
include: [
|
||||
{
|
||||
model: Credit,
|
||||
|
||||
@@ -267,16 +267,20 @@ const getMainStatsByTrackAndYear = async (
|
||||
}
|
||||
}
|
||||
|
||||
const studentHomeCountry = studyRight.student.home_country_en
|
||||
const studentCitizenships = studyRight.student.citizenships
|
||||
|
||||
if (studentHomeCountry === 'Finland') {
|
||||
yearlyStats[year][programmeOrStudyTrack].finnish += 1
|
||||
} else {
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountries += 1
|
||||
if (!yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry]) {
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry] = 0
|
||||
for (const citizenship of studentCitizenships) {
|
||||
const country = citizenship.en
|
||||
if (!country) {
|
||||
continue
|
||||
}
|
||||
if (country === 'Finland') {
|
||||
yearlyStats[year][programmeOrStudyTrack].finnish += 1
|
||||
} else {
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountries += 1
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[country] ??= 0
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[country] += 1
|
||||
}
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry] += 1
|
||||
}
|
||||
|
||||
if (!hasGraduated) return
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export type Graduated = 'GRADUATED_INCLUDED' | 'GRADUATED_EXCLUDED'
|
||||
export type SpecialGroups = 'SPECIAL_INCLUDED' | 'SPECIAL_EXCLUDED'
|
||||
export type ProgrammeFilter = 'NEW_STUDY_PROGRAMMES' | 'ALL_PROGRAMMES'
|
||||
export type YearType = 'ACADEMIC_YEAR' | 'CALENDAR_YEAR'
|
||||
export type StatsType = 'ALL' | 'CREDITS' | 'STUDENT' | 'THESIS'
|
||||
@@ -1,12 +1,10 @@
|
||||
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'
|
||||
export { ExtentCode } from './extentCode'
|
||||
export { Graduated, SpecialGroups, StatsType, ProgrammeFilter, YearType } from './faculty'
|
||||
export { GenderCode } from './genderCode'
|
||||
export { IamAccess } from './iamAccess'
|
||||
export { Phase } from './phase'
|
||||
|
||||
@@ -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
+753
-1013
File diff suppressed because it is too large
Load Diff
@@ -4,35 +4,35 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build": "tsc --noEmit && NODE_OPTIONS=\"--max-old-space-size=4096\" vite build",
|
||||
"preview": "vite preview",
|
||||
"tsc": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^6.1.6",
|
||||
"@mui/material": "^6.1.6",
|
||||
"@mui/x-date-pickers": "^7.22.3",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@sentry/browser": "^8.37.1",
|
||||
"axios": "^0.28.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^6.4.2",
|
||||
"@mui/material": "^6.4.2",
|
||||
"@mui/x-date-pickers": "^7.24.1",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@sentry/browser": "^8.53.0",
|
||||
"axios": "^1.7.9",
|
||||
"highcharts": "^11.4.8",
|
||||
"immer": "^10.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"material-react-table": "^3.0.1",
|
||||
"material-react-table": "^3.1.0",
|
||||
"moment": "^2.30.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "^9.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-datetime": "^3.2.0",
|
||||
"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.1.2",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"scheduler": "^0.23.2",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.4",
|
||||
"scheduler": "^0.25.0",
|
||||
"semantic-ui-css": "^2.5.0",
|
||||
"semantic-ui-react": "^3.0.0-beta.2",
|
||||
"unfuck-spa-shibboleth-session": "^4.1.0",
|
||||
@@ -40,13 +40,14 @@
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/node": "^22.12.0",
|
||||
"@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.6.3",
|
||||
"vite": "^6.0.1"
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"overrides": {
|
||||
"react-highcharts": {
|
||||
|
||||
@@ -110,6 +110,7 @@ export const handleRequest = store => next => async action => {
|
||||
export const RTKApi = createApi({
|
||||
reducerPath: 'api',
|
||||
tagTypes: [
|
||||
'CompletedCoursesSearchList',
|
||||
'CustomPopulationSearches',
|
||||
'ProgressCriteria',
|
||||
'Semester',
|
||||
|
||||
@@ -73,6 +73,9 @@ export const facultyToolTips: Record<string, string> = {
|
||||
start date of the bachelor programme and at the moment, they do not include any transferred credits.
|
||||
Thus, in these statistics some students have fewer credits than in reality.
|
||||
`,
|
||||
interactiveDataTable: `
|
||||
Sort bars in the yearly charts by programme code or other column values. Sorting is done inside the degree group.
|
||||
`,
|
||||
}
|
||||
|
||||
facultyToolTips.studentsStatsOfTheFaculty = studyProgrammeToolTips.studyTrackOverview.replace(
|
||||
|
||||
@@ -37,4 +37,9 @@ export const filterToolTips = {
|
||||
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.',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ 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ä
|
||||
|
||||
**Tags**
|
||||
|
||||
@@ -208,7 +209,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.',
|
||||
|
||||
@@ -117,7 +117,7 @@ export const studyProgrammeToolTips: Record<string, string> = {
|
||||
|
||||
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Vaikka opiskelija olisi ilmoittautunut toiseen opiskeluoikeuteen läsnäolevaksi, mutta hän on laiminlyönyt ilmoittautumisen tarkasteltavaan koulutusohjelmaan liittyvään opiskeluoikeuteen, hänet lasketaan ryhmään ”Inactive”.
|
||||
|
||||
Paksummilla pystyviivoilla eroteltujen kategorioiden (**Current status**, **Gender**, **Countries**) sarakkeet tuottavat yhteenlaskettuna kentän **All** arvon.
|
||||
Paksummilla pystyviivoilla eroteltujen kategorioiden (**Current status**, **Gender**) sarakkeet tuottavat yhteenlaskettuna kentän **All** arvon. **Citizenships**-kategoriat (**Finland** ja **Other**) voivat tuottaa yhteenlaskettuna suuremman tuloksen, koska opiskelijoilla voi olla useampi kuin yksi kansalaisuus.
|
||||
|
||||
Jos sivun yläosassa on valittuna ”All study rights” (oletus), mukana ovat myös ohjelmaan siirtyneet ja ohjelmasta pois siirtyneet opiskelijat.
|
||||
|
||||
|
||||
@@ -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,304 +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 }
|
||||
}
|
||||
|
||||
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'
|
||||
}, [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,10 +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'
|
||||
@@ -288,7 +284,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(' ')
|
||||
|
||||
@@ -352,9 +348,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))
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ThemeProvider } from '@mui/material/styles'
|
||||
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import HighCharts from 'highcharts'
|
||||
import moment from 'moment'
|
||||
import { useEffect } from 'react'
|
||||
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
|
||||
@@ -16,6 +17,21 @@ import { isProduction } from '@/conf'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
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', {
|
||||
week: {
|
||||
dow: 1, // First day of week is Monday
|
||||
|
||||
@@ -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,233 +0,0 @@
|
||||
import qs from 'query-string'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import { Button, Form, Loader, Modal, TextArea } from 'semantic-ui-react'
|
||||
|
||||
import { SearchHistory } from '@/components/SearchHistory'
|
||||
import {
|
||||
useCreateCourseListMutation,
|
||||
useDeleteCourseListMutation,
|
||||
useGetSavedCourseListsQuery,
|
||||
useUpdateCourseListMutation,
|
||||
} from '@/redux/completedCoursesSearch'
|
||||
|
||||
export const CompletedCoursesSearch = ({ setValues }) => {
|
||||
const location = useLocation()
|
||||
const history = useHistory()
|
||||
const [modal, setModal] = useState(false)
|
||||
const [courseInput, setCourseInput] = useState('')
|
||||
const [studentInput, setStudentInput] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const courseLists = useGetSavedCourseListsQuery()
|
||||
const [searchList, setSearches] = useState(null)
|
||||
const [selectedSearchId, setSelectedSearchId] = useState('')
|
||||
const isFetchingOrLoading = courseLists.isLoading || courseLists.isFetching
|
||||
const isError = courseLists.isError || (courseLists.isSuccess && !courseLists.data)
|
||||
const [updateCourseList, { isLoading: updateIsLoading, data: updatedData }] = useUpdateCourseListMutation()
|
||||
const [createCourseList, { isLoading: createIsLoading, data: createdData }] = useCreateCourseListMutation()
|
||||
const [deleteCourseList, { isLoading: deleteIsLoading, data: deletedData }] = useDeleteCourseListMutation()
|
||||
|
||||
const parseQueryFromUrl = () => {
|
||||
const query = qs.parse(location.search)
|
||||
if (!Array.isArray(query.courseList)) query.courseList = [query.courseList]
|
||||
if (!Array.isArray(query.studentList)) query.studentList = [query.studentList]
|
||||
return query
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (courseLists.isSuccess && courseLists.data) {
|
||||
setSearches(courseLists.data)
|
||||
}
|
||||
}, [courseLists])
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedData) {
|
||||
const updatedSearches = searchList.map(s => (s.id === updatedData.id ? updatedData : s))
|
||||
setSearches(updatedSearches)
|
||||
}
|
||||
}, [updatedData])
|
||||
|
||||
useEffect(() => {
|
||||
if (createdData && !createdData.error) {
|
||||
const newList = searchList.concat(createdData)
|
||||
setSearches(newList)
|
||||
}
|
||||
}, [createdData])
|
||||
|
||||
useEffect(() => {
|
||||
if (deletedData) {
|
||||
const filteredSearches = searchList.filter(s => s.id !== deletedData)
|
||||
setSearches(filteredSearches)
|
||||
}
|
||||
}, [deletedData])
|
||||
|
||||
useEffect(() => {
|
||||
if (location.search) {
|
||||
const query = parseQueryFromUrl()
|
||||
setValues(query)
|
||||
}
|
||||
}, [location.search])
|
||||
|
||||
const clearForm = () => {
|
||||
setCourseInput('')
|
||||
setStudentInput('')
|
||||
setSelectedSearchId('')
|
||||
}
|
||||
|
||||
const pushQueryToUrl = query => {
|
||||
const searchString = qs.stringify(query)
|
||||
history.push({ search: searchString })
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setModal(false)
|
||||
clearForm()
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
deleteCourseList({ id: selectedSearchId })
|
||||
clearForm()
|
||||
}
|
||||
|
||||
const onSave = () => {
|
||||
const courseList = courseInput
|
||||
.split(/[\s,]+/)
|
||||
.map(code => code.trim())
|
||||
.filter(code => code !== '')
|
||||
if (selectedSearchId !== '') {
|
||||
updateCourseList({ id: selectedSearchId, courseList })
|
||||
} else {
|
||||
createCourseList({ courseList, name })
|
||||
}
|
||||
}
|
||||
const onSelectSearch = selectedId => {
|
||||
if (!selectedId) {
|
||||
clearForm()
|
||||
return
|
||||
}
|
||||
const { id: selectedSearchId } = selectedId
|
||||
const selectedSearch = searchList?.find(search => search.id === selectedSearchId)
|
||||
if (selectedSearch) {
|
||||
setCourseInput(selectedSearch.courseList.join(', '))
|
||||
setName(selectedSearch.name)
|
||||
setSelectedSearchId(selectedSearch.id)
|
||||
}
|
||||
}
|
||||
|
||||
const onClicker = event => {
|
||||
event.preventDefault()
|
||||
|
||||
const courseList = courseInput
|
||||
.split(/[\s,]+/)
|
||||
.map(code => code.trim())
|
||||
.filter(code => code !== '')
|
||||
const studentList = studentInput
|
||||
.split(/[;\s,]+/)
|
||||
.map(code => code.trim())
|
||||
.filter(s => s !== '')
|
||||
.map(s => (s.length === 8 ? `0${s}` : s))
|
||||
|
||||
if (courseList.length === 0 || studentList.length === 0 || studentList[0] === null) {
|
||||
return
|
||||
}
|
||||
|
||||
setValues({
|
||||
studentList,
|
||||
courseList,
|
||||
})
|
||||
|
||||
pushQueryToUrl({ courseList, studentList })
|
||||
|
||||
handleClose()
|
||||
}
|
||||
|
||||
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
|
||||
if (isFetchingOrLoading) return <Loader active style={{ marginTop: '15em' }} />
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
open={modal}
|
||||
size="small"
|
||||
trigger={
|
||||
<Button color="blue" data-cy="open-completed-courses-modal-button" onClick={() => setModal(true)} size="small">
|
||||
Search completed courses of students
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Modal.Content>
|
||||
<Form>
|
||||
<h2>Search completed courses of students</h2>
|
||||
<Form.Field>
|
||||
<em>Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.</em>
|
||||
<TextArea
|
||||
data-cy="student-no-input"
|
||||
onChange={event => setStudentInput(event.target.value)}
|
||||
placeholder="012345678, 12345678"
|
||||
value={studentInput}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<em>Insert one or more courses, separated by a space, a newline, or a comma.</em>
|
||||
<TextArea
|
||||
data-cy="course-list-input"
|
||||
onChange={event => setCourseInput(event.target.value)}
|
||||
placeholder="TKT12345, PSYK-123"
|
||||
value={courseInput}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Form.Field data-cy="search-name">
|
||||
<em> Insert name for this course list if you wish to save it </em>
|
||||
<Form.Input
|
||||
disabled={selectedSearchId !== ''}
|
||||
onChange={event => setName(event.target.value)}
|
||||
placeholder="name"
|
||||
value={name}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form.Field>
|
||||
<SearchHistory
|
||||
handleSearch={onSelectSearch}
|
||||
header="Saved courselists"
|
||||
items={
|
||||
searchList
|
||||
? searchList.map(list => ({
|
||||
...list,
|
||||
text: list.name,
|
||||
timestamp: new Date(list.updatedAt),
|
||||
params: { id: list.id },
|
||||
}))
|
||||
: []
|
||||
}
|
||||
updateItem={() => null}
|
||||
/>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
content="Save"
|
||||
data-cy="save-courselist"
|
||||
disabled={!name || updateIsLoading || createIsLoading}
|
||||
floated="left"
|
||||
icon="save"
|
||||
loading={updateIsLoading || createIsLoading}
|
||||
onClick={onSave}
|
||||
/>
|
||||
<Button
|
||||
content="Delete"
|
||||
disabled={!selectedSearchId || deleteIsLoading}
|
||||
floated="left"
|
||||
icon="trash"
|
||||
negative
|
||||
onClick={onDelete}
|
||||
/>
|
||||
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button data-cy="completed-courses-search-button" onClick={event => onClicker(event)} positive>
|
||||
Search
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
-183
@@ -1,183 +0,0 @@
|
||||
import moment from 'moment'
|
||||
import { Icon, Loader } from 'semantic-ui-react'
|
||||
|
||||
import { hiddenNameAndEmailForExcel } from '@/common/columns'
|
||||
import { StudentInfoItem } from '@/components/common/StudentInfoItem'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { RightsNotification } from '@/components/RightsNotification'
|
||||
import { SortableTable } from '@/components/SortableTable'
|
||||
import { StudentNameVisibilityToggle, useStudentNameVisibility } from '@/components/StudentNameVisibilityToggle'
|
||||
import { ISO_DATE_FORMAT } from '@/constants/date'
|
||||
import { useGetCompletedCoursesQuery } from '@/redux/completedCoursesSearch'
|
||||
import { isWithinSixMonths } from '@/util/timeAndDate'
|
||||
|
||||
const getColumns = (courses, showStudentNames, getTextIn) => {
|
||||
const isPassed = credit => [4, 7, 9].includes(credit)
|
||||
// 4=completed, 7=improved, 9=transferred, 10=failed
|
||||
|
||||
const getCompletion = (student, courseCode, { icon }) => {
|
||||
const completion = student.credits.find(credit => credit.courseCode === courseCode && isPassed(credit.creditType))
|
||||
const enrollment = student.enrollments[courseCode]
|
||||
if (completion === undefined) {
|
||||
if (!enrollment) {
|
||||
return icon ? null : ''
|
||||
}
|
||||
if (icon) {
|
||||
if (isWithinSixMonths(enrollment.date)) {
|
||||
return <Icon color="yellow" fitted name="minus" />
|
||||
}
|
||||
return <Icon color="grey" fitted name="minus" />
|
||||
}
|
||||
|
||||
return `Latest enrollment: ${moment(enrollment.date).format(ISO_DATE_FORMAT)}`
|
||||
}
|
||||
|
||||
const substitutionString = completion.substitution ? ` as ${completion.substitution}` : ''
|
||||
|
||||
return icon ? <Icon color="green" fitted name="check" /> : `Passed${substitutionString}`
|
||||
}
|
||||
|
||||
const getTotalPassed = student => student.credits.filter(credit => isPassed(credit.creditType)).length
|
||||
|
||||
const getTotalUnfinished = student => Object.values(student.enrollments).length
|
||||
|
||||
const style = {
|
||||
verticalAlign: 'middle',
|
||||
textAlign: 'center',
|
||||
}
|
||||
|
||||
const studentNbrColumn = [
|
||||
{
|
||||
key: 'studentnumber',
|
||||
title: 'Student number',
|
||||
cellProps: { style },
|
||||
getRowVal: student => student.studentNumber,
|
||||
getRowContent: student => <StudentInfoItem showSisuLink student={student} />,
|
||||
},
|
||||
]
|
||||
|
||||
const getCellProps = (student, courseCode) => {
|
||||
const credit = student.credits.find(credit => credit.courseCode === courseCode)
|
||||
const enrollment = student.enrollments[courseCode]
|
||||
if (!credit && !enrollment) {
|
||||
return { style }
|
||||
}
|
||||
const title = credit
|
||||
? `Passed on ${moment(credit.date).format(ISO_DATE_FORMAT)}\nCourse code: ${
|
||||
credit.substitution ? credit.substitution : credit.courseCode
|
||||
}`
|
||||
: `Last enrollment on ${moment(enrollment.date).format(ISO_DATE_FORMAT)}\nCourse code ${
|
||||
enrollment.substitution ? enrollment.substitution : enrollment.courseCode
|
||||
}`
|
||||
return { style, title }
|
||||
}
|
||||
|
||||
const completionStatusColumns = courses.map(course => {
|
||||
return {
|
||||
key: course.code,
|
||||
title: (
|
||||
<div key={course.code} style={{ marginLeft: '-6px', width: '8em', whiteSpace: 'normal' }}>
|
||||
<div key={`key1${course.code}`}>{course.code}</div>
|
||||
<div key={`key2${course.code}`} style={{ color: 'gray', fontWeight: 'normal' }}>
|
||||
{getTextIn(course.name)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
textTitle: course.code,
|
||||
cellProps: student => getCellProps(student, course.code),
|
||||
headerProps: { title: course.code },
|
||||
getRowVal: student => getCompletion(student, course.code, { icon: false }),
|
||||
getRowContent: student => getCompletion(student, course.code, { icon: true }),
|
||||
}
|
||||
})
|
||||
|
||||
const statisticColumns = [
|
||||
{
|
||||
key: 'passed',
|
||||
title: 'Passed',
|
||||
cellProps: { style },
|
||||
headerProps: { title: 'Passed' },
|
||||
getRowVal: student => getTotalPassed(student),
|
||||
},
|
||||
{
|
||||
key: 'unfinished',
|
||||
title: 'Unfinished',
|
||||
headerProps: { title: 'Unfinished' },
|
||||
getRowVal: student => getTotalUnfinished(student),
|
||||
cellProps: { style },
|
||||
},
|
||||
]
|
||||
|
||||
const statisticHeader = {
|
||||
key: 'statistics',
|
||||
title: <b>Totals:</b>,
|
||||
export: false,
|
||||
textTitle: null,
|
||||
children: statisticColumns,
|
||||
}
|
||||
|
||||
const emailColumn = showStudentNames
|
||||
? [
|
||||
{
|
||||
key: 'email',
|
||||
title: 'Email',
|
||||
cellProps: { style },
|
||||
headerProps: { title: 'Email' },
|
||||
getRowVal: student => (student.email ? student.email : ''),
|
||||
export: false,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const nameColumns = showStudentNames
|
||||
? [
|
||||
{
|
||||
key: 'lastname',
|
||||
title: 'Last name',
|
||||
cellProps: { style },
|
||||
getRowVal: student => student.lastname,
|
||||
export: false,
|
||||
},
|
||||
{
|
||||
key: 'firstnames',
|
||||
title: 'First names',
|
||||
cellProps: { style },
|
||||
getRowVal: student => student.firstnames,
|
||||
export: false,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
return [...nameColumns, ...studentNbrColumn, ...completionStatusColumns, statisticHeader, ...emailColumn]
|
||||
}
|
||||
|
||||
export const CompletedCoursesSearchResults = ({ searchValues }) => {
|
||||
const { courseList, studentList } = searchValues
|
||||
const showStudentNames = useStudentNameVisibility()
|
||||
const { data, isLoading, isFetching, isError } = useGetCompletedCoursesQuery({ courseList, studentList })
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
|
||||
if (isFetching || isLoading || !data) return <Loader active style={{ marginTop: '15em' }} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StudentNameVisibilityToggle />
|
||||
{data.discardedStudentNumbers?.length > 0 && (
|
||||
<RightsNotification discardedStudentNumbers={data.discardedStudentNumbers} />
|
||||
)}
|
||||
<div
|
||||
data-cy="completed-courses-table-div"
|
||||
style={{ maxWidth: '100vh', overflowX: 'auto', paddingBottom: '50px', padding: '0.5em' }}
|
||||
>
|
||||
<SortableTable
|
||||
columns={getColumns(data.courses, showStudentNames.visible, getTextIn)}
|
||||
data={data.students}
|
||||
featureName="completed_courses"
|
||||
onlyExportColumns={hiddenNameAndEmailForExcel}
|
||||
title="Completed courses search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Icon, Message } from 'semantic-ui-react'
|
||||
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { CompletedCoursesSearch } from './CompletedCoursesSearch'
|
||||
import { CompletedCoursesSearchResults } from './CompletedCoursesSearchResults'
|
||||
|
||||
export const CompletedCourses = () => {
|
||||
useTitle('Search completed courses')
|
||||
const [searchValues, setValues] = useState({ courseList: [], studentList: [] })
|
||||
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Message style={{ maxWidth: '800px', fontSize: '16px' }}>
|
||||
<Message.Header>Check completed courses</Message.Header>
|
||||
<p>
|
||||
Here you can search by a list of student and course numbers to see whether students have completed certain
|
||||
courses yet. The tool will also show if students have enrolled on the course, if they have not yet completed
|
||||
it. Course substitutions are taken into account.
|
||||
</p>
|
||||
<p>
|
||||
<Icon color="green" fitted name="check" />: Student has completed the course with a passing grade. <br />
|
||||
<Icon color="yellow" fitted name="minus" />: Student has not completed the course, but has an active
|
||||
enrollment from less than 6 months ago. <br />
|
||||
<Icon color="grey" fitted name="minus" />: Student has not completed the course, but has an enrollment from
|
||||
more than 6 months ago. <br />
|
||||
<b>Empty cell</b>: Student has no completion or enrollment for the course.
|
||||
</p>
|
||||
</Message>
|
||||
<CompletedCoursesSearch setValues={setValues} />
|
||||
<div style={{ paddingTop: '25px' }}>
|
||||
{searchValues && searchValues.courseList?.length > 0 && searchValues.studentList?.length > 0 && (
|
||||
<CompletedCoursesSearchResults searchValues={searchValues} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+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'
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import qs from 'query-string'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
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,69 +0,0 @@
|
||||
import qs from 'query-string'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useHistory, useLocation } from 'react-router-dom'
|
||||
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 history = useHistory()
|
||||
const location = useLocation()
|
||||
const [tab, setTab] = useTabs('cs_tab', 0, history)
|
||||
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 => {
|
||||
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) }
|
||||
history.replace({ search: qs.stringify(queryToString) })
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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 { useHistory, useLocation } from 'react-router-dom'
|
||||
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 history = useHistory()
|
||||
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)
|
||||
history.push({ 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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user