mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -238,7 +238,7 @@ 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', () => {
|
||||
@@ -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()
|
||||
|
||||
|
||||
+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')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-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',
|
||||
|
||||
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
-703
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,14 @@
|
||||
"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",
|
||||
@@ -36,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 '../shared/types/api/faculty'
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
// Faculty uses a lot of tools designed for Study programme.
|
||||
|
||||
@@ -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 '../shared/types/api/faculty'
|
||||
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 '../shared/types/api/faculty'
|
||||
import { Graduated, NameWithCode } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
|
||||
import { Graduated, SpecialGroups, YearType } from '../shared/types/api/faculty'
|
||||
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,7 +1,6 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { Name } from '../../shared/types'
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, YearType } from '../../shared/types/api/faculty'
|
||||
import { Graduated, Name, ProgrammeFilter, SpecialGroups, YearType } from '../../shared/types'
|
||||
import { DegreeProgrammeType } from '../../types'
|
||||
import { redisClient } from '../redis'
|
||||
import { FacultyProgressData } from './facultyStudentProgress'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cloneDeep } from 'lodash'
|
||||
import moment from 'moment'
|
||||
|
||||
import { rootOrgId } from '../../config'
|
||||
import { Graduated, SpecialGroups } from '../../shared/types/api/faculty'
|
||||
import { Graduated, SpecialGroups } from '../../shared/types'
|
||||
import { Unarray } from '../../types'
|
||||
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
|
||||
import { getYearsArray } from '../studyProgramme/studyProgrammeHelpers'
|
||||
|
||||
@@ -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 '../../shared/types/api/faculty'
|
||||
import { StatsType } from '../../shared/types'
|
||||
import { setCreditStats } from '../analyticsService'
|
||||
import { computeCreditsProduced } from '../providerCredits'
|
||||
import { getDegreeProgrammesOfFaculty } from './faculty'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
`,
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export const useTabChangeAnalytics = () => {
|
||||
return { handleTabChange }
|
||||
}
|
||||
|
||||
// Use @/hooks/tabs for MUI components instead
|
||||
export const useTabs = (id, initialTab, { location, replace }) => {
|
||||
const [tab, setTab] = useState(-1)
|
||||
const [didMount, setDidMount] = useState(false)
|
||||
@@ -179,6 +180,9 @@ export const useProgress = loading => {
|
||||
export const useTitle = title => {
|
||||
useEffect(() => {
|
||||
document.title = title ? `${title} - Oodikone` : 'Oodikone'
|
||||
return () => {
|
||||
document.title = 'Oodikone'
|
||||
}
|
||||
}, [title])
|
||||
}
|
||||
|
||||
|
||||
@@ -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(' ')
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ThemeProvider } from '@mui/material/styles'
|
||||
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import HighCharts from 'highcharts'
|
||||
import moment from 'moment'
|
||||
import { useEffect } from 'react'
|
||||
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
|
||||
@@ -16,6 +17,15 @@ 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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
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'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import qs from 'query-string'
|
||||
import { useCallback } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useHistory, useLocation } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import { Segment, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { useProgress, useTabs } from '@/common/hooks'
|
||||
@@ -10,9 +11,10 @@ import { StudentsPane } from './panes/StudentsPane'
|
||||
import './resultTabs.css'
|
||||
|
||||
export const ResultTabs = ({ primary, comparison, separate, availableStats }) => {
|
||||
const history = useHistory()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [tab, setTab] = useTabs('cs_tab', 0, history)
|
||||
const replace = useCallback(options => navigate(options, { replace: true }), [navigate])
|
||||
const [tab, setTab] = useTabs('cs_tab', 0, { location, replace })
|
||||
const { userHasAccessToAllStats } = primary
|
||||
const courseStats = useSelector(({ courseStats }) => courseStats)
|
||||
const { pending: loading } = courseStats
|
||||
@@ -24,6 +26,11 @@ export const ResultTabs = ({ primary, comparison, separate, availableStats }) =>
|
||||
}
|
||||
|
||||
const updateSeparate = separate => {
|
||||
// Only proceed if we're still on the coursestatistics page
|
||||
if (!location.pathname.includes('coursestatistics')) {
|
||||
return
|
||||
}
|
||||
|
||||
const { courseCodes, ...params } = qs.parse(location.search)
|
||||
const query = {
|
||||
...params,
|
||||
@@ -32,7 +39,7 @@ export const ResultTabs = ({ primary, comparison, separate, availableStats }) =>
|
||||
}
|
||||
dispatch(getCourseStats(query, onProgress))
|
||||
const queryToString = { ...query, courseCodes: JSON.stringify(query.courseCodes) }
|
||||
history.replace({ search: qs.stringify(queryToString) })
|
||||
navigate({ search: qs.stringify(queryToString) }, { replace: true })
|
||||
}
|
||||
|
||||
const paneTypes = [
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { mapValues, uniq } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from 'react-router'
|
||||
import { Header, Icon, Item } from 'semantic-ui-react'
|
||||
|
||||
import {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { cloneDeep, uniq } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from 'react-router'
|
||||
import { Header, Icon, Item } from 'semantic-ui-react'
|
||||
|
||||
import { isDefaultServiceProvider } from '@/common'
|
||||
|
||||
-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,26 +1,18 @@
|
||||
import { sortBy } from 'lodash'
|
||||
import { omit, 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 { useNavigate, useLocation } from 'react-router'
|
||||
import { Form, Header, Input, 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 { useDebouncedState } from '@/hooks/useDebouncedState'
|
||||
import { useGetCourseSearchResultQuery } 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)
|
||||
@@ -37,42 +29,55 @@ export const SearchForm = ({ onProgress }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const dispatch = useDispatch()
|
||||
const location = useLocation()
|
||||
const history = useHistory()
|
||||
const isLoading = useSelector(state => state.courseStats.pending)
|
||||
const navigate = useNavigate()
|
||||
const isLoadingCourseStats = 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 [courseName, setCourseName] = useState('')
|
||||
const [courseCode, setCourseCode] = useState('')
|
||||
const [debouncedCourseName, setDebouncedCourseName] = useDebouncedState(courseName)
|
||||
const [debouncedCourseCode, setDebouncedCourseCode] = useDebouncedState(courseCode)
|
||||
const [selectedCourses, setSelectedCourses] = useState({})
|
||||
|
||||
const isInputValid = validateInputLength(courseName, 5) || validateInputLength(courseCode, 2)
|
||||
const isDebouncedInputValid =
|
||||
validateInputLength(debouncedCourseName, 5) || validateInputLength(debouncedCourseCode, 2)
|
||||
const debouncedChanged = debouncedCourseName !== courseName || debouncedCourseCode !== courseCode
|
||||
|
||||
const [searchHistory, addItemToSearchHistory, updateItemInSearchHistory] = useSearchHistory('courseSearch', 6)
|
||||
const { data, isFetching } = useGetCourseSearchResultQuery(
|
||||
{ name: debouncedCourseName, code: debouncedCourseCode, combineSubstitutions },
|
||||
{ skip: !isDebouncedInputValid }
|
||||
)
|
||||
const matchingCourses = data?.courses ?? []
|
||||
|
||||
const handleCourseNameChange = event => {
|
||||
setCourseName(event.target.value)
|
||||
setDebouncedCourseName(event.target.value)
|
||||
}
|
||||
|
||||
const handleCourseCodeChange = event => {
|
||||
setCourseCode(event.target.value)
|
||||
setDebouncedCourseCode(event.target.value)
|
||||
}
|
||||
|
||||
const parseQueryFromUrl = () => {
|
||||
const {
|
||||
courseCodes,
|
||||
separate,
|
||||
unifyOpenUniCourses,
|
||||
combineSubstitutions: combineSubstitutionsFromUrl,
|
||||
...rest
|
||||
} = qs.parse(location.search)
|
||||
const search = qs.parse(location.search)
|
||||
const query = {
|
||||
...INITIAL,
|
||||
...rest,
|
||||
courseCodes: JSON.parse(courseCodes),
|
||||
separate: JSON.parse(separate),
|
||||
combineSubstitutions: combineSubstitutionsFromUrl ?? JSON.parse(combineSubstitutions),
|
||||
courseCodes: JSON.parse(search.courseCodes),
|
||||
separate: JSON.parse(search.separate),
|
||||
combineSubstitutions: search.combineSubstitutionsFrom ?? 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())
|
||||
}
|
||||
}, [])
|
||||
@@ -84,23 +89,15 @@ export const SearchForm = ({ onProgress }) => {
|
||||
}, [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,
|
||||
})
|
||||
setSelectedCourses(previousSelectedCourses => omit(previousSelectedCourses, course.code))
|
||||
} else {
|
||||
setState({
|
||||
...state,
|
||||
selectedCourses: {
|
||||
...selectedCourses,
|
||||
[course.code]: { ...course, selected: true },
|
||||
},
|
||||
})
|
||||
setSelectedCourses(previousSelectedCourses => ({
|
||||
...previousSelectedCourses,
|
||||
[course.code]: { ...course, selected: true },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +109,7 @@ export const SearchForm = ({ onProgress }) => {
|
||||
combineSubstitutions: JSON.stringify(combineSubstitutions),
|
||||
}
|
||||
const searchString = qs.stringify(queryObject)
|
||||
history.push({ search: searchString })
|
||||
navigate({ search: searchString })
|
||||
}
|
||||
|
||||
const onSearchHistorySelected = historyItem => {
|
||||
@@ -123,7 +120,7 @@ export const SearchForm = ({ onProgress }) => {
|
||||
const codes = sortBy(Object.keys(selectedCourses))
|
||||
const params = {
|
||||
courseCodes: codes,
|
||||
separate,
|
||||
separate: false,
|
||||
combineSubstitutions,
|
||||
}
|
||||
const searchHistoryText = codes.map(code => `${getTextIn(selectedCourses[code].name)} ${code}`)
|
||||
@@ -134,21 +131,8 @@ export const SearchForm = ({ onProgress }) => {
|
||||
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: {} })
|
||||
setSelectedCourses({})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -157,10 +141,6 @@ export const SearchForm = ({ onProgress }) => {
|
||||
}, [selectedCourses])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
await fetchCourses()
|
||||
}
|
||||
fetchData()
|
||||
clearSelectedCourses()
|
||||
}, [combineSubstitutions])
|
||||
|
||||
@@ -171,9 +151,9 @@ export const SearchForm = ({ onProgress }) => {
|
||||
|
||||
const courses = matchingCourses.filter(course => !selectedCourses[course.code])
|
||||
|
||||
const selected = Object.values(selectedCourses).map(course => ({ ...course, selected: true }))
|
||||
const selected = Object.values(selectedCourses)
|
||||
const noSelectedCourses = selected.length === 0
|
||||
const disabled = isLoading || noSelectedCourses || selected.length > MAX_SELECTED_COURSES
|
||||
const disabled = isLoadingCourseStats || noSelectedCourses || selected.length > MAX_SELECTED_COURSES
|
||||
|
||||
const renderFetchStatisticsButton = () => {
|
||||
const FetchStatisticsButton = (
|
||||
@@ -213,51 +193,44 @@ export const SearchForm = ({ onProgress }) => {
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
setSelectedCourses(previousSelectedCourses => ({
|
||||
...previousSelectedCourses,
|
||||
...newSelectedCourses,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Segment loading={isLoading}>
|
||||
<Segment loading={isLoadingCourseStats}>
|
||||
<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 })}
|
||||
<Input
|
||||
data-cy="course-name-input"
|
||||
fluid
|
||||
icon="search"
|
||||
onChange={handleCourseNameChange}
|
||||
placeholder="Search by course name"
|
||||
value={courseName}
|
||||
/>
|
||||
</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 })}
|
||||
<Input
|
||||
data-cy="course-code-input"
|
||||
fluid
|
||||
icon="search"
|
||||
onChange={handleCourseCodeChange}
|
||||
placeholder="Search by course code"
|
||||
value={courseCode}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
@@ -314,43 +287,57 @@ export const SearchForm = ({ onProgress }) => {
|
||||
<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>
|
||||
<b>Currently selected: {Object.keys(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"
|
||||
<Segment basic loading={(isFetching || debouncedChanged) && isInputValid} style={{ padding: 0 }}>
|
||||
{!isInputValid ? (
|
||||
<Message
|
||||
content="Please enter at least 5 characters for course name or 2 characters for course code."
|
||||
negative
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
<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={
|
||||
isLoadingCourseStats ||
|
||||
(Object.keys(courses).length === 0 && Object.keys(selectedCourses).length > 0)
|
||||
}
|
||||
onSelectCourse={onSelectCourse}
|
||||
title="Searched courses"
|
||||
/>
|
||||
{courses.length > 0 && selectMultipleCoursesEnabled && (
|
||||
<div className="select-all-container">
|
||||
<Form.Button
|
||||
basic
|
||||
color="green"
|
||||
content="Select all search results"
|
||||
onClick={addAllCourses}
|
||||
size="large"
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Segment>
|
||||
</div>
|
||||
</Form>
|
||||
</Segment>
|
||||
<SearchHistory
|
||||
disabled={isLoading}
|
||||
disabled={isLoadingCourseStats}
|
||||
handleSearch={onSearchHistorySelected}
|
||||
items={searchHistory.map(item => {
|
||||
item.timestamp = new Date(item.timestamp)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { difference, flatten, max, min, pickBy, uniq } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useHistory, useLocation } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import { Button, Form, Grid, Header, Popup, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { ProgrammeDropdown } from '@/components/CourseStatistics/ProgrammeDropdown'
|
||||
@@ -29,7 +29,7 @@ const countFilteredStudents = (stat, filter) => {
|
||||
}
|
||||
|
||||
export const SingleCourseStats = ({ stats, availableStats, userHasAccessToAllStats }) => {
|
||||
const history = useHistory()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dispatch = useDispatch()
|
||||
const { getTextIn } = useLanguage()
|
||||
@@ -380,7 +380,7 @@ export const SingleCourseStats = ({ stats, availableStats, userHasAccessToAllSta
|
||||
unifyCourses,
|
||||
}
|
||||
const searchString = qs.stringify(queryObject)
|
||||
history.push(`/coursepopulation?${searchString}`)
|
||||
navigate(`/coursepopulation?${searchString}`)
|
||||
}
|
||||
|
||||
const renderShowPopulation = (disabled = false) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import qs from 'query-string'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { Header, Message, Segment, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { checkUserAccess, getFullStudyProgrammeRights } from '@/common'
|
||||
@@ -24,7 +24,8 @@ const MENU = {
|
||||
}
|
||||
|
||||
export const CourseStatistics = () => {
|
||||
const history = useHistory()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dispatch = useDispatch()
|
||||
const { programmeRights, roles } = useGetAuthorizedUserQuery()
|
||||
const { pending: loading, data: courseStatsData } = useSelector(({ courseStats }) => courseStats)
|
||||
@@ -43,14 +44,14 @@ export const CourseStatistics = () => {
|
||||
}, [initCourseCode])
|
||||
|
||||
useEffect(() => {
|
||||
const { courseCodes, ...params } = qs.parse(history.location.search)
|
||||
const { courseCodes, ...params } = qs.parse(location.search)
|
||||
if (!courseCodes) return
|
||||
const query = {
|
||||
...params,
|
||||
courseCodes: JSON.parse(courseCodes),
|
||||
}
|
||||
dispatch(getCourseStats(query, onProgress))
|
||||
}, [history.location.search])
|
||||
}, [location.search])
|
||||
|
||||
useEffect(() => {
|
||||
if (statsIsEmpty) {
|
||||
@@ -104,7 +105,7 @@ export const CourseStatistics = () => {
|
||||
content: MENU.QUERY,
|
||||
icon: 'search',
|
||||
position: 'right',
|
||||
onClick: () => history.push('/coursestatistics'),
|
||||
onClick: () => navigate('/coursestatistics'),
|
||||
},
|
||||
render: () => null,
|
||||
},
|
||||
@@ -133,7 +134,7 @@ export const CourseStatistics = () => {
|
||||
const panes = getPanes()
|
||||
|
||||
const getContent = () => {
|
||||
if (statsIsEmpty || history.location.search === '') {
|
||||
if (statsIsEmpty || location.search === '') {
|
||||
return <SearchForm onProgress={onProgress} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { orderBy } from 'lodash'
|
||||
import { Form, Dropdown } from 'semantic-ui-react'
|
||||
|
||||
import { filterToolTips } from '@/common/InfoToolTips'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
@@ -8,22 +9,26 @@ const CitizenshipFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const { selected } = options
|
||||
|
||||
const dropdownOptions = withoutSelf().reduce((citizenships, student) => {
|
||||
const { home_country_en: homeCountryEn, home_country_fi: homeCountryFi, home_country_sv: homeCountrySv } = student
|
||||
const dropdownOptions = withoutSelf().reduce((options, student) => {
|
||||
for (const citizenship of student.citizenships) {
|
||||
const countryName = citizenship.en
|
||||
if (options.some(option => option.value === countryName)) {
|
||||
continue
|
||||
}
|
||||
const count = withoutSelf().filter(student =>
|
||||
student.citizenships.some(citizenship => citizenship.en === countryName)
|
||||
).length
|
||||
const country = getTextIn(citizenship)
|
||||
|
||||
if (citizenships.every(option => option.value !== homeCountryEn)) {
|
||||
const count = withoutSelf().filter(student => student.home_country_en === homeCountryEn).length
|
||||
const homeCountry = getTextIn({ fi: homeCountryFi, en: homeCountryEn, sv: homeCountrySv })
|
||||
|
||||
citizenships.push({
|
||||
key: homeCountryEn,
|
||||
value: homeCountryEn,
|
||||
text: `${homeCountry} (${count})`,
|
||||
options.push({
|
||||
key: countryName,
|
||||
value: countryName,
|
||||
text: `${country} (${count})`,
|
||||
count,
|
||||
})
|
||||
}
|
||||
|
||||
return citizenships
|
||||
return options
|
||||
}, [])
|
||||
|
||||
const sortedDropdownOptions = orderBy(dropdownOptions, ['count', 'text'], ['desc', 'asc'])
|
||||
@@ -56,10 +61,12 @@ export const citizenshipFilter = createFilter({
|
||||
selected: '',
|
||||
},
|
||||
|
||||
info: filterToolTips.citizenship,
|
||||
|
||||
isActive: ({ selected }) => selected !== '',
|
||||
|
||||
filter(student, { selected }) {
|
||||
return selected === student.home_country_en
|
||||
return student.citizenships.some(citizenship => citizenship.en === selected)
|
||||
},
|
||||
|
||||
component: CitizenshipFilterCard,
|
||||
|
||||
@@ -133,7 +133,6 @@ const getStudentProgrammes = student =>
|
||||
|
||||
const createStudentToProgrammeMap = (students, studyRightPredicate) => {
|
||||
const studentToProgrammeMap = {}
|
||||
const programmes = []
|
||||
|
||||
for (const student of students) {
|
||||
const studentProgrammes = getStudentProgrammes(student)
|
||||
@@ -146,19 +145,15 @@ const createStudentToProgrammeMap = (students, studyRightPredicate) => {
|
||||
}
|
||||
|
||||
studentToProgrammeMap[student.studentNumber] = filteredProgrammes.reduce((acc, { code, name }) => {
|
||||
if (acc.some(programme => programme.code === code)) {
|
||||
return acc
|
||||
}
|
||||
acc.push({ code, name })
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
for (const { code, name } of filteredProgrammes) {
|
||||
if (programmes.some(prog => prog.code === code)) {
|
||||
continue
|
||||
}
|
||||
programmes.push({ code, name })
|
||||
}
|
||||
}
|
||||
|
||||
return { programmes, studentToProgrammeMap }
|
||||
return studentToProgrammeMap
|
||||
}
|
||||
|
||||
const MODE_PREDICATES = {
|
||||
@@ -204,7 +199,7 @@ export const programmeFilter = createFilter({
|
||||
|
||||
isActive: ({ selectedProgrammes }) => selectedProgrammes.length > 0,
|
||||
|
||||
filter({ studentNumber }, { selectedProgrammes }, { precomputed: { studentToProgrammeMap } }) {
|
||||
filter({ studentNumber }, { selectedProgrammes }, { precomputed: studentToProgrammeMap }) {
|
||||
return selectedProgrammes.every(pcode => studentToProgrammeMap[studentNumber].some(({ code }) => code === pcode))
|
||||
},
|
||||
|
||||
@@ -224,11 +219,11 @@ export const programmeFilter = createFilter({
|
||||
},
|
||||
},
|
||||
|
||||
render: (props, { precomputed, args }) => (
|
||||
render: (props, { precomputed: studentToProgrammeMap, args }) => (
|
||||
<ProgrammeFilterCard
|
||||
{...props}
|
||||
additionalModes={args?.additionalModes ?? []}
|
||||
studentToProgrammeMap={precomputed.studentToProgrammeMap}
|
||||
studentToProgrammeMap={studentToProgrammeMap}
|
||||
/>
|
||||
),
|
||||
})
|
||||
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Divider, Message } from 'semantic-ui-react'
|
||||
|
||||
import '@/components/FacultyStatistics/faculty.css'
|
||||
import { BreakdownBarChart } from './BreakdownBarChart'
|
||||
import { MedianBarChart } from './MedianBarChart'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { chain, range } from 'lodash'
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from 'react-router'
|
||||
import { Icon, Item } from 'semantic-ui-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from 'react-router'
|
||||
import { Item, Icon } from 'semantic-ui-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { chain, range } from 'lodash'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from 'react-router'
|
||||
import { Item, Icon, Button } from 'semantic-ui-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
|
||||
+2
-3
@@ -6,7 +6,7 @@ import moment from 'moment'
|
||||
import qs from 'query-string'
|
||||
import { useState } from 'react'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router'
|
||||
import { Dropdown, Radio, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { generateGradientColors, getCreditCategories, getTargetCreditsForProgramme, TimeDivision } from '@/common'
|
||||
@@ -145,7 +145,7 @@ const getChartData = (students, timeSlots, programme, timeDivision, cumulative,
|
||||
export const CreditDistributionDevelopment = ({ students, programme, combinedProgramme, year }) => {
|
||||
const [cumulative, setCumulative] = useState(true)
|
||||
const [timeDivision, setTimeDivision] = useState(TimeDivision.SEMESTER)
|
||||
const [stackOrdering, setStackOrdering] = useState(StackOrdering.ASCENDING)
|
||||
const [stackOrdering, setStackOrdering] = useState(StackOrdering.DESCENDING)
|
||||
const location = useLocation()
|
||||
const { months } = qs.parse(location.search)
|
||||
const { data: semestersAndYears = {} } = useGetSemestersQuery()
|
||||
@@ -238,7 +238,6 @@ export const CreditDistributionDevelopment = ({ students, programme, combinedPro
|
||||
},
|
||||
series: {
|
||||
cursor: 'pointer',
|
||||
animation: false,
|
||||
point: {
|
||||
events: {
|
||||
click(event) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FormControl, MenuItem, Select } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Dropdown, Form, Input, Radio } from 'semantic-ui-react'
|
||||
import { Form, Input, Radio } from 'semantic-ui-react'
|
||||
|
||||
import { useGetCurriculumsQuery, useGetCurriculumOptionsQuery } from '@/redux/populationCourses'
|
||||
|
||||
@@ -45,23 +46,22 @@ export const CurriculumPicker = ({ setCurriculum, programmeCodes, disabled, year
|
||||
if (curriculums.length === 0) return null
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="link item"
|
||||
data-cy="curriculum-picker"
|
||||
disabled={disabled}
|
||||
onChange={(_, { value }) => setSelectedCurriculum(curriculums.find(curriculum => curriculum.id === value))}
|
||||
options={curriculums.map(curriculum => ({
|
||||
key: curriculum.curriculum_period_ids.toSorted().join(', '),
|
||||
value: curriculum.id,
|
||||
text: curriculum.curriculumName,
|
||||
}))}
|
||||
style={{
|
||||
background: '#e3e3e3',
|
||||
marginLeft: '10px',
|
||||
padding: '4px 4px 4px 8px',
|
||||
}}
|
||||
value={chosenCurriculum.id}
|
||||
/>
|
||||
<FormControl disabled={disabled} variant="standard">
|
||||
<Select
|
||||
MenuProps={{
|
||||
disablePortal: true,
|
||||
}}
|
||||
data-cy="curriculum-picker"
|
||||
onChange={event => setSelectedCurriculum(curriculums.find(({ id }) => id === event.target.value))}
|
||||
value={chosenCurriculum.id}
|
||||
>
|
||||
{curriculums.map(curriculum => (
|
||||
<MenuItem key={curriculum.id} value={curriculum.id}>
|
||||
{curriculum.curriculumName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export const PopulationDetails = ({
|
||||
{filteredStudents.length > 0 && (
|
||||
<CreditAccumulationGraphHighCharts
|
||||
programmeCodes={programmeCodes}
|
||||
showFullStudyPath={query?.showFullStudyPath}
|
||||
showFullStudyPath={query?.showFullStudyPath === 'true'}
|
||||
students={filteredStudents}
|
||||
studyPlanFilterIsActive={studyPlanFilterIsActive}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import qs from 'query-string'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Datetime from 'react-datetime'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useHistory, useLocation } from 'react-router-dom'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { Button, Form, Message, Radio } from 'semantic-ui-react'
|
||||
|
||||
import { createPinnedFirstComparator, isNewStudyProgramme, textAndDescriptionSearch } from '@/common'
|
||||
@@ -33,7 +33,7 @@ const initialQuery = () => ({
|
||||
})
|
||||
|
||||
export const PopulationSearchForm = ({ onProgress }) => {
|
||||
const history = useHistory()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dispatch = useDispatch()
|
||||
const populations = useSelector(state => state.populations)
|
||||
@@ -161,7 +161,7 @@ export const PopulationSearchForm = ({ onProgress }) => {
|
||||
: studyRights.programme
|
||||
const queryObject = { ...rest, studyRights: JSON.stringify(studyRights) }
|
||||
const searchString = qs.stringify(queryObject)
|
||||
history.push({ search: searchString })
|
||||
navigate({ search: searchString })
|
||||
}, 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import qs from 'query-string'
|
||||
import { useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button, Form } from 'semantic-ui-react'
|
||||
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
@@ -12,7 +12,7 @@ import { FilterActiveNote } from './FilterActiveNote'
|
||||
import './populationSearch.css'
|
||||
|
||||
export const PopulationSearchHistory = () => {
|
||||
const history = useHistory()
|
||||
const navigate = useNavigate()
|
||||
const populations = useSelector(state => state.populations)
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false)
|
||||
const [semesters, setSemesters] = useState(populations.query?.semesters || ['FALL', 'SPRING'])
|
||||
@@ -47,7 +47,7 @@ export const PopulationSearchHistory = () => {
|
||||
studyRights: JSON.stringify(studyRights),
|
||||
}
|
||||
const searchString = qs.stringify(queryObject)
|
||||
history.push({ search: searchString })
|
||||
navigate({ search: searchString })
|
||||
}
|
||||
|
||||
const renderAdvancedSettingsSelector = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link, useHistory, useLocation } from 'react-router-dom'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import { Button, Divider, Form, Header, Icon, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { useProgress } from '@/common/hooks'
|
||||
@@ -12,7 +12,6 @@ import { PopulationSearchForm } from './PopulationSearchForm'
|
||||
import { PopulationSearchHistory } from './PopulationSearchHistory'
|
||||
|
||||
export const PopulationSearch = ({ combinedProgrammeCode }) => {
|
||||
const history = useHistory()
|
||||
const location = useLocation()
|
||||
const populations = useSelector(state => state.populations)
|
||||
const populationFound = populations.data.students !== undefined
|
||||
@@ -22,12 +21,12 @@ export const PopulationSearch = ({ combinedProgrammeCode }) => {
|
||||
const onlyHopsCredit = useFilterSelector(hopsFilter.selectors.isActive)
|
||||
const combinedHopsSelected = useFilterSelector(hopsFilter.selectors.isCombinedSelected(combinedProgrammeCode))
|
||||
const bothHopsSelected = useFilterSelector(hopsFilter.selectors.isBothSelected(combinedProgrammeCode))
|
||||
const title = populationFound && history.location.search ? null : 'Search for class'
|
||||
const title = populationFound && location.search ? null : 'Search for class'
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
{title && <Header size="medium">{title}</Header>}
|
||||
{(!populationFound || !history.location.search) && (
|
||||
{!populationFound && (
|
||||
<>
|
||||
<InfoBox content={populationStatisticsToolTips.search} cypress="PopulationSearch" />
|
||||
<Divider />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router'
|
||||
import { Header, Message, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { getFullStudyProgrammeRights, getStudentTotalCredits, getUnifiedProgrammeName } from '@/common'
|
||||
|
||||
+19
-18
@@ -402,6 +402,19 @@ export const GeneralTab = ({
|
||||
filterType: 'date',
|
||||
getRowVal: student => reformatDate(studentToProgrammeStartMap[student.studentNumber], ISO_DATE_FORMAT),
|
||||
},
|
||||
option: (isBachelorsProgramme || isMastersProgramme) && {
|
||||
key: 'option',
|
||||
title: isMastersProgramme ? 'Bachelor' : 'Master',
|
||||
getRowVal: student => (student.option ? getTextIn(student.option.name) : ''),
|
||||
formatValue: value => (
|
||||
<div style={{ maxWidth: '290px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{value}</div>
|
||||
),
|
||||
cellProps: student => {
|
||||
return {
|
||||
title: student.option ? getTextIn(student.option.name) : '',
|
||||
}
|
||||
},
|
||||
},
|
||||
semesterEnrollments: {
|
||||
key: 'semesterEnrollments',
|
||||
title: 'Semesters\npresent',
|
||||
@@ -534,24 +547,12 @@ export const GeneralTab = ({
|
||||
title: 'Gender',
|
||||
getRowVal: student => getGender(student.gender_code),
|
||||
},
|
||||
citizenship: {
|
||||
key: 'citizenship',
|
||||
title: 'Citizenship',
|
||||
getRowVal: student =>
|
||||
getTextIn({ fi: student.home_country_fi, en: student.home_country_en, sv: student.home_country_sv }),
|
||||
},
|
||||
option: (isBachelorsProgramme || isMastersProgramme) && {
|
||||
key: 'option',
|
||||
title: isMastersProgramme ? 'Bachelor' : 'Master',
|
||||
getRowVal: student => (student.option ? getTextIn(student.option.name) : ''),
|
||||
formatValue: value => (
|
||||
<div style={{ maxWidth: '290px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{value}</div>
|
||||
),
|
||||
cellProps: student => {
|
||||
return {
|
||||
title: student.option ? getTextIn(student.option.name) : '',
|
||||
}
|
||||
},
|
||||
citizenships: {
|
||||
key: 'citizenships',
|
||||
title: 'Citizenships',
|
||||
getRowVal: student => student.citizenships.map(getTextIn).sort(),
|
||||
formatValue: value => value.join(', '),
|
||||
filterType: 'multi',
|
||||
},
|
||||
curriculumPeriod: {
|
||||
key: 'curriculumPeriod',
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ export const GeneralTabContainer = ({ studyGuidanceGroup, variant, ...props }) =
|
||||
const columns = ['credits.since', 'programme', 'startYear']
|
||||
if (studyGuidanceGroup?.tags?.studyProgramme)
|
||||
columns.push(
|
||||
'citizenship',
|
||||
'citizenships',
|
||||
'credits.hops',
|
||||
'curriculumPeriod',
|
||||
'endDate',
|
||||
@@ -81,7 +81,7 @@ export const GeneralTabContainer = ({ studyGuidanceGroup, variant, ...props }) =
|
||||
coursePopulation: ['enrollmentDate', 'gradeForSingleCourse', 'language', 'passDate', 'programme', 'startYear'],
|
||||
population: [
|
||||
'admissionType',
|
||||
'citizenship',
|
||||
'citizenships',
|
||||
'credits.hops',
|
||||
'credits.studyright',
|
||||
'curriculumPeriod',
|
||||
|
||||
-9
@@ -1,12 +1,3 @@
|
||||
.cardHeader {
|
||||
display: flex !important;
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
|
||||
.controlIcon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.enrollment-label-no-margin {
|
||||
border-radius: 0.28rem;
|
||||
box-shadow:
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
import { keyBy } from 'lodash'
|
||||
import moment from 'moment'
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from 'react-router'
|
||||
import { Icon, Message, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { StudentInfoItem } from '@/components/common/StudentInfoItem'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import '@/components/StudentStatistics/StudentInfoCard/studentInfoCard.css'
|
||||
import './index.css'
|
||||
import { SortableTable } from '@/components/SortableTable'
|
||||
import { useStudentNameVisibility } from '@/components/StudentNameVisibilityToggle'
|
||||
import { ISO_DATE_FORMAT } from '@/constants/date'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link } from 'react-router'
|
||||
import { Tab } from 'semantic-ui-react'
|
||||
|
||||
import { TagList } from '@/components/TagList'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import moment from 'moment'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Tab } from 'semantic-ui-react'
|
||||
|
||||
@@ -131,7 +131,6 @@ const PopulationStudents = ({
|
||||
variant,
|
||||
year,
|
||||
}) => {
|
||||
const [state, setState] = useState({})
|
||||
const studentRef = useRef()
|
||||
const { query } = useSelector(({ populations }) => populations)
|
||||
let mainProgramme = query?.studyRights?.programme || ''
|
||||
@@ -152,10 +151,6 @@ const PopulationStudents = ({
|
||||
|
||||
const { isAdmin } = useGetAuthorizedUserQuery()
|
||||
|
||||
useEffect(() => {
|
||||
setState({ ...state, admin: isAdmin })
|
||||
}, [])
|
||||
|
||||
if (filteredStudents.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user