Compare commits

...
172 Commits
Author SHA1 Message Date
Valtteri Kantanen 5ce3337977 Fix Markdown error in documentation 2025-01-22 15:15:34 +02:00
Valtteri Kantanen c5e24c7409 Add documentation for db schema updates 2025-01-22 14:58:30 +02:00
Joonas Ahovalli 34f029da9e [Close to graduation] Show preferred language for student 2025-01-22 14:52:13 +02:00
Joonas Ahovalli e95414806c [Updater] add preferred language to student model 2025-01-22 12:44:03 +02:00
Riku Rauhala 0eb87ea44a [Hooks] Extract remaining hooks
All hooks are now converted into TypeScript and stored in @/hooks
2025-01-20 13:53:09 +02:00
Riku Rauhala 454fabeb1d [Hooks] Extract hook useSemanticTabs
Renaming this for clarity. This will be replaced by the new useTabs hook for all MUI components
2025-01-20 12:52:04 +02:00
Riku Rauhala d3f022adc5 [Hooks] Extract hook useDeepMemo 2025-01-20 12:46:15 +02:00
Riku Rauhala 8efe3d2aa3 [Hooks] Extract hook useTabChangeAnalytics 2025-01-20 12:40:48 +02:00
Riku Rauhala d3003d5797 [Hooks] Extract hook useProgress 2025-01-20 12:36:45 +02:00
Riku Rauhala 0dd5f32f96 [Hooks] Extract hook useDidMount 2025-01-20 12:31:09 +02:00
Riku Rauhala 85b307b306 [Hooks] Extract hook useLocalStorage 2025-01-20 12:09:48 +02:00
Riku Rauhala 73eadbd1b1 [Hooks] Remove duplicate toggle hook 2025-01-20 11:59:12 +02:00
Riku Rauhala b294c560e4 [Hooks] Extract hook useTitle 2025-01-20 11:56:41 +02:00
Riku Rauhala 455d9dc532 [Hooks] Rename debouncedState for consistency 2025-01-20 11:47:32 +02:00
Riku RauhalaandGitHub df1049a436 Merge pull request #4717 from UniversityOfHelsinkiCS/material-ui-course-search
Material UI: Course search
2025-01-20 10:56:49 +02:00
Riku Rauhala ac22df8ced [Course search] Hide selected list when not selecting multiple 2025-01-20 09:52:28 +02:00
Riku Rauhala 1fd987221d [Course statistics] Update tests 2025-01-20 09:43:25 +02:00
Riku Rauhala 45985f2c44 [CourseStatistics] Convert utils into TypeScript 2025-01-17 21:27:47 +02:00
Riku Rauhala 159e11a508 [CourseStatistics] Convert to TypeScript 2025-01-17 21:08:46 +02:00
Riku Rauhala 5ff68533d7 [Courses search] Add circular progress when fetching 2025-01-17 21:04:04 +02:00
Riku Rauhala 61f20173a3 [Courses search] Place the toggles back to their original location
This seems to work better than having the toggles below
2025-01-17 20:26:56 +02:00
Riku Rauhala 987f1ec42b [Courses search] Refactor toggles 2025-01-17 20:08:19 +02:00
Riku Rauhala 3f4c39600b [Course search] Add missing search icons 2025-01-17 19:54:02 +02:00
Riku Rauhala 88ef490b1b [CourseStatistics] Rewrite course search with Material UI 2025-01-17 18:34:57 +02:00
Riku Rauhala 7e135d4643 [Types] Extract type SearchHistoryItem 2025-01-16 10:03:10 +02:00
Riku Rauhala 06a8760701 [Hooks] Extract hook useSearchHistory 2025-01-15 19:16:42 +02:00
Riku Rauhala 5eaa035f1d [Hooks] Extract hook useToggle 2025-01-15 18:05:50 +02:00
Riku Rauhala ae1f0ceeb7 [Redux] Type courseSearch 2025-01-15 17:56:04 +02:00
Riku Rauhala 3ca82286b4 [Redux] Type courseStats 2025-01-15 17:09:22 +02:00
Riku Rauhala 63add43c0f [Course statistics] Move components under pages 2025-01-15 10:56:44 +02:00
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
Riku Rauhala c26728d24a [Navigation bar] Fix issues 2024-12-02 15:33:38 +02:00
Riku Rauhala 4c1e11692a [Navigation bar] Make the tab clickable
The tab was technically clickable but would not trigger the link. The user would have to hit the text to navigate which was not ideal
2024-12-02 14:29:41 +02:00
Riku Rauhala 132a21e113 [Navigation bar] Fix active status for subpaths 2024-12-02 12:42:18 +02:00
Riku Rauhala 324fcb71a9 [Backend] Remove oodikone-shared
Looks like we forgot to remove this one
2024-12-02 11:46:20 +02:00
Riku Rauhala 55111201e6 [Backend] Fix broken imports 2024-12-02 11:45:13 +02:00
Riku Rauhala 067040d92f [Backend] Extract faculty param types to shared 2024-12-02 11:25:05 +02:00
Riku Rauhala 0d857d716a [ESLint] Ignore no-unused-modules for shared 2024-12-02 10:59:50 +02:00
Riku Rauhala ec9b0b22b0 [Faculties] Only send relevant fields to frontend 2024-12-02 10:41:02 +02:00
Riku Rauhala 32cf9ed185 [Shared] Add GetFacultiesResponse 2024-12-02 10:32:15 +02:00
Riku Rauhala 1702e086a3 [Shared] Move API types to types/api 2024-12-02 09:38:25 +02:00
Riku Rauhala 8246818a94 [Navigation bar] Fix active path 2024-12-02 09:03:52 +02:00
Riku RauhalaandGitHub e1ee00a331 Merge pull request #4693 from UniversityOfHelsinkiCS/responsive-nav-bar
Responsive navigation bar
2024-11-29 17:57:38 +02:00
Riku Rauhala 6e22cb5e58 [Changelog] Add version number 2024-11-29 17:28:26 +02:00
Riku Rauhala f4ebb3d547 [Footer] Tweak gaps 2024-11-29 17:27:15 +02:00
Riku Rauhala 5683fbc3cb [Navigation bar] Switch to a tab based layout 2024-11-29 17:17:24 +02:00
Riku Rauhala 0c78f1c7e2 [Changelog] Increase the number of visible items to 20 2024-11-29 10:27:43 +02:00
Valtteri Kantanen 5051bbdded [Close to graduation] Fix old view with the new one 2024-11-28 17:47:32 +02:00
Valtteri Kantanen c7b86856fb [Backend] Fix study guidance group fetching when the user has no sisPersonId 2024-11-28 15:45:45 +02:00
Valtteri Kantanen afcf72e29f Improve quality on Cypress videos 2024-11-28 15:45:12 +02:00
Valtteri Kantanen a83482179a [Close to graduation] Fix excel exports 2024-11-28 15:24:11 +02:00
Riku RauhalaandGitHub 1e7a198dec Merge pull request #4692 from UniversityOfHelsinkiCS/material-ui-access-denied
Material UI: Access denied
2024-11-28 14:55:35 +02:00
Riku Rauhala 41646985e4 [Access denied] Remove old Semantic UI component 2024-11-28 14:24:23 +02:00
Valtteri Kantanen 80f6c7b1fb [Close to graduation] Use correct filters for columns 2024-11-28 14:15:43 +02:00
Valtteri Kantanen cc9465f141 Update default material-react-table options 2024-11-28 14:15:01 +02:00
Riku Rauhala d56c472e77 [Access denied] Rewrite in Material UI 2024-11-28 13:50:51 +02:00
Riku Rauhala 68e5daae13 [University] Revert to old graduation time colors 2024-11-28 11:09:15 +02:00
Riku Rauhala b259f30093 [University] Remove message about MH90_001 being excluded
This appears to no longer be the case
2024-11-28 10:51:15 +02:00
Riku Rauhala 804f5df55f [InfoBox] Switch to contained variant and increase width 2024-11-28 10:44:15 +02:00
Riku Rauhala 96ec7000fa [Faculty progress] Reduce copy-paste 2024-11-28 10:44:14 +02:00
329 changed files with 8236 additions and 9112 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')
})
})
+10 -10
View File
@@ -163,8 +163,8 @@ describe('Course Statistics tests', () => {
it('Searching multiple courses having substitution mappings shows course statistics', () => {
cy.contains('Search for courses')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'checked').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'checked')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
searchByCourseCode('TKT')
cy.contains('td', /^TKT20001/).click()
cy.contains('td', /^TKT10002/).click()
@@ -195,13 +195,13 @@ describe('Course Statistics tests', () => {
cy.contains('Fetch statistics').click()
cy.contains('Search for courses').should('not.exist')
cy.get('.ui.contentSegment .ui.menu').contains('Course').click()
cy.get('.ui.menu').contains('Course').click()
cy.cs('course-selector').get('.active.selected.item').contains('TKT10002')
cy.get('#CourseStatPanes table a.item:first').click()
cy.contains('Population of course Introduction to Programming 2023-2024 (open and normal)')
cy.go('back')
cy.get('.ui.contentSegment .ui.menu').contains('Course').click()
cy.get('.ui.menu').contains('Course').click()
cy.cs('course-selector').click()
cy.cs('course-selector').contains('TKT20001').click()
cy.get('#CourseStatPanes table a.item:first').click()
@@ -238,17 +238,17 @@ describe('Course Statistics tests', () => {
cy.contains('581328 Tietokantojen perusteet')
cy.contains('A581328 Avoin yo: Tietokantojen perusteet')
cy.get('.right').click()
cy.contains('No results')
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
})
it('"Select all search results" button is not showing unless "Select multiple courses" toggle is on', () => {
cy.contains('Search for courses')
searchByCourseCode('TKT')
cy.get('[data-cy="select-multiple-courses-toggle"]').should('not.have.class', 'checked')
cy.get('[data-cy="select-multiple-courses-toggle"]').should('not.have.class', 'Mui-checked')
cy.contains('TKT10004, BSCS2001, 581328, A581328, AYTKT10004')
cy.contains('Select all search results').should('not.exist')
cy.get('[data-cy="select-multiple-courses-toggle"]').click()
cy.get('[data-cy="select-multiple-courses-toggle"]').should('have.class', 'checked')
cy.get('[data-cy="select-multiple-courses-toggle"]').should('have.class', 'Mui-checked')
cy.contains('Select all search results')
})
@@ -283,7 +283,7 @@ describe('Course Statistics tests', () => {
cy.contains('BSCS1003 Data Structures and Algorithms')
cy.contains('58131 Tietorakenteet')
cy.get('.right').click()
cy.contains('No results')
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
searchByCourseName('tietorakenteet ja algoritmit')
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
@@ -354,8 +354,8 @@ describe('Course Statistics tests', () => {
beforeEach(() => {
cy.url().should('include', '/coursestatistics')
cy.contains('Search for courses')
cy.get('[data-cy="combine-substitutions-toggle"]').should('have.class', 'checked').click()
cy.get('[data-cy="combine-substitutions-toggle"]').should('not.have.class', 'checked')
cy.get('[data-cy="combine-substitutions-toggle"]').should('have.class', 'Mui-checked').click()
cy.get('[data-cy="combine-substitutions-toggle"]').should('not.have.class', 'Mui-checked')
searchByCourseCode('TKT10002')
cy.contains('td', /^TKT10002$/).click()
cy.contains('Search for courses').should('not.exist')
+236 -284
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
View File
@@ -28,7 +28,6 @@ describe('University view', () => {
beforeEach(() => {
cy.init('/university')
cy.contains('University')
cy.contains('Programme MH90_001 (Veterinary medicine bachelor + licentiate) is currently excluded.')
})
describe('Faculty progress tab', () => {
+1 -10
View File
@@ -8,10 +8,6 @@ const visibleLinks = {
visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Programmes', 'Students']
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
const hasVisibleItems = visibleItems => {
cy.get('[data-cy=nav-bar]').children().should('have.length', visibleItems)
}
const containsLinks = links => {
cy.get('[data-cy=nav-bar]').within(() => {
for (const link of links) {
@@ -34,7 +30,6 @@ describe('Users tests', () => {
describe('Using as user with just grp-oodikone-user, no other rights', () => {
it('shows correct tabs', () => {
cy.init('', 'norights')
hasVisibleItems(7)
containsLinks(visibleLinks.norights)
userButtonWorks('norights')
})
@@ -43,7 +38,6 @@ describe('Users tests', () => {
describe('Using as coursestatistics user', () => {
it('shows correct tabs', () => {
cy.init('', 'onlycoursestatistics')
hasVisibleItems(7)
containsLinks(visibleLinks.onlycoursestatistics)
userButtonWorks('onlycoursestatistics')
})
@@ -52,7 +46,6 @@ describe('Users tests', () => {
describe('Using as basic user', () => {
it('shows correct tabs', () => {
cy.init('')
hasVisibleItems(10)
containsLinks(visibleLinks.basic)
userButtonWorks('basic')
})
@@ -64,7 +57,6 @@ describe('Users tests', () => {
})
it('should see more stuff than others', () => {
hasVisibleItems(13)
containsLinks(visibleLinks.admin)
userButtonWorks('admin')
})
@@ -80,8 +72,7 @@ describe('Users tests', () => {
})
it("only the mocked user's programmes are visible", () => {
cy.get('[data-cy=nav-bar-button-studyProgramme]').click()
cy.get('[data-cy=nav-bar-button-class]').click()
cy.visit('/populations')
cy.contains('label', 'Study programme')
.siblings()
.within(() => {
+13
View File
@@ -19,4 +19,17 @@
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
on('before:browser:launch', (browser = {}, launchOptions) => {
// By manually setting the browser width and height in headless
// mode, screenshots and videos will have a better quality
// https://www.cypress.io/blog/generate-high-resolution-videos-and-screenshots
const width = 2560
const height = 1440
if (browser.name === 'chrome' && browser.isHeadless) {
launchOptions.args.push(`--window-size=${width},${height}`)
launchOptions.args.push('--force-device-scale-factor=1')
}
return launchOptions
})
}
+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
+117
View File
@@ -0,0 +1,117 @@
# How to update the database schema
These instructions explain how to update the schemas for the following databases used by Oodikone:
- `sis-db`
- `user-db`
- `kone-db`
The steps to **add, remove, or modify columns** are mostly the same for each database, but the file paths and container names differ. Always **double-check** that you have the correct function name, column name, data type, and other relevant details in your migration file.
> [!NOTE]
> If you need to change the `sis-importer-db` schema, see the instructions in the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips).
## General guidelines
### File names
- Name new migration files using the format `YYYYMMDD_XX_description.js`.
- The `XX` part is a migration number (starting from `00` each day).
- Example: `20250122_00_add_column_to_table.js`.
### Model vs. column names
- Use **camelCase** in your model definitions.
- Use **snake_case** in the actual database columns.
- If a model has `underscored: true`, Sequelize automatically converts camelCase to snake_case in all queries. Otherwise, specify the column name explicitly.
> [!IMPORTANT]
> The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
### Verifying the migration locally
- Migrations are run automatically whenever the server restarts.
- After creating a migration, ensure the relevant service is running:
- For `sis-db`: `npm run both:real` or `npm run updater:real`
- For `user-db`/`kone-db`: `npm run both:real` or `npm run oodikone:real`
- **Connect to the database container** and check:
```bash
docker exec -it <container-name> psql -U postgres <database-name>-real
```
Replace `<container-name>` and `<database-name>` with the actual values (e.g., `sis-db`, `user-db`, or `kone-db`).
- In the psql prompt, verify your migration:
```sql
SELECT * FROM migrations;
```
Also confirm that the new (or updated) column exists in the appropriate table and has the correct type.
### Deploying changes to production
- **Push changes** to the `master` branch and wait for the workflow to finish.
- For **`sis-db`** migrations (in the updater):
- You **do not** need to create a new release. Once tests pass, changes to the updater go directly to production.
- For **`user-db`** and **`kone-db`** migrations (in the backend):
- A new release must be made before the changes go to production.
- After deployment, check logs (for example, in OpenShift) to confirm the migration ran successfully.
- Optionally, use the `db-cli` tool to inspect the production database. See `how_to_db_cli.md` (in GitLab) for instructions.
## `sis-db`
> [!NOTE]
> These instructions assume that needed data is already in the `sis-importer-db`. If not, you must add the data there first. See the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips) for more details.
1. **Update the updater model:**
- Go to `updater/sis-updater-worker/src/db/models/` and choose the model you want to change.
- Add or modify the column (with its **type** and any other necessary fields).
- Check the correct data type in the importer.
- Use camelCase in the model, snake_case in the database.
1. **Update the backend model:**
- Go to `services/backend/src/models/`.
- The models are in different formats (JavaScript in the updater, TypeScript in the backend), but follow existing examples.
1. **Create a migration file:**
- Go to `updater/sis-updater-worker/src/db/migrations/`.
- Name your file using the format described above (e.g. `20250122_00_add_column_to_table.js`).
- **Remember**: The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
1. **Run and verify the migration:**
- Ensure that the updater is running (`npm run both:real` or `npm run updater:real`).
- Use the steps in [General guidelines](#general-guidelines) to verify.
1. **Push to master and deploy:**
- Your migration will go to production automatically after tests pass.
- Check logs in production (OpenShift) or use `db-cli` to confirm success.
## `user-db` and `kone-db`
1. **Update the model:**
- Go to `services/backend/src/models/kone` (for `kone-db`) or `services/backend/src/models/user.ts` (the only model for user-db)
- Add or modify the column, type, and any other needed fields. Follow existing models for structure.
1. **Create a migration file:**
- Go to `services/backend/src/database/`.
- Use `migrations_kone` (for `kone-db`) or `migrations_user` (for `user-db`).
- Name the file using the same `YYYYMMDD_XX_description.js` format.
- **Remember**: The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
1. **Run and verify the migration:**
- Make sure Oodikone is running (`npm run both:real` or `npm run oodikone:real`).
- Follow the same verification steps as in [General Guidelines](#general-guidelines).
1. **Push to master and release:**
- Wait for the CI workflow to pass.
- **Create a new release**; then check the production logs (in OpenShift) to confirm the migration.
- Optionally, use `db-cli` to check the production database state (see `how_to_db_cli.md` in GitLab).
+8 -1
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',
@@ -132,6 +133,12 @@ export default [
'no-console': 'off',
},
},
{
files: ['services/**/shared/**/*'],
rules: {
'import/no-unused-modules': 'off',
},
},
{
files: ['cypress/**/*.js', 'services/backend/**/*.js', 'updater/**/*.js'],
languageOptions: {
+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 -709
View File
File diff suppressed because it is too large Load Diff
+8 -9
View File
@@ -5,21 +5,20 @@
"build": "tsc"
},
"dependencies": {
"@sentry/node": "^8.37.1",
"@sentry/profiling-node": "^8.37.1",
"axios": "^0.28.1",
"bullmq": "^5.25.4",
"@sentry/node": "^8.45.1",
"@sentry/profiling-node": "^8.45.1",
"axios": "^1.7.9",
"bullmq": "^5.34.2",
"compression": "^1.7.5",
"cors": "^2.8.5",
"cron": "^3.1.9",
"express": "^4.21.1",
"cron": "^3.3.1",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash": "^4.17.21",
"lru-cache": "^11.0.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"morgan": "^1.10.0",
"oodikone-shared": "file:../oodikone-shared",
"papaparse": "^5.4.1",
"pg": "^8.13.1",
"redis": "^4.7.0",
@@ -37,10 +36,10 @@
"@types/express": "^4.17.21",
"@types/lodash": "^4.17.13",
"@types/morgan": "^1.9.9",
"@types/node": "^22.9.0",
"@types/node": "^22.10.2",
"@types/validator": "^13.12.2",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
"typescript": "^5.7.2"
},
"overrides": {
"winston-gelf-transporter": {
+6 -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
@@ -83,6 +69,9 @@ export class Student extends Model<InferAttributes<Student>> {
@Column(DataType.BOOLEAN)
hasPersonalIdentityCode!: boolean
@Column(DataType.STRING)
preferredLanguage!: string
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
+6 -2
View File
@@ -16,18 +16,21 @@ router.get('/', async (_req: Request, res: Response) => {
const fakeRelease: Release[] = [
{
description: '**Feature 1**\n- Added a fancy new feature \n\n**Feature 2**\n- Fixed a bug\n- Fixed another bug',
title: 'Release 1',
title: 'Release 3',
time: new Date().toISOString(),
version: '0.0.3',
},
{
description: "Let's not spam the GitHub API in development!",
title: 'Release 2',
time: new Date().toISOString(),
version: '0.0.2',
},
{
description: 'This release should not be visible on the frontpage',
title: 'Release 3',
title: 'Release 1',
time: new Date().toISOString(),
version: '0.0.1',
},
]
return res.status(200).json(fakeRelease)
@@ -37,6 +40,7 @@ router.get('/', async (_req: Request, res: Response) => {
description: release.body,
time: release.published_at,
title: release.name,
version: release.tag_name,
}))
changelog.data = releasesFromAPI
res.status(200).json(releasesFromAPI)
+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 {
+3 -3
View File
@@ -5,7 +5,7 @@ import { getDegreeProgrammesOfFaculty, getFacultyCodeById } from '../services/fa
import { combineFacultyBasics } from '../services/faculty/facultyBasics'
import { getFacultyCredits } from '../services/faculty/facultyCredits'
import { countGraduationTimes } from '../services/faculty/facultyGraduationTimes'
import { getSortedFaculties } from '../services/faculty/facultyHelpers'
import { getFacultiesForFacultyList } from '../services/faculty/facultyHelpers'
import {
getBasicStats,
setBasicStats,
@@ -22,7 +22,7 @@ import { combineFacultyStudentProgress } from '../services/faculty/facultyStuden
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWriters'
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../types'
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../shared/types'
import logger from '../util/logger'
// Faculty uses a lot of tools designed for Study programme.
@@ -31,7 +31,7 @@ import logger from '../util/logger'
const router = Router()
router.get('/', async (_req: Request, res: Response) => {
const faculties = await getSortedFaculties()
const faculties = await getFacultiesForFacultyList()
res.json(faculties)
})
@@ -18,7 +18,7 @@ import { getGraduationStatsForStudyTrack } from '../services/studyProgramme/stud
import { updateBasicView, updateStudyTrackView } from '../services/studyProgramme/studyProgrammeUpdates'
import { getStudyRightsInProgramme, getStudyTracksForProgramme } from '../services/studyProgramme/studyRightFinders'
import { getStudyTrackStatsForStudyProgramme } from '../services/studyProgramme/studyTrackStats'
import { Graduated, SpecialGroups, YearType } from '../types'
import { Graduated, SpecialGroups, YearType } from '../shared/types'
import logger from '../util/logger'
import { logInfoForGrafana } from '../util/logInfoForGrafana'
+2 -3
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 '../types'
import { Graduated, NameWithCode } from '../shared/types'
import logger from '../util/logger'
const router = Router()
@@ -33,7 +32,7 @@ const getProgrammeNames = (faculties: Organization[]) => {
interface GetProgressStatsRequest extends Request {
query: {
specialsIncluded: 'true' | undefined
specialsIncluded: 'true' | 'false'
graduated: Graduated
}
}
@@ -1,7 +1,7 @@
import moment from 'moment'
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
import { Graduated, SpecialGroups, YearType } from '../types'
import { Graduated, SpecialGroups, YearType } from '../shared/types'
import { redisClient } from './redis'
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
@@ -1,3 +1,4 @@
import { orderBy } from 'lodash'
import { Op } from 'sequelize'
import { Course } from '../../models'
@@ -52,7 +53,17 @@ const getRawCourses = async (name: string, code: string) => {
const getCourses = async (name: string, code: string) => {
const rawCourses = await getRawCourses(name, code)
const courses: CourseWithSubsId[] = rawCourses
.map(course => ({ ...course.dataValues }))
.map(course => ({
...course.toJSON(),
substitutions: orderBy(course.substitutions, [
substitution => {
if (/^A/.exec(substitution)) return 4 // open university codes come last
if (/^\d/.exec(substitution)) return 2 // old numeric codes come second
if (/^[A-Za-z]/.exec(substitution)) return 1 // new letter based codes come first
return 3 // unknown, comes before open uni?
},
]),
}))
.sort((a, b) => getSortRank(b.code) - getSortRank(a.code))
return courses
}
@@ -87,7 +98,7 @@ export const getCoursesByNameAndOrCode = async (name: string, code: string) => {
}
})
return { courses }
return courses
}
export const getCoursesByCodes = (codes: string[]) => {
@@ -1,12 +1,11 @@
import { cloneDeep } from 'lodash'
import { NameWithCode } from '../../shared/types'
import { DegreeProgrammeType } from '../../types'
import { getBasicStats, setBasicStats } from '../analyticsService'
import { getBasicStatsForStudytrack } from '../studyProgramme/studyProgrammeBasics'
import type { ProgrammesOfOrganization } from './faculty'
type ProgrammeName = { code: string; en?: string; fi?: string; sv?: string }
type StudyTrackBasicStats = Awaited<ReturnType<typeof getBasicStatsForStudytrack>>
const calculateCombinedStats = async (
@@ -19,12 +18,12 @@ const calculateCombinedStats = async (
graphStats: [] as StudyTrackBasicStats['graphStats'],
programmeTableStats: {} as Record<string, StudyTrackBasicStats['tableStats']>,
tableStats: [] as StudyTrackBasicStats['tableStats'],
titles: ['', 'Started\nstudying', 'Accepted', 'Graduated'],
titles: ['Code', 'Started\nstudying', 'Accepted', 'Graduated'],
}
if (includeAllSpecials) {
studentInfo.titles.push('Transferred out\nof programme', 'Transferred into\nprogramme')
}
const programmeNames: Record<string, ProgrammeName> = {}
const programmeNames: Record<string, NameWithCode> = {}
let years: Array<string | number> = []
for (const programme of facultyProgrammes) {
@@ -143,7 +142,7 @@ const calculateCombinedStats = async (
graphStats: combinedGraphStats,
programmeTableStats: combinedProgrammeTableStats,
tableStats: combinedTableStats,
titles: ['', 'All', 'Bachelors', 'Masters', 'Doctors', 'Others'],
titles: ['Code', 'All', 'Bachelors', 'Masters', 'Doctors', 'Others'],
},
}
}
@@ -1,13 +1,12 @@
import { ignoredFacultyCodes } from '../../config/organizationConstants'
import { Organization } from '../../models'
import { getOrganizations } from '../organizations'
export const getFaculties = async (): Promise<Organization[]> => {
export const getFaculties = async () => {
const faculties = (await getOrganizations()).filter(faculty => !ignoredFacultyCodes.includes(faculty.code))
return faculties
}
export const getSortedFaculties = async (): Promise<Organization[]> => {
export const getSortedFaculties = async () => {
const faculties = await getFaculties()
return faculties.sort((a, b) => {
const nameA = a.name.fi ?? ''
@@ -16,8 +15,11 @@ export const getSortedFaculties = async (): Promise<Organization[]> => {
})
}
const commissionedProgrammes = ['KH50_009', 'MH50_015', 'T923103-N']
export const checkCommissioned = (studyRight: any): boolean => {
return studyRight.studyrightElements.some(element => commissionedProgrammes.includes(element.code))
export const getFacultiesForFacultyList = async () => {
const faculties = await getSortedFaculties()
return faculties.map(faculty => ({
code: faculty.code,
id: faculty.id,
name: faculty.name,
}))
}
@@ -1,7 +1,7 @@
import moment from 'moment'
import { Name } from '../../shared/types'
import { DegreeProgrammeType, Graduated, ProgrammeFilter, SpecialGroups, YearType } from '../../types'
import { Graduated, Name, ProgrammeFilter, SpecialGroups, YearType } from '../../shared/types'
import { DegreeProgrammeType } from '../../types'
import { redisClient } from '../redis'
import { FacultyProgressData } from './facultyStudentProgress'
@@ -2,7 +2,8 @@ import { cloneDeep } from 'lodash'
import moment from 'moment'
import { rootOrgId } from '../../config'
import { Graduated, SpecialGroups, Unarray } from '../../types'
import { Graduated, SpecialGroups } from '../../shared/types'
import { Unarray } from '../../types'
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
import { getYearsArray } from '../studyProgramme/studyProgrammeHelpers'
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
@@ -133,7 +133,7 @@ export const combineFacultyThesisWriters = async (
tableStats,
graphStats,
programmeTableStats,
titles: ['', 'All', 'Bachelors', 'Masters'],
titles: ['Code', 'All', 'Bachelors', 'Masters'],
programmeNames,
status: 'Done',
lastUpdated: '',
@@ -1,4 +1,4 @@
import { StatsType } from '../../types'
import { StatsType } from '../../shared/types'
import { setCreditStats } from '../analyticsService'
import { computeCreditsProduced } from '../providerCredits'
import { getDegreeProgrammesOfFaculty } from './faculty'
@@ -31,6 +31,7 @@ type AccumulatorType = {
email: string
phoneNumber: string
secondaryEmail: string
preferredLanguage: string
}
studyright: {
startDate: Date
@@ -105,6 +106,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
sis_person_id,
email,
phone_number: phoneNumber,
preferredLanguage,
secondary_email: secondaryEmail,
} = student
return student.studyplans.reduce<AccumulatorType[]>((acc, studyPlan) => {
@@ -137,7 +139,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
)
acc.push({
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail },
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail, preferredLanguage },
studyright: {
startDate: startOfStudyright,
semesterEnrollments,
@@ -178,6 +180,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
'creditcount',
'email',
'phone_number',
'preferredLanguage',
'secondary_email',
'sis_person_id',
'studentnumber',
@@ -97,9 +97,7 @@ const getStudents = async (studentNumbers: string[]) => {
'firstnames',
'lastname',
'studentnumber',
'home_country_en',
'home_country_fi',
'home_country_sv',
'citizenships',
'dateofuniversityenrollment',
'creditcount',
'abbreviatedname',
@@ -159,9 +159,7 @@ const formatStudentForPopulationStatistics = (
tags,
birthdate,
sis_person_id,
home_country_fi,
home_country_sv,
home_country_en,
citizenships,
} = student
const criteriaCoursesBySubstitutions = criteria?.allCourses
@@ -271,9 +269,7 @@ const formatStudentForPopulationStatistics = (
birthdate,
studyplans,
sis_person_id,
home_country_en,
home_country_fi,
home_country_sv,
citizenships,
criteriaProgress: toProgressCriteria(),
curriculumVersion: getCurriculumVersion(correctStudyplan[0]?.curriculum_period_id),
transferredStudyright,
@@ -344,6 +344,7 @@ export const bySearchTermAndStudentNumbers = async (searchterm: string, studentN
},
}
: { [Op.or]: [nameLike(terms), studentnumberLike(terms)] },
order: [['dateofuniversityenrollment', 'DESC NULLS LAST']],
})
).map(formatSharedStudentData)
}
@@ -41,7 +41,7 @@ type StudyGuidanceGroup = {
}
const getGroupsFromImporter = async (sisPersonId: string) => {
if (!importerClient) {
if (!importerClient || !sisPersonId) {
return []
}
const answerTimeout = new Promise(resolve => setTimeout(resolve, 2000))
@@ -114,7 +114,30 @@ export const getTransferredCredits = async (provider: string, since: Date) =>
},
})
export const getThesisCredits = async (provider: string, thesisType: string[], studentnumbers: string[]) =>
export const getStudyRightThesisCredits = async (thesisType: string[], studyRightIds: string[]) =>
await Credit.findAll({
attributes: ['attainment_date', 'student_studentnumber'],
include: {
model: Course,
attributes: [],
where: {
course_unit_type: {
[Op.in]: thesisType,
},
},
},
where: {
credittypecode: CreditTypeCode.PASSED,
isStudyModule: {
[Op.not]: true,
},
studyright_id: {
[Op.in]: studyRightIds,
},
},
})
export const getOrganizationThesisCredits = async (provider: string, thesisType: string[], studentnumbers: string[]) =>
await Credit.findAll({
attributes: ['attainment_date', 'student_studentnumber'],
include: {
@@ -7,7 +7,7 @@ import { DegreeProgrammeType, ExtentCode, Phase, SemesterEnrollment, StudyTrack
import { getDegreeProgrammeType, getMinimumCreditsOfProgramme, sortByProgrammeCode } from '../../util'
import { countTimeCategories } from '../graduationHelpers'
import { getSemestersAndYears } from '../semesters'
import { getThesisCredits } from './creditGetters'
import { getStudyRightThesisCredits, getOrganizationThesisCredits } from './creditGetters'
import { getGraduatedStats } from './studyProgrammeBasics'
import {
defineYear,
@@ -101,19 +101,35 @@ const getGraduationTimeAndThesisWriterStats = async ({
thesisTableStats: tableStats,
}
}
const doCombo = await shouldIncludeComboStats(studyProgramme)
const graduationTimes: Record<string, number[]> = getYearsObject({ years, emptyArrays: true })
const graduationTimesCombo: Record<string, number[]> = getYearsObject({ years, emptyArrays: true })
const { semesters } = await getSemestersAndYears()
const studyRights = await getStudyRightsInProgramme(studyProgramme, false)
const studentNumbers = studyRights.map(studyRight => studyRight.studentNumber)
const thesisType = await getThesisType(studyProgramme)
const thesisCredits = await getThesisCredits(mapToProviders([studyProgramme])[0], thesisType, studentNumbers)
const thesisWriterMap = thesisCredits.reduce<Record<string, Date>>((acc, credit) => {
acc[credit.student_studentnumber] = credit.attainment_date
return acc
}, {})
const studyRights = await getStudyRightsInProgramme(studyProgramme, false)
const studyRightIds = studyRights.map(studyRight => studyRight.id)
const studentNumbers = studyRights.map(studyRight => studyRight.studentNumber)
const studyRightThesisCredits = await getStudyRightThesisCredits(thesisType, studyRightIds)
const studyRightThesisStudentNumbers = studyRightThesisCredits.map(item => item.student_studentnumber)
const organizationStudentNumbers = studentNumbers.filter(student => !studyRightThesisStudentNumbers.includes(student))
const organizationThesisCredits = await getOrganizationThesisCredits(
mapToProviders([studyProgramme])[0],
thesisType,
organizationStudentNumbers
)
const thesisWriterMap = [...studyRightThesisCredits, ...organizationThesisCredits].reduce<Record<string, Date>>(
(acc, credit) => {
acc[credit.student_studentnumber] = credit.attainment_date
return acc
},
{}
)
for (const studyRight of studyRights) {
const correctStudyRightElement = studyRight.studyRightElements.find(element => element.code === studyProgramme)
@@ -35,7 +35,7 @@ export const getStudyRightsInProgramme = async (
if (includeStudentsAndCredits) {
include.push({
model: Student,
attributes: ['gender_code', 'home_country_en'],
attributes: ['gender_code', 'citizenships'],
include: [
{
model: Credit,
@@ -267,16 +267,20 @@ const getMainStatsByTrackAndYear = async (
}
}
const studentHomeCountry = studyRight.student.home_country_en
const studentCitizenships = studyRight.student.citizenships
if (studentHomeCountry === 'Finland') {
yearlyStats[year][programmeOrStudyTrack].finnish += 1
} else {
yearlyStats[year][programmeOrStudyTrack].otherCountries += 1
if (!yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry]) {
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry] = 0
for (const citizenship of studentCitizenships) {
const country = citizenship.en
if (!country) {
continue
}
if (country === 'Finland') {
yearlyStats[year][programmeOrStudyTrack].finnish += 1
} else {
yearlyStats[year][programmeOrStudyTrack].otherCountries += 1
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[country] ??= 0
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[country] += 1
}
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry] += 1
}
if (!hasGraduated) return
-5
View File
@@ -1,5 +0,0 @@
export type Graduated = 'GRADUATED_INCLUDED' | 'GRADUATED_EXCLUDED'
export type SpecialGroups = 'SPECIAL_INCLUDED' | 'SPECIAL_EXCLUDED'
export type ProgrammeFilter = 'NEW_STUDY_PROGRAMMES' | 'ALL_PROGRAMMES'
export type YearType = 'ACADEMIC_YEAR' | 'CALENDAR_YEAR'
export type StatsType = 'ALL' | 'CREDITS' | 'STUDENT' | 'THESIS'
-1
View File
@@ -6,7 +6,6 @@ export { DegreeProgrammeType } from './degreeProgrammeType'
export { EnrollmentState } from './enrollmentState'
export { EnrollmentType } from './enrollmentType'
export { ExtentCode } from './extentCode'
export { Graduated, SpecialGroups, StatsType, ProgrammeFilter, YearType } from './faculty'
export { GenderCode } from './genderCode'
export { IamAccess } from './iamAccess'
export { Phase } from './phase'
+2 -1
View File
@@ -26,10 +26,11 @@ export const getUserIamAccess = async (
return {}
}
try {
const getSisuAccess = !isStaging && sisPersonId != null && sisPersonId !== ''
const { data: iamAccess } = await jamiClient.post('/', {
userId: sisPersonId,
iamGroups,
getSisuAccess: !isStaging,
getSisuAccess,
})
const { specialGroup } = iamAccess as { specialGroup: Record<string, boolean> }
delete iamAccess.specialGroup
+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',
@@ -1,10 +1,7 @@
export const closeToGraduationToolTips = `
You can filter students by choosing a degree programme and/or a faculty.
You can also select multiple programmes or faculties.
Please note that if you select a faculty, only the degree programmes of that faculty will be shown.
You can use the dropdowns in the ’Faculty’ and ’Programme’ column headers to filter students. You can also select multiple programmes or faculties. Please note that if you select a faculty, only the programmes of that faculty will be shown.
Students are included in this list based on the number of credits they have completed in their primary study plan.
The limits for each study programme are as follows:
Students are included in this list based on the number of credits they have completed in their primary study plan. The limits for each study programme are as follows:
- All bachelor’s programmes: 140 credits
- Degree Programme in Veterinary Medicine (eläinlääketieteen lisensiaatin koulutusohjelma): 150 credits
@@ -13,6 +10,5 @@ export const closeToGraduationToolTips = `
- Master’s Programme in Psychology (psykologian maisteriohjelma): 115 credits
- Other master’s programmes: 70 credits
If you have access to all student data in Oodikone, you can also see all students here.
Otherwise you will only see the students in your study guidance groups.
If you have access to all student data in Oodikone, you can also see all students here. Otherwise you will only see the students in your study guidance groups.
`
@@ -73,6 +73,9 @@ export const facultyToolTips: Record<string, string> = {
start date of the bachelor programme and at the moment, they do not include any transferred credits.
Thus, in these statistics some students have fewer credits than in reality.
`,
interactiveDataTable: `
Sort bars in the yearly charts by programme code or other column values. Sorting is done inside the degree group.
`,
}
facultyToolTips.studentsStatsOfTheFaculty = studyProgrammeToolTips.studyTrackOverview.replace(
@@ -37,4 +37,9 @@ export const filterToolTips = {
Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.
`,
},
citizenship: {
label: null,
short:
'Rajaa opiskelijoita kansalaisuuden perusteella. Tulokseen sisältyvät opiskelijat, joilla on valitun valtion kansalaisuus, riippumatta siitä, onko heillä myös muita kansalaisuuksia.',
},
}
@@ -208,7 +208,7 @@ export const populationStatisticsToolTips = {
3. Jos suoritukseen tai ilmoittautumiseen ei liity opiskeluoikeutta eikä kurssia ole sijoitettu opintosuunnitelmaan, käytetään viimeisintä koulutusohjelmaa suorituksen tai ilmoittautumisen hetkellä
`,
programmeDistributionCustomPopulation: `
'Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
`,
gradeDistributionCoursePopulation: 'Näyttää korkeimman arvosanan, jonka opiskelija on saanut valitulla aikavälillä.',
creditDistributionCoursePopulation: 'Näyttää tiedekunta-/ohjelmankohtaisen opintopistejakauman.',
@@ -117,7 +117,7 @@ export const studyProgrammeToolTips: Record<string, string> = {
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Vaikka opiskelija olisi ilmoittautunut toiseen opiskeluoikeuteen läsnäolevaksi, mutta hän on laiminlyönyt ilmoittautumisen tarkasteltavaan koulutusohjelmaan liittyvään opiskeluoikeuteen, hänet lasketaan ryhmään ”Inactive”.
Paksummilla pystyviivoilla eroteltujen kategorioiden (**Current status**, **Gender**, **Countries**) sarakkeet tuottavat yhteenlaskettuna kentän **All** arvon.
Paksummilla pystyviivoilla eroteltujen kategorioiden (**Current status**, **Gender**) sarakkeet tuottavat yhteenlaskettuna kentän **All** arvon. **Citizenships**-kategoriat (**Finland** ja **Other**) voivat tuottaa yhteenlaskettuna suuremman tuloksen, koska opiskelijoilla voi olla useampi kuin yksi kansalaisuus.
Jos sivun yläosassa on valittuna ”All study rights” (oletus), mukana ovat myös ohjelmaan siirtyneet ja ohjelmasta pois siirtyneet opiskelijat.
@@ -0,0 +1,16 @@
export const updaterToolTips = {
updaterSection: `
**Update meta**: Updates organisations, study modules, course units, study levels, education types, credit types
**Update students**: Updates 1000 students at one click in development and all in production environment. Takes about 5 hours in production.
**Update curriculums**: Updates all study programmes and their curriculums. This takes a few minutes, and breaks the curriculum features for that time, so do not run in production unnecessarily.
**Refresh updater redis**: Refreshes maps (COUNTRIES, EDUCATION_TYPES, ORGANISATION_ID_TO_CODE, etc.) stored in updater redis. Check \`loadMapsOnDemand\` function in updater for more information.
`,
refreshDataSection: `
**Refresh all teacher leaderboards**: Refresh all teacher leaderboard statistics from 1951 until today. Might take some time.
**Refresh teacher leaderboards of current and previous year**: Refresh teacher leaderboard statistics for current and previous academic year.
**Refresh faculties**: Refresh data for all faculties for all tabs (time consuming).
**Refresh study programmes**: Refresh data for new study programmes for basic and studytrack tabs (time consuming).
**Refresh language center data**: Refresh data for language center view.
**Refresh close to graduation data**: Refresh data for close to graduation view.
`,
}
-304
View File
@@ -1,304 +0,0 @@
import { isEqual } from 'lodash'
import qs from 'query-string'
import { useCallback, useEffect, useRef, useState } from 'react'
import { SEARCH_HISTORY_VERSION } from '@/constants'
import { useGetCurriculumPeriodsQuery } from '@/redux/curriculumPeriods'
import { useGetProgrammesQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
export const useTabChangeAnalytics = () => {
const previousTabIndex = useRef(0)
const handleTabChange = useCallback(
(_, data) => {
const { activeIndex } = data
if (previousTabIndex.current !== activeIndex) {
previousTabIndex.current = activeIndex
}
},
[previousTabIndex]
)
return { handleTabChange }
}
export const useTabs = (id, initialTab, { location, replace }) => {
const [tab, setTab] = useState(-1)
const [didMount, setDidMount] = useState(false)
const pushToUrl = newTab => {
replace({
pathname: location.pathname,
search: qs.stringify({ ...qs.parse(location.search), [id]: newTab }),
})
}
useEffect(() => {
const params = qs.parse(location.search)
const queryTab = params[id]
setTab(queryTab === undefined ? initialTab : JSON.parse(queryTab))
setDidMount(true)
}, [])
useEffect(() => {
if (tab !== undefined && didMount) pushToUrl(tab)
}, [tab])
return [
tab,
(_, { activeIndex }) => {
setTab(activeIndex)
},
]
}
export const useSearchHistory = (id, capacity = 5) => {
const [searchHistory, setSearchHistory] = useState([])
const [didMount, setDidMount] = useState(false)
const getSearchHistoryStore = () => JSON.parse(localStorage.getItem('searchHistoryStore')) || {}
const saveSearchHistoryStore = newStore => localStorage.setItem('searchHistoryStore', JSON.stringify(newStore))
const saveSearchHistory = () => {
const searchHistoryStore = getSearchHistoryStore()
searchHistoryStore[id] = searchHistory
saveSearchHistoryStore(searchHistoryStore)
}
useEffect(() => {
if (localStorage.getItem('searchHistoryVersion') !== SEARCH_HISTORY_VERSION) {
saveSearchHistoryStore({})
localStorage.setItem('searchHistoryVersion', SEARCH_HISTORY_VERSION)
}
setSearchHistory(getSearchHistoryStore()[id] || [])
setDidMount(true)
}, [])
useEffect(() => {
if (didMount) {
saveSearchHistory()
}
}, [searchHistory])
const addItem = item => {
const filteredSearchHistory = searchHistory.filter(sh => sh.text !== item.text)
setSearchHistory(
filteredSearchHistory.concat({ ...item, timestamp: new Date(), id: new Date().getTime() }).slice(-capacity)
)
}
const updateItem = item => {
const updatedSearchHistory = [{ ...item, timestamp: new Date() }].concat(
searchHistory.filter(s => s.id !== item.id)
)
setSearchHistory(updatedSearchHistory)
}
return [searchHistory, addItem, updateItem]
}
const useInterval = (callback, delay) => {
const savedCallback = useRef()
const savedId = useRef()
const clear = () => {
if (savedId.current) {
clearInterval(savedId.current)
}
}
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
const tick = () => savedCallback.current()
clear()
if (delay !== null) {
savedId.current = setInterval(tick, delay)
}
return clear
}, [delay])
}
export const useDidMount = () => {
const [didMount, setDidMount] = useState(false)
useEffect(() => {
setDidMount(true)
}, [])
return didMount
}
export const useProgress = loading => {
const didMount = useDidMount()
const [progress, setProgress] = useState(100)
const [delay, setDelay] = useState(null)
const amountToProgress = delay ? Math.ceil(Math.random() * 4) : 0
useInterval(() => {
setProgress(progress + amountToProgress > 50 ? 50 : progress + amountToProgress)
}, delay)
useEffect(() => {
if (delay && progress >= 50) {
setDelay(null)
}
}, [progress])
const restartProgress = () => {
setProgress(0)
setDelay(500)
}
const finishProgress = () => {
setDelay(null)
setTimeout(() => setProgress(100), 0)
}
useEffect(() => {
if (loading) restartProgress()
else if (didMount) finishProgress()
}, [loading])
const onProgress = progress => {
if (progress > 0) {
setProgress(50 + Math.floor(progress / 2))
}
}
return {
progress,
onProgress,
}
}
export const useTitle = title => {
useEffect(() => {
document.title = title ? `${title} - Oodikone` : 'Oodikone'
}, [title])
}
export const useLocalStorage = (key, initialValue) => {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
return initialValue
}
})
const setValue = useCallback(
value => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (error) {
console.log(error) // eslint-disable-line no-console
}
},
[key]
)
return [storedValue, setValue]
}
export const useDebounce = (value, timeout, onChange) => {
const [innerValue, setInnerValue] = useState(value)
const [dirty, setDirty] = useState(false)
const timeoutRef = useRef(null)
useEffect(() => {
if (!isEqual(value, innerValue)) {
setInnerValue(value)
setDirty(false)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [value])
const setValue = useCallback(
value => {
setInnerValue(value)
setDirty(true)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
timeoutRef.current = setTimeout(() => {
onChange(value)
setDirty(false)
}, timeout)
},
[innerValue, setInnerValue, timeoutRef, onChange, timeout]
)
const flush = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
onChange(innerValue)
setDirty(false)
}, [timeoutRef, onChange, innerValue])
return [innerValue, setValue, flush, dirty]
}
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => {
setValue(value => !value)
}, [])
return [value, toggle]
}
/**
* This hook is similar to useMemo, but it does a deep comparison of the dependencies (using `isEqual` from Lodash).
*/
export const useDeepMemo = (factory, dependencies) => {
const ref = useRef()
if (!ref.current || !isEqual(dependencies, ref.current.deps)) {
ref.current = { deps: dependencies, result: factory() }
}
return ref.current.result
}
export const useCurrentSemester = () => {
const { data: semesterData } = useGetSemestersQuery()
if (!semesterData) return null
return Object.values(semesterData.semesters).find(
semester => new Date(semester.startdate) <= new Date() && new Date(semester.enddate) >= new Date()
)
}
export const useCurrentCurriculumPeriod = () => {
const { data: curriculumPeriods = [] } = useGetCurriculumPeriodsQuery()
return curriculumPeriods.find(
curriculumPeriod =>
new Date(curriculumPeriod.startDate) <= new Date() && new Date(curriculumPeriod.endDate) >= new Date()
)
}
export const useDegreeProgrammeTypes = programmeCodes => {
const { data: degreeProgrammes } = useGetProgrammesQuery()
if (!degreeProgrammes) return {}
return programmeCodes.reduce((acc, programmeCode) => {
acc[programmeCode] = degreeProgrammes[programmeCode]?.degreeProgrammeType ?? null
return acc
}, {})
}
+1 -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(' ')
@@ -1,98 +0,0 @@
import { random } from 'lodash'
import { useEffect, useState } from 'react'
import ReactHighchart from 'react-highcharts'
import { Button, Container, Dimmer, Header, Image, Transition } from 'semantic-ui-react'
import { images } from '@/common'
import { useLogoutMutation } from '@/redux/auth'
const names = [
'mluukkai',
'jakousa',
'totutotu',
'sasumaki',
'ikuisma',
'eero3',
'mitiaine',
'rimi',
'esakemp',
'woltsu',
'cxcorp',
'ajhaa',
'joonashak',
'vaahtokarkki',
'otahontas',
'saarasat',
'popalmu',
'ollikehy',
]
const dummyData = names.map(name => ({
name,
data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].reduce((acc, i) => {
acc[i] = (acc[i - 1] || 0) + (random(0, 3) === 0 ? 0 : random(10.0, 100.0))
return acc
}, []),
}))
export const AccessDenied = ({ notEnabled }) => {
const header = notEnabled ? 'Welcome to Oodikone!' : 'Something broke'
const subheader = notEnabled
? "You're currently not allowed to enter but you will get an email when you're authorized"
: 'If this was not intended try refreshing your browser window, pressing log out or contacting grp-toska@helsinki.fi'
const [easterEgg, setEasterEgg] = useState(false)
const [logout] = useLogoutMutation()
useEffect(() => {
if (notEnabled) {
setTimeout(() => setEasterEgg(true), Math.floor(Math.random() * 1800000) + 600000)
}
}, [])
return (
<div>
<Container style={{ alignItems: 'stretch', display: 'flex', height: '100vh', justifyContent: 'space-evenly' }}>
<ReactHighchart
config={{
credits: { enabled: false },
plotOptions: { series: { label: { connectorAllowed: false }, pointStart: 2010 } },
series: dummyData,
title: { text: 'Students of Computer Science 2018-2020' },
yAxis: { title: { text: 'Cumulative credits' } },
}}
/>
<ReactHighchart
config={{
chart: { type: 'column' },
credits: { enabled: false },
series: dummyData.map(element => ({ name: element.name, data: [Math.max(...element.data)] })),
title: { text: "Your students' future" },
xAxis: { categories: ['2018'] },
yAxis: { title: { text: 'Cumulative credits' } },
}}
/>
</Container>
<Dimmer active page>
<Image centered size="medium" src={images.toskaLogo} style={{ paddingTop: '2%' }} />
<Header as="h2" inverted>
<p>{header}</p>
<Header.Subheader>{subheader}</Header.Subheader>
<br />
<Button color="pink" onClick={() => logout()}>
Log out
</Button>
</Header>
</Dimmer>
<Transition animation="fly up" duration={10000} visible={easterEgg}>
<Image
inline
size="huge"
src={images.irtomikko}
style={{ position: 'absolute', right: '10px', top: '350px' }}
verticalAlign="top"
/>
</Transition>
</div>
)
}
+28 -7
View File
@@ -1,10 +1,14 @@
import { CssBaseline } from '@mui/material'
import { ThemeProvider } from '@mui/material/styles'
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import * as Sentry from '@sentry/browser'
import HighCharts from 'highcharts'
import moment from 'moment'
import { useEffect } from 'react'
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
import { AccessDenied } from '@/components/AccessDenied'
import { AccessDenied } from '@/components/material/AccessDenied'
import { Footer } from '@/components/material/Footer'
import { NavigationBar } from '@/components/material/NavigationBar'
import { Routes } from '@/components/Routes'
@@ -13,6 +17,21 @@ import { isProduction } from '@/conf'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { theme } from '@/theme'
// TODO: What other options should be set globally?
HighCharts.setOptions({
plotOptions: {
series: {
animation: false,
},
},
})
moment.updateLocale('en', {
week: {
dow: 1, // First day of week is Monday
},
})
const addUserDetailsToLoggers = ({ id, username, mockedBy }) => {
if (!isProduction || !id || !username) {
return
@@ -28,12 +47,14 @@ const Layout = ({ children }) => (
minHeight: '100vh',
}}
>
<ThemeProvider theme={theme}>
<CssBaseline />
<NavigationBar />
<main style={{ flex: 1 }}>{children}</main>
<Footer />
</ThemeProvider>
<LocalizationProvider dateAdapter={AdapterMoment}>
<ThemeProvider theme={theme}>
<CssBaseline />
<NavigationBar />
<main style={{ flex: 1 }}>{children}</main>
<Footer />
</ThemeProvider>
</LocalizationProvider>
</div>
)
@@ -1,398 +0,0 @@
import { range } from 'lodash'
import { useState } from 'react'
import { Divider, Dropdown, Form, Icon, Loader, Message, Radio, Tab } from 'semantic-ui-react'
import { createLocaleComparator, getCurrentSemester, getEnrollmentTypeTextForExcel, isFall } from '@/common'
import { useTitle } from '@/common/hooks'
import { closeToGraduationToolTips } from '@/common/InfoToolTips'
import { StudentInfoItem } from '@/components/common/StudentInfoItem'
import { InfoBox } from '@/components/InfoBox'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { getSemestersPresentFunctions } from '@/components/PopulationStudents/StudentTable/GeneralTab/columnHelpers/semestersPresent'
import { PaginatedSortableTable } from '@/components/SortableTable/PaginatedSortableTable'
import { StudentNameVisibilityToggle, useStudentNameVisibility } from '@/components/StudentNameVisibilityToggle'
import { ISO_DATE_FORMAT, LONG_DATE_TIME_FORMAT } from '@/constants/date'
import { useGetStudentsCloseToGraduationQuery } from '@/redux/closeToGraduation'
import { useGetFacultiesQuery } from '@/redux/facultyStats'
import { useGetSemestersQuery } from '@/redux/semesters'
import { useFilteredAndFormattedStudyProgrammes } from '@/redux/studyProgramme'
import { reformatDate } from '@/util/timeAndDate'
const NUMBER_OF_DISPLAYED_SEMESTERS = 6
const getColumns = (
getTextIn,
namesVisible,
semesterEnrollmentsVisible,
studyTrackVisible,
allSemestersMap,
bachelorStudentsAreDisplayed,
semesterEnrollmentFunctions
) => {
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = semesterEnrollmentFunctions
const currentSemesterCode = getCurrentSemester(allSemestersMap)?.semestercode
const semestersToInclude = range(
isFall(currentSemesterCode)
? currentSemesterCode - NUMBER_OF_DISPLAYED_SEMESTERS + 2
: currentSemesterCode - NUMBER_OF_DISPLAYED_SEMESTERS + 1,
isFall(currentSemesterCode) ? currentSemesterCode + 2 : currentSemesterCode + 1
)
return [
{
key: 'studentNumber',
title: 'Student number',
getRowVal: row => row.student.studentNumber,
getRowContent: row => <StudentInfoItem showSisuLink student={row.student} />,
filterable: false,
},
{
key: 'name',
title: 'Name',
getRowVal: row => row.student.name,
filterable: false,
displayColumn: namesVisible,
},
{
key: 'phoneNumber',
title: 'Phone number',
getRowVal: row => row.student.phoneNumber,
displayColumn: false,
},
{
key: 'email',
title: 'Email',
getRowVal: row => row.student.email,
displayColumn: false,
},
{
key: 'secondaryEmail',
title: 'Secondary email',
getRowVal: row => row.student.secondaryEmail,
displayColumn: false,
},
{
key: 'programme',
title: 'Programme',
getRowVal: row => getTextIn(row.programme.name),
filterable: false,
},
{
key: 'studytrack',
title: 'Study track',
getRowVal: row => getTextIn(row.programme.studyTrack),
displayColumn: studyTrackVisible,
},
{
key: 'startOfStudyright',
title: 'Start of\nstudy right',
getRowVal: row => row.studyright.startDate,
formatValue: date => reformatDate(date, ISO_DATE_FORMAT),
filterType: 'date',
},
{
key: 'startedInProgramme',
title: 'Started in programme',
getRowVal: row => row.programme.startedAt,
formatValue: date => reformatDate(date, ISO_DATE_FORMAT),
filterType: 'date',
displayColumn: !bachelorStudentsAreDisplayed,
helpText:
"For students with only a study right in the master’s programme, this date is the same as 'Start of study right'. For students with study rights in both the bachelor’s and master’s programmes, this date represents when they started in the master’s programme (i.e. one day after graduating from the bachelor’s programme).",
},
{
key: 'credits',
title: 'Completed credits',
children: [
{
key: 'creditsHops',
title: 'HOPS',
getRowVal: row => row.credits.hops,
filterType: 'range',
helpText: "The credits earned from courses in the student's primary study plan",
},
{
key: 'creditsTotal',
title: 'Total',
getRowVal: row => row.credits.all,
filterType: 'range',
helpText: 'The total number of credits the student has earned at the university',
},
],
},
{
key: 'isBaMa',
title: 'Continued\nfrom\nbachelor’s',
getRowVal: row => (row.studyright.isBaMa ? 'Continued from bachelor’s' : 'Not continued from bachelor’s'),
getRowContent: row => (row.studyright.isBaMa ? <Icon color="green" name="check" /> : null),
getRowExportVal: row => (row.studyright.isBaMa ? '1' : null),
cellProps: () => ({ style: { textAlign: 'center' } }),
displayColumn: !bachelorStudentsAreDisplayed,
helpText: 'Indicates whether the student has continued their studies from a bachelor’s degree',
},
{
key: 'curriculumPeriod',
title: 'Curriculum\nperiod',
getRowVal: row => row.curriculumPeriod,
helpText: 'The curriculum period the student has chosen for their primary study plan',
},
{
key: 'semesterEnrollments',
title: 'Semesters\npresent',
filterType: 'range',
displayColumn: semesterEnrollmentsVisible,
export: false,
getRowContent: row => getSemesterEnrollmentsContent(row.student, row.studyright),
getRowVal: row => getSemesterEnrollmentsVal(row.student, row.studyright),
},
{
key: 'semestersAbsent',
title: 'Semesters\nabsent',
getRowVal: row => row.numberOfAbsentSemesters,
helpText:
'The number of semesters the student has been absent (both statutory and non-statutory absences) during their study right',
},
{
key: 'semesterEnrollmentsForExcel',
title: 'Enrollment status',
displayColumn: false,
children: semestersToInclude.map(semester => ({
key: `${semester}`,
title: getTextIn(allSemestersMap[`${semester}`]?.name),
displayColumn: false,
getRowVal: student => {
if (!student.studyright.semesterEnrollments) {
return 'Not enrolled'
}
const enrollment = student.studyright.semesterEnrollments.find(enrollment => enrollment.semester === semester)
return getEnrollmentTypeTextForExcel(enrollment?.type, enrollment?.statutoryAbsence)
},
})),
},
{
key: 'thesisCompleted',
title: 'Thesis\ncompleted',
getRowVal: row => (row.thesisInfo ? 'Thesis completed' : 'Thesis not completed'),
getRowContent: row => (row.thesisInfo ? <Icon color="green" name="check" /> : null),
getRowExportVal: row => (row.thesisInfo ? '1' : null),
cellProps: row =>
row.thesisInfo
? {
style: { textAlign: 'center' },
title: [
`Attainment date: ${reformatDate(row.thesisInfo.attainmentDate, ISO_DATE_FORMAT)}`,
`Course code: ${row.thesisInfo.courseCode}`,
`Grade: ${row.thesisInfo.grade}`,
].join('\n'),
}
: {},
helpText:
'The thesis attainment must be linked to the correct study right. You can see the attainment date, course code, and grade by hovering over the check mark.',
},
{
key: 'latestAttainmentDates',
title: 'Latest attainment date',
children: [
{
key: 'latestAttainmentHops',
title: 'HOPS',
getRowVal: row => reformatDate(row.attainmentDates.latestHops, ISO_DATE_FORMAT),
filterType: 'date',
helpText: 'The date when the student last completed a course in their primary study plan',
},
{
key: 'latestAttainmentTotal',
title: 'Total',
getRowVal: row => reformatDate(row.attainmentDates.latestTotal, ISO_DATE_FORMAT),
filterType: 'date',
helpText: 'The date when the student last completed any course at the university',
},
],
},
{
key: 'earliestAttainmentDates',
title: 'Earliest\nattainment date',
children: [
{
key: 'earliestAttainmentHops',
title: 'HOPS',
getRowVal: row => reformatDate(row.attainmentDates.earliestHops, ISO_DATE_FORMAT),
filterType: 'date',
helpText: 'The date when the student first completed a course in their primary study plan',
},
],
},
]
}
export const CloseToGraduation = () => {
useTitle('Students close to graduation')
const { data: students = {}, isError, isLoading } = useGetStudentsCloseToGraduationQuery()
const { data: faculties = [] } = useGetFacultiesQuery()
const { data: semesterData = [] } = useGetSemestersQuery()
const studyProgrammes = useFilteredAndFormattedStudyProgrammes()
const { getTextIn } = useLanguage()
const { visible: namesVisible } = useStudentNameVisibility()
const [chosenFaculties, setChosenFaculties] = useState([])
const [chosenProgrammes, setChosenProgrammes] = useState([])
const [activeTabIndex, setActiveTabIndex] = useState(0)
const [rowCount, setRowCount] = useState(0)
const [semesterEnrollmentsVisible, setSemesterEnrollmentsVisible] = useState(false)
const allSemesters = Object.entries(semesterData?.semesters || {}).map(item => item[1])
const allSemestersMap = allSemesters.reduce((obj, cur, index) => {
obj[index + 1] = cur
return obj
}, {})
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = getSemestersPresentFunctions({
getTextIn,
allSemesters,
allSemestersMap,
filteredStudents: students,
year: `${new Date().getFullYear() - Math.floor(NUMBER_OF_DISPLAYED_SEMESTERS / 2)}`,
})
const { bachelor = [], masterAndLicentiate = [] } = students
const bachelorStudentsAreDisplayed = activeTabIndex === 0
const displayedStudents = bachelorStudentsAreDisplayed ? bachelor : masterAndLicentiate
const handleDisplayedDataChange = students => setRowCount(students.length)
const filteredStudents = displayedStudents.filter(s => {
if (chosenProgrammes.length > 0) return chosenProgrammes.includes(s.programme.code)
return chosenFaculties.length === 0 || chosenFaculties.includes(s.programme.code.slice(1, 4))
})
const studyTrackVisible =
chosenProgrammes.length === 1 && filteredStudents.some(student => student.programme.studyTrack != null)
const columns = getColumns(
getTextIn,
namesVisible,
semesterEnrollmentsVisible,
studyTrackVisible,
allSemestersMap,
bachelorStudentsAreDisplayed,
{
getSemesterEnrollmentsContent,
getSemesterEnrollmentsVal,
}
)
const onTabChange = (_event, { activeIndex }) => {
setActiveTabIndex(activeIndex)
setChosenFaculties([])
setChosenProgrammes([])
}
const renderContent = () => {
if (isError) {
return (
<Message content="Please try reloading the page." header="There was an error" icon="ban" negative size="big" />
)
}
if (isLoading) return <Loader active inline="centered" />
const facultyOptions = faculties.map(faculty => ({
key: faculty.code,
text: getTextIn(faculty.name),
value: faculty.code,
description: faculty.code,
}))
const programmeOptions = studyProgrammes
.filter(
programme =>
!programme.value.includes('+') && programme.value.startsWith(bachelorStudentsAreDisplayed ? 'KH' : 'MH')
)
.filter(programme => (chosenFaculties.length > 0 ? chosenFaculties.includes(programme.value.slice(1, 4)) : true))
.sort(createLocaleComparator('text'))
return (
<Tab.Pane>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<InfoBox content={closeToGraduationToolTips} />
<Form style={{ width: '100%', marginBottom: '1em' }}>
<Form.Field>
<label>Faculties</label>
<Dropdown
multiple
onChange={(_, { value }) => {
setChosenFaculties([...value])
setChosenProgrammes(chosenProgrammes.filter(p => value.includes(p.slice(1, 4))))
}}
options={facultyOptions}
placeholder="Choose faculties"
search
selection
value={chosenFaculties}
/>
</Form.Field>
<Form.Field>
<label>Degree programmes</label>
<Dropdown
multiple
onChange={(_, { value }) => setChosenProgrammes([...value])}
options={programmeOptions}
placeholder="Choose degree programmes"
search
selection
value={chosenProgrammes}
/>
</Form.Field>
</Form>
<Message info style={{ width: '100%' }}>
{`Last updated: ${reformatDate(students.lastUpdated, LONG_DATE_TIME_FORMAT)}`}
</Message>
<PaginatedSortableTable
columns={columns}
data={filteredStudents}
defaultSort={['programme', 'asc']}
featureName="students_close_to_graduation"
handleDisplayedDataChange={handleDisplayedDataChange}
rowCount={rowCount}
rowsPerPage={200}
title={`Students close to graduation (${rowCount} out of ${displayedStudents.length} students selected)`}
/>
</div>
</Tab.Pane>
)
}
const panes = [
{
menuItem: "Bachelor's programmes",
render: renderContent,
},
{
menuItem: "Master's and licentiate's programmes",
render: renderContent,
},
]
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Divider horizontal style={{ margin: '3em' }}>
Students close to graduation
</Divider>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginBottom: '2em',
padding: '2em',
gap: '2em',
}}
>
<StudentNameVisibilityToggle style={{ marginTop: '0', marginBottom: '0' }} />
<Radio
checked={semesterEnrollmentsVisible}
label="Show semester enrollments"
onChange={() => setSemesterEnrollmentsVisible(!semesterEnrollmentsVisible)}
toggle
/>
</div>
<Tab activeIndex={activeTabIndex} onTabChange={onTabChange} panes={panes} style={{ maxWidth: '80%' }} />
</div>
)
}
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Divider, Loader, Tab } from 'semantic-ui-react'
import { getCurrentSemester } from '@/common'
import { useTitle } from '@/common/hooks'
import { useTitle } from '@/hooks/title'
import { useGetSemestersQuery } from '@/redux/semesters'
import { ColorizedCoursesTableContext } from './common'
import { FacultiesTab } from './FacultiesTab'
@@ -1,233 +0,0 @@
import qs from 'query-string'
import { useEffect, useState } from 'react'
import { useLocation, useHistory } from 'react-router-dom'
import { Button, Form, Loader, Modal, TextArea } from 'semantic-ui-react'
import { SearchHistory } from '@/components/SearchHistory'
import {
useCreateCourseListMutation,
useDeleteCourseListMutation,
useGetSavedCourseListsQuery,
useUpdateCourseListMutation,
} from '@/redux/completedCoursesSearch'
export const CompletedCoursesSearch = ({ setValues }) => {
const location = useLocation()
const history = useHistory()
const [modal, setModal] = useState(false)
const [courseInput, setCourseInput] = useState('')
const [studentInput, setStudentInput] = useState('')
const [name, setName] = useState('')
const courseLists = useGetSavedCourseListsQuery()
const [searchList, setSearches] = useState(null)
const [selectedSearchId, setSelectedSearchId] = useState('')
const isFetchingOrLoading = courseLists.isLoading || courseLists.isFetching
const isError = courseLists.isError || (courseLists.isSuccess && !courseLists.data)
const [updateCourseList, { isLoading: updateIsLoading, data: updatedData }] = useUpdateCourseListMutation()
const [createCourseList, { isLoading: createIsLoading, data: createdData }] = useCreateCourseListMutation()
const [deleteCourseList, { isLoading: deleteIsLoading, data: deletedData }] = useDeleteCourseListMutation()
const parseQueryFromUrl = () => {
const query = qs.parse(location.search)
if (!Array.isArray(query.courseList)) query.courseList = [query.courseList]
if (!Array.isArray(query.studentList)) query.studentList = [query.studentList]
return query
}
useEffect(() => {
if (courseLists.isSuccess && courseLists.data) {
setSearches(courseLists.data)
}
}, [courseLists])
useEffect(() => {
if (updatedData) {
const updatedSearches = searchList.map(s => (s.id === updatedData.id ? updatedData : s))
setSearches(updatedSearches)
}
}, [updatedData])
useEffect(() => {
if (createdData && !createdData.error) {
const newList = searchList.concat(createdData)
setSearches(newList)
}
}, [createdData])
useEffect(() => {
if (deletedData) {
const filteredSearches = searchList.filter(s => s.id !== deletedData)
setSearches(filteredSearches)
}
}, [deletedData])
useEffect(() => {
if (location.search) {
const query = parseQueryFromUrl()
setValues(query)
}
}, [location.search])
const clearForm = () => {
setCourseInput('')
setStudentInput('')
setSelectedSearchId('')
}
const pushQueryToUrl = query => {
const searchString = qs.stringify(query)
history.push({ search: searchString })
}
const handleClose = () => {
setModal(false)
clearForm()
}
const onDelete = () => {
deleteCourseList({ id: selectedSearchId })
clearForm()
}
const onSave = () => {
const courseList = courseInput
.split(/[\s,]+/)
.map(code => code.trim())
.filter(code => code !== '')
if (selectedSearchId !== '') {
updateCourseList({ id: selectedSearchId, courseList })
} else {
createCourseList({ courseList, name })
}
}
const onSelectSearch = selectedId => {
if (!selectedId) {
clearForm()
return
}
const { id: selectedSearchId } = selectedId
const selectedSearch = searchList?.find(search => search.id === selectedSearchId)
if (selectedSearch) {
setCourseInput(selectedSearch.courseList.join(', '))
setName(selectedSearch.name)
setSelectedSearchId(selectedSearch.id)
}
}
const onClicker = event => {
event.preventDefault()
const courseList = courseInput
.split(/[\s,]+/)
.map(code => code.trim())
.filter(code => code !== '')
const studentList = studentInput
.split(/[;\s,]+/)
.map(code => code.trim())
.filter(s => s !== '')
.map(s => (s.length === 8 ? `0${s}` : s))
if (courseList.length === 0 || studentList.length === 0 || studentList[0] === null) {
return
}
setValues({
studentList,
courseList,
})
pushQueryToUrl({ courseList, studentList })
handleClose()
}
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
if (isFetchingOrLoading) return <Loader active style={{ marginTop: '15em' }} />
return (
<Modal
onClose={handleClose}
open={modal}
size="small"
trigger={
<Button color="blue" data-cy="open-completed-courses-modal-button" onClick={() => setModal(true)} size="small">
Search completed courses of students
</Button>
}
>
<Modal.Content>
<Form>
<h2>Search completed courses of students</h2>
<Form.Field>
<em>Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.</em>
<TextArea
data-cy="student-no-input"
onChange={event => setStudentInput(event.target.value)}
placeholder="012345678, 12345678"
value={studentInput}
/>
</Form.Field>
<Form.Field>
<em>Insert one or more courses, separated by a space, a newline, or a comma.</em>
<TextArea
data-cy="course-list-input"
onChange={event => setCourseInput(event.target.value)}
placeholder="TKT12345, PSYK-123"
value={courseInput}
/>
</Form.Field>
<Form.Field>
<Form.Field data-cy="search-name">
<em> Insert name for this course list if you wish to save it </em>
<Form.Input
disabled={selectedSearchId !== ''}
onChange={event => setName(event.target.value)}
placeholder="name"
value={name}
/>
</Form.Field>
</Form.Field>
<SearchHistory
handleSearch={onSelectSearch}
header="Saved courselists"
items={
searchList
? searchList.map(list => ({
...list,
text: list.name,
timestamp: new Date(list.updatedAt),
params: { id: list.id },
}))
: []
}
updateItem={() => null}
/>
<Modal.Actions>
<Button
content="Save"
data-cy="save-courselist"
disabled={!name || updateIsLoading || createIsLoading}
floated="left"
icon="save"
loading={updateIsLoading || createIsLoading}
onClick={onSave}
/>
<Button
content="Delete"
disabled={!selectedSearchId || deleteIsLoading}
floated="left"
icon="trash"
negative
onClick={onDelete}
/>
<Button onClick={handleClose}>Cancel</Button>
<Button data-cy="completed-courses-search-button" onClick={event => onClicker(event)} positive>
Search
</Button>
</Modal.Actions>
</Form>
</Modal.Content>
</Modal>
)
}
@@ -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>
)
}
@@ -2,11 +2,11 @@ import { maxBy } from 'lodash'
import moment from 'moment'
import { Loader, Tab, Table } from 'semantic-ui-react'
import { useCurrentSemester } from '@/common/hooks'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { findCorrectProgramme } from '@/components/CustomPopulation/CustomPopulationProgrammeDist'
import { InfoBox } from '@/components/InfoBox'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useCurrentSemester } from '@/hooks/currentSemester'
import { useGetFacultiesQuery } from '@/redux/facultyStats'
import { useGetSemestersQuery } from '@/redux/semesters'
@@ -1,9 +1,8 @@
import qs from 'query-string'
import { useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocation } from 'react-router'
import { Form, Header, Input, Segment } from 'semantic-ui-react'
import { getStudentToTargetCourseDateMap, getUnifyTextIn } from '@/common'
import { useCurrentSemester, useProgress, useTitle } from '@/common/hooks'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { PanelView } from '@/components/common/PanelView'
import {
@@ -26,6 +25,9 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
import { PopulationStudentsContainer as PopulationStudents } from '@/components/PopulationStudents'
import { ProgressBar } from '@/components/ProgressBar'
import { useCurrentSemester } from '@/hooks/currentSemester'
import { useProgress } from '@/hooks/progress'
import { useTitle } from '@/hooks/title'
import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourses'
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
@@ -1,9 +0,0 @@
.controlIcon,
.controlIcon:active {
box-shadow: none !important;
background: none !important;
}
#selected-table {
padding: 0 !important;
}
@@ -1,68 +0,0 @@
import { sortBy } from 'lodash'
import { arrayOf, bool, func, shape, string } from 'prop-types'
import { memo } from 'react'
import { Table } from 'semantic-ui-react'
import { getActiveYears } from '@/components/CourseStatistics/courseStatisticsUtils'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import './courseTable.css'
const CourseTable = ({ courses, onSelectCourse, hidden, title, controlIcon }) => {
const { getTextIn } = useLanguage()
const noContent = courses.length === 0
const sortCourses = courses => sortBy(courses, course => getTextIn(course.name))
const getEmptyListRow = () => (
<Table.Row>
<Table.Cell colSpan="3" content="No results." />
</Table.Row>
)
const toCourseRow = course => (
<Table.Row
key={course.id}
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
style={{ cursor: 'pointer' }}
>
<Table.Cell width={10}>
<div>{getTextIn(course.name)}</div>
<div>{getActiveYears(course)}</div>
</Table.Cell>
<Table.Cell content={[course.code, ...course.substitutions].join(', ')} />
{controlIcon ? <Table.Cell icon={controlIcon} /> : null}
</Table.Row>
)
return (
!hidden && (
<Table className="fixed-header" selectable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>{title}</Table.HeaderCell>
<Table.HeaderCell>Code</Table.HeaderCell>
{controlIcon ? <Table.HeaderCell /> : null}
</Table.Row>
</Table.Header>
<Table.Body>{noContent ? getEmptyListRow() : sortCourses(courses).map(toCourseRow)}</Table.Body>
</Table>
)
)
}
CourseTable.propTypes = {
courses: arrayOf(shape({ code: string, name: shape({}), seleted: bool })).isRequired,
onSelectCourse: func.isRequired,
hidden: bool.isRequired,
title: string.isRequired,
controlIcon: string,
}
const areEqual = (prevProps, nextProps) => {
if (prevProps.courses.length !== nextProps.courses.length) {
return false
}
return prevProps.courses.every(c1 => nextProps.courses.some(c2 => c1.code === c2.code))
}
export const MemoizedCourseTable = memo(CourseTable, areEqual)
@@ -1,67 +0,0 @@
import { useEffect, useState } from 'react'
import { Input } from 'semantic-ui-react'
import { Timeout } from '@/components/Timeout'
enum Timeouts {
FETCH = 'fetch',
SEARCH = 'search',
}
interface AutoSubmitSearchInputProps {
cypress: string
doSearch: (searchTerm: string) => void
inputValue: string
loading: boolean
onChange: (searchTerm: string) => void
setTimeout: (timeout: Timeouts, callback: () => void, latency: number) => void
placeholder: string
}
const AutoSubmitSearchInput = ({
cypress,
doSearch,
inputValue,
loading,
onChange,
setTimeout,
placeholder,
}: AutoSubmitSearchInputProps) => {
const [input, setInput] = useState(inputValue)
const latency = 250
const minSearchLength = 0
const executeSearch = (searchTerm: string) => {
clearTimeout(Timeouts.FETCH)
setTimeout(Timeouts.FETCH, () => doSearch(searchTerm), latency)
}
useEffect(() => {
if ((input || input === '') && input.length >= minSearchLength) {
executeSearch(input)
}
}, [input])
const handleSearchChange = (_event, { value }) => {
clearTimeout(Timeouts.SEARCH)
if (value.length >= 0) {
onChange(value)
setTimeout(Timeouts.SEARCH, () => setInput(value), latency)
} else {
onChange('')
}
}
return (
<Input
data-cy={cypress}
fluid
icon="search"
loading={loading}
onChange={handleSearchChange}
placeholder={placeholder}
value={inputValue}
/>
)
}
export const TimeoutAutoSubmitSearchInput = Timeout(AutoSubmitSearchInput)
@@ -1,363 +0,0 @@
import { sortBy } from 'lodash'
import qs from 'query-string'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory, useLocation } from 'react-router-dom'
import { Form, Header, Message, Popup, Radio, Segment } from 'semantic-ui-react'
import { validateInputLength } from '@/common'
import { useSearchHistory, useToggle } from '@/common/hooks'
import { MemoizedCourseTable as CourseTable } from '@/components/CourseStatistics/CourseTable'
import { TimeoutAutoSubmitSearchInput as AutoSubmitSearchInput } from '@/components/CourseStatistics/SearchForm/AutoSubmitSearchInput'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { SearchHistory } from '@/components/SearchHistory'
import { clearCourses, findCoursesV2 } from '@/redux/coursesearch'
import { getCourseStats, clearCourseStats } from '@/redux/coursestats'
import { getCourseSearchResults } from '@/selectors/courses'
const INITIAL = {
courseName: '',
courseCode: '',
selectedCourses: {},
separate: false,
}
// For now, let's allow more courses because it's necessary and doesn't necessarily fail if the courses
// have small populations (this used to be limited to 40)
const MAX_SELECTED_COURSES = 99999
const searchBoxStyle = {
border: '2px solid black',
borderRadius: '0.3em',
padding: '12px',
boxShadow: '0 2px 4px 0 rgb(34 36 38 / 12%), 0 2px 10px 0 rgb(34 36 38 / 15%)',
}
export const SearchForm = ({ onProgress }) => {
const { getTextIn } = useLanguage()
const dispatch = useDispatch()
const location = useLocation()
const history = useHistory()
const isLoading = useSelector(state => state.courseStats.pending)
const [combineSubstitutions, toggleCombineSubstitutions] = useToggle(true)
const [selectMultipleCoursesEnabled, toggleSelectMultipleCoursesEnabled] = useToggle(false)
const matchingCourses = useSelector(state => getCourseSearchResults(state, combineSubstitutions))
const [state, setState] = useState({ ...INITIAL })
const { courseName, courseCode, selectedCourses, separate } = state
const [searchHistory, addItemToSearchHistory, updateItemInSearchHistory] = useSearchHistory('courseSearch', 6)
const parseQueryFromUrl = () => {
const {
courseCodes,
separate,
unifyOpenUniCourses,
combineSubstitutions: combineSubstitutionsFromUrl,
...rest
} = qs.parse(location.search)
const query = {
...INITIAL,
...rest,
courseCodes: JSON.parse(courseCodes),
separate: JSON.parse(separate),
combineSubstitutions: combineSubstitutionsFromUrl ?? JSON.parse(combineSubstitutions),
}
return query
}
const fetchStatisticsFromUrlParams = () => {
const query = parseQueryFromUrl()
setState({ ...state, ...query })
dispatch(getCourseStats(query, onProgress))
}
useEffect(() => {
if (!location.search) {
dispatch(clearCourses())
dispatch(clearCourseStats())
}
}, [])
useEffect(() => {
if (location.search) {
fetchStatisticsFromUrlParams()
}
}, [location.search])
const onSelectCourse = course => {
course.selected = !course.selected
const isSelected = !!selectedCourses[course.code]
if (isSelected) {
const { [course.code]: omit, ...rest } = selectedCourses
setState({
...state,
selectedCourses: rest,
})
} else {
setState({
...state,
selectedCourses: {
...selectedCourses,
[course.code]: { ...course, selected: true },
},
})
}
}
const pushQueryToUrl = query => {
const { courseCodes, ...rest } = query
const queryObject = {
...rest,
courseCodes: JSON.stringify(courseCodes),
combineSubstitutions: JSON.stringify(combineSubstitutions),
}
const searchString = qs.stringify(queryObject)
history.push({ search: searchString })
}
const onSearchHistorySelected = historyItem => {
pushQueryToUrl(historyItem)
}
const onSubmitFormClick = () => {
const codes = sortBy(Object.keys(selectedCourses))
const params = {
courseCodes: codes,
separate,
combineSubstitutions,
}
const searchHistoryText = codes.map(code => `${getTextIn(selectedCourses[code].name)} ${code}`)
addItemToSearchHistory({
text: searchHistoryText.join(', '),
params,
})
pushQueryToUrl(params)
}
const fetchCourses = () => {
const isValidName = validateInputLength(courseName, 5)
const isValidCode = validateInputLength(courseCode, 2)
if (isValidName || isValidCode) {
return dispatch(findCoursesV2({ name: courseName, code: courseCode, combineSubstitutions }))
}
if (courseName.length < 5 && courseCode.length < 2) {
dispatch(clearCourses())
}
return Promise.resolve()
}
const clearSelectedCourses = () => {
setState({ ...state, selectedCourses: {} })
}
useEffect(() => {
if (Object.keys(selectedCourses).length === 0 || selectMultipleCoursesEnabled) return
onSubmitFormClick()
}, [selectedCourses])
useEffect(() => {
const fetchData = async () => {
await fetchCourses()
}
fetchData()
clearSelectedCourses()
}, [combineSubstitutions])
useEffect(() => {
if (selectMultipleCoursesEnabled) return
clearSelectedCourses()
}, [selectMultipleCoursesEnabled])
const courses = matchingCourses.filter(course => !selectedCourses[course.code])
const selected = Object.values(selectedCourses).map(course => ({ ...course, selected: true }))
const noSelectedCourses = selected.length === 0
const disabled = isLoading || noSelectedCourses || selected.length > MAX_SELECTED_COURSES
const renderFetchStatisticsButton = () => {
const FetchStatisticsButton = (
<Form.Button
basic
content="Fetch statistics"
data-cy="fetch-stats-button"
disabled={disabled}
fluid
onClick={onSubmitFormClick}
positive
primary
size="huge"
type="button"
/>
)
if (noSelectedCourses) {
return null
}
if (disabled) {
return (
<Popup
content={getTextIn({
fi: `Olet valinnut ${selected.length} kurssia. Voit valita tarkasteltavaksi enintään ${MAX_SELECTED_COURSES} kurssia kerrallaan. Tarkenna hakua tai poista valittuja kursseja.`,
en: `You have selected ${selected.length} courses. You can select up to ${MAX_SELECTED_COURSES} courses at a time. Refine your search or remove selected courses.`,
})}
on="hover"
position="top center"
trigger={<span>{FetchStatisticsButton}</span>}
/>
)
}
return FetchStatisticsButton
}
const addAllCourses = () => {
courses.forEach(course => {
course.selected = true
})
const newSelectedCourses = courses.reduce((newSelected, course) => {
newSelected[course.code] = { ...course }
return newSelected
}, {})
setState({
...state,
selectedCourses: {
...selectedCourses,
...newSelectedCourses,
},
})
}
return (
<>
<Segment loading={isLoading}>
<Form>
<Header>Search for courses</Header>
<div style={{ marginBottom: '15px' }}>
<Form.Group>
<Form.Field width={7}>
<label>Name</label>
<AutoSubmitSearchInput
cypress="course-name-input"
doSearch={fetchCourses}
inputValue={courseName}
loading={isLoading}
onChange={courseName => setState({ ...state, courseName })}
placeholder="Search by course name"
/>
</Form.Field>
<Form.Field width={4}>
<label>Code</label>
<AutoSubmitSearchInput
cypress="course-code-input"
doSearch={fetchCourses}
inputValue={courseCode}
loading={isLoading}
onChange={courseCode => setState({ ...state, courseCode })}
placeholder="Search by course code"
/>
</Form.Field>
<Form.Field>
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: '20px',
marginLeft: '10px',
}}
>
<Popup
content={getTextIn({
fi: 'Jos "Combine substitutions" on valittuna (oletuksena), niin kurssi ja leikkaavat kurssit yhdistetään tilastoissa.',
en: 'If "Combine substitutions" is on (default behavior), then course and its substitutions are combined in the statistics.',
})}
on="hover"
position="top center"
size="tiny"
trigger={
<Radio
checked={combineSubstitutions}
data-cy="combine-substitutions-toggle"
label="Combine substitutions"
onChange={toggleCombineSubstitutions}
toggle
/>
}
/>
<Popup
content={getTextIn({
fi: 'Jos "Select multiple courses" on valittuna, voit valita tarkasteltavaksi useita kursseja.',
en: 'If "Select multiple courses" is on, you can select multiple courses to view statistics for.',
})}
on="hover"
position="top center"
size="tiny"
trigger={
<Radio
checked={selectMultipleCoursesEnabled}
data-cy="select-multiple-courses-toggle"
label="Select multiple courses"
onChange={toggleSelectMultipleCoursesEnabled}
toggle
/>
}
/>
</div>
</Form.Field>
</Form.Group>
{selectMultipleCoursesEnabled && (
<Message>
<b style={{ color: 'red', fontSize: '14pt' }}>Notice:</b> Selecting many courses may fail if there are
too many students in total. If the feature does not work, try again with fewer courses. <br /> <br />
<b>Currently selected: {Object.keys(state.selectedCourses).length || 0}</b>
</Message>
)}
<div style={!noSelectedCourses ? searchBoxStyle : null}>
<CourseTable
controlIcon="trash alternate outline"
courses={selected}
hidden={noSelectedCourses}
onSelectCourse={onSelectCourse}
selectedTable
title="Selected courses"
/>
{renderFetchStatisticsButton()}
</div>
<CourseTable
courses={courses}
hidden={isLoading || (Object.keys(courses).length === 0 && Object.keys(selectedCourses).length > 0)}
onSelectCourse={onSelectCourse}
title="Searched courses"
/>
{courses.length && selectMultipleCoursesEnabled ? (
<div className="select-all-container">
<Form.Button
basic
color="green"
content="Select all search results"
onClick={addAllCourses}
size="large"
type="button"
/>
</div>
) : null}
</div>
</Form>
</Segment>
<SearchHistory
disabled={isLoading}
handleSearch={onSearchHistorySelected}
items={searchHistory.map(item => {
item.timestamp = new Date(item.timestamp)
return item
})}
updateItem={updateItemInSearchHistory}
/>
</>
)
}
@@ -1,39 +0,0 @@
.container {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.yearSelectorGroup {
align-items: center !important;
}
.yearSelect {
padding-right: 0 !important;
}
.yearSelectInput input {
border-bottom-right-radius: 0 !important;
border-top-right-radius: 0 !important;
}
.yearControl {
padding-top: 22px !important;
padding-left: 0 !important;
}
.yearControlButtonGroup {
border-bottom-left-radius: 0 !important;
border-top-left-radius: 0 !important;
}
.yearControlButton {
font-size: 8px !important;
}
.select-all-container {
width: 100%;
display: flex;
justify-content: space-around;
}
@@ -8,9 +8,9 @@ import ReactHighstock from 'react-highcharts/ReactHighstock'
import { Button, Radio } from 'semantic-ui-react'
import { getStudyRightElementTargetDates } from '@/common'
import { useDeepMemo } from '@/common/hooks'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
import { useDeepMemo } from '@/hooks/deepMemo'
import { reformatDate } from '@/util/timeAndDate'
import { CreditGraphTooltip } from './CreditGraphTooltip'
@@ -2,7 +2,7 @@ import moment from 'moment'
import qs from 'query-string'
import { useEffect, useState } from 'react'
import Datetime from 'react-datetime'
import { useLocation, useHistory } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router'
import { Button, Form, Modal, TextArea } from 'semantic-ui-react'
import { SearchHistory } from '@/components/SearchHistory'
@@ -14,7 +14,7 @@ import {
export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
const location = useLocation()
const history = useHistory()
const navigate = useNavigate()
const [modal, setModal] = useState(false)
const [input, setInput] = useState('')
const [searchList, setSearches] = useState(savedSearches)
@@ -53,7 +53,7 @@ export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
const pushQueryToUrl = query => {
setTimeout(() => {
const searchString = qs.stringify(query)
history.push({ search: searchString })
navigate({ search: searchString }, { replace: true })
}, 0)
}
@@ -2,8 +2,8 @@ import moment from 'moment'
import { useState } from 'react'
import { Message, Icon, Loader } from 'semantic-ui-react'
import { useTitle } from '@/common/hooks'
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
import { useTitle } from '@/hooks/title'
import { useGetSavedSearchesQuery } from '@/redux/openUniPopulations'
import { CustomOpenUniSearch } from './CustomOpenUniSearch'
import { OpenUniPopulationResults } from './OpenUniPopulationResults'
@@ -1,12 +1,12 @@
import moment from 'moment'
import { getNewestProgrammeOfStudentAt } from '@/common'
import { useCurrentSemester } from '@/common/hooks'
import { FilterToggleIcon } from '@/components/common/FilterToggleIcon'
import { ProgressBarWithLabel } from '@/components/common/ProgressBarWithLabel'
import { isProgrammeSelected, toggleProgrammeSelection } from '@/components/FilterView/filters/programmes'
import { useFilters } from '@/components/FilterView/useFilters'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useCurrentSemester } from '@/hooks/currentSemester'
import { useGetSemestersQuery } from '@/redux/semesters'
import { SearchResultTable } from './SearchResultTable'
@@ -2,8 +2,8 @@ import { useState } from 'react'
import { Button, Form, Modal, TextArea } from 'semantic-ui-react'
import { extractItems, textAndDescriptionSearch } from '@/common'
import { useTitle } from '@/common/hooks'
import { SearchHistory } from '@/components/SearchHistory'
import { useTitle } from '@/hooks/title'
import {
useCreateCustomPopulationSearchMutation,
useDeleteCustomPopulationSearchMutation,
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Form, Header, Input, Label, Message, Segment } from 'semantic-ui-react'
import { useProgress, useTitle } from '@/common/hooks'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { PanelView } from '@/components/common/PanelView'
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
@@ -24,6 +23,8 @@ import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/Po
import { PopulationStudentsContainer as PopulationStudents } from '@/components/PopulationStudents'
import { ProgressBar } from '@/components/ProgressBar'
import { RightsNotification } from '@/components/RightsNotification'
import { useProgress } from '@/hooks/progress'
import { useTitle } from '@/hooks/title'
import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourses'
import { useGetCustomPopulationQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
@@ -3,7 +3,7 @@ import { Component, Suspense } from 'react'
import { connect } from 'react-redux'
import { Loader } from 'semantic-ui-react'
import { AccessDenied } from '@/components/AccessDenied'
import { AccessDenied } from '@/components/material/AccessDenied'
let sent = null
@@ -1,356 +0,0 @@
import { useState } from 'react'
import { Divider, Loader, Message, Radio } from 'semantic-ui-react'
import { facultyToolTips } from '@/common/InfoToolTips'
import { makeGraphData, makeTableStats } from '@/components/common/CreditsProduced'
import { InteractiveDataTable } from '@/components/FacultyStatistics/InteractiveDataView'
import { InfoBox } from '@/components/InfoBox'
import { LineGraph } from '@/components/StudyProgramme/BasicOverview/LineGraph'
import { StackedBarChart } from '@/components/StudyProgramme/BasicOverview/StackedBarChart'
import { Toggle } from '@/components/StudyProgramme/Toggle'
import '@/components/FacultyStatistics/faculty.css'
import '@/components/StudyProgramme/studyprogramme.css'
import {
useGetFacultyBasicStatsQuery,
useGetFacultyCreditStatsQuery,
useGetFacultyThesisStatsQuery,
} from '@/redux/facultyStats'
import { useGetProgrammesQuery } from '@/redux/populations'
import { getTimestamp } from '@/util/timeAndDate'
const calculateTotals = stats => {
const totals = {}
for (const id of stats.ids) {
const providerStats = stats[id].stats
for (const year of Object.keys(providerStats)) {
const yearStats = providerStats[year]
if (!totals[year]) totals[year] = {}
for (const field of Object.keys(yearStats)) {
if (field === 'total') continue
if (!totals[year][field]) totals[year][field] = 0
if (!totals[year].total) totals[year].total = 0
totals[year][field] += Math.round(yearStats[field])
totals[year].total += Math.round(yearStats[field])
}
}
}
return totals
}
const getSortedProgrammeIdsAndPlotLinePlaces = (data, degreeProgrammes, faculty) => {
if (!data || !degreeProgrammes) {
return { programmes: [], chartPlotLinePlaces: [] }
}
const programmeCodes = Object.values(data)
.map(prog => prog.code)
.reduce((acc, code) => {
acc[code] = degreeProgrammes[code]?.degreeProgrammeType ?? null
return acc
}, {})
const programmeIds = Object.entries(data).reduce((acc, [key, value]) => {
acc[value.code] = key
return acc
}, {})
const degreeTypes = [
{
types: ['urn:code:degree-program-type:bachelors-degree'],
key: 'Bachelors',
programmes: [],
},
{
types: ['urn:code:degree-program-type:masters-degree'],
key: 'Masters',
programmes: [],
},
{
types: ['urn:code:degree-program-type:doctor', 'urn:code:degree-program-type:lic'],
key: 'Doctors and Licentiates',
programmes: [],
},
{
types: ['urn:code:degree-program-type:postgraduate-professional'],
key: 'Postgraduate professionals',
programmes: [],
},
]
for (const [programmeCode, degreeType] of Object.entries(programmeCodes)) {
const progId = programmeIds[programmeCode]
for (const degree of degreeTypes) {
if (degree.types.includes(degreeType)) {
degree.programmes.push(progId)
break
}
}
}
for (const degree of degreeTypes) {
degree.programmes.sort((a, b) => a.localeCompare(b))
}
const sortedProgrammes = []
const plotLinePlaces = []
for (const degree of degreeTypes) {
if (degree.programmes.length > 0) {
plotLinePlaces.push([sortedProgrammes.length, degree.key])
sortedProgrammes.push(...degree.programmes)
}
}
if (faculty) {
plotLinePlaces.push([sortedProgrammes.length, 'Produced by faculty'])
sortedProgrammes.push(faculty)
}
return { programmes: sortedProgrammes, plotLinePlaces }
}
export const BasicOverview = ({
academicYear,
faculty,
setAcademicYear,
setSpecialGroups,
setStudyProgrammes,
specialGroups,
studyProgrammes,
}) => {
const [showAll, setShowAll] = useState(false)
const yearType = academicYear ? 'ACADEMIC_YEAR' : 'CALENDAR_YEAR'
const studyProgrammeFilter = studyProgrammes ? 'ALL_PROGRAMMES' : 'NEW_STUDY_PROGRAMMES'
const special = specialGroups ? 'SPECIAL_EXCLUDED' : 'SPECIAL_INCLUDED'
const basics = useGetFacultyBasicStatsQuery({
id: faculty?.id,
yearType,
studyProgrammeFilter,
specialGroups: special,
})
const thesisWriters = useGetFacultyThesisStatsQuery({
id: faculty?.id,
yearType,
studyProgrammeFilter,
specialGroups: special,
})
const credits = useGetFacultyCreditStatsQuery({
id: faculty?.id,
yearType,
})
const { data: degreeProgrammes } = useGetProgrammesQuery()
const tableStats = credits.data ? makeTableStats(calculateTotals(credits.data), showAll, academicYear) : {}
const graphStats = credits.data ? makeGraphData(calculateTotals(credits.data), showAll, academicYear) : null
const programmeStats = credits.data?.ids.reduce((obj, id) => {
return {
...obj,
[id]: makeTableStats(credits.data[id].stats, showAll, academicYear).data,
}
}, {})
const getDivider = (title, toolTipText) => (
<>
<Divider data-cy={`Section-${toolTipText}`} horizontal>
{title}
</Divider>
<InfoBox content={facultyToolTips[toolTipText]} cypress={toolTipText} />
</>
)
const isFetchingOrLoading =
credits.isLoading ||
credits.isFetching ||
basics.isLoading ||
basics.isFetching ||
thesisWriters.isLoading ||
thesisWriters.isFetching
const isError =
(basics.isError && credits.isError && thesisWriters.isError) ||
(basics.isSuccess &&
!basics.data &&
credits.isSuccess &&
!credits.data &&
thesisWriters.isSuccess &&
!thesisWriters.data)
if (isError) {
return <h3>Something went wrong, please try refreshing the page.</h3>
}
const creditSortingTitles = ['Code', 'Total', 'Degree', 'Exchange', 'Open uni', 'Transferred']
if (showAll) creditSortingTitles.splice(5, 0, 'Other uni', 'Separate')
const transferShortTitles = ['Code', 'Started', 'Accepted', 'Graduated']
if (special === 'SPECIAL_INCLUDED') {
transferShortTitles.push('Transferred out', 'Transferred into')
}
const { programmes: basicStatsProgrammes, plotLinePlaces: basicStatsPlotLinePlaces } =
getSortedProgrammeIdsAndPlotLinePlaces(basics.data?.programmeNames, degreeProgrammes)
const { programmes: thesisWriterStatsProgrammes, plotLinePlaces: thesisWriterStatsPlotLinePlaces } =
getSortedProgrammeIdsAndPlotLinePlaces(thesisWriters.data?.programmeNames, degreeProgrammes)
const { programmes: creditStatsProgrammes, plotLinePlaces: creditStatsPlotLinePlaces } =
getSortedProgrammeIdsAndPlotLinePlaces(credits.data?.programmeNames, degreeProgrammes, faculty.code)
return (
<div className="faculty-overview">
<div className="toggle-container">
<Toggle
cypress="YearToggle"
firstLabel="Calendar year"
secondLabel="Academic year"
setValue={setAcademicYear}
toolTips={facultyToolTips.yearToggle}
value={academicYear}
/>
<Toggle
cypress="ProgrammeToggle"
firstLabel="New study programmes"
secondLabel="All study programmes"
setValue={setStudyProgrammes}
toolTips={facultyToolTips.programmeToggle}
value={studyProgrammes}
/>
<Toggle
cypress="StudentToggle"
firstLabel="All study rights"
secondLabel="Special study rights excluded"
setValue={setSpecialGroups}
toolTips={facultyToolTips.studentToggle}
value={specialGroups}
/>
</div>
{isFetchingOrLoading ? (
<Loader active style={{ marginTop: '15em' }} />
) : (
<>
{studyProgrammeFilter === 'ALL_PROGRAMMES' && (
<Message data-cy="FacultyProgrammesShownInfo">
Please note that the data is complete only for current Bachelor's, Master's and Doctoral programmes.
Especially, credits and thesis writers contain only data for current programmes.
</Message>
)}
{special === 'SPECIAL_EXCLUDED' && (
<Message data-cy="FacultyExcludeSpecialsInfo">
Please note that excluding the special study rights does not affect the "Credits produced by the faculty"
view.
</Message>
)}
{basics.isSuccess && basics.data && (
<>
{getDivider('Students of the faculty', 'studentsOfTheFaculty')}
<div className="section-container">
<LineGraph
cypress="StudentsOfTheFaculty"
data={{ ...basics?.data.studentInfo, years: basics.data.years }}
exportFileName={`oodikone_StudentsOfTheFaculty_${faculty?.code}_${getTimestamp()}`}
wideTable
/>
<div className="table-container-wide datatable">
<InteractiveDataTable
cypress="StudentsOfTheFaculty"
dataProgrammeStats={basics.data.studentInfo.programmeTableStats}
dataStats={basics.data.studentInfo.tableStats}
plotLinePlaces={basicStatsPlotLinePlaces}
programmeNames={basics.data.programmeNames}
shortNames={transferShortTitles}
sliceStart={1}
sortedKeys={basicStatsProgrammes}
titles={basics.data.studentInfo.titles}
/>
</div>
</div>
</>
)}
{basics.isSuccess && basics.data && (
<>
{getDivider('Graduated of the faculty', 'graduatedOfTheFaculty')}
<div className="section-container">
<LineGraph
cypress="GraduatedOfTheFaculty"
data={{ ...basics?.data.graduationInfo, years: basics.data.years }}
exportFileName={`oodikone_GraduatedOfTheFaculty_${faculty?.code}_${getTimestamp()}`}
/>
<div className="table-container">
<InteractiveDataTable
cypress="GraduatedOfTheFaculty"
dataProgrammeStats={basics.data.graduationInfo.programmeTableStats}
dataStats={basics.data.graduationInfo.tableStats}
plotLinePlaces={basicStatsPlotLinePlaces}
programmeNames={basics.data.programmeNames}
sliceStart={2}
sortedKeys={basicStatsProgrammes}
titles={basics.data.graduationInfo.titles}
/>
</div>
</div>
</>
)}
{thesisWriters.isSuccess && thesisWriters.data && (
<>
{getDivider('Thesis writers of the faculty', 'thesisWritersOfTheFaculty')}
<div className="section-container">
<LineGraph
cypress="ThesisWritersOfTheFaculty"
data={{ ...thesisWriters.data, years: thesisWriters.data.years }}
exportFileName={`oodikone_ThesisWritersOfTheFaculty_${faculty?.code}_${getTimestamp()}`}
/>
<div className="table-container">
<InteractiveDataTable
cypress="ThesisWritersOfTheFaculty"
dataProgrammeStats={thesisWriters.data.programmeTableStats}
dataStats={thesisWriters.data.tableStats}
plotLinePlaces={thesisWriterStatsPlotLinePlaces}
programmeNames={thesisWriters.data.programmeNames}
sliceStart={2}
sortedKeys={thesisWriterStatsProgrammes}
titles={thesisWriters.data.titles}
/>
</div>
</div>
</>
)}
{credits.isSuccess && credits.data && (
<>
{getDivider('Credits produced by the faculty', 'creditsProducedByTheFaculty')}
<div className="original-toggle-container">
<Radio checked={showAll} label="Show special categories" onChange={() => setShowAll(!showAll)} toggle />
</div>
<div className="section-container">
<StackedBarChart
cypress="CreditsProducedByTheFaculty"
data={graphStats.data}
exportFileName={`oodikone_CreditsProducedByTheFaculty_${faculty?.code}_${getTimestamp()}`}
labels={graphStats.years}
wideTable
/>
<div className="table-container-wide">
<InteractiveDataTable
cypress="CreditsProducedByTheFaculty"
dataProgrammeStats={programmeStats}
dataStats={tableStats.data}
plotLinePlaces={creditStatsPlotLinePlaces}
programmeNames={{
...credits.data.programmeNames,
[faculty.code]: { ...faculty.name, code: faculty.code },
}}
shortNames={creditSortingTitles}
sliceStart={2}
sortedKeys={creditStatsProgrammes}
titles={tableStats.titles}
/>
</div>
</div>
</>
)}
</>
)}
</div>
)
}
@@ -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,7 +1,7 @@
import { useCallback, useMemo } from 'react'
import { useDebounce } from '@/common/hooks'
import { RangeSelector } from '@/components/common/RangeSelector'
import { useDebounce } from '@/hooks/debounce'
import { getAge } from '@/util/timeAndDate'
import { createFilter } from './createFilter'

Some files were not shown because too many files have changed in this diff Show More