Compare commits

...
266 Commits
Author SHA1 Message Date
Riku RauhalaandGitHub abfefa8c3e Merge pull request #4739 from UniversityOfHelsinkiCS/course-statistics-pass-rate-fix
[Course statistics] Limit pass/fail rate to 100%
2025-02-11 22:57:46 +02:00
Riku Rauhala eb82eee286 [Course statistics] Update tests 2025-02-11 22:47:15 +02:00
Riku Rauhala 5ff9b3b788 [Course statistics] Limit pass/fail rate to 100%
In some cases the pass rate could go over 100% if there are more passed grades than attempts logged. This should not be possible
2025-02-11 22:27:54 +02:00
Riku Rauhala f4d73b649f [Students close to graduation] Use title 2025-02-11 21:16:09 +02:00
Riku Rauhala 4136c0d3b5 [Languages] Extract function getFullLanguage 2025-02-11 21:15:38 +02:00
Riku Rauhala 2b8cf76053 [Course statistics] Include name of the selected course in the url 2025-02-11 21:04:35 +02:00
Riku RauhalaandGitHub e23c60cb38 Merge pull request #4737 from UniversityOfHelsinkiCS/course-statistics-empty-rows
[Course statistics] Insert empty rows for years/semesters without attainments
2025-02-11 20:16:43 +02:00
Riku Rauhala 676804b770 [Course statistics] Update tests
Include empty rows
2025-02-11 20:05:18 +02:00
Riku Rauhala 2785742934 [Backend] Convert group keys to integers
The min-max does not work for strings in some cases, better to handle the values as numbers instead
2025-02-11 19:14:37 +02:00
Riku Rauhala f82638763f [Course statistics] Fix show grades on the attempts tab
Not a full fix yet but a step in the correct direction
2025-02-11 17:36:39 +02:00
Riku Rauhala 7253ba1e17 [Backend] Insert empty rows for missing courseStat years/semesters 2025-02-11 17:36:39 +02:00
Valtteri Kantanen e2847a68ea [Updater] Change included_modules column from varchar(255) to text[] in studyplan table 2025-02-11 16:47:28 +02:00
Valtteri Kantanen 687e231e65 [Students] Prevent crash when study rights lack semester enrollments 2025-02-11 14:55:57 +02:00
Riku Rauhala 0629614ed8 [Course statistics] Fix separate by semesters state
The toggle state wouldn't update if separate was already set to true in the query params
2025-02-10 22:10:05 +02:00
Riku Rauhala 843e04f154 [Grade distribution] Refactor getGradeSeries 2025-02-10 18:22:20 +02:00
Riku Rauhala d7c3423a3f [Grade distribution] Use new colors and update relative view
Relative view should stack the bars like in the pass rate chart. The new colors help make this more clear to read
2025-02-10 18:22:20 +02:00
Riku Rauhala 8be238d4cc [Theme] Update grade colors
Use a unique color for each grade
2025-02-10 18:22:20 +02:00
Valtteri Kantanen ecbaa35802 [Class statistics] Make 'Show Bachelor + Master' toggle disabled if no programme is selected or the selected programme is not a bachelor's or master's programme 2025-02-10 16:57:48 +02:00
Valtteri Kantanen 732e14cb2d [Class statistics] Show more options in 'Study right status' filter if Bachelor + Master is selected 2025-02-10 16:49:53 +02:00
Valtteri Kantanen e98ef1b94f [Class statistics] Make header better if Bachelor + Master is selected 2025-02-10 16:47:53 +02:00
Valtteri Kantanen e254de4df3 [Close to graduation] Add 'Semesters used' column (#4491) 2025-02-10 12:52:23 +02:00
Riku Rauhala 1d169ee70a [Students] Fix content alignment if there are no enrollments 2025-02-07 15:43:04 +02:00
Riku Rauhala 0380cab715 [Course statistics] Extract type ViewMode and refactor 2025-02-07 15:06:54 +02:00
Riku Rauhala 4391c06f00 [Users] Acually add retries to a flaky test 2025-02-07 15:02:42 +02:00
Riku Rauhala 36a3044b16 [Users] Add retries to a flaky test 2025-02-07 14:53:07 +02:00
Joonas Ahovalli db08330cb9 [Updater] Fix studyplanmapper for students with multiple programmes 2025-02-07 14:48:12 +02:00
Joonas Ahovalli 0a071ee071 [Updater] Add chosen modules to studyplan 2025-02-07 14:48:12 +02:00
Riku Rauhala 2354b696a5 [Course statistics] Combine graph options for grade distribution
The same options can be used for the grade distribution chart as well
2025-02-07 14:36:27 +02:00
Riku Rauhala f6db0f2fe8 [Course statistics] Combine attempts/students graph options
These are identical except for yAxis title
2025-02-07 14:22:26 +02:00
Riku Rauhala e40c219e78 [Course statistics] Extract component TotalsDisclaimer 2025-02-07 14:04:06 +02:00
Riku Rauhala 2e7ca4d960 [Highcharts] Disable titles globally
This was also done individually for most charts. If a title is needed, it will overwrite the global default setting
2025-02-07 13:42:43 +02:00
Riku Rauhala c3fd874559 [Highcharts] Disable credits globally 2025-02-07 13:29:52 +02:00
Riku Rauhala 80768b4d15 [Course statistics] Move grade colors to theme
This change gives more meaningful names to the colors used for grades, gets rid of the legacy color file and its unused colors
2025-02-07 13:21:49 +02:00
Riku Rauhala ef3aa1faf2 [Course statistics] Test info boxes 2025-02-06 14:34:05 +02:00
Riku Rauhala 7444567d64 [Course statistics] Update tests for ProviderOrganizationSelect 2025-02-06 14:14:27 +02:00
Riku Rauhala a559dab709 [Frontend] Update icon imports
Update the remaining MUI icon imports for consistency
2025-02-06 13:49:53 +02:00
Riku Rauhala 34feb51d7b [Course statistics] Turn Provider organization selector into a dropdown 2025-02-06 13:36:36 +02:00
Riku Rauhala a206a1a9b3 [Close to graduation] Align numeric columns to the right 2025-02-06 12:58:09 +02:00
Riku Rauhala 9a18de83f9 [Backend] Remove Funidata debug logging
Confirmed by Funidata on Slack
2025-02-05 16:10:52 +02:00
Riku RauhalaandGitHub d1723e7ad6 Merge pull request #4733 from UniversityOfHelsinkiCS/material-ui-course-statistics
Material UI: Course statistics
2025-02-05 16:08:03 +02:00
Riku Rauhala e1adc24ccd [Course statistics] Add missing info boxes 2025-02-05 15:59:33 +02:00
Riku Rauhala 1668f9581b [Course statistics] Update tests 2025-02-05 15:46:18 +02:00
Riku Rauhala 58b0b97e3a [Course statistics] Revert getGrades 2025-02-05 15:27:30 +02:00
Riku Rauhala 90479e5fbf [Course statistics] Fix console warning for YearFilter 2025-02-05 15:03:52 +02:00
Riku Rauhala 7461ace443 [Course statistics] Update tests 2025-02-05 12:57:40 +02:00
Riku Rauhala 7490395f99 [Course statistics] Update tests 2025-02-04 22:22:18 +02:00
Riku Rauhala a16195d47a [Course statistics] Add New query button back 2025-02-04 20:49:43 +02:00
Riku Rauhala c4084faf89 Update tests 2025-02-04 12:17:17 +02:00
Riku Rauhala 09a9958272 [Course statistics] Convert tables back to JavaScript
These will be rewritten separately with Material React Table
2025-02-04 11:11:31 +02:00
Riku Rauhala e4325f67bd [Documentation] Add Material React Table 2025-02-04 11:03:26 +02:00
Riku Rauhala 91145e3a88 [Course statistics] Improve typing 2025-02-04 11:03:26 +02:00
Riku Rauhala f4920de7d2 [Course statistics] Improve typing of charts 2025-02-04 11:03:26 +02:00
Riku Rauhala d2f96b4657 [Course statistics] Update settings to MUI 2025-02-04 11:03:26 +02:00
Riku Rauhala 21d252f1c7 [Course statistics] Refactor result tabs 2025-02-04 11:03:26 +02:00
Riku Rauhala 06f63110b9 [Course statistics] Replace semantic tabs in ResultsTabs 2025-02-04 11:03:26 +02:00
Riku Rauhala 9bbd67f7a3 [Hooks] Refactor useTabs 2025-02-04 11:03:25 +02:00
Riku Rauhala fb465ec17e [Course statistics] Improve typing 2025-02-04 11:03:25 +02:00
Riku Rauhala 76f06a6481 [Course statistics] Rewrite Course tab with Material UI 2025-02-04 11:03:25 +02:00
Riku Rauhala 929c022bf8 [Course statistics] Simplify DropdownOptions
Remove unused fields
2025-02-04 11:03:25 +02:00
Riku Rauhala d32aba88fc [Course statistics] Update types 2025-02-04 11:03:25 +02:00
Riku Rauhala e9d482f99c [ProgrammeDropdown] Move to components directory 2025-02-04 11:03:25 +02:00
Riku Rauhala 496fd6bb15 [Course statistics] Fix type issues 2025-02-04 11:03:25 +02:00
Riku Rauhala 016d8da641 [Course statistics] Type selector 2025-02-04 11:03:25 +02:00
Riku Rauhala 37d12ac65f [Frontend] Fix types attemptData and courseStat 2025-02-04 11:03:25 +02:00
Riku Rauhala 1b42b51e6e [Redux] Refactor coursesSummaryForm
Rewriting with modern syntax and deprecating formreducer as unused
2025-02-04 11:03:25 +02:00
Riku Rauhala 36230033ef [Redux] Refactor selectedCourse reducer
Rewriting with more modern syntax and removing unused stats object
2025-02-04 11:03:25 +02:00
Riku Rauhala dd1e143367 [Course statistics] Rewrite selected courses section with MUI 2025-02-04 11:03:25 +02:00
Riku Rauhala d44ee6738f [Course statistics] Fix edge case for pass rates
If passed is 0 and failed is >0, pass rate should be 0.00%
2025-02-04 11:03:25 +02:00
Riku Rauhala 1e8c6a2c79 [Course statistics] Remove Timeframe from the Summary tab
This seemed very redundant as it offered no functionality. Removal mluukkai approved
2025-02-04 11:03:25 +02:00
Riku Rauhala 3833720a22 [Course statistics] Convert CourseTab into TypeScript
Also, renaming the component SingleCourseTab to CourseTab to match the texts displayed in the UI
2025-02-04 11:03:25 +02:00
Riku Rauhala 90ae26903e [Course statistics] Refactor Excel export functionality 2025-02-04 11:03:25 +02:00
Riku Rauhala b68806e6ed [Course statistics] Remove outer Sections 2025-02-04 11:03:25 +02:00
Riku Rauhala 3a030a163d [Util] Extract access checking functionality 2025-02-04 11:03:25 +02:00
Riku Rauhala cd726716f5 [Course statistics] Fix selector 2025-02-04 11:03:25 +02:00
Riku Rauhala 627cd52b5e [Redux] Type feedbackApi 2025-02-04 11:03:25 +02:00
Riku Rauhala 01afe1607c [Course statistics] Rewrite ProgrammeDropdown with MUI 2025-02-04 11:03:25 +02:00
Riku Rauhala fd497422f8 [Redux] Type coursesSummaryForm reducer 2025-02-04 11:03:24 +02:00
Riku Rauhala fdcc445086 [Course statistics] Convert AttemptsTable into MUI 2025-02-04 11:03:24 +02:00
Riku Rauhala 36d7b3f7f3 [Course statistics] Rearrange components 2025-02-04 11:03:24 +02:00
Riku Rauhala b8d8e7df87 [Course statistics] Rewrite Faculty statistics tab with MUI 2025-02-04 11:03:24 +02:00
Riku Rauhala 7c2f2344b8 [Course statistics] Update tabs to Material UI 2025-02-04 11:03:24 +02:00
Riku Rauhala 33691621d8 [Hooks] Allow manually changing the tab 2025-02-04 11:03:24 +02:00
Valtteri Kantanen 93ed56dd38 [Class statistics] Add bachelor's and master's modes to "Graduated from programme" filter if "Show Bachelor + Master" is toggled on (#4718) 2025-02-03 18:58:57 +02:00
Valtteri Kantanen 54f47b8607 [Updater] Remove three attainments from excluded partial attainments CSV file that shouldn't be excluded (#4396) 2025-02-03 13:02:16 +02:00
Valtteri Kantanen db2649be6f [Filters] Fix programme filter to correctly check active study right (#4723) 2025-02-03 12:32:32 +02:00
Valtteri Kantanen 17a07de1f0 [Updater] Upgrade vulnerable @sentry/node version 2025-02-03 12:12:12 +02:00
Valtteri Kantanen 3aa6892bff [Frontend] Hide placeholder text for most column filters in material-react-table 2025-02-03 12:08:00 +02:00
Joonas Ahovalli 92405c3561 [Courses Tab] update tooltip 2025-02-01 00:47:43 +02:00
Joonas Ahovalli cfc2658078 [Courses Tab] Refactor, add HOPS icons, fix memoization & incorrect counting of completions 2025-02-01 00:45:53 +02:00
Valtteri Kantanen 209feebb5d [Class statistics] Display "Show Bachelor + Master" to all users instead of just admins 2025-01-31 16:12:25 +02:00
Valtteri Kantanen 0fb7a94eb1 [Class statistics] Change "showFullStudyPath" to "showBachelorAndMaster" everywhere 2025-01-31 16:10:39 +02:00
Valtteri KantanenandGitHub e80c0aaf85 Merge pull request #4728 from UniversityOfHelsinkiCS/renovate/all-minor-patch
Update all non-major dependencies
2025-01-31 13:33:47 +02:00
renovate[bot]andGitHub 8dda45da48 Update all non-major dependencies 2025-01-31 11:23:03 +00:00
Valtteri Kantanen e5a85500b3 Fix eslint errors 2025-01-31 13:21:07 +02:00
Valtteri Kantanen a4b8844c77 Upgrade package gelf-pro from version 1.3.12 to version 1.4.0 2025-01-31 13:14:21 +02:00
Valtteri Kantanen 7696e5bcd8 [Course statistics] Fix faculty statistics calculations 2025-01-31 12:53:43 +02:00
Valtteri Kantanen 486561789b Upgrade Redis image version in docker-compose files from 6.2.16 to 7.2.6 to match production version 2025-01-31 12:48:30 +02:00
Valtteri Kantanen 28fc185a2a Exclude Docker images from Renovate updates
We want the local Docker versions to match production versions as well as possible so automatic updates aren't good
2025-01-31 12:39:26 +02:00
Valtteri KantanenandGitHub db59d0e138 Merge pull request #4716 from UniversityOfHelsinkiCS/renovate/cypress-14.x
Update dependency cypress to v14
2025-01-31 11:43:49 +02:00
Valtteri KantanenandGitHub 1687f0a0c6 Merge pull request #4719 from UniversityOfHelsinkiCS/renovate/npm-vite-vulnerability
Update dependency vite to v6.0.9 [SECURITY]
2025-01-31 11:42:56 +02:00
Valtteri KantanenandGitHub 6ac6ad687e Merge pull request #4724 from UniversityOfHelsinkiCS/renovate/npm-sentry-node-vulnerability
Update dependency @sentry/node to v8.49.0 [SECURITY]
2025-01-31 11:42:08 +02:00
renovate[bot]andGitHub 5e1e71b559 Update dependency @sentry/node to v8.49.0 [SECURITY] 2025-01-28 21:58:26 +00:00
Joonas Ahovalli 66b5e9a65d [Completed courses of students] Display an icon for courses that are in students' HOPS but have no enrollment or completion 2025-01-28 12:08:29 +02:00
Joonas Ahovalli 30b1a31bb3 [Backend] completedCoursesSearch additionally fetches list of courses in student's HOPS 2025-01-28 10:51:00 +02:00
Santeri Rajamäki d6765bd7d6 [Class statistics] Improved performance of "Courses of Class" tab
Closes #4715
2025-01-27 16:21:12 +02:00
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
renovate[bot]andGitHub 456b420321 Update dependency vite to v6.0.9 [SECURITY] 2025-01-21 21:22:24 +00: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
renovate[bot]andGitHub dbc8b684c2 Update dependency cypress to v14 2025-01-17 01:32:19 +00: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
391 changed files with 12573 additions and 13494 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')
})
})
+318 -150
View File
@@ -1,7 +1,7 @@
/// <reference types="cypress" />
const checkGradeTable = gradesTableContents => {
cy.get('[data-cy="Grade distribution"]')
cy.cs('Grade distribution')
.parent()
.siblings('.content.active')
.get('table > tbody')
@@ -22,7 +22,7 @@ const checkGradeTable = gradesTableContents => {
}
const checkTableContents = contents => {
cy.get('#CourseStatPanes table>tbody').within(() => {
cy.get('table>tbody').within(() => {
contents.forEach((values, trIndex) => {
cy.get('tr')
.eq(trIndex)
@@ -38,15 +38,19 @@ const checkTableContents = contents => {
}
const toggleShowGrades = () => {
cy.get('[data-cy=gradeToggle]', { force: true }).click({ force: true })
cy.cs('gradeToggle', { force: true }).click({ force: true })
}
const toggleSeparateBySemesters = () => {
cy.get('[data-cy=separateToggle]', { force: true }).click({ force: true })
cy.cs('separateToggle', { force: true }).click({ force: true })
}
const openStudentsTab = () => {
cy.cs('StudentsTab').click()
}
const openAttemptsTab = () => {
cy.contains('#CourseStatPanes a.item', 'Attempts').click()
cy.cs('AttemptsTab').click()
}
const searchByCourseName = courseName => {
@@ -57,6 +61,23 @@ const searchByCourseCode = courseCode => {
cy.get("input[placeholder='Search by course code']").type(courseCode)
}
const clickNewQueryButton = () => {
cy.cs('NewQueryButton').click()
}
const selectFromYear = year => {
cy.cs('FromYearSelector').click()
cy.cs(`FromYearSelectorOption${year}`).click()
}
const openSummaryTab = () => {
cy.cs('SummaryTab').click()
}
const clickAway = () => {
cy.get('body').click(0, 0)
}
describe('Course Statistics tests', () => {
describe('When using basic user', () => {
beforeEach(() => {
@@ -76,21 +97,20 @@ describe('Course Statistics tests', () => {
searchByCourseCode('KK-RUKIRJ')
cy.contains('td', /^KK-RUKIRJ,/).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('KK-RUKIRJ Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
cy.contains('AYKK-RUKIRJ Avoin yo: Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
cy.contains('KK-RULAAK2 Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
cy.contains('KK-RUKIRJ • Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
cy.contains('AYKK-RUKIRJ • Avoin yo: Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
cy.contains('KK-RULAAK2 • Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1)')
cy.contains(
'KK-RUHYK Helsingin yliopistossa toiseen tutkintoon suoritettu toisen kotimaisen kielen kirjallinen taito, ruotsi'
'KK-RUHYK • Helsingin yliopistossa toiseen tutkintoon suoritettu toisen kotimaisen kielen kirjallinen taito, ruotsi'
)
cy.contains('992912 Toisen kotimaisen kielen kirjallinen taito, ruotsi')
cy.contains('A992912 Avoin yo: Toisen kotimaisen kielen kirjallinen taito, ruotsi')
cy.contains('992912 • Toisen kotimaisen kielen kirjallinen taito, ruotsi')
cy.contains('A992912 • Avoin yo: Toisen kotimaisen kielen kirjallinen taito, ruotsi')
cy.contains('2004-2005')
cy.contains('Show population').should('be.disabled')
cy.contains('Show population').trigger('mouseover', { force: true })
cy.contains('The maximum time range to generate a population for this course is 17 years')
cy.contains('Show population').trigger('mouseleave', { force: true })
cy.contains('div', '2004-2005').click()
cy.contains('2019-2020').click()
selectFromYear('2019-2020')
cy.contains('Show population').click()
cy.contains(
'Population of course Toisen kotimaisen kielen kirjallinen taito, ruotsi (CEFR B1) 2019-2024 (open and normal)'
@@ -110,7 +130,7 @@ describe('Course Statistics tests', () => {
searchByCourseCode('50131')
cy.contains('td', '50131').click()
cy.contains('Search for courses').should('not.exist')
cy.contains('50131 Pro gradu -tutkielma tietojenkäsittelytieteessä')
cy.contains('50131 • Pro gradu -tutkielma tietojenkäsittelytieteessä')
cy.contains('Show population').should('be.enabled').click()
cy.contains('Population of course Pro gradu -tutkielma tietojenkäsittelytieteessä 2007-2020 (open and normal)')
cy.contains('10 students out of 10 shown')
@@ -127,9 +147,9 @@ describe('Course Statistics tests', () => {
cy.contains('td', '200012').click()
cy.contains('Search for courses').should('not.exist')
cy.contains(
'ON-310 Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot'
'ON-310 • Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot'
)
cy.contains('200012 Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot')
cy.contains('200012 • Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot')
cy.contains('Show population').should('be.enabled').click()
cy.contains(
'Population of course Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot 2011-2018 (open and normal)'
@@ -149,41 +169,40 @@ describe('Course Statistics tests', () => {
cy.contains('TKT20001')
cy.contains('58131')
cy.contains('.tabular.menu a', 'Students').click()
cy.contains('All')
openStudentsTab()
cy.contains('svg', 'Pass rate')
cy.contains('.tabular.menu a', 'Attempts').click()
cy.contains('All')
openAttemptsTab()
cy.contains('svg', 'Pass rate')
cy.contains('a', 'New query').click()
clickNewQueryButton()
cy.contains('Search for courses')
})
it('Searching multiple courses having substitution mappings shows course statistics', () => {
cy.contains('Search for courses')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'checked').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'checked')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
searchByCourseCode('TKT')
cy.contains('td', /^TKT20001/).click()
cy.contains('td', /^TKT10002/).click()
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('Search for courses').should('not.exist')
cy.contains('.courseNameCell', 'Tietorakenteet ja algoritmit').contains('TKT20001').click()
cy.contains('.courseNameCell', 'Ohjelmoinnin perusteet').should('not.exist')
openSummaryTab()
cy.contains('Tietorakenteet ja algoritmit').click()
cy.contains('Ohjelmoinnin perusteet').should('not.exist')
cy.contains('TKT20001')
cy.contains('58131')
cy.contains('Summary').click()
cy.contains('.courseNameCell', 'Ohjelmoinnin perusteet').contains('TKT10002').click()
cy.contains('.courseNameCell', 'Käyttöjärjestelmät').should('not.exist')
openSummaryTab()
cy.contains('Ohjelmoinnin perusteet').click()
cy.contains('Käyttöjärjestelmät').should('not.exist')
cy.contains('TKT10002')
cy.contains('581325')
cy.contains('Summary').click()
openSummaryTab()
cy.contains('a', 'New query').click()
clickNewQueryButton()
cy.contains('Search for courses')
})
@@ -195,16 +214,13 @@ describe('Course Statistics tests', () => {
cy.contains('Fetch statistics').click()
cy.contains('Search for courses').should('not.exist')
cy.get('.ui.contentSegment .ui.menu').contains('Course').click()
cy.cs('course-selector').get('.active.selected.item').contains('TKT10002')
cy.get('#CourseStatPanes table a.item:first').click()
cy.get('table a.item:first').click()
cy.contains('Population of course Introduction to Programming 2023-2024 (open and normal)')
cy.go('back')
cy.get('.ui.contentSegment .ui.menu').contains('Course').click()
cy.cs('course-selector').click()
cy.cs('course-selector').contains('TKT20001').click()
cy.get('#CourseStatPanes table a.item:first').click()
cy.cs('CourseSelector').click()
cy.cs('CourseSelectorOptionTKT20001').click()
cy.get('table a.item:first').click()
cy.contains('Population of course Tietorakenteet ja algoritmit')
})
@@ -232,43 +248,54 @@ describe('Course Statistics tests', () => {
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT10004 Tietokantojen perusteet')
cy.contains('AYTKT10004 Avoin yo: Tietokantojen perusteet')
cy.contains('BSCS2001 Introduction to Databases')
cy.contains('581328 Tietokantojen perusteet')
cy.contains('A581328 Avoin yo: Tietokantojen perusteet')
cy.get('.right').click()
cy.contains('No results')
cy.contains('TKT10004 • Tietokantojen perusteet')
cy.contains('AYTKT10004 • Avoin yo: Tietokantojen perusteet')
cy.contains('BSCS2001 • Introduction to Databases')
cy.contains('581328 • Tietokantojen perusteet')
cy.contains('A581328 • Avoin yo: Tietokantojen perusteet')
clickNewQueryButton()
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
})
it('"Select all search results" button is not showing unless "Select multiple courses" toggle is on', () => {
cy.contains('Search for courses')
searchByCourseCode('TKT')
cy.get('[data-cy="select-multiple-courses-toggle"]').should('not.have.class', 'checked')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
cy.contains('TKT10004, BSCS2001, 581328, A581328, AYTKT10004')
cy.contains('Select all search results').should('not.exist')
cy.get('[data-cy="select-multiple-courses-toggle"]').click()
cy.get('[data-cy="select-multiple-courses-toggle"]').should('have.class', 'checked')
cy.cs('select-multiple-courses-toggle').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
cy.contains('Select all search results')
})
it('Provider organization toggle works', () => {
it('Provider organization select works', () => {
cy.contains('Search for courses')
searchByCourseName('tietokantojen perusteet')
cy.contains('td', /^TKT10004/).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('AYTKT10004 Avoin yo: Tietokantojen perusteet')
cy.contains('A581328 Avoin yo: Tietokantojen perusteet')
cy.contains('TKT10004 Tietokantojen perusteet')
cy.contains('BSCS2001 Introduction to Databases')
cy.contains('581328 Tietokantojen perusteet')
cy.cs('providerCheckboxUniversity').should('have.class', 'checked').click()
cy.cs('providerCheckboxOpenUni').should('have.class', 'checked').click()
cy.contains('TKT10004 Tietokantojen perusteet')
cy.contains('BSCS2001 Introduction to Databases')
cy.contains('581328 Tietokantojen perusteet')
cy.contains('TKT10004 • Tietokantojen perusteet')
cy.contains('AYTKT10004 • Avoin yo: Tietokantojen perusteet')
cy.contains('BSCS2001 • Introduction to Databases')
cy.contains('581328 • Tietokantojen perusteet')
cy.contains('A581328 • Avoin yo: Tietokantojen perusteet')
cy.cs('ProviderOrganizationSelect').click()
cy.cs('ProviderOrganizationSelectOptionBoth').should('have.class', 'Mui-selected')
cy.cs('ProviderOrganizationSelectOptionRegular').should('not.have.class', 'Mui-selected')
cy.cs('ProviderOrganizationSelectOptionOpen').should('not.have.class', 'Mui-selected')
cy.cs('ProviderOrganizationSelectOptionRegular').click()
cy.cs('ProviderOrganizationSelectOptionBoth').should('not.have.class', 'Mui-selected')
cy.cs('ProviderOrganizationSelectOptionRegular').should('have.class', 'Mui-selected')
cy.cs('ProviderOrganizationSelectOptionOpen').should('not.have.class', 'Mui-selected')
cy.cs('ProviderOrganizationSelect').click()
cy.cs('ProviderOrganizationSelectOptionOpen').click()
cy.cs('ProviderOrganizationSelectOptionBoth').should('not.have.class', 'Mui-selected')
cy.cs('ProviderOrganizationSelectOptionRegular').should('not.have.class', 'Mui-selected')
cy.cs('ProviderOrganizationSelectOptionOpen').should('have.class', 'Mui-selected')
})
it('Searching course by name displays right courses, 10 credit courses', { retries: 2 }, () => {
@@ -278,28 +305,28 @@ describe('Course Statistics tests', () => {
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT20001 Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 Data Structures and Algorithms')
cy.contains('58131 Tietorakenteet')
cy.get('.right').click()
cy.contains('No results')
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 • Data Structures and Algorithms')
cy.contains('58131 • Tietorakenteet')
clickNewQueryButton()
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
searchByCourseName('tietorakenteet ja algoritmit')
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT20001 Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 Data Structures and Algorithms')
cy.contains('58131 Tietorakenteet')
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 • Data Structures and Algorithms')
cy.contains('58131 • Tietorakenteet')
})
it('Can find course population', () => {
cy.contains('Search for courses')
searchByCourseCode('TKT20003')
cy.contains('tr', 'TKT20003').click()
cy.contains('TKT20003 Käyttöjärjestelmät')
cy.contains('582219 Käyttöjärjestelmät')
cy.contains('TKT20003 • Käyttöjärjestelmät')
cy.contains('582219 • Käyttöjärjestelmät')
cy.get('tbody > :nth-child(4) > :nth-child(2) .level').click()
cy.contains('Population of course Käyttöjärjestelmät 2020-2021 (open and normal')
cy.contains('TKT20003')
@@ -312,10 +339,10 @@ describe('Course Statistics tests', () => {
it('Population of course shows grades for each student', () => {
searchByCourseCode('TKT20001')
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
cy.contains('TKT20001 Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 Data Structures and Algorithms')
cy.contains('58131 Tietorakenteet')
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 • Data Structures and Algorithms')
cy.contains('58131 • Tietorakenteet')
cy.get('tbody >:nth-child(5) > :nth-child(2) .level').click()
cy.contains('Population of course Tietorakenteet ja algoritmit 2019-2020 (open and normal)')
cy.contains('Students (33)').click()
@@ -326,10 +353,10 @@ describe('Course Statistics tests', () => {
it("In 'Course population' view, student numbers of students that the user isn't allowed to see are hidden", () => {
searchByCourseCode('TKT20001')
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
cy.contains('TKT20001 Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 Data Structures and Algorithms')
cy.contains('58131 Tietorakenteet')
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 • Data Structures and Algorithms')
cy.contains('58131 • Tietorakenteet')
cy.get('tbody >:nth-child(5) > :nth-child(2) .level').click()
cy.contains('Population of course Tietorakenteet ja algoritmit 2019-2020 (open and normal)')
cy.contains('Students (33)').click()
@@ -339,9 +366,8 @@ describe('Course Statistics tests', () => {
it('Language distribution is correct', () => {
searchByCourseCode('TKT20003')
cy.contains('td', 'TKT20003, 582219').click()
cy.contains('TKT20003 Käyttöjärjestelmät')
cy.contains('582219 Käyttöjärjestelmät')
cy.cs('providerCheckboxOpenUni').click()
cy.contains('TKT20003 • Käyttöjärjestelmät')
cy.contains('582219 • Käyttöjärjestelmät')
cy.get('tbody > :nth-child(3) > :nth-child(2) .level').click()
cy.contains('Population of course Käyttöjärjestelmät 2021-2022 (open and normal)')
cy.contains('Language distribution').click()
@@ -354,32 +380,47 @@ describe('Course Statistics tests', () => {
beforeEach(() => {
cy.url().should('include', '/coursestatistics')
cy.contains('Search for courses')
cy.get('[data-cy="combine-substitutions-toggle"]').should('have.class', 'checked').click()
cy.get('[data-cy="combine-substitutions-toggle"]').should('not.have.class', 'checked')
cy.cs('combine-substitutions-toggle').should('have.class', 'Mui-checked').click()
cy.cs('combine-substitutions-toggle').should('not.have.class', 'Mui-checked')
searchByCourseCode('TKT10002')
cy.contains('td', /^TKT10002$/).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT10002 Ohjelmoinnin perusteet')
cy.contains('TKT10002 • Ohjelmoinnin perusteet')
})
it('Time range', () => {
const yearRange = { from: '2016-2017', to: '2023-2024' }
cy.get("div[name='fromYear']").within(() => {
cy.get("div[role='option']").first().should('have.text', yearRange.to)
cy.contains("div[role='option']", yearRange.from).should('have.class', 'selected')
cy.get("div[role='option']").last().should('have.text', '2016-2017')
cy.get("div[role='option']").should('have.length', 8)
})
cy.get("div[name='toYear']").within(() => {
cy.get("div[role='option']").first().should('have.text', '2023-2024')
cy.contains("div[role='option']", yearRange.to).should('have.class', 'selected')
cy.get("div[role='option']").last().should('have.text', yearRange.from)
cy.get("div[role='option']").should('have.length', 8)
})
cy.cs('FromYearSelector').click()
cy.cs('FromYearSelectorOption2016-2017').should('have.class', 'Mui-selected')
cy.get('[data-cy^="FromYearSelectorOption"]').should('have.length', 8)
clickAway()
cy.cs('ToYearSelector').click()
cy.cs('ToYearSelectorOption2023-2024').should('have.class', 'Mui-selected')
cy.get('[data-cy^="ToYearSelectorOption"]').should('have.length', 8)
clickAway()
cy.contains('Show population').should('be.enabled')
})
describe('Students tab', () => {
describe('Info boxes', () => {
it('Student statistics table', () => {
cy.cs('StudentStatisticsInfoBoxButton').click()
cy.cs('StudentStatisticsInfoBoxContent').contains('Table - Students')
})
it('Pass rate', () => {
cy.cs('PassRateStudentsInfoBoxButton').click()
cy.cs('PassRateStudentsInfoBoxContent').contains('Pass rate - Students')
})
it('Grade distribution', () => {
toggleShowGrades()
cy.cs('GradeDistributionInfoBoxButton').click()
cy.cs('GradeDistributionInfoBoxContent').contains('Grade distribution')
})
})
it('Show grades off, Separate by semesters off', () => {
const tableContents = [
// [Time, --, Total students, Passed, Failed, Enrolled no grade, Pass rate, Fail rate]
@@ -413,6 +454,7 @@ describe('Course Statistics tests', () => {
['Syksy 2018', null, 22, 18, 4, null, '81.82 %', '18.18 %'],
['Kevät 2018', null, 19, 12, 7, null, '63.16 %', '36.84 %'],
['Syksy 2017', null, 8, 7, 1, null, '87.50 %', '12.50 %'],
['Kevät 2017', null, 0, 0, 0, null, '–', '–'],
['Syksy 2016', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
]
toggleSeparateBySemesters()
@@ -453,6 +495,7 @@ describe('Course Statistics tests', () => {
['Syksy 2018', null, 22, 4, 2, 1, 1, 2, 12, 0, null, '81.82 %', '18.18 %'],
['Kevät 2018', null, 19, 7, 0, 1, 0, 3, 8, 0, null, '63.16 %', '36.84 %'],
['Syksy 2017', null, 8, 1, 0, 0, 2, 2, 3, 0, null, '87.50 %', '12.50 %'],
['Kevät 2017', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 2016', null, 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
]
toggleShowGrades()
@@ -466,6 +509,24 @@ describe('Course Statistics tests', () => {
openAttemptsTab()
})
describe('Info boxes', () => {
it('Attempt statistics table', () => {
cy.cs('AttemptStatisticsInfoBoxButton').click()
cy.cs('AttemptStatisticsInfoBoxContent').contains('Table - Attempts')
})
it('Pass rate', () => {
cy.cs('PassRateAttemptsInfoBoxButton').click()
cy.cs('PassRateAttemptsInfoBoxContent').contains('Pass rate - Attempts')
})
it('Grade distribution', () => {
toggleShowGrades()
cy.cs('GradeDistributionInfoBoxButton').click()
cy.cs('GradeDistributionInfoBoxContent').contains('Grade distribution')
})
})
it('Show grades off, Separate by semesters off', () => {
const tableContents = [
// [Time, --, Total attempts, Passed, Failed, Pass rate, Enrollments]
@@ -499,6 +560,7 @@ describe('Course Statistics tests', () => {
['Syksy 2018', null, 24, 18, 6, '75.00 %', null],
['Kevät 2018', null, 20, 12, 8, '60.00 %', null],
['Syksy 2017', null, 8, 7, 1, '87.50 %', null],
['Kevät 2017', null, 0, 0, 0, '–', 0],
['Syksy 2016', null, 1, 1, 0, '100.00 %', null],
]
toggleSeparateBySemesters()
@@ -539,6 +601,7 @@ describe('Course Statistics tests', () => {
['Syksy 2018', null, 24, 6, 2, 1, 1, 2, 12, 0],
['Kevät 2018', null, 20, 8, 0, 1, 0, 3, 8, 0],
['Syksy 2017', null, 8, 1, 0, 0, 2, 2, 3, 0],
['Kevät 2017', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2016', null, 1, 0, 0, 0, 0, 1, 0, 0],
]
toggleShowGrades()
@@ -555,28 +618,24 @@ describe('Course Statistics tests', () => {
searchByCourseCode('TKT10002')
cy.contains('td', /^TKT10002, BSCS1001, 581325, A581325, AYTKT10002$/).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT10002 Ohjelmoinnin perusteet')
cy.contains('AYTKT10002 Avoin yo: Ohjelmoinnin perusteet')
cy.contains('BSCS1001 Introduction to Programming')
cy.contains('581325 Ohjelmoinnin perusteet')
cy.contains('A581325 Avoin yo: Ohjelmoinnin perusteet')
cy.contains('TKT10002 • Ohjelmoinnin perusteet')
cy.contains('AYTKT10002 • Avoin yo: Ohjelmoinnin perusteet')
cy.contains('BSCS1001 • Introduction to Programming')
cy.contains('581325 • Ohjelmoinnin perusteet')
cy.contains('A581325 • Avoin yo: Ohjelmoinnin perusteet')
})
it('Time range', () => {
const yearRange = { from: '1999-2000', to: '2023-2024' }
cy.contains('Statistics by time range')
cy.get("div[name='fromYear']").within(() => {
cy.get("div[role='option']").first().should('have.text', yearRange.to)
cy.contains("div[role='option']", yearRange.from).should('have.class', 'selected')
cy.get("div[role='option']").last().should('have.text', '1999-2000')
cy.get("div[role='option']").should('have.length', 25)
})
cy.get("div[name='toYear']").within(() => {
cy.get("div[role='option']").first().should('have.text', '2023-2024')
cy.contains("div[role='option']", yearRange.to).should('have.class', 'selected')
cy.get("div[role='option']").last().should('have.text', yearRange.from)
cy.get("div[role='option']").should('have.length', 25)
})
cy.cs('FromYearSelector').click()
cy.cs('FromYearSelectorOption1999-2000').should('have.class', 'Mui-selected')
cy.get('[data-cy^="FromYearSelectorOption"]').should('have.length', 25)
clickAway()
cy.cs('ToYearSelector').click()
cy.cs('ToYearSelectorOption2023-2024').should('have.class', 'Mui-selected')
cy.get('[data-cy^="ToYearSelectorOption"]').should('have.length', 25)
clickAway()
cy.contains('Show population').should('be.enabled')
})
@@ -599,11 +658,16 @@ describe('Course Statistics tests', () => {
['2012-2013', null, 4, 4, 0, null, '100.00 %', '0.00 %'],
['2011-2012', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['2010-2011', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['2009-2010', null, 0, 0, 0, null, '–', '–'],
['2008-2009', null, 1, 0, 1, null, '0.00 %', '100.00 %'],
['2007-2008', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['2006-2007', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['2005-2006', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['2004-2005', null, 0, 0, 0, null, '–', '–'],
['2003-2004', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['2002-2003', null, 0, 0, 0, null, '–', '–'],
['2001-2002', null, 0, 0, 0, null, '–', '–'],
['2000-2001', null, 0, 0, 0, null, '–', '–'],
['1999-2000', null, 2, 2, 0, null, '100.00 %', '0.00 %'],
]
checkTableContents(tableContents)
@@ -634,14 +698,33 @@ describe('Course Statistics tests', () => {
['Syksy 2014', null, 3, 3, 0, null, '100.00 %', '0.00 %'],
['Kevät 2014', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2013', null, 1, 0, 1, null, '0.00 %', '100.00 %'],
['Kevät 2013', null, 0, 0, 0, 0, '–', '–'],
['Syksy 2012', null, 4, 4, 0, null, '100.00 %', '0.00 %'],
['Kevät 2012', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2011', null, 0, 0, 0, 0, '–', '–'],
['Kevät 2011', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2010', null, 0, 0, 0, 0, '–', '–'],
['Kevät 2010', null, 0, 0, 0, 0, '–', '–'],
['Syksy 2009', null, 0, 0, 0, 0, '–', '–'],
['Kevät 2009', null, 0, 0, 0, 0, '–', '–'],
['Syksy 2008', null, 1, 0, 1, null, '0.00 %', '100.00 %'],
['Kevät 2008', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2007', null, 0, 0, 0, 0, '–', '–'],
['Kevät 2007', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2006', null, 0, 0, 0, 0, '–', '–'],
['Kevät 2006', null, 0, 0, 0, 0, '–', '–'],
['Syksy 2005', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['Kevät 2005', null, 0, 0, 0, 0, '–', '–'],
['Syksy 2004', null, 0, 0, 0, 0, '–', '–'],
['Kevät 2004', null, 1, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2003', null, 0, 0, 0, 0, '–', '–'],
['Kevät 2003', null, 0, 0, 0, 0, '–', '–'],
['Syksy 2002', null, 0, 0, 0, 0, '–', '–'],
['Kevät 2002', null, 0, 0, 0, 0, '–', '–'],
['Syksy 2001', null, 0, 0, 0, 0, '–', '–'],
['Kevät 2001', null, 0, 0, 0, 0, '–', '–'],
['Syksy 2000', null, 0, 0, 0, 0, '–', '–'],
['Kevät 2000', null, 0, 0, 0, 0, '–', '–'],
['Syksy 1999', null, 2, 2, 0, null, '100.00 %', '0.00 %'],
]
toggleSeparateBySemesters()
@@ -666,11 +749,16 @@ describe('Course Statistics tests', () => {
['2012-2013', null, 4, 0, 0, 0, 0, 3, 1, 0, null, '100.00 %', '0.00 %'],
['2011-2012', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['2010-2011', null, 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
['2009-2010', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['2008-2009', null, 1, 1, 0, 0, 0, 0, 0, 0, null, '0.00 %', '100.00 %'],
['2007-2008', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['2006-2007', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['2005-2006', null, 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
['2004-2005', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['2003-2004', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['2002-2003', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['2001-2002', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['2000-2001', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['1999-2000', null, 2, 0, 0, 0, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
]
toggleShowGrades()
@@ -702,14 +790,33 @@ describe('Course Statistics tests', () => {
['Syksy 2014', null, 3, 0, 0, 1, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
['Kevät 2014', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2013', null, 1, 1, 0, 0, 0, 0, 0, 0, null, '0.00 %', '100.00 %'],
['Kevät 2013', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 2012', null, 4, 0, 0, 0, 0, 3, 1, 0, null, '100.00 %', '0.00 %'],
['Kevät 2012', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2011', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Kevät 2011', null, 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
['Syksy 2010', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Kevät 2010', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 2009', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Kevät 2009', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 2008', null, 1, 1, 0, 0, 0, 0, 0, 0, null, '0.00 %', '100.00 %'],
['Kevät 2008', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2007', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Kevät 2007', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2006', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Kevät 2006', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 2005', null, 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
['Kevät 2005', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 2004', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Kevät 2004', null, 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2003', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Kevät 2003', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 2002', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Kevät 2002', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 2001', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Kevät 2001', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 2000', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Kevät 2000', null, 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 1999', null, 2, 0, 0, 0, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
]
toggleShowGrades()
@@ -741,11 +848,16 @@ describe('Course Statistics tests', () => {
['2012-2013', null, 4, 4, 0, '100.00 %', null],
['2011-2012', null, 1, 1, 0, '100.00 %', null],
['2010-2011', null, 1, 1, 0, '100.00 %', null],
['2009-2010', null, 0, 0, 0, '–', null],
['2008-2009', null, 1, 0, 1, '0.00 %', null],
['2007-2008', null, 1, 1, 0, '100.00 %', null],
['2006-2007', null, 1, 1, 0, '100.00 %', null],
['2005-2006', null, 1, 1, 0, '100.00 %', null],
['2004-2005', null, 0, 0, 0, '–', null],
['2003-2004', null, 1, 1, 0, '100.00 %', null],
['2002-2003', null, 0, 0, 0, '–', null],
['2001-2002', null, 0, 0, 0, '–', null],
['2000-2001', null, 0, 0, 0, '–', null],
['1999-2000', null, 2, 2, 0, '100.00 %', null],
]
checkTableContents(tableContents)
@@ -776,14 +888,33 @@ describe('Course Statistics tests', () => {
['Syksy 2014', null, 3, 3, 0, '100.00 %', null],
['Kevät 2014', null, 1, 1, 0, '100.00 %', null],
['Syksy 2013', null, 1, 0, 1, '0.00 %', null],
['Kevät 2013', null, 0, 0, 0, '–', 0],
['Syksy 2012', null, 4, 4, 0, '100.00 %', null],
['Kevät 2012', null, 1, 1, 0, '100.00 %', null],
['Syksy 2011', null, 0, 0, 0, '–', 0],
['Kevät 2011', null, 1, 1, 0, '100.00 %', null],
['Syksy 2010', null, 0, 0, 0, '–', 0],
['Kevät 2010', null, 0, 0, 0, '–', 0],
['Syksy 2009', null, 0, 0, 0, '–', 0],
['Kevät 2009', null, 0, 0, 0, '–', 0],
['Syksy 2008', null, 1, 0, 1, '0.00 %', null],
['Kevät 2008', null, 1, 1, 0, '100.00 %', null],
['Syksy 2007', null, 0, 0, 0, '–', 0],
['Kevät 2007', null, 1, 1, 0, '100.00 %', null],
['Syksy 2006', null, 0, 0, 0, '–', 0],
['Kevät 2006', null, 0, 0, 0, '–', 0],
['Syksy 2005', null, 1, 1, 0, '100.00 %', null],
['Kevät 2005', null, 0, 0, 0, '–', 0],
['Syksy 2004', null, 0, 0, 0, '–', 0],
['Kevät 2004', null, 1, 1, 0, '100.00 %', null],
['Syksy 2003', null, 0, 0, 0, '–', 0],
['Kevät 2003', null, 0, 0, 0, '–', 0],
['Syksy 2002', null, 0, 0, 0, '–', 0],
['Kevät 2002', null, 0, 0, 0, '–', 0],
['Syksy 2001', null, 0, 0, 0, '–', 0],
['Kevät 2001', null, 0, 0, 0, '–', 0],
['Syksy 2000', null, 0, 0, 0, '–', 0],
['Kevät 2000', null, 0, 0, 0, '–', 0],
['Syksy 1999', null, 2, 2, 0, '100.00 %', null],
]
toggleSeparateBySemesters()
@@ -808,11 +939,16 @@ describe('Course Statistics tests', () => {
['2012-2013', null, 4, 0, 0, 0, 0, 3, 1, 0],
['2011-2012', null, 1, 0, 0, 0, 0, 0, 1, 0],
['2010-2011', null, 1, 0, 0, 0, 0, 1, 0, 0],
['2009-2010', null, 0, 0, 0, 0, 0, 0, 0, 0],
['2008-2009', null, 1, 1, 0, 0, 0, 0, 0, 0],
['2007-2008', null, 1, 0, 0, 0, 0, 0, 1, 0],
['2006-2007', null, 1, 0, 0, 0, 0, 0, 1, 0],
['2005-2006', null, 1, 0, 0, 0, 0, 1, 0, 0],
['2004-2005', null, 0, 0, 0, 0, 0, 0, 0, 0],
['2003-2004', null, 1, 0, 0, 0, 0, 0, 1, 0],
['2002-2003', null, 0, 0, 0, 0, 0, 0, 0, 0],
['2001-2002', null, 0, 0, 0, 0, 0, 0, 0, 0],
['2000-2001', null, 0, 0, 0, 0, 0, 0, 0, 0],
['1999-2000', null, 2, 0, 0, 0, 0, 0, 2, 0],
]
toggleShowGrades()
@@ -844,14 +980,33 @@ describe('Course Statistics tests', () => {
['Syksy 2014', null, 3, 0, 0, 1, 0, 0, 2, 0],
['Kevät 2014', null, 1, 0, 0, 0, 0, 0, 1, 0],
['Syksy 2013', null, 1, 1, 0, 0, 0, 0, 0, 0],
['Kevät 2013', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2012', null, 4, 0, 0, 0, 0, 3, 1, 0],
['Kevät 2012', null, 1, 0, 0, 0, 0, 0, 1, 0],
['Syksy 2011', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Kevät 2011', null, 1, 0, 0, 0, 0, 1, 0, 0],
['Syksy 2010', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Kevät 2010', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2009', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Kevät 2009', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2008', null, 1, 1, 0, 0, 0, 0, 0, 0],
['Kevät 2008', null, 1, 0, 0, 0, 0, 0, 1, 0],
['Syksy 2007', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Kevät 2007', null, 1, 0, 0, 0, 0, 0, 1, 0],
['Syksy 2006', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Kevät 2006', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2005', null, 1, 0, 0, 0, 0, 1, 0, 0],
['Kevät 2005', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2004', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Kevät 2004', null, 1, 0, 0, 0, 0, 0, 1, 0],
['Syksy 2003', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Kevät 2003', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2002', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Kevät 2002', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2001', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Kevät 2001', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2000', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Kevät 2000', null, 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 1999', null, 2, 0, 0, 0, 0, 0, 2, 0],
]
toggleShowGrades()
@@ -860,44 +1015,52 @@ describe('Course Statistics tests', () => {
})
})
it('After changing time range shows same stats', () => {
const newYearRange = { from: '2016-2017', to: '2019-2020' }
cy.get("div[name='fromYear']")
.click()
.within(() => {
cy.contains(newYearRange.from).click()
})
cy.get("div[name='toYear']")
.click()
.within(() => {
cy.contains(newYearRange.to).click()
})
it('After changing time range shows correct options', () => {
cy.cs('FromYearSelector').click()
cy.cs('FromYearSelectorOption2016-2017').click()
cy.cs('FromYearSelectorOption2016-2017').should('have.class', 'Mui-selected')
clickAway()
cy.cs('ToYearSelector').click()
cy.cs('ToYearSelectorOption2019-2020').click()
cy.cs('ToYearSelectorOption2019-2020').should('have.class', 'Mui-selected')
clickAway()
cy.cs('FromYearSelector').click()
cy.get('[data-cy^="FromYearSelectorOption"]').should('have.length', 21)
clickAway()
cy.cs('ToYearSelector').click()
cy.get('[data-cy^="ToYearSelectorOption"]').should('have.length', 8)
clickAway()
// Time range
cy.get("div[name='fromYear']").within(() => {
cy.get("div[role='option']").first().should('have.text', newYearRange.to)
cy.contains("div[role='option']", newYearRange.from).should('have.class', 'selected')
cy.get("div[role='option']").last().should('have.text', '1999-2000')
cy.get("div[role='option']").should('have.length', 21)
})
cy.get("div[name='toYear']").within(() => {
cy.get("div[role='option']").first().should('have.text', '2023-2024')
cy.contains("div[role='option']", newYearRange.to).should('have.class', 'selected')
cy.get("div[role='option']").last().should('have.text', newYearRange.from)
cy.get("div[role='option']").should('have.length', 8)
})
cy.contains('Show population').should('be.enabled')
})
})
it('If no data available, provider organization(s) toggle is disabled', () => {
it('If no data available, provider organization(s) select is disabled', () => {
searchByCourseCode('TKT20014')
cy.contains('TKT20014').click()
cy.contains('TKT20014 Kypsyysnäyte LuK')
cy.contains('50036 Suomenkielinen kypsyysnäyte LuK')
cy.contains('50037 Ruotsinkielinen kypsyysnäyte LuK')
cy.cs('providerCheckboxUniversity').find('input').should('not.be.disabled')
cy.cs('providerCheckboxOpenUni').find('input').should('be.disabled')
cy.contains('TKT20014 • Kypsyysnäyte LuK')
cy.contains('50037 • Ruotsinkielinen kypsyysnäyte LuK')
cy.contains('50036 • Suomenkielinen kypsyysnäyte LuK')
cy.cs('ProviderOrganizationSelect').click()
cy.cs('ProviderOrganizationSelectOptionBoth').should('have.class', 'Mui-selected')
cy.cs('ProviderOrganizationSelectOptionBoth').should('not.have.class', 'Mui-disabled')
cy.cs('ProviderOrganizationSelectOptionBoth').should('have.text', 'Both')
cy.cs('ProviderOrganizationSelectOptionRegular').should('not.have.class', 'Mui-selected')
cy.cs('ProviderOrganizationSelectOptionRegular').should('not.have.class', 'Mui-disabled')
cy.cs('ProviderOrganizationSelectOptionRegular').should('have.text', 'University')
cy.cs('ProviderOrganizationSelectOptionRegular').should('not.have.text', 'University (not available)')
cy.cs('ProviderOrganizationSelectOptionOpen').should('not.have.class', 'Mui-selected')
cy.cs('ProviderOrganizationSelectOptionOpen').should('have.class', 'Mui-disabled')
cy.cs('ProviderOrganizationSelectOptionOpen').should('have.text', 'Open university (not available)')
cy.cs('ProviderOrganizationSelectOptionOpen').should('not.have.text', 'University')
})
it('Has right to see all the students, because course provider is TKT', () => {
@@ -912,13 +1075,13 @@ describe('Course Statistics tests', () => {
it('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
cy.init('/coursestatistics', 'onlycoursestatistics')
cy.get('[data-cy=nav-bar-button-courseStatistics]').click()
cy.get('[data-cy=course-code-input]').type('TKT10002')
cy.cs('nav-bar-button-courseStatistics').click()
cy.cs('course-code-input').type('TKT10002')
cy.contains('tr', 'TKT10002').click()
cy.contains('Filter statistics by study programmes').should('not.exist')
cy.contains('Faculty statistics').should('not.exist')
cy.contains('Show population').should('not.exist')
cy.contains('.tabular.menu a', 'Attempts').click()
openAttemptsTab()
const emptyYear = year => [year, null, '5 or fewer students', 'NA', 'NA', 'NA', 'NA']
@@ -939,11 +1102,16 @@ describe('Course Statistics tests', () => {
emptyYear('2012-2013'),
emptyYear('2011-2012'),
emptyYear('2010-2011'),
emptyYear('2009-2010'),
emptyYear('2008-2009'),
emptyYear('2007-2008'),
emptyYear('2006-2007'),
emptyYear('2005-2006'),
emptyYear('2004-2005'),
emptyYear('2003-2004'),
emptyYear('2002-2003'),
emptyYear('2001-2002'),
emptyYear('2000-2001'),
emptyYear('1999-2000'),
]
+236 -284
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)
})
})
+11 -7
View File
@@ -98,10 +98,10 @@ describe('Population statistics tests', () => {
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
cy.wait('@coursePage')
cy.url().should('include', '/coursestatistics')
cy.contains('MAT11001 Johdatus yliopistomatematiikkaan')
cy.contains('AYMAT11001 Avoin yo: Johdatus yliopistomatematiikkaan')
cy.contains('57033 Johdatus yliopistomatematiikkaan')
cy.contains('A57033 Avoin yo: Johdatus yliopistomatematiikkaan')
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
cy.contains('AYMAT11001 • Avoin yo: Johdatus yliopistomatematiikkaan')
cy.contains('57033 • Johdatus yliopistomatematiikkaan')
cy.contains('A57033 • Avoin yo: Johdatus yliopistomatematiikkaan')
})
it('Courses of class curriculum selection works', () => {
@@ -109,7 +109,8 @@ describe('Population statistics tests', () => {
cy.contains('Courses of class').click()
cy.get('[data-cy=curriculum-picker]').contains('2020–2023')
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
cy.get('[data-cy=curriculum-picker]').click().contains('2023–2026').click()
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2023–2026').click()
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
})
@@ -117,14 +118,17 @@ describe('Population statistics tests', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v2/populationstatistics/courses').as('courseData')
cy.get('[data-cy=curriculum-picker]').click().contains('2020–2023').click()
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2020–2023').click({ force: true })
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(27)
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(true)
})
cy.get('[data-cy=curriculum-picker]').click().contains('2023–2026').click()
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2023–2026').click()
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
+66 -54
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,17 +95,17 @@ describe('Students tests', () => {
.siblings()
.last()
.within(() => {
cy.get('.level').click()
cy.get('a').click()
})
cy.url().should('include', '/coursestatistics')
cy.contains('MAT12004 Tilastollinen päättely I')
cy.contains('AYMAT12004 Avoin yo: Tilastollinen päättely I')
cy.contains('57046 Johdatus tilastolliseen päättelyyn')
cy.contains('MAT12004 • Tilastollinen päättely I')
cy.contains('AYMAT12004 • Avoin yo: Tilastollinen päättely I')
cy.contains('57046 • Johdatus tilastolliseen päättelyyn')
})
it('Has correct Sisu link', () => {
typeStudentNumberAndClick(student.studentnumber)
cy.get('[data-cy=sisulink] > a')
cy.get('[data-cy=sisu-link]')
.should('have.attr', 'href')
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sis_person_id}/basic/basic-info`)
})
@@ -110,7 +117,7 @@ describe('Students tests', () => {
'Matemaattisten tieteiden kandiohjelma',
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
]
cy.get('div.ui.fluid.card').within(() => {
cy.get('[data-cy=student-info-card]').within(() => {
cy.contains('Enrollments').click()
cy.get('table tbody tr')
.should('have.length', 3)
@@ -122,18 +129,22 @@ describe('Students tests', () => {
})
})
it('Searching with bad inputs doesnt yield results', () => {
cy.get('.prompt').type('SWAG LITTINEN')
it("Searching with bad inputs doesn't yield results", () => {
typeToSearch('SWAG LITTINEN')
cy.contains('Student number').should('not.exist')
cy.get('.prompt').clear().type('01114')
clearSearch()
typeToSearch('01114')
cy.contains('Student number').should('not.exist')
})
it('Can jump to population page', () => {
typeStudentNumberAndClick(student.studentnumber)
cy.contains('.ui.table', 'Completed').within(() => {
cy.get('i.level.up.alternate.icon').click()
cy.get('[data-cy=study-rights-section]').within(() => {
cy.contains('Matemaattisten tieteiden kandiohjelma')
.parent()
.within(() => {
cy.get('a').click()
})
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('class size 30 students')
@@ -141,33 +152,32 @@ describe('Students tests', () => {
it('Grade graph works in all three different modes', () => {
typeStudentNumberAndClick(student.studentnumber)
cy.contains('a.item', 'Grade graph').click()
cy.contains('text.highcharts-title', 'Grade plot')
cy.contains('a.item', 'Show group mean').click()
cy.get('.labeled.input').within(() => {
cy.contains('.label', 'Group size')
cy.contains('button', 'Grade graph').click()
cy.contains('button', 'Show group mean').click()
cy.get('[data-cy=group-size-input]').within(() => {
cy.contains('label', 'Group size')
cy.get('input').should('have.value', '5')
})
cy.contains('.highcharts-container text', "Nov '22")
cy.contains('.highcharts-container text', "Jul '22").should('not.exist')
cy.contains('text.highcharts-title', 'Grade plot')
cy.get('.labeled.input').within(() => {
cy.contains('.label', 'Group size')
cy.get('input').clear().type('10')
cy.get('[data-cy=group-size-input]').within(() => {
cy.contains('label', 'Group size')
cy.get('input').clear()
cy.get('input').type('10')
cy.get('input').should('have.value', '10')
})
cy.contains('.highcharts-container text', "Jul '22")
cy.contains('.highcharts-container text', "Nov '22").should('not.exist')
cy.contains('a.item', 'Show semester mean').click()
cy.contains('button', 'Show semester mean').click()
cy.contains('.highcharts-container text', "Jan '22")
})
describe('Bachelor Honours section', () => {
it("Shows 'Qualified for Honours' tag and main modules info when the student is qualified", () => {
cy.visit('/students/495976')
cy.contains('.divider h4', 'Bachelor Honours')
cy.contains('.green.tag.label', 'Qualified for Honours')
cy.contains('Main courses and other modules').click()
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-qualified]', 'Qualified for Honours')
cy.contains('Study modules').click()
cy.get('[data-cy=main-modules] tbody tr')
.should('have.length', 3)
.each(($tr, index) => {
@@ -186,35 +196,35 @@ describe('Students tests', () => {
it("Shows 'Did not graduate in time' when the student has graduated but not in time", () => {
cy.visit('/students/540355')
cy.contains('.divider h4', 'Bachelor Honours')
cy.contains('.red.tag.label', 'Not qualified for Honours')
cy.contains('.red.tag.label', 'Did not graduate in time')
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
cy.contains('[data-cy=honours-chip-error]', 'Did not graduate in time')
})
it("Shows 'Module grades too low' when the student has graduated in time but has too low grades", () => {
cy.visit('/students/547934')
cy.contains('.divider h4', 'Bachelor Honours')
cy.contains('.red.tag.label', 'Not qualified for Honours')
cy.contains('.red.tag.label', 'Module grades too low')
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
cy.contains('[data-cy=honours-chip-error]', 'Module grades too low')
})
it("Shows 'Might need further inspection' when the student has graduated in time but has more than four main modules", () => {
cy.visit('/students/478837')
cy.contains('.divider h4', 'Bachelor Honours')
cy.contains('.blue.tag.label', 'Might need further inspection')
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-inspection]', 'Might need further inspection')
})
it("Shows 'Has not graduated' when the student has not graduated", () => {
cy.visit(`students/${student.studentnumber}`)
cy.contains('.divider h4', 'Bachelor Honours')
cy.contains('.red.tag.label', 'Not qualified for Honours')
cy.contains('.red.tag.label', 'Has not graduated')
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
cy.contains('[data-cy=honours-chip-error]', 'Has not graduated')
})
})
})
// Use admin to see all students
describe('when using admin user', () => {
describe('When using admin user', () => {
beforeEach(() => {
cy.init('/students', 'admin')
})
@@ -226,14 +236,14 @@ describe('Students tests', () => {
it("'Update student' button is shown", () => {
typeStudentNumberAndClick(student.studentnumber)
cy.get('div.ui.fluid.card').within(() => {
cy.get('[data-cy=student-info-card]').within(() => {
cy.contains('button', 'Update student')
})
})
it('Bachelor Honours section is not shown for students outside of Faculty of Science', () => {
cy.visit('/students/453146')
cy.contains('.divider h4', 'Bachelor Honours').should('not.exist')
cy.contains('h2', 'Bachelor Honours').should('not.exist')
})
it('When a study plan is selected, courses included in the study plan are highlighted with a blue background', () => {
@@ -241,9 +251,11 @@ describe('Students tests', () => {
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
cy.get('td').eq(0).click()
})
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)')
.should('have.attr', 'style')
.and('equal', 'background-color: rgb(232, 244, 255);')
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)').should(
'have.css',
'background-color',
'rgb(232, 244, 255)'
)
})
it('When a study plan is selected, the time frame of the credit graph is updated', () => {
+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')
}
})
+4 -13
View File
@@ -8,12 +8,8 @@ const visibleLinks = {
visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Programmes', 'Students']
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
const hasVisibleItems = visibleItems => {
cy.get('[data-cy=nav-bar]').children().should('have.length', visibleItems)
}
const containsLinks = links => {
cy.get('[data-cy=nav-bar]').within(() => {
cy.cs('nav-bar').within(() => {
for (const link of links) {
cy.contains(link)
}
@@ -21,7 +17,7 @@ const containsLinks = links => {
}
const userButtonWorks = (username, mocking = false) => {
cy.get('[data-cy=nav-bar-user-button]').click()
cy.cs('nav-bar-user-button').click()
cy.contains(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)
cy.contains('Language')
cy.contains('suomi')
@@ -34,7 +30,6 @@ describe('Users tests', () => {
describe('Using as user with just grp-oodikone-user, no other rights', () => {
it('shows correct tabs', () => {
cy.init('', 'norights')
hasVisibleItems(7)
containsLinks(visibleLinks.norights)
userButtonWorks('norights')
})
@@ -43,7 +38,6 @@ describe('Users tests', () => {
describe('Using as coursestatistics user', () => {
it('shows correct tabs', () => {
cy.init('', 'onlycoursestatistics')
hasVisibleItems(7)
containsLinks(visibleLinks.onlycoursestatistics)
userButtonWorks('onlycoursestatistics')
})
@@ -52,7 +46,6 @@ describe('Users tests', () => {
describe('Using as basic user', () => {
it('shows correct tabs', () => {
cy.init('')
hasVisibleItems(10)
containsLinks(visibleLinks.basic)
userButtonWorks('basic')
})
@@ -64,12 +57,11 @@ describe('Users tests', () => {
})
it('should see more stuff than others', () => {
hasVisibleItems(13)
containsLinks(visibleLinks.admin)
userButtonWorks('admin')
})
describe('can mock as other users', () => {
describe('can mock as other users', { retries: 3 }, () => {
beforeEach(() => {
cy.cs('user-edit-button-basic').click()
cy.get('i.spy').click()
@@ -80,8 +72,7 @@ describe('Users tests', () => {
})
it("only the mocked user's programmes are visible", () => {
cy.get('[data-cy=nav-bar-button-studyProgramme]').click()
cy.get('[data-cy=nav-bar-button-class]').click()
cy.visit('/populations')
cy.contains('label', 'Study programme')
.siblings()
.within(() => {
+2 -2
View File
@@ -46,7 +46,7 @@ services:
<<: *defaults
command: >
sh -c 'redis-server --appendonly yes && redis-cli flushall'
image: redis:6.2.16
image: redis:7.2.6
ports:
- 6379:6379
@@ -83,7 +83,7 @@ services:
jami-db:
<<: *defaults
image: postgres:15.8
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
+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:
+2 -2
View File
@@ -76,7 +76,7 @@ services:
<<: *defaults
command: 'redis-server --appendonly yes'
container_name: redis
image: redis:6.2.16
image: redis:7.2.6
ports:
- '6379:6379'
volumes:
@@ -189,7 +189,7 @@ services:
jami-db:
<<: *defaults
image: postgres:15.8
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
+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).
+1
View File
@@ -44,6 +44,7 @@ A collection of useful tools, documentation and other resources.
- [Material UI documentation](https://mui.com/material-ui/getting-started/)
- [Material Icons](https://mui.com/material-ui/material-icons/)
- [Material React Table documentation](https://www.material-react-table.com/docs)
- [Semantic UI React documentation](https://react.semantic-ui.com)
<!--
+8 -1
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: {
+974 -679
View File
File diff suppressed because it is too large Load Diff
+14 -14
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.11",
"@eslint/js": "^9.19.0",
"@istanbuljs/schema": "^0.1.3",
"cypress": "^13.15.2",
"eslint": "^9.14.0",
"cypress": "^14.0.1",
"eslint": "^9.19.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-cypress": "^4.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"globals": "^15.12.0",
"husky": "^9.1.6",
"lint-staged": "^15.2.10",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"globals": "^15.14.0",
"husky": "^9.1.7",
"lint-staged": "^15.4.3",
"node-actionlint": "^1.2.2",
"nyc": "^17.1.0",
"prettier": "3.3.3",
"stylelint": "^16.10.0",
"prettier": "3.4.2",
"stylelint": "^16.14.1",
"stylelint-config-standard": "^36.0.1",
"typescript-eslint": "^8.13.0"
"typescript-eslint": "^8.22.0"
},
"dependencies": {
"lodash": "^4.17.21",
+1 -12
View File
@@ -7,19 +7,10 @@
"groupSlug": "all-minor-patch",
"matchUpdateTypes": ["minor", "patch"],
"matchCurrentVersion": "!/^0/",
"automerge": true,
"matchPackageNames": ["*"]
},
{
"matchDatasources": ["docker"],
"matchPackageNames": ["node"],
"versionCompatibility": "^(?<version>[^-]+)(?<compatibility>-.*)?$",
"versioning": "node"
},
{
"matchDatasources": ["docker"],
"matchPackageNames": ["node"],
"matchUpdateTypes": ["minor", "patch"],
"enabled": false
}
],
@@ -28,7 +19,5 @@
"labels": ["security"]
},
"addLabels": ["dependencies"],
"rangeStrategy": "bump",
"schedule": ["before 5:00am"],
"timezone": "Europe/Helsinki"
"rangeStrategy": "bump"
}
+297 -726
View File
File diff suppressed because it is too large Load Diff
+11 -12
View File
@@ -5,22 +5,21 @@
"build": "tsc"
},
"dependencies": {
"@sentry/node": "^8.37.1",
"@sentry/profiling-node": "^8.37.1",
"axios": "^0.28.1",
"bullmq": "^5.25.4",
"@sentry/node": "^8.53.0",
"@sentry/profiling-node": "^8.53.0",
"axios": "^1.7.9",
"bullmq": "^5.39.1",
"compression": "^1.7.5",
"cors": "^2.8.5",
"cron": "^3.1.9",
"express": "^4.21.1",
"cron": "^3.5.0",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash": "^4.17.21",
"lru-cache": "^11.0.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"morgan": "^1.10.0",
"oodikone-shared": "file:../oodikone-shared",
"papaparse": "^5.4.1",
"papaparse": "^5.5.2",
"pg": "^8.13.1",
"redis": "^4.7.0",
"reflect-metadata": "^0.2.2",
@@ -35,16 +34,16 @@
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/lodash": "^4.17.13",
"@types/lodash": "^4.17.15",
"@types/morgan": "^1.9.9",
"@types/node": "^22.9.0",
"@types/node": "^22.12.0",
"@types/validator": "^13.12.2",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
"typescript": "^5.7.3"
},
"overrides": {
"winston-gelf-transporter": {
"gelf-pro": "^1.3.12"
"gelf-pro": "^1.4.0"
}
}
}
+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
+3
View File
@@ -43,6 +43,9 @@ export class Studyplan extends Model<InferAttributes<Studyplan>> {
@Column(DataType.ARRAY(DataType.STRING))
included_courses!: string[]
@Column({ type: DataType.ARRAY(DataType.TEXT), field: 'included_modules' })
includedModules!: string[]
@Column(DataType.STRING)
sisu_id!: string
+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 -16
View File
@@ -1,12 +1,10 @@
import crypto from 'crypto'
import { Request, Response, Router } from 'express'
import moment from 'moment'
import { serviceProvider } from '../config'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
import { CourseWithSubsId } from '../types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
import logger from '../util/logger'
const router = Router()
@@ -24,24 +22,45 @@ router.get('/v2/coursesmulti', async (req: GetCoursesRequest, res: Response) =>
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
let results: { courses: CourseWithSubsId[] } = { courses: [] }
results = await getCoursesByNameAndOrCode(name, code)
const courses = await getCoursesByNameAndOrCode(name, code)
if (combineSubstitutions === 'false') {
const courseCodes = results.courses.map(course => course.code)
const courseCodes = courses.map(course => course.code)
const substitutions = [
...new Set(
results.courses
.flatMap(course => course.substitutions)
.filter(code => code !== null && !courseCodes.includes(code))
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
),
]
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
results.courses.push(substitution.dataValues)
courses.push(substitution.toJSON())
}
}
res.json(results)
const mergedCourses = {}
for (const course of courses) {
const groupId = combineSubstitutions === 'true' ? course.subsId! : course.code
if (!(course.max_attainment_date && course.min_attainment_date)) {
continue
}
if (!mergedCourses[groupId]) {
mergedCourses[groupId] = {
...course,
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
}
} else {
const mergedCourse = mergedCourses[groupId]
if (moment(mergedCourse.max_attainment_date).isBefore(course.max_attainment_date)) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (moment(mergedCourse.min_attainment_date).isAfter(course.min_attainment_date)) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
}
res.json({ courses: Object.values(mergedCourses) })
})
interface GetCourseYearlyStatsRequest extends Request {
@@ -72,16 +91,11 @@ router.get('/v3/courseyearlystats', async (req: GetCourseYearlyStatsRequest, res
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
const separate = req.query.separate === 'true'
if (serviceProvider === 'fd')
logger.info('Debugging c.y.s.: got through authorization and collected req query params.')
// Student numbers should be obfuscated to all other users except admins,
// fullSisuAccess users, and users with rights to any specific study programmes
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
const results = await getCourseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
if (serviceProvider === 'fd')
logger.info('Debugging c.y.s.: getCourseYearlyStats returned and route should return result.')
res.json(results)
})
+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,6 +1,6 @@
import { col, fn, Op, where } from 'sequelize'
import { Course, Credit, Enrollment, Student } from '../models/index'
import { Course, Credit, Enrollment, Student, Studyplan } from '../models/index'
import { CreditTypeCode, EnrollmentState } from '../types'
type Courses = Array<Pick<Course, 'code' | 'name' | 'substitutions'>>
@@ -23,9 +23,10 @@ interface StudentInfo {
lastname: string
email: string
sis_person_id: string
secondary_email: string | null
coursesInStudyPlan: string[]
secondaryEmail: string | null
credits: StudentCredit[]
enrollments: StudentEnrollment[]
enrollments: Record<string, StudentEnrollment>
}
type StudentCredits = Record<
@@ -36,16 +37,18 @@ type StudentCredits = Record<
}
>
interface FormattedStudent {
interface FormattedStudent extends StudentInfo {
studentNumber: string
sis_person_id: string
credits: StudentCredit[]
enrollments: Record<string, any>
allEnrollments: StudentEnrollment[]
firstnames: string
lastname: string
email: string
secondaryEmail: string | null
}
interface StudentWithStudyplanNested
extends Pick<Student, 'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'> {
studyplans: { included_courses: string[] }[]
}
interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplans'> {
coursesInStudyPlan: string[]
}
const getCourses = async (courseCodes: string[]) => {
@@ -122,19 +125,27 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
}
const getStudents = async (studentNumbers: string[]) => {
const students: Array<
Pick<Student, 'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'>
> = (
await Student.findAll({
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
where: {
studentnumber: {
[Op.in]: studentNumbers,
},
const students = await Student.findAll({
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
where: {
studentnumber: {
[Op.in]: studentNumbers,
},
})
).map(student => student.toJSON())
return students
},
include: [
{
model: Studyplan,
as: 'studyplans',
attributes: ['included_courses'],
},
],
})
return students.map(student => {
const { studyplans, ...rest } = student.toJSON() as StudentWithStudyplanNested
const coursesInStudyPlan = studyplans.flatMap(studyplan => studyplan.included_courses)
return { ...rest, coursesInStudyPlan } as StudentWithCourses
})
}
export const getCompletedCourses = async (studentNumbers: string[], courseCodes: string[]) => {
@@ -159,7 +170,8 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
lastname: student.lastname,
email: student.email,
sis_person_id: student.sis_person_id,
secondary_email: student.secondary_email,
secondaryEmail: student.secondary_email,
coursesInStudyPlan: student.coursesInStudyPlan,
credits: [],
enrollments: [],
}
@@ -214,7 +226,8 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
firstnames: student.firstnames,
lastname: student.lastname,
email: student.email,
secondaryEmail: student.secondary_email,
secondaryEmail: student.secondaryEmail,
coursesInStudyPlan: student.coursesInStudyPlan,
})
return acc
}, [] as FormattedStudent[])
@@ -1,3 +1,4 @@
import { orderBy } from 'lodash'
import { Op } from 'sequelize'
import { Course } from '../../models'
@@ -52,7 +53,17 @@ const getRawCourses = async (name: string, code: string) => {
const getCourses = async (name: string, code: string) => {
const rawCourses = await getRawCourses(name, code)
const courses: CourseWithSubsId[] = rawCourses
.map(course => ({ ...course.dataValues }))
.map(course => ({
...course.toJSON(),
substitutions: orderBy(course.substitutions, [
substitution => {
if (/^A/.exec(substitution)) return 4 // open university codes come last
if (/^\d/.exec(substitution)) return 2 // old numeric codes come second
if (/^[A-Za-z]/.exec(substitution)) return 1 // new letter based codes come first
return 3 // unknown, comes before open uni?
},
]),
}))
.sort((a, b) => getSortRank(b.code) - getSortRank(a.code))
return courses
}
@@ -87,7 +98,7 @@ export const getCoursesByNameAndOrCode = async (name: string, code: string) => {
}
})
return { courses }
return courses
}
export const getCoursesByCodes = (codes: string[]) => {
@@ -1,4 +1,7 @@
import { max, min } from 'lodash'
import { Name } from '../../shared/types'
import { getSemesterNameByCode } from '../semesters'
import { FormattedProgramme, OrganizationDetails } from './helpers'
type Programme = {
@@ -284,14 +287,42 @@ export class CourseYearlyStatsCounter {
return this.programmes
}
private parseGroupStatistics(anonymizationSalt: string | null) {
private insertEmptyRows = async () => {
const first = min(Object.keys(this.groups).map(Number))
const last = max(Object.keys(this.groups).map(Number))
if (!(first && last)) {
return
}
const getNextYear = (name: string) => {
const years = name.split('-')
const startYear = parseInt(years[0], 10) + 1
const endYear = parseInt(years[1], 10) + 1
return `${startYear}-${endYear}`
}
for (let i = first + 1; i <= last; i++) {
if (this.groups[i]) {
continue
}
const previous = this.groups[i - 1]
const semester = await getSemesterNameByCode(i)
const name = typeof previous.name === 'string' ? getNextYear(previous.name) : semester.name
this.initGroup(i, name, this.groups[i - 1].coursecode, i)
}
}
private async parseGroupStatistics(anonymizationSalt: string | null) {
for (const [studentNumber, data] of this.students) {
this.groups[data.code].students.studentNumbers.push(studentNumber)
}
await this.insertEmptyRows()
const groupStatistics = Object.values(this.groups).map(({ ...rest }) => {
const { students } = rest
const grades = {}
const grades = {} as Grades
Object.keys(students.grades).forEach(student => {
const { grade, passed } = students.grades[student]
const parsedGrade = passed ? grade : '0'
@@ -350,10 +381,10 @@ export class CourseYearlyStatsCounter {
return this.facultyStats
}
public getFinalStatistics(anonymizationSalt: string | null) {
public async getFinalStatistics(anonymizationSalt: string | null) {
return {
programmes: this.parseProgrammeStatistics(anonymizationSalt),
statistics: this.parseGroupStatistics(anonymizationSalt),
statistics: await this.parseGroupStatistics(anonymizationSalt),
facultyStats: this.parseFacultyStatistics(anonymizationSalt),
obfuscated: this.obfuscated,
}
@@ -53,7 +53,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
const studyRightIds = Object.keys(studyRightMap)
const studyRightElements = await SISStudyRightElement.findAll({
attributes: ['code', 'name', 'startDate', 'studyRightId'],
attributes: ['code', 'name', 'startDate', 'endDate', 'studyRightId'],
where: {
studyRightId: {
[Op.in]: studyRightIds,
+14 -37
View File
@@ -1,12 +1,11 @@
import crypto from 'crypto'
import moment from 'moment'
import { Op } from 'sequelize'
import { serviceProvider } from '../../config'
import { Course, Credit, Enrollment, Organization, SISStudyRightElement } from '../../models'
import { Name } from '../../shared/types'
import { EnrollmentState, Unification } from '../../types'
import { isOpenUniCourseCode } from '../../util'
import logger from '../../util/logger'
import { getSortRank } from '../../util/sortRank'
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
import {
@@ -67,11 +66,22 @@ const parseCredit = (
credits,
attainment_date: attainmentDate,
student_studentnumber: studentNumber,
studyright_id: studyRightId,
} = credit
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
const studyRightElements = studentNumberToSrElementsMap[studentNumber] || []
const programmes = studyRightElements.map(formatStudyRightElement)
let programmeOfCredit: SISStudyRightElement | undefined
programmeOfCredit = studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId)
if (!programmeOfCredit) {
programmeOfCredit = studyRightElements
.filter(studyRightElement =>
moment(attainmentDate).isBetween(moment(studyRightElement.startDate), moment(studyRightElement.endDate))
)
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())[0] // The newest studyRightElement
}
const programmes = programmeOfCredit ? [programmeOfCredit].map(formatStudyRightElement) : []
const formattedCredit: FormattedCredit = {
yearCode,
@@ -149,10 +159,6 @@ const getYearlyStatsOfNew = async (
combineSubstitutions: boolean,
studentNumberToSrElementsMap: Record<string, SISStudyRightElement[]>
) => {
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s.: entered getYearlyStatsOfNew with courseCode ${courseCode} and unification ${unification}`
)
const courseForSubs = await Course.findOne({
where: { code: courseCode },
})
@@ -176,11 +182,6 @@ const getYearlyStatsOfNew = async (
}),
])
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s., getYearlyStatsOfNew, courseCode ${courseCode} unification ${unification}: got credits ${credits.length}, enrollments ${enrollments.length} and course ${course ? 'yes' : 'no'}`
)
const counter = new CourseYearlyStatsCounter()
for (const credit of credits) {
@@ -238,17 +239,8 @@ const getYearlyStatsOfNew = async (
counter.markEnrollmentToGroup(studentNumber, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
})
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: finished looping through credits.`
)
const statistics = counter.getFinalStatistics(anonymizationSalt)
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: got final statistics.`
)
const statistics = await counter.getFinalStatistics(anonymizationSalt)
let substitutionCourses: Course[] | undefined
if (combineSubstitutions && courseForSubs?.substitutions && courseForSubs.substitutions.length > 0) {
@@ -262,11 +254,6 @@ const getYearlyStatsOfNew = async (
})
}
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: got substitutionCourses ${substitutionCourses?.length} items.`
)
return {
...statistics,
coursecode: courseCode,
@@ -351,9 +338,6 @@ export const getCourseYearlyStats = async (
},
})
if (serviceProvider === 'fd')
logger.info(`Debugging c.y.s.: got credits ${credits.length} items and enrollments ${enrollments.length} items`)
const studentNumbers = {}
credits.forEach(credit => {
@@ -365,11 +349,6 @@ export const getCourseYearlyStats = async (
const studentNumberToSrElementsMap = await getStudentNumberToSrElementsMap(Object.keys(studentNumbers))
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s.: got a studentNumberToSrElementsMap of size ${Object.keys(studentNumberToSrElementsMap).length}`
)
const statsRegular = await Promise.all(
courseCodes.map(async courseCode => {
const unifyStats = await getYearlyStatsOfNew(
@@ -401,8 +380,6 @@ export const getCourseYearlyStats = async (
})
)
if (serviceProvider === 'fd') logger.info('Debugging c.y.s.: finished creating statsRegular')
return statsRegular
}
@@ -1,12 +1,11 @@
import { cloneDeep } from 'lodash'
import { NameWithCode } from '../../shared/types'
import { DegreeProgrammeType } from '../../types'
import { getBasicStats, setBasicStats } from '../analyticsService'
import { getBasicStatsForStudytrack } from '../studyProgramme/studyProgrammeBasics'
import type { ProgrammesOfOrganization } from './faculty'
type ProgrammeName = { code: string; en?: string; fi?: string; sv?: string }
type StudyTrackBasicStats = Awaited<ReturnType<typeof getBasicStatsForStudytrack>>
const calculateCombinedStats = async (
@@ -19,12 +18,12 @@ const calculateCombinedStats = async (
graphStats: [] as StudyTrackBasicStats['graphStats'],
programmeTableStats: {} as Record<string, StudyTrackBasicStats['tableStats']>,
tableStats: [] as StudyTrackBasicStats['tableStats'],
titles: ['', 'Started\nstudying', 'Accepted', 'Graduated'],
titles: ['Code', 'Started\nstudying', 'Accepted', 'Graduated'],
}
if (includeAllSpecials) {
studentInfo.titles.push('Transferred out\nof programme', 'Transferred into\nprogramme')
}
const programmeNames: Record<string, ProgrammeName> = {}
const programmeNames: Record<string, NameWithCode> = {}
let years: Array<string | number> = []
for (const programme of facultyProgrammes) {
@@ -143,7 +142,7 @@ const calculateCombinedStats = async (
graphStats: combinedGraphStats,
programmeTableStats: combinedProgrammeTableStats,
tableStats: combinedTableStats,
titles: ['', 'All', 'Bachelors', 'Masters', 'Doctors', 'Others'],
titles: ['Code', 'All', 'Bachelors', 'Masters', 'Doctors', 'Others'],
},
}
}
@@ -1,13 +1,12 @@
import { ignoredFacultyCodes } from '../../config/organizationConstants'
import { Organization } from '../../models'
import { getOrganizations } from '../organizations'
export const getFaculties = async (): Promise<Organization[]> => {
export const getFaculties = async () => {
const faculties = (await getOrganizations()).filter(faculty => !ignoredFacultyCodes.includes(faculty.code))
return faculties
}
export const getSortedFaculties = async (): Promise<Organization[]> => {
export const getSortedFaculties = async () => {
const faculties = await getFaculties()
return faculties.sort((a, b) => {
const nameA = a.name.fi ?? ''
@@ -16,8 +15,11 @@ export const getSortedFaculties = async (): Promise<Organization[]> => {
})
}
const commissionedProgrammes = ['KH50_009', 'MH50_015', 'T923103-N']
export const checkCommissioned = (studyRight: any): boolean => {
return studyRight.studyrightElements.some(element => commissionedProgrammes.includes(element.code))
export const getFacultiesForFacultyList = async () => {
const faculties = await getSortedFaculties()
return faculties.map(faculty => ({
code: faculty.code,
id: faculty.id,
name: faculty.name,
}))
}
@@ -1,7 +1,7 @@
import moment from 'moment'
import { Name } from '../../shared/types'
import { DegreeProgrammeType, Graduated, ProgrammeFilter, SpecialGroups, YearType } from '../../types'
import { Graduated, Name, ProgrammeFilter, SpecialGroups, YearType } from '../../shared/types'
import { DegreeProgrammeType } from '../../types'
import { redisClient } from '../redis'
import { FacultyProgressData } from './facultyStudentProgress'
@@ -2,7 +2,8 @@ import { cloneDeep } from 'lodash'
import moment from 'moment'
import { rootOrgId } from '../../config'
import { Graduated, SpecialGroups, Unarray } from '../../types'
import { Graduated, SpecialGroups } from '../../shared/types'
import { Unarray } from '../../types'
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
import { getYearsArray } from '../studyProgramme/studyProgrammeHelpers'
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
@@ -133,7 +133,7 @@ export const combineFacultyThesisWriters = async (
tableStats,
graphStats,
programmeTableStats,
titles: ['', 'All', 'Bachelors', 'Masters'],
titles: ['Code', 'All', 'Bachelors', 'Masters'],
programmeNames,
status: 'Done',
lastUpdated: '',
@@ -1,4 +1,4 @@
import { StatsType } from '../../types'
import { StatsType } from '../../shared/types'
import { setCreditStats } from '../analyticsService'
import { computeCreditsProduced } from '../providerCredits'
import { getDegreeProgrammesOfFaculty } from './faculty'
@@ -22,39 +22,34 @@ const getStudentsAndCourses = async (
studentNumbers: string[] | null,
courseCodes: string[] | undefined
): Promise<[number, CoursesQueryResult, EnrollmentsQueryResult]> => {
if (!studentNumbers) {
const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
const studentnumbers =
selectedStudents ??
(await getStudentNumbersWithAllStudyRightElements({
studyRights,
startDate,
endDate,
exchangeStudents,
nondegreeStudents,
transferredOutStudents: transferredStudents,
}))
const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
const allStudents = studentnumbers.length
const courses = await findCourses(studentnumbers, dateMonthsFromNow(startDate, months), courseCodes)
const foundCourseCodes = Object.keys(keyBy(courses, 'code'))
const filteredCourseCodes = courseCodes?.filter(code => !foundCourseCodes.includes(code))
const courseEnrollments = await findCourseEnrollments(
studentnumbers,
dateMonthsFromNow(startDate, months),
filteredCourseCodes
)
const studentnumbers =
studentNumbers ??
selectedStudents ??
(await getStudentNumbersWithAllStudyRightElements({
studyRights,
startDate,
endDate,
exchangeStudents,
nondegreeStudents,
transferredOutStudents: transferredStudents,
}))
return [allStudents, courses, courseEnrollments]
}
const allStudents = studentnumbers.length
const { months, startDate } = params
const beforeDate = months && startDate ? dateMonthsFromNow(startDate, months) : new Date()
const allStudents = studentNumbers.length
const courses = await findCourses(studentNumbers, beforeDate, courseCodes)
const foundCourseCodes = Object.keys(keyBy(courses, 'code'))
const courses = !studentNumbers
? await findCourses(studentnumbers, dateMonthsFromNow(startDate, months), courseCodes)
: await findCourses(studentnumbers, beforeDate, courseCodes)
const foundCourseCodes = [...new Set(courses.map(({ code }) => code))].toSorted()
const filteredCourseCodes = courseCodes?.filter(code => !foundCourseCodes.includes(code))
const courseEnrollments = await findCourseEnrollments(studentNumbers, beforeDate, filteredCourseCodes)
const courseEnrollments = filteredCourseCodes?.length
? !studentNumbers
? await findCourseEnrollments(studentnumbers, dateMonthsFromNow(startDate, months), filteredCourseCodes)
: await findCourseEnrollments(studentnumbers, beforeDate, filteredCourseCodes)
: []
return [allStudents, courses, courseEnrollments]
}
@@ -149,7 +144,7 @@ export const bottlenecksOf = async (query: Query, studentNumbers: string[] | nul
})
}
if ('credits' in course) {
course.credits.forEach(credit => {
course.credits?.forEach(credit => {
const { studentnumber, passingGrade, improvedGrade, failingGrade, grade, date } = parseCreditInfo(credit)
if (query?.selectedStudents?.includes(studentnumber) || !query?.selectedStudents) {
const semester = getPassingSemester(startYear, date)
@@ -13,6 +13,7 @@ import {
import { Name } from '../../shared/types'
import { CreditTypeCode, DegreeProgrammeType, EnrollmentType, ExtentCode, SemesterEnrollment } from '../../types'
import { redisClient } from '../redis'
import { getCurrentSemester } from '../semesters'
import { getCurriculumVersion } from './shared'
export const CLOSE_TO_GRADUATION_REDIS_KEY = 'CLOSE_TO_GRADUATION_DATA'
@@ -31,6 +32,7 @@ type AccumulatorType = {
email: string
phoneNumber: string
secondaryEmail: string
preferredLanguage: string
}
studyright: {
startDate: Date
@@ -52,6 +54,7 @@ type AccumulatorType = {
faculty: Name
attainmentDates: AttainmentDates
numberOfAbsentSemesters: number
numberOfUsedSemesters: number
curriculumPeriod: string | null
credits: {
hops: number
@@ -98,13 +101,18 @@ const findThesisAndLatestAndEarliestAttainments = (
return { attainmentDates, thesisData }
}
const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<string, Name>) => {
const formatStudent = (
student: InferAttributes<Student>,
facultyMap: Record<string, Name>,
currentSemesterCode: number
) => {
const {
studentnumber: studentNumber,
abbreviatedname: name,
sis_person_id,
email,
phone_number: phoneNumber,
preferredLanguage,
secondary_email: secondaryEmail,
} = student
return student.studyplans.reduce<AccumulatorType[]>((acc, studyPlan) => {
@@ -117,10 +125,20 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
semesterEnrollments,
} = studyRight
const numberOfAbsentSemesters = (semesterEnrollments ?? []).reduce(
(acc, enrollment) => (enrollment.type === EnrollmentType.ABSENT ? acc + 1 : acc),
0
)
let numberOfAbsentSemesters = 0
let numberOfUsedSemesters = 0
if (semesterEnrollments != null) {
for (const enrollment of semesterEnrollments) {
if (enrollment.semester > currentSemesterCode) continue
if (enrollment.type === EnrollmentType.ABSENT) {
numberOfAbsentSemesters += 1
} else {
numberOfUsedSemesters += 1
}
}
}
const {
code: programmeCode,
@@ -137,7 +155,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
)
acc.push({
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail },
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail, preferredLanguage },
studyright: {
startDate: startOfStudyright,
semesterEnrollments,
@@ -160,6 +178,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
faculty: facultyMap[programmeCode],
attainmentDates,
numberOfAbsentSemesters,
numberOfUsedSemesters,
curriculumPeriod: getCurriculumVersion(studyPlan.curriculum_period_id),
credits: {
hops: studyPlan.completed_credits,
@@ -178,6 +197,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
'creditcount',
'email',
'phone_number',
'preferredLanguage',
'secondary_email',
'sis_person_id',
'studentnumber',
@@ -294,8 +314,10 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
facultyMap[programme.code] = programme['organization.name']
}
const { semestercode: currentSemesterCode } = await getCurrentSemester()
return students
.flatMap(student => formatStudent(student, facultyMap))
.flatMap(student => formatStudent(student, facultyMap, currentSemesterCode))
.reduce(
(acc, student) => {
if (student.programme.degreeProgrammeType === DegreeProgrammeType.BACHELOR) {
@@ -97,9 +97,7 @@ const getStudents = async (studentNumbers: string[]) => {
'firstnames',
'lastname',
'studentnumber',
'home_country_en',
'home_country_fi',
'home_country_sv',
'citizenships',
'dateofuniversityenrollment',
'creditcount',
'abbreviatedname',
@@ -159,9 +159,7 @@ const formatStudentForPopulationStatistics = (
tags,
birthdate,
sis_person_id,
home_country_fi,
home_country_sv,
home_country_en,
citizenships,
} = student
const criteriaCoursesBySubstitutions = criteria?.allCourses
@@ -271,9 +269,7 @@ const formatStudentForPopulationStatistics = (
birthdate,
studyplans,
sis_person_id,
home_country_en,
home_country_fi,
home_country_sv,
citizenships,
criteriaProgress: toProgressCriteria(),
curriculumVersion: getCurriculumVersion(correctStudyplan[0]?.curriculum_period_id),
transferredStudyright,
@@ -452,24 +448,6 @@ export const formatStudentsForApi = (
}
}
const getSubstitutions = async (codes: string[]) => {
const courses = await Course.findAll({
attributes: ['code', 'substitutions'],
where: {
code: {
[Op.iLike]: { [Op.any]: codes },
},
},
raw: true,
})
const substitutions = [
...new Set(courses.flatMap(({ code, substitutions }) => [code, ...Object.values(substitutions).flat()])),
]
return substitutions
}
export type EnrollmentsQueryResult = Array<{
code: string
name: Name
@@ -486,12 +464,10 @@ export type CoursesQueryResult = Array<
}
>
// This duplicate code is added here to ensure that we get the enrollments in cases no credits found for the selected students
export const findCourseEnrollments = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => {
const courseCodes = courses.length === 0 ? ['DUMMY'] : await getSubstitutions(courses)
const result: EnrollmentsQueryResult = await sequelize.query(
`
SELECT DISTINCT ON (course.id)
SELECT DISTINCT
course.code,
course.name,
course.substitutions,
@@ -500,24 +476,32 @@ export const findCourseEnrollments = async (studentNumbers: string[], beforeDate
FROM course
INNER JOIN (
SELECT
course_id,
ARRAY_AGG(JSON_BUILD_OBJECT(
course_code,
ARRAY_AGG(JSONB_BUILD_OBJECT(
'studentnumber', studentnumber,
'state', state,
'enrollment_date_time', enrollment_date_time
)) AS data
FROM enrollment
WHERE enrollment.studentnumber IN (:studentnumbers) AND enrollment.enrollment_date_time < :beforeDate AND enrollment.state = :enrollmentState
GROUP BY enrollment.course_id
) enrollment ON enrollment.course_id = course.id
WHERE :skipCourseCodeFilter OR course.code IN (:courseCodes)
WHERE studentnumber IN (:studentnumbers)
AND enrollment_date_time < :beforeDate
AND state = :enrollmentState
GROUP BY course_code
) AS enrollment
ON enrollment.course_code = course.code
WHERE course.code IN (:courseCodes)
OR (
SELECT
JSONB_AGG(DISTINCT alt_code)
FROM course, LATERAL JSONB_ARRAY_ELEMENTS(substitutions) as alt_code
WHERE code IN (:courseCodes)
) ? course.code
`,
{
replacements: {
studentnumbers: studentNumbers.length > 0 ? studentNumbers : ['DUMMY'],
beforeDate,
courseCodes,
skipCourseCodeFilter: courses.length === 0,
courseCodes: courses.length ? courses : ['DUMMY'],
enrollmentState: EnrollmentState.ENROLLED,
},
type: QueryTypes.SELECT,
@@ -527,21 +511,35 @@ export const findCourseEnrollments = async (studentNumbers: string[], beforeDate
}
export const findCourses = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => {
const courseCodes = courses.length === 0 ? ['DUMMY'] : await getSubstitutions(courses)
const result: CoursesQueryResult = await sequelize.query(
return sequelize.query(
`
SELECT DISTINCT ON (course.id)
SELECT
course.code,
course.name,
course.substitutions,
course.main_course_code,
credit.data AS credits,
enrollment.data AS enrollments
enrollment.data AS enrollments,
credit.data AS credits
FROM course
INNER JOIN (
LEFT JOIN (
SELECT
course_code,
ARRAY_AGG(JSON_BUILD_OBJECT(
ARRAY_AGG(JSONB_BUILD_OBJECT(
'studentnumber', studentnumber,
'state', state,
'enrollment_date_time', enrollment_date_time
)) AS data
FROM enrollment
WHERE studentnumber IN (:studentnumbers)
AND enrollment_date_time < :beforeDate
AND state = :enrollmentState
GROUP BY course_code
) AS enrollment
ON enrollment.course_code = course.code
LEFT JOIN (
SELECT
course_code,
ARRAY_AGG(JSONB_BUILD_OBJECT(
'grade', grade,
'student_studentnumber', student_studentnumber,
'attainment_date', attainment_date,
@@ -549,35 +547,31 @@ export const findCourses = async (studentNumbers: string[], beforeDate: Date, co
'course_code', course_code
)) AS data
FROM credit
WHERE student_studentnumber IN (:studentnumbers) AND attainment_date < :beforeDate
GROUP BY credit.course_code
) credit ON credit.course_code = course.code
LEFT JOIN (
SELECT
course_id,
ARRAY_AGG(JSON_BUILD_OBJECT(
'studentnumber', studentnumber,
'state', state,
'enrollment_date_time', enrollment_date_time
)) AS data
FROM enrollment
WHERE enrollment.studentnumber IN (:studentnumbers) AND enrollment.enrollment_date_time < :beforeDate AND enrollment.state = :enrollmentState
GROUP BY enrollment.course_id
) enrollment ON enrollment.course_id = course.id
WHERE :skipCourseCodeFilter OR course.code IN (:courseCodes)
WHERE student_studentnumber IN (:studentnumbers)
AND attainment_date < :beforeDate
GROUP BY course_code
) AS credit
ON credit.course_code = course.code
WHERE :skipCourseCodeFilter
OR course.code IN (:courseCodes)
OR (
SELECT
JSONB_AGG(DISTINCT alt_code)
FROM course, LATERAL JSONB_ARRAY_ELEMENTS(substitutions) as alt_code
WHERE code IN (:courseCodes)
) ? course.code
`,
{
replacements: {
studentnumbers: studentNumbers.length > 0 ? studentNumbers : ['DUMMY'],
beforeDate,
courseCodes,
courseCodes: courses.length ? courses : ['DUMMY'],
skipCourseCodeFilter: courses.length === 0,
enrollmentState: EnrollmentState.ENROLLED,
},
type: QueryTypes.SELECT,
}
)
return result
) as Promise<CoursesQueryResult>
}
export const parseCreditInfo = (credit: CreditPick) => {
@@ -13,6 +13,16 @@ type SemestersAndYears = {
}
}
export const getSemesterNameByCode = async (semesterCode: number) => {
const semester = await Semester.findOne({
attributes: ['name'],
where: {
semestercode: semesterCode,
},
})
return semester!
}
export const getCurrentSemester = async () => {
const today = new Date()
const currentSemester = await Semester.findOne({
@@ -344,6 +344,7 @@ export const bySearchTermAndStudentNumbers = async (searchterm: string, studentN
},
}
: { [Op.or]: [nameLike(terms), studentnumberLike(terms)] },
order: [['dateofuniversityenrollment', 'DESC NULLS LAST']],
})
).map(formatSharedStudentData)
}
@@ -114,7 +114,30 @@ export const getTransferredCredits = async (provider: string, since: Date) =>
},
})
export const getThesisCredits = async (provider: string, thesisType: string[], studentnumbers: string[]) =>
export const getStudyRightThesisCredits = async (thesisType: string[], studyRightIds: string[]) =>
await Credit.findAll({
attributes: ['attainment_date', 'student_studentnumber'],
include: {
model: Course,
attributes: [],
where: {
course_unit_type: {
[Op.in]: thesisType,
},
},
},
where: {
credittypecode: CreditTypeCode.PASSED,
isStudyModule: {
[Op.not]: true,
},
studyright_id: {
[Op.in]: studyRightIds,
},
},
})
export const getOrganizationThesisCredits = async (provider: string, thesisType: string[], studentnumbers: string[]) =>
await Credit.findAll({
attributes: ['attainment_date', 'student_studentnumber'],
include: {
@@ -7,7 +7,7 @@ import { DegreeProgrammeType, ExtentCode, Phase, SemesterEnrollment, StudyTrack
import { getDegreeProgrammeType, getMinimumCreditsOfProgramme, sortByProgrammeCode } from '../../util'
import { countTimeCategories } from '../graduationHelpers'
import { getSemestersAndYears } from '../semesters'
import { getThesisCredits } from './creditGetters'
import { getStudyRightThesisCredits, getOrganizationThesisCredits } from './creditGetters'
import { getGraduatedStats } from './studyProgrammeBasics'
import {
defineYear,
@@ -101,19 +101,35 @@ const getGraduationTimeAndThesisWriterStats = async ({
thesisTableStats: tableStats,
}
}
const doCombo = await shouldIncludeComboStats(studyProgramme)
const graduationTimes: Record<string, number[]> = getYearsObject({ years, emptyArrays: true })
const graduationTimesCombo: Record<string, number[]> = getYearsObject({ years, emptyArrays: true })
const { semesters } = await getSemestersAndYears()
const studyRights = await getStudyRightsInProgramme(studyProgramme, false)
const studentNumbers = studyRights.map(studyRight => studyRight.studentNumber)
const thesisType = await getThesisType(studyProgramme)
const thesisCredits = await getThesisCredits(mapToProviders([studyProgramme])[0], thesisType, studentNumbers)
const thesisWriterMap = thesisCredits.reduce<Record<string, Date>>((acc, credit) => {
acc[credit.student_studentnumber] = credit.attainment_date
return acc
}, {})
const studyRights = await getStudyRightsInProgramme(studyProgramme, false)
const studyRightIds = studyRights.map(studyRight => studyRight.id)
const studentNumbers = studyRights.map(studyRight => studyRight.studentNumber)
const studyRightThesisCredits = await getStudyRightThesisCredits(thesisType, studyRightIds)
const studyRightThesisStudentNumbers = studyRightThesisCredits.map(item => item.student_studentnumber)
const organizationStudentNumbers = studentNumbers.filter(student => !studyRightThesisStudentNumbers.includes(student))
const organizationThesisCredits = await getOrganizationThesisCredits(
mapToProviders([studyProgramme])[0],
thesisType,
organizationStudentNumbers
)
const thesisWriterMap = [...studyRightThesisCredits, ...organizationThesisCredits].reduce<Record<string, Date>>(
(acc, credit) => {
acc[credit.student_studentnumber] = credit.attainment_date
return acc
},
{}
)
for (const studyRight of studyRights) {
const correctStudyRightElement = studyRight.studyRightElements.find(element => element.code === studyProgramme)
@@ -35,7 +35,7 @@ export const getStudyRightsInProgramme = async (
if (includeStudentsAndCredits) {
include.push({
model: Student,
attributes: ['gender_code', 'home_country_en'],
attributes: ['gender_code', 'citizenships'],
include: [
{
model: Credit,
@@ -267,16 +267,20 @@ const getMainStatsByTrackAndYear = async (
}
}
const studentHomeCountry = studyRight.student.home_country_en
const studentCitizenships = studyRight.student.citizenships
if (studentHomeCountry === 'Finland') {
yearlyStats[year][programmeOrStudyTrack].finnish += 1
} else {
yearlyStats[year][programmeOrStudyTrack].otherCountries += 1
if (!yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry]) {
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry] = 0
for (const citizenship of studentCitizenships) {
const country = citizenship.en
if (!country) {
continue
}
if (country === 'Finland') {
yearlyStats[year][programmeOrStudyTrack].finnish += 1
} else {
yearlyStats[year][programmeOrStudyTrack].otherCountries += 1
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[country] ??= 0
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[country] += 1
}
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry] += 1
}
if (!hasGraduated) return
+2 -2
View File
@@ -6,8 +6,8 @@ import { serviceProvider } from '../config'
import { roles } from '../config/roles'
import { sequelizeUser } from '../database/connection'
import { User } from '../models/user'
import { Role } from '../shared/types'
import { DetailedProgrammeRights, ExpandedUser, FormattedUser, IamAccess } from '../types'
import { DetailedProgrammeRights, Role } from '../shared/types'
import { ExpandedUser, FormattedUser, IamAccess } from '../types'
import { createLocaleComparator, getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
import * as jami from '../util/jami'
-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'
-2
View File
@@ -1,12 +1,10 @@
export { CourseWithSubsId, ParsedCourse } from './course'
export { CreditTypeCode } from './creditTypeCode'
export { Criteria } from './criteria'
export { DetailedProgrammeRights } from './detailedProgrammeRights'
export { DegreeProgrammeType } from './degreeProgrammeType'
export { EnrollmentState } from './enrollmentState'
export { EnrollmentType } from './enrollmentType'
export { ExtentCode } from './extentCode'
export { Graduated, SpecialGroups, StatsType, ProgrammeFilter, YearType } from './faculty'
export { GenderCode } from './genderCode'
export { IamAccess } from './iamAccess'
export { Phase } from './phase'
+1 -2
View File
@@ -2,8 +2,7 @@ import { InferAttributes } from 'sequelize'
import { User } from '../models/user'
import { Language } from '../shared/language'
import { Role } from '../shared/types'
import { DetailedProgrammeRights } from './detailedProgrammeRights'
import { DetailedProgrammeRights, Role } from '../shared/types'
export type ExpandedUser = InferAttributes<User> & {
iamGroups: string[]
+1 -2
View File
@@ -1,6 +1,5 @@
import { ProgrammeModule } from '../models'
import { Role } from '../shared/types'
import { DetailedProgrammeRights } from '../types'
import { DetailedProgrammeRights, Role } from '../shared/types'
const isObjectWithKey = (obj: unknown, key: string): obj is Record<string, unknown> => {
return typeof obj === 'object' && obj !== null && key in obj
+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 .
+753 -1013
View File
File diff suppressed because it is too large Load Diff
+21 -20
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.4.2",
"@mui/material": "^6.4.2",
"@mui/x-date-pickers": "^7.24.1",
"@reduxjs/toolkit": "^2.5.1",
"@sentry/browser": "^8.53.0",
"axios": "^1.7.9",
"highcharts": "^11.4.8",
"immer": "^10.1.1",
"lodash": "^4.17.21",
"material-react-table": "^3.0.1",
"material-react-table": "^3.1.0",
"moment": "^2.30.1",
"prop-types": "^15.8.1",
"query-string": "^9.1.1",
"react": "^18.3.1",
"react-datetime": "^3.2.0",
"react-datetime": "^3.3.1",
"react-dom": "^18.3.1",
"react-highcharts": "^16.1.0",
"react-markdown": "^9.0.1",
"react-markdown": "^9.0.3",
"react-range": "^1.10.0",
"react-redux": "^9.1.2",
"react-router-dom": "^5.3.4",
"scheduler": "^0.23.2",
"react-redux": "^9.2.0",
"react-router": "^7.1.4",
"scheduler": "^0.25.0",
"semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^3.0.0-beta.2",
"unfuck-spa-shibboleth-session": "^4.1.0",
@@ -40,13 +40,14 @@
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
},
"devDependencies": {
"@types/lodash": "^4.17.13",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/lodash": "^4.17.15",
"@types/node": "^22.12.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-highcharts": "^16.0.6",
"@vitejs/plugin-react-swc": "^3.7.2",
"typescript": "^5.6.3",
"vite": "^6.0.1"
"typescript": "^5.7.3",
"vite": "^6.0.11"
},
"overrides": {
"react-highcharts": {
@@ -110,6 +110,7 @@ export const handleRequest = store => next => async action => {
export const RTKApi = createApi({
reducerPath: 'api',
tagTypes: [
'CompletedCoursesSearchList',
'CustomPopulationSearches',
'ProgressCriteria',
'Semester',
@@ -73,6 +73,9 @@ export const facultyToolTips: Record<string, string> = {
start date of the bachelor programme and at the moment, they do not include any transferred credits.
Thus, in these statistics some students have fewer credits than in reality.
`,
interactiveDataTable: `
Sort bars in the yearly charts by programme code or other column values. Sorting is done inside the degree group.
`,
}
facultyToolTips.studentsStatsOfTheFaculty = studyProgrammeToolTips.studyTrackOverview.replace(
@@ -37,4 +37,9 @@ export const filterToolTips = {
Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.
`,
},
citizenship: {
label: null,
short:
'Rajaa opiskelijoita kansalaisuuden perusteella. Tulokseen sisältyvät opiskelijat, joilla on valitun valtion kansalaisuus, riippumatta siitä, onko heillä myös muita kansalaisuuksia.',
},
}
@@ -172,6 +172,7 @@ export const populationStatisticsToolTips = {
- ✓ Harmaa tarkastusmerkki = Opiskelija on suorittanut kurssin vastaavalla kurssikoodilla
- — Keltainen viiva = Opiskelijalla on alle 6kk vanha ilmoittautuminen kurssille
- — Harmaa viiva = Opiskelijalla on yli 6kk vanha ilmoittautuminen kurssille
- □ Harmaa laatikko = Opiskelija on lisännyt kurssin opintosuunitelmaansa, mutta ei ole ilmoittautunut kurssille tai suorittanut sitä
**Tags**
@@ -208,7 +209,7 @@ export const populationStatisticsToolTips = {
3. Jos suoritukseen tai ilmoittautumiseen ei liity opiskeluoikeutta eikä kurssia ole sijoitettu opintosuunnitelmaan, käytetään viimeisintä koulutusohjelmaa suorituksen tai ilmoittautumisen hetkellä
`,
programmeDistributionCustomPopulation: `
'Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
`,
gradeDistributionCoursePopulation: 'Näyttää korkeimman arvosanan, jonka opiskelija on saanut valitulla aikavälillä.',
creditDistributionCoursePopulation: 'Näyttää tiedekunta-/ohjelmankohtaisen opintopistejakauman.',
@@ -117,7 +117,7 @@ export const studyProgrammeToolTips: Record<string, string> = {
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Vaikka opiskelija olisi ilmoittautunut toiseen opiskeluoikeuteen läsnäolevaksi, mutta hän on laiminlyönyt ilmoittautumisen tarkasteltavaan koulutusohjelmaan liittyvään opiskeluoikeuteen, hänet lasketaan ryhmään ”Inactive”.
Paksummilla pystyviivoilla eroteltujen kategorioiden (**Current status**, **Gender**, **Countries**) sarakkeet tuottavat yhteenlaskettuna kentän **All** arvon.
Paksummilla pystyviivoilla eroteltujen kategorioiden (**Current status**, **Gender**) sarakkeet tuottavat yhteenlaskettuna kentän **All** arvon. **Citizenships**-kategoriat (**Finland** ja **Other**) voivat tuottaa yhteenlaskettuna suuremman tuloksen, koska opiskelijoilla voi olla useampi kuin yksi kansalaisuus.
Jos sivun yläosassa on valittuna ”All study rights” (oletus), mukana ovat myös ohjelmaan siirtyneet ja ohjelmasta pois siirtyneet opiskelijat.
@@ -0,0 +1,16 @@
export const updaterToolTips = {
updaterSection: `
**Update meta**: Updates organisations, study modules, course units, study levels, education types, credit types
**Update students**: Updates 1000 students at one click in development and all in production environment. Takes about 5 hours in production.
**Update curriculums**: Updates all study programmes and their curriculums. This takes a few minutes, and breaks the curriculum features for that time, so do not run in production unnecessarily.
**Refresh updater redis**: Refreshes maps (COUNTRIES, EDUCATION_TYPES, ORGANISATION_ID_TO_CODE, etc.) stored in updater redis. Check \`loadMapsOnDemand\` function in updater for more information.
`,
refreshDataSection: `
**Refresh all teacher leaderboards**: Refresh all teacher leaderboard statistics from 1951 until today. Might take some time.
**Refresh teacher leaderboards of current and previous year**: Refresh teacher leaderboard statistics for current and previous academic year.
**Refresh faculties**: Refresh data for all faculties for all tabs (time consuming).
**Refresh study programmes**: Refresh data for new study programmes for basic and studytrack tabs (time consuming).
**Refresh language center data**: Refresh data for language center view.
**Refresh close to graduation data**: Refresh data for close to graduation view.
`,
}
-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
}, {})
}
+2 -9
View File
@@ -1,4 +1,4 @@
import { filter, intersection, maxBy, orderBy, range } from 'lodash'
import { filter, maxBy, orderBy, range } from 'lodash'
import moment from 'moment'
import irtomikko from '@/assets/irtomikko.png'
@@ -17,10 +17,6 @@ export const images = {
irtomikko,
}
export const checkUserAccess = (requiredRoles, roles) => {
return intersection(requiredRoles, roles).length > 0
}
export const getGraduationGraphTitle = (studyProgramme, doCombo = false) => {
if (!studyProgramme) return ''
if (['MH30_001', 'MH30_003'].includes(studyProgramme)) return 'Licenciate study right'
@@ -288,7 +284,7 @@ export const getCreditCategories = (
return range(0, limitBreaks.length + 1).map(i => [limitBreaks[i - 1], limitBreaks[i]])
}
export const validateInputLength = (input, minLength) => input && input.trim().length >= minLength
export const validateInputLength = (input, minLength) => input?.trim().length >= minLength
export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
@@ -352,9 +348,6 @@ export const generateGradientColors = steps => {
return gradientColors
}
export const getFullStudyProgrammeRights = programmeRights =>
programmeRights ? programmeRights.filter(({ limited }) => !limited).map(({ code }) => code) : []
export const isNewStudyProgramme = programmeCode => ['MH', 'KH', 'T9'].includes(programmeCode.slice(0, 2))
/**
@@ -3,6 +3,7 @@ import { ThemeProvider } from '@mui/material/styles'
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import * as Sentry from '@sentry/browser'
import HighCharts from 'highcharts'
import moment from 'moment'
import { useEffect } from 'react'
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
@@ -16,6 +17,21 @@ import { isProduction } from '@/conf'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { theme } from '@/theme'
// TODO: What other options should be set globally?
HighCharts.setOptions({
credits: {
enabled: false,
},
plotOptions: {
series: {
animation: false,
},
},
title: {
text: '',
},
})
moment.updateLocale('en', {
week: {
dow: 1, // First day of week is Monday
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Divider, Loader, Tab } from 'semantic-ui-react'
import { getCurrentSemester } from '@/common'
import { useTitle } from '@/common/hooks'
import { useTitle } from '@/hooks/title'
import { useGetSemestersQuery } from '@/redux/semesters'
import { ColorizedCoursesTableContext } from './common'
import { FacultiesTab } from './FacultiesTab'
@@ -1,233 +0,0 @@
import qs from 'query-string'
import { useEffect, useState } from 'react'
import { useLocation, useHistory } from 'react-router-dom'
import { Button, Form, Loader, Modal, TextArea } from 'semantic-ui-react'
import { SearchHistory } from '@/components/SearchHistory'
import {
useCreateCourseListMutation,
useDeleteCourseListMutation,
useGetSavedCourseListsQuery,
useUpdateCourseListMutation,
} from '@/redux/completedCoursesSearch'
export const CompletedCoursesSearch = ({ setValues }) => {
const location = useLocation()
const history = useHistory()
const [modal, setModal] = useState(false)
const [courseInput, setCourseInput] = useState('')
const [studentInput, setStudentInput] = useState('')
const [name, setName] = useState('')
const courseLists = useGetSavedCourseListsQuery()
const [searchList, setSearches] = useState(null)
const [selectedSearchId, setSelectedSearchId] = useState('')
const isFetchingOrLoading = courseLists.isLoading || courseLists.isFetching
const isError = courseLists.isError || (courseLists.isSuccess && !courseLists.data)
const [updateCourseList, { isLoading: updateIsLoading, data: updatedData }] = useUpdateCourseListMutation()
const [createCourseList, { isLoading: createIsLoading, data: createdData }] = useCreateCourseListMutation()
const [deleteCourseList, { isLoading: deleteIsLoading, data: deletedData }] = useDeleteCourseListMutation()
const parseQueryFromUrl = () => {
const query = qs.parse(location.search)
if (!Array.isArray(query.courseList)) query.courseList = [query.courseList]
if (!Array.isArray(query.studentList)) query.studentList = [query.studentList]
return query
}
useEffect(() => {
if (courseLists.isSuccess && courseLists.data) {
setSearches(courseLists.data)
}
}, [courseLists])
useEffect(() => {
if (updatedData) {
const updatedSearches = searchList.map(s => (s.id === updatedData.id ? updatedData : s))
setSearches(updatedSearches)
}
}, [updatedData])
useEffect(() => {
if (createdData && !createdData.error) {
const newList = searchList.concat(createdData)
setSearches(newList)
}
}, [createdData])
useEffect(() => {
if (deletedData) {
const filteredSearches = searchList.filter(s => s.id !== deletedData)
setSearches(filteredSearches)
}
}, [deletedData])
useEffect(() => {
if (location.search) {
const query = parseQueryFromUrl()
setValues(query)
}
}, [location.search])
const clearForm = () => {
setCourseInput('')
setStudentInput('')
setSelectedSearchId('')
}
const pushQueryToUrl = query => {
const searchString = qs.stringify(query)
history.push({ search: searchString })
}
const handleClose = () => {
setModal(false)
clearForm()
}
const onDelete = () => {
deleteCourseList({ id: selectedSearchId })
clearForm()
}
const onSave = () => {
const courseList = courseInput
.split(/[\s,]+/)
.map(code => code.trim())
.filter(code => code !== '')
if (selectedSearchId !== '') {
updateCourseList({ id: selectedSearchId, courseList })
} else {
createCourseList({ courseList, name })
}
}
const onSelectSearch = selectedId => {
if (!selectedId) {
clearForm()
return
}
const { id: selectedSearchId } = selectedId
const selectedSearch = searchList?.find(search => search.id === selectedSearchId)
if (selectedSearch) {
setCourseInput(selectedSearch.courseList.join(', '))
setName(selectedSearch.name)
setSelectedSearchId(selectedSearch.id)
}
}
const onClicker = event => {
event.preventDefault()
const courseList = courseInput
.split(/[\s,]+/)
.map(code => code.trim())
.filter(code => code !== '')
const studentList = studentInput
.split(/[;\s,]+/)
.map(code => code.trim())
.filter(s => s !== '')
.map(s => (s.length === 8 ? `0${s}` : s))
if (courseList.length === 0 || studentList.length === 0 || studentList[0] === null) {
return
}
setValues({
studentList,
courseList,
})
pushQueryToUrl({ courseList, studentList })
handleClose()
}
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
if (isFetchingOrLoading) return <Loader active style={{ marginTop: '15em' }} />
return (
<Modal
onClose={handleClose}
open={modal}
size="small"
trigger={
<Button color="blue" data-cy="open-completed-courses-modal-button" onClick={() => setModal(true)} size="small">
Search completed courses of students
</Button>
}
>
<Modal.Content>
<Form>
<h2>Search completed courses of students</h2>
<Form.Field>
<em>Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.</em>
<TextArea
data-cy="student-no-input"
onChange={event => setStudentInput(event.target.value)}
placeholder="012345678, 12345678"
value={studentInput}
/>
</Form.Field>
<Form.Field>
<em>Insert one or more courses, separated by a space, a newline, or a comma.</em>
<TextArea
data-cy="course-list-input"
onChange={event => setCourseInput(event.target.value)}
placeholder="TKT12345, PSYK-123"
value={courseInput}
/>
</Form.Field>
<Form.Field>
<Form.Field data-cy="search-name">
<em> Insert name for this course list if you wish to save it </em>
<Form.Input
disabled={selectedSearchId !== ''}
onChange={event => setName(event.target.value)}
placeholder="name"
value={name}
/>
</Form.Field>
</Form.Field>
<SearchHistory
handleSearch={onSelectSearch}
header="Saved courselists"
items={
searchList
? searchList.map(list => ({
...list,
text: list.name,
timestamp: new Date(list.updatedAt),
params: { id: list.id },
}))
: []
}
updateItem={() => null}
/>
<Modal.Actions>
<Button
content="Save"
data-cy="save-courselist"
disabled={!name || updateIsLoading || createIsLoading}
floated="left"
icon="save"
loading={updateIsLoading || createIsLoading}
onClick={onSave}
/>
<Button
content="Delete"
disabled={!selectedSearchId || deleteIsLoading}
floated="left"
icon="trash"
negative
onClick={onDelete}
/>
<Button onClick={handleClose}>Cancel</Button>
<Button data-cy="completed-courses-search-button" onClick={event => onClicker(event)} positive>
Search
</Button>
</Modal.Actions>
</Form>
</Modal.Content>
</Modal>
)
}
@@ -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,19 +0,0 @@
.courseNameCell {
font-weight: 600;
}
.courseNameCell > span {
font-weight: initial;
font-size: smaller;
color: grey;
}
.courseRealisationCell {
padding-left: 2em !important;
}
.foldControlCell {
cursor: pointer;
padding-right: 0 !important;
width: 1em !important;
}
@@ -1,70 +0,0 @@
import { bool, func } from 'prop-types'
import { useState } from 'react'
import { Table, Icon } from 'semantic-ui-react'
import { courseDataWithRealisationsType } from '@/constants/types'
import './foldableRow.css'
export const FoldableRow = ({ courseData, onClickFn, userHasAccessToAllStats }) => {
const [isUnfolded, setIsUnfolded] = useState(true)
const { id, category, realisations } = courseData
const hasRealisations = realisations.length && realisations.length > 0
const showCourseRealisations = hasRealisations && isUnfolded
const getCell = (content, obfuscated) => (
<Table.Cell content={obfuscated ? '5 or fewer students' : content} style={{ color: obfuscated && 'gray' }} />
)
const getRow = (rowId, rowData, isMainRow = true) => {
const { passed, failed, passrate, realisation, obfuscated } = rowData
const showFoldIcon = isMainRow && hasRealisations
return (
<Table.Row className={!isMainRow ? 'subRow' : ''} key={rowId}>
<Table.Cell
className={showFoldIcon ? 'foldControlCell' : ''}
onClick={() => isMainRow && setIsUnfolded(isUnfolded => !isUnfolded)}
>
{showFoldIcon ? <Icon name={`angle ${isUnfolded ? 'down' : 'right'}`} /> : null}
</Table.Cell>
<Table.Cell
className={isMainRow ? 'courseNameCell' : 'courseRealisationCell'}
content={
isMainRow ? (
<>
{category}
{!userHasAccessToAllStats && <strong>*</strong>} <span>{id}</span>
</>
) : (
realisation
)
}
onClick={() => onClickFn(id)}
style={{ color: obfuscated && 'gray' }}
/>
{getCell(passed, obfuscated)}
{getCell(failed, obfuscated)}
{getCell(`${passrate || 0} %`, obfuscated)}
</Table.Row>
)
}
return (
<>
{getRow(id, courseData)}
{showCourseRealisations
? realisations.map(r => {
const { realisation } = r
const realisationId = `${id}-${realisation}`
return getRow(realisationId, r, false)
})
: null}
</>
)
}
FoldableRow.propTypes = {
courseData: courseDataWithRealisationsType.isRequired,
onClickFn: func.isRequired,
userHasAccessToAllStats: bool.isRequired,
}
@@ -1,40 +0,0 @@
import { arrayOf, bool, func, string } from 'prop-types'
import { Table } from 'semantic-ui-react'
import { courseDataWithRealisationsType } from '@/constants/types'
import { FoldableRow } from './foldableRow'
const getHeader = categoryName => {
const getHeaderCell = content => <Table.HeaderCell key={content}>{content}</Table.HeaderCell>
const headerLabels = [categoryName, 'Passed', 'Failed', 'Pass rate']
return (
<Table.Header>
<Table.Row>
<Table.HeaderCell />
{headerLabels.map(getHeaderCell)}
</Table.Row>
</Table.Header>
)
}
export const AttemptsTable = ({ categoryName, data, onClickCourse, userHasAccessToAllStats }) => (
<Table className="fixed-header" selectable style={{ cursor: 'pointer' }}>
{getHeader(categoryName)}
<Table.Body>
{data.map(course => (
<FoldableRow
courseData={course}
key={course.id}
onClickFn={onClickCourse}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
))}
</Table.Body>
</Table>
)
AttemptsTable.propTypes = {
categoryName: string.isRequired,
data: arrayOf(courseDataWithRealisationsType).isRequired,
onClickCourse: func.isRequired,
userHasAccessToAllStats: bool.isRequired,
}
@@ -1,9 +0,0 @@
.controlIcon,
.controlIcon:active {
box-shadow: none !important;
background: none !important;
}
#selected-table {
padding: 0 !important;
}
@@ -1,68 +0,0 @@
import { sortBy } from 'lodash'
import { arrayOf, bool, func, shape, string } from 'prop-types'
import { memo } from 'react'
import { Table } from 'semantic-ui-react'
import { getActiveYears } from '@/components/CourseStatistics/courseStatisticsUtils'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import './courseTable.css'
const CourseTable = ({ courses, onSelectCourse, hidden, title, controlIcon }) => {
const { getTextIn } = useLanguage()
const noContent = courses.length === 0
const sortCourses = courses => sortBy(courses, course => getTextIn(course.name))
const getEmptyListRow = () => (
<Table.Row>
<Table.Cell colSpan="3" content="No results." />
</Table.Row>
)
const toCourseRow = course => (
<Table.Row
key={course.id}
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
style={{ cursor: 'pointer' }}
>
<Table.Cell width={10}>
<div>{getTextIn(course.name)}</div>
<div>{getActiveYears(course)}</div>
</Table.Cell>
<Table.Cell content={[course.code, ...course.substitutions].join(', ')} />
{controlIcon ? <Table.Cell icon={controlIcon} /> : null}
</Table.Row>
)
return (
!hidden && (
<Table className="fixed-header" selectable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>{title}</Table.HeaderCell>
<Table.HeaderCell>Code</Table.HeaderCell>
{controlIcon ? <Table.HeaderCell /> : null}
</Table.Row>
</Table.Header>
<Table.Body>{noContent ? getEmptyListRow() : sortCourses(courses).map(toCourseRow)}</Table.Body>
</Table>
)
)
}
CourseTable.propTypes = {
courses: arrayOf(shape({ code: string, name: shape({}), seleted: bool })).isRequired,
onSelectCourse: func.isRequired,
hidden: bool.isRequired,
title: string.isRequired,
controlIcon: string,
}
const areEqual = (prevProps, nextProps) => {
if (prevProps.courses.length !== nextProps.courses.length) {
return false
}
return prevProps.courses.every(c1 => nextProps.courses.some(c2 => c1.code === c2.code))
}
export const MemoizedCourseTable = memo(CourseTable, areEqual)
@@ -1,118 +0,0 @@
import { flatten, uniq } from 'lodash'
import { number, string } from 'prop-types'
import { useState } from 'react'
import { useSelector } from 'react-redux'
import { Dropdown, Form, Header, Table } from 'semantic-ui-react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
const CourseTableRow = ({ facultyCode, students, credits, facultyName }) => (
<Table.Row>
<Table.Cell width={13}>
{facultyCode}, {facultyName}
</Table.Cell>
<Table.Cell width={2}>{students}</Table.Cell>
<Table.Cell width={1}>{credits}</Table.Cell>
</Table.Row>
)
const CourseTable = ({ course, courseInstance }) => {
const { getTextIn } = useLanguage()
const rows = courseInstance ? (
[
...Object.entries(courseInstance.faculties)
.sort(([facultyCodeA], [facultyCodeB]) => facultyCodeA.localeCompare(facultyCodeB))
.map(([facultyCode, instanceFaculty]) => (
<CourseTableRow
credits={instanceFaculty.credits}
facultyCode={facultyCode}
facultyName={getTextIn(instanceFaculty.name)}
key={`${course.coursecode}-${facultyCode}`}
students={instanceFaculty.students.length}
/>
)),
<Table.Row key={`${course.coursecode}-total`} style={{ backgroundColor: '#f9fafb', fontWeight: 'bold' }}>
<Table.Cell width={13}>Total</Table.Cell>
<Table.Cell width={2}>{courseInstance.allStudents.length}</Table.Cell>
<Table.Cell width={1}>{courseInstance.allCredits}</Table.Cell>
</Table.Row>,
]
) : (
<Table.Row>
<Table.Cell width={13}>No data for selected year</Table.Cell>
</Table.Row>
)
return (
<>
<Header>
{getTextIn(course.name)} ({course.coursecode})
</Header>
<Table compact>
<Table.Header>
<Table.Row>
<Table.HeaderCell width={13}>Faculty</Table.HeaderCell>
<Table.HeaderCell width={2}>Students</Table.HeaderCell>
<Table.HeaderCell width={1}>Credits</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>{rows}</Table.Body>
</Table>
</>
)
}
export const FacultyLevelStatistics = () => {
const openOrRegular = useSelector(state => state.courseSearch.openOrRegular)
const courseStats = useSelector(state => state.courseStats.data)
const yearCodes = uniq(
flatten(Object.values(courseStats).map(course => Object.keys(course[openOrRegular].facultyStats)))
)
.sort()
.reverse()
const [selectedYear, setSelectedYear] = useState(yearCodes[0])
const dropdownOptions = yearCodes.map(yearCode => ({
key: yearCode,
text: `${1949 + Number(yearCode)}-${1950 + Number(yearCode)}`,
value: yearCode,
}))
const yearsCourseStats = Object.values(courseStats)
.map(course => ({
course,
courseInstance: course[openOrRegular].facultyStats[selectedYear],
}))
.sort((a, b) => (a.courseInstance == null) - (b.courseInstance == null))
const courseTables = yearsCourseStats.map(({ course, courseInstance }) => (
<CourseTable course={course[openOrRegular]} courseInstance={courseInstance} key={course.unifyStats.coursecode} />
))
return (
<>
<Form>
<Form.Field>
<label>Select academic year</label>
<Dropdown
defaultValue={selectedYear}
onChange={(_, { value }) => setSelectedYear(value)}
options={dropdownOptions}
selection
/>
</Form.Field>
</Form>
{courseTables}
</>
)
}
CourseTableRow.propTypes = {
facultyCode: string.isRequired,
students: number.isRequired,
credits: number.isRequired,
facultyName: string.isRequired,
}
@@ -1,27 +0,0 @@
import { Label, Popup } from 'semantic-ui-react'
interface DropdownItemProps {
code: string
description: string
name: string
size: number
}
export const DropdownItem = ({ code, description, name, size }: DropdownItemProps) => (
<Popup
content={description}
disabled={!['ALL', 'OTHER', 'EXCLUDED'].includes(code)}
size="small"
trigger={
<div style={{ display: 'flex' }}>
<div style={{ flexGrow: 1 }}>
<Label content={size} icon="user" size="tiny" />
<span style={{ paddingLeft: '1rem' }}>{name}</span>
</div>
<div style={{ flexGrow: 0 }}>
<Label content={code} size="tiny" />
</div>
</div>
}
/>
)
@@ -1,49 +0,0 @@
import { orderBy } from 'lodash'
import { Form } from 'semantic-ui-react'
import { DropdownItem } from './DropdownItem'
type Option = {
description: string
key: string
name: string
size: number
students: string[]
text: string
value: string
}
interface ProgrammeDropdownProps {
label: string
name: string
onChange: () => void
options: Option[]
placeholder?: string
value: string[]
}
export const ProgrammeDropdown = ({ label, name, onChange, options, placeholder, value }: ProgrammeDropdownProps) => {
return (
<Form.Dropdown
fluid
label={label}
multiple
name={name}
onChange={onChange}
options={orderBy(options, ['size'], ['desc']).map(option => ({
key: option.key,
content: (
<DropdownItem code={option.key} description={option.description} name={option.text} size={option.size} />
),
text: option.text,
value: option.value,
}))}
placeholder={placeholder}
search
selectOnBlur={false}
selectOnNavigation={false}
selection
value={value}
/>
)
}
@@ -1,69 +0,0 @@
import qs from 'query-string'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory, useLocation } from 'react-router-dom'
import { Segment, Tab } from 'semantic-ui-react'
import { useProgress, useTabs } from '@/common/hooks'
import { getCourseStats } from '@/redux/coursestats'
import { AttemptsPane } from './panes/AttemptsPane'
import { StudentsPane } from './panes/StudentsPane'
import './resultTabs.css'
export const ResultTabs = ({ primary, comparison, separate, availableStats }) => {
const history = useHistory()
const location = useLocation()
const [tab, setTab] = useTabs('cs_tab', 0, history)
const { userHasAccessToAllStats } = primary
const courseStats = useSelector(({ courseStats }) => courseStats)
const { pending: loading } = courseStats
const { onProgress } = useProgress(loading)
const dispatch = useDispatch()
const handleTabChange = (...params) => {
setTab(...params)
}
const updateSeparate = separate => {
const { courseCodes, ...params } = qs.parse(location.search)
const query = {
...params,
courseCodes: JSON.parse(courseCodes),
separate,
}
dispatch(getCourseStats(query, onProgress))
const queryToString = { ...query, courseCodes: JSON.stringify(query.courseCodes) }
history.replace({ search: qs.stringify(queryToString) })
}
const paneTypes = [
{
label: 'Students',
icon: 'user',
component: StudentsPane,
},
{
label: 'Attempts',
icon: 'redo',
component: AttemptsPane,
},
]
const panes = paneTypes.map(({ icon, label, component: Component }) => ({
menuItem: { icon, content: label, key: label },
render: () => (
<Component
availableStats={availableStats}
datasets={[primary, comparison]}
separate={separate}
updateQuery={updateSeparate}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
}))
return (
<Segment loading={loading}>
<Tab activeIndex={tab} id="CourseStatPanes" onTabChange={handleTabChange} panes={panes} />
</Segment>
)
}
@@ -1,15 +0,0 @@
import { Pane } from './Pane'
import { AttemptsTable } from './tables/AttemptsTable'
export const AttemptsPane = ({ availableStats, datasets, separate, userHasAccessToAllStats, updateQuery }) => {
return (
<Pane
availableStats={availableStats}
datasets={datasets}
initialSettings={{ viewMode: 'ATTEMPTS', separate, splitDirection: 'row' }}
tableComponent={AttemptsTable}
updateQuery={updateQuery}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
)
}
@@ -1,179 +0,0 @@
import { useState } from 'react'
import { Segment, Tab } from 'semantic-ui-react'
import { GradeDistributionChart } from './charts/GradeDistributionChart'
import { PassRateChart } from './charts/PassRateChart'
import { ChartSettings } from './settings/ChartSettings'
import { TableSettings } from './settings/TableSettings'
const TableSection = ({
availableStats,
datasets,
settings,
setSettings,
setSplitDirection,
tableComponent: Table,
toggleSeparate,
styleContainer,
styleData,
userHasAccessToAllStats,
}) => {
return (
<>
<div style={{ marginBottom: '1em' }}>
<TableSettings
availableStats={availableStats}
datasets={datasets}
onChange={setSettings}
onSeparateChange={toggleSeparate}
setSplitDirection={setSplitDirection}
value={settings}
/>
</div>
<div style={styleContainer}>
{datasets
.filter(data => data)
.map(data => (
<div key={data.name} style={styleData}>
<Table data={data} settings={settings} userHasAccessToAllStats={userHasAccessToAllStats} />
</div>
))}
</div>
</>
)
}
const PassRateChartSection = ({
datasets,
isRelative,
setIsRelative,
styleContainer,
styleData,
userHasAccessToAllStats,
viewMode,
}) => {
return (
<>
<ChartSettings isRelative={isRelative} setIsRelative={setIsRelative} tab="PassRate" viewMode={viewMode} />
<div style={styleContainer}>
{datasets
.filter(data => data)
.map(data => (
<div key={data.name} style={styleData}>
<PassRateChart
data={data}
isRelative={isRelative}
userHasAccessToAllStats={userHasAccessToAllStats}
viewMode={viewMode}
/>
</div>
))}
</div>
</>
)
}
const GradeDistributionChartSection = ({
datasets,
isRelative,
setIsRelative,
styleContainer,
styleData,
userHasAccessToAllStats,
}) => {
return (
<>
<ChartSettings isRelative={isRelative} setIsRelative={setIsRelative} tab="GradeDistribution" />
<div style={styleContainer}>
{datasets
.filter(data => data)
.map(data => (
<div key={data.name} style={styleData}>
<GradeDistributionChart
data={data}
isRelative={isRelative}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
</div>
))}
</div>
</>
)
}
/**
* A generalized pane component that is used on the Students and the Attempts tabs.
* Contains settings and table for the corresponding tab and the pass rate chart.
*
* @param tableComponent - Should be either StudentsTable or AttemptsTable
*/
export const Pane = ({
availableStats,
datasets,
initialSettings,
tableComponent,
updateQuery,
userHasAccessToAllStats,
}) => {
const [settings, setSettings] = useState(initialSettings)
const setSplitDirection = splitDirection => {
setSettings({ ...settings, splitDirection })
}
const toggleSeparate = separate => {
setSettings({ ...settings, separate })
updateQuery(separate)
}
const halfWidth = datasets.filter(dataset => dataset).length > 1 && settings.splitDirection === 'row'
const styleContainer = {
display: 'flex',
flexDirection: settings.splitDirection,
justifyContent: 'space-between',
}
const styleData = {
flexGrow: 1,
flexBasis: 1,
marginBottom: halfWidth ? '0px' : '20px',
maxWidth: halfWidth ? '49%' : '100%',
}
return (
<Tab.Pane>
<Segment basic>
<TableSection
availableStats={availableStats}
datasets={datasets}
setSettings={setSettings}
setSplitDirection={setSplitDirection}
settings={settings}
styleContainer={styleContainer}
styleData={styleData}
tableComponent={tableComponent}
toggleSeparate={toggleSeparate}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
{settings.showGrades ? (
<GradeDistributionChartSection
datasets={datasets}
isRelative={settings.isRelative}
setIsRelative={isRelative => setSettings({ ...settings, isRelative })}
styleContainer={styleContainer}
styleData={styleData}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
) : (
<PassRateChartSection
datasets={datasets}
isRelative={settings.isRelative}
setIsRelative={isRelative => setSettings({ ...settings, isRelative })}
styleContainer={styleContainer}
styleData={styleData}
userHasAccessToAllStats={userHasAccessToAllStats}
viewMode={settings.viewMode}
/>
)}
</Segment>
</Tab.Pane>
)
}
@@ -1,15 +0,0 @@
import { Pane } from './Pane'
import { StudentsTable } from './tables/StudentsTable'
export const StudentsPane = ({ availableStats, datasets, separate, userHasAccessToAllStats, updateQuery }) => {
return (
<Pane
availableStats={availableStats}
datasets={datasets}
initialSettings={{ viewMode: 'STUDENTS', separate, splitDirection: 'row' }}
tableComponent={StudentsTable}
updateQuery={updateQuery}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
)
}
@@ -1,215 +0,0 @@
import accessibility from 'highcharts/modules/accessibility'
import exportData from 'highcharts/modules/export-data'
import exporting from 'highcharts/modules/exporting'
import ReactHighcharts from 'react-highcharts'
import {
absoluteToRelative,
getDataObject,
getGradeSpread,
getMaxValueOfSeries,
getSeriesType,
getThesisGradeSpread,
} from '@/components/CourseStatistics/ResultTabs/panes/util'
import { chartColor, color } from '@/styles/colors'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
const getGradeGraphOptions = (
colors: string[],
maxGradeValue: number,
isRelative: boolean,
statYears: string[],
title: string
) => ({
chart: {
type: 'column',
},
colors,
credits: {
enabled: false,
},
title: {
text: title,
},
legend: {
enabled: false,
},
xAxis: {
categories: statYears,
},
yAxis: {
allowDecimals: false,
title: {
text: isRelative ? 'Share of students' : 'Number of students',
},
max: maxGradeValue,
floor: -maxGradeValue,
},
plotOptions: {
column: {
stacking: 'normal' as const,
borderRadius: 2,
},
series: {
tooltip: {
valueSuffix: isRelative ? '%' : '',
},
},
},
})
const calculateSumAll = (newSeries: Record<string, number[]>) => {
return Object.values(newSeries)[0].map((_, index) =>
Object.values(newSeries)
.map(series => series[index])
.reduce((a, b) => a + b, 0)
)
}
const getGradeSeries = (series: Array<Record<string, number>>) => {
const seriesType = getSeriesType(series)
const newSeries =
seriesType === 'thesis'
? (getThesisGradeSpread(series) as Record<string, number[]>)
: (getGradeSpread(series) as Record<string, number[]>)
const sumAll = calculateSumAll(newSeries)
if (seriesType === 'thesis') {
return {
absolute: [
getDataObject('I', newSeries.I, 'a'),
getDataObject('A', newSeries.A, 'b'),
getDataObject('NSLA', newSeries.NSLA, 'c'),
getDataObject('LUB', newSeries.LUB, 'd'),
getDataObject('CL', newSeries.CL, 'e'),
getDataObject('MCLA', newSeries.MCLA, 'f'),
getDataObject('ECLA', newSeries.ECLA, 'g'),
getDataObject('L', newSeries.L, 'h'),
],
relative: [
getDataObject('I', newSeries.I.map(absoluteToRelative(sumAll)), 'a'),
getDataObject('A', newSeries.A.map(absoluteToRelative(sumAll)), 'b'),
getDataObject('NSLA', newSeries.NSLA.map(absoluteToRelative(sumAll)), 'c'),
getDataObject('LUB', newSeries.LUB.map(absoluteToRelative(sumAll)), 'd'),
getDataObject('CL', newSeries.CL.map(absoluteToRelative(sumAll)), 'e'),
getDataObject('MCLA', newSeries.MCLA.map(absoluteToRelative(sumAll)), 'f'),
getDataObject('ECLA', newSeries.ECLA.map(absoluteToRelative(sumAll)), 'g'),
getDataObject('L', newSeries.L.map(absoluteToRelative(sumAll)), 'h'),
],
}
}
if (seriesType === 'second-national-language') {
return {
absolute: [
getDataObject('0', newSeries[0], 'a'),
getDataObject('TT', newSeries.TT, 'b'),
getDataObject('HT', newSeries.HT, 'c'),
getDataObject('Hyv.', newSeries['Hyv.'], 'd'),
],
relative: [
getDataObject('0', newSeries[0].map(absoluteToRelative(sumAll)), 'a'),
getDataObject('TT', newSeries.TT.map(absoluteToRelative(sumAll)), 'b'),
getDataObject('HT', newSeries.HT.map(absoluteToRelative(sumAll)), 'c'),
getDataObject('Hyv.', newSeries['Hyv.'].map(absoluteToRelative(sumAll)), 'd'),
],
}
}
if (seriesType === 'pass-fail') {
return {
absolute: [getDataObject('0', newSeries[0], 'a'), getDataObject('Hyv.', newSeries['Hyv.'], 'b')],
relative: [
getDataObject('0', newSeries[0].map(absoluteToRelative(sumAll)), 'a'),
getDataObject('Hyv.', newSeries['Hyv.'].map(absoluteToRelative(sumAll)), 'b'),
],
}
}
return {
absolute: [
getDataObject('0', newSeries[0], 'a'),
getDataObject('1', newSeries[1], 'b'),
getDataObject('2', newSeries[2], 'c'),
getDataObject('3', newSeries[3], 'd'),
getDataObject('4', newSeries[4], 'e'),
getDataObject('5', newSeries[5], 'f'),
getDataObject('HT', newSeries.HT, 'g'),
getDataObject('TT', newSeries.TT, 'h'),
getDataObject('Hyv.', newSeries['Hyv.'], 'i'),
],
relative: [
getDataObject('0', newSeries[0].map(absoluteToRelative(sumAll)), 'a'),
getDataObject('1', newSeries[1].map(absoluteToRelative(sumAll)), 'b'),
getDataObject('2', newSeries[2].map(absoluteToRelative(sumAll)), 'c'),
getDataObject('3', newSeries[3].map(absoluteToRelative(sumAll)), 'd'),
getDataObject('4', newSeries[4].map(absoluteToRelative(sumAll)), 'e'),
getDataObject('5', newSeries[5].map(absoluteToRelative(sumAll)), 'f'),
getDataObject('HT', newSeries.HT.map(absoluteToRelative(sumAll)), 'g'),
getDataObject('TT', newSeries.TT.map(absoluteToRelative(sumAll)), 'h'),
getDataObject('Hyv.', newSeries['Hyv.'].map(absoluteToRelative(sumAll)), 'i'),
],
}
}
const getGrades = students => {
const grades = { ...students.grades }
const enrolledWithNoGrade = students.enrolledStudentsWithNoGrade || 0
grades[0] = (grades[0] || 0) + enrolledWithNoGrade
return grades
}
export const GradeDistributionChart = ({ data, isRelative, userHasAccessToAllStats }) => {
const stats = data.stats.filter(stat => stat.name !== 'Total' || isRelative)
const statYears = stats.map(year => year.name)
const grades = stats.map(year => getGrades(year.students))
const gradeGraphSeries = getGradeSeries(grades)
const seriesType = getSeriesType(grades)
const maxGradeValue = isRelative ? 100 : getMaxValueOfSeries(gradeGraphSeries.absolute)
const title = `Grades for group ${data.name}`
const colors = {
other: [
color.red,
chartColor.blue,
chartColor.blue,
chartColor.blue,
chartColor.blue,
chartColor.blue,
color.green,
color.green,
color.green,
],
'pass-fail': [color.red, color.green],
'second-national-language': [color.red, color.green, color.green, color.green],
thesis: [chartColor.blue],
}
const primaryDistributionOptions = getGradeGraphOptions(
colors[seriesType],
maxGradeValue,
isRelative,
statYears,
title
)
return (
<div>
<ReactHighcharts
config={{
...primaryDistributionOptions,
series: isRelative ? gradeGraphSeries.relative : gradeGraphSeries.absolute,
}}
/>
{!userHasAccessToAllStats && (
<span className="totalsDisclaimer">* Years with 5 students or fewer are shown as 0 in the chart</span>
)}
</div>
)
}
@@ -1,182 +0,0 @@
import accessibility from 'highcharts/modules/accessibility'
import exportData from 'highcharts/modules/export-data'
import exporting from 'highcharts/modules/exporting'
import ReactHighcharts from 'react-highcharts'
import {
absoluteToRelative,
getDataObject,
getMaxValueOfSeries,
} from '@/components/CourseStatistics/ResultTabs/panes/util'
import { chartColor, color } from '@/styles/colors'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
const colorsRelative = [color.green, color.red, chartColor.redLight]
const colors = [chartColor.blue, ...colorsRelative]
const passRateAttemptGraphOptions = (isRelative: boolean, categories: string[], max: number, title: string) => ({
chart: {
type: 'column',
},
colors: isRelative ? colorsRelative : colors,
credits: {
enabled: false,
},
title: {
text: title,
},
xAxis: {
categories,
},
yAxis: {
allowDecimals: false,
title: {
text: isRelative ? 'Share of students' : 'Number of students',
},
max,
floor: -max,
},
plotOptions: {
column: {
stacking: 'normal' as const,
borderRadius: 3,
},
series: {
tooltip: {
valueSuffix: isRelative ? '%' : '',
},
},
},
})
const passRateStudentGraphOptions = (isRelative: boolean, categories: string[], max: number, title: string) => ({
chart: {
type: 'column',
},
colors: isRelative ? colorsRelative : colors,
credits: {
enabled: false,
},
title: {
text: title,
},
xAxis: {
categories,
},
yAxis: {
allowDecimals: false,
title: {
text: isRelative ? 'Share of students' : 'Number of students',
},
max,
floor: -max,
},
plotOptions: {
column: {
stacking: 'normal' as const,
borderRadius: 1,
},
series: {
tooltip: {
valueSuffix: isRelative ? '%' : '',
},
},
},
})
const getPassRateAttemptSeriesFromStats = stats => {
const all: number[] = []
const passed: number[] = []
const failed: number[] = []
const enrolledNoGrade: number[] = []
stats.forEach(year => {
const { passed: p, failed: f } = year.attempts.categories
const { totalEnrollments } = year.attempts
const enrolledWithNoGrade = totalEnrollments > 0 ? totalEnrollments - p - f : 0
all.push(totalEnrollments || p + f)
passed.push(p)
failed.push(f)
enrolledNoGrade.push(enrolledWithNoGrade)
})
return {
absolute: [
getDataObject('all', all, 'a'),
getDataObject('passed', passed, 'b'),
getDataObject('failed', failed, 'c'),
getDataObject('enrolled, no grade', enrolledNoGrade, 'c'),
],
relative: [
getDataObject('passed', passed.map(absoluteToRelative(all)), 'a'),
getDataObject('failed', failed.map(absoluteToRelative(all)), 'a'),
getDataObject('enrolled, no grade', enrolledNoGrade.map(absoluteToRelative(all)), 'a'),
],
}
}
const getPassRateStudentSeriesFromStats = stats => {
const all: number[] = []
const passed: number[] = []
const failed: number[] = []
const enrolledNoGrade: number[] = []
stats.forEach(year => {
all.push(year.students.total || 0)
passed.push(year.students.totalPassed || 0)
failed.push(year.students.totalFailed || 0)
enrolledNoGrade.push(year.students.enrolledStudentsWithNoGrade || 0)
})
return {
absolute: [
getDataObject('all', all, 'a'),
getDataObject('passed', passed, 'b'),
getDataObject('failed', failed, 'c'),
getDataObject('enrolled, no grade', enrolledNoGrade, 'c'),
],
relative: [
getDataObject('passed', passed.map(absoluteToRelative(all)), 'a'),
getDataObject('failed', failed.map(absoluteToRelative(all)), 'a'),
getDataObject('enrolled, no grade', enrolledNoGrade.map(absoluteToRelative(all)), 'a'),
],
}
}
interface PassRateChartProps {
data: any
isRelative: boolean
userHasAccessToAllStats: boolean
viewMode: 'ATTEMPTS' | 'STUDENTS'
}
export const PassRateChart = ({ data, isRelative, userHasAccessToAllStats, viewMode }: PassRateChartProps) => {
const stats = data.stats.filter(stat => stat.name !== 'Total')
const statYears = stats.map(year => year.name)
const isAttemptsMode = viewMode === 'ATTEMPTS'
const passRateGraphSeries = isAttemptsMode
? getPassRateAttemptSeriesFromStats(stats)
: getPassRateStudentSeriesFromStats(stats)
const maxPassRateVal = isRelative ? 100 : getMaxValueOfSeries(passRateGraphSeries.absolute)
const graphOptions = isAttemptsMode ? passRateAttemptGraphOptions : passRateStudentGraphOptions
const primaryGraphOptions = graphOptions(isRelative, statYears, maxPassRateVal, `Pass rate for group ${data.name}`)
return (
<div>
<ReactHighcharts
config={{
...primaryGraphOptions,
series: isRelative ? passRateGraphSeries.relative : passRateGraphSeries.absolute,
}}
/>
{!userHasAccessToAllStats && (
<span className="totalsDisclaimer">* Years with 5 students or fewer are shown as 0 in the chart</span>
)}
</div>
)
}
@@ -1,26 +0,0 @@
import { Radio, Segment, SegmentGroup } from 'semantic-ui-react'
import { courseStatisticsToolTips } from '@/common/InfoToolTips'
import { InfoBox } from '@/components/InfoBox'
import { Setting } from './common/Setting'
export const ChartSettings = ({ isRelative, setIsRelative, tab, viewMode }) => {
return (
<div style={{ alignItems: 'center', display: 'flex', marginTop: '20px' }}>
<SegmentGroup horizontal>
<Segment>
<Setting labelText="Show relative">
<Radio checked={isRelative} onChange={() => setIsRelative(!isRelative)} toggle />
</Setting>
</Segment>
</SegmentGroup>
<div style={{ marginLeft: '20px' }}>
{tab === 'GradeDistribution' ? (
<InfoBox content={courseStatisticsToolTips.gradeDistribution} popup />
) : (
<InfoBox content={courseStatisticsToolTips.passRate[viewMode]} popup />
)}
</div>
</div>
)
}
@@ -1,49 +0,0 @@
import { Radio, Segment, SegmentGroup } from 'semantic-ui-react'
import { courseStatisticsToolTips } from '@/common/InfoToolTips'
import { InfoBox } from '@/components/InfoBox'
import { DirectionToggle } from './common/DirectionToggle'
import { ProviderOrganization } from './common/ProviderOrganization'
import { Setting } from './common/Setting'
export const TableSettings = ({ availableStats, datasets, onChange, onSeparateChange, setSplitDirection, value }) => {
const { showGrades, separate, splitDirection, viewMode } = value
const settings = [
<Setting key="gradeToggle" labelText="Show grades">
<Radio
checked={showGrades}
data-cy="gradeToggle"
onChange={() => onChange({ ...value, showGrades: !showGrades })}
toggle
/>
</Setting>,
<Setting key="separateToggle" labelText="Separate by semesters">
<Radio checked={separate} data-cy="separateToggle" onChange={() => onSeparateChange(!separate)} toggle />
</Setting>,
<Setting key="providerOrganization" labelText="Provider organization(s)">
<ProviderOrganization availableStats={availableStats} />
</Setting>,
]
if (datasets.filter(dataset => dataset).length > 1) {
settings.push(
<Setting key="splitDirection" labelText="Split direction">
<DirectionToggle setSplitDirection={setSplitDirection} splitDirection={splitDirection} />
</Setting>
)
}
return (
<div style={{ alignItems: 'center', display: 'flex' }}>
<SegmentGroup horizontal>
{settings.map(setting => (
<Segment key={setting.key}>{setting}</Segment>
))}
</SegmentGroup>
<div style={{ marginLeft: '20px' }}>
<InfoBox content={courseStatisticsToolTips.tables[viewMode]} popup />
</div>
</div>
)
}
@@ -1,14 +0,0 @@
import { Icon, Menu } from 'semantic-ui-react'
export const DirectionToggle = ({ setSplitDirection, splitDirection }) => {
return (
<Menu>
<Menu.Item active={splitDirection === 'row'} onClick={() => setSplitDirection('row')}>
<Icon name="arrows alternate horizontal" />
</Menu.Item>
<Menu.Item active={splitDirection === 'column'} onClick={() => setSplitDirection('column')}>
<Icon name="arrows alternate vertical" />
</Menu.Item>
</Menu>
)
}
@@ -1,53 +0,0 @@
import { useDispatch, useSelector } from 'react-redux'
import { Checkbox, Segment, SegmentGroup } from 'semantic-ui-react'
import { toggleOpenAndRegularCourses } from '@/redux/coursesearch'
export const ProviderOrganization = ({ availableStats }) => {
const dispatch = useDispatch()
const openOrRegular = useSelector(state => state.courseSearch.openOrRegular)
const toggleValue = (_event, { value }) => {
const options = {
regularStats: {
regularStats: 'openStats',
openStats: 'unifyStats',
unifyStats: 'openStats',
},
openStats: {
openStats: 'regularStats',
regularStats: 'unifyStats',
unifyStats: 'regularStats',
},
}
return dispatch(toggleOpenAndRegularCourses(options[value][openOrRegular]))
}
return (
<SegmentGroup horizontal style={{ margin: 0, padding: 0 }}>
<Segment>
<Checkbox
checked={openOrRegular === 'regularStats' || openOrRegular === 'unifyStats'}
data-cy="providerCheckboxUniversity"
disabled={!availableStats.university}
label={availableStats.university ? 'University' : 'University (no data)'}
name="radioGroup"
onChange={toggleValue}
value="regularStats"
/>
</Segment>
<Segment>
<Checkbox
checked={openOrRegular === 'openStats' || openOrRegular === 'unifyStats'}
data-cy="providerCheckboxOpenUni"
disabled={!availableStats.open}
label={availableStats.open ? 'Open uni' : 'Open uni (no data)'}
name="radioGroup"
onChange={toggleValue}
value="openStats"
/>
</Segment>
</SegmentGroup>
)
}
@@ -1,17 +0,0 @@
export const Setting = ({ children, labelText }) => {
return (
<div style={{ alignItems: 'center', display: 'flex', flexDirection: 'column' }}>
<label style={{ marginBottom: '10px' }}>{labelText}</label>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '60px',
justifyContent: 'center',
}}
>
{children}
</div>
</div>
)
}
@@ -1,30 +0,0 @@
.modeSelectorRow {
margin-top: 1em !important;
padding-bottom: 0 !important;
}
.modeSelectorContainer {
margin-left: 2em;
width: 100%;
}
.chartToggleContainer {
display: flex;
margin-bottom: 1.5em;
}
.toggleContainer {
display: flex;
align-items: center;
}
.toggleLabel {
cursor: pointer;
padding-left: 0.5em;
padding-right: 0.5em;
}
.totalsDisclaimer {
font-style: italic;
color: gray;
}
@@ -1,67 +0,0 @@
import { useEffect, useState } from 'react'
import { Input } from 'semantic-ui-react'
import { Timeout } from '@/components/Timeout'
enum Timeouts {
FETCH = 'fetch',
SEARCH = 'search',
}
interface AutoSubmitSearchInputProps {
cypress: string
doSearch: (searchTerm: string) => void
inputValue: string
loading: boolean
onChange: (searchTerm: string) => void
setTimeout: (timeout: Timeouts, callback: () => void, latency: number) => void
placeholder: string
}
const AutoSubmitSearchInput = ({
cypress,
doSearch,
inputValue,
loading,
onChange,
setTimeout,
placeholder,
}: AutoSubmitSearchInputProps) => {
const [input, setInput] = useState(inputValue)
const latency = 250
const minSearchLength = 0
const executeSearch = (searchTerm: string) => {
clearTimeout(Timeouts.FETCH)
setTimeout(Timeouts.FETCH, () => doSearch(searchTerm), latency)
}
useEffect(() => {
if ((input || input === '') && input.length >= minSearchLength) {
executeSearch(input)
}
}, [input])
const handleSearchChange = (_event, { value }) => {
clearTimeout(Timeouts.SEARCH)
if (value.length >= 0) {
onChange(value)
setTimeout(Timeouts.SEARCH, () => setInput(value), latency)
} else {
onChange('')
}
}
return (
<Input
data-cy={cypress}
fluid
icon="search"
loading={loading}
onChange={handleSearchChange}
placeholder={placeholder}
value={inputValue}
/>
)
}
export const TimeoutAutoSubmitSearchInput = Timeout(AutoSubmitSearchInput)
@@ -1,363 +0,0 @@
import { sortBy } from 'lodash'
import qs from 'query-string'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory, useLocation } from 'react-router-dom'
import { Form, Header, Message, Popup, Radio, Segment } from 'semantic-ui-react'
import { validateInputLength } from '@/common'
import { useSearchHistory, useToggle } from '@/common/hooks'
import { MemoizedCourseTable as CourseTable } from '@/components/CourseStatistics/CourseTable'
import { TimeoutAutoSubmitSearchInput as AutoSubmitSearchInput } from '@/components/CourseStatistics/SearchForm/AutoSubmitSearchInput'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { SearchHistory } from '@/components/SearchHistory'
import { clearCourses, findCoursesV2 } from '@/redux/coursesearch'
import { getCourseStats, clearCourseStats } from '@/redux/coursestats'
import { getCourseSearchResults } from '@/selectors/courses'
const INITIAL = {
courseName: '',
courseCode: '',
selectedCourses: {},
separate: false,
}
// For now, let's allow more courses because it's necessary and doesn't necessarily fail if the courses
// have small populations (this used to be limited to 40)
const MAX_SELECTED_COURSES = 99999
const searchBoxStyle = {
border: '2px solid black',
borderRadius: '0.3em',
padding: '12px',
boxShadow: '0 2px 4px 0 rgb(34 36 38 / 12%), 0 2px 10px 0 rgb(34 36 38 / 15%)',
}
export const SearchForm = ({ onProgress }) => {
const { getTextIn } = useLanguage()
const dispatch = useDispatch()
const location = useLocation()
const history = useHistory()
const isLoading = useSelector(state => state.courseStats.pending)
const [combineSubstitutions, toggleCombineSubstitutions] = useToggle(true)
const [selectMultipleCoursesEnabled, toggleSelectMultipleCoursesEnabled] = useToggle(false)
const matchingCourses = useSelector(state => getCourseSearchResults(state, combineSubstitutions))
const [state, setState] = useState({ ...INITIAL })
const { courseName, courseCode, selectedCourses, separate } = state
const [searchHistory, addItemToSearchHistory, updateItemInSearchHistory] = useSearchHistory('courseSearch', 6)
const parseQueryFromUrl = () => {
const {
courseCodes,
separate,
unifyOpenUniCourses,
combineSubstitutions: combineSubstitutionsFromUrl,
...rest
} = qs.parse(location.search)
const query = {
...INITIAL,
...rest,
courseCodes: JSON.parse(courseCodes),
separate: JSON.parse(separate),
combineSubstitutions: combineSubstitutionsFromUrl ?? JSON.parse(combineSubstitutions),
}
return query
}
const fetchStatisticsFromUrlParams = () => {
const query = parseQueryFromUrl()
setState({ ...state, ...query })
dispatch(getCourseStats(query, onProgress))
}
useEffect(() => {
if (!location.search) {
dispatch(clearCourses())
dispatch(clearCourseStats())
}
}, [])
useEffect(() => {
if (location.search) {
fetchStatisticsFromUrlParams()
}
}, [location.search])
const onSelectCourse = course => {
course.selected = !course.selected
const isSelected = !!selectedCourses[course.code]
if (isSelected) {
const { [course.code]: omit, ...rest } = selectedCourses
setState({
...state,
selectedCourses: rest,
})
} else {
setState({
...state,
selectedCourses: {
...selectedCourses,
[course.code]: { ...course, selected: true },
},
})
}
}
const pushQueryToUrl = query => {
const { courseCodes, ...rest } = query
const queryObject = {
...rest,
courseCodes: JSON.stringify(courseCodes),
combineSubstitutions: JSON.stringify(combineSubstitutions),
}
const searchString = qs.stringify(queryObject)
history.push({ search: searchString })
}
const onSearchHistorySelected = historyItem => {
pushQueryToUrl(historyItem)
}
const onSubmitFormClick = () => {
const codes = sortBy(Object.keys(selectedCourses))
const params = {
courseCodes: codes,
separate,
combineSubstitutions,
}
const searchHistoryText = codes.map(code => `${getTextIn(selectedCourses[code].name)} ${code}`)
addItemToSearchHistory({
text: searchHistoryText.join(', '),
params,
})
pushQueryToUrl(params)
}
const fetchCourses = () => {
const isValidName = validateInputLength(courseName, 5)
const isValidCode = validateInputLength(courseCode, 2)
if (isValidName || isValidCode) {
return dispatch(findCoursesV2({ name: courseName, code: courseCode, combineSubstitutions }))
}
if (courseName.length < 5 && courseCode.length < 2) {
dispatch(clearCourses())
}
return Promise.resolve()
}
const clearSelectedCourses = () => {
setState({ ...state, selectedCourses: {} })
}
useEffect(() => {
if (Object.keys(selectedCourses).length === 0 || selectMultipleCoursesEnabled) return
onSubmitFormClick()
}, [selectedCourses])
useEffect(() => {
const fetchData = async () => {
await fetchCourses()
}
fetchData()
clearSelectedCourses()
}, [combineSubstitutions])
useEffect(() => {
if (selectMultipleCoursesEnabled) return
clearSelectedCourses()
}, [selectMultipleCoursesEnabled])
const courses = matchingCourses.filter(course => !selectedCourses[course.code])
const selected = Object.values(selectedCourses).map(course => ({ ...course, selected: true }))
const noSelectedCourses = selected.length === 0
const disabled = isLoading || noSelectedCourses || selected.length > MAX_SELECTED_COURSES
const renderFetchStatisticsButton = () => {
const FetchStatisticsButton = (
<Form.Button
basic
content="Fetch statistics"
data-cy="fetch-stats-button"
disabled={disabled}
fluid
onClick={onSubmitFormClick}
positive
primary
size="huge"
type="button"
/>
)
if (noSelectedCourses) {
return null
}
if (disabled) {
return (
<Popup
content={getTextIn({
fi: `Olet valinnut ${selected.length} kurssia. Voit valita tarkasteltavaksi enintään ${MAX_SELECTED_COURSES} kurssia kerrallaan. Tarkenna hakua tai poista valittuja kursseja.`,
en: `You have selected ${selected.length} courses. You can select up to ${MAX_SELECTED_COURSES} courses at a time. Refine your search or remove selected courses.`,
})}
on="hover"
position="top center"
trigger={<span>{FetchStatisticsButton}</span>}
/>
)
}
return FetchStatisticsButton
}
const addAllCourses = () => {
courses.forEach(course => {
course.selected = true
})
const newSelectedCourses = courses.reduce((newSelected, course) => {
newSelected[course.code] = { ...course }
return newSelected
}, {})
setState({
...state,
selectedCourses: {
...selectedCourses,
...newSelectedCourses,
},
})
}
return (
<>
<Segment loading={isLoading}>
<Form>
<Header>Search for courses</Header>
<div style={{ marginBottom: '15px' }}>
<Form.Group>
<Form.Field width={7}>
<label>Name</label>
<AutoSubmitSearchInput
cypress="course-name-input"
doSearch={fetchCourses}
inputValue={courseName}
loading={isLoading}
onChange={courseName => setState({ ...state, courseName })}
placeholder="Search by course name"
/>
</Form.Field>
<Form.Field width={4}>
<label>Code</label>
<AutoSubmitSearchInput
cypress="course-code-input"
doSearch={fetchCourses}
inputValue={courseCode}
loading={isLoading}
onChange={courseCode => setState({ ...state, courseCode })}
placeholder="Search by course code"
/>
</Form.Field>
<Form.Field>
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: '20px',
marginLeft: '10px',
}}
>
<Popup
content={getTextIn({
fi: 'Jos "Combine substitutions" on valittuna (oletuksena), niin kurssi ja leikkaavat kurssit yhdistetään tilastoissa.',
en: 'If "Combine substitutions" is on (default behavior), then course and its substitutions are combined in the statistics.',
})}
on="hover"
position="top center"
size="tiny"
trigger={
<Radio
checked={combineSubstitutions}
data-cy="combine-substitutions-toggle"
label="Combine substitutions"
onChange={toggleCombineSubstitutions}
toggle
/>
}
/>
<Popup
content={getTextIn({
fi: 'Jos "Select multiple courses" on valittuna, voit valita tarkasteltavaksi useita kursseja.',
en: 'If "Select multiple courses" is on, you can select multiple courses to view statistics for.',
})}
on="hover"
position="top center"
size="tiny"
trigger={
<Radio
checked={selectMultipleCoursesEnabled}
data-cy="select-multiple-courses-toggle"
label="Select multiple courses"
onChange={toggleSelectMultipleCoursesEnabled}
toggle
/>
}
/>
</div>
</Form.Field>
</Form.Group>
{selectMultipleCoursesEnabled && (
<Message>
<b style={{ color: 'red', fontSize: '14pt' }}>Notice:</b> Selecting many courses may fail if there are
too many students in total. If the feature does not work, try again with fewer courses. <br /> <br />
<b>Currently selected: {Object.keys(state.selectedCourses).length || 0}</b>
</Message>
)}
<div style={!noSelectedCourses ? searchBoxStyle : null}>
<CourseTable
controlIcon="trash alternate outline"
courses={selected}
hidden={noSelectedCourses}
onSelectCourse={onSelectCourse}
selectedTable
title="Selected courses"
/>
{renderFetchStatisticsButton()}
</div>
<CourseTable
courses={courses}
hidden={isLoading || (Object.keys(courses).length === 0 && Object.keys(selectedCourses).length > 0)}
onSelectCourse={onSelectCourse}
title="Searched courses"
/>
{courses.length && selectMultipleCoursesEnabled ? (
<div className="select-all-container">
<Form.Button
basic
color="green"
content="Select all search results"
onClick={addAllCourses}
size="large"
type="button"
/>
</div>
) : null}
</div>
</Form>
</Segment>
<SearchHistory
disabled={isLoading}
handleSearch={onSearchHistorySelected}
items={searchHistory.map(item => {
item.timestamp = new Date(item.timestamp)
return item
})}
updateItem={updateItemInSearchHistory}
/>
</>
)
}
@@ -1,39 +0,0 @@
import { DropdownProps, Form } from 'semantic-ui-react'
interface YearFilterProps {
years: Array<{ key: number; text: string; value: number }>
fromYear: number
toYear: number
handleChange: (event: React.SyntheticEvent, data: DropdownProps) => void
}
export const YearFilter = ({ years, fromYear, toYear, handleChange }: YearFilterProps) => (
<Form>
<Form.Group inline>
<Form.Dropdown
inline
label="From:"
name="fromYear"
onChange={handleChange}
options={toYear ? years.filter(({ value }) => value <= toYear) : years}
placeholder="Select academic year"
selectOnBlur={false}
selectOnNavigation={false}
selection
value={fromYear}
/>
<Form.Dropdown
inline
label="To:"
name="toYear"
onChange={handleChange}
options={fromYear ? years.filter(({ value }) => value >= fromYear) : years}
placeholder="Select academic year"
selectOnBlur={false}
selectOnNavigation={false}
selection
value={toYear}
/>
</Form.Group>
</Form>
)
@@ -1,70 +0,0 @@
import { useDispatch, useSelector } from 'react-redux'
import { Divider, Form, Header, Label, Segment } from 'semantic-ui-react'
import { SingleCourseStats } from '@/components/CourseStatistics/SingleCourseStats'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { setSelectedCourse } from '@/redux/singleCourseStats'
import { getAvailableStats, getCourses, getCourseStats } from '@/selectors/courseStats'
const CourseSelector = ({ courses, selected, setSelected }) => {
const dispatch = useDispatch()
const onCourseChange = (_, { value }) => {
setSelected(value)
dispatch(setSelectedCourse(value))
}
return (
<>
<Header as="h4">Select course</Header>
<Form>
<Form.Select data-cy="course-selector" fluid onChange={onCourseChange} options={courses} value={selected} />
</Form>
<Divider />
</>
)
}
const CourseLabel = ({ code, name, primary }) => (
<Label color={primary ? 'blue' : undefined} size="large" style={{ marginBottom: 5, marginRight: 5 }}>
{code} {name}
</Label>
)
export const SingleCourseTab = ({ selected, setSelected, userHasAccessToAllStats }) => {
const { getTextIn } = useLanguage()
const stats = useSelector(getCourseStats)
const availableStats = useSelector(getAvailableStats)
const courses = useSelector(getCourses).map(({ code, name }) => ({
key: code,
value: code,
text: `${getTextIn(name)} (${code})`,
}))
if (!stats[selected]) {
return null
}
const hasSubstitutions = stats[selected].alternatives.length > 1
return (
<div>
<Segment>
<Header as="h4">{hasSubstitutions ? 'Selected courses' : 'Selected course'}</Header>
{courses.length > 1 && <CourseSelector courses={courses} selected={selected} setSelected={setSelected} />}
{hasSubstitutions && <Header as="h5">Course</Header>}
<CourseLabel code={selected} key={selected} name={getTextIn(stats[selected].name)} primary />
{hasSubstitutions && <Header as="h5">Substitutions</Header>}
{stats[selected].alternatives
.filter(course => course.code !== selected)
.map(course => (
<CourseLabel code={course.code} key={course.code} name={getTextIn(course.name)} />
))}
</Segment>
<SingleCourseStats
availableStats={availableStats[selected]}
stats={stats[selected]}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
</div>
)
}

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