mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ce3337977 | ||
|
|
c5e24c7409 | ||
|
|
34f029da9e | ||
|
|
e95414806c | ||
|
|
0eb87ea44a | ||
|
|
454fabeb1d | ||
|
|
d3f022adc5 | ||
|
|
8efe3d2aa3 | ||
|
|
d3003d5797 | ||
|
|
0dd5f32f96 | ||
|
|
85b307b306 | ||
|
|
73eadbd1b1 | ||
|
|
b294c560e4 | ||
|
|
455d9dc532 | ||
|
|
df1049a436 | ||
|
|
ac22df8ced | ||
|
|
1fd987221d | ||
|
|
45985f2c44 | ||
|
|
159e11a508 | ||
|
|
5ff68533d7 | ||
|
|
61f20173a3 | ||
|
|
987f1ec42b | ||
|
|
3f4c39600b | ||
|
|
88ef490b1b | ||
|
|
7e135d4643 | ||
|
|
06a8760701 | ||
|
|
5eaa035f1d | ||
|
|
ae1f0ceeb7 | ||
|
|
3ca82286b4 | ||
|
|
63add43c0f | ||
|
|
72440609b4 | ||
|
|
c946cd2405 | ||
|
|
96d316ebf5 | ||
|
|
b3eb7dceac | ||
|
|
ee02d002ba | ||
|
|
34448d27d0 | ||
|
|
39ce64edce | ||
|
|
0d210a607b | ||
|
|
f1a6302928 | ||
|
|
359db8f365 | ||
|
|
387a3e0ef2 | ||
|
|
4c957b347b | ||
|
|
10d3d5aeed | ||
|
|
6e365d4a23 | ||
|
|
68292a45d0 | ||
|
|
f0088cf7a0 | ||
|
|
b8469a242e | ||
|
|
8c5d5f1ee9 | ||
|
|
80759cacee | ||
|
|
4df2cc78ff | ||
|
|
8b34f7a5f9 | ||
|
|
e81d6eb877 | ||
|
|
0c47d7c21c | ||
|
|
175996235d | ||
|
|
640dc741a4 |
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
SENTRY_DSN: ''
|
||||
SERVICE_PROVIDER: fd
|
||||
LANGUAGE_CENTER_VIEW_ENABLED: 'false' # currently no intention to be enabled
|
||||
SIS_URL: 'https://sis-qa.funidata.fi'
|
||||
NODE_ENV: production
|
||||
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/oodikone-frontend:${DOCKER_IMAGE_TAG:-dev}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ services:
|
||||
environment:
|
||||
REACT_APP_SERVICE_PROVIDER: fd
|
||||
REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED: 'false'
|
||||
REACT_APP_SIS_URL: 'https://sis-qa.funidata.fi'
|
||||
volumes:
|
||||
- ./services/frontend/src/:/opt/app-root/src/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
|
||||
@@ -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).
|
||||
Generated
+95
-95
@@ -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
@@ -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",
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchCurrentVersion": "!/^0/",
|
||||
"automerge": true,
|
||||
"matchPackageNames": ["*"]
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+60
-837
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,6 +32,9 @@ ENV REACT_APP_SERVICE_PROVIDER=$SERVICE_PROVIDER
|
||||
ARG LANGUAGE_CENTER_VIEW_ENABLED
|
||||
ENV REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED=$LANGUAGE_CENTER_VIEW_ENABLED
|
||||
|
||||
ARG SIS_URL
|
||||
ENV REACT_APP_SIS_URL=$SIS_URL
|
||||
|
||||
COPY ./frontend/package* ./
|
||||
RUN npm ci --include=dev
|
||||
COPY ./frontend .
|
||||
|
||||
Generated
+39
-39
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
`,
|
||||
}
|
||||
@@ -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
|
||||
}, {})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
+1
-1
@@ -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)
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Input } from 'semantic-ui-react'
|
||||
|
||||
import { Timeout } from '@/components/Timeout'
|
||||
|
||||
enum Timeouts {
|
||||
FETCH = 'fetch',
|
||||
SEARCH = 'search',
|
||||
}
|
||||
|
||||
interface AutoSubmitSearchInputProps {
|
||||
cypress: string
|
||||
doSearch: (searchTerm: string) => void
|
||||
inputValue: string
|
||||
loading: boolean
|
||||
onChange: (searchTerm: string) => void
|
||||
setTimeout: (timeout: Timeouts, callback: () => void, latency: number) => void
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
const AutoSubmitSearchInput = ({
|
||||
cypress,
|
||||
doSearch,
|
||||
inputValue,
|
||||
loading,
|
||||
onChange,
|
||||
setTimeout,
|
||||
placeholder,
|
||||
}: AutoSubmitSearchInputProps) => {
|
||||
const [input, setInput] = useState(inputValue)
|
||||
const latency = 250
|
||||
const minSearchLength = 0
|
||||
|
||||
const executeSearch = (searchTerm: string) => {
|
||||
clearTimeout(Timeouts.FETCH)
|
||||
setTimeout(Timeouts.FETCH, () => doSearch(searchTerm), latency)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if ((input || input === '') && input.length >= minSearchLength) {
|
||||
executeSearch(input)
|
||||
}
|
||||
}, [input])
|
||||
|
||||
const handleSearchChange = (_event, { value }) => {
|
||||
clearTimeout(Timeouts.SEARCH)
|
||||
if (value.length >= 0) {
|
||||
onChange(value)
|
||||
setTimeout(Timeouts.SEARCH, () => setInput(value), latency)
|
||||
} else {
|
||||
onChange('')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
data-cy={cypress}
|
||||
fluid
|
||||
icon="search"
|
||||
loading={loading}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const TimeoutAutoSubmitSearchInput = Timeout(AutoSubmitSearchInput)
|
||||
@@ -1,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
-1
@@ -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
-1
@@ -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'
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+15
-14
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}, {})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
-3
@@ -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
|
||||
+1
-1
@@ -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)
|
||||
+1
-5
@@ -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
Reference in New Issue
Block a user