Compare commits

...
113 Commits
Author SHA1 Message Date
Rajamäki Santeri J 72440609b4 [Study Programme] Fixed Cypress broken tests 2025-01-13 18:14:59 +02:00
Rajamäki Santeri J c946cd2405 [Study Programme] Added study right thesis counting
Closing issue #4712
2025-01-13 17:56:51 +02:00
Valtteri Kantanen 96d316ebf5 [Frontend] Rewrite Updater page with MUI components 2025-01-13 15:43:32 +02:00
Riku Rauhala b3eb7dceac [Custom population] Fix an info box text
There was an extra ' character
2025-01-10 14:18:07 +02:00
Valtteri Kantanen ee02d002ba [Course statistics] Fix tests 2025-01-10 13:16:22 +02:00
Valtteri Kantanen 34448d27d0 [Frontend] Remove unused components 2025-01-10 13:03:05 +02:00
Valtteri Kantanen 39ce64edce [Course statistics] Move minimum and maximum attainment date calculation to the backend 2025-01-10 13:02:46 +02:00
Valtteri Kantanen 0d210a607b [Course statistics] Fix a bug in the search form where every keypress triggered a new backend request 2025-01-10 13:01:23 +02:00
Valtteri Kantanen f1a6302928 [Course statistics] Use RTK Query for course search 2025-01-10 12:59:25 +02:00
Valtteri Kantanen 359db8f365 [Frontend] Add custom useDebouncedState hook 2025-01-10 12:58:12 +02:00
Valtteri Kantanen 387a3e0ef2 [Frontend] Refactor validateInputLength to always return a boolean 2025-01-10 12:56:47 +02:00
Valtteri Kantanen 4c957b347b Remove automerge from renovate config as it doesn't seem to work properly 2025-01-09 10:41:35 +02:00
Valtteri KantanenandGitHub 10d3d5aeed Merge pull request #4711 from UniversityOfHelsinkiCS/OOD-80
[Frontend] Added env variable for sisu url and changed to use that instead of hardcoded sisu urls
2025-01-09 10:26:10 +02:00
Matti Räsänen 6e365d4a23 Removed ending slash from SIS_URL in fd ci compose 2025-01-08 09:08:32 +02:00
Matti Räsänen 68292a45d0 [FD compose] Changed SIS_HOST to SIS_URL in Dockerfile and added SIS_URL to fd ci compose as well 2025-01-07 16:17:25 +02:00
Matti Räsänen f0088cf7a0 [Frontend] Added env variable for sisu url and changed to use that instead of hardcoded sisu urls 2025-01-07 16:03:09 +02:00
Valtteri KantanenandGitHub b8469a242e Merge pull request #4595 from UniversityOfHelsinkiCS/renovate/axios-1.x
Update dependency axios to v1
2025-01-07 15:21:27 +02:00
Valtteri Kantanen 8c5d5f1ee9 [General tab] Move Bachelor/Master column more to the left 2025-01-07 15:13:31 +02:00
Valtteri Kantanen 80759cacee [Frontend] Fix key spread error on RangeSelector 2025-01-07 14:25:38 +02:00
renovate[bot]andGitHub 4df2cc78ff Update dependency axios to v1 2025-01-07 11:58:36 +00:00
Valtteri Kantanen 8b34f7a5f9 [Faculties] Use StyledTable instead of regular Table 2025-01-07 13:55:09 +02:00
Valtteri Kantanen e81d6eb877 [Faculties] Fix tests 2025-01-07 10:58:32 +02:00
Valtteri Kantanen 0c47d7c21c [Faculties] Combine 'Students by starting year' and 'Progress' tabs 2025-01-07 10:46:43 +02:00
Valtteri KantanenandGitHub 175996235d Merge pull request #4705 from UniversityOfHelsinkiCS/renovate/all-minor-patch
Update all non-major dependencies
2025-01-07 09:45:05 +02:00
Valtteri Kantanen 42409d555e [Students] Add better styles to study rights table 2025-01-02 16:14:52 +02:00
Valtteri Kantanen 23b1a02d42 [Filters] Fix Programme filter showing wrong numbers
This happened in those rare cases where a student had multiple study rights for the same programme
2025-01-02 15:51:20 +02:00
Valtteri Kantanen 1be53368c6 Delete old JS version of Routes 2024-12-19 16:35:23 +02:00
Valtteri Kantanen 52e0d28a4d Remove unused static text file 2024-12-19 16:25:33 +02:00
Valtteri Kantanen 83114a26f9 [Frontend] Simplify route handling 2024-12-19 15:56:01 +02:00
Valtteri KantanenandGitHub bdc09f86e6 Merge pull request #4708 from UniversityOfHelsinkiCS/mui-completed-courses-search
Material UI: Completed courses search
2024-12-19 15:31:24 +02:00
Valtteri Kantanen 6dd9679267 [Completed courses search] Delete old components 2024-12-19 15:07:28 +02:00
Valtteri Kantanen 5fb7168d17 [Completed courses search] Update tests 2024-12-19 15:07:11 +02:00
Valtteri Kantanen 6ac4b6fb3d [Completed courses search] Rewrite with MUI 2024-12-19 15:01:17 +02:00
Valtteri Kantanen 1a8de4344c [Frontend] Rewrite SearchHistory with MUI 2024-12-19 15:00:29 +02:00
Valtteri Kantanen 1493725699 [Frontend] Rewrite RightsNotification with MUI 2024-12-19 14:57:02 +02:00
Valtteri Kantanen 6cff94e2ca [Completed courses search] Add RTK Query tag for CompletedCoursesSearchList 2024-12-19 14:37:29 +02:00
Valtteri KantanenandGitHub d51a70e259 Merge pull request #4706 from UniversityOfHelsinkiCS/frontend-build-more-max-old-space-size
[Frontend] Added max-old-space-size to package.json build
2024-12-18 13:15:26 +02:00
Valtteri Kantanen 7f84c0b9f0 Update documentation on access rights 2024-12-18 13:12:49 +02:00
Valtteri Kantanen df555ca93f Remove all remaining references to katselmusViewer role 2024-12-18 13:08:01 +02:00
Matti Räsänen b68b98b62c [Frontend] Added max-old-space-size to package.json build 2024-12-17 13:12:29 +02:00
renovate[bot]andGitHub 640dc741a4 Update all non-major dependencies 2024-12-17 08:19:50 +00:00
Valtteri Kantanen 5d02b839ee Update tests after updating students in the test db 2024-12-17 08:34:51 +02:00
Valtteri Kantanen ae60a38445 Handle cases where a student has multiple citizenships 2024-12-16 16:02:20 +02:00
Valtteri Kantanen 83d4bd26c4 Reset document title on component unmount 2024-12-16 15:56:55 +02:00
Valtteri Kantanen 481b351276 [Populations] Remove unnecessary state 2024-12-16 15:27:59 +02:00
Valtteri Kantanen 23cb8c8ee5 [Class statistics] Fix credit accumulation graph 2024-12-16 15:27:20 +02:00
Valtteri Kantanen 9bcdd81772 Update ESLint configuration 2024-12-16 15:26:58 +02:00
Valtteri KantanenandGitHub 53e443142d Merge pull request #4704 from UniversityOfHelsinkiCS/mui-students
Material UI: Students
2024-12-16 15:18:21 +02:00
Valtteri Kantanen da72107eb9 Fix tests 2024-12-16 15:02:59 +02:00
Valtteri Kantanen d2cbb89eb9 Add data-cy attribute to CurriculumPicker 2024-12-16 14:00:21 +02:00
Valtteri Kantanen 3bf0c60378 [Students] Fix tests 2024-12-16 13:58:36 +02:00
Valtteri Kantanen 59a9854ed5 Add optional cypress prop to ExternalLink 2024-12-16 13:54:58 +02:00
Valtteri Kantanen b70bc09359 [Students] Enhance student search with uninitialized state alert 2024-12-16 13:52:52 +02:00
Valtteri Kantanen 8ef82d01b1 [Students] Remove old Semantic UI components 2024-12-16 12:13:59 +02:00
Valtteri Kantanen ac683175a8 [Students] Sort students by date of university enrollment in search results 2024-12-16 11:59:03 +02:00
Valtteri Kantanen 5e86967627 Use correct components in the /students route 2024-12-16 11:58:09 +02:00
Valtteri Kantanen d971bb993f [Students] Rewrite student page with MUI 2024-12-16 11:55:54 +02:00
Valtteri Kantanen 2328f2deff [Students] Rewrite search page with MUI 2024-12-16 11:53:15 +02:00
Valtteri Kantanen ddeb65abef Rewrite CurriculumPicker with MUI 2024-12-16 11:50:59 +02:00
Valtteri Kantanen 0871d8ecbd Remove minWidth from TableHeaderWithTooltip component 2024-12-16 11:49:35 +02:00
Valtteri Kantanen 3b8aec2fef Add variant prop to ExternalLink component for customizable typography 2024-12-16 11:48:36 +02:00
Valtteri Kantanen 71dc06728f Create StyledTable 2024-12-16 11:36:55 +02:00
Valtteri Kantanen 9484ecb456 Create StyledAccordion 2024-12-16 11:36:25 +02:00
Valtteri Kantanen 25319d1c8f Rewrite StudentNameVisibilityToggle with MUI 2024-12-16 11:35:51 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Valtteri Kantanen
e7fd54b443 Update all non-major dependencies (#4703)
* Update all non-major dependencies

* Fix ESLint warnings

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Valtteri Kantanen <valtteri.kantanen@iki.fi>
2024-12-16 09:24:15 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
aed2bc154b Update dependency axios to ^0.29.0 (#4684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-15 15:59:19 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
09a991669c Update dependency scheduler to ^0.25.0 (#4694)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-15 15:58:24 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
41325bfa12 Update dependency @types/node to v22 (#4701)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-15 15:51:51 +02:00
Ilkka LappeteläinenandGitHub 17a1d3718f Updated react-router-dom from v5 to v7 (#4700) 2024-12-15 15:04:42 +02:00
Valtteri Kantanen 8b9c5b8789 Remove schedule from Renovate config 2024-12-12 14:12:42 +02:00
Valtteri Kantanen e11609c335 [Faculties] Students by starting year: Put cell content always on one line 2024-12-12 14:02:32 +02:00
Valtteri Kantanen 3a5b3c2d65 [Faculties] Move state from parent to child 2024-12-12 14:00:15 +02:00
Valtteri Kantanen 33916312b5 [Frontend] Make the toggle with two labels always have the same colors 2024-12-12 13:59:36 +02:00
Riku RauhalaandGitHub dab8a2679f Merge pull request #4698 from UniversityOfHelsinkiCS/faculties-accordion
[Faculties] Wrap progress sections into accordions
2024-12-12 12:49:51 +02:00
Riku Rauhala 390334c820 [Faculties] Update tests 2024-12-12 12:08:36 +02:00
Riku Rauhala 356ffc953e [Faculties] Wrap progress sections into accordions 2024-12-12 11:58:10 +02:00
Riku Rauhala a2f3a5de61 [Class statistics] Credit statistics: Change default sorting order to descending 2024-12-12 10:56:33 +02:00
Riku Rauhala 635a3fbcbc [Toggles] Extract component ToggleContainer
Wrapping all Toggles for a consistent layout
2024-12-12 10:51:41 +02:00
Riku Rauhala 7ebcb86924 [Toggle] Use bold font for the active option 2024-12-12 10:44:16 +02:00
Riku Rauhala eb1781e6af [Faculties] Add missing Code title and tweak margin 2024-12-11 15:05:55 +02:00
Riku Rauhala 6df983642b [Faculties] Add mini info button for sorting the data table 2024-12-11 14:41:52 +02:00
Riku Rauhala 634c4c9a97 [Navigation bar] Improve nav button visibility 2024-12-11 14:08:35 +02:00
Riku Rauhala 9371c7d2ab [Frontend] Rearrange Material components
Let's move components that do not have other files associated with them away from index.tsx files
2024-12-11 09:33:41 +02:00
Riku RauhalaandGitHub f6678739ad Merge pull request #4695 from UniversityOfHelsinkiCS/material-ui-faculties
Material UI: Faculties
2024-12-10 15:40:41 +02:00
Riku Rauhala 2882dd25c6 [Faculties] Skip export tests 2024-12-10 15:23:09 +02:00
Riku Rauhala ed3166d08f [Faculties] Update tests 2024-12-10 15:00:14 +02:00
Valtteri Kantanen 39340108f2 [Updater] Replace country_fi, country_sv, and country_en with citizenships array 2024-12-10 13:39:24 +02:00
Riku Rauhala 94f037b6af [Faculties] Remove Semantic UI components
Saving legacy GraduationTimes for now as it is still being used in Study track overview
2024-12-09 20:09:36 +02:00
Riku Rauhala 090e81a432 [Faculties] Add missing info boxes 2024-12-09 20:01:04 +02:00
Riku Rauhala 6e8ae9b320 [FacultyStudentDataTable] Rewrite with MUI 2024-12-09 19:45:50 +02:00
Riku Rauhala 0db094655e [PopulationLink] Rewrite with MUI 2024-12-09 17:44:25 +02:00
Riku Rauhala c437ed4e96 [Faculties] Refactor and fix exports 2024-12-09 16:55:42 +02:00
Riku Rauhala ed5a778e50 [Faculties] Extract utils 2024-12-09 16:21:30 +02:00
Riku Rauhala 13633ce11a [Faculties] Extract type DegreeProgramme 2024-12-09 15:13:24 +02:00
Riku Rauhala eab47657e6 [Backend] Fix type imports 2024-12-09 15:09:32 +02:00
Riku Rauhala 35267e8b6d [Faculties] Rename BasicInformationTab for consistency 2024-12-09 15:06:03 +02:00
Riku Rauhala 15cc9cef4a [Faculties] Rewrite the Progress tab with MUI 2024-12-09 14:30:34 +02:00
Riku Rauhala f5e1e7267f [Faculties] Rewrite Students by starting year tab with MUI 2024-12-09 13:51:51 +02:00
Riku Rauhala c6eaa18a1f [Faculties] Rewrite the Update statistics tab with MUI 2024-12-09 11:13:52 +02:00
Riku Rauhala 63e16e4332 [Faculties] Rewrite Graduation times tab with MUI 2024-12-09 09:13:58 +02:00
Riku Rauhala 074e41de59 [Section] Add optional Export button
Also, let's specify a color (deep purple) to be associated with export functionality
2024-12-06 16:24:27 +02:00
Riku Rauhala 46b6686f4b [Faculty graduations] Fix types 2024-12-06 15:46:13 +02:00
Riku Rauhala ede94708d5 [Faculties] Type faculty RTK API 2024-12-05 21:19:13 +02:00
Riku Rauhala f20953c8d2 [FacultyGraduations] Generalize name
The name refers to names of faculties in the university page and to programmes in faculties page
2024-12-05 20:29:43 +02:00
Riku Rauhala 1e728593ea [University] Fix layout 2024-12-05 19:36:14 +02:00
Riku Rauhala 21f7fcc6d2 [Faculties] Rewrite Basic information tab with MUI 2024-12-05 13:45:23 +02:00
Riku Rauhala 20c026aa56 [Section] Remove margin
The section should not have margin but instead the spacing/gap between sections should be handled in each component with Stack
2024-12-04 16:45:40 +02:00
Riku Rauhala 24eb824163 [FacultyProgressTable] Extract component ExpandableRow
This can be used in faculty statistics too, reducing copy-paste
2024-12-04 16:38:15 +02:00
Riku Rauhala 2065afe852 [Section] Make title optional and improve error message
The error message now matches the loading skeleton better -> no layout shift in case of error
2024-12-04 11:46:00 +02:00
Riku Rauhala d60480d1ae [LineGraph] Rewrite for Material UI 2024-12-04 10:52:42 +02:00
Riku Rauhala 1be0f9090e [Highcharts] Disable animations for all charts 2024-12-02 22:00:29 +02:00
Riku Rauhala f9dad0a183 [Faculties] Rewrite faculty list with MUI 2024-12-02 20:08:46 +02:00
Riku Rauhala 597f1f0c0b [University] Extract tabs to a new hook 2024-12-02 18:21:02 +02:00
237 changed files with 6669 additions and 7506 deletions
+1
View File
@@ -70,3 +70,4 @@ environments/
# Target of shared symlink
services/**/src/shared
+4 -4
View File
@@ -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
+82 -60
View File
@@ -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')
})
})
+2 -2
View File
@@ -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
View File
@@ -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')
})
})
})
+3 -3
View File
@@ -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)
})
})
+7 -3
View File
@@ -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
View File
@@ -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', () => {
+1 -1
View File
@@ -141,7 +141,7 @@ describe('Study guidance group tests', () => {
'Transferred from',
'Admission type',
'Gender',
'Citizenship',
'Citizenships',
'Curriculum period',
'Latest attainment date',
'Tags',
+31 -24
View File
@@ -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')
}
})
+1 -1
View File
@@ -83,7 +83,7 @@ services:
jami-db:
<<: *defaults
image: postgres:15.8
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
+1
View File
@@ -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}
+4 -3
View File
@@ -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
View File
@@ -189,7 +189,7 @@ services:
jami-db:
<<: *defaults
image: postgres:15.8
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
+2 -5
View File
@@ -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
View File
@@ -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',
+245 -247
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
+279 -703
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -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": {
+3 -17
View File
@@ -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
+30 -9
View File
@@ -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 {
+1 -1
View File
@@ -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'
+1 -2
View File
@@ -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
+3
View File
@@ -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 .
+613 -929
View File
File diff suppressed because it is too large Load Diff
+19 -18
View File
@@ -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.
`,
}
+4
View File
@@ -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])
}
+1 -1
View File
@@ -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>
)
}
@@ -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,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 {
@@ -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'
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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} />
}
@@ -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>
)
}
@@ -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,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,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'
@@ -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'
@@ -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',
@@ -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',
@@ -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:
@@ -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