mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ce3337977 | ||
|
|
c5e24c7409 | ||
|
|
34f029da9e | ||
|
|
e95414806c | ||
|
|
0eb87ea44a | ||
|
|
454fabeb1d | ||
|
|
d3f022adc5 | ||
|
|
8efe3d2aa3 | ||
|
|
d3003d5797 | ||
|
|
0dd5f32f96 | ||
|
|
85b307b306 | ||
|
|
73eadbd1b1 | ||
|
|
b294c560e4 | ||
|
|
455d9dc532 | ||
|
|
df1049a436 | ||
|
|
ac22df8ced | ||
|
|
1fd987221d | ||
|
|
45985f2c44 | ||
|
|
159e11a508 | ||
|
|
5ff68533d7 | ||
|
|
61f20173a3 | ||
|
|
987f1ec42b | ||
|
|
3f4c39600b | ||
|
|
88ef490b1b | ||
|
|
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 | ||
|
|
5051bbdded | ||
|
|
c7b86856fb | ||
|
|
afcf72e29f | ||
|
|
a83482179a | ||
|
|
1e7a198dec | ||
|
|
41646985e4 | ||
|
|
80f6c7b1fb | ||
|
|
cc9465f141 | ||
|
|
d56c472e77 | ||
|
|
68e5daae13 | ||
|
|
b259f30093 | ||
|
|
804f5df55f | ||
|
|
96ec7000fa |
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -163,8 +163,8 @@ describe('Course Statistics tests', () => {
|
||||
|
||||
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()
|
||||
@@ -195,13 +195,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.get('.ui.menu').contains('Course').click()
|
||||
cy.cs('course-selector').get('.active.selected.item').contains('TKT10002')
|
||||
cy.get('#CourseStatPanes 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.get('.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()
|
||||
@@ -238,17 +238,17 @@ describe('Course Statistics tests', () => {
|
||||
cy.contains('581328 Tietokantojen perusteet')
|
||||
cy.contains('A581328 Avoin yo: Tietokantojen perusteet')
|
||||
cy.get('.right').click()
|
||||
cy.contains('No results')
|
||||
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.get('[data-cy="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.get('[data-cy="select-multiple-courses-toggle"]').should('have.class', 'Mui-checked')
|
||||
cy.contains('Select all search results')
|
||||
})
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('Course Statistics tests', () => {
|
||||
cy.contains('BSCS1003 Data Structures and Algorithms')
|
||||
cy.contains('58131 Tietorakenteet')
|
||||
cy.get('.right').click()
|
||||
cy.contains('No results')
|
||||
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()
|
||||
|
||||
@@ -354,8 +354,8 @@ 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.get('[data-cy="combine-substitutions-toggle"]').should('have.class', 'Mui-checked').click()
|
||||
cy.get('[data-cy="combine-substitutions-toggle"]').should('not.have.class', 'Mui-checked')
|
||||
searchByCourseCode('TKT10002')
|
||||
cy.contains('td', /^TKT10002$/).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
+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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
+63
-51
@@ -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,7 +95,7 @@ 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')
|
||||
@@ -98,7 +105,7 @@ describe('Students tests', () => {
|
||||
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -28,7 +28,6 @@ describe('University view', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/university')
|
||||
cy.contains('University')
|
||||
cy.contains('Programme MH90_001 (Veterinary medicine bachelor + licentiate) is currently excluded.')
|
||||
})
|
||||
|
||||
describe('Faculty progress tab', () => {
|
||||
|
||||
+1
-10
@@ -8,10 +8,6 @@ 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(() => {
|
||||
for (const link of links) {
|
||||
@@ -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,7 +57,6 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it('should see more stuff than others', () => {
|
||||
hasVisibleItems(13)
|
||||
containsLinks(visibleLinks.admin)
|
||||
userButtonWorks('admin')
|
||||
})
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -19,4 +19,17 @@
|
||||
module.exports = (on, config) => {
|
||||
// `on` is used to hook into various events Cypress emits
|
||||
// `config` is the resolved Cypress config
|
||||
on('before:browser:launch', (browser = {}, launchOptions) => {
|
||||
// By manually setting the browser width and height in headless
|
||||
// mode, screenshots and videos will have a better quality
|
||||
// https://www.cypress.io/blog/generate-high-resolution-videos-and-screenshots
|
||||
const width = 2560
|
||||
const height = 1440
|
||||
|
||||
if (browser.name === 'chrome' && browser.isHeadless) {
|
||||
launchOptions.args.push(`--window-size=${width},${height}`)
|
||||
launchOptions.args.push('--force-device-scale-factor=1')
|
||||
}
|
||||
return launchOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -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).
|
||||
+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
+245
-247
@@ -11,27 +11,27 @@
|
||||
"moment": "^2.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cypress/code-coverage": "^3.13.6",
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@cypress/code-coverage": "^3.13.9",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"cypress": "^13.15.2",
|
||||
"eslint": "^9.14.0",
|
||||
"cypress": "^13.16.1",
|
||||
"eslint": "^9.17.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-react-hooks": "^5.1.0",
|
||||
"globals": "^15.13.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.2.11",
|
||||
"node-actionlint": "^1.2.2",
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "3.3.3",
|
||||
"stylelint": "^16.10.0",
|
||||
"prettier": "3.4.2",
|
||||
"stylelint": "^16.12.0",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"typescript-eslint": "^8.13.0"
|
||||
"typescript-eslint": "^8.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -1867,9 +1867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.1.tgz",
|
||||
"integrity": "sha512-lSquqZCHxDfuTg/Sk2hiS0mcSFCEBuj49JfzPHJogDBT0mGCyY5A1AQzBWngitrp7i1/HAZpIgzF/VjhOEIJIg==",
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz",
|
||||
"integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1886,13 +1886,13 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.1"
|
||||
"@csstools/css-tokenizer": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.1.tgz",
|
||||
"integrity": "sha512-UBqaiu7kU0lfvaP982/o3khfXccVlHPWp0/vwwiIgDF0GmqqqxoiXC/6FCjlS9u92f7CoEz6nXKQnrn1kIAkOw==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz",
|
||||
"integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1910,9 +1910,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/media-query-list-parser": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-3.0.1.tgz",
|
||||
"integrity": "sha512-HNo8gGD02kHmcbX6PvCoUuOQvn4szyB9ca63vZHKX5A81QytgDG4oxG4IaEfHTlEZSZ6MjPEMWIVU+zF2PZcgw==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.2.tgz",
|
||||
"integrity": "sha512-EUos465uvVvMJehckATTlNqGj4UJWkTmdWuDMjqvSUkjGpmOyFZBVwb4knxCm/k2GMTXY+c/5RkdndzFYWeX5A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1929,14 +1929,14 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.1",
|
||||
"@csstools/css-tokenizer": "^3.0.1"
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/selector-specificity": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-4.0.0.tgz",
|
||||
"integrity": "sha512-189nelqtPd8++phaHNwYovKZI0FOzH1vQEE3QhHHkNIGrg5fSs9CbYP3RvfEH5geztnIA9Jwq91wyOIwAW5JIQ==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz",
|
||||
"integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1953,13 +1953,13 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss-selector-parser": "^6.1.0"
|
||||
"postcss-selector-parser": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cypress/code-coverage": {
|
||||
"version": "3.13.6",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/code-coverage/-/code-coverage-3.13.6.tgz",
|
||||
"integrity": "sha512-nNVDYDK6r9zPqDIv9k7FibPP9/dATGRR3us9Ued/ldcxPz5x8WbVthjV5OIjqotRKEmS7wxiXFHSDhKJqaZNuw==",
|
||||
"version": "3.13.9",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/code-coverage/-/code-coverage-3.13.9.tgz",
|
||||
"integrity": "sha512-VnsBLmhRLE3xgeX5hdN7aWCU3l8UHttUCMO7b+2OrRvs+R3kpBwjejfSC0/H7ErXz5o6r99biClIaS5bqo60uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2248,13 +2248,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz",
|
||||
"integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==",
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz",
|
||||
"integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^2.1.4",
|
||||
"@eslint/object-schema": "^2.1.5",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.1.2"
|
||||
},
|
||||
@@ -2263,19 +2263,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz",
|
||||
"integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==",
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz",
|
||||
"integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz",
|
||||
"integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz",
|
||||
"integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2310,9 +2313,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz",
|
||||
"integrity": "sha512-pFoEtFWCPyDOl+C6Ift+wC7Ro89otjigCf5vcuWqWgqNSQbRrpjSvdeE6ofLz4dHmyxD5f7gIdGT4+p36L6Twg==",
|
||||
"version": "9.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz",
|
||||
"integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2320,9 +2323,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz",
|
||||
"integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==",
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz",
|
||||
"integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@@ -2330,9 +2333,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.2.tgz",
|
||||
"integrity": "sha512-CXtq5nR4Su+2I47WPOlWud98Y5Lv8Kyxp2ukhgFx/eW6Blm18VXJO5WuQylPugRo8nbluoi6GvvxBLqHcvqUUw==",
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz",
|
||||
"integrity": "sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -2640,17 +2643,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.13.0.tgz",
|
||||
"integrity": "sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==",
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.1.tgz",
|
||||
"integrity": "sha512-Ncvsq5CT3Gvh+uJG0Lwlho6suwDfUXH0HztslDf5I+F2wAFAZMRwYLEorumpKLzmO2suAXZ/td1tBg4NZIi9CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.13.0",
|
||||
"@typescript-eslint/type-utils": "8.13.0",
|
||||
"@typescript-eslint/utils": "8.13.0",
|
||||
"@typescript-eslint/visitor-keys": "8.13.0",
|
||||
"@typescript-eslint/scope-manager": "8.18.1",
|
||||
"@typescript-eslint/type-utils": "8.18.1",
|
||||
"@typescript-eslint/utils": "8.18.1",
|
||||
"@typescript-eslint/visitor-keys": "8.18.1",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^5.3.1",
|
||||
"natural-compare": "^1.4.0",
|
||||
@@ -2665,25 +2668,21 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.13.0.tgz",
|
||||
"integrity": "sha512-w0xp+xGg8u/nONcGw1UXAr6cjCPU1w0XVyBs6Zqaj5eLmxkKQAByTdV/uGgNN5tVvN/kKpoQlP2cL7R+ajZZIQ==",
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.1.tgz",
|
||||
"integrity": "sha512-rBnTWHCdbYM2lh7hjyXqxk70wvon3p2FyaniZuey5TrcGBpfhVp0OxOa6gxr9Q9YhZFKyfbEnxc24ZnVbbUkCA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.13.0",
|
||||
"@typescript-eslint/types": "8.13.0",
|
||||
"@typescript-eslint/typescript-estree": "8.13.0",
|
||||
"@typescript-eslint/visitor-keys": "8.13.0",
|
||||
"@typescript-eslint/scope-manager": "8.18.1",
|
||||
"@typescript-eslint/types": "8.18.1",
|
||||
"@typescript-eslint/typescript-estree": "8.18.1",
|
||||
"@typescript-eslint/visitor-keys": "8.18.1",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2694,23 +2693,19 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.13.0.tgz",
|
||||
"integrity": "sha512-XsGWww0odcUT0gJoBZ1DeulY1+jkaHUciUq4jKNv4cpInbvvrtDoyBH9rE/n2V29wQJPk8iCH1wipra9BhmiMA==",
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.1.tgz",
|
||||
"integrity": "sha512-HxfHo2b090M5s2+/9Z3gkBhI6xBH8OJCFjH9MhQ+nnoZqxU3wNxkLT+VWXWSFWc3UF3Z+CfPAyqdCTdoXtDPCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.13.0",
|
||||
"@typescript-eslint/visitor-keys": "8.13.0"
|
||||
"@typescript-eslint/types": "8.18.1",
|
||||
"@typescript-eslint/visitor-keys": "8.18.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2721,14 +2716,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.13.0.tgz",
|
||||
"integrity": "sha512-Rqnn6xXTR316fP4D2pohZenJnp+NwQ1mo7/JM+J1LWZENSLkJI8ID8QNtlvFeb0HnFSK94D6q0cnMX6SbE5/vA==",
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.1.tgz",
|
||||
"integrity": "sha512-jAhTdK/Qx2NJPNOTxXpMwlOiSymtR2j283TtPqXkKBdH8OAMmhiUfP0kJjc/qSE51Xrq02Gj9NY7MwK+UxVwHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/typescript-estree": "8.13.0",
|
||||
"@typescript-eslint/utils": "8.13.0",
|
||||
"@typescript-eslint/typescript-estree": "8.18.1",
|
||||
"@typescript-eslint/utils": "8.18.1",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^1.3.0"
|
||||
},
|
||||
@@ -2739,16 +2734,15 @@
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.13.0.tgz",
|
||||
"integrity": "sha512-4cyFErJetFLckcThRUFdReWJjVsPCqyBlJTi6IDEpc1GWCIIZRFxVppjWLIMcQhNGhdWJJRYFHpHoDWvMlDzng==",
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.1.tgz",
|
||||
"integrity": "sha512-7uoAUsCj66qdNQNpH2G8MyTFlgerum8ubf21s3TSM3XmKXuIn+H2Sifh/ES2nPOPiYSRJWAk0fDkW0APBWcpfw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2760,14 +2754,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.13.0.tgz",
|
||||
"integrity": "sha512-v7SCIGmVsRK2Cy/LTLGN22uea6SaUIlpBcO/gnMGT/7zPtxp90bphcGf4fyrCQl3ZtiBKqVTG32hb668oIYy1g==",
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.1.tgz",
|
||||
"integrity": "sha512-z8U21WI5txzl2XYOW7i9hJhxoKKNG1kcU4RzyNvKrdZDmbjkmLBo8bgeiOJmA06kizLI76/CCBAAGlTlEeUfyg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.13.0",
|
||||
"@typescript-eslint/visitor-keys": "8.13.0",
|
||||
"@typescript-eslint/types": "8.18.1",
|
||||
"@typescript-eslint/visitor-keys": "8.18.1",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -2782,10 +2776,8 @@
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
@@ -2828,16 +2820,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.13.0.tgz",
|
||||
"integrity": "sha512-A1EeYOND6Uv250nybnLZapeXpYMl8tkzYUxqmoKAWnI4sei3ihf2XdZVd+vVOmHGcp3t+P7yRrNsyyiXTvShFQ==",
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.1.tgz",
|
||||
"integrity": "sha512-8vikiIj2ebrC4WRdcAdDcmnu9Q/MXXwg+STf40BVfT8exDqBCUPdypvzcUPxEqRGKg9ALagZ0UWcYCtn+4W2iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"@typescript-eslint/scope-manager": "8.13.0",
|
||||
"@typescript-eslint/types": "8.13.0",
|
||||
"@typescript-eslint/typescript-estree": "8.13.0"
|
||||
"@typescript-eslint/scope-manager": "8.18.1",
|
||||
"@typescript-eslint/types": "8.18.1",
|
||||
"@typescript-eslint/typescript-estree": "8.18.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2847,18 +2839,19 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0"
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.13.0.tgz",
|
||||
"integrity": "sha512-7N/+lztJqH4Mrf0lb10R/CbI1EaAMMGyF5y0oJvFoAhafwgiRA7TXyd8TFn8FC8k5y2dTsYogg238qavRGNnlw==",
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.1.tgz",
|
||||
"integrity": "sha512-Vj0WLm5/ZsD013YeUKn+K0y8p1M0jPpxOkKdbD1wB0ns53a5piVY02zjf072TblEweAbcYiFiPoSMF3kp+VhhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.13.0",
|
||||
"eslint-visitor-keys": "^3.4.3"
|
||||
"@typescript-eslint/types": "8.18.1",
|
||||
"eslint-visitor-keys": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2868,19 +2861,6 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
|
||||
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz",
|
||||
@@ -3968,6 +3948,22 @@
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ci-info": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.1.0.tgz",
|
||||
"integrity": "sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sibiraj-s"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/clean-stack": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||
@@ -4210,10 +4206,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
@@ -4234,13 +4231,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.0.0.tgz",
|
||||
"integrity": "sha512-o88DVQ6GzsABn1+6+zo2ct801dBO5OASVyxbbvA2W20ue2puSh/VOuqUj90eUeMSX/xqGqBmOKiRQN7tJOuBXw==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
||||
"integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.10.0",
|
||||
"mdn-data": "2.12.2",
|
||||
"source-map-js": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4260,9 +4257,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cypress": {
|
||||
"version": "13.15.2",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.2.tgz",
|
||||
"integrity": "sha512-ARbnUorjcCM3XiPwgHKuqsyr5W9Qn+pIIBPaoilnoBkLdSC2oLQjV1BUpnmc7KR+b7Avah3Ly2RMFnfxr96E/A==",
|
||||
"version": "13.16.1",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.16.1.tgz",
|
||||
"integrity": "sha512-17FtCaz0cx7ssWYKXzGB0Vub8xHwpVPr+iPt2fHhLMDhVAPVrplD+rTQsZUsfb19LVBn5iwkEUFjQ1yVVJXsLA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -4364,22 +4361,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cypress/node_modules/ci-info": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz",
|
||||
"integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sibiraj-s"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cypress/node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -4928,27 +4909,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.14.0.tgz",
|
||||
"integrity": "sha512-c2FHsVBr87lnUtjP4Yhvk4yEhKrQavGafRA/Se1ouse8PfbfC/Qh9Mxa00yWsZRlqeUB9raXip0aiiUZkgnr9g==",
|
||||
"version": "9.17.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz",
|
||||
"integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.18.0",
|
||||
"@eslint/core": "^0.7.0",
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "9.14.0",
|
||||
"@eslint/plugin-kit": "^0.2.0",
|
||||
"@eslint/config-array": "^0.19.0",
|
||||
"@eslint/core": "^0.9.0",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "9.17.0",
|
||||
"@eslint/plugin-kit": "^0.2.3",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.0",
|
||||
"@humanwhocodes/retry": "^0.4.1",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.2",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"debug": "^4.3.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^8.2.0",
|
||||
@@ -4967,8 +4948,7 @@
|
||||
"lodash.merge": "^4.6.2",
|
||||
"minimatch": "^3.1.2",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3",
|
||||
"text-table": "^0.2.0"
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
"bin": {
|
||||
"eslint": "bin/eslint.js"
|
||||
@@ -5024,20 +5004,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-resolver-typescript": {
|
||||
"version": "3.6.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.3.tgz",
|
||||
"integrity": "sha512-ud9aw4szY9cCT1EWWdGv1L1XR6hh2PaRWif0j2QjQ0pgTY/69iw+W0Z4qZv5wHahOl8isEr+k/JnyAqNQkLkIA==",
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.7.0.tgz",
|
||||
"integrity": "sha512-Vrwyi8HHxY97K5ebydMtffsWAn1SCR9eol49eCd5fJS4O1WV7PaAjbcjmbfJJSMz/t4Mal212Uz/fQZrOB8mow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nolyfill/is-core-module": "1.0.39",
|
||||
"debug": "^4.3.5",
|
||||
"debug": "^4.3.7",
|
||||
"enhanced-resolve": "^5.15.0",
|
||||
"eslint-module-utils": "^2.8.1",
|
||||
"fast-glob": "^3.3.2",
|
||||
"get-tsconfig": "^4.7.5",
|
||||
"is-bun-module": "^1.0.2",
|
||||
"is-glob": "^4.0.3"
|
||||
"is-glob": "^4.0.3",
|
||||
"stable-hash": "^0.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
@@ -5209,9 +5189,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz",
|
||||
"integrity": "sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz",
|
||||
"integrity": "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -6117,9 +6097,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "15.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-15.12.0.tgz",
|
||||
"integrity": "sha512-1+gLErljJFhbOVyaetcwJiJ4+eLe45S2E7P5UiZ9xGfeq3ATQf5DOv9G7MH3gGbKQLkzmNh2DxfZwLdw+j6oTQ==",
|
||||
"version": "15.13.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-15.13.0.tgz",
|
||||
"integrity": "sha512-49TewVEz0UxZjr1WYYsWpPrhyC/B/pA8Bq0fUmet2n+eR7yn0IvNzNaoBwnK6mdkzcN+se7Ez9zUgULTz2QH4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -6350,9 +6330,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.6",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz",
|
||||
"integrity": "sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==",
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -7327,9 +7307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/known-css-properties": {
|
||||
"version": "0.34.0",
|
||||
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.34.0.tgz",
|
||||
"integrity": "sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ==",
|
||||
"version": "0.35.0",
|
||||
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz",
|
||||
"integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -7357,9 +7337,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz",
|
||||
"integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==",
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7376,22 +7356,22 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lint-staged": {
|
||||
"version": "15.2.10",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz",
|
||||
"integrity": "sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==",
|
||||
"version": "15.2.11",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.11.tgz",
|
||||
"integrity": "sha512-Ev6ivCTYRTGs9ychvpVw35m/bcNDuBN+mnTeObCL5h+boS5WzBEC6LHI4I9F/++sZm1m+J2LEiy0gxL/R9TBqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "~5.3.0",
|
||||
"commander": "~12.1.0",
|
||||
"debug": "~4.3.6",
|
||||
"debug": "~4.4.0",
|
||||
"execa": "~8.0.1",
|
||||
"lilconfig": "~3.1.2",
|
||||
"listr2": "~8.2.4",
|
||||
"lilconfig": "~3.1.3",
|
||||
"listr2": "~8.2.5",
|
||||
"micromatch": "~4.0.8",
|
||||
"pidtree": "~0.6.0",
|
||||
"string-argv": "~0.3.2",
|
||||
"yaml": "~2.5.0"
|
||||
"yaml": "~2.6.1"
|
||||
},
|
||||
"bin": {
|
||||
"lint-staged": "bin/lint-staged.js"
|
||||
@@ -7501,6 +7481,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/lint-staged/node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lint-staged/node_modules/execa": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
|
||||
@@ -8124,9 +8122,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.10.0.tgz",
|
||||
"integrity": "sha512-qq7C3EtK3yJXMwz1zAab65pjl+UhohqMOctTgcqjLOWABqmwj+me02LSsCuEUxnst9X1lCBpoE0WArGKgdGDzw==",
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
|
||||
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
@@ -8844,9 +8842,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
|
||||
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -8906,9 +8904,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.47",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
|
||||
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
|
||||
"version": "8.4.49",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8927,7 +8925,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.1.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -8969,9 +8967,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz",
|
||||
"integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8999,10 +8997,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
|
||||
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
|
||||
"integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -9786,6 +9785,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.4.tgz",
|
||||
"integrity": "sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-argv": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
||||
@@ -9951,9 +9957,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/stylelint": {
|
||||
"version": "16.10.0",
|
||||
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.10.0.tgz",
|
||||
"integrity": "sha512-z/8X2rZ52dt2c0stVwI9QL2AFJhLhbPkyfpDFcizs200V/g7v+UYY6SNcB9hKOLcDDX/yGLDsY/pX08sLkz9xQ==",
|
||||
"version": "16.12.0",
|
||||
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.12.0.tgz",
|
||||
"integrity": "sha512-F8zZ3L/rBpuoBZRvI4JVT20ZanPLXfQLzMOZg1tzPflRVh9mKpOZ8qcSIhh1my3FjAjZWG4T2POwGnmn6a6hbg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -9967,16 +9973,16 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.1",
|
||||
"@csstools/css-tokenizer": "^3.0.1",
|
||||
"@csstools/media-query-list-parser": "^3.0.1",
|
||||
"@csstools/selector-specificity": "^4.0.0",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"@csstools/media-query-list-parser": "^4.0.2",
|
||||
"@csstools/selector-specificity": "^5.0.0",
|
||||
"@dual-bundle/import-meta-resolve": "^4.1.0",
|
||||
"balanced-match": "^2.0.0",
|
||||
"colord": "^2.9.3",
|
||||
"cosmiconfig": "^9.0.0",
|
||||
"css-functions-list": "^3.2.3",
|
||||
"css-tree": "^3.0.0",
|
||||
"css-tree": "^3.0.1",
|
||||
"debug": "^4.3.7",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
@@ -9988,22 +9994,22 @@
|
||||
"ignore": "^6.0.2",
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"known-css-properties": "^0.34.0",
|
||||
"known-css-properties": "^0.35.0",
|
||||
"mathml-tag-names": "^2.1.3",
|
||||
"meow": "^13.2.0",
|
||||
"micromatch": "^4.0.8",
|
||||
"normalize-path": "^3.0.0",
|
||||
"picocolors": "^1.0.1",
|
||||
"postcss": "^8.4.47",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-resolve-nested-selector": "^0.1.6",
|
||||
"postcss-safe-parser": "^7.0.1",
|
||||
"postcss-selector-parser": "^6.1.2",
|
||||
"postcss-selector-parser": "^7.0.0",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"resolve-from": "^5.0.0",
|
||||
"string-width": "^4.2.3",
|
||||
"supports-hyperlinks": "^3.1.0",
|
||||
"svg-tags": "^1.0.0",
|
||||
"table": "^6.8.2",
|
||||
"table": "^6.9.0",
|
||||
"write-file-atomic": "^5.0.1"
|
||||
},
|
||||
"bin": {
|
||||
@@ -10202,9 +10208,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/table": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz",
|
||||
"integrity": "sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==",
|
||||
"version": "6.9.0",
|
||||
"resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz",
|
||||
"integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -10394,13 +10400,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/throttleit": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz",
|
||||
@@ -10666,15 +10665,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.13.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.13.0.tgz",
|
||||
"integrity": "sha512-vIMpDRJrQd70au2G8w34mPps0ezFSPMEX4pXkTzUkrNbRX+36ais2ksGWN0esZL+ZMaFJEneOBHzCgSqle7DHw==",
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.18.1.tgz",
|
||||
"integrity": "sha512-Mlaw6yxuaDEPQvb/2Qwu3/TfgeBHy9iTJ3mTwe7OvpPmF6KPQjVOfGyEJpPv6Ez2C34OODChhXrzYw/9phI0MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.13.0",
|
||||
"@typescript-eslint/parser": "8.13.0",
|
||||
"@typescript-eslint/utils": "8.13.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.18.1",
|
||||
"@typescript-eslint/parser": "8.18.1",
|
||||
"@typescript-eslint/utils": "8.18.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -10683,10 +10682,9 @@
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
@@ -11159,9 +11157,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz",
|
||||
"integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==",
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz",
|
||||
"integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
|
||||
+12
-12
@@ -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.9",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"cypress": "^13.15.2",
|
||||
"eslint": "^9.14.0",
|
||||
"cypress": "^13.16.1",
|
||||
"eslint": "^9.17.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-react-hooks": "^5.1.0",
|
||||
"globals": "^15.13.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.2.11",
|
||||
"node-actionlint": "^1.2.2",
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "3.3.3",
|
||||
"stylelint": "^16.10.0",
|
||||
"prettier": "3.4.2",
|
||||
"stylelint": "^16.12.0",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"typescript-eslint": "^8.13.0"
|
||||
"typescript-eslint": "^8.18.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
+1
-4
@@ -7,7 +7,6 @@
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchCurrentVersion": "!/^0/",
|
||||
"automerge": true,
|
||||
"matchPackageNames": ["*"]
|
||||
},
|
||||
{
|
||||
@@ -28,7 +27,5 @@
|
||||
"labels": ["security"]
|
||||
},
|
||||
"addLabels": ["dependencies"],
|
||||
"rangeStrategy": "bump",
|
||||
"schedule": ["before 5:00am"],
|
||||
"timezone": "Europe/Helsinki"
|
||||
"rangeStrategy": "bump"
|
||||
}
|
||||
|
||||
Generated
+279
-709
File diff suppressed because it is too large
Load Diff
@@ -5,21 +5,20 @@
|
||||
"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.45.1",
|
||||
"@sentry/profiling-node": "^8.45.1",
|
||||
"axios": "^1.7.9",
|
||||
"bullmq": "^5.34.2",
|
||||
"compression": "^1.7.5",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^3.1.9",
|
||||
"express": "^4.21.1",
|
||||
"cron": "^3.3.1",
|
||||
"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",
|
||||
"pg": "^8.13.1",
|
||||
"redis": "^4.7.0",
|
||||
@@ -37,10 +36,10 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/validator": "^13.12.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"overrides": {
|
||||
"winston-gelf-transporter": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,10 +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'
|
||||
|
||||
@@ -24,24 +24,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 {
|
||||
|
||||
@@ -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,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,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'
|
||||
|
||||
@@ -31,6 +31,7 @@ type AccumulatorType = {
|
||||
email: string
|
||||
phoneNumber: string
|
||||
secondaryEmail: string
|
||||
preferredLanguage: string
|
||||
}
|
||||
studyright: {
|
||||
startDate: Date
|
||||
@@ -105,6 +106,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
|
||||
sis_person_id,
|
||||
email,
|
||||
phone_number: phoneNumber,
|
||||
preferredLanguage,
|
||||
secondary_email: secondaryEmail,
|
||||
} = student
|
||||
return student.studyplans.reduce<AccumulatorType[]>((acc, studyPlan) => {
|
||||
@@ -137,7 +139,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,
|
||||
@@ -178,6 +180,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
|
||||
'creditcount',
|
||||
'email',
|
||||
'phone_number',
|
||||
'preferredLanguage',
|
||||
'secondary_email',
|
||||
'sis_person_id',
|
||||
'studentnumber',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -344,6 +344,7 @@ export const bySearchTermAndStudentNumbers = async (searchterm: string, studentN
|
||||
},
|
||||
}
|
||||
: { [Op.or]: [nameLike(terms), studentnumberLike(terms)] },
|
||||
order: [['dateofuniversityenrollment', 'DESC NULLS LAST']],
|
||||
})
|
||||
).map(formatSharedStudentData)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ type StudyGuidanceGroup = {
|
||||
}
|
||||
|
||||
const getGroupsFromImporter = async (sisPersonId: string) => {
|
||||
if (!importerClient) {
|
||||
if (!importerClient || !sisPersonId) {
|
||||
return []
|
||||
}
|
||||
const answerTimeout = new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -6,7 +6,6 @@ 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'
|
||||
|
||||
@@ -26,10 +26,11 @@ export const getUserIamAccess = async (
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const getSisuAccess = !isStaging && sisPersonId != null && sisPersonId !== ''
|
||||
const { data: iamAccess } = await jamiClient.post('/', {
|
||||
userId: sisPersonId,
|
||||
iamGroups,
|
||||
getSisuAccess: !isStaging,
|
||||
getSisuAccess,
|
||||
})
|
||||
const { specialGroup } = iamAccess as { specialGroup: Record<string, boolean> }
|
||||
delete iamAccess.specialGroup
|
||||
|
||||
@@ -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
+613
-929
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.2.0",
|
||||
"@mui/material": "^6.2.0",
|
||||
"@mui/x-date-pickers": "^7.23.2",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@sentry/browser": "^8.45.1",
|
||||
"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.0.3",
|
||||
"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-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.0.2",
|
||||
"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",
|
||||
@@ -41,12 +41,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.17",
|
||||
"@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.2",
|
||||
"vite": "^6.0.3"
|
||||
},
|
||||
"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',
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
export const closeToGraduationToolTips = `
|
||||
You can filter students by choosing a degree programme and/or a faculty.
|
||||
You can also select multiple programmes or faculties.
|
||||
Please note that if you select a faculty, only the degree programmes of that faculty will be shown.
|
||||
You can use the dropdowns in the ’Faculty’ and ’Programme’ column headers to filter students. You can also select multiple programmes or faculties. Please note that if you select a faculty, only the programmes of that faculty will be shown.
|
||||
|
||||
Students are included in this list based on the number of credits they have completed in their primary study plan.
|
||||
The limits for each study programme are as follows:
|
||||
Students are included in this list based on the number of credits they have completed in their primary study plan. The limits for each study programme are as follows:
|
||||
|
||||
- All bachelor’s programmes: 140 credits
|
||||
- Degree Programme in Veterinary Medicine (eläinlääketieteen lisensiaatin koulutusohjelma): 150 credits
|
||||
@@ -13,6 +10,5 @@ export const closeToGraduationToolTips = `
|
||||
- Master’s Programme in Psychology (psykologian maisteriohjelma): 115 credits
|
||||
- Other master’s programmes: 70 credits
|
||||
|
||||
If you have access to all student data in Oodikone, you can also see all students here.
|
||||
Otherwise you will only see the students in your study guidance groups.
|
||||
If you have access to all student data in Oodikone, you can also see all students here. Otherwise you will only see the students in your study guidance groups.
|
||||
`
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -208,7 +208,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
|
||||
}, {})
|
||||
}
|
||||
@@ -288,7 +288,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(' ')
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { random } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ReactHighchart from 'react-highcharts'
|
||||
import { Button, Container, Dimmer, Header, Image, Transition } from 'semantic-ui-react'
|
||||
|
||||
import { images } from '@/common'
|
||||
import { useLogoutMutation } from '@/redux/auth'
|
||||
|
||||
const names = [
|
||||
'mluukkai',
|
||||
'jakousa',
|
||||
'totutotu',
|
||||
'sasumaki',
|
||||
'ikuisma',
|
||||
'eero3',
|
||||
'mitiaine',
|
||||
'rimi',
|
||||
'esakemp',
|
||||
'woltsu',
|
||||
'cxcorp',
|
||||
'ajhaa',
|
||||
'joonashak',
|
||||
'vaahtokarkki',
|
||||
'otahontas',
|
||||
'saarasat',
|
||||
'popalmu',
|
||||
'ollikehy',
|
||||
]
|
||||
|
||||
const dummyData = names.map(name => ({
|
||||
name,
|
||||
data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].reduce((acc, i) => {
|
||||
acc[i] = (acc[i - 1] || 0) + (random(0, 3) === 0 ? 0 : random(10.0, 100.0))
|
||||
return acc
|
||||
}, []),
|
||||
}))
|
||||
|
||||
export const AccessDenied = ({ notEnabled }) => {
|
||||
const header = notEnabled ? 'Welcome to Oodikone!' : 'Something broke'
|
||||
const subheader = notEnabled
|
||||
? "You're currently not allowed to enter but you will get an email when you're authorized"
|
||||
: 'If this was not intended try refreshing your browser window, pressing log out or contacting grp-toska@helsinki.fi'
|
||||
|
||||
const [easterEgg, setEasterEgg] = useState(false)
|
||||
const [logout] = useLogoutMutation()
|
||||
|
||||
useEffect(() => {
|
||||
if (notEnabled) {
|
||||
setTimeout(() => setEasterEgg(true), Math.floor(Math.random() * 1800000) + 600000)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Container style={{ alignItems: 'stretch', display: 'flex', height: '100vh', justifyContent: 'space-evenly' }}>
|
||||
<ReactHighchart
|
||||
config={{
|
||||
credits: { enabled: false },
|
||||
plotOptions: { series: { label: { connectorAllowed: false }, pointStart: 2010 } },
|
||||
series: dummyData,
|
||||
title: { text: 'Students of Computer Science 2018-2020' },
|
||||
yAxis: { title: { text: 'Cumulative credits' } },
|
||||
}}
|
||||
/>
|
||||
<ReactHighchart
|
||||
config={{
|
||||
chart: { type: 'column' },
|
||||
credits: { enabled: false },
|
||||
series: dummyData.map(element => ({ name: element.name, data: [Math.max(...element.data)] })),
|
||||
title: { text: "Your students' future" },
|
||||
xAxis: { categories: ['2018'] },
|
||||
yAxis: { title: { text: 'Cumulative credits' } },
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
<Dimmer active page>
|
||||
<Image centered size="medium" src={images.toskaLogo} style={{ paddingTop: '2%' }} />
|
||||
<Header as="h2" inverted>
|
||||
<p>{header}</p>
|
||||
<Header.Subheader>{subheader}</Header.Subheader>
|
||||
<br />
|
||||
<Button color="pink" onClick={() => logout()}>
|
||||
Log out
|
||||
</Button>
|
||||
</Header>
|
||||
</Dimmer>
|
||||
<Transition animation="fly up" duration={10000} visible={easterEgg}>
|
||||
<Image
|
||||
inline
|
||||
size="huge"
|
||||
src={images.irtomikko}
|
||||
style={{ position: 'absolute', right: '10px', top: '350px' }}
|
||||
verticalAlign="top"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { CssBaseline } from '@mui/material'
|
||||
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'
|
||||
|
||||
import { AccessDenied } from '@/components/AccessDenied'
|
||||
import { AccessDenied } from '@/components/material/AccessDenied'
|
||||
import { Footer } from '@/components/material/Footer'
|
||||
import { NavigationBar } from '@/components/material/NavigationBar'
|
||||
import { Routes } from '@/components/Routes'
|
||||
@@ -13,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({
|
||||
plotOptions: {
|
||||
series: {
|
||||
animation: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
moment.updateLocale('en', {
|
||||
week: {
|
||||
dow: 1, // First day of week is Monday
|
||||
},
|
||||
})
|
||||
|
||||
const addUserDetailsToLoggers = ({ id, username, mockedBy }) => {
|
||||
if (!isProduction || !id || !username) {
|
||||
return
|
||||
@@ -28,12 +47,14 @@ const Layout = ({ children }) => (
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<NavigationBar />
|
||||
<main style={{ flex: 1 }}>{children}</main>
|
||||
<Footer />
|
||||
</ThemeProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterMoment}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<NavigationBar />
|
||||
<main style={{ flex: 1 }}>{children}</main>
|
||||
<Footer />
|
||||
</ThemeProvider>
|
||||
</LocalizationProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
import { range } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { Divider, Dropdown, Form, Icon, Loader, Message, Radio, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { createLocaleComparator, getCurrentSemester, getEnrollmentTypeTextForExcel, isFall } from '@/common'
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { closeToGraduationToolTips } from '@/common/InfoToolTips'
|
||||
import { StudentInfoItem } from '@/components/common/StudentInfoItem'
|
||||
import { InfoBox } from '@/components/InfoBox'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { getSemestersPresentFunctions } from '@/components/PopulationStudents/StudentTable/GeneralTab/columnHelpers/semestersPresent'
|
||||
import { PaginatedSortableTable } from '@/components/SortableTable/PaginatedSortableTable'
|
||||
import { StudentNameVisibilityToggle, useStudentNameVisibility } from '@/components/StudentNameVisibilityToggle'
|
||||
import { ISO_DATE_FORMAT, LONG_DATE_TIME_FORMAT } from '@/constants/date'
|
||||
import { useGetStudentsCloseToGraduationQuery } from '@/redux/closeToGraduation'
|
||||
import { useGetFacultiesQuery } from '@/redux/facultyStats'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { useFilteredAndFormattedStudyProgrammes } from '@/redux/studyProgramme'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
|
||||
const NUMBER_OF_DISPLAYED_SEMESTERS = 6
|
||||
|
||||
const getColumns = (
|
||||
getTextIn,
|
||||
namesVisible,
|
||||
semesterEnrollmentsVisible,
|
||||
studyTrackVisible,
|
||||
allSemestersMap,
|
||||
bachelorStudentsAreDisplayed,
|
||||
semesterEnrollmentFunctions
|
||||
) => {
|
||||
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = semesterEnrollmentFunctions
|
||||
const currentSemesterCode = getCurrentSemester(allSemestersMap)?.semestercode
|
||||
const semestersToInclude = range(
|
||||
isFall(currentSemesterCode)
|
||||
? currentSemesterCode - NUMBER_OF_DISPLAYED_SEMESTERS + 2
|
||||
: currentSemesterCode - NUMBER_OF_DISPLAYED_SEMESTERS + 1,
|
||||
isFall(currentSemesterCode) ? currentSemesterCode + 2 : currentSemesterCode + 1
|
||||
)
|
||||
return [
|
||||
{
|
||||
key: 'studentNumber',
|
||||
title: 'Student number',
|
||||
getRowVal: row => row.student.studentNumber,
|
||||
getRowContent: row => <StudentInfoItem showSisuLink student={row.student} />,
|
||||
filterable: false,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
getRowVal: row => row.student.name,
|
||||
filterable: false,
|
||||
displayColumn: namesVisible,
|
||||
},
|
||||
{
|
||||
key: 'phoneNumber',
|
||||
title: 'Phone number',
|
||||
getRowVal: row => row.student.phoneNumber,
|
||||
displayColumn: false,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
title: 'Email',
|
||||
getRowVal: row => row.student.email,
|
||||
displayColumn: false,
|
||||
},
|
||||
{
|
||||
key: 'secondaryEmail',
|
||||
title: 'Secondary email',
|
||||
getRowVal: row => row.student.secondaryEmail,
|
||||
displayColumn: false,
|
||||
},
|
||||
{
|
||||
key: 'programme',
|
||||
title: 'Programme',
|
||||
getRowVal: row => getTextIn(row.programme.name),
|
||||
filterable: false,
|
||||
},
|
||||
{
|
||||
key: 'studytrack',
|
||||
title: 'Study track',
|
||||
getRowVal: row => getTextIn(row.programme.studyTrack),
|
||||
displayColumn: studyTrackVisible,
|
||||
},
|
||||
{
|
||||
key: 'startOfStudyright',
|
||||
title: 'Start of\nstudy right',
|
||||
getRowVal: row => row.studyright.startDate,
|
||||
formatValue: date => reformatDate(date, ISO_DATE_FORMAT),
|
||||
filterType: 'date',
|
||||
},
|
||||
{
|
||||
key: 'startedInProgramme',
|
||||
title: 'Started in programme',
|
||||
getRowVal: row => row.programme.startedAt,
|
||||
formatValue: date => reformatDate(date, ISO_DATE_FORMAT),
|
||||
filterType: 'date',
|
||||
displayColumn: !bachelorStudentsAreDisplayed,
|
||||
helpText:
|
||||
"For students with only a study right in the master’s programme, this date is the same as 'Start of study right'. For students with study rights in both the bachelor’s and master’s programmes, this date represents when they started in the master’s programme (i.e. one day after graduating from the bachelor’s programme).",
|
||||
},
|
||||
{
|
||||
key: 'credits',
|
||||
title: 'Completed credits',
|
||||
children: [
|
||||
{
|
||||
key: 'creditsHops',
|
||||
title: 'HOPS',
|
||||
getRowVal: row => row.credits.hops,
|
||||
filterType: 'range',
|
||||
helpText: "The credits earned from courses in the student's primary study plan",
|
||||
},
|
||||
{
|
||||
key: 'creditsTotal',
|
||||
title: 'Total',
|
||||
getRowVal: row => row.credits.all,
|
||||
filterType: 'range',
|
||||
helpText: 'The total number of credits the student has earned at the university',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'isBaMa',
|
||||
title: 'Continued\nfrom\nbachelor’s',
|
||||
getRowVal: row => (row.studyright.isBaMa ? 'Continued from bachelor’s' : 'Not continued from bachelor’s'),
|
||||
getRowContent: row => (row.studyright.isBaMa ? <Icon color="green" name="check" /> : null),
|
||||
getRowExportVal: row => (row.studyright.isBaMa ? '1' : null),
|
||||
cellProps: () => ({ style: { textAlign: 'center' } }),
|
||||
displayColumn: !bachelorStudentsAreDisplayed,
|
||||
helpText: 'Indicates whether the student has continued their studies from a bachelor’s degree',
|
||||
},
|
||||
{
|
||||
key: 'curriculumPeriod',
|
||||
title: 'Curriculum\nperiod',
|
||||
getRowVal: row => row.curriculumPeriod,
|
||||
helpText: 'The curriculum period the student has chosen for their primary study plan',
|
||||
},
|
||||
{
|
||||
key: 'semesterEnrollments',
|
||||
title: 'Semesters\npresent',
|
||||
filterType: 'range',
|
||||
displayColumn: semesterEnrollmentsVisible,
|
||||
export: false,
|
||||
getRowContent: row => getSemesterEnrollmentsContent(row.student, row.studyright),
|
||||
getRowVal: row => getSemesterEnrollmentsVal(row.student, row.studyright),
|
||||
},
|
||||
{
|
||||
key: 'semestersAbsent',
|
||||
title: 'Semesters\nabsent',
|
||||
getRowVal: row => row.numberOfAbsentSemesters,
|
||||
helpText:
|
||||
'The number of semesters the student has been absent (both statutory and non-statutory absences) during their study right',
|
||||
},
|
||||
{
|
||||
key: 'semesterEnrollmentsForExcel',
|
||||
title: 'Enrollment status',
|
||||
displayColumn: false,
|
||||
children: semestersToInclude.map(semester => ({
|
||||
key: `${semester}`,
|
||||
title: getTextIn(allSemestersMap[`${semester}`]?.name),
|
||||
displayColumn: false,
|
||||
getRowVal: student => {
|
||||
if (!student.studyright.semesterEnrollments) {
|
||||
return 'Not enrolled'
|
||||
}
|
||||
const enrollment = student.studyright.semesterEnrollments.find(enrollment => enrollment.semester === semester)
|
||||
return getEnrollmentTypeTextForExcel(enrollment?.type, enrollment?.statutoryAbsence)
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'thesisCompleted',
|
||||
title: 'Thesis\ncompleted',
|
||||
getRowVal: row => (row.thesisInfo ? 'Thesis completed' : 'Thesis not completed'),
|
||||
getRowContent: row => (row.thesisInfo ? <Icon color="green" name="check" /> : null),
|
||||
getRowExportVal: row => (row.thesisInfo ? '1' : null),
|
||||
cellProps: row =>
|
||||
row.thesisInfo
|
||||
? {
|
||||
style: { textAlign: 'center' },
|
||||
title: [
|
||||
`Attainment date: ${reformatDate(row.thesisInfo.attainmentDate, ISO_DATE_FORMAT)}`,
|
||||
`Course code: ${row.thesisInfo.courseCode}`,
|
||||
`Grade: ${row.thesisInfo.grade}`,
|
||||
].join('\n'),
|
||||
}
|
||||
: {},
|
||||
helpText:
|
||||
'The thesis attainment must be linked to the correct study right. You can see the attainment date, course code, and grade by hovering over the check mark.',
|
||||
},
|
||||
{
|
||||
key: 'latestAttainmentDates',
|
||||
title: 'Latest attainment date',
|
||||
children: [
|
||||
{
|
||||
key: 'latestAttainmentHops',
|
||||
title: 'HOPS',
|
||||
getRowVal: row => reformatDate(row.attainmentDates.latestHops, ISO_DATE_FORMAT),
|
||||
filterType: 'date',
|
||||
helpText: 'The date when the student last completed a course in their primary study plan',
|
||||
},
|
||||
{
|
||||
key: 'latestAttainmentTotal',
|
||||
title: 'Total',
|
||||
getRowVal: row => reformatDate(row.attainmentDates.latestTotal, ISO_DATE_FORMAT),
|
||||
filterType: 'date',
|
||||
helpText: 'The date when the student last completed any course at the university',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'earliestAttainmentDates',
|
||||
title: 'Earliest\nattainment date',
|
||||
children: [
|
||||
{
|
||||
key: 'earliestAttainmentHops',
|
||||
title: 'HOPS',
|
||||
getRowVal: row => reformatDate(row.attainmentDates.earliestHops, ISO_DATE_FORMAT),
|
||||
filterType: 'date',
|
||||
helpText: 'The date when the student first completed a course in their primary study plan',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const CloseToGraduation = () => {
|
||||
useTitle('Students close to graduation')
|
||||
|
||||
const { data: students = {}, isError, isLoading } = useGetStudentsCloseToGraduationQuery()
|
||||
const { data: faculties = [] } = useGetFacultiesQuery()
|
||||
const { data: semesterData = [] } = useGetSemestersQuery()
|
||||
const studyProgrammes = useFilteredAndFormattedStudyProgrammes()
|
||||
const { getTextIn } = useLanguage()
|
||||
const { visible: namesVisible } = useStudentNameVisibility()
|
||||
const [chosenFaculties, setChosenFaculties] = useState([])
|
||||
const [chosenProgrammes, setChosenProgrammes] = useState([])
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0)
|
||||
const [rowCount, setRowCount] = useState(0)
|
||||
const [semesterEnrollmentsVisible, setSemesterEnrollmentsVisible] = useState(false)
|
||||
const allSemesters = Object.entries(semesterData?.semesters || {}).map(item => item[1])
|
||||
const allSemestersMap = allSemesters.reduce((obj, cur, index) => {
|
||||
obj[index + 1] = cur
|
||||
return obj
|
||||
}, {})
|
||||
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = getSemestersPresentFunctions({
|
||||
getTextIn,
|
||||
allSemesters,
|
||||
allSemestersMap,
|
||||
filteredStudents: students,
|
||||
year: `${new Date().getFullYear() - Math.floor(NUMBER_OF_DISPLAYED_SEMESTERS / 2)}`,
|
||||
})
|
||||
const { bachelor = [], masterAndLicentiate = [] } = students
|
||||
|
||||
const bachelorStudentsAreDisplayed = activeTabIndex === 0
|
||||
const displayedStudents = bachelorStudentsAreDisplayed ? bachelor : masterAndLicentiate
|
||||
|
||||
const handleDisplayedDataChange = students => setRowCount(students.length)
|
||||
|
||||
const filteredStudents = displayedStudents.filter(s => {
|
||||
if (chosenProgrammes.length > 0) return chosenProgrammes.includes(s.programme.code)
|
||||
return chosenFaculties.length === 0 || chosenFaculties.includes(s.programme.code.slice(1, 4))
|
||||
})
|
||||
|
||||
const studyTrackVisible =
|
||||
chosenProgrammes.length === 1 && filteredStudents.some(student => student.programme.studyTrack != null)
|
||||
|
||||
const columns = getColumns(
|
||||
getTextIn,
|
||||
namesVisible,
|
||||
semesterEnrollmentsVisible,
|
||||
studyTrackVisible,
|
||||
allSemestersMap,
|
||||
bachelorStudentsAreDisplayed,
|
||||
{
|
||||
getSemesterEnrollmentsContent,
|
||||
getSemesterEnrollmentsVal,
|
||||
}
|
||||
)
|
||||
|
||||
const onTabChange = (_event, { activeIndex }) => {
|
||||
setActiveTabIndex(activeIndex)
|
||||
setChosenFaculties([])
|
||||
setChosenProgrammes([])
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (isError) {
|
||||
return (
|
||||
<Message content="Please try reloading the page." header="There was an error" icon="ban" negative size="big" />
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) return <Loader active inline="centered" />
|
||||
|
||||
const facultyOptions = faculties.map(faculty => ({
|
||||
key: faculty.code,
|
||||
text: getTextIn(faculty.name),
|
||||
value: faculty.code,
|
||||
description: faculty.code,
|
||||
}))
|
||||
const programmeOptions = studyProgrammes
|
||||
.filter(
|
||||
programme =>
|
||||
!programme.value.includes('+') && programme.value.startsWith(bachelorStudentsAreDisplayed ? 'KH' : 'MH')
|
||||
)
|
||||
.filter(programme => (chosenFaculties.length > 0 ? chosenFaculties.includes(programme.value.slice(1, 4)) : true))
|
||||
.sort(createLocaleComparator('text'))
|
||||
|
||||
return (
|
||||
<Tab.Pane>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<InfoBox content={closeToGraduationToolTips} />
|
||||
<Form style={{ width: '100%', marginBottom: '1em' }}>
|
||||
<Form.Field>
|
||||
<label>Faculties</label>
|
||||
<Dropdown
|
||||
multiple
|
||||
onChange={(_, { value }) => {
|
||||
setChosenFaculties([...value])
|
||||
setChosenProgrammes(chosenProgrammes.filter(p => value.includes(p.slice(1, 4))))
|
||||
}}
|
||||
options={facultyOptions}
|
||||
placeholder="Choose faculties"
|
||||
search
|
||||
selection
|
||||
value={chosenFaculties}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Degree programmes</label>
|
||||
<Dropdown
|
||||
multiple
|
||||
onChange={(_, { value }) => setChosenProgrammes([...value])}
|
||||
options={programmeOptions}
|
||||
placeholder="Choose degree programmes"
|
||||
search
|
||||
selection
|
||||
value={chosenProgrammes}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
<Message info style={{ width: '100%' }}>
|
||||
{`Last updated: ${reformatDate(students.lastUpdated, LONG_DATE_TIME_FORMAT)}`}
|
||||
</Message>
|
||||
<PaginatedSortableTable
|
||||
columns={columns}
|
||||
data={filteredStudents}
|
||||
defaultSort={['programme', 'asc']}
|
||||
featureName="students_close_to_graduation"
|
||||
handleDisplayedDataChange={handleDisplayedDataChange}
|
||||
rowCount={rowCount}
|
||||
rowsPerPage={200}
|
||||
title={`Students close to graduation (${rowCount} out of ${displayedStudents.length} students selected)`}
|
||||
/>
|
||||
</div>
|
||||
</Tab.Pane>
|
||||
)
|
||||
}
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: "Bachelor's programmes",
|
||||
render: renderContent,
|
||||
},
|
||||
{
|
||||
menuItem: "Master's and licentiate's programmes",
|
||||
render: renderContent,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<Divider horizontal style={{ margin: '3em' }}>
|
||||
Students close to graduation
|
||||
</Divider>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: '2em',
|
||||
padding: '2em',
|
||||
gap: '2em',
|
||||
}}
|
||||
>
|
||||
<StudentNameVisibilityToggle style={{ marginTop: '0', marginBottom: '0' }} />
|
||||
<Radio
|
||||
checked={semesterEnrollmentsVisible}
|
||||
label="Show semester enrollments"
|
||||
onChange={() => setSemesterEnrollmentsVisible(!semesterEnrollmentsVisible)}
|
||||
toggle
|
||||
/>
|
||||
</div>
|
||||
<Tab activeIndex={activeTabIndex} onTabChange={onTabChange} panes={panes} style={{ maxWidth: '80%' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Divider, Loader, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { getCurrentSemester } from '@/common'
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { ColorizedCoursesTableContext } from './common'
|
||||
import { FacultiesTab } from './FacultiesTab'
|
||||
|
||||
@@ -1,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,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)
|
||||
-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 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.yearSelectorGroup {
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.yearSelect {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.yearSelectInput input {
|
||||
border-bottom-right-radius: 0 !important;
|
||||
border-top-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
.yearControl {
|
||||
padding-top: 22px !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.yearControlButtonGroup {
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-top-left-radius: 0 !important;
|
||||
}
|
||||
|
||||
.yearControlButton {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.select-all-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import ReactHighstock from 'react-highcharts/ReactHighstock'
|
||||
import { Button, Radio } from 'semantic-ui-react'
|
||||
|
||||
import { getStudyRightElementTargetDates } from '@/common'
|
||||
import { useDeepMemo } from '@/common/hooks'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
|
||||
import { useDeepMemo } from '@/hooks/deepMemo'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { CreditGraphTooltip } from './CreditGraphTooltip'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import moment from 'moment'
|
||||
import qs from 'query-string'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Datetime from 'react-datetime'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import { Button, Form, Modal, TextArea } from 'semantic-ui-react'
|
||||
|
||||
import { SearchHistory } from '@/components/SearchHistory'
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
|
||||
const location = useLocation()
|
||||
const history = useHistory()
|
||||
const navigate = useNavigate()
|
||||
const [modal, setModal] = useState(false)
|
||||
const [input, setInput] = useState('')
|
||||
const [searchList, setSearches] = useState(savedSearches)
|
||||
@@ -53,7 +53,7 @@ export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
|
||||
const pushQueryToUrl = query => {
|
||||
setTimeout(() => {
|
||||
const searchString = qs.stringify(query)
|
||||
history.push({ search: searchString })
|
||||
navigate({ search: searchString }, { replace: true })
|
||||
}, 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import moment from 'moment'
|
||||
import { useState } from 'react'
|
||||
import { Message, Icon, Loader } from 'semantic-ui-react'
|
||||
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetSavedSearchesQuery } from '@/redux/openUniPopulations'
|
||||
import { CustomOpenUniSearch } from './CustomOpenUniSearch'
|
||||
import { OpenUniPopulationResults } from './OpenUniPopulationResults'
|
||||
|
||||
+1
-1
@@ -1,12 +1,12 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { getNewestProgrammeOfStudentAt } from '@/common'
|
||||
import { useCurrentSemester } from '@/common/hooks'
|
||||
import { FilterToggleIcon } from '@/components/common/FilterToggleIcon'
|
||||
import { ProgressBarWithLabel } from '@/components/common/ProgressBarWithLabel'
|
||||
import { isProgrammeSelected, toggleProgrammeSelection } from '@/components/FilterView/filters/programmes'
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useCurrentSemester } from '@/hooks/currentSemester'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { SearchResultTable } from './SearchResultTable'
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from 'react'
|
||||
import { Button, Form, Modal, TextArea } from 'semantic-ui-react'
|
||||
|
||||
import { extractItems, textAndDescriptionSearch } from '@/common'
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { SearchHistory } from '@/components/SearchHistory'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import {
|
||||
useCreateCustomPopulationSearchMutation,
|
||||
useDeleteCustomPopulationSearchMutation,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Form, Header, Input, Label, Message, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { useProgress, useTitle } from '@/common/hooks'
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { PanelView } from '@/components/common/PanelView'
|
||||
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
|
||||
@@ -24,6 +23,8 @@ import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/Po
|
||||
import { PopulationStudentsContainer as PopulationStudents } from '@/components/PopulationStudents'
|
||||
import { ProgressBar } from '@/components/ProgressBar'
|
||||
import { RightsNotification } from '@/components/RightsNotification'
|
||||
import { useProgress } from '@/hooks/progress'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourses'
|
||||
import { useGetCustomPopulationQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Component, Suspense } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Loader } from 'semantic-ui-react'
|
||||
|
||||
import { AccessDenied } from '@/components/AccessDenied'
|
||||
import { AccessDenied } from '@/components/material/AccessDenied'
|
||||
|
||||
let sent = null
|
||||
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Divider, Loader, Message, Radio } from 'semantic-ui-react'
|
||||
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import { makeGraphData, makeTableStats } from '@/components/common/CreditsProduced'
|
||||
import { InteractiveDataTable } from '@/components/FacultyStatistics/InteractiveDataView'
|
||||
import { InfoBox } from '@/components/InfoBox'
|
||||
import { LineGraph } from '@/components/StudyProgramme/BasicOverview/LineGraph'
|
||||
import { StackedBarChart } from '@/components/StudyProgramme/BasicOverview/StackedBarChart'
|
||||
import { Toggle } from '@/components/StudyProgramme/Toggle'
|
||||
import '@/components/FacultyStatistics/faculty.css'
|
||||
import '@/components/StudyProgramme/studyprogramme.css'
|
||||
import {
|
||||
useGetFacultyBasicStatsQuery,
|
||||
useGetFacultyCreditStatsQuery,
|
||||
useGetFacultyThesisStatsQuery,
|
||||
} from '@/redux/facultyStats'
|
||||
import { useGetProgrammesQuery } from '@/redux/populations'
|
||||
import { getTimestamp } from '@/util/timeAndDate'
|
||||
|
||||
const calculateTotals = stats => {
|
||||
const totals = {}
|
||||
for (const id of stats.ids) {
|
||||
const providerStats = stats[id].stats
|
||||
for (const year of Object.keys(providerStats)) {
|
||||
const yearStats = providerStats[year]
|
||||
if (!totals[year]) totals[year] = {}
|
||||
for (const field of Object.keys(yearStats)) {
|
||||
if (field === 'total') continue
|
||||
if (!totals[year][field]) totals[year][field] = 0
|
||||
if (!totals[year].total) totals[year].total = 0
|
||||
totals[year][field] += Math.round(yearStats[field])
|
||||
totals[year].total += Math.round(yearStats[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
const getSortedProgrammeIdsAndPlotLinePlaces = (data, degreeProgrammes, faculty) => {
|
||||
if (!data || !degreeProgrammes) {
|
||||
return { programmes: [], chartPlotLinePlaces: [] }
|
||||
}
|
||||
|
||||
const programmeCodes = Object.values(data)
|
||||
.map(prog => prog.code)
|
||||
.reduce((acc, code) => {
|
||||
acc[code] = degreeProgrammes[code]?.degreeProgrammeType ?? null
|
||||
return acc
|
||||
}, {})
|
||||
const programmeIds = Object.entries(data).reduce((acc, [key, value]) => {
|
||||
acc[value.code] = key
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const degreeTypes = [
|
||||
{
|
||||
types: ['urn:code:degree-program-type:bachelors-degree'],
|
||||
key: 'Bachelors',
|
||||
programmes: [],
|
||||
},
|
||||
{
|
||||
types: ['urn:code:degree-program-type:masters-degree'],
|
||||
key: 'Masters',
|
||||
programmes: [],
|
||||
},
|
||||
{
|
||||
types: ['urn:code:degree-program-type:doctor', 'urn:code:degree-program-type:lic'],
|
||||
key: 'Doctors and Licentiates',
|
||||
programmes: [],
|
||||
},
|
||||
{
|
||||
types: ['urn:code:degree-program-type:postgraduate-professional'],
|
||||
key: 'Postgraduate professionals',
|
||||
programmes: [],
|
||||
},
|
||||
]
|
||||
|
||||
for (const [programmeCode, degreeType] of Object.entries(programmeCodes)) {
|
||||
const progId = programmeIds[programmeCode]
|
||||
for (const degree of degreeTypes) {
|
||||
if (degree.types.includes(degreeType)) {
|
||||
degree.programmes.push(progId)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const degree of degreeTypes) {
|
||||
degree.programmes.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
const sortedProgrammes = []
|
||||
const plotLinePlaces = []
|
||||
|
||||
for (const degree of degreeTypes) {
|
||||
if (degree.programmes.length > 0) {
|
||||
plotLinePlaces.push([sortedProgrammes.length, degree.key])
|
||||
sortedProgrammes.push(...degree.programmes)
|
||||
}
|
||||
}
|
||||
|
||||
if (faculty) {
|
||||
plotLinePlaces.push([sortedProgrammes.length, 'Produced by faculty'])
|
||||
sortedProgrammes.push(faculty)
|
||||
}
|
||||
|
||||
return { programmes: sortedProgrammes, plotLinePlaces }
|
||||
}
|
||||
|
||||
export const BasicOverview = ({
|
||||
academicYear,
|
||||
faculty,
|
||||
setAcademicYear,
|
||||
setSpecialGroups,
|
||||
setStudyProgrammes,
|
||||
specialGroups,
|
||||
studyProgrammes,
|
||||
}) => {
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
const yearType = academicYear ? 'ACADEMIC_YEAR' : 'CALENDAR_YEAR'
|
||||
const studyProgrammeFilter = studyProgrammes ? 'ALL_PROGRAMMES' : 'NEW_STUDY_PROGRAMMES'
|
||||
const special = specialGroups ? 'SPECIAL_EXCLUDED' : 'SPECIAL_INCLUDED'
|
||||
|
||||
const basics = useGetFacultyBasicStatsQuery({
|
||||
id: faculty?.id,
|
||||
yearType,
|
||||
studyProgrammeFilter,
|
||||
specialGroups: special,
|
||||
})
|
||||
|
||||
const thesisWriters = useGetFacultyThesisStatsQuery({
|
||||
id: faculty?.id,
|
||||
yearType,
|
||||
studyProgrammeFilter,
|
||||
specialGroups: special,
|
||||
})
|
||||
|
||||
const credits = useGetFacultyCreditStatsQuery({
|
||||
id: faculty?.id,
|
||||
yearType,
|
||||
})
|
||||
|
||||
const { data: degreeProgrammes } = useGetProgrammesQuery()
|
||||
|
||||
const tableStats = credits.data ? makeTableStats(calculateTotals(credits.data), showAll, academicYear) : {}
|
||||
const graphStats = credits.data ? makeGraphData(calculateTotals(credits.data), showAll, academicYear) : null
|
||||
const programmeStats = credits.data?.ids.reduce((obj, id) => {
|
||||
return {
|
||||
...obj,
|
||||
[id]: makeTableStats(credits.data[id].stats, showAll, academicYear).data,
|
||||
}
|
||||
}, {})
|
||||
|
||||
const getDivider = (title, toolTipText) => (
|
||||
<>
|
||||
<Divider data-cy={`Section-${toolTipText}`} horizontal>
|
||||
{title}
|
||||
</Divider>
|
||||
<InfoBox content={facultyToolTips[toolTipText]} cypress={toolTipText} />
|
||||
</>
|
||||
)
|
||||
|
||||
const isFetchingOrLoading =
|
||||
credits.isLoading ||
|
||||
credits.isFetching ||
|
||||
basics.isLoading ||
|
||||
basics.isFetching ||
|
||||
thesisWriters.isLoading ||
|
||||
thesisWriters.isFetching
|
||||
|
||||
const isError =
|
||||
(basics.isError && credits.isError && thesisWriters.isError) ||
|
||||
(basics.isSuccess &&
|
||||
!basics.data &&
|
||||
credits.isSuccess &&
|
||||
!credits.data &&
|
||||
thesisWriters.isSuccess &&
|
||||
!thesisWriters.data)
|
||||
|
||||
if (isError) {
|
||||
return <h3>Something went wrong, please try refreshing the page.</h3>
|
||||
}
|
||||
|
||||
const creditSortingTitles = ['Code', 'Total', 'Degree', 'Exchange', 'Open uni', 'Transferred']
|
||||
if (showAll) creditSortingTitles.splice(5, 0, 'Other uni', 'Separate')
|
||||
|
||||
const transferShortTitles = ['Code', 'Started', 'Accepted', 'Graduated']
|
||||
if (special === 'SPECIAL_INCLUDED') {
|
||||
transferShortTitles.push('Transferred out', 'Transferred into')
|
||||
}
|
||||
|
||||
const { programmes: basicStatsProgrammes, plotLinePlaces: basicStatsPlotLinePlaces } =
|
||||
getSortedProgrammeIdsAndPlotLinePlaces(basics.data?.programmeNames, degreeProgrammes)
|
||||
const { programmes: thesisWriterStatsProgrammes, plotLinePlaces: thesisWriterStatsPlotLinePlaces } =
|
||||
getSortedProgrammeIdsAndPlotLinePlaces(thesisWriters.data?.programmeNames, degreeProgrammes)
|
||||
const { programmes: creditStatsProgrammes, plotLinePlaces: creditStatsPlotLinePlaces } =
|
||||
getSortedProgrammeIdsAndPlotLinePlaces(credits.data?.programmeNames, degreeProgrammes, faculty.code)
|
||||
|
||||
return (
|
||||
<div className="faculty-overview">
|
||||
<div className="toggle-container">
|
||||
<Toggle
|
||||
cypress="YearToggle"
|
||||
firstLabel="Calendar year"
|
||||
secondLabel="Academic year"
|
||||
setValue={setAcademicYear}
|
||||
toolTips={facultyToolTips.yearToggle}
|
||||
value={academicYear}
|
||||
/>
|
||||
<Toggle
|
||||
cypress="ProgrammeToggle"
|
||||
firstLabel="New study programmes"
|
||||
secondLabel="All study programmes"
|
||||
setValue={setStudyProgrammes}
|
||||
toolTips={facultyToolTips.programmeToggle}
|
||||
value={studyProgrammes}
|
||||
/>
|
||||
<Toggle
|
||||
cypress="StudentToggle"
|
||||
firstLabel="All study rights"
|
||||
secondLabel="Special study rights excluded"
|
||||
setValue={setSpecialGroups}
|
||||
toolTips={facultyToolTips.studentToggle}
|
||||
value={specialGroups}
|
||||
/>
|
||||
</div>
|
||||
{isFetchingOrLoading ? (
|
||||
<Loader active style={{ marginTop: '15em' }} />
|
||||
) : (
|
||||
<>
|
||||
{studyProgrammeFilter === 'ALL_PROGRAMMES' && (
|
||||
<Message data-cy="FacultyProgrammesShownInfo">
|
||||
Please note that the data is complete only for current Bachelor's, Master's and Doctoral programmes.
|
||||
Especially, credits and thesis writers contain only data for current programmes.
|
||||
</Message>
|
||||
)}
|
||||
{special === 'SPECIAL_EXCLUDED' && (
|
||||
<Message data-cy="FacultyExcludeSpecialsInfo">
|
||||
Please note that excluding the special study rights does not affect the "Credits produced by the faculty"
|
||||
view.
|
||||
</Message>
|
||||
)}
|
||||
{basics.isSuccess && basics.data && (
|
||||
<>
|
||||
{getDivider('Students of the faculty', 'studentsOfTheFaculty')}
|
||||
<div className="section-container">
|
||||
<LineGraph
|
||||
cypress="StudentsOfTheFaculty"
|
||||
data={{ ...basics?.data.studentInfo, years: basics.data.years }}
|
||||
exportFileName={`oodikone_StudentsOfTheFaculty_${faculty?.code}_${getTimestamp()}`}
|
||||
wideTable
|
||||
/>
|
||||
<div className="table-container-wide datatable">
|
||||
<InteractiveDataTable
|
||||
cypress="StudentsOfTheFaculty"
|
||||
dataProgrammeStats={basics.data.studentInfo.programmeTableStats}
|
||||
dataStats={basics.data.studentInfo.tableStats}
|
||||
plotLinePlaces={basicStatsPlotLinePlaces}
|
||||
programmeNames={basics.data.programmeNames}
|
||||
shortNames={transferShortTitles}
|
||||
sliceStart={1}
|
||||
sortedKeys={basicStatsProgrammes}
|
||||
titles={basics.data.studentInfo.titles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{basics.isSuccess && basics.data && (
|
||||
<>
|
||||
{getDivider('Graduated of the faculty', 'graduatedOfTheFaculty')}
|
||||
<div className="section-container">
|
||||
<LineGraph
|
||||
cypress="GraduatedOfTheFaculty"
|
||||
data={{ ...basics?.data.graduationInfo, years: basics.data.years }}
|
||||
exportFileName={`oodikone_GraduatedOfTheFaculty_${faculty?.code}_${getTimestamp()}`}
|
||||
/>
|
||||
<div className="table-container">
|
||||
<InteractiveDataTable
|
||||
cypress="GraduatedOfTheFaculty"
|
||||
dataProgrammeStats={basics.data.graduationInfo.programmeTableStats}
|
||||
dataStats={basics.data.graduationInfo.tableStats}
|
||||
plotLinePlaces={basicStatsPlotLinePlaces}
|
||||
programmeNames={basics.data.programmeNames}
|
||||
sliceStart={2}
|
||||
sortedKeys={basicStatsProgrammes}
|
||||
titles={basics.data.graduationInfo.titles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{thesisWriters.isSuccess && thesisWriters.data && (
|
||||
<>
|
||||
{getDivider('Thesis writers of the faculty', 'thesisWritersOfTheFaculty')}
|
||||
<div className="section-container">
|
||||
<LineGraph
|
||||
cypress="ThesisWritersOfTheFaculty"
|
||||
data={{ ...thesisWriters.data, years: thesisWriters.data.years }}
|
||||
exportFileName={`oodikone_ThesisWritersOfTheFaculty_${faculty?.code}_${getTimestamp()}`}
|
||||
/>
|
||||
<div className="table-container">
|
||||
<InteractiveDataTable
|
||||
cypress="ThesisWritersOfTheFaculty"
|
||||
dataProgrammeStats={thesisWriters.data.programmeTableStats}
|
||||
dataStats={thesisWriters.data.tableStats}
|
||||
plotLinePlaces={thesisWriterStatsPlotLinePlaces}
|
||||
programmeNames={thesisWriters.data.programmeNames}
|
||||
sliceStart={2}
|
||||
sortedKeys={thesisWriterStatsProgrammes}
|
||||
titles={thesisWriters.data.titles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{credits.isSuccess && credits.data && (
|
||||
<>
|
||||
{getDivider('Credits produced by the faculty', 'creditsProducedByTheFaculty')}
|
||||
<div className="original-toggle-container">
|
||||
<Radio checked={showAll} label="Show special categories" onChange={() => setShowAll(!showAll)} toggle />
|
||||
</div>
|
||||
<div className="section-container">
|
||||
<StackedBarChart
|
||||
cypress="CreditsProducedByTheFaculty"
|
||||
data={graphStats.data}
|
||||
exportFileName={`oodikone_CreditsProducedByTheFaculty_${faculty?.code}_${getTimestamp()}`}
|
||||
labels={graphStats.years}
|
||||
wideTable
|
||||
/>
|
||||
<div className="table-container-wide">
|
||||
<InteractiveDataTable
|
||||
cypress="CreditsProducedByTheFaculty"
|
||||
dataProgrammeStats={programmeStats}
|
||||
dataStats={tableStats.data}
|
||||
plotLinePlaces={creditStatsPlotLinePlaces}
|
||||
programmeNames={{
|
||||
...credits.data.programmeNames,
|
||||
[faculty.code]: { ...faculty.name, code: faculty.code },
|
||||
}}
|
||||
shortNames={creditSortingTitles}
|
||||
sliceStart={2}
|
||||
sortedKeys={creditStatsProgrammes}
|
||||
titles={tableStats.titles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-58
@@ -1,58 +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 { generateGradientColors } from '@/common'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const FacultyBarChart = ({ cypress, data }) => {
|
||||
if (!data.stats) return null
|
||||
|
||||
const colors = generateGradientColors(Object.keys(data.stats).length)
|
||||
const dataWithColors = Object.values(data.stats).map((series, index) => ({ ...series, color: colors[index] }))
|
||||
|
||||
const config = {
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
series: dataWithColors,
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
xAxis: {
|
||||
categories: data?.years,
|
||||
},
|
||||
chart: {
|
||||
type: 'column',
|
||||
height: '450px',
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'percent',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
format: '{point.percentage:.1f}%',
|
||||
},
|
||||
},
|
||||
},
|
||||
exporting: {
|
||||
filename: `oodikone_progress_of_students_in_${data?.id}_by_study_start_year`,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: '',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-cy={`Graph-${cypress}`}>
|
||||
<ReactHighcharts config={config} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import { Fragment, useState } from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
|
||||
import { ExpandableRow } from '@/components/FacultyStatistics/InteractiveDataView/ExpandableRow'
|
||||
import { ProgrammeProgressChart } from './ProgrammeProgressChart'
|
||||
|
||||
export const FacultyProgressTable = ({
|
||||
data,
|
||||
programmeStats,
|
||||
titles,
|
||||
sortedKeys,
|
||||
programmeNames,
|
||||
cypress,
|
||||
progressTitles,
|
||||
}) => {
|
||||
const [visibleYears, setVisibleYears] = useState(new Array(data.length).fill(false))
|
||||
const toggleVisibility = yearIndex => {
|
||||
setVisibleYears(visibleYears.map((year, index) => (index === yearIndex ? !year : year)))
|
||||
}
|
||||
|
||||
const isTotalRow = yearArray => yearArray[0].toLowerCase() === 'total'
|
||||
|
||||
return (
|
||||
<Table celled compact data-cy={cypress}>
|
||||
<Table.Header>
|
||||
<Table.Row textAlign="center">
|
||||
{titles.map(title => (
|
||||
<Table.HeaderCell key={title}>{title}</Table.HeaderCell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{data.map((yearArray, yearIndex) => (
|
||||
<Fragment key={yearArray[0]}>
|
||||
{!isTotalRow(yearArray) ? (
|
||||
<ExpandableRow
|
||||
cypress={visibleYears[yearIndex] ? `Hide-${cypress}` : `Show-${cypress}`}
|
||||
icon={visibleYears[yearIndex] ? 'angle down' : 'angle right'}
|
||||
toggleVisibility={() => toggleVisibility(yearIndex)}
|
||||
yearArray={yearArray}
|
||||
yearIndex={yearIndex}
|
||||
/>
|
||||
) : (
|
||||
<Table.Row style={{ textAlign: 'right' }}>
|
||||
{yearArray.map((value, index) => (
|
||||
/* eslint-disable-next-line react/no-array-index-key */
|
||||
<Table.Cell className="total-row-cell" key={`${index}-${value}`}>
|
||||
{value}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
)}
|
||||
{!isTotalRow(yearArray) && (
|
||||
<Table.Row style={{ display: visibleYears[yearIndex] ? '' : 'none' }}>
|
||||
<Table.Cell colSpan={100} data-cy={`Cell-${cypress}-${yearIndex}`}>
|
||||
<ProgrammeProgressChart
|
||||
data={sortedKeys?.map(programme => programmeStats[programme][yearIndex])}
|
||||
labels={sortedKeys}
|
||||
longLabels={programmeNames}
|
||||
names={progressTitles ? progressTitles[yearIndex] : titles.slice(2)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
-238
@@ -1,238 +0,0 @@
|
||||
import { Fragment, useState } from 'react'
|
||||
import { Button, Icon, Label, Popup, Table } from 'semantic-ui-react'
|
||||
|
||||
import { getCalendarYears } from '@/common'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { PopulationLink } from '@/components/StudyProgramme/StudyTrackOverview/PopulationLink'
|
||||
import { Toggle } from '@/components/StudyProgramme/Toggle'
|
||||
|
||||
const getStyle = index => {
|
||||
if ([4, 12].includes(index)) return { backgroundColor: '#f9f9f9', borderLeftWidth: 'thick' }
|
||||
if ([1, 5, 8, 9, 13, 16, 17, 20, 21].includes(index)) return { backgroundColor: '#f9f9f9' }
|
||||
if (index === 18) return { borderLeftWidth: 'thick' }
|
||||
return {}
|
||||
}
|
||||
|
||||
const backgroundColors = { KH: '#ffffff', MH: '#f2f6f7', T: '#e7edee' }
|
||||
const backgroundColorsDarks = { KH: '#f9f9f9', MH: '#eeeeee', T: '#dddddd' }
|
||||
|
||||
const getRowStyle = (index, tableLinePlaces, dark = false) => {
|
||||
const backgroundColorsToUse = dark ? backgroundColorsDarks : backgroundColors
|
||||
|
||||
if (tableLinePlaces[2]?.[0] <= index) return { backgroundColor: backgroundColorsToUse[tableLinePlaces[2][1]] }
|
||||
|
||||
if (tableLinePlaces[1]?.[0] <= index) return { backgroundColor: backgroundColorsToUse[tableLinePlaces[1][1]] }
|
||||
|
||||
return { backgroundColor: backgroundColorsToUse[tableLinePlaces[0][1]] }
|
||||
}
|
||||
|
||||
const getTableCell = ({ year, programme, valIndex, rowIndex, tableLinePlaces, value }) => {
|
||||
return (
|
||||
<Table.Cell
|
||||
key={`${year}-${programme}-color-${Math.random()}`}
|
||||
style={
|
||||
getStyle(valIndex + 1)?.backgroundColor
|
||||
? {
|
||||
borderLeftWidth: getStyle(valIndex + 1).borderLeftWidth,
|
||||
...getRowStyle(rowIndex, tableLinePlaces, true),
|
||||
}
|
||||
: { ...getStyle(valIndex + 1), ...getRowStyle(rowIndex, tableLinePlaces) }
|
||||
}
|
||||
textAlign="right"
|
||||
>
|
||||
{value}
|
||||
</Table.Cell>
|
||||
)
|
||||
}
|
||||
|
||||
const getOtherCountriesList = ({ year, code, extraTableStats }) => {
|
||||
const countriesData = extraTableStats?.[year]?.[code]
|
||||
|
||||
if (!countriesData || Object.keys(countriesData).length === 0) return null
|
||||
|
||||
return Object.keys(countriesData)
|
||||
.sort()
|
||||
.map(country => (
|
||||
<div key={country}>
|
||||
{country}: <b>{countriesData[country]}</b>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
const getRows = ({
|
||||
index,
|
||||
programme,
|
||||
year,
|
||||
showPercentages,
|
||||
programmeStats,
|
||||
extraTableStats,
|
||||
tableLinePlaces,
|
||||
programmeNames,
|
||||
}) => {
|
||||
return programmeStats[programme][year].map((value, valIndex) => {
|
||||
const key = `${programme}-${year}-${`${value}${valIndex}`}`
|
||||
if (!showPercentages && typeof value === 'string' && (value.includes('%') || value.includes('NA'))) return null
|
||||
const tableCell = getTableCell({ year, programme, valIndex, rowIndex: index, tableLinePlaces, value })
|
||||
if (valIndex === 19) {
|
||||
const countriesPopupContent = getOtherCountriesList({
|
||||
year,
|
||||
code: programmeNames[programme].code,
|
||||
extraTableStats,
|
||||
})
|
||||
if (!countriesPopupContent) return tableCell
|
||||
return <Popup content={countriesPopupContent} key={key} trigger={tableCell} />
|
||||
}
|
||||
return tableCell
|
||||
})
|
||||
}
|
||||
|
||||
export const FacultyStudentDataTable = ({
|
||||
cypress,
|
||||
extraTableStats,
|
||||
programmeNames,
|
||||
programmeStats,
|
||||
requiredRights,
|
||||
sortedKeys,
|
||||
tableLinePlaces,
|
||||
tableStats,
|
||||
titles,
|
||||
years,
|
||||
}) => {
|
||||
const [yearsVisible, setVisible] = useState(new Array(years.length).fill(false))
|
||||
const [showPercentages, setShowPercentages] = useState(false)
|
||||
const { getTextIn } = useLanguage()
|
||||
const toggleVisibility = yearIndex => {
|
||||
const arrayToModify = [...yearsVisible]
|
||||
arrayToModify[yearIndex] = !yearsVisible[yearIndex]
|
||||
setVisible(arrayToModify)
|
||||
}
|
||||
return (
|
||||
<div className="datatable">
|
||||
<Toggle
|
||||
cypress="HidePercentagesToggle"
|
||||
firstLabel="Hide percentages"
|
||||
secondLabel="Show percentages"
|
||||
setValue={setShowPercentages}
|
||||
value={showPercentages}
|
||||
/>
|
||||
<Table celled data-cy={cypress} structured>
|
||||
<Table.Header>
|
||||
<Table.Row key="FirstHeader">
|
||||
<Table.HeaderCell colSpan={!showPercentages ? 3 : 4} />
|
||||
<Table.HeaderCell colSpan={!showPercentages ? 4 : 8} style={{ borderLeftWidth: 'thick' }}>
|
||||
Current status
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan={!showPercentages ? 3 : 6} style={{ borderLeftWidth: 'thick' }}>
|
||||
Gender
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell colSpan={!showPercentages ? 2 : 4} style={{ borderLeftWidth: 'thick' }}>
|
||||
<Popup
|
||||
content="Hover over 'Other' cell to see from which countries students are coming. Shown only for study programmes."
|
||||
trigger={
|
||||
<div>
|
||||
Countries <Icon name="question circle" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row key="secondHeader">
|
||||
{titles.map((title, index) => (
|
||||
<Table.HeaderCell
|
||||
colSpan={index === 0 || index === 1 || !showPercentages ? 1 : 2}
|
||||
key={title}
|
||||
style={[3, 7, 10].includes(index) ? { borderLeftWidth: 'thick' } : {}}
|
||||
textAlign="center"
|
||||
>
|
||||
{title}
|
||||
</Table.HeaderCell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{years.map((year, yearIndex) => {
|
||||
return (
|
||||
<Fragment key={`${year}-fragment`}>
|
||||
<Table.Row className={year === 'Total' ? 'total-row-cell' : ''} key={`${year}-faculty-row}`}>
|
||||
{tableStats[year]?.map((value, valueIndex) => {
|
||||
if (valueIndex === 0)
|
||||
return (
|
||||
<Table.Cell key={`${year}-faculty-cell}`}>
|
||||
<Button
|
||||
as="div"
|
||||
data-cy={`Button-${cypress}-${yearIndex}`}
|
||||
key={`${year}-studentsTableButton}`}
|
||||
labelPosition="right"
|
||||
onClick={() => toggleVisibility(yearIndex)}
|
||||
style={{ backgroundColor: 'white', borderRadius: 0, margin: 0, padding: 0 }}
|
||||
>
|
||||
<Button icon style={{ backgroundColor: 'white', borderRadius: 0, margin: 0, padding: 0 }}>
|
||||
<Icon name={yearsVisible[yearIndex] ? 'angle down' : 'angle right'} />
|
||||
</Button>
|
||||
<Label as="a" style={{ backgroundColor: 'white', borderRadius: 0, margin: 0, padding: 0 }}>
|
||||
<b>{value}</b>
|
||||
</Label>
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
)
|
||||
if (!showPercentages && typeof value === 'string' && (value.includes('%') || value.includes('NA')))
|
||||
return null
|
||||
return (
|
||||
<Table.Cell
|
||||
key={`${year}$-cell-colorless-${valueIndex + Math.random()}`}
|
||||
style={getStyle(valueIndex)}
|
||||
textAlign="right"
|
||||
>
|
||||
{value}
|
||||
</Table.Cell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
{yearsVisible[yearIndex] &&
|
||||
sortedKeys.map((programme, index) => {
|
||||
return !programmeStats[programme][year] || programmeStats[programme][year].length === 0 ? null : (
|
||||
<Table.Row className="regular-row" key={`${year}-regular-row-${programme}`}>
|
||||
<Table.Cell
|
||||
key={`${year}-${programme}`}
|
||||
style={{ paddingLeft: '50px', ...getRowStyle(index, tableLinePlaces) }}
|
||||
textAlign="left"
|
||||
>
|
||||
<Popup
|
||||
content={
|
||||
<p>
|
||||
{programmeNames[programme].code} – {getTextIn(programmeNames[programme].name)}
|
||||
</p>
|
||||
}
|
||||
trigger={<b>{programmeNames[programme].progId}</b>}
|
||||
/>
|
||||
{requiredRights.programmeRights?.includes(programmeNames[programme].code) ||
|
||||
(requiredRights.fullAccessToStudentData && (
|
||||
<PopulationLink
|
||||
studyProgramme={programmeNames[programme].code}
|
||||
year={year}
|
||||
years={getCalendarYears(years)}
|
||||
/>
|
||||
))}
|
||||
</Table.Cell>
|
||||
{getRows({
|
||||
index,
|
||||
programme,
|
||||
year,
|
||||
showPercentages,
|
||||
programmeStats,
|
||||
extraTableStats,
|
||||
tableLinePlaces,
|
||||
programmeNames,
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
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 { generateGradientColors } from '@/common'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const ProgrammeProgressChart = ({ data, labels, longLabels, names }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
if (!data || data.length === 0) return <b>No data available</b>
|
||||
const transpose = matrix => {
|
||||
return matrix.reduce((prev, next) => next.map((_item, i) => (prev[i] || []).concat(next[i])), [])
|
||||
}
|
||||
|
||||
const colors = generateGradientColors(data[0]?.length || 6)
|
||||
const dataTranspose = transpose(data).map((obj, index) => ({ name: names[index], data: obj, color: colors[index] }))
|
||||
|
||||
// Tooltip formatter function: this.<some value> needs to be used in order to obtain access to the values.
|
||||
const config = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
marginTop: 60,
|
||||
height: 80 + 45 * labels.length,
|
||||
},
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
series: dataTranspose,
|
||||
xAxis: {
|
||||
categories: labels,
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
reversed: true,
|
||||
title: '',
|
||||
},
|
||||
legend: {
|
||||
verticalAlign: 'top',
|
||||
borderColor: '#CCC',
|
||||
borderWidth: 1,
|
||||
},
|
||||
tooltip: {
|
||||
shared: true,
|
||||
backgroundColor: 'white',
|
||||
formatter() {
|
||||
let tooltipString = `<b>${getTextIn(longLabels[this.x])}</b><br /><p>${this.x} - ${
|
||||
longLabels[this.x]?.code
|
||||
}</p><br />`
|
||||
this.points.forEach(point => {
|
||||
tooltipString += `<span style="color:${point.color}">●</span> ${point.series.name}: <b>${point.y}</b>
|
||||
</span><br />`
|
||||
})
|
||||
return tooltipString
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
stacking: 'percent',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
format: '{point.percentage:.1f}%',
|
||||
filter: {
|
||||
property: 'percentage',
|
||||
operator: '>',
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return <ReactHighcharts config={config} />
|
||||
}
|
||||
-357
@@ -1,357 +0,0 @@
|
||||
import { Button, Divider, Loader, Message, Popup } from 'semantic-ui-react'
|
||||
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import { calculateStats, sortProgrammeKeys } from '@/components/FacultyStatistics/facultyHelpers'
|
||||
import { downloadProgressTable, downloadStudentTable } from '@/components/FacultyStatistics/xlsxFileDownloadHelper'
|
||||
import '@/components/FacultyStatistics/faculty.css'
|
||||
import { InfoBox } from '@/components/InfoBox'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { Toggle } from '@/components/StudyProgramme/Toggle'
|
||||
import { useGetFacultyProgressStatsQuery, useGetFacultyStudentStatsQuery } from '@/redux/facultyStats'
|
||||
import { FacultyBarChart } from './FacultyBarChart'
|
||||
import { FacultyProgressTable } from './FacultyProgressTable'
|
||||
import { FacultyStudentDataTable } from './FacultyStudentDataTable'
|
||||
|
||||
const getDivider = (title, toolTipText, content, cypress) => (
|
||||
<>
|
||||
<div className="divider">
|
||||
<Divider data-cy={`Section-${toolTipText}`} horizontal>
|
||||
{title}
|
||||
</Divider>
|
||||
</div>
|
||||
{content === 'no-infobox' ? null : <InfoBox content={content} cypress={cypress} />}
|
||||
</>
|
||||
)
|
||||
|
||||
const getKey = (programmeKeys, index) => {
|
||||
if (programmeKeys[index][1].startsWith('T') || programmeKeys[index][1].startsWith('LIS')) {
|
||||
return 'T'
|
||||
}
|
||||
if (programmeKeys[index][1].includes('KH')) {
|
||||
return 'KH'
|
||||
}
|
||||
return 'MH'
|
||||
}
|
||||
|
||||
export const FacultyProgrammeOverview = ({
|
||||
faculty,
|
||||
graduatedGroup,
|
||||
requiredRights,
|
||||
setGraduatedGroup,
|
||||
setSpecialGroups,
|
||||
specialGroups,
|
||||
}) => {
|
||||
const specials = specialGroups ? 'SPECIAL_EXCLUDED' : 'SPECIAL_INCLUDED'
|
||||
const graduated = graduatedGroup ? 'GRADUATED_EXCLUDED' : 'GRADUATED_INCLUDED'
|
||||
const { getTextIn } = useLanguage()
|
||||
const progressStats = useGetFacultyProgressStatsQuery({
|
||||
id: faculty?.id,
|
||||
specialGroups: specials,
|
||||
graduated,
|
||||
})
|
||||
const studentStats = useGetFacultyStudentStatsQuery({
|
||||
id: faculty.id,
|
||||
specialGroups: specials,
|
||||
graduated,
|
||||
})
|
||||
|
||||
const isFetchingOrLoading =
|
||||
progressStats.isLoading || progressStats.isFetching || studentStats.isLoading || studentStats.isFetching
|
||||
|
||||
const isError =
|
||||
progressStats.isError ||
|
||||
studentStats.isError ||
|
||||
(progressStats.isSuccess && !progressStats.data) ||
|
||||
(studentStats.isSuccess && !studentStats.data)
|
||||
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
|
||||
|
||||
// These are for color coding the rows based on the programme; bachelor, master, doctor
|
||||
const getTableLinePlaces = programmeKeys => {
|
||||
if (programmeKeys.length === 0) return []
|
||||
const key = getKey(programmeKeys, 0)
|
||||
const plotLinePlaces = [[0, key]]
|
||||
for (let i = 0; i < programmeKeys.length - 1; i++) {
|
||||
if (
|
||||
(programmeKeys[i][1].includes('KH') && programmeKeys[i + 1][1].includes('MH')) ||
|
||||
(programmeKeys[i][1].includes('MH') &&
|
||||
(programmeKeys[i + 1][1].startsWith('T') || programmeKeys[i + 1][1].startsWith('LIS')))
|
||||
) {
|
||||
const key = getKey(programmeKeys, i + 1)
|
||||
plotLinePlaces.push([i + 1, key])
|
||||
}
|
||||
}
|
||||
return plotLinePlaces
|
||||
}
|
||||
const sortedProgrammeKeysStudents = studentStats?.data?.programmeStats
|
||||
? Object.keys(studentStats?.data?.programmeStats || {})
|
||||
.sort((a, b) => {
|
||||
const priority = {
|
||||
'urn:code:degree-program-type:bachelors-degree': 1,
|
||||
'urn:code:degree-program-type:masters-degree': 2,
|
||||
}
|
||||
|
||||
const aPriority = priority[studentStats.data.programmeNames[a].degreeProgrammeType] || 3
|
||||
const bPriority = priority[studentStats.data.programmeNames[b].degreeProgrammeType] || 3
|
||||
|
||||
return aPriority - bPriority
|
||||
})
|
||||
.map(obj => [studentStats.data.programmeNames[obj]?.id, obj])
|
||||
: []
|
||||
|
||||
const getSortedProgrammeKeysProgress = studyLevelStats => {
|
||||
return sortProgrammeKeys(
|
||||
Object.keys(studyLevelStats).map(obj => [obj, progressStats?.data?.programmeNames[obj].code]),
|
||||
faculty.code
|
||||
)
|
||||
}
|
||||
|
||||
const bachelorStats = calculateStats(progressStats?.data?.creditCounts?.bachelor, 180)
|
||||
const bachelorMasterStats = calculateStats(
|
||||
progressStats?.data?.creditCounts?.bachelorMaster,
|
||||
faculty.code === 'H90' ? 360 : 300,
|
||||
180,
|
||||
7
|
||||
)
|
||||
const masterStats = calculateStats(progressStats?.data?.creditCounts?.master, 120)
|
||||
const doctorStats = calculateStats(progressStats?.data?.creditCounts?.doctor, 40, 0, 5)
|
||||
|
||||
const hasNonZeroStats = stats => {
|
||||
const allValuesZero = values => values.every(value => parseFloat(value) === 0)
|
||||
return stats?.chartStats.some(row => !allValuesZero(row.data))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="faculty-overview">
|
||||
<div className="toggle-container">
|
||||
<Toggle
|
||||
cypress="StudentToggle"
|
||||
firstLabel="All study rights"
|
||||
secondLabel="Special study rights excluded"
|
||||
setValue={setSpecialGroups}
|
||||
toolTips={facultyToolTips.studentToggle}
|
||||
value={specialGroups}
|
||||
/>
|
||||
<Toggle
|
||||
cypress="GraduatedToggle"
|
||||
firstLabel="Graduated included"
|
||||
secondLabel="Graduated excluded"
|
||||
setValue={setGraduatedGroup}
|
||||
toolTips={facultyToolTips.graduatedToggle}
|
||||
value={graduatedGroup}
|
||||
/>
|
||||
</div>
|
||||
{isFetchingOrLoading ? (
|
||||
<Loader active style={{ marginTop: '15em' }} />
|
||||
) : (
|
||||
<div className="programmes-overview">
|
||||
{studentStats?.data && (
|
||||
<>
|
||||
{getDivider(
|
||||
'Students of the faculty by starting year',
|
||||
'studentsOfTheFacultyByStartingYear',
|
||||
facultyToolTips.studentsStatsOfTheFaculty,
|
||||
'InfoFacultyStudentTable'
|
||||
)}
|
||||
<Popup
|
||||
content="Download student table statistics as xlsx."
|
||||
trigger={
|
||||
<Button
|
||||
floated="right"
|
||||
icon="download"
|
||||
onClick={() =>
|
||||
downloadStudentTable(
|
||||
studentStats,
|
||||
studentStats.data.programmeNames,
|
||||
faculty,
|
||||
sortedProgrammeKeysStudents.map(listObj => listObj[1]),
|
||||
getTextIn
|
||||
)
|
||||
}
|
||||
style={{ backgroundColor: 'white', borderRadius: 0 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<FacultyStudentDataTable
|
||||
cypress="FacultyStudentStatsTable"
|
||||
extraTableStats={studentStats?.data.facultyTableStatsExtra}
|
||||
programmeNames={studentStats?.data.programmeNames}
|
||||
programmeStats={studentStats?.data.programmeStats}
|
||||
requiredRights={requiredRights}
|
||||
sortedKeys={sortedProgrammeKeysStudents.map(listObj => listObj[1])}
|
||||
tableLinePlaces={getTableLinePlaces(sortedProgrammeKeysStudents)}
|
||||
tableStats={studentStats?.data.facultyTableStats}
|
||||
titles={studentStats?.data.titles}
|
||||
years={studentStats?.data.years}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{progressStats.isSuccess && progressStats.data && (
|
||||
<>
|
||||
{getDivider(
|
||||
'Progress of students of the faculty ',
|
||||
'bachelorStudentsOfTheFacultyByStartingYear',
|
||||
facultyToolTips.studentProgress,
|
||||
'InfoFacultyProgress'
|
||||
)}
|
||||
<Popup
|
||||
content="Download progress statistics as xlsx."
|
||||
trigger={
|
||||
<Button
|
||||
floated="right"
|
||||
icon="download"
|
||||
onClick={() =>
|
||||
downloadProgressTable(
|
||||
{
|
||||
...progressStats,
|
||||
bachelorStats,
|
||||
bachelorMasterStats,
|
||||
masterStats,
|
||||
doctorStats,
|
||||
},
|
||||
progressStats?.data?.programmeNames,
|
||||
faculty,
|
||||
getTextIn
|
||||
)
|
||||
}
|
||||
style={{ backgroundColor: 'white', borderRadius: 0 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{hasNonZeroStats(bachelorStats) && (
|
||||
<>
|
||||
{getDivider('Bachelor', 'bachelorStudentsOfTheFacultyByStartingYear', 'no-infobox')}
|
||||
<div className="section-container">
|
||||
<div className="graph-container">
|
||||
<FacultyBarChart
|
||||
cypress="FacultyBachelorsProgress"
|
||||
data={{
|
||||
id: faculty.code,
|
||||
stats: bachelorStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
cypress="FacultyBachelorsProgressTable"
|
||||
data={bachelorStats.tableStats}
|
||||
programmeNames={progressStats?.data.programmeNames}
|
||||
programmeStats={progressStats?.data.bachelorsProgStats}
|
||||
progressTitles={progressStats?.data.yearlyBachelorTitles}
|
||||
progressYearsVisible={Array(progressStats?.data.years.slice(1).length).fill(false)}
|
||||
sortedKeys={getSortedProgrammeKeysProgress(progressStats?.data.bachelorsProgStats).map(
|
||||
listObj => listObj[0]
|
||||
)}
|
||||
titles={bachelorStats.tableTitles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hasNonZeroStats(bachelorMasterStats) && (
|
||||
<>
|
||||
{getDivider(
|
||||
faculty.code === 'H90' ? 'Bachelor + Licentiate' : 'Bachelor + Master',
|
||||
'progressOfBachelorMaster',
|
||||
'no-infobox'
|
||||
)}
|
||||
<Message data-cy="FacultyProgrammesShownInfo">
|
||||
Please note: The starting year is the studyright start in the bachelor programme. The credits are
|
||||
computed by the 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.
|
||||
</Message>
|
||||
<div className="section-container">
|
||||
<div className="graph-container">
|
||||
<FacultyBarChart
|
||||
cypress="FacultyBachelorMastersProgress"
|
||||
data={{
|
||||
id: faculty.code,
|
||||
stats: bachelorMasterStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
cypress="FacultyBachelorMasterProgressTable"
|
||||
data={bachelorMasterStats.tableStats}
|
||||
programmeNames={progressStats?.data.programmeNames}
|
||||
programmeStats={progressStats?.data.bcMsProgStats}
|
||||
progressTitles={progressStats?.data.yearlyBcMsTitles}
|
||||
sortedKeys={getSortedProgrammeKeysProgress(progressStats?.data.bcMsProgStats).map(
|
||||
listObj => listObj[0]
|
||||
)}
|
||||
titles={bachelorMasterStats.tableTitles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hasNonZeroStats(masterStats) && !(faculty.code === 'H90') && (
|
||||
<>
|
||||
{getDivider('Master', 'masterStudentsOfTheFacultyByStartingYear', 'no-infobox')}
|
||||
<div className="section-container">
|
||||
<div className="graph-container">
|
||||
<FacultyBarChart
|
||||
cypress="FacultyMastersProgress"
|
||||
data={{
|
||||
id: faculty.code,
|
||||
stats: masterStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
cypress="FacultyMastersProgressTable"
|
||||
data={masterStats.tableStats}
|
||||
programmeNames={progressStats?.data.programmeNames}
|
||||
programmeStats={progressStats?.data.mastersProgStats}
|
||||
progressTitles={progressStats?.data.yearlyMasterTitles}
|
||||
sortedKeys={getSortedProgrammeKeysProgress(progressStats?.data.mastersProgStats).map(
|
||||
listObj => listObj[0]
|
||||
)}
|
||||
titles={masterStats.tableTitles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hasNonZeroStats(doctorStats) && (
|
||||
<>
|
||||
{getDivider('Doctor', 'doctoralStudentsOfTheFacultyByStartingYear', 'no-infobox')}
|
||||
<div className="section-container">
|
||||
<div className="graph-container">
|
||||
<FacultyBarChart
|
||||
cypress="FacultyDoctoralProgress"
|
||||
data={{
|
||||
id: faculty.code,
|
||||
stats: doctorStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
cypress="FacultyDoctoralProgressTable"
|
||||
data={doctorStats.tableStats}
|
||||
programmeNames={progressStats?.data.programmeNames}
|
||||
programmeStats={progressStats?.data.doctoralProgStats}
|
||||
sortedKeys={getSortedProgrammeKeysProgress(progressStats?.data.doctoralProgStats).map(
|
||||
listObj => listObj[0]
|
||||
)}
|
||||
titles={doctorStats.tableTitles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Container, Grid, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
|
||||
export const FacultySegment = ({ faculty }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
return (
|
||||
<Container text>
|
||||
<Segment>
|
||||
<Grid columns={2} divided>
|
||||
<Grid.Column textAlign="center" verticalAlign="middle" width={2}>
|
||||
{faculty.code}
|
||||
</Grid.Column>
|
||||
<Grid.Column width={9}>
|
||||
<Link to={`/faculties/${faculty.id}`}>{getTextIn(faculty.name)}</Link>
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
</Segment>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Message } from 'semantic-ui-react'
|
||||
|
||||
import { FacultySegment } from './FacultySegment'
|
||||
|
||||
export const FacultySelector = ({ faculties, selected }) => {
|
||||
if (selected) return null
|
||||
|
||||
if (faculties == null) {
|
||||
return <Message>You do not have access to any faculties</Message>
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-cy="select-faculty">
|
||||
{faculties.map(faculty => (
|
||||
<FacultySegment faculty={faculty} key={faculty.id} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
import { Button, Icon, Table, Label } from 'semantic-ui-react'
|
||||
|
||||
const roundValue = value => {
|
||||
return Number.isInteger(value) ? value : value?.toFixed(1)
|
||||
}
|
||||
|
||||
export const ExpandableRow = ({ icon, yearArray, cypress, yearIndex, toggleVisibility, styles }) => {
|
||||
return (
|
||||
<Table.Row key={`Basic-row-${cypress}-${yearIndex}`} style={styles}>
|
||||
{yearArray?.map((value, index) => (
|
||||
<Table.Cell key={`random-button-cell-key-${Math.random()}`} textAlign={index === 0 ? 'center' : 'right'}>
|
||||
{index === 0 ? (
|
||||
<Button
|
||||
as="div"
|
||||
data-cy={`Button-${cypress}-${yearIndex}`}
|
||||
labelPosition="right"
|
||||
onClick={toggleVisibility}
|
||||
style={{ backgroundColor: 'white', borderRadius: 0, padding: 0, margin: 0 }}
|
||||
>
|
||||
<Button icon style={{ backgroundColor: 'white', borderRadius: 0, padding: 0, margin: 0 }}>
|
||||
<Icon name={icon} />
|
||||
</Button>
|
||||
<Label as="a" style={{ backgroundColor: 'white', borderRadius: 0, padding: 0, margin: 0 }}>
|
||||
{value}
|
||||
</Label>
|
||||
</Button>
|
||||
) : (
|
||||
roundValue(value)
|
||||
)}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Fragment, useState } from 'react'
|
||||
import { Icon, Menu, Popup, Table } from 'semantic-ui-react'
|
||||
|
||||
import { CollapsedStackedBar } from './CollapsedStackedBar'
|
||||
import { ExpandableRow } from './ExpandableRow'
|
||||
|
||||
export const InteractiveDataTable = ({
|
||||
cypress,
|
||||
dataProgrammeStats,
|
||||
dataStats,
|
||||
plotLinePlaces,
|
||||
programmeNames,
|
||||
shortNames,
|
||||
sliceStart,
|
||||
sortedKeys,
|
||||
studentsTable,
|
||||
titles,
|
||||
}) => {
|
||||
const [sorter, setSorter] = useState('Code')
|
||||
const [sortDir, setSortDir] = useState(1)
|
||||
const [columnIndex, setSelectedIndex] = useState(0)
|
||||
const [visible, setVisible] = useState(new Array(dataStats?.length ?? 0).fill(false))
|
||||
|
||||
if (!dataStats || !titles || !dataProgrammeStats) return null
|
||||
|
||||
// Note: sorting is happening inside of the degree levels, e.g. bachelors, masters, etc...
|
||||
const keyOrder = {}
|
||||
if (!(columnIndex === 0)) {
|
||||
const numbersOfYears = dataStats.length
|
||||
const data = sortedKeys?.map(programme => [programme, dataProgrammeStats[programme]])
|
||||
const groupIndices = plotLinePlaces.length > 0 ? plotLinePlaces.map(value => value[0]) : []
|
||||
for (let yearIndex = 0; yearIndex < numbersOfYears; yearIndex++) {
|
||||
let yearlySortedKeys = []
|
||||
if (groupIndices.length > 0) {
|
||||
for (let i = 0; i < groupIndices.length; i++) {
|
||||
const ending = i === groupIndices.length - 1 ? data.length : groupIndices[i + 1]
|
||||
yearlySortedKeys = [
|
||||
...yearlySortedKeys,
|
||||
...data
|
||||
.slice(groupIndices[i], ending)
|
||||
.sort((a, b) => {
|
||||
if (sortDir === -1) return a[1][yearIndex][columnIndex] - b[1][yearIndex][columnIndex]
|
||||
return b[1][yearIndex][columnIndex] - a[1][yearIndex][columnIndex]
|
||||
})
|
||||
.map(yearlyProgrammes => yearlyProgrammes[0]),
|
||||
]
|
||||
}
|
||||
} else {
|
||||
yearlySortedKeys = data
|
||||
.sort((a, b) => {
|
||||
if (sortDir === -1) return a[1][yearIndex][columnIndex] - b[1][yearIndex][columnIndex]
|
||||
return b[1][yearIndex][columnIndex] - a[1][yearIndex][columnIndex]
|
||||
})
|
||||
.map(yearlyProgrammes => yearlyProgrammes[0])
|
||||
}
|
||||
keyOrder[yearIndex] = yearlySortedKeys
|
||||
}
|
||||
}
|
||||
|
||||
/* Calculation works as follows
|
||||
1. Calculate difference between current and previous year: current year - previous year
|
||||
2. Calculations are done for the values starting from the index 2 (index 0: year, index 1: total)
|
||||
3. Last year, currently 2017 is initialized to zeros
|
||||
4. A new object with programme names as keys and the difference array as value is created
|
||||
*/
|
||||
const calculatDiffToPrevYear = programmeData => {
|
||||
const differenceMatrix = Object.keys(programmeData).reduce(
|
||||
(differenceMatrix, programme) => ({
|
||||
...differenceMatrix,
|
||||
[programme]: programmeData[programme].map((_yearlyValues, yearIndex) =>
|
||||
yearIndex < programmeData[programme].length - 1
|
||||
? programmeData[programme][yearIndex]
|
||||
.slice(sliceStart)
|
||||
.map((value, index) => value - programmeData[programme][yearIndex + 1][index + sliceStart])
|
||||
: new Array(programmeData[programme][yearIndex].length - sliceStart).fill(0)
|
||||
),
|
||||
}),
|
||||
{}
|
||||
)
|
||||
return differenceMatrix
|
||||
}
|
||||
|
||||
const handleClick = (sorterName, nameIndex) => {
|
||||
if (sorterName === sorter) setSortDir(-1 * sortDir)
|
||||
setSorter(sorterName)
|
||||
setSelectedIndex(nameIndex)
|
||||
}
|
||||
|
||||
const toggleVisibility = yearIndex => {
|
||||
const arrayToModify = [...visible]
|
||||
arrayToModify[yearIndex] = !visible[yearIndex]
|
||||
setVisible(arrayToModify)
|
||||
}
|
||||
|
||||
const sorterNames = shortNames || titles.map(title => (title === '' ? 'Code' : title))
|
||||
|
||||
const differenceToPrevYears = calculatDiffToPrevYear(dataProgrammeStats)
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Menu compact>
|
||||
<Menu.Item color="black" style={{ cursor: 'default', borderRadius: '1px', padding: '0 10px' }}>
|
||||
<Popup
|
||||
content="Sort bars in the yearly charts by programme code or other column values. Sort is done inside the degree group."
|
||||
trigger={
|
||||
<div>
|
||||
<Icon name="question circle" /> Sort by
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Menu.Item>
|
||||
{sorterNames.map((sorterName, nameIndex) => (
|
||||
<Menu.Item
|
||||
active={sorter === sorterName}
|
||||
color={sorter === sorterName ? 'blue' : 'black'}
|
||||
content={sorterName}
|
||||
data-cy={`Menu-${cypress}-${sorterName}`}
|
||||
icon={sortDir === -1 && sorter === sorterName ? 'triangle up' : 'triangle down'}
|
||||
key={sorterName}
|
||||
onClick={() => handleClick(sorterName, nameIndex)}
|
||||
style={{ borderRadius: '1px', fontSize: '14px', padding: '0 10px' }}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
<Table celled data-cy={`Table-${cypress}`}>
|
||||
<Table.Header>
|
||||
<Table.Row textAlign="center">
|
||||
{titles?.map(title => (
|
||||
<Table.HeaderCell key={title}>{title}</Table.HeaderCell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{dataStats?.map((yearArray, yearIndex) => (
|
||||
<Fragment key={yearArray[0]}>
|
||||
<ExpandableRow
|
||||
cypress={visible[yearIndex] ? `Hide-${cypress}` : `Show-${cypress}`}
|
||||
icon={visible[yearIndex] ? 'angle down' : 'angle right'}
|
||||
toggleVisibility={() => toggleVisibility(yearIndex)}
|
||||
yearArray={yearArray}
|
||||
yearIndex={yearIndex}
|
||||
/>
|
||||
<Table.Row style={{ display: visible[yearIndex] ? '' : 'none' }}>
|
||||
<Table.Cell colSpan={100} data-cy={`Cell-${cypress}-${yearIndex}`}>
|
||||
<CollapsedStackedBar
|
||||
data={
|
||||
columnIndex === 0
|
||||
? sortedKeys
|
||||
?.map(programme =>
|
||||
dataProgrammeStats[programme]
|
||||
? dataProgrammeStats[programme][yearIndex].slice(sliceStart)
|
||||
: null
|
||||
)
|
||||
.filter(programme => !!programme)
|
||||
: keyOrder[yearIndex]
|
||||
?.map(programme =>
|
||||
dataProgrammeStats[programme]
|
||||
? dataProgrammeStats[programme][yearIndex].slice(sliceStart)
|
||||
: null
|
||||
)
|
||||
.filter(programme => !!programme)
|
||||
}
|
||||
differenceData={Object.keys(differenceToPrevYears)?.reduce(
|
||||
(yearlyObject, programme) => ({
|
||||
...yearlyObject,
|
||||
[programme]: differenceToPrevYears[programme][yearIndex],
|
||||
}),
|
||||
{}
|
||||
)}
|
||||
labels={columnIndex === 0 ? sortedKeys : keyOrder[yearIndex]}
|
||||
longLabels={programmeNames}
|
||||
names={titles?.slice(sliceStart)}
|
||||
plotLinePlaces={plotLinePlaces}
|
||||
studentsTable={studentsTable}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
</Fragment>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Divider, Loader, Popup } from 'semantic-ui-react'
|
||||
import { utils, writeFile } from 'xlsx'
|
||||
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import '@/components/FacultyStatistics/faculty.css'
|
||||
import { InfoBox } from '@/components/InfoBox'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { Toggle } from '@/components/StudyProgramme/Toggle'
|
||||
import { useGetFacultyGraduationTimesQuery } from '@/redux/facultyStats'
|
||||
import { getTimestamp } from '@/util/timeAndDate'
|
||||
import { GraduationTimes } from './GraduationTimes'
|
||||
|
||||
export const TimesAndPathsView = ({ faculty, setStudyProgrammes, studyProgrammes }) => {
|
||||
const [showMedian, setShowMedian] = useState(false)
|
||||
const [groupByStartYear, setGroupByStartYear] = useState(false)
|
||||
const studyProgrammeFilter = studyProgrammes ? 'ALL_PROGRAMMES' : 'NEW_STUDY_PROGRAMMES'
|
||||
const graduationStats = useGetFacultyGraduationTimesQuery({ id: faculty?.id, studyProgrammeFilter })
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const groupBy = groupByStartYear ? 'byStartYear' : 'byGradYear'
|
||||
const yearLabel = groupByStartYear ? 'Start year' : 'Graduation year'
|
||||
const data = graduationStats?.data?.[groupBy].medians
|
||||
const goals = graduationStats?.data?.goals
|
||||
const goalExceptions = { ...goals?.exceptions, needed: faculty?.code === 'H30' }
|
||||
const programmeData = graduationStats?.data?.[groupBy].programmes.medians
|
||||
const programmeNames = graduationStats?.data?.programmeNames
|
||||
const classSizes = graduationStats?.data?.classSizes
|
||||
const commonProps = { yearLabel, programmeNames, showMedian, classSizes, goalExceptions, groupBy, mode: 'programme' }
|
||||
|
||||
const isFetchingOrLoading = graduationStats.isLoading || graduationStats.isFetching
|
||||
|
||||
const isError = graduationStats.isError || (graduationStats.isSuccess && !graduationStats.data)
|
||||
|
||||
if (isError) {
|
||||
return <h3>Something went wrong, please try refreshing the page.</h3>
|
||||
}
|
||||
|
||||
const exportToExcel = (graduationStats, programmeNames) => {
|
||||
const educationLevels = ['bachelor', 'bcMsCombo', 'master', 'doctor', 'licentiate']
|
||||
const sheetTitles = {
|
||||
bachelor: 'Bachelor',
|
||||
bcMsCombo: 'Bachelor + Master',
|
||||
master: 'Master',
|
||||
doctor: 'Doctor',
|
||||
licentiate: 'Licentiate',
|
||||
}
|
||||
|
||||
const book = utils.book_new()
|
||||
|
||||
educationLevels.forEach(level => {
|
||||
const levelData = graduationStats.data[groupBy]?.programmes?.medians[level]
|
||||
if (!levelData) {
|
||||
return
|
||||
}
|
||||
|
||||
const sheetData = []
|
||||
|
||||
Object.keys(levelData).forEach(year => {
|
||||
const yearData = levelData[year]
|
||||
const { programmes } = yearData
|
||||
const { data } = yearData
|
||||
|
||||
data.forEach((item, index) => {
|
||||
sheetData.push({
|
||||
Code: programmes[index],
|
||||
Abbreviation: item.name,
|
||||
Name: getTextIn(programmeNames[programmes[index]]),
|
||||
[yearLabel]: year,
|
||||
'On time': item.statistics.onTime || 0,
|
||||
'Max. year overtime': item.statistics.yearOver || 0,
|
||||
Overtime: item.statistics.wayOver || 0,
|
||||
'Median study time (months)': item.median || 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const sheet = utils.json_to_sheet(sheetData)
|
||||
utils.book_append_sheet(book, sheet, sheetTitles[level])
|
||||
})
|
||||
|
||||
writeFile(book, `oodikone_${faculty.code}_graduation_times_${getTimestamp()}.xlsx`)
|
||||
}
|
||||
|
||||
const getContent = () => {
|
||||
if (isFetchingOrLoading) {
|
||||
return <Loader active style={{ marginTop: '15em' }} />
|
||||
}
|
||||
|
||||
if (!(graduationStats.isSuccess && graduationStats.data)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="divider">
|
||||
<Divider data-cy="Section-AverageGraduationTimes" horizontal>
|
||||
Average graduation times by education level
|
||||
</Divider>
|
||||
</div>
|
||||
<div style={{ marginBottom: '3em' }}>
|
||||
<InfoBox content={facultyToolTips.averageGraduationTimes} />
|
||||
</div>
|
||||
<Popup
|
||||
content="Download statistics as xlsx file"
|
||||
trigger={
|
||||
<Button
|
||||
data-cy="DownloadButton-AverageGraduationTimes"
|
||||
floated="right"
|
||||
icon="download"
|
||||
onClick={() => exportToExcel(graduationStats, programmeNames)}
|
||||
style={{ backgroundColor: 'white', borderRadius: 0 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="toggle-container">
|
||||
<Toggle
|
||||
cypress="GraduationTimeToggle"
|
||||
firstLabel="Breakdown"
|
||||
secondLabel="Median study times"
|
||||
setValue={setShowMedian}
|
||||
value={showMedian}
|
||||
/>
|
||||
<Toggle
|
||||
cypress="GroupByToggle"
|
||||
firstLabel="Group by: Graduation year"
|
||||
secondLabel="Starting year"
|
||||
setValue={setGroupByStartYear}
|
||||
value={groupByStartYear}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<GraduationTimes
|
||||
data={data?.bachelor}
|
||||
goal={goals?.bachelor}
|
||||
level="bachelor"
|
||||
levelProgrammeData={programmeData?.bachelor}
|
||||
title="Bachelor"
|
||||
{...commonProps}
|
||||
/>
|
||||
<GraduationTimes
|
||||
data={data?.bcMsCombo}
|
||||
goal={goals?.bcMsCombo}
|
||||
level="bcMsCombo"
|
||||
levelProgrammeData={programmeData?.bcMsCombo}
|
||||
title="Bachelor + Master"
|
||||
{...commonProps}
|
||||
/>
|
||||
<GraduationTimes
|
||||
data={data?.master}
|
||||
goal={goals?.master}
|
||||
level="master"
|
||||
levelProgrammeData={programmeData?.master}
|
||||
title="Master"
|
||||
{...commonProps}
|
||||
/>
|
||||
<GraduationTimes
|
||||
data={data?.doctor}
|
||||
goal={goals?.doctor}
|
||||
level="doctor"
|
||||
levelProgrammeData={programmeData?.doctor}
|
||||
title="Doctor"
|
||||
{...commonProps}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="programmes-overview">
|
||||
<div className="toggle-container" style={{ marginTop: '30px' }}>
|
||||
<Toggle
|
||||
cypress="ProgrammeToggle"
|
||||
firstLabel="New study programmes"
|
||||
secondLabel="All study programmes"
|
||||
setValue={setStudyProgrammes}
|
||||
toolTips={facultyToolTips.programmeToggle}
|
||||
value={studyProgrammes}
|
||||
/>
|
||||
</div>
|
||||
{getContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'semantic-ui-react'
|
||||
|
||||
import { UpdateStatusIcon } from '@/components/common/UpdateStatusIcon'
|
||||
import { useUpdateFacultyBasicViewQuery, useUpdateFacultyProgressViewQuery } from '@/redux/facultyStats'
|
||||
|
||||
export const UpdateView = ({ faculty }) => {
|
||||
const [skipBasic, setSkipBasic] = useState(true)
|
||||
const [skipCredits, setSkipCredits] = useState(true)
|
||||
const [skipThesis, setSkipThesis] = useState(true)
|
||||
const [skipProgressTab, setSkipProgressTab] = useState(true)
|
||||
|
||||
const basicBasicTabStats = useUpdateFacultyBasicViewQuery({ id: faculty, statsType: 'STUDENT' }, { skip: skipBasic })
|
||||
const creditsBasicTabStats = useUpdateFacultyBasicViewQuery(
|
||||
{ id: faculty, statsType: 'CREDITS' },
|
||||
{ skip: skipCredits }
|
||||
)
|
||||
const thesisBasicTabStats = useUpdateFacultyBasicViewQuery({ id: faculty, statsType: 'THESIS' }, { skip: skipThesis })
|
||||
const progressViewStats = useUpdateFacultyProgressViewQuery({ id: faculty }, { skip: skipProgressTab })
|
||||
|
||||
return (
|
||||
<div className="update-view">
|
||||
<div className="button-container">
|
||||
<h3>Update data in Basic information tab</h3>
|
||||
<p>
|
||||
<i>(For large faculties updates can take a couple of minutes)</i>
|
||||
</p>
|
||||
<h4>Update students data on Basic information view</h4>
|
||||
<Button color="blue" disabled={basicBasicTabStats.isLoading} onClick={() => setSkipBasic(false)}>
|
||||
Update
|
||||
</Button>
|
||||
<UpdateStatusIcon stats={basicBasicTabStats} />
|
||||
<h4>Update credits data on Basic information view</h4>
|
||||
<Button color="blue" disabled={creditsBasicTabStats.isLoading} onClick={() => setSkipCredits(false)}>
|
||||
Update
|
||||
</Button>
|
||||
<UpdateStatusIcon stats={creditsBasicTabStats} />
|
||||
<h4>Update thesis writers data on Basic information view</h4>
|
||||
<Button color="blue" disabled={thesisBasicTabStats.isLoading} onClick={() => setSkipThesis(false)}>
|
||||
Update
|
||||
</Button>
|
||||
<UpdateStatusIcon stats={thesisBasicTabStats} />
|
||||
<h3>Update data in Progress and student populations tab</h3>
|
||||
<Button color="blue" disabled={progressViewStats.isLoading} onClick={() => setSkipProgressTab(false)}>
|
||||
Update
|
||||
</Button>
|
||||
<UpdateStatusIcon stats={progressViewStats} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
.programmes-overview .toggle-container,
|
||||
.faculty-overview .toggle-container,
|
||||
.faculty-courses .toggle-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.faculty-overview .radio-toggle,
|
||||
.faculty-courses .radio-toggle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 20px 35px;
|
||||
}
|
||||
|
||||
.programmes-overview .radio-toggle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0 35px 70px;
|
||||
}
|
||||
|
||||
.programmes-overview .toggle-label,
|
||||
.faculty-overview .toggle-label,
|
||||
.faculty-courses .toggle-label {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
.programmes-overview .toggle-label-checked,
|
||||
.faculty-overview .toggle-label-checked,
|
||||
.faculty-courses .toggle-label-checked {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
opacity: 0.5;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
.programmes-overview .ui.toggle.checkbox input:checked ~ label::before,
|
||||
.faculty-courses .ui.toggle.checkbox input:checked ~ label::before,
|
||||
.programmes-overview .ui.toggle.checkbox input:focus:checked ~ label::before,
|
||||
.faculty-courses .ui.toggle.checkbox input:focus:checked ~ label::before,
|
||||
.faculty-overview .ui.toggle.checkbox:not(.original-toggle-container .ui.toggle.checkbox) input:checked ~ label::before,
|
||||
.faculty-overview
|
||||
.ui.toggle.checkbox:not(.original-toggle-container .ui.toggle.checkbox)
|
||||
input:focus:checked
|
||||
~ label::before {
|
||||
background-color: rgb(0 0 0 / 5%) !important;
|
||||
}
|
||||
|
||||
.original-toggle-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.faculty-overview .divider {
|
||||
margin-bottom: 1em;
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
.faculty-overview .ok-infobox-collapsed {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.total-row-cell {
|
||||
border-top: 3px solid rgb(189 180 180);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.programmes-overview .programmes-selector {
|
||||
margin: 60px 60px 30px;
|
||||
}
|
||||
|
||||
.programmes-overview .header-row {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.programmes-overview .datatable {
|
||||
margin: 2em 0 5em;
|
||||
}
|
||||
|
||||
.programmes-overview .datatable th,
|
||||
.faculty-overview .datatable th {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.programmes-overview .no-data-message {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.update-view {
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
.update-view .button-container {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.graduations-message {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.graduations-chart-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.facultyLinkBox {
|
||||
display: grid;
|
||||
gap: 0.5rem 1rem;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useHistory, useParams } from 'react-router-dom'
|
||||
import { Header, Loader, Segment, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { useTabs, useTitle } from '@/common/hooks'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { useGetFacultiesQuery } from '@/redux/facultyStats'
|
||||
import { BasicOverview } from './BasicOverview'
|
||||
import { FacultyProgrammeOverview } from './FacultyProgrammeOverview'
|
||||
import { FacultySelector } from './FacultySelector'
|
||||
import { TimesAndPathsView } from './TimesAndPaths'
|
||||
import { UpdateView } from './UpdateView'
|
||||
|
||||
export const FacultyStatistics = () => {
|
||||
useTitle('Faculties')
|
||||
const history = useHistory()
|
||||
const { facultyId } = useParams()
|
||||
|
||||
const { getTextIn } = useLanguage()
|
||||
const { data: faculties = [], isLoading } = useGetFacultiesQuery()
|
||||
const faculty = faculties.length > 0 && facultyId && faculties.find(faculty => faculty.id === facultyId)
|
||||
const facultyCode = faculty && faculty.code
|
||||
const facultyName = faculty && getTextIn(faculty.name)
|
||||
|
||||
const { isAdmin, fullAccessToStudentData, programmeRights } = useGetAuthorizedUserQuery()
|
||||
const [tab, setTab] = useTabs('p_tab', 0, history)
|
||||
const [academicYear, setAcademicYear] = useState(false)
|
||||
const [studyProgrammes, setStudyProgrammes] = useState(false)
|
||||
const [specialGroups, setSpecialGroups] = useState(false)
|
||||
const [graduatedGroup, setGraduatedGroup] = useState(false)
|
||||
const requiredRights = { fullAccessToStudentData, programmeRights }
|
||||
|
||||
const handleSelect = useCallback(
|
||||
faculty => {
|
||||
history.push(`/faculties/${faculty}`, { selected: faculty })
|
||||
},
|
||||
[history]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader active style={{ marginTop: '10em' }} />
|
||||
}
|
||||
|
||||
const getPanes = () => {
|
||||
const panes = [
|
||||
{
|
||||
menuItem: 'Basic information',
|
||||
render: () => (
|
||||
<BasicOverview
|
||||
academicYear={academicYear}
|
||||
faculty={faculty}
|
||||
setAcademicYear={setAcademicYear}
|
||||
setSpecialGroups={setSpecialGroups}
|
||||
setStudyProgrammes={setStudyProgrammes}
|
||||
specialGroups={specialGroups}
|
||||
studyProgrammes={studyProgrammes}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'Programmes and student populations',
|
||||
render: () => (
|
||||
<FacultyProgrammeOverview
|
||||
faculty={faculty}
|
||||
graduatedGroup={graduatedGroup}
|
||||
requiredRights={requiredRights}
|
||||
setGraduatedGroup={setGraduatedGroup}
|
||||
setSpecialGroups={setSpecialGroups}
|
||||
specialGroups={specialGroups}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'Graduation times',
|
||||
render: () => (
|
||||
<TimesAndPathsView
|
||||
faculty={faculty}
|
||||
setStudyProgrammes={setStudyProgrammes}
|
||||
studyProgrammes={studyProgrammes}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (isAdmin) {
|
||||
panes.push({
|
||||
menuItem: 'Update statistics',
|
||||
render: () => <UpdateView faculty={faculty?.id} />,
|
||||
})
|
||||
}
|
||||
|
||||
return panes
|
||||
}
|
||||
|
||||
const panes = getPanes()
|
||||
|
||||
const sortedFaculties = faculties.toSorted((a, b) => a.code.localeCompare(b.code))
|
||||
|
||||
if (!facultyCode) {
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Header className="segmentTitle" size="large">
|
||||
Faculties
|
||||
</Header>
|
||||
<Segment className="contentSegment">
|
||||
<FacultySelector
|
||||
faculties={sortedFaculties}
|
||||
handleSelect={handleSelect}
|
||||
selected={facultyCode !== undefined}
|
||||
/>
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="segmentContainer" data-cy="FacultySegmentContainer">
|
||||
<Segment className="contentSegment">
|
||||
<div align="center" style={{ padding: '30px' }}>
|
||||
<Header textAlign="center">{facultyName}</Header>
|
||||
<span>{facultyCode}</span>
|
||||
</div>
|
||||
<Tab activeIndex={tab} onTabChange={setTab} panes={panes} />
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { utils, writeFile } from 'xlsx'
|
||||
|
||||
import { getTimestamp } from '@/util/timeAndDate'
|
||||
import { sortProgrammeKeys } from './facultyHelpers'
|
||||
|
||||
export const downloadStudentTable = (studentStats, programmeNames, faculty, sortedkeys, getTextIn) => {
|
||||
const book = utils.book_new()
|
||||
const tableHeaders = studentStats.data.titles.slice(1)
|
||||
const countriesExtra = studentStats.data.facultyTableStatsExtra
|
||||
const years = Object.keys(studentStats.data.facultyTableStats).sort((a, b) => {
|
||||
if (a === 'Total') return 1
|
||||
if (b === 'Total') return -1
|
||||
|
||||
const yearA = parseInt(a.split(' - ')[0], 10)
|
||||
const yearB = parseInt(b.split(' - ')[0], 10)
|
||||
|
||||
return yearB - yearA
|
||||
})
|
||||
|
||||
const processTableData = (data, tableHeaders) => {
|
||||
let counter = 0
|
||||
return data.map(row =>
|
||||
row.reduce((result, value) => {
|
||||
let header
|
||||
if (typeof value !== 'string') {
|
||||
header = tableHeaders[counter].replace('\n', ' ')
|
||||
counter += 1
|
||||
} else {
|
||||
header = counter === 0 ? '' : `${tableHeaders[counter - 1].replace('\n', ' ')} %`
|
||||
if (counter >= tableHeaders.length) counter = 0
|
||||
}
|
||||
return { ...result, [header]: value }
|
||||
}, {})
|
||||
)
|
||||
}
|
||||
|
||||
const tableStats = processTableData(
|
||||
years.map(year => studentStats.data.facultyTableStats[year]),
|
||||
tableHeaders
|
||||
)
|
||||
const tableSheet = utils.json_to_sheet(tableStats)
|
||||
utils.book_append_sheet(book, tableSheet, 'TotalTableStats')
|
||||
|
||||
const programmeStats = studentStats?.data?.programmeStats || {}
|
||||
const progressStats = sortedkeys.reduce(
|
||||
(results, programme) => [
|
||||
...results,
|
||||
...Object.keys(programmeStats[programme]).map((yearRow, yearIndex) => {
|
||||
return {
|
||||
'Academic Year': years[yearIndex],
|
||||
Programme: programme,
|
||||
Name: getTextIn(programmeNames[programme]),
|
||||
...processTableData([programmeStats[programme][yearRow]], tableHeaders)[0],
|
||||
}
|
||||
}),
|
||||
],
|
||||
[]
|
||||
)
|
||||
const sheet = utils.json_to_sheet(progressStats)
|
||||
utils.book_append_sheet(book, sheet, 'FacultyProgrammeStats')
|
||||
|
||||
const countriesHeadersForEachYear = years.reduce(
|
||||
(result, year) => ({
|
||||
...result,
|
||||
[year]: [
|
||||
...new Set(
|
||||
Object.keys(countriesExtra[year])
|
||||
.reduce((acc, programme) => [...acc, ...Object.keys(countriesExtra[year][programme])], [])
|
||||
.sort()
|
||||
),
|
||||
],
|
||||
}),
|
||||
{}
|
||||
)
|
||||
|
||||
const programmeNamesByCode = Object.keys(programmeNames).reduce(
|
||||
(res, data) => ({
|
||||
...res,
|
||||
[programmeNames[data].code]: {
|
||||
fi: programmeNames[data].fi,
|
||||
en: programmeNames[data].en,
|
||||
sv: programmeNames[data].sv,
|
||||
},
|
||||
}),
|
||||
{}
|
||||
)
|
||||
|
||||
years.forEach(year => {
|
||||
const countriesStats = Object.keys(countriesExtra[year]).reduce(
|
||||
(result, programme) => [
|
||||
...result,
|
||||
{
|
||||
'Academic Year': year,
|
||||
Programme: programme,
|
||||
Name: getTextIn(programmeNamesByCode[programme]),
|
||||
...countriesHeadersForEachYear[year].reduce(
|
||||
(stats, country) => ({
|
||||
...stats,
|
||||
[country]: countriesExtra[year][programme][country] || '',
|
||||
}),
|
||||
{}
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
const countriesSheet = utils.json_to_sheet(countriesStats)
|
||||
utils.book_append_sheet(book, countriesSheet, `CountriesStats-${year}`)
|
||||
})
|
||||
|
||||
writeFile(book, `oodikone_${faculty.code}_programme_stats_${getTimestamp()}.xlsx`)
|
||||
}
|
||||
|
||||
export const downloadProgressTable = (progressStats, programmeNames, faculty, getTextIn) => {
|
||||
const bachelorStats = progressStats?.bachelorStats
|
||||
const bachelorMasterStats = progressStats?.bachelorMasterStats
|
||||
const masterStats = progressStats?.masterStats
|
||||
const doctorStats = progressStats?.doctorStats
|
||||
|
||||
const book = utils.book_new()
|
||||
|
||||
const progressStatsBachelor = bachelorStats.tableStats || []
|
||||
const progressStatsBachelorMaster = bachelorMasterStats.tableStats || []
|
||||
const progressStatsMaster = masterStats.tableStats || []
|
||||
const progressStatsDoctor = doctorStats.tableStats || []
|
||||
|
||||
const tableData = [progressStatsBachelor, progressStatsMaster, progressStatsBachelorMaster, progressStatsDoctor]
|
||||
const tableHeadersData = [
|
||||
bachelorStats.tableTitles,
|
||||
masterStats.tableTitles,
|
||||
bachelorMasterStats.tableTitles,
|
||||
doctorStats.tableTitles,
|
||||
]
|
||||
|
||||
const sheetNames = ['Bachelor', 'Master', 'Bachelor-Master', 'Doctor']
|
||||
let counter = -1
|
||||
tableData.forEach(tableStats => {
|
||||
counter += 1
|
||||
if (tableStats.length > 0) {
|
||||
const tableStatsAsXlsx = tableStats.map(yearArray =>
|
||||
yearArray.reduce(
|
||||
(result, value, yearIndex) => ({ ...result, [tableHeadersData[counter][yearIndex]]: value }),
|
||||
{}
|
||||
)
|
||||
)
|
||||
const tableSheet = utils.json_to_sheet(tableStatsAsXlsx)
|
||||
utils.book_append_sheet(book, tableSheet, `TableStats-${sheetNames[counter]}`)
|
||||
}
|
||||
})
|
||||
|
||||
const progressStatsBachelorProg = progressStats?.data?.bachelorsProgStats || {}
|
||||
const progressStatsBcMsProg = progressStats?.data?.bcMsProgStats || {}
|
||||
const progressStatsMastersProg = progressStats?.data?.mastersProgStats || {}
|
||||
const progressStatsDoctorProg = progressStats?.data?.doctoralProgStats || {}
|
||||
const programmeData = [
|
||||
progressStatsBachelorProg,
|
||||
progressStatsMastersProg,
|
||||
progressStatsBcMsProg,
|
||||
progressStatsDoctorProg,
|
||||
]
|
||||
const tableHeadersDataProg = [
|
||||
progressStats?.data?.yearlyBachelorTitles,
|
||||
progressStats?.data?.yearlyMasterTitles,
|
||||
progressStats?.data?.yearlyBcMsTitles,
|
||||
doctorStats.tableTitles,
|
||||
]
|
||||
counter = -1
|
||||
const years = progressStatsBachelor.map(row => row[0])
|
||||
|
||||
programmeData.forEach(programmes => {
|
||||
counter += 1
|
||||
if (programmes) {
|
||||
const progressStats = sortProgrammeKeys(Object.keys(programmes), faculty.code).reduce(
|
||||
(results, programme) => [
|
||||
...results,
|
||||
...programmes[programme].map((yearRow, yearIndex) => {
|
||||
const yearlyHeaders =
|
||||
tableHeadersDataProg[counter][0] !== ''
|
||||
? tableHeadersDataProg[counter][yearIndex]
|
||||
: tableHeadersDataProg[counter].slice(1)
|
||||
return {
|
||||
'Academic Year': years[yearIndex],
|
||||
Programme: programme,
|
||||
Name: getTextIn(programmeNames[programme]),
|
||||
...yearRow.reduce(
|
||||
(result, value, valueIndex) => ({
|
||||
...result,
|
||||
[yearlyHeaders[valueIndex]]: value,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
}
|
||||
}),
|
||||
],
|
||||
[]
|
||||
)
|
||||
const sheet = utils.json_to_sheet(progressStats)
|
||||
utils.book_append_sheet(book, sheet, sheetNames[counter])
|
||||
}
|
||||
})
|
||||
writeFile(book, `oodikone_${faculty.code}_progress_tab_${getTimestamp()}.xlsx`)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import { useDebounce } from '@/common/hooks'
|
||||
import { RangeSelector } from '@/components/common/RangeSelector'
|
||||
import { useDebounce } from '@/hooks/debounce'
|
||||
import { getAge } from '@/util/timeAndDate'
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user