Compare commits

...
55 Commits
Author SHA1 Message Date
Valtteri Kantanen 5ce3337977 Fix Markdown error in documentation 2025-01-22 15:15:34 +02:00
Valtteri Kantanen c5e24c7409 Add documentation for db schema updates 2025-01-22 14:58:30 +02:00
Joonas Ahovalli 34f029da9e [Close to graduation] Show preferred language for student 2025-01-22 14:52:13 +02:00
Joonas Ahovalli e95414806c [Updater] add preferred language to student model 2025-01-22 12:44:03 +02:00
Riku Rauhala 0eb87ea44a [Hooks] Extract remaining hooks
All hooks are now converted into TypeScript and stored in @/hooks
2025-01-20 13:53:09 +02:00
Riku Rauhala 454fabeb1d [Hooks] Extract hook useSemanticTabs
Renaming this for clarity. This will be replaced by the new useTabs hook for all MUI components
2025-01-20 12:52:04 +02:00
Riku Rauhala d3f022adc5 [Hooks] Extract hook useDeepMemo 2025-01-20 12:46:15 +02:00
Riku Rauhala 8efe3d2aa3 [Hooks] Extract hook useTabChangeAnalytics 2025-01-20 12:40:48 +02:00
Riku Rauhala d3003d5797 [Hooks] Extract hook useProgress 2025-01-20 12:36:45 +02:00
Riku Rauhala 0dd5f32f96 [Hooks] Extract hook useDidMount 2025-01-20 12:31:09 +02:00
Riku Rauhala 85b307b306 [Hooks] Extract hook useLocalStorage 2025-01-20 12:09:48 +02:00
Riku Rauhala 73eadbd1b1 [Hooks] Remove duplicate toggle hook 2025-01-20 11:59:12 +02:00
Riku Rauhala b294c560e4 [Hooks] Extract hook useTitle 2025-01-20 11:56:41 +02:00
Riku Rauhala 455d9dc532 [Hooks] Rename debouncedState for consistency 2025-01-20 11:47:32 +02:00
Riku RauhalaandGitHub df1049a436 Merge pull request #4717 from UniversityOfHelsinkiCS/material-ui-course-search
Material UI: Course search
2025-01-20 10:56:49 +02:00
Riku Rauhala ac22df8ced [Course search] Hide selected list when not selecting multiple 2025-01-20 09:52:28 +02:00
Riku Rauhala 1fd987221d [Course statistics] Update tests 2025-01-20 09:43:25 +02:00
Riku Rauhala 45985f2c44 [CourseStatistics] Convert utils into TypeScript 2025-01-17 21:27:47 +02:00
Riku Rauhala 159e11a508 [CourseStatistics] Convert to TypeScript 2025-01-17 21:08:46 +02:00
Riku Rauhala 5ff68533d7 [Courses search] Add circular progress when fetching 2025-01-17 21:04:04 +02:00
Riku Rauhala 61f20173a3 [Courses search] Place the toggles back to their original location
This seems to work better than having the toggles below
2025-01-17 20:26:56 +02:00
Riku Rauhala 987f1ec42b [Courses search] Refactor toggles 2025-01-17 20:08:19 +02:00
Riku Rauhala 3f4c39600b [Course search] Add missing search icons 2025-01-17 19:54:02 +02:00
Riku Rauhala 88ef490b1b [CourseStatistics] Rewrite course search with Material UI 2025-01-17 18:34:57 +02:00
Riku Rauhala 7e135d4643 [Types] Extract type SearchHistoryItem 2025-01-16 10:03:10 +02:00
Riku Rauhala 06a8760701 [Hooks] Extract hook useSearchHistory 2025-01-15 19:16:42 +02:00
Riku Rauhala 5eaa035f1d [Hooks] Extract hook useToggle 2025-01-15 18:05:50 +02:00
Riku Rauhala ae1f0ceeb7 [Redux] Type courseSearch 2025-01-15 17:56:04 +02:00
Riku Rauhala 3ca82286b4 [Redux] Type courseStats 2025-01-15 17:09:22 +02:00
Riku Rauhala 63add43c0f [Course statistics] Move components under pages 2025-01-15 10:56:44 +02:00
Rajamäki Santeri J 72440609b4 [Study Programme] Fixed Cypress broken tests 2025-01-13 18:14:59 +02:00
Rajamäki Santeri J c946cd2405 [Study Programme] Added study right thesis counting
Closing issue #4712
2025-01-13 17:56:51 +02:00
Valtteri Kantanen 96d316ebf5 [Frontend] Rewrite Updater page with MUI components 2025-01-13 15:43:32 +02:00
Riku Rauhala b3eb7dceac [Custom population] Fix an info box text
There was an extra ' character
2025-01-10 14:18:07 +02:00
Valtteri Kantanen ee02d002ba [Course statistics] Fix tests 2025-01-10 13:16:22 +02:00
Valtteri Kantanen 34448d27d0 [Frontend] Remove unused components 2025-01-10 13:03:05 +02:00
Valtteri Kantanen 39ce64edce [Course statistics] Move minimum and maximum attainment date calculation to the backend 2025-01-10 13:02:46 +02:00
Valtteri Kantanen 0d210a607b [Course statistics] Fix a bug in the search form where every keypress triggered a new backend request 2025-01-10 13:01:23 +02:00
Valtteri Kantanen f1a6302928 [Course statistics] Use RTK Query for course search 2025-01-10 12:59:25 +02:00
Valtteri Kantanen 359db8f365 [Frontend] Add custom useDebouncedState hook 2025-01-10 12:58:12 +02:00
Valtteri Kantanen 387a3e0ef2 [Frontend] Refactor validateInputLength to always return a boolean 2025-01-10 12:56:47 +02:00
Valtteri Kantanen 4c957b347b Remove automerge from renovate config as it doesn't seem to work properly 2025-01-09 10:41:35 +02:00
Valtteri KantanenandGitHub 10d3d5aeed Merge pull request #4711 from UniversityOfHelsinkiCS/OOD-80
[Frontend] Added env variable for sisu url and changed to use that instead of hardcoded sisu urls
2025-01-09 10:26:10 +02:00
Matti Räsänen 6e365d4a23 Removed ending slash from SIS_URL in fd ci compose 2025-01-08 09:08:32 +02:00
Matti Räsänen 68292a45d0 [FD compose] Changed SIS_HOST to SIS_URL in Dockerfile and added SIS_URL to fd ci compose as well 2025-01-07 16:17:25 +02:00
Matti Räsänen f0088cf7a0 [Frontend] Added env variable for sisu url and changed to use that instead of hardcoded sisu urls 2025-01-07 16:03:09 +02:00
Valtteri KantanenandGitHub b8469a242e Merge pull request #4595 from UniversityOfHelsinkiCS/renovate/axios-1.x
Update dependency axios to v1
2025-01-07 15:21:27 +02:00
Valtteri Kantanen 8c5d5f1ee9 [General tab] Move Bachelor/Master column more to the left 2025-01-07 15:13:31 +02:00
Valtteri Kantanen 80759cacee [Frontend] Fix key spread error on RangeSelector 2025-01-07 14:25:38 +02:00
renovate[bot]andGitHub 4df2cc78ff Update dependency axios to v1 2025-01-07 11:58:36 +00:00
Valtteri Kantanen 8b34f7a5f9 [Faculties] Use StyledTable instead of regular Table 2025-01-07 13:55:09 +02:00
Valtteri Kantanen e81d6eb877 [Faculties] Fix tests 2025-01-07 10:58:32 +02:00
Valtteri Kantanen 0c47d7c21c [Faculties] Combine 'Students by starting year' and 'Progress' tabs 2025-01-07 10:46:43 +02:00
Valtteri KantanenandGitHub 175996235d Merge pull request #4705 from UniversityOfHelsinkiCS/renovate/all-minor-patch
Update all non-major dependencies
2025-01-07 09:45:05 +02:00
renovate[bot]andGitHub 640dc741a4 Update all non-major dependencies 2024-12-17 08:19:50 +00:00
145 changed files with 1883 additions and 2533 deletions
+10 -10
View File
@@ -163,8 +163,8 @@ describe('Course Statistics tests', () => {
it('Searching multiple courses having substitution mappings shows course statistics', () => {
cy.contains('Search for courses')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'checked').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'checked')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
searchByCourseCode('TKT')
cy.contains('td', /^TKT20001/).click()
cy.contains('td', /^TKT10002/).click()
@@ -195,13 +195,13 @@ describe('Course Statistics tests', () => {
cy.contains('Fetch statistics').click()
cy.contains('Search for courses').should('not.exist')
cy.get('.ui.contentSegment .ui.menu').contains('Course').click()
cy.get('.ui.menu').contains('Course').click()
cy.cs('course-selector').get('.active.selected.item').contains('TKT10002')
cy.get('#CourseStatPanes table a.item:first').click()
cy.contains('Population of course Introduction to Programming 2023-2024 (open and normal)')
cy.go('back')
cy.get('.ui.contentSegment .ui.menu').contains('Course').click()
cy.get('.ui.menu').contains('Course').click()
cy.cs('course-selector').click()
cy.cs('course-selector').contains('TKT20001').click()
cy.get('#CourseStatPanes table a.item:first').click()
@@ -238,17 +238,17 @@ describe('Course Statistics tests', () => {
cy.contains('581328 Tietokantojen perusteet')
cy.contains('A581328 Avoin yo: Tietokantojen perusteet')
cy.get('.right').click()
cy.contains('No results')
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
})
it('"Select all search results" button is not showing unless "Select multiple courses" toggle is on', () => {
cy.contains('Search for courses')
searchByCourseCode('TKT')
cy.get('[data-cy="select-multiple-courses-toggle"]').should('not.have.class', 'checked')
cy.get('[data-cy="select-multiple-courses-toggle"]').should('not.have.class', 'Mui-checked')
cy.contains('TKT10004, BSCS2001, 581328, A581328, AYTKT10004')
cy.contains('Select all search results').should('not.exist')
cy.get('[data-cy="select-multiple-courses-toggle"]').click()
cy.get('[data-cy="select-multiple-courses-toggle"]').should('have.class', 'checked')
cy.get('[data-cy="select-multiple-courses-toggle"]').should('have.class', 'Mui-checked')
cy.contains('Select all search results')
})
@@ -283,7 +283,7 @@ describe('Course Statistics tests', () => {
cy.contains('BSCS1003 Data Structures and Algorithms')
cy.contains('58131 Tietorakenteet')
cy.get('.right').click()
cy.contains('No results')
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
searchByCourseName('tietorakenteet ja algoritmit')
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
@@ -354,8 +354,8 @@ describe('Course Statistics tests', () => {
beforeEach(() => {
cy.url().should('include', '/coursestatistics')
cy.contains('Search for courses')
cy.get('[data-cy="combine-substitutions-toggle"]').should('have.class', 'checked').click()
cy.get('[data-cy="combine-substitutions-toggle"]').should('not.have.class', 'checked')
cy.get('[data-cy="combine-substitutions-toggle"]').should('have.class', 'Mui-checked').click()
cy.get('[data-cy="combine-substitutions-toggle"]').should('not.have.class', 'Mui-checked')
searchByCourseCode('TKT10002')
cy.contains('td', /^TKT10002$/).click()
cy.contains('Search for courses').should('not.exist')
-2
View File
@@ -58,7 +58,6 @@ describe('Faculty statistics', () => {
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', 'Progress')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Update statistics')
})
@@ -106,7 +105,6 @@ describe('Faculty statistics', () => {
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', 'Progress')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
cy.get('[data-cy="FacultyTabs"]').should('not.contain', 'Update statistics')
})
+11 -11
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()
+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}
+1
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
+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).
+95 -95
View File
@@ -29,9 +29,9 @@
"node-actionlint": "^1.2.2",
"nyc": "^17.1.0",
"prettier": "3.4.2",
"stylelint": "^16.11.0",
"stylelint": "^16.12.0",
"stylelint-config-standard": "^36.0.1",
"typescript-eslint": "^8.18.0"
"typescript-eslint": "^8.18.1"
}
},
"node_modules/@ampproject/remapping": {
@@ -1933,6 +1933,29 @@
"@csstools/css-tokenizer": "^3.0.3"
}
},
"node_modules/@csstools/selector-specificity": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz",
"integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"postcss-selector-parser": "^7.0.0"
}
},
"node_modules/@cypress/code-coverage": {
"version": "3.13.9",
"resolved": "https://registry.npmjs.org/@cypress/code-coverage/-/code-coverage-3.13.9.tgz",
@@ -2620,17 +2643,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.0.tgz",
"integrity": "sha512-NR2yS7qUqCL7AIxdJUQf2MKKNDVNaig/dEB0GBLU7D+ZdHgK1NoH/3wsgO3OnPVipn51tG3MAwaODEGil70WEw==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.1.tgz",
"integrity": "sha512-Ncvsq5CT3Gvh+uJG0Lwlho6suwDfUXH0HztslDf5I+F2wAFAZMRwYLEorumpKLzmO2suAXZ/td1tBg4NZIi9CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.18.0",
"@typescript-eslint/type-utils": "8.18.0",
"@typescript-eslint/utils": "8.18.0",
"@typescript-eslint/visitor-keys": "8.18.0",
"@typescript-eslint/scope-manager": "8.18.1",
"@typescript-eslint/type-utils": "8.18.1",
"@typescript-eslint/utils": "8.18.1",
"@typescript-eslint/visitor-keys": "8.18.1",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@@ -2650,16 +2673,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.0.tgz",
"integrity": "sha512-hgUZ3kTEpVzKaK3uNibExUYm6SKKOmTU2BOxBSvOYwtJEPdVQ70kZJpPjstlnhCHcuc2WGfSbpKlb/69ttyN5Q==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.1.tgz",
"integrity": "sha512-rBnTWHCdbYM2lh7hjyXqxk70wvon3p2FyaniZuey5TrcGBpfhVp0OxOa6gxr9Q9YhZFKyfbEnxc24ZnVbbUkCA==",
"dev": true,
"license": "MITClause",
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.18.0",
"@typescript-eslint/types": "8.18.0",
"@typescript-eslint/typescript-estree": "8.18.0",
"@typescript-eslint/visitor-keys": "8.18.0",
"@typescript-eslint/scope-manager": "8.18.1",
"@typescript-eslint/types": "8.18.1",
"@typescript-eslint/typescript-estree": "8.18.1",
"@typescript-eslint/visitor-keys": "8.18.1",
"debug": "^4.3.4"
},
"engines": {
@@ -2675,14 +2698,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.0.tgz",
"integrity": "sha512-PNGcHop0jkK2WVYGotk/hxj+UFLhXtGPiGtiaWgVBVP1jhMoMCHlTyJA+hEj4rszoSdLTK3fN4oOatrL0Cp+Xw==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.1.tgz",
"integrity": "sha512-HxfHo2b090M5s2+/9Z3gkBhI6xBH8OJCFjH9MhQ+nnoZqxU3wNxkLT+VWXWSFWc3UF3Z+CfPAyqdCTdoXtDPCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.18.0",
"@typescript-eslint/visitor-keys": "8.18.0"
"@typescript-eslint/types": "8.18.1",
"@typescript-eslint/visitor-keys": "8.18.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2693,14 +2716,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.0.tgz",
"integrity": "sha512-er224jRepVAVLnMF2Q7MZJCq5CsdH2oqjP4dT7K6ij09Kyd+R21r7UVJrF0buMVdZS5QRhDzpvzAxHxabQadow==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.1.tgz",
"integrity": "sha512-jAhTdK/Qx2NJPNOTxXpMwlOiSymtR2j283TtPqXkKBdH8OAMmhiUfP0kJjc/qSE51Xrq02Gj9NY7MwK+UxVwHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.18.0",
"@typescript-eslint/utils": "8.18.0",
"@typescript-eslint/typescript-estree": "8.18.1",
"@typescript-eslint/utils": "8.18.1",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@@ -2717,9 +2740,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.0.tgz",
"integrity": "sha512-FNYxgyTCAnFwTrzpBGq+zrnoTO4x0c1CKYY5MuUTzpScqmY5fmsh2o3+57lqdI3NZucBDCzDgdEbIaNfAjAHQA==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.1.tgz",
"integrity": "sha512-7uoAUsCj66qdNQNpH2G8MyTFlgerum8ubf21s3TSM3XmKXuIn+H2Sifh/ES2nPOPiYSRJWAk0fDkW0APBWcpfw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2731,14 +2754,14 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.0.tgz",
"integrity": "sha512-rqQgFRu6yPkauz+ms3nQpohwejS8bvgbPyIDq13cgEDbkXt4LH4OkDMT0/fN1RUtzG8e8AKJyDBoocuQh8qNeg==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.1.tgz",
"integrity": "sha512-z8U21WI5txzl2XYOW7i9hJhxoKKNG1kcU4RzyNvKrdZDmbjkmLBo8bgeiOJmA06kizLI76/CCBAAGlTlEeUfyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.18.0",
"@typescript-eslint/visitor-keys": "8.18.0",
"@typescript-eslint/types": "8.18.1",
"@typescript-eslint/visitor-keys": "8.18.1",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -2797,16 +2820,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.0.tgz",
"integrity": "sha512-p6GLdY383i7h5b0Qrfbix3Vc3+J2k6QWw6UMUeY5JGfm3C5LbZ4QIZzJNoNOfgyRe0uuYKjvVOsO/jD4SJO+xg==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.1.tgz",
"integrity": "sha512-8vikiIj2ebrC4WRdcAdDcmnu9Q/MXXwg+STf40BVfT8exDqBCUPdypvzcUPxEqRGKg9ALagZ0UWcYCtn+4W2iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.18.0",
"@typescript-eslint/types": "8.18.0",
"@typescript-eslint/typescript-estree": "8.18.0"
"@typescript-eslint/scope-manager": "8.18.1",
"@typescript-eslint/types": "8.18.1",
"@typescript-eslint/typescript-estree": "8.18.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2821,13 +2844,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.0.tgz",
"integrity": "sha512-pCh/qEA8Lb1wVIqNvBke8UaRjJ6wrAWkJO5yyIbs8Yx6TNGYyfNjOo61tLv+WwLvoLPp4BQ8B7AHKijl8NGUfw==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.1.tgz",
"integrity": "sha512-Vj0WLm5/ZsD013YeUKn+K0y8p1M0jPpxOkKdbD1wB0ns53a5piVY02zjf072TblEweAbcYiFiPoSMF3kp+VhhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.18.0",
"@typescript-eslint/types": "8.18.1",
"eslint-visitor-keys": "^4.2.0"
},
"engines": {
@@ -8943,6 +8966,20 @@
"postcss": "^8.4.31"
}
},
"node_modules/postcss-selector-parser": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz",
"integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
@@ -9920,9 +9957,9 @@
}
},
"node_modules/stylelint": {
"version": "16.11.0",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.11.0.tgz",
"integrity": "sha512-zrl4IrKmjJQ+h9FoMp69UMCq5SxeHk0URhxUBj4d3ISzo/DplOFBJZc7t7Dr6otB+1bfbbKNLOmCDpzKSlW+Nw==",
"version": "16.12.0",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.12.0.tgz",
"integrity": "sha512-F8zZ3L/rBpuoBZRvI4JVT20ZanPLXfQLzMOZg1tzPflRVh9mKpOZ8qcSIhh1my3FjAjZWG4T2POwGnmn6a6hbg==",
"dev": true,
"funding": [
{
@@ -9972,7 +10009,7 @@
"string-width": "^4.2.3",
"supports-hyperlinks": "^3.1.0",
"svg-tags": "^1.0.0",
"table": "^6.8.2",
"table": "^6.9.0",
"write-file-atomic": "^5.0.1"
},
"bin": {
@@ -10031,29 +10068,6 @@
"stylelint": "^16.1.0"
}
},
"node_modules/stylelint/node_modules/@csstools/selector-specificity": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz",
"integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"postcss-selector-parser": "^7.0.0"
}
},
"node_modules/stylelint/node_modules/balanced-match": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz",
@@ -10098,20 +10112,6 @@
"node": ">= 4"
}
},
"node_modules/stylelint/node_modules/postcss-selector-parser": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz",
"integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/stylelint/node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
@@ -10208,9 +10208,9 @@
}
},
"node_modules/table": {
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz",
"integrity": "sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==",
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz",
"integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -10665,15 +10665,15 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.18.0.tgz",
"integrity": "sha512-Xq2rRjn6tzVpAyHr3+nmSg1/9k9aIHnJ2iZeOH7cfGOWqTkXTm3kwpQglEuLGdNrYvPF+2gtAs+/KF5rjVo+WQ==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.18.1.tgz",
"integrity": "sha512-Mlaw6yxuaDEPQvb/2Qwu3/TfgeBHy9iTJ3mTwe7OvpPmF6KPQjVOfGyEJpPv6Ez2C34OODChhXrzYw/9phI0MQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.18.0",
"@typescript-eslint/parser": "8.18.0",
"@typescript-eslint/utils": "8.18.0"
"@typescript-eslint/eslint-plugin": "8.18.1",
"@typescript-eslint/parser": "8.18.1",
"@typescript-eslint/utils": "8.18.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+2 -2
View File
@@ -56,9 +56,9 @@
"node-actionlint": "^1.2.2",
"nyc": "^17.1.0",
"prettier": "3.4.2",
"stylelint": "^16.11.0",
"stylelint": "^16.12.0",
"stylelint-config-standard": "^36.0.1",
"typescript-eslint": "^8.18.0"
"typescript-eslint": "^8.18.1"
},
"dependencies": {
"lodash": "^4.17.21",
-1
View File
@@ -7,7 +7,6 @@
"groupSlug": "all-minor-patch",
"matchUpdateTypes": ["minor", "patch"],
"matchCurrentVersion": "!/^0/",
"automerge": true,
"matchPackageNames": ["*"]
},
{
+60 -837
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -5,9 +5,9 @@
"build": "tsc"
},
"dependencies": {
"@sentry/node": "^8.45.0",
"@sentry/profiling-node": "^8.45.0",
"axios": "^0.29.0",
"@sentry/node": "^8.45.1",
"@sentry/profiling-node": "^8.45.1",
"axios": "^1.7.9",
"bullmq": "^5.34.2",
"compression": "^1.7.5",
"cors": "^2.8.5",
+3
View File
@@ -69,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
+30 -9
View File
@@ -1,10 +1,10 @@
import crypto from 'crypto'
import { Request, Response, Router } from 'express'
import moment from 'moment'
import { serviceProvider } from '../config'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
import { CourseWithSubsId } from '../types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
import logger from '../util/logger'
@@ -24,24 +24,45 @@ router.get('/v2/coursesmulti', async (req: GetCoursesRequest, res: Response) =>
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
let results: { courses: CourseWithSubsId[] } = { courses: [] }
results = await getCoursesByNameAndOrCode(name, code)
const courses = await getCoursesByNameAndOrCode(name, code)
if (combineSubstitutions === 'false') {
const courseCodes = results.courses.map(course => course.code)
const courseCodes = courses.map(course => course.code)
const substitutions = [
...new Set(
results.courses
.flatMap(course => course.substitutions)
.filter(code => code !== null && !courseCodes.includes(code))
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
),
]
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
results.courses.push(substitution.dataValues)
courses.push(substitution.toJSON())
}
}
res.json(results)
const mergedCourses = {}
for (const course of courses) {
const groupId = combineSubstitutions === 'true' ? course.subsId! : course.code
if (!(course.max_attainment_date && course.min_attainment_date)) {
continue
}
if (!mergedCourses[groupId]) {
mergedCourses[groupId] = {
...course,
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
}
} else {
const mergedCourse = mergedCourses[groupId]
if (moment(mergedCourse.max_attainment_date).isBefore(course.max_attainment_date)) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (moment(mergedCourse.min_attainment_date).isAfter(course.min_attainment_date)) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
}
res.json({ courses: Object.values(mergedCourses) })
})
interface GetCourseYearlyStatsRequest extends Request {
@@ -1,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[]) => {
@@ -31,6 +31,7 @@ type AccumulatorType = {
email: string
phoneNumber: string
secondaryEmail: string
preferredLanguage: string
}
studyright: {
startDate: Date
@@ -105,6 +106,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
sis_person_id,
email,
phone_number: phoneNumber,
preferredLanguage,
secondary_email: secondaryEmail,
} = student
return student.studyplans.reduce<AccumulatorType[]>((acc, studyPlan) => {
@@ -137,7 +139,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
)
acc.push({
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail },
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail, preferredLanguage },
studyright: {
startDate: startOfStudyright,
semesterEnrollments,
@@ -178,6 +180,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
'creditcount',
'email',
'phone_number',
'preferredLanguage',
'secondary_email',
'sis_person_id',
'studentnumber',
@@ -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)
+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 .
+39 -39
View File
@@ -12,8 +12,8 @@
"@mui/material": "^6.2.0",
"@mui/x-date-pickers": "^7.23.2",
"@reduxjs/toolkit": "^2.5.0",
"@sentry/browser": "^8.45.0",
"axios": "^0.29.0",
"@sentry/browser": "^8.45.1",
"axios": "^1.7.9",
"highcharts": "^11.4.8",
"immer": "^10.1.1",
"lodash": "^4.17.21",
@@ -39,7 +39,7 @@
"devDependencies": {
"@types/lodash": "^4.17.13",
"@types/node": "^22.10.2",
"@types/react": "^18.3.16",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/react-highcharts": "^16.0.6",
"@vitejs/plugin-react-swc": "^3.7.2",
@@ -1347,75 +1347,75 @@
}
},
"node_modules/@sentry-internal/browser-utils": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.45.0.tgz",
"integrity": "sha512-MX/E/C+W5I9jkGD1PsbZ2hpCc7YuizNKmEbuGPxQPfUSIPrdE2wpo6ZfIhEbxq9m/trl1oRCN4PXi3BB7dlYYg==",
"version": "8.45.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.45.1.tgz",
"integrity": "sha512-sZwtP3zAzDsjUS7WkMW5VGbvSl7hGKTMc8gAJbpEsrybMxllIP13zzMRwpeFF11RnnvbrZ/FtAeX58Mvj0jahA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "8.45.0"
"@sentry/core": "8.45.1"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.45.0.tgz",
"integrity": "sha512-WerpfkKrKPAlnQuqjEgKXZtrx68cla7GyOkNOeL40JQbY4/By4Qjx1atUOmgk/FdjrCLPw+jQQY9pXRpMRqqRw==",
"version": "8.45.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.45.1.tgz",
"integrity": "sha512-zCKptzki4SLnG+s8je8dgnppOKFjiiO4GVBc4fh7uL8zjNPBnxW8wK4SrPfAEKVYaHUzkKc5vixwUqcpmfLLGw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "8.45.0"
"@sentry/core": "8.45.1"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.45.0.tgz",
"integrity": "sha512-SOFwFpzx0B6lxhLl2hBnxvybo7gdB5TMY8dOHMwXgk5A2+BXvSpvWXnr33yqUlBmC8R3LeFTB3C0plzM5lhkJg==",
"version": "8.45.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.45.1.tgz",
"integrity": "sha512-cOA9CodNSR9+hmICDaGIDUvWiwxQxeMHk/esbjB8uAW8HG4CYTG3CTYTZmlmou7DuysfMd4JNuFmDFBj+YU5/A==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "8.45.0",
"@sentry/core": "8.45.0"
"@sentry-internal/browser-utils": "8.45.1",
"@sentry/core": "8.45.1"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.45.0.tgz",
"integrity": "sha512-LZ8kBuzO5gutDiWnCyYEzBMDLq9PIllcsWsXRpKoau0Zqs3DbyRolI11dNnxmUSh7UW21FksxBpqn5yPmUMbag==",
"version": "8.45.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.45.1.tgz",
"integrity": "sha512-qiPg6XwOwkiMMe/8Qf3EhXCqkSlSnWLlorYngIbdkV2klbWjd7vKnqkFJF4PnaS0g7kkZr7nh+MdzpyLyuj2Mw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "8.45.0",
"@sentry/core": "8.45.0"
"@sentry-internal/replay": "8.45.1",
"@sentry/core": "8.45.1"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry/browser": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.45.0.tgz",
"integrity": "sha512-Y+BcfpXY1eEkOYOzgLGkx1YH940uMAymYOxfSZSvC+Vx6xHuaGT05mIFef/aeZbyu2AUs6JjdvD1BRBZlHg78w==",
"version": "8.45.1",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.45.1.tgz",
"integrity": "sha512-/KvYhQSRg8m9kotG8h9FrfXCWRlebrvdfXKjj1oE9SyZ2LmR8Ze9AcEw1qzsBsa1F1D/a5FQbUJahSoLBkaQPA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "8.45.0",
"@sentry-internal/feedback": "8.45.0",
"@sentry-internal/replay": "8.45.0",
"@sentry-internal/replay-canvas": "8.45.0",
"@sentry/core": "8.45.0"
"@sentry-internal/browser-utils": "8.45.1",
"@sentry-internal/feedback": "8.45.1",
"@sentry-internal/replay": "8.45.1",
"@sentry-internal/replay-canvas": "8.45.1",
"@sentry/core": "8.45.1"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry/core": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.45.0.tgz",
"integrity": "sha512-4YTuBipWSh4JrtSYS5GxUQBAcAgOIkEoFfFbwVcr3ivijOacJLRXTBn3rpcy1CKjBq0PHDGR+2RGRYC+bNAMxg==",
"version": "8.45.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.45.1.tgz",
"integrity": "sha512-1fGmkr0paZshh38mD29c4CfkRkgFoYDaAGyDLoGYfTbEph/lU8RHB2HWzN93McqNdMEhl1DRRyqIasUZoPlqSA==",
"license": "MIT",
"engines": {
"node": ">=14.18"
@@ -1794,9 +1794,9 @@
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="
},
"node_modules/@types/react": {
"version": "18.3.16",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.16.tgz",
"integrity": "sha512-oh8AMIC4Y2ciKufU8hnKgs+ufgbA/dhPTACaZPM86AbwX9QwnFtSoPWEeRUj8fge+v6kFt78BXcDhAU1SrrAsw==",
"version": "18.3.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.17.tgz",
"integrity": "sha512-opAQ5no6LqJNo9TqnxBKsgnkIYHozW9KSTlFVoSUJYh1Fl/sswkEoqIugRSm7tbh6pABtYjGAjW+GOS23j8qbw==",
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1864,12 +1864,12 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.29.0.tgz",
"integrity": "sha512-Kjsq1xisgO5DjjNQwZFsy0gpcU1P2j36dZeQDXVhpIU26GVgkDUnROaHLSuluhMqtDE7aKA2hbKXG5yu5DN8Tg==",
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.4",
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
+3 -3
View File
@@ -15,8 +15,8 @@
"@mui/material": "^6.2.0",
"@mui/x-date-pickers": "^7.23.2",
"@reduxjs/toolkit": "^2.5.0",
"@sentry/browser": "^8.45.0",
"axios": "^0.29.0",
"@sentry/browser": "^8.45.1",
"axios": "^1.7.9",
"highcharts": "^11.4.8",
"immer": "^10.1.1",
"lodash": "^4.17.21",
@@ -42,7 +42,7 @@
"devDependencies": {
"@types/lodash": "^4.17.13",
"@types/node": "^22.10.2",
"@types/react": "^18.3.16",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/react-highcharts": "^16.0.6",
"@vitejs/plugin-react-swc": "^3.7.2",
@@ -208,7 +208,7 @@ export const populationStatisticsToolTips = {
3. Jos suoritukseen tai ilmoittautumiseen ei liity opiskeluoikeutta eikä kurssia ole sijoitettu opintosuunnitelmaan, käytetään viimeisintä koulutusohjelmaa suorituksen tai ilmoittautumisen hetkellä
`,
programmeDistributionCustomPopulation: `
'Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
`,
gradeDistributionCoursePopulation: 'Näyttää korkeimman arvosanan, jonka opiskelija on saanut valitulla aikavälillä.',
creditDistributionCoursePopulation: 'Näyttää tiedekunta-/ohjelmankohtaisen opintopistejakauman.',
@@ -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.
`,
}
-308
View File
@@ -1,308 +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 }
}
// Use @/hooks/tabs for MUI components instead
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'
return () => {
document.title = 'Oodikone'
}
}, [title])
}
export const useLocalStorage = (key, initialValue) => {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
return initialValue
}
})
const setValue = useCallback(
value => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (error) {
console.log(error) // eslint-disable-line no-console
}
},
[key]
)
return [storedValue, setValue]
}
export const useDebounce = (value, timeout, onChange) => {
const [innerValue, setInnerValue] = useState(value)
const [dirty, setDirty] = useState(false)
const timeoutRef = useRef(null)
useEffect(() => {
if (!isEqual(value, innerValue)) {
setInnerValue(value)
setDirty(false)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [value])
const setValue = useCallback(
value => {
setInnerValue(value)
setDirty(true)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
timeoutRef.current = setTimeout(() => {
onChange(value)
setDirty(false)
}, timeout)
},
[innerValue, setInnerValue, timeoutRef, onChange, timeout]
)
const flush = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
onChange(innerValue)
setDirty(false)
}, [timeoutRef, onChange, innerValue])
return [innerValue, setValue, flush, dirty]
}
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => {
setValue(value => !value)
}, [])
return [value, toggle]
}
/**
* This hook is similar to useMemo, but it does a deep comparison of the dependencies (using `isEqual` from Lodash).
*/
export const useDeepMemo = (factory, dependencies) => {
const ref = useRef()
if (!ref.current || !isEqual(dependencies, ref.current.deps)) {
ref.current = { deps: dependencies, result: factory() }
}
return ref.current.result
}
export const useCurrentSemester = () => {
const { data: semesterData } = useGetSemestersQuery()
if (!semesterData) return null
return Object.values(semesterData.semesters).find(
semester => new Date(semester.startdate) <= new Date() && new Date(semester.enddate) >= new Date()
)
}
export const useCurrentCurriculumPeriod = () => {
const { data: curriculumPeriods = [] } = useGetCurriculumPeriodsQuery()
return curriculumPeriods.find(
curriculumPeriod =>
new Date(curriculumPeriod.startDate) <= new Date() && new Date(curriculumPeriod.endDate) >= new Date()
)
}
export const useDegreeProgrammeTypes = programmeCodes => {
const { data: degreeProgrammes } = useGetProgrammesQuery()
if (!degreeProgrammes) return {}
return programmeCodes.reduce((acc, programmeCode) => {
acc[programmeCode] = degreeProgrammes[programmeCode]?.degreeProgrammeType ?? null
return acc
}, {})
}
+1 -1
View File
@@ -288,7 +288,7 @@ export const getCreditCategories = (
return range(0, limitBreaks.length + 1).map(i => [limitBreaks[i - 1], limitBreaks[i]])
}
export const validateInputLength = (input, minLength) => input && input.trim().length >= minLength
export const validateInputLength = (input, minLength) => input?.trim().length >= minLength
export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
@@ -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'
@@ -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'
@@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router'
import { Form, Header, Input, Segment } from 'semantic-ui-react'
import { getStudentToTargetCourseDateMap, getUnifyTextIn } from '@/common'
import { useCurrentSemester, useProgress, useTitle } from '@/common/hooks'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { PanelView } from '@/components/common/PanelView'
import {
@@ -26,6 +25,9 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
import { PopulationStudentsContainer as PopulationStudents } from '@/components/PopulationStudents'
import { ProgressBar } from '@/components/ProgressBar'
import { useCurrentSemester } from '@/hooks/currentSemester'
import { useProgress } from '@/hooks/progress'
import { useTitle } from '@/hooks/title'
import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourses'
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
@@ -1,9 +0,0 @@
.controlIcon,
.controlIcon:active {
box-shadow: none !important;
background: none !important;
}
#selected-table {
padding: 0 !important;
}
@@ -1,68 +0,0 @@
import { sortBy } from 'lodash'
import { arrayOf, bool, func, shape, string } from 'prop-types'
import { memo } from 'react'
import { Table } from 'semantic-ui-react'
import { getActiveYears } from '@/components/CourseStatistics/courseStatisticsUtils'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import './courseTable.css'
const CourseTable = ({ courses, onSelectCourse, hidden, title, controlIcon }) => {
const { getTextIn } = useLanguage()
const noContent = courses.length === 0
const sortCourses = courses => sortBy(courses, course => getTextIn(course.name))
const getEmptyListRow = () => (
<Table.Row>
<Table.Cell colSpan="3" content="No results." />
</Table.Row>
)
const toCourseRow = course => (
<Table.Row
key={course.id}
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
style={{ cursor: 'pointer' }}
>
<Table.Cell width={10}>
<div>{getTextIn(course.name)}</div>
<div>{getActiveYears(course)}</div>
</Table.Cell>
<Table.Cell content={[course.code, ...course.substitutions].join(', ')} />
{controlIcon ? <Table.Cell icon={controlIcon} /> : null}
</Table.Row>
)
return (
!hidden && (
<Table className="fixed-header" selectable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>{title}</Table.HeaderCell>
<Table.HeaderCell>Code</Table.HeaderCell>
{controlIcon ? <Table.HeaderCell /> : null}
</Table.Row>
</Table.Header>
<Table.Body>{noContent ? getEmptyListRow() : sortCourses(courses).map(toCourseRow)}</Table.Body>
</Table>
)
)
}
CourseTable.propTypes = {
courses: arrayOf(shape({ code: string, name: shape({}), seleted: bool })).isRequired,
onSelectCourse: func.isRequired,
hidden: bool.isRequired,
title: string.isRequired,
controlIcon: string,
}
const areEqual = (prevProps, nextProps) => {
if (prevProps.courses.length !== nextProps.courses.length) {
return false
}
return prevProps.courses.every(c1 => nextProps.courses.some(c2 => c1.code === c2.code))
}
export const MemoizedCourseTable = memo(CourseTable, areEqual)
@@ -1,67 +0,0 @@
import { useEffect, useState } from 'react'
import { Input } from 'semantic-ui-react'
import { Timeout } from '@/components/Timeout'
enum Timeouts {
FETCH = 'fetch',
SEARCH = 'search',
}
interface AutoSubmitSearchInputProps {
cypress: string
doSearch: (searchTerm: string) => void
inputValue: string
loading: boolean
onChange: (searchTerm: string) => void
setTimeout: (timeout: Timeouts, callback: () => void, latency: number) => void
placeholder: string
}
const AutoSubmitSearchInput = ({
cypress,
doSearch,
inputValue,
loading,
onChange,
setTimeout,
placeholder,
}: AutoSubmitSearchInputProps) => {
const [input, setInput] = useState(inputValue)
const latency = 250
const minSearchLength = 0
const executeSearch = (searchTerm: string) => {
clearTimeout(Timeouts.FETCH)
setTimeout(Timeouts.FETCH, () => doSearch(searchTerm), latency)
}
useEffect(() => {
if ((input || input === '') && input.length >= minSearchLength) {
executeSearch(input)
}
}, [input])
const handleSearchChange = (_event, { value }) => {
clearTimeout(Timeouts.SEARCH)
if (value.length >= 0) {
onChange(value)
setTimeout(Timeouts.SEARCH, () => setInput(value), latency)
} else {
onChange('')
}
}
return (
<Input
data-cy={cypress}
fluid
icon="search"
loading={loading}
onChange={handleSearchChange}
placeholder={placeholder}
value={inputValue}
/>
)
}
export const TimeoutAutoSubmitSearchInput = Timeout(AutoSubmitSearchInput)
@@ -1,363 +0,0 @@
import { sortBy } from 'lodash'
import qs from 'query-string'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useNavigate, useLocation } from 'react-router'
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 navigate = useNavigate()
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)
navigate({ search: searchString })
}
const onSearchHistorySelected = historyItem => {
pushQueryToUrl(historyItem)
}
const onSubmitFormClick = () => {
const codes = sortBy(Object.keys(selectedCourses))
const params = {
courseCodes: codes,
separate,
combineSubstitutions,
}
const searchHistoryText = codes.map(code => `${getTextIn(selectedCourses[code].name)} ${code}`)
addItemToSearchHistory({
text: searchHistoryText.join(', '),
params,
})
pushQueryToUrl(params)
}
const fetchCourses = () => {
const isValidName = validateInputLength(courseName, 5)
const isValidCode = validateInputLength(courseCode, 2)
if (isValidName || isValidCode) {
return dispatch(findCoursesV2({ name: courseName, code: courseCode, combineSubstitutions }))
}
if (courseName.length < 5 && courseCode.length < 2) {
dispatch(clearCourses())
}
return Promise.resolve()
}
const clearSelectedCourses = () => {
setState({ ...state, selectedCourses: {} })
}
useEffect(() => {
if (Object.keys(selectedCourses).length === 0 || selectMultipleCoursesEnabled) return
onSubmitFormClick()
}, [selectedCourses])
useEffect(() => {
const fetchData = async () => {
await fetchCourses()
}
fetchData()
clearSelectedCourses()
}, [combineSubstitutions])
useEffect(() => {
if (selectMultipleCoursesEnabled) return
clearSelectedCourses()
}, [selectMultipleCoursesEnabled])
const courses = matchingCourses.filter(course => !selectedCourses[course.code])
const selected = Object.values(selectedCourses).map(course => ({ ...course, selected: true }))
const noSelectedCourses = selected.length === 0
const disabled = isLoading || noSelectedCourses || selected.length > MAX_SELECTED_COURSES
const renderFetchStatisticsButton = () => {
const FetchStatisticsButton = (
<Form.Button
basic
content="Fetch statistics"
data-cy="fetch-stats-button"
disabled={disabled}
fluid
onClick={onSubmitFormClick}
positive
primary
size="huge"
type="button"
/>
)
if (noSelectedCourses) {
return null
}
if (disabled) {
return (
<Popup
content={getTextIn({
fi: `Olet valinnut ${selected.length} kurssia. Voit valita tarkasteltavaksi enintään ${MAX_SELECTED_COURSES} kurssia kerrallaan. Tarkenna hakua tai poista valittuja kursseja.`,
en: `You have selected ${selected.length} courses. You can select up to ${MAX_SELECTED_COURSES} courses at a time. Refine your search or remove selected courses.`,
})}
on="hover"
position="top center"
trigger={<span>{FetchStatisticsButton}</span>}
/>
)
}
return FetchStatisticsButton
}
const addAllCourses = () => {
courses.forEach(course => {
course.selected = true
})
const newSelectedCourses = courses.reduce((newSelected, course) => {
newSelected[course.code] = { ...course }
return newSelected
}, {})
setState({
...state,
selectedCourses: {
...selectedCourses,
...newSelectedCourses,
},
})
}
return (
<>
<Segment loading={isLoading}>
<Form>
<Header>Search for courses</Header>
<div style={{ marginBottom: '15px' }}>
<Form.Group>
<Form.Field width={7}>
<label>Name</label>
<AutoSubmitSearchInput
cypress="course-name-input"
doSearch={fetchCourses}
inputValue={courseName}
loading={isLoading}
onChange={courseName => setState({ ...state, courseName })}
placeholder="Search by course name"
/>
</Form.Field>
<Form.Field width={4}>
<label>Code</label>
<AutoSubmitSearchInput
cypress="course-code-input"
doSearch={fetchCourses}
inputValue={courseCode}
loading={isLoading}
onChange={courseCode => setState({ ...state, courseCode })}
placeholder="Search by course code"
/>
</Form.Field>
<Form.Field>
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: '20px',
marginLeft: '10px',
}}
>
<Popup
content={getTextIn({
fi: 'Jos "Combine substitutions" on valittuna (oletuksena), niin kurssi ja leikkaavat kurssit yhdistetään tilastoissa.',
en: 'If "Combine substitutions" is on (default behavior), then course and its substitutions are combined in the statistics.',
})}
on="hover"
position="top center"
size="tiny"
trigger={
<Radio
checked={combineSubstitutions}
data-cy="combine-substitutions-toggle"
label="Combine substitutions"
onChange={toggleCombineSubstitutions}
toggle
/>
}
/>
<Popup
content={getTextIn({
fi: 'Jos "Select multiple courses" on valittuna, voit valita tarkasteltavaksi useita kursseja.',
en: 'If "Select multiple courses" is on, you can select multiple courses to view statistics for.',
})}
on="hover"
position="top center"
size="tiny"
trigger={
<Radio
checked={selectMultipleCoursesEnabled}
data-cy="select-multiple-courses-toggle"
label="Select multiple courses"
onChange={toggleSelectMultipleCoursesEnabled}
toggle
/>
}
/>
</div>
</Form.Field>
</Form.Group>
{selectMultipleCoursesEnabled && (
<Message>
<b style={{ color: 'red', fontSize: '14pt' }}>Notice:</b> Selecting many courses may fail if there are
too many students in total. If the feature does not work, try again with fewer courses. <br /> <br />
<b>Currently selected: {Object.keys(state.selectedCourses).length || 0}</b>
</Message>
)}
<div style={!noSelectedCourses ? searchBoxStyle : null}>
<CourseTable
controlIcon="trash alternate outline"
courses={selected}
hidden={noSelectedCourses}
onSelectCourse={onSelectCourse}
selectedTable
title="Selected courses"
/>
{renderFetchStatisticsButton()}
</div>
<CourseTable
courses={courses}
hidden={isLoading || (Object.keys(courses).length === 0 && Object.keys(selectedCourses).length > 0)}
onSelectCourse={onSelectCourse}
title="Searched courses"
/>
{courses.length && selectMultipleCoursesEnabled ? (
<div className="select-all-container">
<Form.Button
basic
color="green"
content="Select all search results"
onClick={addAllCourses}
size="large"
type="button"
/>
</div>
) : null}
</div>
</Form>
</Segment>
<SearchHistory
disabled={isLoading}
handleSearch={onSearchHistorySelected}
items={searchHistory.map(item => {
item.timestamp = new Date(item.timestamp)
return item
})}
updateItem={updateItemInSearchHistory}
/>
</>
)
}
@@ -1,39 +0,0 @@
.container {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.yearSelectorGroup {
align-items: center !important;
}
.yearSelect {
padding-right: 0 !important;
}
.yearSelectInput input {
border-bottom-right-radius: 0 !important;
border-top-right-radius: 0 !important;
}
.yearControl {
padding-top: 22px !important;
padding-left: 0 !important;
}
.yearControlButtonGroup {
border-bottom-left-radius: 0 !important;
border-top-left-radius: 0 !important;
}
.yearControlButton {
font-size: 8px !important;
}
.select-all-container {
width: 100%;
display: flex;
justify-content: space-around;
}
@@ -8,9 +8,9 @@ import ReactHighstock from 'react-highcharts/ReactHighstock'
import { Button, Radio } from 'semantic-ui-react'
import { getStudyRightElementTargetDates } from '@/common'
import { useDeepMemo } from '@/common/hooks'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
import { useDeepMemo } from '@/hooks/deepMemo'
import { reformatDate } from '@/util/timeAndDate'
import { CreditGraphTooltip } from './CreditGraphTooltip'
@@ -2,8 +2,8 @@ import moment from 'moment'
import { useState } from 'react'
import { Message, Icon, Loader } from 'semantic-ui-react'
import { useTitle } from '@/common/hooks'
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
import { useTitle } from '@/hooks/title'
import { useGetSavedSearchesQuery } from '@/redux/openUniPopulations'
import { CustomOpenUniSearch } from './CustomOpenUniSearch'
import { OpenUniPopulationResults } from './OpenUniPopulationResults'
@@ -1,12 +1,12 @@
import moment from 'moment'
import { getNewestProgrammeOfStudentAt } from '@/common'
import { useCurrentSemester } from '@/common/hooks'
import { FilterToggleIcon } from '@/components/common/FilterToggleIcon'
import { ProgressBarWithLabel } from '@/components/common/ProgressBarWithLabel'
import { isProgrammeSelected, toggleProgrammeSelection } from '@/components/FilterView/filters/programmes'
import { useFilters } from '@/components/FilterView/useFilters'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useCurrentSemester } from '@/hooks/currentSemester'
import { useGetSemestersQuery } from '@/redux/semesters'
import { SearchResultTable } from './SearchResultTable'
@@ -2,8 +2,8 @@ import { useState } from 'react'
import { Button, Form, Modal, TextArea } from 'semantic-ui-react'
import { extractItems, textAndDescriptionSearch } from '@/common'
import { useTitle } from '@/common/hooks'
import { SearchHistory } from '@/components/SearchHistory'
import { useTitle } from '@/hooks/title'
import {
useCreateCustomPopulationSearchMutation,
useDeleteCustomPopulationSearchMutation,
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Form, Header, Input, Label, Message, Segment } from 'semantic-ui-react'
import { useProgress, useTitle } from '@/common/hooks'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { PanelView } from '@/components/common/PanelView'
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
@@ -24,6 +23,8 @@ import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/Po
import { PopulationStudentsContainer as PopulationStudents } from '@/components/PopulationStudents'
import { ProgressBar } from '@/components/ProgressBar'
import { RightsNotification } from '@/components/RightsNotification'
import { useProgress } from '@/hooks/progress'
import { useTitle } from '@/hooks/title'
import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourses'
import { useGetCustomPopulationQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react'
import { useDebounce } from '@/common/hooks'
import { RangeSelector } from '@/components/common/RangeSelector'
import { useDebounce } from '@/hooks/debounce'
import { getAge } from '@/util/timeAndDate'
import { createFilter } from './createFilter'
@@ -2,8 +2,8 @@ import { max, min } from 'lodash'
import { useMemo } from 'react'
import { getStudentTotalCredits } from '@/common'
import { useDebounce } from '@/common/hooks'
import { RangeSelector } from '@/components/common/RangeSelector'
import { useDebounce } from '@/hooks/debounce'
import { createFilter } from './createFilter'
const CreditsEarnedFilterCard = ({ options, onOptionsChange, bounds }) => {
@@ -1,7 +1,7 @@
import { useDispatch } from 'react-redux'
import { Tab } from 'semantic-ui-react'
import { clearCourseStats } from '@/redux/coursestats'
import { clearCourseStats } from '@/redux/courseStats'
import { GradeDistribution } from './GradeDistribution'
import { PassFailEnrollments } from './PassFailEnrollments'
import { PopulationCourseContext } from './PopulationCourseContext'
@@ -3,8 +3,8 @@ import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { Tab } from 'semantic-ui-react'
import { useTabChangeAnalytics } from '@/common/hooks'
import { clearCourseStats } from '@/redux/coursestats'
import { useTabChangeAnalytics } from '@/hooks/tabChangeAnalytics'
import { clearCourseStats } from '@/redux/courseStats'
import { GradeDistribution } from './GradeDistribution'
import { PassFailEnrollments } from './PassFailEnrollments'
import { PassingSemesters } from './PassingSemesters'
@@ -10,10 +10,10 @@ import { useLocation } from 'react-router'
import { Dropdown, Radio, Segment } from 'semantic-ui-react'
import { generateGradientColors, getCreditCategories, getTargetCreditsForProgramme, TimeDivision } from '@/common'
import { useDeepMemo } from '@/common/hooks'
import { studentNumberFilter } from '@/components/FilterView/filters'
import { useFilters } from '@/components/FilterView/useFilters'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useDeepMemo } from '@/hooks/deepMemo'
import { useGetSemestersQuery } from '@/redux/semesters'
exporting(ReactHighcharts.Highcharts)
@@ -3,12 +3,12 @@ import { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Segment } from 'semantic-ui-react'
import { useDeepMemo } from '@/common/hooks'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { InfoBox } from '@/components/InfoBox'
import { PopulationCourseStats } from '@/components/PopulationCourseStats'
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
import { SegmentDimmer } from '@/components/SegmentDimmer'
import { useDeepMemo } from '@/hooks/deepMemo'
import { getPopulationSelectedStudentCourses } from '@/redux/populationSelectedStudentCourses'
import { FilterDegreeCoursesModal } from './FilterDegreeCoursesModal'
@@ -8,11 +8,11 @@ import { useNavigate, useLocation } from 'react-router'
import { Button, Form, Message, Radio } from 'semantic-ui-react'
import { createPinnedFirstComparator, isNewStudyProgramme, textAndDescriptionSearch } from '@/common'
import { useSearchHistory } from '@/common/hooks'
import { FilterOldProgrammesToggle } from '@/components/common/FilterOldProgrammesToggle'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { SearchHistory } from '@/components/SearchHistory'
import { YEAR_DATE_FORMAT } from '@/constants/date'
import { useSearchHistory } from '@/hooks/searchHistory'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { getPopulationStatistics, clearPopulations, useGetProgrammesQuery } from '@/redux/populations'
import { clearSelected } from '@/redux/populationSelectedStudentCourses'
@@ -2,12 +2,12 @@ import { useSelector } from 'react-redux'
import { Link, useLocation } from 'react-router'
import { Button, Divider, Form, Header, Icon, Segment } from 'semantic-ui-react'
import { useProgress } from '@/common/hooks'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { hopsFilter } from '@/components/FilterView/filters'
import { useFilters } from '@/components/FilterView/useFilters'
import { InfoBox } from '@/components/InfoBox'
import { ProgressBar } from '@/components/ProgressBar'
import { useProgress } from '@/hooks/progress'
import { PopulationSearchForm } from './PopulationSearchForm'
import { PopulationSearchHistory } from './PopulationSearchHistory'
@@ -4,7 +4,6 @@ import { useLocation } from 'react-router'
import { Header, Message, Segment } from 'semantic-ui-react'
import { getFullStudyProgrammeRights, getStudentTotalCredits, getUnifiedProgrammeName } from '@/common'
import { useCurrentSemester, useDegreeProgrammeTypes, useTitle } from '@/common/hooks'
import { FilterView } from '@/components/FilterView'
import {
admissionTypeFilter,
@@ -29,6 +28,9 @@ import {
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { PopulationDetails } from '@/components/PopulationDetails'
import { PopulationSearch } from '@/components/PopulationSearch'
import { useCurrentSemester } from '@/hooks/currentSemester'
import { useDegreeProgrammeTypes } from '@/hooks/degreeProgrammeTypes'
import { useTitle } from '@/hooks/title'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { useGetProgrammesQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
@@ -4,12 +4,13 @@ import { useState } from 'react'
import { getEnrollmentTypeTextForExcel, getHighestGradeOfCourseBetweenRange, getStudentTotalCredits } from '@/common'
import { getCopyableEmailColumn, getCopyableStudentNumberColumn, hiddenNameAndEmailForExcel } from '@/common/columns'
import { useCurrentSemester, useDegreeProgrammeTypes } from '@/common/hooks'
import { creditDateFilter } from '@/components/FilterView/filters'
import { useFilters } from '@/components/FilterView/useFilters'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { SortableTable } from '@/components/SortableTable'
import { DISPLAY_DATE_FORMAT, ISO_DATE_FORMAT, ISO_DATE_FORMAT_DEV } from '@/constants/date'
import { useCurrentSemester } from '@/hooks/currentSemester'
import { useDegreeProgrammeTypes } from '@/hooks/degreeProgrammeTypes'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { useGetProgrammesQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
@@ -402,6 +403,19 @@ export const GeneralTab = ({
filterType: 'date',
getRowVal: student => reformatDate(studentToProgrammeStartMap[student.studentNumber], ISO_DATE_FORMAT),
},
option: (isBachelorsProgramme || isMastersProgramme) && {
key: 'option',
title: isMastersProgramme ? 'Bachelor' : 'Master',
getRowVal: student => (student.option ? getTextIn(student.option.name) : ''),
formatValue: value => (
<div style={{ maxWidth: '290px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{value}</div>
),
cellProps: student => {
return {
title: student.option ? getTextIn(student.option.name) : '',
}
},
},
semesterEnrollments: {
key: 'semesterEnrollments',
title: 'Semesters\npresent',
@@ -541,19 +555,6 @@ export const GeneralTab = ({
formatValue: value => value.join(', '),
filterType: 'multi',
},
option: (isBachelorsProgramme || isMastersProgramme) && {
key: 'option',
title: isMastersProgramme ? 'Bachelor' : 'Master',
getRowVal: student => (student.option ? getTextIn(student.option.name) : ''),
formatValue: value => (
<div style={{ maxWidth: '290px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{value}</div>
),
cellProps: student => {
return {
title: student.option ? getTextIn(student.option.name) : '',
}
},
},
curriculumPeriod: {
key: 'curriculumPeriod',
title: 'Curriculum\nperiod',
@@ -3,10 +3,11 @@ import { useRef } from 'react'
import { useSelector } from 'react-redux'
import { Tab } from 'semantic-ui-react'
import { useTabChangeAnalytics, useToggle } from '@/common/hooks'
import { coursePopulationToolTips, populationStatisticsToolTips } from '@/common/InfoToolTips'
import { InfoBox } from '@/components/InfoBox'
import { StudentNameVisibilityToggle } from '@/components/StudentNameVisibilityToggle'
import { useTabChangeAnalytics } from '@/hooks/tabChangeAnalytics'
import { useToggle } from '@/hooks/toggle'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { useGetTagsByStudyTrackQuery } from '@/redux/tags'
import { CheckStudentList } from './CheckStudentList'
@@ -2,7 +2,7 @@ import { number } from 'prop-types'
import { useEffect, useRef, useState } from 'react'
import { Progress } from 'semantic-ui-react'
import { useDidMount } from '@/common/hooks'
import { useDidMount } from '@/hooks/didMount'
import './progressBar.css'
export const ProgressBar = ({ progress, fixed = false }) => {
@@ -1,8 +1,8 @@
import { DoNotDisturb } from '@mui/icons-material'
import { Alert, AlertTitle, Box, Container, Link } from '@mui/material'
import { useTitle } from '@/common/hooks'
import { PageTitle } from '../material/PageTitle'
import { PageTitle } from '@/components/material/PageTitle'
import { useTitle } from '@/hooks/title'
export const AccessDeniedMessage = () => {
useTitle('Access denied')
@@ -3,7 +3,6 @@ import { Navigate, Route, Routes as RouterRoutes } from 'react-router'
import { isDefaultServiceProvider } from '@/common'
import { CoursePopulation } from '@/components/CoursePopulation'
import { CourseStatistics } from '@/components/CourseStatistics'
import { CustomOpenUniPopulation } from '@/components/CustomOpenUniPopulation'
import { CustomPopulation } from '@/components/CustomPopulation'
import { LanguageCenterView } from '@/components/LanguageCenterView'
@@ -12,12 +11,12 @@ import { SegmentDimmer } from '@/components/SegmentDimmer'
import { StudyGuidanceGroups } from '@/components/StudyGuidanceGroups'
import { StudyProgramme } from '@/components/StudyProgramme'
import { Teachers } from '@/components/Teachers'
import { Updater } from '@/components/Updater'
import { Users } from '@/components/Users'
import { languageCenterViewEnabled } from '@/conf'
import { Changelog } from '@/pages/Changelog'
import { CloseToGraduation } from '@/pages/CloseToGraduation'
import { CompletedCourses } from '@/pages/CompletedCoursesSearch'
import { CourseStatistics } from '@/pages/CourseStatistics'
import { Faculties } from '@/pages/Faculties'
import { Feedback } from '@/pages/Feedback'
import { FrontPage } from '@/pages/FrontPage'
@@ -25,6 +24,7 @@ import { Students } from '@/pages/Students'
import { StudentDetails } from '@/pages/Students/StudentDetails'
import { StudentSearch } from '@/pages/Students/StudentSearch'
import { University } from '@/pages/University'
import { Updater } from '@/pages/Updater'
import { ProtectedRoute } from './ProtectedRoute'
export const Routes = () => (
@@ -3,9 +3,9 @@ import { useMemo } from 'react'
import { Icon } from 'semantic-ui-react'
import { useContextSelector } from 'use-context-selector'
import { useDebounce } from '@/common/hooks'
import { RangeSelector } from '@/components/common/RangeSelector'
import { SortableTableContext, getColumnValue } from '@/components/SortableTable/common'
import { useDebounce } from '@/hooks/debounce'
const RangeColumnFilterComponent = ({ column, options, dispatch }) => {
const values = useContextSelector(SortableTableContext, ctx => ctx.values[column.key]) ?? []
@@ -4,10 +4,10 @@ import { Link, useNavigate } from 'react-router'
import { Button, Form, Icon, Message, Modal } from 'semantic-ui-react'
import { textAndDescriptionSearch } from '@/common'
import { useToggle } from '@/common/hooks'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { SortableTable } from '@/components/SortableTable'
import { YEAR_DATE_FORMAT } from '@/constants/date'
import { useToggle } from '@/hooks/toggle'
import { useChangeStudyGuidanceGroupTagsMutation } from '@/redux/studyGuidanceGroups'
import { useFilteredAndFormattedStudyProgrammes } from '@/redux/studyProgramme'
import { startYearToAcademicYear, StyledMessage } from './common'
@@ -1,7 +1,7 @@
import { useParams } from 'react-router'
import { Header } from 'semantic-ui-react'
import { useTitle } from '@/common/hooks'
import { useTitle } from '@/hooks/title'
import { useGetAllStudyGuidanceGroupsQuery } from '@/redux/studyGuidanceGroups'
import { Wrapper } from './common'
import { SingleStudyGuidanceGroupContainer as SingleStudyGuidanceGroup } from './SingleStudyGuidanceGroup'
@@ -8,9 +8,9 @@ import {
getUnifiedProgrammeName,
isDefaultServiceProvider,
} from '@/common'
import { useCurrentCurriculumPeriod } from '@/common/hooks'
import { FilterOldProgrammesToggle } from '@/components/common/FilterOldProgrammesToggle'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useCurrentCurriculumPeriod } from '@/hooks/currentCurriculumPeriod'
import { useGetProgrammesQuery } from '@/redux/populations'
import {
useAddStudyProgrammePinMutation,
@@ -3,8 +3,9 @@ import { useParams, useLocation, useNavigate } from 'react-router'
import { Container, Header, Menu, Segment, Tab } from 'semantic-ui-react'
import { getFullStudyProgrammeRights, getUnifiedProgrammeName, isDefaultServiceProvider } from '@/common'
import { useTabs, useTitle } from '@/common/hooks'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useSemanticTabs } from '@/hooks/tabs'
import { useTitle } from '@/hooks/title'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { useGetProgrammesQuery } from '@/redux/populations'
import { BasicOverview } from './BasicOverview'
@@ -34,7 +35,7 @@ export const StudyProgramme = () => {
const { isAdmin, fullAccessToStudentData, programmeRights } = useGetAuthorizedUserQuery()
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const replace = useCallback(options => navigate(options, { replace: true }), [navigate])
const [tab, setTab] = useTabs('p_tab', 0, { location, replace })
const [tab, setTab] = useSemanticTabs('p_tab', 0, { location, replace })
const [academicYear, setAcademicYear] = useState(false)
const [specialGroupsExcluded, setSpecialGroupsExcluded] = useState(false)
const [graduated, setGraduated] = useState(false)
@@ -2,7 +2,8 @@ import { useCallback } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router'
import { Header, Segment, Tab } from 'semantic-ui-react'
import { useTabs, useTitle } from '@/common/hooks'
import { useSemanticTabs } from '@/hooks/tabs'
import { useTitle } from '@/hooks/title'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { TeacherDetails } from './TeacherDetails'
import { TeacherLeaderBoard } from './TeacherLeaderBoard'
@@ -24,7 +25,7 @@ const TeachersTabs = () => {
const location = useLocation()
const { roles, iamGroups } = useGetAuthorizedUserQuery()
const replace = useCallback(options => navigate(options, { replace: true }), [navigate])
const [tab, setTab] = useTabs('t_tab', 0, { location, replace })
const [tab, setTab] = useSemanticTabs('t_tab', 0, { location, replace })
const panes = [pane('Statistics', TeacherStatistics, 'table')]
if (hasFullAccessToTeacherData(roles, iamGroups)) {
panes.push(pane('Leaderboard', TeacherLeaderBoard, 'trophy'), pane('Search', TeacherSearchTab, 'user'))
@@ -1,42 +0,0 @@
import { Component } from 'react'
export const Timeout = Composition =>
class _Timeout extends Component {
componentDidMount() {
this.timeouts = {}
}
componentWillUnmount() {
this.clearAllTimeouts()
}
setTimeout = (identifier, ...args) => {
this.timeouts[identifier] = setTimeout(...args)
}
clearTimeout = identifier => {
if (this.timeouts[identifier]) {
clearTimeout(this.timeouts[identifier])
}
}
clearAllTimeouts = () => {
Object.keys(this.timeouts)
.map(key => this.timeouts[key])
.forEach(clearTimeout)
this.timeouts = {}
}
render() {
const { setTimeout, clearTimeout, clearAllTimeouts } = this
return (
<Composition
clearAllTimeouts={clearAllTimeouts}
clearTimeout={clearTimeout}
setTimeout={setTimeout}
{...this.props}
/>
)
}
}
@@ -1,195 +0,0 @@
/* eslint-disable no-alert */
import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { Button, Form, Header, Message, Radio, Segment, Table, TextArea } from 'semantic-ui-react'
import { callApi } from '@/apiConnection'
import { isDefaultServiceProvider } from '@/common'
import { useTitle } from '@/common/hooks'
import { languageCenterViewEnabled } from '@/conf'
export const Updater = () => {
const [messages, setMessages] = useState([])
const [customList, setCustomList] = useState('')
const [type, setType] = useState('students')
const [jobs, setJobs] = useState(null)
const [error, setError] = useState(false)
useTitle('Updater')
const apiCall = async (url, method, data) => {
try {
const response = await callApi(url, method, data)
setMessages(oldMessages => oldMessages.concat({ time: new Date(), message: response.data, color: 'green' }))
} catch {
setMessages(oldMessages => oldMessages.concat({ time: new Date(), message: 'Updater api error', color: 'red' }))
}
}
const updateSISMeta = () => apiCall('/updater/update/v2/meta')
const updateSISStudents = () => apiCall('/updater/update/v2/students')
const updateSISProgrammes = () => apiCall('/updater/update/v2/programmes')
const updateSISCustomList = () =>
apiCall(`/updater/update/v2/customlist/${type}`, 'post', customList.trim().split('\n'))
const refreshTeacherLeaderboardForCurrentAndPreviousYear = () =>
apiCall('/updater/refresh-teacher-leaderboard', 'post')
const abortSisUpdater = () => apiCall('/updater/abort')
const refreshSISRedisCache = () => apiCall('/updater/refresh_redis_cache')
const refreshAllTeacherLeaderboards = () => apiCall('/teachers/top', 'post')
const refreshFaculties = () => apiCall('/updater/refresh_faculties_v2', 'post')
const refreshStudyProgrammes = () => apiCall('/updater/refresh_study_programmes_v2', 'post')
const refreshLanguageCenterData = () => apiCall('/updater/refresh_language_center_data', 'post')
const refreshCloseToGraduationData = () => apiCall('/updater/refresh-close-to-graduation', 'post')
const getJobs = () => callApi('/updater/jobs')
const removeWaitingJobs = () => callApi('/updater/jobs', 'delete')
const updateJobs = async () => {
const jobs = await getJobs()
setJobs(jobs?.data)
}
useEffect(() => {
updateJobs()
}, [])
const displayJobStatus = () => {
if (!jobs) return <Message>Loading job statuses...</Message>
return (
<Message style={{ fontSize: '1.1rem' }}>
<Button icon="refresh" onClick={updateJobs} size="big" />
<p>Jobs running: {jobs.active?.length}</p>
<ul style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
{jobs.active.map(job => (
<li key={job.name}>{job.name}</li>
))}
</ul>
<p>Jobs waiting: {jobs.waiting?.length}</p>
<ul style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
{jobs.waiting.map(job => (
<li key={job.name}>{job.name}</li>
))}
</ul>
</Message>
)
}
if (error) throw new Error('Admin intentionally caused frontend crash')
return (
<Segment>
<Message style={{ fontSize: '16px' }}>
<Message.Header>Update data</Message.Header>
<ReactMarkdown>
{`**Updater sis-db - Update meta** Updates organisations, study modules, course units, study levels, education types, credit types
**Updater sis-db - Update students** Updates 1000 students at one click in development and all in production environment. Takes about 5 hours in production.
**Updater sis-db - 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.
**Updater redis - Update redis** Updates updater redis.
**Oodikone redis - Refresh all teacher leaderboards** Refresh all teacher leaderboard statistics from 1951 until today. Might take some time.
**Oodikone redis - Refresh teacher leaderboards of current and previous year** Refresh teacher leaderboard statistics for current and previous academic year.
**Oodikone redis - Refresh faculties** Refresh data for all faculties for all tabs (time consuming).
**Oodikone redis - Refresh study programmes** Refresh data for new study programmes for basic and studytrack tabs (time consuming).
**Oodikone redis - Refresh language center data** Refresh data for language center view.
**Oodikone redis - Refresh close to graduation data** Refresh data for close to graduation view.`}
</ReactMarkdown>
{isDefaultServiceProvider() && (
<Button color="red" onClick={() => setError(true)}>
Cause frontend crash
</Button>
)}
</Message>
<Form>
<Header>Updater (data pulled from importer db and brought to oodikone db)</Header>
<Form.Group>
<Form.Button content="Update meta" onClick={updateSISMeta} />
<Form.Button content="Update students" onClick={updateSISStudents} />
<Form.Button
content="Update curriculums"
onClick={() => {
if (window.confirm('This breaks all curriculum-related features for a few minutes. Continue?')) {
updateSISProgrammes()
}
}}
/>
</Form.Group>
<Header>Refresh data (calculations done by oodikone-backend and cached in redis)</Header>
{displayJobStatus()}
<Form.Group style={{ maxWidth: '10em' }}>
<Form.Button content="Refresh updater redis cache" onClick={refreshSISRedisCache} />
<Form.Button
content="Refresh all teacher leaderboards"
onClick={() => {
if (window.confirm('This is not ran in worker yet. Continue?')) refreshAllTeacherLeaderboards()
}}
/>
<Form.Button
content="Refresh teacher leaderboards of current and previous year"
onClick={refreshTeacherLeaderboardForCurrentAndPreviousYear}
/>
<Form.Button content="Refresh faculties" onClick={refreshFaculties} />
<Form.Button content="Refresh study programmes" onClick={refreshStudyProgrammes} />
{languageCenterViewEnabled && (
<Form.Button content="Refresh language center data" onClick={refreshLanguageCenterData} />
)}
<Form.Button content="Refresh close to graduation data" onClick={refreshCloseToGraduationData} />
</Form.Group>
</Form>
<Form.Group>
<Header>
Update custom list of items (students & courses in updater, programmes & faculties computed on backend)
</Header>
<TextArea onChange={(_, { value }) => setCustomList(value)} style={{ width: '25%' }} />
<div style={{ display: 'flex', flexDirection: 'row', gap: '2em', marginTop: '2em' }}>
{['students', 'courses', 'faculties', 'programmes'].map(thisType => (
<Radio
checked={type === thisType}
data-cy={`${thisType}-button`}
key={thisType}
label={thisType}
name="modeRadioGroup"
onChange={() => setType(thisType)}
style={{ fontSize: '24px', paddingTop: '10px' }}
value={thisType}
/>
))}
<Form.Button content="Update custom list of items" icon="refresh" onClick={updateSISCustomList} />
</div>
</Form.Group>
<Segment>
<Header>Stop updating</Header>
<Table collapsing>
<Table.Body>
<Table.Row>
<Table.Cell>
<ReactMarkdown>
Aborts all updating processes in the worker, also those started by a cron-job
</ReactMarkdown>
</Table.Cell>
<Table.Cell>
<Form.Button content="Stop updater-worker" negative onClick={abortSisUpdater} />
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>
<ReactMarkdown>
Removes all jobs **waiting** in the queue (listed above under *Jobs waiting*)
</ReactMarkdown>
</Table.Cell>
<Table.Cell>
<Form.Button content="Remove waiting jobs" negative onClick={removeWaitingJobs} />
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</Segment>
<Segment>
<Header>Status messages</Header>
<Button content="Clear messages" onClick={() => setMessages([])} />
{messages.map(message => (
<Header key={`${message.time.getTime()}-${message.message}`} style={{ color: message.color }}>
{message.time.toLocaleTimeString()}: {message.message}
</Header>
))}
</Segment>
</Segment>
)
}
@@ -3,7 +3,7 @@ import { useParams } from 'react-router'
import { Header } from 'semantic-ui-react'
import { isDefaultServiceProvider } from '@/common'
import { useTitle } from '@/common/hooks'
import { useTitle } from '@/hooks/title'
import { useLazyGetAllUsersQuery } from '@/redux/users'
import { NewUserSection } from './NewUserSection'
import { UserPage } from './UserPage'
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Accordion } from 'semantic-ui-react'
import { useLocalStorage } from '@/common/hooks'
import { useLocalStorage } from '@/hooks/localStorage'
const titleStyle = { paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }
@@ -51,20 +51,24 @@ export const RangeSelector = ({ min, max, value, onChange, disabled = false }) =
max={isDisabled ? 1 : max}
min={isDisabled ? 0 : min}
onChange={isDisabled ? noop : onChange}
renderThumb={({ props }) => (
<div
{...props}
style={{
...props.style,
height: '13px',
width: '13px',
backgroundColor: '#f2f2f2',
border: isDisabled ? '#f2f2f2' : '3px solid rgb(33, 133, 208)',
borderRadius: '10px',
}}
tabIndex=""
/>
)}
renderThumb={({ props }) => {
const { key, ...rest } = props
return (
<div
key={key}
{...rest}
style={{
...props.style,
height: '13px',
width: '13px',
backgroundColor: '#f2f2f2',
border: isDisabled ? '#f2f2f2' : '3px solid rgb(33, 133, 208)',
borderRadius: '10px',
}}
tabIndex=""
/>
)
}}
renderTrack={({ props, children }) => (
<div
{...props}
@@ -1,4 +1,5 @@
import { Icon, Item } from 'semantic-ui-react'
import { sisUrl } from '@/conf'
interface SisuLinkItemProps {
id: string
@@ -6,7 +7,7 @@ interface SisuLinkItemProps {
export const SisuLinkItem = ({ id }: SisuLinkItemProps) => (
<div data-cy="sisulink">
<Item as="a" href={`https://sisu.helsinki.fi/tutor/role/staff/student/${id}/basic/basic-info`} target="_blank">
<Item as="a" href={`${sisUrl}/tutor/role/staff/student/${id}/basic/basic-info`} target="_blank">
<Icon name="external alternate" />
Sisu
</Item>
@@ -5,7 +5,7 @@ import { useEffect, useState } from 'react'
import ReactHighchart from 'react-highcharts'
import { images } from '@/common'
import { useTitle } from '@/common/hooks'
import { useTitle } from '@/hooks/title'
import { useLogoutMutation } from '@/redux/auth'
import { EasterEgg } from './EasterEgg'
@@ -1,8 +1,9 @@
import { Box, Collapse, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'
import { Collapse, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'
import { Fragment, useState } from 'react'
import { ExpandableRow } from '@/components/material/ExpandableRow'
import { Section } from '@/components/material/Section'
import { StyledTable } from '@/components/material/StyledTable'
import { NameWithCode } from '@/shared/types'
import { ProgrammeProgressChart } from './ProgrammeProgressChart'
@@ -39,7 +40,7 @@ export const FacultyProgressTable = ({
return (
<Section>
<TableContainer>
<Table data-cy={cypress} size="small">
<StyledTable data-cy={cypress} showCellBorders size="small">
<TableHead>
<TableRow>
{titles.map(title => (
@@ -61,26 +62,20 @@ export const FacultyProgressTable = ({
yearArray={yearArray}
yearIndex={yearIndex}
/>
<TableRow>
<TableCell
colSpan={100}
style={{
padding: 0,
borderBottom: visibleYears[yearIndex] ? '' : 'none',
}}
>
<Collapse in={visibleYears[yearIndex]} timeout="auto" unmountOnExit>
<Box padding={1}>
{visibleYears[yearIndex] && (
<TableRow>
<TableCell colSpan={100}>
<Collapse in={visibleYears[yearIndex]} timeout="auto" unmountOnExit>
<ProgrammeProgressChart
data={sortedKeys?.map(programme => programmeStats[programme][yearIndex])}
labels={sortedKeys}
longLabels={programmeNames}
names={progressTitles ? progressTitles[yearIndex] : titles.slice(2)}
/>
</Box>
</Collapse>
</TableCell>
</TableRow>
</Collapse>
</TableCell>
</TableRow>
)}
</>
) : (
<TableRow>
@@ -94,7 +89,7 @@ export const FacultyProgressTable = ({
</Fragment>
))}
</TableBody>
</Table>
</StyledTable>
</TableContainer>
</Section>
)
@@ -5,13 +5,7 @@ import moment from 'moment'
import { useEffect, useState } from 'react'
import { DISPLAY_DATE_FORMAT_DEV } from '@/constants/date'
type ListItem = {
id: string
text: string
timestamp: string
params: any
}
import { SearchHistoryItem } from '@/types/searchHistory'
export const SearchHistory = ({
handleSearch,
@@ -20,13 +14,13 @@ export const SearchHistory = ({
}: {
handleSearch: (params: any) => void
header?: string
items: ListItem[]
items: SearchHistoryItem[]
}) => {
const [selected, setSelected] = useState<ListItem | null>(null)
const [selected, setSelected] = useState<SearchHistoryItem | null>(null)
const sortedItems = sortBy(items, item => -new Date(item.timestamp).getTime())
const sortedItems = sortBy(items, item => -new Date(item.timestamp!).getTime())
const handleChange = (_event: React.SyntheticEvent, newValue: ListItem | null) => {
const handleChange = (_event: React.SyntheticEvent, newValue: SearchHistoryItem | null) => {
if (newValue === null) {
handleSearch(newValue)
} else {
@@ -1,7 +1,7 @@
import { Person as PersonIcon } from '@mui/icons-material'
import { IconButton, Stack } from '@mui/material'
import { Link } from 'react-router'
import { sisUrl } from '@/conf'
import { ExternalLink } from './Footer/ExternalLink'
export const StudentInfoItem = ({ sisPersonId, studentNumber }: { sisPersonId: string; studentNumber: string }) => (
@@ -11,10 +11,7 @@ export const StudentInfoItem = ({ sisPersonId, studentNumber }: { sisPersonId: s
<IconButton component={Link} sx={{ padding: 0 }} target="_blank" to={`/students/${studentNumber}`}>
<PersonIcon color="primary" fontSize="small" />
</IconButton>
<ExternalLink
href={`https://sisu.helsinki.fi/tutor/role/staff/student/${sisPersonId}/basic/basic-info`}
text="Sisu"
/>
<ExternalLink href={`${sisUrl}/tutor/role/staff/student/${sisPersonId}/basic/basic-info`} text="Sisu" />
</Stack>
</Stack>
)
@@ -0,0 +1,27 @@
import { Stack, Switch, Typography } from '@mui/material'
import { InfoBox } from '@/components/material/InfoBox'
export const ToggleWithTooltip = ({
checked,
cypress,
label,
onChange,
tooltipText,
}: {
checked: boolean
cypress: string
label: string
onChange: () => void
tooltipText?: string | null
}) => {
return (
<Stack alignItems="center" direction="row" gap={1}>
<Stack alignItems="center" direction="row">
<Switch checked={checked} data-cy={cypress} onChange={onChange} />
<Typography color="text.primary">{label}</Typography>
</Stack>
{tooltipText && <InfoBox content={tooltipText} mini />}
</Stack>
)
}
+3
View File
@@ -28,3 +28,6 @@ export const serviceProvider = process.env.REACT_APP_SERVICE_PROVIDER
export const languageCenterViewEnabled = !process.env.REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED
? true
: process.env.REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED === 'true'
// SISU url
export const sisUrl = process.env.REACT_APP_SIS_URL ? process.env.REACT_APP_SIS_URL : 'https://sisu.helsinki.fi'
@@ -0,0 +1,11 @@
import { useGetCurriculumPeriodsQuery } from '@/redux/curriculumPeriods'
export const useCurrentCurriculumPeriod = () => {
const { data: curriculumPeriods = [] } = useGetCurriculumPeriodsQuery()
const currentCurriculumPeriod = curriculumPeriods.find(
({ startDate, endDate }) => new Date(startDate) <= new Date() && new Date(endDate) >= new Date()
)
return currentCurriculumPeriod
}
@@ -0,0 +1,14 @@
import { useGetSemestersQuery } from '@/redux/semesters'
export const useCurrentSemester = () => {
const { data: semesterData } = useGetSemestersQuery()
if (!semesterData) {
return null
}
const currentSemester = Object.values(semesterData.semesters).find(
semester => new Date(semester.startdate) <= new Date() && new Date(semester.enddate) >= new Date()
)
return currentSemester
}
+51
View File
@@ -0,0 +1,51 @@
import { isEqual } from 'lodash'
import { useCallback, useEffect, useRef, useState } from 'react'
export const useDebounce = (value: any, timeout: number, onChange) => {
const [innerValue, setInnerValue] = useState(value)
const [dirty, setDirty] = useState(false)
const timeoutRef = useRef<any>(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] as const
}
@@ -0,0 +1,29 @@
import { debounce } from 'lodash'
import { useState, useEffect, useMemo } from 'react'
/**
* A custom hook that returns a stateful value and a debounced setter.
*
* @param initialValue - The initial state value
* @param delay - The debounce delay (in milliseconds, defaults to 500)
*/
export const useDebouncedState = <T>(initialValue: T, delay = 500) => {
const [value, setValue] = useState<T>(initialValue)
const debouncedSetValue = useMemo(() => {
const debouncedFn = debounce((newValue: T | ((prevValue: T) => T)) => {
setValue(prevValue => (typeof newValue === 'function' ? (newValue as (prevValue: T) => T)(prevValue) : newValue))
}, delay)
return debouncedFn
}, [delay])
// Cleanup on unmount
useEffect(() => {
return () => {
debouncedSetValue.cancel()
}
}, [debouncedSetValue])
return [value, debouncedSetValue] as const
}
+15
View File
@@ -0,0 +1,15 @@
import { isEqual } from 'lodash'
import { useRef } from 'react'
/**
* This hook is similar to useMemo, but it does a deep comparison of the dependencies (using `isEqual` from Lodash).
*/
export const useDeepMemo = (factory: () => any, dependencies: any[]) => {
const ref = useRef<any>()
if (!ref.current || !isEqual(dependencies, ref.current.deps)) {
ref.current = { deps: dependencies, result: factory() }
}
return ref.current.result
}
@@ -0,0 +1,13 @@
import { useGetProgrammesQuery } from '@/redux/populations'
export const useDegreeProgrammeTypes = (programmeCodes: string[]) => {
const { data: degreeProgrammes } = useGetProgrammesQuery({})
if (!degreeProgrammes) {
return {}
}
return programmeCodes.reduce((acc, programmeCode) => {
acc[programmeCode] = degreeProgrammes[programmeCode]?.degreeProgrammeType ?? null
return acc
}, {})
}
+9
View File
@@ -0,0 +1,9 @@
import { useEffect, useState } from 'react'
export const useDidMount = () => {
const [didMount, setDidMount] = useState(false)
useEffect(() => {
setDidMount(true)
}, [])
return didMount
}
@@ -0,0 +1,27 @@
import { useCallback, useState } from 'react'
export const useLocalStorage = (key: string, initialValue: any) => {
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] as const
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from 'react'
import { useDidMount } from './didMount'
const useInterval = (callback: any, delay: number | null) => {
const savedCallback = useRef<any>()
const savedId = useRef<any>()
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 useProgress = (loading: boolean) => {
const didMount = useDidMount()
const [progress, setProgress] = useState<number>(100)
const [delay, setDelay] = useState<number | null>(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: number) => {
if (progress > 0) {
setProgress(50 + Math.floor(progress / 2))
}
}
return {
progress,
onProgress,
} as const
}
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import { SEARCH_HISTORY_VERSION } from '@/constants'
import { SearchHistoryItem } from '@/types/searchHistory'
export const useSearchHistory = (id: string, capacity = 5) => {
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([])
const [didMount, setDidMount] = useState(false)
const getSearchHistoryStore = () => {
const store = localStorage.getItem('searchHistoryStore')
return store ? JSON.parse(store) : {}
}
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: SearchHistoryItem) => {
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] as const
}
@@ -0,0 +1,18 @@
import { useCallback, useRef } from 'react'
export const useTabChangeAnalytics = () => {
const previousTabIndex = useRef<number>(0)
const handleTabChange = useCallback(
(_, data) => {
const { activeIndex } = data
if (previousTabIndex.current !== activeIndex) {
previousTabIndex.current = activeIndex
}
},
[previousTabIndex]
)
return { handleTabChange } as const
}
+33
View File
@@ -45,3 +45,36 @@ export const useTabs = (totalTabs: number) => {
return [tab, handleChange] as const
}
// ! To be deprecated, use useTabs for MUI components
export const useSemanticTabs = (id: string, initialTab: number, { 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 as string))
setDidMount(true)
}, [])
useEffect(() => {
if (tab !== undefined && didMount) {
pushToUrl(tab)
}
}, [tab])
return [
tab,
(_, { activeIndex }) => {
setTab(activeIndex)
},
]
}
+10
View File
@@ -0,0 +1,10 @@
import { useEffect } from 'react'
export const useTitle = (title?: string) => {
useEffect(() => {
document.title = title ? `${title} - Oodikone` : 'Oodikone'
return () => {
document.title = 'Oodikone'
}
}, [title])
}
+9
View File
@@ -0,0 +1,9 @@
import { useCallback, useState } from 'react'
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => {
setValue(value => !value)
}, [])
return [value, toggle] as const
}
@@ -2,8 +2,8 @@ import { Container, Divider, Stack } from '@mui/material'
import { useEffect, useState } from 'react'
import { filterInternalReleases } from '@/common'
import { useTitle } from '@/common/hooks'
import { PageTitle } from '@/components/material/PageTitle'
import { useTitle } from '@/hooks/title'
import { useGetChangelogQuery } from '@/redux/changelog'
import { Release } from '@/shared/types'
import { ReleaseCard } from './ReleaseCard'
@@ -4,7 +4,6 @@ import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'materi
import { useEffect, useMemo, useState } from 'react'
import { getEnrollmentTypeTextForExcel, isFall } from '@/common'
import { useCurrentSemester } from '@/common/hooks'
import { closeToGraduationToolTips } from '@/common/InfoToolTips'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { CheckIconWithTitle } from '@/components/material/CheckIconWithTitle'
@@ -15,13 +14,28 @@ import { StudentInfoItem } from '@/components/material/StudentInfoItem'
import { TableHeaderWithTooltip } from '@/components/material/TableHeaderWithTooltip'
import { getSemestersPresentFunctions } from '@/components/PopulationStudents/StudentTable/GeneralTab/columnHelpers/semestersPresent'
import { ISO_DATE_FORMAT, LONG_DATE_TIME_FORMAT } from '@/constants/date'
import { useCurrentSemester } from '@/hooks/currentSemester'
import { useGetStudentsCloseToGraduationQuery } from '@/redux/closeToGraduation'
import { useGetSemestersQuery } from '@/redux/semesters'
import { Language } from '@/shared/language'
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
import { reformatDate } from '@/util/timeAndDate'
const NUMBER_OF_DISPLAYED_SEMESTERS = 6
const getFullLanguage = (languageCode: Language) => {
switch (languageCode) {
case 'fi':
return 'Finnish'
case 'sv':
return 'Swedish'
case 'en':
return 'English'
default:
return null
}
}
export const CloseToGraduation = () => {
const { data: students } = useGetStudentsCloseToGraduationQuery()
const { data: semesterData } = useGetSemestersQuery()
@@ -89,6 +103,12 @@ export const CloseToGraduation = () => {
accessorKey: 'student.secondaryEmail',
header: 'Secondary email',
},
{
accessorFn: row => getFullLanguage(row.student.preferredLanguage),
id: 'preferredLanguage',
header: 'Preferred language',
filterVariant: 'multi-select',
},
{
accessorFn: row => getTextIn(row.faculty),
header: 'Faculty',
@@ -263,6 +283,7 @@ export const CloseToGraduation = () => {
'student.phoneNumber': false,
'student.email': false,
'student.secondaryEmail': false,
preferredLanguage: false,
semesterEnrollments: false,
})
@@ -3,8 +3,8 @@ import { Alert, Box, Container, Stack } from '@mui/material'
import { green, yellow, grey } from '@mui/material/colors'
import { useState } from 'react'
import { useTitle } from '@/common/hooks'
import { PageTitle } from '@/components/material/PageTitle'
import { useTitle } from '@/hooks/title'
import { SearchModal } from './SearchModal'
import { SearchResults } from './SearchResults'
@@ -4,8 +4,9 @@ import { useDispatch, useSelector } from 'react-redux'
import { useLocation, useNavigate } from 'react-router'
import { Segment, Tab } from 'semantic-ui-react'
import { useProgress, useTabs } from '@/common/hooks'
import { getCourseStats } from '@/redux/coursestats'
import { useProgress } from '@/hooks/progress'
import { useSemanticTabs } from '@/hooks/tabs'
import { getCourseStats } from '@/redux/courseStats'
import { AttemptsPane } from './panes/AttemptsPane'
import { StudentsPane } from './panes/StudentsPane'
import './resultTabs.css'
@@ -14,7 +15,7 @@ export const ResultTabs = ({ primary, comparison, separate, availableStats }) =>
const navigate = useNavigate()
const location = useLocation()
const replace = useCallback(options => navigate(options, { replace: true }), [navigate])
const [tab, setTab] = useTabs('cs_tab', 0, { location, replace })
const [tab, setTab] = useSemanticTabs('cs_tab', 0, { location, replace })
const { userHasAccessToAllStats } = primary
const courseStats = useSelector(({ courseStats }) => courseStats)
const { pending: loading } = courseStats
@@ -10,7 +10,7 @@ import {
getMaxValueOfSeries,
getSeriesType,
getThesisGradeSpread,
} from '@/components/CourseStatistics/ResultTabs/panes/util'
} from '@/pages/CourseStatistics/ResultTabs/panes/util'
import { chartColor, color } from '@/styles/colors'
exporting(ReactHighcharts.Highcharts)
@@ -3,11 +3,7 @@ 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 { absoluteToRelative, getDataObject, getMaxValueOfSeries } from '@/pages/CourseStatistics/ResultTabs/panes/util'
import { chartColor, color } from '@/styles/colors'
exporting(ReactHighcharts.Highcharts)

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