Compare commits

...
62 Commits
Author SHA1 Message Date
Santeri Rajamäki 072360763f Update cypress a bit more 2025-06-12 19:44:10 +03:00
Santeri Rajamäki 639858f874 Update cypress to the new reality 2025-06-12 19:27:11 +03:00
Santeri Rajamäki 92f3e67293 [Frontend] Make filters render faster
Huge performance gains!?
2025-06-12 18:26:39 +03:00
Santeri Rajamäki 983390040e [Frontend] Massively improve render times for some filter components
This tweak improves the render time of FilterTray by 5 fold.
2025-06-12 17:05:40 +03:00
Santeri Rajamäki 277b97327e Update CI serve version to 14.x
This hasn't been updated and was hardcoded to 12.0.0.
The is no comment on why it *shouldn't* be updated.
2025-06-12 15:16:47 +03:00
Santeri Rajamäki 3dab357396 Fix skill issue 2025-06-12 15:15:25 +03:00
Santeri Rajamäki a39648f380 [Frontend] Offload cheap calculatoin to child components
This avoids some prop drilling and has positive performance impact at least in development.
2025-06-12 15:03:16 +03:00
Santeri Rajamäki ccb1865853 Update sentry to version 9.28
Closes #4830
2025-06-11 14:00:01 +03:00
Santeri Rajamäki 81da089798 Update scheduler to version 0.26
Closes #4725
2025-06-11 13:56:41 +03:00
Santeri Rajamäki 785b7d5d74 Update minor version of dependencies
Closes #4762
Closes #4783
2025-06-10 17:51:07 +03:00
Santeri Rajamäki aca70d5442 Revert optimistic eslint fixes 2025-06-10 15:29:47 +03:00
Santeri Rajamäki 41d93c9b43 Fix lint errors triggered by linter updates 2025-06-10 14:33:03 +03:00
Santeri Rajamäki 835c9e580b Update lint tools 2025-06-09 16:38:39 +03:00
Santeri Rajamäki 9c9f317ecb Update eslint-import-resolver-typescript to version 4.4
Cloess #4769
2025-06-09 16:19:08 +03:00
Santeri Rajamäki 3ee6758c72 Update eslint-plugin-cypress to version 5.x
Closes #4826
2025-06-09 16:16:49 +03:00
Santeri Rajamäki 88764f83de Update react-router to version 7.5
Closes #4794
2025-06-09 14:54:57 +03:00
Santeri Rajamäki e8908d28fc Update axios to version 1.9
Closes #4791
2025-06-09 14:54:02 +03:00
Santeri Rajamäki a6d7da315f Update sentry monorepo to versoin 9.x
Closes #4736
2025-06-09 14:45:04 +03:00
santeri0200andGitHub e72e0bb5ad Merge pull request #4821 from UniversityOfHelsinkiCS/filtering
[Frontend] Filtering cleanup
2025-06-09 13:43:22 +03:00
Santeri Rajamäki 15b0286eda [Frontend] Force failure-state instead of crash with faulty courses 2025-06-09 13:32:55 +03:00
Santeri Rajamäki 1104483cac [Frontend] Simplified filter action and selector structure 2025-06-09 13:32:42 +03:00
Santeri Rajamäki 604bcf4998 [Frontend] Remove actions and selectors from filters
Actions and selectors are still attached to the filter factories
2025-06-09 13:32:42 +03:00
Santeri Rajamäki cc839a9e14 [Frontend] Removed extra parameter from filter calls 2025-06-09 13:32:42 +03:00
Santeri Rajamäki 2a076ef6e8 [Frontend] Täsäfy rest of the FilterView 2025-06-09 13:32:42 +03:00
Santeri Rajamäki b05e1c22e5 [Frontend] Minor improvments to filters and filter context 2025-06-09 13:32:26 +03:00
Santeri Rajamäki b9800c857f [Frontend] Converted all filter components to render functions
This should be non-breaking as all the components were functions anyways
2025-06-09 13:31:33 +03:00
Santeri Rajamäki 6d535bf4bb [Frontend] Täsäfy filters
Added FilterTrayProps to FilterTray.tsx
Fixed Filter context typings
Reworked createFilter types
Also reword createFilter to work a little better
2025-06-09 13:30:55 +03:00
joonas-a 714f85d5c0 [Updater-worker] fix winston-sentry 2025-06-07 01:38:04 +03:00
joonas-a 01af4cfd2c [Updater-worker] ES6 2025-06-07 01:02:11 +03:00
Santeri Rajamäki fe55b770e9 [Updater] Update umzug to v3
Closes #4610
2025-06-06 15:25:17 +03:00
Santeri Rajamäki 43e4e605d6 [Cypress] Make OpenUni test use pathname and params instead of hardcoded path 2025-06-06 15:09:52 +03:00
Santeri Rajamäki 15a54f1e26 [Frontend] Replace query-string dependency with two typed utility functions 2025-06-06 14:35:00 +03:00
Santeri Rajamäki 631b5be00a Update shared model definition for SISStudyRight model 2025-06-06 12:01:53 +03:00
joonas-a b8d9648b8c [Student statistics] Infer studyright state from validity period if applicable 2025-06-06 01:22:30 +03:00
joonas-a d52562330b [Backend] Add studyright expiration urns to db model 2025-06-06 00:18:51 +03:00
joonas-a f4c9137a05 [Updater] Add study right expiration rule urns 2025-06-05 23:48:46 +03:00
santeri0200andGitHub 9a1802b6b4 [Frontend] Populus optimus (#4824)
* [Frontend] Täsäfy PopulationStatistics

* [Frontend] Reorganized PopulationStatistics

* [Redux] Removed dangling reducer actions left back in 51e5846 and further in history
This commit is 4 years due...

* Convert to RTKQuery for populationStatistics

* Removed useSelector for state.population

* Reset PopulationsStatistics params based on the new query param formatting

* Format semesters to array instead of spreading strings

* Force array formating with single key values

* Stop lying to the users
Progressbar is no more
2025-06-05 16:04:40 +03:00
Santeri Rajamäki fd2d95a832 [Frontend] Format RTKApi query params in Express friendly manner 2025-06-04 19:27:10 +03:00
Joonas Ahovalli dfb557d4e1 hotfix: undefined studyright.... 2025-05-30 16:37:11 +03:00
Joonas Ahovalli 22e2ccf562 [MRT] Adjust default col width + minor changes
Have admissiontype be either value or null, no boolean
Hide email and phone from table, show only in excel
2025-05-30 15:52:57 +03:00
Joonas Ahovalli 68480a7211 [General tab] Add new column for TVEX bitti 2025-05-30 15:25:13 +03:00
Joonas Ahovalli bb4edef070 [Backend] Fetch tvex info along with studyrights 2025-05-30 15:20:04 +03:00
Joonas Ahovalli 6877716468 [Updater] Add tvex info to sis_study_rights 2025-05-30 13:18:47 +03:00
Santeri Rajamäki a21e91fbaa [Updater] added loki 2025-05-30 12:03:36 +03:00
Santeri Rajamäki 62e7de5fef [Backend] added loki 2025-05-28 13:05:33 +03:00
santeri0200andGitHub 785b84b14c Merge pull request #4820 from UniversityOfHelsinkiCS/OOD-89
Edited shared mounting in fd compose
2025-05-28 11:00:06 +03:00
Matti Räsänen e488327fd4 Edited shared mounting in fd compose 2025-05-26 15:09:25 +03:00
Santeri Rajamäki 2642e15479 Fk go back 2025-05-21 19:00:01 +03:00
Santeri Rajamäki 547d1c7d35 [Frontend] Prepare PopulationStatistics for changes 2025-05-21 18:36:38 +03:00
Santeri Rajamäki d57ecb2aa9 [Frontend] MUI the right way
According to MUI minimizing bundle size. Closes #4816
2025-05-21 18:19:04 +03:00
Santeri Rajamäki ef76b0f350 [Frontend] Preliminary FilterView täsäfication
This commit includes the most rudimentary implementation of TS types for the view.
We need more types from backend to advance these files.
2025-05-21 16:08:25 +03:00
Joonas Ahovalli 3ba2bd8d5b [Custom population] indicate if graduated or inactive primary study programme 2025-05-21 13:18:35 +03:00
Santeri Rajamäki 12976a961b [Frontend] Fix deepCopy issue 2025-05-19 22:59:54 +03:00
Santeri Rajamäki 01c6159ffa [Frontend] Make FilterView great again 2025-05-19 22:32:13 +03:00
Santeri Rajamäki a6c1e3891d [Frontend] Remove unused directive to make lint happy again 2025-05-19 22:28:19 +03:00
Santeri Rajamäki 6c4b42c98d [Shared] Add keyBy function to utils 2025-05-19 22:13:34 +03:00
Joonas Ahovalli 10941259a2 [Redux] Implement hooks useAppDispatch and useAppSelector
Allows for more streamlined typing
2025-05-19 22:08:44 +03:00
Joonas Ahovalli b7563cd4ec [Modules tab] Improve typing 2025-05-19 17:11:58 +03:00
Joonas Ahovalli 8d261aae51 Fix typo 2025-05-19 17:10:03 +03:00
Joonas Ahovalli ccf70ed420 [General tab] improve typing 2025-05-19 16:23:36 +03:00
Joonas Ahovalli 20f4bd32f8 [General tab] Fix tags broken 2025-05-16 11:45:55 +03:00
Joonas Ahovalli d62140e97d Add scripts to clean install local/docker/both 2025-05-16 11:43:55 +03:00
318 changed files with 6275 additions and 3826 deletions
+2 -2
View File
@@ -194,7 +194,7 @@ describe('Population Statistics', () => {
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
checkFilteringResult(1)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
cy.cs('startYearAtUni-dropdown').selectFromDropdown(6)
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
checkFilteringResult(21)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
})
@@ -249,7 +249,7 @@ describe('Population Statistics', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(13)
cy.cs('StartYearAtUni-header').click()
cy.cs('startYearAtUni-dropdown').selectFromDropdown(5)
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
checkFilteringResult(8)
cy.cs('reset-all-filters').click()
checkFilteringResult(defaultAmountOfStudents)
+10 -4
View File
@@ -43,10 +43,16 @@ describe('Open uni population tests', () => {
cy.contains('01.01.2015')
cy.contains('End of the search for enrollments:')
cy.contains('01.01.2020')
cy.url().should(
'include',
'/openunipopulation?courseCode=TKT10001&courseCode=TKT10002&enddate=01-01-2020&startdate=01-01-2015'
)
cy.location('pathname').should('eq', '/openunipopulation')
cy.location('search').then(search => {
const params = new URLSearchParams(search)
const courseCodes = params.getAll('courseCode')
expect(courseCodes).to.include.members(['TKT10001', 'TKT10002'])
expect(params.get('startdate')).to.eq('01-01-2015')
expect(params.get('enddate')).to.eq('01-01-2020')
})
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
})
+9 -5
View File
@@ -49,9 +49,10 @@ services:
ports:
- 8080:8080
volumes:
- ./environments/ha:/opt/app-root/src/src/environment
- ./services/backend/src:/opt/app-root/src/src
- ./services/shared/:/opt/app-root/src/src/shared
- backend-nodemod:/opt/app-root/backend/node_modules
- ./environments/testu:/opt/app-root/backend/src/environment
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
frontend:
<<: *defaults
@@ -66,8 +67,9 @@ services:
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
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/:/opt/app-root/frontend
- ./services/shared/:/opt/app-root/shared
kone-db:
<<: *defaults
@@ -171,6 +173,8 @@ services:
- 12347:5432
volumes:
backend-nodemod:
frontend-nodemod:
kone-db-data:
redis-data:
sis-db-data:
+1
View File
@@ -83,6 +83,7 @@ export default [
'object-shorthand': ['error', 'always'],
'prefer-const': 'error',
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
"no-restricted-imports": ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
'react/display-name': 'off', // TODO: Delete this override
'react/function-component-definition': [
+805 -453
View File
File diff suppressed because it is too large Load Diff
+18 -15
View File
@@ -25,8 +25,11 @@
"prepare": "husky",
"rapodiff": "docker exec -t oodikone_backend_1 /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
"install:docker": "docker exec frontend npm i && docker exec oodikone_backend_1 npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone_sis-updater-worker_1 npm i",
"install:both": "npm run install:local && npm run install:docker"
"ci:docker": "docker exec frontend npm ci && docker exec oodikone_backend_1 npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone_sis-updater-worker_1 npm ci",
"install:both": "npm run install:local && npm run install:docker",
"ci:both": "npm run ci:local && npm run ci:docker"
},
"repository": {
"type": "git",
@@ -38,27 +41,27 @@
},
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@cypress/code-coverage": "^3.13.12",
"@eslint/js": "^9.22.0",
"@cypress/code-coverage": "^3.14.4",
"@eslint/js": "^9.28.0",
"@istanbuljs/schema": "^0.1.3",
"cypress": "^14.1.0",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.1.1",
"eslint-import-resolver-typescript": "^3.8.3",
"eslint-plugin-cypress": "^4.2.0",
"cypress": "^14.4.1",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-cypress": "^5.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-prettier": "^5.4.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.0.0",
"globals": "^16.2.0",
"husky": "^9.1.7",
"lint-staged": "^15.4.3",
"lint-staged": "^16.1.0",
"node-actionlint": "^1.2.2",
"nyc": "^17.1.0",
"prettier": "3.5.3",
"stylelint": "^16.15.0",
"stylelint-config-standard": "^37.0.0",
"typescript-eslint": "^8.26.0"
"stylelint": "^16.20.0",
"stylelint-config-standard": "^38.0.0",
"typescript-eslint": "^8.34.0"
},
"dependencies": {
"lodash": "^4.17.21",
+730 -429
View File
File diff suppressed because it is too large Load Diff
+21 -20
View File
@@ -6,40 +6,41 @@
},
"dependencies": {
"@oodikone/shared": "file:../shared",
"@sentry/node": "^8.55.0",
"@sentry/profiling-node": "^8.55.0",
"axios": "^1.8.1",
"bullmq": "^5.41.8",
"@sentry/node": "^9.28.1",
"@sentry/profiling-node": "^9.28.1",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
"cron": "^4.1.0",
"cron": "^4.3.1",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash": "^4.17.21",
"lru-cache": "^11.0.2",
"lru-cache": "^11.1.0",
"moment": "^2.30.1",
"morgan": "^1.10.0",
"papaparse": "^5.5.2",
"pg": "^8.13.3",
"redis": "^4.7.0",
"papaparse": "^5.5.3",
"pg": "^8.16.0",
"redis": "^4.7.1",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.6",
"sequelize": "^6.37.7",
"sequelize-typescript": "^2.1.6",
"umzug": "^3.8.2",
"unfuck-utf8-headers-middleware": "^1.0.1",
"winston": "^3.17.0",
"winston-gelf-transporter": "^1.0.2"
"winston-gelf-transporter": "^1.0.2",
"winston-loki": "^6.1.3"
},
"devDependencies": {
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/lodash": "^4.17.16",
"@types/morgan": "^1.9.9",
"@types/node": "^22.13.10",
"@types/validator": "^13.12.2",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.23",
"@types/lodash": "^4.17.17",
"@types/morgan": "^1.9.10",
"@types/node": "^22.15.31",
"@types/validator": "^13.15.1",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
"overrides": {
"winston-gelf-transporter": {
+1 -1
View File
@@ -65,7 +65,7 @@ const refreshProgrammesAndFaculties = async () => {
export const refreshProgramme = async (code: string) => {
await updateBasicView(code, '')
await updateStudyTrackView(code, '')
const combinedProgramme = combinedStudyProgrammes[code] || ''
const combinedProgramme = combinedStudyProgrammes[code] ?? ''
await updateBasicView(code, combinedProgramme)
await updateStudyTrackView(code, combinedProgramme)
}
@@ -86,6 +86,12 @@ export class SISStudyRightModel extends Model<SISStudyRight> implements SISStudy
@HasMany(() => EnrollmentModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
enrollments!: Enrollment[]
@Column(DataType.BOOLEAN)
tvex!: boolean
@Column(DataType.ARRAY(DataType.STRING))
expirationRuleUrns!: string[]
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
@@ -23,7 +23,7 @@ type SearchQuery = {
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
const { courselist, startdate, enddate } = req.query
const courseCodes = (await safeJSONParse(courselist)) || []
const courseCodes = (await safeJSONParse(courselist)) ?? []
if (!Array.isArray(courseCodes)) {
return res.status(400).json({ error: 'Courses must be of type array' })
}
@@ -26,7 +26,7 @@ router.post('/', async (req: Request, res: Response) => {
return res.status(400).json({ error: 'Students must be of type array' })
}
const customPopulationSearch = await createCustomPopulationSearch(name, id, students || [])
const customPopulationSearch = await createCustomPopulationSearch(name, id, students ?? [])
res.json(customPopulationSearch)
})
@@ -180,16 +180,10 @@ export class CourseStatsCounter {
}
public markEnrollment(studentNumber: string, state: EnrollmentState, semester: string) {
if (!this.enrollments[state]) {
this.enrollments[state] = new Set()
}
this.enrollments[state] ??= new Set()
this.enrollments[state].add(studentNumber)
if (!this.enrollments.semesters[semester]) {
this.enrollments.semesters[semester] = {}
}
if (!this.enrollments.semesters[semester][state]) {
this.enrollments.semesters[semester][state] = new Set()
}
this.enrollments.semesters[semester] ??= {}
this.enrollments.semesters[semester][state] ??= new Set()
this.enrollments.semesters[semester][state].add(studentNumber)
}
@@ -73,9 +73,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
const studentNumberToSrElementsMap = studyRightElements.reduce((obj, cur) => {
const studyRight = studyRightMap[cur.studyRightId]
const { studentNumber } = studyRight
if (!obj[studentNumber]) {
obj[studentNumber] = []
}
obj[studentNumber] ??= []
obj[studentNumber].push({ ...cur.toJSON(), studyRight })
return obj
}, {})
@@ -71,15 +71,13 @@ const parseCredit = (
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
const studyRightElements = studentNumberToSrElementsMap[studentNumber] || []
let programmeOfCredit: SISStudyRightElementModel | undefined
programmeOfCredit = studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId)
if (!programmeOfCredit) {
programmeOfCredit = studyRightElements
const programmeOfCredit: SISStudyRightElementModel | undefined =
studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId) ??
studyRightElements
.filter(studyRightElement =>
dateIsBetween(attainmentDate, studyRightElement.startDate, studyRightElement.endDate)
)
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())[0] // The newest studyRightElement
}
const programmes = programmeOfCredit ? [programmeOfCredit].map(formatStudyRightElement) : []
@@ -89,13 +89,9 @@ const findThesisAndLatestAndEarliestAttainments = (
) {
thesisData = attainment
}
if (!attainmentDates.latestTotal) {
attainmentDates.latestTotal = attainment.attainment_date
}
attainmentDates.latestTotal ??= attainment.attainment_date
if (studyPlan.included_courses.includes(attainment.course?.code)) {
if (!attainmentDates.latestHops) {
attainmentDates.latestHops = attainment.attainment_date
}
attainmentDates.latestHops ??= attainment.attainment_date
if (
!attainmentDates.earliestHops ||
attainment.attainment_date.getTime() < attainmentDates.earliestHops.getTime()
@@ -133,7 +133,7 @@ type StudentStudyRightElement = Pick<
type StudentStudyRight = Pick<
SISStudyRightModel,
'id' | 'extentCode' | 'facultyCode' | 'admissionType' | 'cancelled' | 'semesterEnrollments' | 'startDate'
'id' | 'extentCode' | 'facultyCode' | 'admissionType' | 'cancelled' | 'semesterEnrollments' | 'startDate' | 'tvex'
> & {
studyRightElements: Array<StudentStudyRightElement>
}
@@ -182,6 +182,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
'cancelled',
'semesterEnrollments',
'startDate',
'tvex',
],
separate: true,
include: [
+1 -5
View File
@@ -234,11 +234,7 @@ const formatSharedStudentData = ({
}
}
if (credits === undefined) {
credits = []
}
const formattedCredits = credits
const formattedCredits = (credits ?? [])
.sort((a, b) => new Date(a.attainment_date).getTime() - new Date(b.attainment_date).getTime())
.map(toCourse)
@@ -51,9 +51,7 @@ const getGraduationTimeStats = async (
) => {
for (const programmeOrTrack of Object.keys(graduationTimes)) {
const stats = graduationTimes[programmeOrTrack]
if (!finalGraduationTimes[programmeOrTrack]) {
finalGraduationTimes[programmeOrTrack] = { medians: { basic: [] as MedianEntry[], combo: [] as MedianEntry[] } }
}
finalGraduationTimes[programmeOrTrack] ??= { medians: { basic: [] as MedianEntry[], combo: [] as MedianEntry[] } }
for (const [year, oneYearStats] of Object.entries(stats)) {
const classSize = mainStatsByTrack[combinedProgramme ? studyProgramme : programmeOrTrack].find(
stats => stats[0] === year
@@ -102,7 +102,7 @@ const markCreditForSemester = (semesters: Record<string, { id: number; name: Nam
const markCreditForYear = (years: Record<number, any>, credit: Credit) => {
const { passed, failed, credits, semester, transferred } = parseCreditInfo(credit)
const { yearcode, yearname } = semester
const { stats, ...rest } = years[yearcode] || { id: yearcode, name: yearname }
const { stats, ...rest } = years[yearcode] ?? { id: yearcode, name: yearname }
return {
...years,
[yearcode]: {
@@ -116,7 +116,7 @@ const markCreditForCourse = (courses: Record<string, any>, credit: Credit) => {
const { passed, failed, credits, course, semester, transferred } = parseCreditInfo(credit)
const { code, name } = course
const { semestercode } = semester
const { stats, semesters = {}, ...rest } = courses[code] || { id: code, name }
const { stats, semesters = {}, ...rest } = courses[code] ?? { id: code, name }
return {
...courses,
[code]: {
+10
View File
@@ -1,6 +1,7 @@
const os = require('os')
const winston = require('winston')
const { WinstonGelfTransporter } = require('winston-gelf-transporter')
const LokiTransport = require('winston-loki')
const { isProduction, serviceProvider } = require('../config')
@@ -8,6 +9,15 @@ const { colorize, combine, timestamp, printf } = winston.format
const transports = [new winston.transports.Console()]
if (serviceProvider !== 'fd') {
transports.push(
new LokiTransport({
host: 'http://loki-svc.toska-lokki.svc.cluster.local:3100',
labels: { app: 'oodikone', environment: process.env.NODE_ENV ?? 'production' },
})
)
}
if (isProduction && serviceProvider !== 'fd') {
transports.push(
new WinstonGelfTransporter({
+1 -1
View File
@@ -39,7 +39,7 @@ COPY ./frontend/package*.json .
RUN npm ci --include=dev
COPY ./frontend .
RUN npm run build && npm install -g serve@12.0.0
RUN npm run build && npm install -g serve@^14
EXPOSE 5000
CMD ["serve", "--single", "--listen", "5000", "build"]
+362 -331
View File
File diff suppressed because it is too large Load Diff
+16 -17
View File
@@ -11,20 +11,19 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.4.7",
"@mui/material": "^6.4.7",
"@mui/x-date-pickers": "^7.27.3",
"@mui/icons-material": "^6.4.12",
"@mui/material": "^6.4.12",
"@mui/x-date-pickers": "^7.29.4",
"@oodikone/shared": "file:../shared",
"@reduxjs/toolkit": "^2.6.1",
"@sentry/browser": "^8.55.0",
"axios": "^1.8.2",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/browser": "^9.28.1",
"axios": "^1.9.0",
"highcharts": "^11.4.8",
"immer": "^10.1.1",
"lodash": "^4.17.21",
"material-react-table": "^3.2.1",
"moment": "^2.30.1",
"prop-types": "^15.8.1",
"query-string": "^9.1.1",
"react": "^18.3.1",
"react-datetime": "^3.3.1",
"react-dom": "^18.3.1",
@@ -32,8 +31,8 @@
"react-markdown": "^9.1.0",
"react-range": "^1.10.0",
"react-redux": "^9.2.0",
"react-router": "^7.3.0",
"scheduler": "^0.25.0",
"react-router": "^7.6.2",
"scheduler": "^0.26.0",
"semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^3.0.0-beta.2",
"unfuck-spa-shibboleth-session": "^4.1.0",
@@ -41,14 +40,14 @@
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
},
"devDependencies": {
"@types/lodash": "^4.17.16",
"@types/node": "^22.13.10",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-highcharts": "^16.0.6",
"@vitejs/plugin-react-swc": "^3.8.0",
"typescript": "^5.8.2",
"vite": "^6.2.1"
"@types/lodash": "^4.17.17",
"@types/node": "^22.15.31",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@types/react-highcharts": "^16.0.7",
"@vitejs/plugin-react-swc": "^3.10.2",
"typescript": "^5.8.3",
"vite": "^6.3.5"
},
"overrides": {
"react-highcharts": {
@@ -4,6 +4,7 @@ import axios from 'axios'
import { showAsUserKey } from '@/common'
import { apiBasePath, isDev } from '@/conf'
import { formatToArray } from '@oodikone/shared/util'
const getHeaders = () => {
// Set up dev user for development environment, mimicking production admin user
@@ -128,6 +129,16 @@ export const RTKApi = createApi({
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
return headers
},
paramsSerializer: params => {
const searchParams = new URLSearchParams()
Object.entries(params).map(([key, val]) => {
const subfix = Array.isArray(val) ? '[]' : ''
formatToArray(val).forEach(item => searchParams.append(key + subfix, item))
})
return searchParams.toString()
},
}),
endpoints: () => ({}),
})
@@ -1,7 +1,8 @@
import { CssBaseline } from '@mui/material'
import { ThemeProvider } from '@mui/material/styles'
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import CssBaseline from '@mui/material/CssBaseline'
import ThemeProvider from '@mui/material/styles/ThemeProvider'
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment/AdapterMoment'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider/LocalizationProvider'
import * as Sentry from '@sentry/browser'
import HighCharts from 'highcharts'
import moment from 'moment'
@@ -1,4 +1,3 @@
import qs from 'query-string'
import { useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router'
import { Form, Header, Input, Segment } from 'semantic-ui-react'
@@ -32,6 +31,7 @@ import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourse
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
import { useGetSingleCourseStatsQuery } from '@/redux/singleCourseStats'
import { parseQueryParams } from '@/util/queryparams'
import { CoursePopulationCreditGainTable } from './CoursePopulationCreditGainTable'
import { CoursePopulationGradeDist } from './CoursePopulationGradeDist'
import { CoursePopulationLanguageDist } from './CoursePopulationLanguageDist'
@@ -42,7 +42,7 @@ export const CoursePopulation = () => {
const [codes, setCodes] = useState([])
useTitle('Course population')
const { coursecodes, from, to, separate, unifyCourses, years, years2, combineSubstitutions } = qs.parse(
const { coursecodes, from, to, separate, unifyCourses, years, years2, combineSubstitutions } = parseQueryParams(
location.search
)
@@ -1,5 +1,4 @@
import moment from 'moment'
import qs from 'query-string'
import { useEffect, useState } from 'react'
import Datetime from 'react-datetime'
import { useLocation, useNavigate } from 'react-router'
@@ -11,6 +10,7 @@ import {
useDeleteOpenUniCourseSearchMutation,
useUpdateOpenUniCourseSearchMutation,
} from '@/redux/openUniPopulations'
import { parseQueryParams, queryParamsToString } from '@/util/queryparams'
import { formatToArray } from '@oodikone/shared/util'
export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
@@ -53,13 +53,13 @@ export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
const pushQueryToUrl = query => {
setTimeout(() => {
const searchString = qs.stringify(query)
const searchString = queryParamsToString(query)
navigate({ search: searchString }, { replace: true })
}, 0)
}
const parseQueryFromUrl = () => {
const { courseCode, startdate, enddate } = qs.parse(location.search)
const { courseCode, startdate, enddate } = parseQueryParams(location.search)
const courseCodes = formatToArray(courseCode)
const query = {
courseList: courseCodes,
@@ -1,35 +0,0 @@
import { Popup, Button, Icon } from 'semantic-ui-react'
export const FilterToggle = ({ active, applyFilter, clearFilter, popupContent, disabled }) => {
const toggleFilter = () => {
if (active) {
clearFilter()
} else {
applyFilter()
}
}
const Toggle = () => (
<Button basic={!active} disabled={disabled} icon onClick={toggleFilter} primary={active} size="mini">
<Icon name="filter" />
</Button>
)
// Trigger must be wrapped in <div> for the tooltip to work.
// See https://stackoverflow.com/questions/63611315/semantics-popup-does-not-show-up-when-passing-a-custom-component-as-trigger
return !disabled && popupContent ? (
<Popup
content={popupContent}
size="mini"
trigger={
<div>
<Toggle />
</div>
}
/>
) : (
<div style={{ cursor: disabled ? 'not-allowed' : 'default' }}>
<Toggle />
</div>
)
}
@@ -0,0 +1,16 @@
import { Popup, Button, Icon } from 'semantic-ui-react'
export const FilterToggle = ({ active, applyFilter, clearFilter, popupContent, disabled }) => {
const toggleFilter = () => (active ? clearFilter() : applyFilter())
const Toggle = (
<Button basic={!active} disabled={disabled} icon onClick={toggleFilter} primary={active} size="mini">
<Icon name="filter" />
</Button>
)
return !disabled && popupContent ? (
<Popup content={popupContent} size="mini" trigger={Toggle} />
) : (
<div style={{ cursor: disabled ? 'not-allowed' : 'default' }}>{Toggle}</div>
)
}
@@ -1,15 +1,22 @@
import { useContext } from 'react'
import { Button, Header, Segment } from 'semantic-ui-react'
import { Student } from '.'
import { FilterViewContext } from './context'
import type { FilterContext } from './context'
import { FilterCard } from './filters/common/FilterCard'
import { FilterViewContext } from './FilterViewContext'
export type FilterTrayProps = {
options: FilterContext['options']
onOptionsChange: (options) => void
students: Student[]
}
export const FilterTray = () => {
const {
filteredStudents,
allStudents,
filters,
withoutFilter,
setFilterOptions,
resetFilter,
resetFilters,
@@ -18,19 +25,18 @@ export const FilterTray = () => {
} = useContext(FilterViewContext)
const haveOptionsBeenChanged = filters.some(({ key }) => areOptionsDirty(key))
const filterSet = filters
.sort((a, b) => (a.title ?? a.key).localeCompare(b.title ?? b.key))
.sort(({ title: a }, { title: b }) => a.localeCompare(b))
.map(filter => {
const { key, render } = filter
const ctx = getContextByKey(key)
const props = {
const props: FilterTrayProps = {
options: ctx.options,
onOptionsChange: options => {
setFilterOptions(key, options)
},
withoutSelf: () => withoutFilter(key),
students: allStudents.slice(), // Copy instead of move
}
return (
@@ -1,23 +0,0 @@
import { createContext } from 'react'
const defaultState = {
viewName: '<Unset>',
allStudents: [],
filteredStudents: [],
getContextByKey: () => ({
students: [],
precomputed: null,
options: null,
args: null,
}),
filterOptions: {},
filters: [],
precomputed: {},
withoutFilter: () => [],
setFilterOptions: () => {},
resetFilter: () => {},
resetFilters: () => {},
}
export const FilterViewContext = createContext(defaultState)
FilterViewContext.displayName = 'FilterView'
@@ -0,0 +1,47 @@
import { createContext } from 'react'
import type { Student } from '.'
import type { Filter } from './filters/createFilter'
export type FilterContext = {
students: Student[]
precomputed: any // can be null
options: Record<string, any>
args: any // can be null
}
export const getDefaultFilterContext = () => ({ ...defaultFilterContext })
const defaultFilterContext: FilterContext = {
students: [],
precomputed: null,
options: {},
args: null,
}
export type FilterViewContextState = {
viewName: string
allStudents: Student[]
filters: Filter[]
filteredStudents: Student[]
getContextByKey: (key: string) => FilterContext
/** Set filter options */
setFilterOptions: (filter: string, options: any) => void
resetFilter: (filter: string) => void
resetFilters: () => void
areOptionsDirty: (key: string) => boolean
}
const defaultState: FilterViewContextState = {
viewName: '<Unset>',
allStudents: [],
filters: [],
filteredStudents: [],
getContextByKey: () => getDefaultFilterContext(),
setFilterOptions: () => {},
resetFilter: () => {},
resetFilters: () => {},
areOptionsDirty: () => false,
}
export const FilterViewContext = createContext<FilterViewContextState>(defaultState)
FilterViewContext.displayName = 'FilterView'
@@ -10,14 +10,6 @@ export const filter = code => value => student => {
const programmeStudyRights = findAllStudyRightsForProgramme(student, code)
const fixedValue = value !== 'Valintakoe' ? value : 'Koepisteet'
if (programmeStudyRights.length === 0) return false
if (programmeStudyRights.length === 1) {
return value === null || value === 'Ei valintatapaa'
? !programmeStudyRights[0].admissionType
: programmeStudyRights[0].admissionType === fixedValue
}
return programmeStudyRights.some(
studyRight =>
!studyRight.cancelled &&
@@ -27,17 +19,18 @@ export const filter = code => value => student => {
)
}
const AdmissionTypeFilterCard = ({ options, onOptionsChange, withoutSelf, code }) => {
const AdmissionTypeFilterCard = ({ options, onOptionsChange, students, code }) => {
const { selected } = options
const name = 'admissionTypeFilter'
const count = admissionType => withoutSelf().filter(filter(code)(admissionType)).length
const count = admissionType => students.filter(filter(code)(admissionType)).length
const dropdownOptions = Object.entries(ADMISSION_TYPES)
.filter(([_, admissionType]) => !!admissionType)
.map(([key, admissionType]) => {
const value = admissionType || 'Ei valintatapaa'
const value = admissionType ?? 'Ei valintatapaa'
const numberOfStudents = count(admissionType)
if (numberOfStudents === 0) return null
return {
key,
text: `${value} (${numberOfStudents})`,
@@ -45,7 +38,7 @@ const AdmissionTypeFilterCard = ({ options, onOptionsChange, withoutSelf, code }
numberOfStudents,
}
})
.filter(a => a !== null)
.filter(a => a.numberOfStudents)
.sort((a, b) => b.numberOfStudents - a.numberOfStudents)
return (
@@ -80,7 +73,9 @@ export const admissionTypeFilter = createFilter({
isActive: ({ selected }) => !!selected,
filter(student, { selected }, { args }) {
filter(student, { args, options }) {
const { selected } = options
return filter(args.programme)(selected)(student)
},
@@ -48,18 +48,11 @@ export const ageFilter = createFilter({
}
},
filter: (student, { min, max }) => {
filter: (student, { options }) => {
const { min, max } = options
const age = getAge(student.birthdate)
if (min !== null && min > age) {
return false
}
if (max !== null && max < age) {
return false
}
return true
return !(min !== null && min > age) && !(max !== null && max < age)
},
render: (props, { precomputed }) => <AgeFilterCard {...props} bounds={precomputed} />,
@@ -5,31 +5,27 @@ import { filterToolTips } from '@/common/InfoToolTips'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { createFilter } from './createFilter'
const CitizenshipFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
const CitizenshipFilterCard = ({ options, onOptionsChange, students }) => {
const { getTextIn } = useLanguage()
const { selected } = options
const dropdownOptions = withoutSelf().reduce((options, student) => {
for (const citizenship of student.citizenships) {
const countryName = citizenship.en
if (options.some(option => option.value === countryName)) {
continue
}
const count = withoutSelf().filter(student =>
student.citizenships.some(citizenship => citizenship.en === countryName)
).length
const country = getTextIn(citizenship)
const uniqueContryNames = new Map<string, string>(
students.flatMap(({ citizenships }) => citizenships).map(citizenship => [citizenship.en, getTextIn(citizenship)])
)
options.push({
key: countryName,
value: countryName,
text: `${country} (${count})`,
count,
})
}
const dropdownOptions = [] as any[]
for (const [countryName, country] of uniqueContryNames.entries()) {
const count = students.filter(({ citizenships: otherCitizenships }) =>
otherCitizenships.some(({ en: otherCountryName }) => otherCountryName === countryName)
).length
return options
}, [])
dropdownOptions.push({
key: countryName,
value: countryName,
text: `${country} (${count})`,
count,
})
}
const sortedDropdownOptions = orderBy(dropdownOptions, ['count', 'text'], ['desc', 'asc'])
@@ -65,9 +61,11 @@ export const citizenshipFilter = createFilter({
isActive: ({ selected }) => selected !== '',
filter(student, { selected }) {
return student.citizenships.some(citizenship => citizenship.en === selected)
filter(student, { options }) {
const { selected } = options
return student.citizenships.some(({ en: countryName }) => countryName === selected)
},
component: CitizenshipFilterCard,
render: CitizenshipFilterCard,
})
@@ -1,39 +1,38 @@
import { isEqual } from 'lodash'
import { useState, useRef } from 'react'
import { FC, useState, useRef } from 'react'
import { Icon, Header } from 'semantic-ui-react'
import { HoverableHelpPopup } from '@/components/common/HoverableHelpPopup'
import './FilterCard.css'
const useChange = value => {
const prevValue = useRef()
if (prevValue.current === undefined) {
prevValue.current = value
return true
}
const change = !isEqual(value, prevValue.current)
import { FilterContext, FilterViewContextState } from '../../context'
import type { Filter } from '../createFilter'
const useChange = (value: FilterContext['options']) => {
const prevValue = useRef<typeof value>()
prevValue.current = value
return change
return !isEqual(value, prevValue.current)
}
export const FilterCard = ({ filter, options, children, onClear }) => {
const title = filter.title ?? filter.key
const active = filter.isActive(options)
const { info, key } = filter
export const FilterCard: FC<{
filter: Filter
options: FilterContext['options']
children: ReturnType<Filter['render']>
onClear: () => ReturnType<FilterViewContextState['resetFilter']>
}> = ({ filter, options, children, onClear }) => {
const { info, key, title, isActive } = filter
const active = isActive(options)
const hasChanged = useChange(options)
const [manuallyOpened, setManuallyOpened] = useState(null)
const [manuallyOpened, setManuallyOpened] = useState<boolean | null>(null)
if (hasChanged && manuallyOpened === false) {
setManuallyOpened(null)
}
const open = manuallyOpened !== null ? manuallyOpened : active
const open = manuallyOpened ?? active
return (
<div data-cy={`${key}-filter-card`} data-open={open} style={{ margin: '1rem 0' }}>
@@ -1,73 +0,0 @@
import { Button, Dropdown, Icon, Label, Popup } from 'semantic-ui-react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { FilterType } from './filterType'
const filterTexts = {
[FilterType.ALL]: { label: 'All' },
[FilterType.PASSED]: { label: 'Passed' },
[FilterType.FAILED]: { label: 'Failed' },
[FilterType.ENROLLED_NO_GRADE]: { label: 'Enrolled, No Grade' },
}
// a bandaid solution to prevent oodikone crashing
const translate = {
ALL: 'all',
PASSED: 'passed',
FAILED: 'failed',
ENROLLED_NO_GRADE: 'enrolledNoGrade',
}
export const CourseCard = ({ course, filterType, onChange }) => {
const { getTextIn } = useLanguage()
const name = 'courseFilter'
const onClick = (_, { value }) => onChange(value)
const clear = () => {
onChange(null)
}
return (
<Label style={{ marginTop: '0.5rem' }}>
{getTextIn(course?.course?.name)}
<Dropdown
button
className="mini"
data-cy={`${name}-${course?.course?.code}-dropdown`}
fluid
style={{ marginTop: '0.5rem' }}
text={filterTexts[filterType].label}
value={filterType}
>
<Dropdown.Menu>
{Object.entries(filterTexts).map(([type, { label, info }]) => {
if (info) {
return (
<Popup
basic
content={info}
key={label}
trigger={<Dropdown.Item onClick={onClick} text={label} value={type} />}
/>
)
}
if (course?.students[translate[type]] && Object.keys(course?.students[translate[type]]).length === 0)
return <Dropdown.Item disabled key={label} onClick={onClick} text={label} value={type} />
return <Dropdown.Item key={label} onClick={onClick} text={label} value={type} />
})}
</Dropdown.Menu>
</Dropdown>
<Button
compact
data-cy={`${name}-${course?.course?.code}-clear`}
icon
onClick={clear}
size="tiny"
style={{ marginTop: '0.5rem' }}
>
<Icon name="close" />
</Button>
</Label>
)
}
@@ -0,0 +1,79 @@
import { FC } from 'react'
import { Button, Dropdown, Icon, Label } from 'semantic-ui-react'
import type { DropdownProps } from 'semantic-ui-react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { FilterType } from './filterType'
const filterTexts = {
[FilterType.ALL]: {
key: 'all',
label: 'All',
},
[FilterType.PASSED]: {
key: 'passed',
label: 'Passed',
},
[FilterType.FAILED]: {
key: 'failed',
label: 'Failed',
},
[FilterType.ENROLLED_NO_GRADE]: {
key: 'enrolledNoGrade',
label: 'Enrolled, No Grade',
},
}
export const CourseCard: FC<{
course: any
filterType: keyof typeof FilterType
onChange: (type) => any
}> = ({ course, filterType, onChange }) => {
const { getTextIn } = useLanguage()
const name = 'courseFilter'
const onClick: NonNullable<DropdownProps['Item']['onClick']> = (_, { value }) => onChange(value)
const clear = () => {
onChange(null)
}
return (
<Label style={{ marginTop: '0.5rem' }}>
{getTextIn(course?.course?.name)}
<Dropdown
button
className="mini"
data-cy={`${name}-${course?.course?.code}-dropdown`}
fluid
style={{ marginTop: '0.5rem' }}
text={filterTexts[filterType].label}
value={filterType}
>
<Dropdown.Menu>
{Object.entries(filterTexts).map(([type, { key, label }]) => (
<Dropdown.Item
disabled={!Object.keys(course?.students[key] ?? {}).length}
key={label}
onClick={onClick}
text={label}
value={type}
/>
))}
</Dropdown.Menu>
</Dropdown>
<Button
compact
data-cy={`${name}-${course?.course?.code}-clear`}
icon
onClick={clear}
size="tiny"
style={{ marginTop: '0.5rem' }}
>
<Icon name="close" />
</Button>
</Label>
)
}
@@ -3,4 +3,4 @@ export const FilterType = {
PASSED: 'PASSED',
FAILED: 'FAILED',
ENROLLED_NO_GRADE: 'ENROLLED_NO_GRADE',
}
} as const
@@ -1,29 +1,38 @@
import { produce } from 'immer'
import { keyBy } from 'lodash'
import { Dropdown } from 'semantic-ui-react'
import { FC } from 'react'
import { Dropdown, type DropdownProps } from 'semantic-ui-react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import type { FilterContext } from '../../context'
import type { FilterTrayProps } from '../../FilterTray'
import { createFilter } from '../createFilter'
import { CourseCard } from './CourseCard'
import { FilterType } from './filterType'
const CourseFilterCard = ({ courseStats, options, onOptionsChange }) => {
const { courseFilters } = options
type CourseStats = Record<string, any>
const CourseFilterCard: FC<{
courseStats: CourseStats
options: FilterTrayProps['options']
onOptionsChange: FilterContext['precomputed']
}> = ({ courseStats, options, onOptionsChange }) => {
const { courseFilters } = options ?? {}
const { getTextIn } = useLanguage()
const name = 'course-filter-card'
// Wrestle course stats into something semantic-ui eats without throwing up.
const makeLabel = cs => `${cs.course.code} - ${getTextIn(cs.course.name)}`
const dropdownOptions = Object.values(courseStats)
.filter(cs => !courseFilters[cs.course.code])
.sort((a, b) => makeLabel(a).localeCompare(makeLabel(b)))
.sort((a, b) => a.course.code.localeCompare(b.course.code))
.map(cs => ({
key: `course-filter-option-${cs.course.code}`,
text: makeLabel(cs),
text: `${cs.course.code} - ${getTextIn(cs.course.name)}`,
value: cs.course.code,
}))
const setCourseFilter = (code, type) =>
onOptionsChange(
produce(options, draft => {
produce(options ?? {}, draft => {
if (type === null) {
delete draft.courseFilters[code]
} else {
@@ -32,8 +41,8 @@ const CourseFilterCard = ({ courseStats, options, onOptionsChange }) => {
})
)
const onChange = (_, { value }) => {
setCourseFilter(value[0], FilterType.ALL)
const onChange: NonNullable<DropdownProps['onChange']> = (_, { value }) => {
setCourseFilter(value?.[0], FilterType.ALL)
}
return (
@@ -89,11 +98,12 @@ export const courseFilter = createFilter({
isActive: ({ courseFilters }) => Object.entries(courseFilters).length > 0,
filter(student, { courseFilters }, { precomputed: courseMap }) {
filter(student, { precomputed: courseMap, options }) {
const { courseFilters = {} } = options ?? { courseFilters: null }
return Object.entries(courseFilters).reduce((result, [code, filterType]) => {
const stats = courseMap[code]
if (!stats) return false
return result && filterFunctions[filterType](student, stats)
return !!stats && result && filterFunctions[filterType as string](student, stats)
}, true)
},
@@ -1,147 +0,0 @@
import { produce } from 'immer'
import { mapValues } from 'lodash'
import { setFilterOptions } from '@/redux/filters'
export const createFilter = options => {
const Component = options.component
const selectorFuncs = {
...(options.selectors ?? {}),
selectOptions: opts => opts,
isActive: opts => options.isActive(opts),
}
const actionFuncs = {
...(options.actions ?? {}),
setOptions: (_options, value) => value,
reset: () => null,
}
/**
* Selectors are wrapped redux selectors that act on the filter's options.
*/
const selectors = mapValues(selectorFuncs, selector => {
if (selector.length === 1) {
const wrapper = options => selector(options)
wrapper.filter = options.key
return wrapper
}
return (...args) => {
const wrapper = options => selector(options, ...args)
wrapper.filter = options.key
return wrapper
}
})
/**
* Actions are wrapped redux actions that act on the filter's options.
*
* Note that these actions cannot be dispatched using the vanilla redux
* dispatch function. You need to use the dipatch function obtained form
* the useFilterDispatch hook.
*/
const actions = mapValues(actionFuncs, (action, name) => payload => (view, getContext) => {
const ctx = getContext(options.key)
return setFilterOptions({
view,
filter: options.key,
action: `${options.key}/${name}`,
options: produce(ctx.options, draft => action(draft, payload, ctx)),
})
})
const factory = args => ({
args,
/**
* Non-user-visible key for differentiating the filter.
*/
key: options.key,
/**
* User-visible label for the filter.
*
* Defaults to the filter key.
*/
title: options.title ?? options.key,
/**
* Information tooltip displayed on hover.
*/
info: options.info,
/**
* Default options with which the filter is initialized.
*/
defaultOptions: options.defaultOptions,
/**
* Returns whether the filter is active or not, based on the current options.
*
* The filter is evaluated over the student list only when this function
* returns true. Activity of the filter is also reflected in the user
* interface.
*/
isActive: options.isActive,
/**
* Evaluated for each student in the population.
* Returns true if the student passes the filter.
*
* Parameters:
* 1. student - The student being evaluated.
* 2. options - Current options of the filter.
* 3. precomputed - Value returned by the precomputetion function of
* this filter, if one was defined.
*/
filter: options.filter,
/**
* The precompute function is executes _once_ every time the student list
* changes. It's return value is passed to the filter and render functions.
*
* This can be used to, for example, generate lookup-tables from the student
* data for use in the filtering.
*/
precompute: options.precompute,
/**
* The render function renders the UI for this filter displayed in the
* filter tray.
*
* For the simplest use cases, a component property can be provided, for
* which a simple default render funciton is genereated.
*
* Parameters:
* 1. props - Props to be passed to the component.
* props.options - Current options of the filter.
* props.onOptionsChange - Callback used to update the options.
* props.wihtoutSelf - Function used to evaluate the current filter-set
* _without_ this filter.
* 2. precomputed - The value passed from the precompute function.
*/
render: options.render ? options.render : props => <Component {...props} />,
/**
* Filters' priority determines the order in which filters are evaluated.
*
* In the normal case, this should not matter. But especially filters which
* modify the student data (ikr - sounds bad), this is important.
*
* The filters with lower priority are executed first.
*/
priority: options.priority ?? 0,
actions,
selectors,
})
factory.key = options.key
factory.actions = actions
factory.selectors = selectors
return factory
}
@@ -0,0 +1,246 @@
import { produce } from 'immer'
import { ReactNode } from 'react'
import { setFilterOptions } from '@/redux/filters'
import { mapValues } from '@oodikone/shared/util'
import { Student } from '..'
import type { FilterContext, FilterViewContextState } from '../context'
import type { FilterTrayProps } from '../FilterTray'
/** TODO: Find acual types */
type FilterOptions = {
/**
* Non-user visible (unique) identifier for the filter.
*/
key: string
/**
* User visible identifier. Defaults to the key value.
*/
title?: string
/**
* User visible tooltip.
*/
info?: string
/**
* Default options with which the filter is initialized.
*
* Fallback for `resolveFilterOptions` if values are not stored
* and initial values are not set.
*/
defaultOptions: Record<any, any>
/**
* Function used to filter the students.
*/
filter: (students: Student, ctx: FilterContext) => boolean
isActive: (opts: FilterContext['options']) => boolean
/**
* Redux selectors.
* `selectOptions` and `isActive` will be overwriten.
*/
selectors?: Record<string, (options: any, ...args: any[]) => any>
/**
* By default `setOptions` and `reset` are assigned.
* NOTE: `reset` will set the value to null, this may not be desired!
*/
actions?: Record<string, (options: FilterContext['options'], payload: any) => void>
/**
* Precompute filter;
* This value is used instead of running the filter again for the population.
*/
precompute?: (ctx: FilterContext) => any
/**
* Used to determine sort order.
*/
priority?: number
/**
* Filter tray render component.
*/
render: (props: FilterTrayProps, ctx: FilterContext) => ReactNode
}
export type Filter = {
args?: any
key: FilterOptions['key']
title: NonNullable<FilterOptions['title'] | FilterOptions['key']>
info: FilterOptions['info']
defaultOptions: FilterOptions['defaultOptions']
isActive: FilterOptions['isActive']
filter: FilterOptions['filter']
precompute: FilterOptions['precompute']
render: FilterOptions['render']
priority: number
}
export type FilterFactory = {
key: Filter['key']
actions: Record<
string,
(payload: any) => (
view: string,
getContext: FilterViewContextState['getContextByKey']
) => {
payload: any
type: string
}
>
selectors: Record<
string,
(...args: any[]) => {
(opts: any): any
filter: string
}
>
(args?: any): Filter
}
/**
* Unlike the name suggests, this function returns a filter factory.
*/
export const createFilter = (options: FilterOptions): FilterFactory => {
const opt_selectors = options.selectors ?? {}
const opt_actions = options.actions ?? {}
/**
* Selectors are wrapped redux selectors that act on the filter's options.
*/
const selectors = mapValues(
{
...opt_selectors,
selectOptions: (opts, _) => opts,
isActive: options.isActive,
},
([key, selector]) => {
const gift = (...args) => {
const wrapper = opts => selector(opts, args)
wrapper.filter = options.key
return wrapper
}
return [key, gift]
}
)
/**
* Actions are wrapped redux actions that act on the filter's options.
*
* Note that these actions cannot be dispatched using the vanilla redux
* dispatch function. You need to use the dipatch function obtained form
* the useFilterDispatch hook.
*/
const actions = mapValues(
{
setOptions: (_, value) => value,
reset: (..._) => null,
...opt_actions,
},
([key, action]) => {
return [
key,
payload => (view: string, getContext: FilterViewContextState['getContextByKey']) => {
const ctx = getContext(options.key)
return setFilterOptions({
view,
filter: options.key,
action: `${options.key}/${key}`,
options: produce(ctx.options, (draft: FilterContext['options']) => action(draft, payload)),
})
},
]
}
)
/**
* `filter`
* Evaluated for each student in the population.
* Returns true if the student passes the filter.
*
* Parameters:
* 1. student - The student being evaluated.
* 2. options - Current options of the filter.
* 3. precomputed - Value returned by the precomputetion function of
* this filter, if one was defined.
*
* ---
*
* `render`
* The render function renders the UI for this filter displayed in the
* filter tray.
*
* For the simplest use cases, a component property can be provided, for
* which a simple default render funciton is genereated.
*
* Parameters:
* 1. props - Props to be passed to the component.
* props.options - Current options of the filter.
* props.onOptionsChange - Callback used to update the options.
* props.wihtoutSelf - Function used to evaluate the current filter-set
* _without_ this filter.
* 2. precomputed - The value passed from the precompute function.
*
* ---
*
* `precompute`
* The precompute function is executes _once_ every time the student list
* changes. It's return value is passed to the filter and render functions.
*
* This can be used to, for example, generate lookup-tables from the student
* data for use in the filtering.
*
* `isActive`
* Returns whether the filter is active or not, based on the current options.
* The filter is evaluated over the student list only when this function returns true.
* Activity of the filter is also reflected in the user interface.
*
* ---
*
* `priority`
* Filters' priority determines the order in which filters are evaluated.
*
* In the normal case, this should not matter. But especially filters which
* modify the student data (ikr - sounds bad), this is important.
*
* The filters with lower priority are executed first.
*
* @dogamak
*/
const factory = (args?: any): Filter => ({
args,
key: options.key,
title: options.title ?? options.key,
info: options.info,
defaultOptions: options.defaultOptions,
isActive: options.isActive,
filter: options.filter,
precompute: options.precompute,
render: options.render,
priority: options.priority ?? 0,
})
factory.key = options.key
factory.actions = actions
factory.selectors = selectors
return factory
}
@@ -46,7 +46,7 @@ export const creditsEarnedFilter = createFilter({
isActive: ({ min, max }) => min !== null || max !== null,
precompute: ({ students }) => {
const credits = students.map(getStudentTotalCredits).filter(n => !Number.isNaN(n))
const credits = students.map(student => getStudentTotalCredits(student)).filter(n => !Number.isNaN(n))
return {
max: max(credits),
@@ -54,18 +54,10 @@ export const creditsEarnedFilter = createFilter({
}
},
filter(student, { min, max }) {
const { credits } = student
filter({ credits }, { options }) {
const { min, max } = options
if (min !== null && credits < min) {
return false
}
if (max !== null && credits > max) {
return false
}
return true
return !(min !== null && credits < min) && !(max !== null && credits > max)
},
render: (props, { precomputed }) => <CreditsEarnedFilterCard {...props} bounds={precomputed} />,
@@ -3,19 +3,17 @@ import { Form, Dropdown } from 'semantic-ui-react'
import { createFilter } from './createFilter'
const CurriculumPeriodFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
const CurriculumPeriodFilterCard = ({ options, onOptionsChange, students }) => {
const { selected } = options
const dropdownOptions = withoutSelf().reduce((curriculumPeriods, student) => {
const curriculumPeriod = student.curriculumVersion
const dropdownOptions = students.reduce((curriculumPeriods, { curriculumPeriod }) => {
if (curriculumPeriods.every(option => option.value !== curriculumPeriod)) {
const count = withoutSelf().filter(student => student.curriculumVersion === curriculumPeriod).length
const count = students.filter(({ curriculumVersion }) => curriculumVersion === curriculumPeriod).length
curriculumPeriods.push({
key: curriculumPeriod,
value: curriculumPeriod,
text: `${curriculumPeriod || 'No period selected'} (${count})`,
text: `${curriculumPeriod ?? 'No period selected'} (${count})`,
count,
})
}
@@ -53,11 +51,13 @@ export const curriculumPeriodFilter = createFilter({
selected: '',
},
isActive: ({ selected }) => selected !== '',
isActive: ({ selected }) => !!selected,
filter(student, { selected }) {
return selected === student.curriculumVersion
filter({ curriculumVersion }, { options }) {
const { selected } = options
return selected === curriculumVersion
},
component: CurriculumPeriodFilterCard,
render: CurriculumPeriodFilterCard,
})
@@ -44,18 +44,15 @@ export const creditDateFilter = createFilter({
isActive: ({ startDate, endDate }) => startDate !== null || endDate !== null,
filter(student, { startDate, endDate }) {
student.courses = student.courses.filter(course => {
if (startDate && startDate.isAfter(course.date, 'day')) {
return false
}
/**
* startDate and endDate are both moment.Moment
*/
filter(student, { options }) {
const { startDate, endDate } = options
if (endDate && endDate.isBefore(course.date, 'day')) {
return false
}
return true
})
student.courses = student.courses.filter(
course => !startDate?.isAfter(course.date, 'day') && !endDate?.isBefore(course.date, 'day')
)
return true
},
@@ -64,5 +61,5 @@ export const creditDateFilter = createFilter({
selectedStartDate: ({ startDate }, _) => startDate,
},
component: CreditDateFilterCard,
render: CreditDateFilterCard,
})
@@ -1,3 +1,4 @@
import { FC } from 'react'
import { Dropdown, Form } from 'semantic-ui-react'
import { filterToolTips } from '@/common/InfoToolTips'
@@ -10,16 +11,20 @@ const STATUS_OPTIONS = [
{ key: 'enrl-status-inactive', text: 'Passive', value: 3 },
]
const EnrollmentStatusFilterCard = ({ options, onOptionsChange, allSemesters }) => {
const EnrollmentStatusFilterCard: FC<{
options: any
onOptionsChange: any
allSemesters: Record<string, any>
}> = ({ options, onOptionsChange, allSemesters }) => {
const name = 'enrollmentStatusFilter'
const { getTextIn } = useLanguage()
const { semesters, status } = options
if (!Object.keys(allSemesters).length) {
return null
}
const { semesters, status } = options
const semesterOptions = Object.values(allSemesters)
.filter(semester => new Date(semester.startdate) <= new Date())
.sort((a, b) => b.semestercode - a.semestercode)
@@ -88,29 +93,29 @@ export const enrollmentStatusFilter = createFilter({
isActive: ({ status }) => status !== null,
filter(student, { status, semesters }, { args }) {
filter({ studyRights }, { args, options }) {
const { status, semesters } = options
if (args.programme) {
const correctStudyRight = student.studyRights.find(studyRight =>
studyRight.studyRightElements.some(element => element.code === args.programme)
const correctStudyRight = studyRights.find(({ studyRightElements }) =>
studyRightElements.some(({ code }) => code === args.programme)
)
return semesters.every(
semester =>
correctStudyRight.semesterEnrollments.find(
({ semester: enrollmentSemester }) => enrollmentSemester === semester
)?.type === status
)
return correctStudyRight
? semesters.every(semester => {
const enrollment = correctStudyRight.semesterEnrollments.find(
enrollment => enrollment.semester === semester
)
return enrollment ? enrollment.type === status : false
})
: false
}
const allEnrollments = student.studyRights
.flatMap(studyRight => studyRight.semesterEnrollments)
.filter(enrollment => enrollment != null)
const allEnrollments = studyRights.flatMap(studyRight => studyRight.semesterEnrollments).filter(Boolean)
return semesters.every(semester => {
const enrollments = allEnrollments.filter(enrl => enrl.semester === semester)
// If enrollment info not found, return false. This may or may not be what we want?
return enrollments.length ? enrollments.some(enrl => enrl.type === status) : false
// HACK: If enrollment info not found, return false. This may or may not be what we want?
return allEnrollments
.filter(({ semester: enrollmentSemester }) => enrollmentSemester === semester)
.some(({ type }) => type === status)
})
},
@@ -1,4 +1,5 @@
import { Form, Dropdown } from 'semantic-ui-react'
import { createFilter } from './createFilter'
const GENDERS = {
@@ -8,15 +9,15 @@ const GENDERS = {
unknown: { label: 'Unknown', value: 0 },
}
const GenderFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
const GenderFilterCard = ({ options, onOptionsChange, students }) => {
const { selected } = options
const count = genderCode => withoutSelf().filter(student => Number(student.gender_code) === genderCode).length
const count = (genderCode: number) => students.filter(student => Number(student.gender_code) === genderCode).length
const dropdownOptions = Object.entries(GENDERS).map(([key, gender]) => ({
const dropdownOptions = Object.entries(GENDERS).map(([key, { label, value }]) => ({
key,
text: `${gender.label} (${count(gender.value)})`,
value: gender.value,
text: `${label} (${count(value)})`,
value,
}))
return (
@@ -49,9 +50,11 @@ export const genderFilter = createFilter({
isActive: ({ selected }) => selected !== '',
filter(student, { selected }) {
return selected === Number(student.gender_code)
filter(student, { options }) {
const { selected } = options
return Number(student.gender_code) === selected
},
component: GenderFilterCard,
render: GenderFilterCard,
})
@@ -8,11 +8,13 @@ import { createFilter } from './createFilter'
* Grade filter.
* Only applicable to a single course.
*/
const GradeFilterCard = ({ options, onOptionsChange, grades, withoutSelf }) => {
const GradeFilterCard = ({ options, onOptionsChange, grades, students }) => {
const { selected } = options
const name = 'gradeFilter'
const choices = Object.keys(grades).sort((a, b) => b - a)
// There are grades 1..5 (inclusive) and 'No grade'
// Therefore this cannot be filtered by Number
const choices = Object.keys(grades).sort((a, b) => +b - +a)
const checked = grade => selected.includes(grade)
@@ -30,11 +32,8 @@ const GradeFilterCard = ({ options, onOptionsChange, grades, withoutSelf }) => {
}
}
const studentsWithoutSelf = withoutSelf()
const gradesWithoutSelf = fp.mapValues(
fp.filter(
studentNumber => studentsWithoutSelf.find(student => student.studentNumber === studentNumber) !== undefined
)
fp.filter(studentNumber => students.some(student => student.studentNumber === studentNumber))
)(grades)
return (
@@ -70,10 +69,10 @@ export const gradeFilter = createFilter({
precompute: ({ students, args }) =>
fp.flow(
fp.map(student => [
fp.map((student: any) => [
student.studentNumber,
fp.filter(course => args.courseCodes.includes(course.course_code))(student.courses),
fp.filter(enrollment => args.courseCodes.includes(enrollment.course_code))(student.enrollments),
fp.filter((course: any) => args.courseCodes.includes(course.course_code))(student.courses),
fp.filter((enrollment: any) => args.courseCodes.includes(enrollment.course_code))(student.enrollments),
]),
/* fp.filter(
fp.flow(
@@ -92,7 +91,9 @@ export const gradeFilter = createFilter({
grades => ({ grades })
)(students),
filter(student, { selected }, { precomputed }) {
filter(student, { precomputed, options }) {
const { selected } = options
return selected.some(selectedGrade => precomputed.grades[selectedGrade].includes(student.studentNumber))
},
@@ -55,7 +55,9 @@ export const graduatedFromProgrammeFilter = createFilter({
isActive: ({ mode }) => mode !== null,
filter(student, { mode }, { args }) {
filter(student, { args, options }) {
const { mode } = options
const { code, showBachelorAndMaster } = args
const BACHELORS = 'urn:code:degree-program-type:bachelors-degree'
const MASTERS = 'urn:code:degree-program-type:masters-degree'
@@ -85,7 +87,7 @@ export const graduatedFromProgrammeFilter = createFilter({
render: (props, { args }) => (
<GraduatedFromProgrammeFilterCard
{...props}
isCombinedExtent={args.code && (args.combinedProgrammeCode || args.showBachelorAndMaster)}
isCombinedExtent={!!args.code && (!!args.combinedProgrammeCode || !!args.showBachelorAndMaster)}
isLicentiate={args.combinedProgrammeCode === 'MH90_001'}
/>
),
@@ -1,4 +1,5 @@
import moment from 'moment'
import { FC } from 'react'
import { Radio, Button, Form } from 'semantic-ui-react'
import { useFilters } from '@/components/FilterView/useFilters'
@@ -29,7 +30,11 @@ const getCutStudyStart = ({ options, filterDispatch }) => {
)
}
const HopsFilterCard = ({ options, onOptionsChange, combinedProgramme }) => {
const HopsFilterCard: FC<{
options: any
onOptionsChange: any
combinedProgramme: any
}> = ({ options, onOptionsChange, combinedProgramme }) => {
const { selectedStartDate } = creditDateFilter.selectors
const { filterDispatch, useFilterSelector } = useFilters()
const selectedCreditStartDate = useFilterSelector(selectedStartDate(''))
@@ -162,12 +167,15 @@ export const hopsFilter = createFilter({
return args.combinedProgrammeCode
},
isActive: arg => arg?.activeProgramme || arg?.activeCombinedProgramme,
isActive: arg => !!arg?.activeProgramme || !!arg?.activeCombinedProgramme,
filter: (student, { activeProgramme, activeCombinedProgramme }, { args }) => {
const studyRights = student.studyRights.filter(studyRight => !studyRight.cancelled).map(({ id }) => id)
filter: (student, { args, options }) => {
const { activeProgramme, activeCombinedProgramme } = options
const studyRights = student.studyRights.filter(({ cancelled }) => !cancelled).map(({ id }) => id)
const hops = student.studyplans.find(
plan => plan.programme_code === args.programmeCode && studyRights.includes(plan.sis_study_right_id)
({ programme_code, sis_study_right_id }) =>
programme_code === args.programmeCode && studyRights.includes(sis_study_right_id)
)
const secondHops = args.combinedProgrammeCode
? student.studyplans.find(
@@ -183,7 +191,7 @@ export const hopsFilter = createFilter({
const courses = new Set(hops && activeProgramme ? hops.included_courses : [])
const secondProgrammeCourses = new Set(secondHops && activeCombinedProgramme ? secondHops.included_courses : [])
const hopsCourses = student.courses.filter(
course => courses.has(course.course_code) || secondProgrammeCourses.has(course.course_code)
({ course_code }) => courses.has(course_code) || secondProgrammeCourses.has(course_code)
)
student.courses = [...new Set(hopsCourses)]
return true
@@ -214,7 +222,5 @@ export const hopsFilter = createFilter({
},
},
component: HopsFilterCard,
render: (props, { precomputed }) => <HopsFilterCard {...props} combinedProgramme={precomputed} />,
})
@@ -1,45 +1,49 @@
import moment from 'moment'
import { Dropdown } from 'semantic-ui-react'
import { Dropdown, type DropdownProps } from 'semantic-ui-react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { createFilter } from './createFilter'
const NO_PROGRAMME = { code: '00000', name: { en: 'No programme', fi: 'Ei ohjelmaa' } }
const ProgrammeFilterCard = ({ additionalModes, onOptionsChange, options, studentToProgrammeMap, withoutSelf }) => {
const ProgrammeFilterCard = ({ additionalModes, onOptionsChange, options, studentToProgrammeMap, students }) => {
const { getTextIn } = useLanguage()
const { selectedProgrammes } = options
const visibleProgrammes = withoutSelf().reduce((acc, student) => {
const studentsProgrammes = studentToProgrammeMap[student.studentNumber]
for (const programme of studentsProgrammes) {
const existingProgramme = acc.find(prog => prog.code === programme.code)
if (existingProgramme) {
existingProgramme.studentCount += 1
} else {
acc.push({ ...programme, studentCount: 1 })
}
}
return acc
}, [])
// TODO: retype
const visibleProgrammes: {
code
name
studentCount
}[] = []
students.forEach(student => {
studentToProgrammeMap[student.studentNumber].forEach(programme => {
const prog = visibleProgrammes.find(prog => prog.code === programme.code)
if (!prog) {
visibleProgrammes.push({ ...programme, studentCount: 1 })
} else prog.studentCount += 1
})
})
const dropdownOptions = visibleProgrammes
.map(({ code, name, studentCount }) => ({
key: `programme-filter-value-${code}`,
text: getTextIn(name),
text: getTextIn(name) ?? '',
value: code,
content: (
<>
{getTextIn(name)}{' '}
{getTextIn(name)}
<br />
<span style={{ color: '#888', whiteSpace: 'nowrap' }}>
({studentCount} student{studentCount === 1 ? '' : 's'})
({studentCount} student{studentCount !== 1 && 's'})
</span>
</>
),
}))
.sort((a, b) => a.text.localeCompare(b.text))
const handleChange = (_, { value }) => {
const handleChange: NonNullable<DropdownProps['onChange']> = (_, { value }) => {
onOptionsChange({
...options,
selectedProgrammes: value,
@@ -127,8 +131,8 @@ const ProgrammeFilterCard = ({ additionalModes, onOptionsChange, options, studen
}
const getStudentProgrammes = student =>
(student?.studyRights ?? []).flatMap(studyRight =>
studyRight.studyRightElements.map(element => ({ ...element, cancelled: studyRight.cancelled }))
(student?.studyRights ?? []).flatMap(({ studyRightElements, cancelled }) =>
studyRightElements.map(element => ({ ...element, cancelled }))
)
const createStudentToProgrammeMap = (students, studyRightPredicate) => {
@@ -168,12 +172,12 @@ export const programmeFilter = createFilter({
key: 'Programme',
defaultOptions: {
selectedProgrammes: [],
selectedProgrammes: [] as string[],
mode: 'active',
},
precompute: ({ students, options, args }) => {
let predicate = () => true
let predicate = (..._: any[]) => true
if (args?.studyRightPredicate) {
predicate = args.studyRightPredicate
@@ -200,7 +204,9 @@ export const programmeFilter = createFilter({
isActive: ({ selectedProgrammes }) => selectedProgrammes.length > 0,
filter({ studentNumber }, { selectedProgrammes }, { precomputed: studentToProgrammeMap }) {
filter({ studentNumber }, { precomputed: studentToProgrammeMap, options }) {
const { selectedProgrammes } = options
return selectedProgrammes.every(pcode => studentToProgrammeMap[studentNumber].some(({ code }) => code === pcode))
},
@@ -1,22 +1,23 @@
import fp from 'lodash/fp'
import { Form, Dropdown } from 'semantic-ui-react'
import { filterToolTips } from '@/common/InfoToolTips'
import { createFilter } from './createFilter'
const StartYearAtUniFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
const StartYearAtUniFilterCard = ({ options, onOptionsChange, students }) => {
const name = 'startYearAtUni'
const { selected } = options
const countsByYear = fp.flow(
fp.groupBy(student => new Date(student.started).getFullYear()),
fp.mapValues(students => students.length)
)(withoutSelf())
const countsByYear = {}
for (const { started } of students) {
const year = new Date(started).getFullYear()
const dropdownOptions = Object.keys(countsByYear).map(year => ({
countsByYear[year] ??= 0
countsByYear[year]++
}
const dropdownOptions = Object.entries(countsByYear).map(([year, count]) => ({
key: `year-${year}`,
text: `${year} (${countsByYear[year]})`,
text: `${year} (${count})`,
value: Number(year),
}))
@@ -57,7 +58,11 @@ export const startYearAtUniFilter = createFilter({
isActive: ({ selected }) => selected.length > 0,
filter: (student, { selected }) => selected.includes(new Date(student.started).getFullYear()),
filter: (student, { options }) => {
const { selected } = options
component: StartYearAtUniFilterCard,
return selected.includes(new Date(student.started).getFullYear())
},
render: StartYearAtUniFilterCard,
})
@@ -27,12 +27,13 @@ const EditableList = ({ value, onChange, renderLabel }) => {
scrollbarWidth: 'thin',
}}
>
{value.map((studentNumber, index) => (
{value.map((studentNumber, index: number) => (
<li
className={'asdasd'}
key={studentNumber}
style={{
alignItems: 'center',
borderTop: index > 0 && '0px solid #e6e6e7',
borderTop: index ? '1px solid #e6e6e7' : undefined,
display: 'flex',
height: '1.75em',
}}
@@ -103,9 +104,7 @@ const EditableList = ({ value, onChange, renderLabel }) => {
)
}
const StudentNumberFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
const students = withoutSelf()
const StudentNumberFilterCard = ({ options, onOptionsChange, students }) => {
const swapLists = () => {
onOptionsChange({
allowlist: options.blocklist,
@@ -169,7 +168,9 @@ export const studentNumberFilter = createFilter({
isActive: ({ allowlist, blocklist }) => allowlist.length > 0 || blocklist.length > 0,
filter(student, { allowlist, blocklist }) {
filter(student, { options }) {
const { allowlist, blocklist } = options
return (
(allowlist.length === 0 || includes(allowlist, student.studentNumber)) &&
(blocklist.length === 0 || !includes(blocklist, student.studentNumber))
@@ -84,7 +84,8 @@ export const studyRightStatusFilter = createFilter({
isActive: ({ activeProgramme, activeCombinedProgramme }) =>
activeProgramme !== null || activeCombinedProgramme !== null,
filter: (student, { activeProgramme, activeCombinedProgramme }, { args }) => {
filter: (student, { args, options }) => {
const { activeProgramme, activeCombinedProgramme } = options
const { code, currentSemester, showBachelorAndMaster } = args
if (!currentSemester) {
return true
@@ -48,7 +48,9 @@ export const studyRightTypeFilter = createFilter({
isActive: ({ mode }) => mode !== 0,
filter(student, { mode }, { args }) {
filter(student, { args, options }) {
const { mode } = options
if (mode === 0) return true
const studyRight = student.studyRights.find(studyRight =>
@@ -60,5 +62,5 @@ export const studyRightTypeFilter = createFilter({
return mode === 1 ? studyRight.extentCode === 5 : studyRight.extentCode === 2
},
component: StudyRightTypeFilterCard,
render: StudyRightTypeFilterCard,
})
@@ -1,13 +1,14 @@
import { Dropdown, Form } from 'semantic-ui-react'
import type { DropdownProps } from 'semantic-ui-react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { createFilter } from './createFilter'
const StudyTrackFilterCard = ({ code, onOptionsChange, options, withoutSelf }) => {
const StudyTrackFilterCard = ({ code, onOptionsChange, options, students }) => {
const { selected } = options
const { getTextIn } = useLanguage()
const dropdownOptions = withoutSelf()
const dropdownOptions = students
.flatMap(student => student.studyRights)
.flatMap(studyRight => studyRight.studyRightElements)
.filter(element => element.code === code && element.studyTrack !== null)
@@ -30,7 +31,7 @@ const StudyTrackFilterCard = ({ code, onOptionsChange, options, withoutSelf }) =
return acc
}, [])
const handleChange = (_, { value }) => {
const handleChange: NonNullable<DropdownProps['onChange']> = (_, { value }) => {
onOptionsChange({
selected: value,
})
@@ -63,10 +64,14 @@ export const studyTrackFilter = createFilter({
selected: [],
},
isActive: ({ selected }) => (selected !== undefined ? selected.length > 0 : false),
filter: (student, { selected }, { args }) =>
student.studyRights
filter: (student, { args, options }) => {
const { selected } = options
return student.studyRights
.flatMap(studyRight => studyRight.studyRightElements)
.filter(element => element.code === args.code && element.studyTrack !== null)
.some(element => selected.includes(element.studyTrack.code)),
.some(element => selected.includes(element.studyTrack.code))
},
render: (props, { args }) => <StudyTrackFilterCard {...props} code={args.code} />,
})
@@ -2,25 +2,22 @@ import { Form, Dropdown, Message } from 'semantic-ui-react'
import { createFilter } from './createFilter'
const TagsFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
const TagsFilterCard = ({ options, onOptionsChange, students }) => {
const name = 'tagsFilter'
const { includedTags, excludedTags } = options
const tagCounts = withoutSelf().reduce((acc, student) => {
student.tags.forEach(tag => {
const {
tag_id: id,
tag: { tagname },
} = tag
if (!acc[id]) {
acc[id] = { count: 0, name: tagname }
}
acc[id].count += 1
})
return acc
}, {})
const tagCounts = new Map<string, { count: number; name: string }>()
for (const student of students) {
for (const {
tag_id: id,
tag: { tagname },
} of student.tags) {
tagCounts[id] ??= { count: 0, name: tagname }
tagCounts[id].count++
}
}
const dropdownOptions = Object.entries(tagCounts).map(([tagId, { count, name }]) => ({
const dropdownOptions = Array.from(tagCounts.entries()).map(([tagId, { count, name }]) => ({
key: `tag-${tagId}`,
text: `${name} (${count})`,
value: tagId,
@@ -29,13 +26,12 @@ const TagsFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
const includeOptions = dropdownOptions.filter(({ value }) => !excludedTags.includes(value))
const excludeOptions = dropdownOptions.filter(({ value }) => !includedTags.includes(value))
if (Object.entries(tagCounts).length === 0) {
if (!tagCounts.size)
return (
<Message color="orange" size="tiny">
No tags have been defined for any of the selected students.
</Message>
)
}
return (
<Form>
@@ -77,20 +73,18 @@ export const tagsFilter = createFilter({
excludedTags: [],
},
isActive: ({ includedTags, excludedTags }) => includedTags.length > 0 || excludedTags.length > 0,
isActive: ({ includedTags, excludedTags }) => !!includedTags.length || !!excludedTags.length,
filter(student, { includedTags, excludedTags }) {
filter(student, { options }) {
const { includedTags, excludedTags } = options
const tags = (student.tags ?? []).map(tag => tag.tag_id)
if (includedTags.length > 0 && excludedTags.length > 0) {
if (includedTags.length && excludedTags.length)
return includedTags.some(tag => tags.includes(tag)) && !excludedTags.some(tag => tags.includes(tag))
}
if (includedTags.length > 0) return includedTags.some(tag => tags.includes(tag))
if (excludedTags.length > 0) return !excludedTags.some(tag => tags.includes(tag))
else if (includedTags.length) return includedTags.some(tag => tags.includes(tag))
else if (excludedTags.length) return !excludedTags.some(tag => tags.includes(tag))
return true
},
component: TagsFilterCard,
render: TagsFilterCard,
})
@@ -53,7 +53,11 @@ export const transferredToProgrammeFilter = createFilter({
isActive: ({ transferred }) => transferred !== null,
filter: (student, { transferred }) => student.transferredStudyright === transferred,
filter: (student, { options }) => {
const { transferred } = options
return student.transferredStudyright === transferred
},
actions: {
set: (options, value) => {
@@ -61,5 +65,5 @@ export const transferredToProgrammeFilter = createFilter({
},
},
component: TransferredToProgrammeFilterCard,
render: TransferredToProgrammeFilterCard,
})
@@ -1,122 +0,0 @@
import { produce } from 'immer'
import { find, get, keyBy } from 'lodash'
import fp from 'lodash/fp'
import { useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { selectViewFilters, setFilterOptions, resetFilter, resetViewFilters } from '@/redux/filters'
import { FilterTray } from './FilterTray'
import { FilterViewContext } from './FilterViewContext'
const resolveFilterOptions = (store, filters, initialOptions) => {
return fp.flow(
fp.map(({ key, defaultOptions }) => [
key,
[store[key]?.options, !store[key] ? get(initialOptions, key) : null, defaultOptions],
]),
fp.fromPairs,
fp.mapValues(values => find(values))
)(filters)
}
export const FilterView = ({
children,
name,
filters: pFilters,
students,
displayTray: displayTrayProp,
initialOptions,
}) => {
const storeFilterOptions = useSelector(state => selectViewFilters(state, name))
const filters = pFilters.map(filter => (typeof filter === 'function' ? filter() : filter))
const filtersByKey = keyBy(filters, 'key')
const filterOptions = useMemo(
() => resolveFilterOptions(storeFilterOptions, filters, initialOptions),
[storeFilterOptions, filters, initialOptions]
)
const orderedFilters = filters.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
const displayTray = displayTrayProp !== undefined ? !!displayTrayProp : true
const precompute = fp.flow(
fp.filter(({ precompute }) => precompute),
fp.keyBy('key'),
fp.mapValues(({ precompute, key }) =>
precompute({
students,
options: filterOptions[key] ?? filtersByKey[key].defaultOptions,
precomputed: null,
args: filtersByKey[key].args,
})
)
)
const precomputed = useMemo(() => precompute(orderedFilters), [precompute, orderedFilters])
const getFilterContext = key => {
const filter = filtersByKey[key]
if (filter === undefined) {
return { students, options: null, precomputed: null, args: null }
}
return {
students,
options: filterOptions[key],
precomputed: precomputed[key],
args: filtersByKey[key].args,
}
}
const applyFilters = fp.flow(
fp.map(filter => [filter, getFilterContext(filter.key)]),
fp.filter(([{ key, isActive }, ctx]) => isActive(filterOptions[key], ctx)),
fp.reduce((students, [{ filter }, ctx]) => {
return students
.map(student => {
const res = []
const newStudent = produce(student, s => {
res.push(filter(s, ctx.options, ctx))
})
res.push(newStudent)
return res
})
.filter(([keep]) => keep)
.map(([, student]) => student)
}, students)
)
const filteredStudents = useMemo(() => applyFilters(orderedFilters), [applyFilters, orderedFilters])
const areOptionsDirty = key => !!storeFilterOptions[key]
const dispatch = useDispatch()
const value = {
viewName: name,
getContextByKey: getFilterContext,
allStudents: students,
filteredStudents,
filterOptions,
filters,
precomputed,
areOptionsDirty,
withoutFilter: key => applyFilters(orderedFilters.filter(filter => filter.key !== key)),
setFilterOptions: (filter, options) => dispatch(setFilterOptions({ view: name, filter, options })),
resetFilter: filter => dispatch(resetFilter({ view: name, filter })),
resetFilters: () => dispatch(resetViewFilters({ view: name })),
}
return (
<FilterViewContext.Provider value={value}>
<div style={{ display: 'flex', alignItems: 'start', justifyContent: 'center', gap: '0.75rem' }}>
<div style={{ zIndex: 1, alignSelf: 'flex-start', position: 'sticky', top: '1rem' }}>
{displayTray && <FilterTray />}
</div>
<div style={{ flexGrow: 1, minWidth: 0 }}>
{typeof children === 'function' ? children(filteredStudents) : children}
</div>
</div>
</FilterViewContext.Provider>
)
}
@@ -0,0 +1,110 @@
import { FC, useMemo } from 'react'
import { selectViewFilters, setFilterOptions, resetFilter, resetViewFilters } from '@/redux/filters'
import { useAppDispatch, useAppSelector } from '@/redux/hooks'
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
import { keyBy } from '@oodikone/shared/util'
import { FilterViewContext } from './context'
import type { FilterContext, FilterViewContextState } from './context'
import type { Filter, FilterFactory } from './filters/createFilter'
import { FilterTray } from './FilterTray'
// TODO: Use acual Student type when available
export type Student = ReturnType<typeof useGetPopulationStatisticsByCourseQuery>['data']['students']
const resolveFilterOptions = <T,>(
store: Record<Filter['key'], { options: T }>,
filters: Filter[],
initialOptions?: Record<Filter['key'], T>
): Record<Filter['key'], any> =>
Object.fromEntries(
filters.map(({ key, defaultOptions }) => [key, store[key]?.options ?? initialOptions?.[key] ?? defaultOptions])
)
export const FilterView: FC<{
children: (filteredStudents: Student[]) => any
name: string
filters: (FilterFactory | Filter)[]
students: Student[]
displayTray?: boolean
initialOptions?: Record<Filter['key'], any>
}> = ({ children, name, filters: pFilters, students, displayTray: displayTrayProp, initialOptions }) => {
const storeFilterOptions = useAppSelector(state => selectViewFilters(state, name))
const filters: Filter[] = pFilters.map(filter => (typeof filter === 'function' ? filter() : filter))
const filtersByKey = keyBy(filters, 'key')
const filterOptions = useMemo(
() => resolveFilterOptions(storeFilterOptions, filters, initialOptions),
[storeFilterOptions, filters, initialOptions]
)
const orderedFilters = filters.sort((a, b) => a.priority - b.priority)
const displayTray = displayTrayProp === undefined || !!displayTrayProp
const precomputed = useMemo(
() =>
Object.fromEntries(
orderedFilters
.filter(({ precompute }) => precompute)
.map(({ precompute, key }) => [
key,
precompute!({
students,
options: filterOptions[key],
precomputed: null,
args: filtersByKey[key].args,
}),
])
),
[orderedFilters]
)
const getFilterContext = (key: string): FilterContext => ({
students,
options: filterOptions[key] ?? null,
precomputed: precomputed[key] ?? null,
args: filtersByKey[key]?.args ?? null,
})
const applyFilters = (filters: Filter[]) =>
filters
.map(filter => ({ filter, ctx: getFilterContext(filter.key) }))
.filter(({ filter: { key, isActive } }) => isActive(filterOptions[key]))
.reduce((students, { filter: { filter }, ctx }) => {
return students
.map(student => {
const newStudent = structuredClone(student)
return filter(newStudent, ctx) ? newStudent : null
})
.filter(Boolean)
}, students)
const filteredStudents = useMemo(() => applyFilters(orderedFilters), [orderedFilters])
const dispatch = useAppDispatch()
const value: FilterViewContextState = {
viewName: name,
allStudents: students,
filters,
filteredStudents,
getContextByKey: getFilterContext,
areOptionsDirty: key => !!storeFilterOptions[key],
setFilterOptions: (filter, options) => dispatch(setFilterOptions({ view: name, filter, options })),
resetFilter: filter => dispatch(resetFilter({ view: name, filter })),
resetFilters: () => dispatch(resetViewFilters({ view: name })),
}
return (
<FilterViewContext.Provider value={value}>
<div style={{ display: 'flex', alignItems: 'start', justifyContent: 'center', gap: '0.75rem' }}>
<div style={{ zIndex: 1, alignSelf: 'flex-start', position: 'sticky', top: '1rem' }}>
{displayTray && <FilterTray />}
</div>
<div style={{ flexGrow: 1, minWidth: 0 }}>
{typeof children === 'function' ? children(filteredStudents) : children}
</div>
</div>
</FilterViewContext.Provider>
)
}
@@ -1,10 +1,10 @@
import { useContext } from 'react'
import { useDispatch } from 'react-redux'
import { FilterViewContext } from './FilterViewContext'
import { useAppDispatch } from '@/redux/hooks'
import { FilterViewContext } from './context'
export const useFilters = () => {
const dispatch = useDispatch()
const { getContextByKey, viewName, filterOptions } = useContext(FilterViewContext)
const dispatch = useAppDispatch()
const { getContextByKey, viewName } = useContext(FilterViewContext)
const filterDispatch = filterAction => {
dispatch({
@@ -14,7 +14,7 @@ export const useFilters = () => {
}
const useFilterSelector = filterSelector => {
const options = filterOptions[filterSelector.filter]
const { options } = getContextByKey(filterSelector.filter)
return filterSelector(options)
}
@@ -1,13 +1,13 @@
import { useDispatch } from 'react-redux'
import { Tab } from 'semantic-ui-react'
import { clearCourseStats } from '@/redux/courseStats'
import { useAppDispatch } from '@/redux/hooks'
import { GradeDistribution } from './GradeDistribution'
import { PassFailEnrollments } from './PassFailEnrollments'
import { PopulationCourseContext } from './PopulationCourseContext'
export const PopulationCourseStatsFlat = ({ courses, studentAmountLimit }) => {
const dispatch = useDispatch()
const dispatch = useAppDispatch()
if (!courses) return null
@@ -1,10 +1,10 @@
import { orderBy } from 'lodash'
import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { Tab } from 'semantic-ui-react'
import { useTabChangeAnalytics } from '@/hooks/tabChangeAnalytics'
import { clearCourseStats } from '@/redux/courseStats'
import { useAppDispatch } from '@/redux/hooks'
import { GradeDistribution } from './GradeDistribution'
import { PassFailEnrollments } from './PassFailEnrollments'
import { PassingSemesters } from './PassingSemesters'
@@ -17,9 +17,8 @@ const visibleCoursesFilter = ({ course }, mandatoryCourses) =>
mandatoryCourses.secondProgrammeCourses?.some(
programmeCourse => programmeCourse.code === course.code && programmeCourse.visible.visibility
)
export const PopulationCourseStats = ({ mandatoryCourses, courses, pending, onlyIamRights }) => {
const dispatch = useDispatch()
const dispatch = useAppDispatch()
const [modules, setModules] = useState([])
const [expandedGroups, setExpandedGroups] = useState(new Set())
const { handleTabChange } = useTabChangeAnalytics()
@@ -3,7 +3,6 @@ import exportData from 'highcharts/modules/export-data'
import exporting from 'highcharts/modules/exporting'
import { chain, range, sortBy } from 'lodash'
import moment from 'moment'
import qs from 'query-string'
import { useState } from 'react'
import ReactHighcharts from 'react-highcharts'
import { useLocation } from 'react-router'
@@ -16,6 +15,7 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useDeepMemo } from '@/hooks/deepMemo'
import { useGetSemestersQuery } from '@/redux/semesters'
import { generateGradientColors } from '@/util/color'
import { parseQueryParams } from '@/util/queryparams'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
@@ -148,7 +148,7 @@ export const CreditDistributionDevelopment = ({ students, programme, combinedPro
const [timeDivision, setTimeDivision] = useState(TimeDivision.SEMESTER)
const [stackOrdering, setStackOrdering] = useState(StackOrdering.DESCENDING)
const location = useLocation()
const { months } = qs.parse(location.search)
const { months } = parseQueryParams(location.search)
const { data: semestersAndYears = {} } = useGetSemestersQuery()
const { semesters = {} } = semestersAndYears
const { getTextIn } = useLanguage()
@@ -9,8 +9,8 @@ import { CollapsibleCreditRow } from './CollapsibleCreditRow'
export const CreditsGainedTable = ({ filteredStudents, programmeGoalTime, type, year }) => {
const { useFilterSelector } = useFilters()
const creditDateFilterOptions = useFilterSelector(creditDateFilter.selectors.selectOptions)
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive)
const creditDateFilterOptions = useFilterSelector(creditDateFilter.selectors.selectOptions())
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive())
if (!filteredStudents || !filteredStudents.length || !type) return null
let creditList = []
@@ -5,7 +5,7 @@ import { useFilters } from '@/components/FilterView/useFilters'
export const ExternalCreditFilterToggle = ({ students, helpText }) => {
const { filterDispatch, useFilterSelector } = useFilters()
const currentFilterIsActive = useFilterSelector(studentNumberFilter.selectors.studentListIsEqualToAllowlist(students))
const someFilterIsActive = useFilterSelector(studentNumberFilter.selectors.isActive)
const someFilterIsActive = useFilterSelector(studentNumberFilter.selectors.isActive())
return (
<FilterToggle
@@ -1,6 +1,5 @@
import { isEqual } from 'lodash'
import { useCallback, useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Segment } from 'semantic-ui-react'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
@@ -9,22 +8,37 @@ import { PopulationCourseStats } from '@/components/PopulationCourseStats'
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
import { SegmentDimmer } from '@/components/SegmentDimmer'
import { useDeepMemo } from '@/hooks/deepMemo'
import { useAppSelector, useAppDispatch } from '@/redux/hooks'
import { getPopulationSelectedStudentCourses } from '@/redux/populationSelectedStudentCourses'
import { FilterDegreeCoursesModal } from './FilterDegreeCoursesModal'
const mapStudentNumbersToStartingYears = samples => {
const years = [...new Set(samples.map(student => new Date(student.studyrightStart).getFullYear()))]
const studentsToYears = Object.fromEntries(years.map(y => [y, []]))
samples.forEach(student => {
studentsToYears[new Date(student.studyrightStart).getFullYear()].push(student.studentNumber)
})
return studentsToYears
}
export const PopulationCourses = ({
query = {},
query,
filteredStudents,
selectedStudentsByYear,
onlyIamRights,
curriculum: mandatoryCourses,
courseTableMode,
studentAmountLimit,
}) => {
const dispatch = useDispatch()
const populationSelectedStudentCourses = useSelector(
({ populationSelectedStudentCourses }) => populationSelectedStudentCourses
)
const dispatch = useAppDispatch()
const selectedStudentsByYear = mapStudentNumbersToStartingYears(filteredStudents ?? [])
const {
query: courseQuery,
data,
pending,
} = useAppSelector(({ populationSelectedStudentCourses }) => populationSelectedStudentCourses)
const fetch = useCallback(
courses => {
@@ -53,10 +67,10 @@ export const PopulationCourses = ({
}, [mandatoryCourses])
useEffect(() => {
if (programmeCourses == null || populationSelectedStudentCourses?.query == null) {
if (programmeCourses == null || courseQuery == null) {
return
}
const { courses, selectedStudents } = populationSelectedStudentCourses.query
const { courses, selectedStudents } = courseQuery
if (
!isEqual(programmeCourses, courses) ||
selectedStudents.length !== filteredStudents.length ||
@@ -67,9 +81,9 @@ export const PopulationCourses = ({
) {
fetch(programmeCourses)
}
}, [programmeCourses, filteredStudents, populationSelectedStudentCourses.query, onlyIamRights, fetch])
}, [programmeCourses, filteredStudents, courseQuery, onlyIamRights, fetch])
const pending = populationSelectedStudentCourses.pending || !mandatoryCourses
const isPending = pending || !mandatoryCourses
return (
<Segment basic>
@@ -89,18 +103,18 @@ export const PopulationCourses = ({
</div>
)}
</div>
<SegmentDimmer isLoading={pending} />
<SegmentDimmer isLoading={isPending} />
{courseTableMode === 'curriculum' ? (
<PopulationCourseStats
courses={populationSelectedStudentCourses.data ?? []}
key={populationSelectedStudentCourses.query.uuid}
courses={data ?? []}
key={courseQuery?.uuid}
mandatoryCourses={mandatoryCourses}
onlyIamRights={onlyIamRights}
pending={pending}
pending={isPending}
/>
) : (
<PopulationCourseStatsFlat
courses={pending ? null : (populationSelectedStudentCourses.data ?? [])}
courses={isPending ? null : (data ?? [])}
filteredStudents={filteredStudents}
studentAmountLimit={studentAmountLimit}
/>
@@ -15,36 +15,26 @@ import { CourseTableModeSelector } from './CourseTableModeSelector'
import { CreditGainStats } from './CreditGainStats'
import { PopulationCourses } from './PopulationCourses'
export const PopulationDetails = ({
filteredStudents,
isLoading,
programmeCodes,
query,
queryIsSet,
selectedStudentsByYear,
}) => {
export const PopulationDetails = ({ filteredStudents, isLoading, programmeCodes, query }) => {
const { isLoading: authLoading, programmeRights, fullAccessToStudentData } = useGetAuthorizedUserQuery()
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const { useFilterSelector } = useFilters()
const [studentAmountLimit, setStudentAmountLimit] = useState(0)
const [curriculum, setCurriculum] = useState(null)
useEffect(() => {
setStudentAmountLimit(Math.round(filteredStudents.length ? filteredStudents.length * 0.3 : 0))
}, [filteredStudents.length])
const filteredStudentsLength = filteredStudents?.length ?? 0
useEffect(() => setStudentAmountLimit(filteredStudentsLength * 0.3), [filteredStudentsLength])
const criteria = useGetProgressCriteriaQuery(
{ programmeCode: query?.studyRights?.programme },
{ skip: !query?.studyRights?.programme }
)
const programme = programmeCodes[0]
const criteria = useGetProgressCriteriaQuery({ programmeCode: programme }, { skip: !programme })
const [courseTableMode, setCourseTableMode] = useState('curriculum')
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive)
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive())
const onStudentAmountLimitChange = value => {
setStudentAmountLimit(Number.isNaN(Number(value)) ? studentAmountLimit : Number(value))
}
if (isLoading || !queryIsSet) {
if (isLoading || !Object.keys(query).length) {
return null
}
@@ -62,7 +52,7 @@ export const PopulationDetails = ({
<InfoBox content={populationStatisticsToolTips.creditAccumulation} />
{filteredStudents.length > 0 && (
<CreditAccumulationGraphHighCharts
programmeCodes={programmeCodes}
programmeCodes={programmeCodes.filter(Boolean)}
showBachelorAndMaster={query?.showBachelorAndMaster === 'true'}
students={filteredStudents}
studyPlanFilterIsActive={studyPlanFilterIsActive}
@@ -95,7 +85,7 @@ export const PopulationDetails = ({
setCurriculum={setCurriculum}
setStudentAmountLimit={setStudentAmountLimit}
studentAmountLimit={studentAmountLimit}
studyProgramme={query?.studyRights?.programme}
studyProgramme={programme}
year={query?.year}
/>
<PopulationCourses
@@ -104,31 +94,33 @@ export const PopulationDetails = ({
filteredStudents={filteredStudents}
onlyIamRights={onlyIamRights}
query={query}
selectedStudentsByYear={selectedStudentsByYear}
studentAmountLimit={studentAmountLimit}
/>
</div>
),
alwaysRender: true,
},
]
!onlyIamRights
? {
title: `Students (${filteredStudents.length})`,
content: (
<div>
<PopulationStudents
criteria={criteria?.data}
curriculum={curriculum}
filteredStudents={filteredStudents}
months={query?.months ?? 0}
programmeCode={programme}
showBachelorAndMaster={query?.showBachelorAndMaster}
studyRights={query?.studyRights}
variant="population"
year={query?.year}
/>
</div>
),
}
: null,
].filter(item => !!item)
if (!onlyIamRights) {
panels.push({
title: `Students (${filteredStudents.length})`,
content: (
<div>
<PopulationStudents
criteria={criteria?.data}
curriculum={curriculum}
filteredStudents={filteredStudents}
programmeCode={query?.studyRights?.programme}
variant="population"
year={query?.year}
/>
</div>
),
})
}
return <PanelView panels={panels} viewTitle="classstatistics" />
}
@@ -2,16 +2,19 @@ import { minBy } from 'lodash'
import { Card } from 'semantic-ui-react'
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
import { useGetPopulationStatisticsQuery } from '@/redux/populations'
import { useGetTagsByStudyTrackQuery } from '@/redux/tags'
import { reformatDate } from '@/util/timeAndDate'
export const PopulationQueryCard = ({ population, query }) => {
export const PopulationQueryCard = ({ query, skipQuery }) => {
const { studentStatuses, tag } = query
const { data: population } = useGetPopulationStatisticsQuery(query, { skip: skipQuery })
const students = population?.students ?? []
const { data: tags = [] } = useGetTagsByStudyTrackQuery(query?.studyRights?.programme, {
skip: !query?.studyRights?.programme,
})
const tagName = tag ? tags.find(currentTag => currentTag.id === tag)?.name : ''
const { students } = population
if (!students.length) return null
@@ -1,10 +1,9 @@
import { Stack } from '@mui/material'
import { isEqual, sortBy } from 'lodash'
import Stack from '@mui/material/Stack'
import { sortBy } from 'lodash'
import moment from 'moment'
import qs from 'query-string'
import { useEffect, useState } from 'react'
import Datetime from 'react-datetime'
import { useDispatch, useSelector } from 'react-redux'
import { useNavigate, useLocation } from 'react-router'
import { Button, Form, Message, Radio } from 'semantic-ui-react'
@@ -17,34 +16,28 @@ import { YEAR_DATE_FORMAT } from '@/constants/date'
import { useDegreeProgrammeTypes } from '@/hooks/degreeProgrammeTypes'
import { useSearchHistory } from '@/hooks/searchHistory'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { getPopulationStatistics, clearPopulations, useGetProgrammesQuery } from '@/redux/populations'
import { clearSelected } from '@/redux/populationSelectedStudentCourses'
import { useGetProgrammesQuery } from '@/redux/populations'
import { useGetStudyTracksQuery } from '@/redux/studyProgramme'
import { useGetStudyProgrammePinsQuery } from '@/redux/studyProgrammePins'
import { createPinnedFirstComparator } from '@/util/comparator'
import { queryParamsToString } from '@/util/queryparams'
import { momentFromFormat, reformatDate } from '@/util/timeAndDate'
import { formatQueryParamsToArrays } from '@oodikone/shared/util'
import { getMonths } from './common'
import './populationSearch.css'
const initialQuery = () => ({
year: '2017',
semesters: ['FALL', 'SPRING'],
studentStatuses: [],
studyRights: {},
months: getMonths('2017', 'FALL'),
showBachelorAndMaster: false,
})
export const PopulationSearchForm = ({ onProgress }) => {
export const PopulationSearchForm = () => {
const navigate = useNavigate()
const location = useLocation()
const dispatch = useDispatch()
const populations = useSelector(state => state.populations)
const previousQuery = populations.query || {}
const { getTextIn } = useLanguage()
const { fullAccessToStudentData } = useGetAuthorizedUserQuery()
const [query, setQuery] = useState(initialQuery())
const [query, setQuery] = useState({
year: '2017',
semesters: ['FALL', 'SPRING'],
studentStatuses: [],
studyRights: {},
months: getMonths('2017', 'FALL'),
showBachelorAndMaster: false,
})
const [showBachelorAndMaster, setShowBachelorAndMaster] = useState(false)
const [searchHistory, addItemToSearchHistory, updateItemInSearchHistory] = useSearchHistory('populationSearch', 8)
const [filterProgrammes, setFilterProgrammes] = useState(fullAccessToStudentData)
@@ -76,61 +69,6 @@ export const PopulationSearchForm = ({ onProgress }) => {
'urn:code:degree-program-type:masters-degree',
].includes(degreeProgrammeType[query.studyRights.programme])
const parseQueryFromUrl = () => {
const initial = initialQuery()
const { studyRights, months, ...rest } = qs.parse(location.search)
const query = {
...initial,
...rest,
studyRights: JSON.parse(studyRights),
months: JSON.parse(months),
}
return query
}
const checkPreviousQuery = (query, previousQuery) => {
const sameMonths = query.months === previousQuery.months
const sameYear = query.year === previousQuery.year
const sameSemesters = isEqual(previousQuery.semesters, query.semesters)
const sameStudentStatuses = isEqual(previousQuery.studentStatuses, query.studentStatuses)
const sameYears = isEqual(previousQuery.years, query.years)
const sameStudyRights = isEqual(previousQuery.studyRights, query.studyRights)
const sameTag = query.tag === previousQuery.tag
return sameStudyRights && sameMonths && sameYear && sameSemesters && sameStudentStatuses && sameYears && sameTag
}
const fetchPopulation = async query => {
const uuid = crypto.randomUUID()
dispatch(clearSelected())
dispatch(getPopulationStatistics({ ...query, uuid, onProgress }))
}
// HACK: This function doesn't work as intended
const fetchPopulationFromUrlParams = () => {
const query = parseQueryFromUrl()
const formattedQuery = formatQueryParamsToArrays(query, ['semesters', 'studentStatuses', 'years'])
if (!checkPreviousQuery(formattedQuery, previousQuery)) {
setQuery(query)
fetchPopulation(formattedQuery)
}
}
useEffect(() => {
if (!studyProgrammes || Object.values(studyProgrammes).length === 0) {
setQuery(initialQuery())
}
if (location.search) {
fetchPopulationFromUrlParams()
}
if (!location.search) {
setQuery({
...query,
studentStatuses: [],
semesters: ['FALL', 'SPRING'],
})
}
}, [location.search])
const handleProgrammeChange = (_event, { value: programme }) => {
setQuery({
...query,
@@ -156,22 +94,16 @@ export const PopulationSearchForm = ({ onProgress }) => {
}, [])
const pushQueryToUrl = query => {
if (!checkPreviousQuery(query, previousQuery)) {
dispatch(clearPopulations())
}
// Just to be sure that the previous population's data has been cleared
setTimeout(() => {
const { studyRights, ...rest } = query
studyRights.combinedProgramme =
studyRights.programme && studyRights.programme.includes('+') ? studyRights.programme.split('+')[1] : ''
studyRights.programme =
studyRights.programme && studyRights.programme.includes('+')
? studyRights.programme.split('+')[0]
: studyRights.programme
const queryObject = { ...rest, studyRights: JSON.stringify(studyRights) }
const searchString = qs.stringify(queryObject)
navigate({ search: searchString })
}, 0)
const { studyRights, ...rest } = query
const [programme, combinedProgramme] = studyRights.programme.split('+')
studyRights.programme = programme
studyRights.combinedProgramme = combinedProgramme ?? ''
const queryObject = { ...rest, studyRights: JSON.stringify(studyRights) }
const searchString = queryParamsToString(queryObject)
navigate({ search: searchString })
}
const getSearchHistoryTextFromQuery = () => {
@@ -198,6 +130,7 @@ export const PopulationSearchForm = ({ onProgress }) => {
text: getSearchHistoryTextFromQuery(),
params: query,
})
pushQueryToUrl(query)
}
@@ -1,31 +1,32 @@
import qs from 'query-string'
import { useState } from 'react'
import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router'
import { Button, Form } from 'semantic-ui-react'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { InfoBox } from '@/components/InfoBox'
import { PopulationQueryCard } from '@/components/PopulationQueryCard'
import { queryParamsToString } from '@/util/queryparams'
import { formatToArray } from '@oodikone/shared/util'
import { getMonths } from './common'
import { FilterActiveNote } from './FilterActiveNote'
import './populationSearch.css'
export const PopulationSearchHistory = () => {
export const PopulationSearchHistory = ({ query, skipQuery }) => {
const navigate = useNavigate()
const populations = useSelector(state => state.populations)
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false)
const [semesters, setSemesters] = useState(populations.query?.semesters || ['FALL', 'SPRING'])
const [studentStatuses, setStudentStatus] = useState(populations.query?.studentStatuses || [])
const [months, setMonths] = useState(populations.query?.months || 0)
const [semesters, setSemesters] = useState(
query.semesters.length ? formatToArray(query.semesters) : ['FALL', 'SPRING']
)
const [studentStatuses, setStudentStatus] = useState(query.studentStatuses)
const [months, setMonths] = useState(query.months ?? 0)
const handleSemesterSelection = (_event, { value }) => {
const newSemesters = semesters.includes(value)
? semesters.filter(semester => semester !== value)
: [...semesters, value]
if (!populations.query.tag) {
if (!query.tag) {
setSemesters(newSemesters)
setMonths(getMonths(populations.query.year, semesters.includes('FALL') ? 'FALL' : 'SPRING'))
setMonths(getMonths(query.year, semesters.includes('FALL') ? 'FALL' : 'SPRING'))
}
}
@@ -36,7 +37,7 @@ export const PopulationSearchHistory = () => {
}
const pushQueryToUrl = () => {
const { studyRights, tag, year } = populations.query
const { studyRights, tag, year } = query
const queryObject = {
tag,
@@ -46,7 +47,7 @@ export const PopulationSearchHistory = () => {
semesters,
studyRights: JSON.stringify(studyRights),
}
const searchString = qs.stringify(queryObject)
const searchString = queryParamsToString(queryObject)
navigate({ search: searchString })
}
@@ -57,7 +58,7 @@ export const PopulationSearchHistory = () => {
return (
<Form.Group style={{ flexDirection: 'column' }}>
{!populations.query.tag ? (
{!query.tag && (
<Form.Field style={{ marginTop: '15px' }}>
<b>Starting semesters</b>
<Form.Checkbox
@@ -81,7 +82,7 @@ export const PopulationSearchHistory = () => {
value="SPRING"
/>
</Form.Field>
) : null}
)}
<Form.Field style={{ marginTop: '15px' }}>
<b>Include</b>
<Form.Checkbox
@@ -122,7 +123,7 @@ export const PopulationSearchHistory = () => {
}
const renderQueryCards = () => {
if (!populations.query || !populations.data.students) {
if (!Object.keys(query).length) {
return null
}
@@ -130,13 +131,13 @@ export const PopulationSearchHistory = () => {
return (
<div style={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
<div>
<PopulationQueryCard population={populations.data} query={populations.query} />
<PopulationQueryCard query={query} skipQuery={skipQuery} />
<div style={{ marginLeft: '5px', marginTop: '15px' }}>
<InfoBox content={populationStatisticsToolTips.advanced} />
</div>
</div>
<div style={{ marginLeft: '100px' }}>
{populations.query.year !== 'All' && (
{query.year !== 'All' && (
<Form.Group>
<Form.Field>
<Form.Radio
@@ -1,39 +1,30 @@
import { useSelector } from 'react-redux'
import { Link, useLocation } from 'react-router'
import { Link } from 'react-router'
import { Button, Divider, Form, Header, Icon, Segment } from 'semantic-ui-react'
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'
export const PopulationSearch = ({ combinedProgrammeCode }) => {
const location = useLocation()
const populations = useSelector(state => state.populations)
const populationFound = populations.data.students !== undefined
const loading = !!populations.pending
const { onProgress, progress } = useProgress(loading)
export const PopulationSearch = ({ query, skipQuery, isLoading, populationFound, combinedProgrammeCode }) => {
const { filterDispatch, useFilterSelector } = useFilters()
const onlyHopsCredit = useFilterSelector(hopsFilter.selectors.isActive)
const onlyHopsCredit = useFilterSelector(hopsFilter.selectors.isActive())
const combinedHopsSelected = useFilterSelector(hopsFilter.selectors.isCombinedSelected(combinedProgrammeCode))
const bothHopsSelected = useFilterSelector(hopsFilter.selectors.isBothSelected(combinedProgrammeCode))
const title = populationFound && location.search ? null : 'Search for class'
return (
<Segment>
{title && <Header size="medium">{title}</Header>}
{skipQuery && <Header size="medium">{'Search for class'}</Header>}
{!populationFound && (
<>
<InfoBox content={populationStatisticsToolTips.search} cypress="PopulationSearch" />
<Divider />
</>
)}
<PopulationSearchForm onProgress={onProgress} />
{location.search !== '' && !loading && (
<PopulationSearchForm />
{!skipQuery && !isLoading && (
<Form>
<Form.Field>
<Link to="/populations">
@@ -65,10 +56,9 @@ export const PopulationSearch = ({ combinedProgrammeCode }) => {
/>
</Form.Field>
)}
<PopulationSearchHistory />
<PopulationSearchHistory query={query} skipQuery={skipQuery} />
</Form>
)}
<ProgressBar progress={progress} />
</Segment>
)
}
@@ -1,223 +0,0 @@
import { useMemo } from 'react'
import { useSelector } from 'react-redux'
import { useLocation } from 'react-router'
import { Header, Message, Segment } from 'semantic-ui-react'
import { getStudentTotalCredits } from '@/common'
import { FilterView } from '@/components/FilterView'
import {
admissionTypeFilter,
ageFilter,
citizenshipFilter,
courseFilter,
creditDateFilter,
creditsEarnedFilter,
curriculumPeriodFilter,
enrollmentStatusFilter,
genderFilter,
graduatedFromProgrammeFilter,
hopsFilter,
startYearAtUniFilter,
studentNumberFilter,
studyRightStatusFilter,
studyRightTypeFilter,
studyTrackFilter,
tagsFilter,
transferredToProgrammeFilter,
} from '@/components/FilterView/filters'
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'
import { makePopulationsToData } from '@/selectors/populationDetails'
import { getFullStudyProgrammeRights } from '@/util/access'
import { getCombinedProgrammeName } from '@/util/combinedProgramme'
const getYearText = year => {
if (year === 'All') return ''
return `${year} - ${Number(year) + 1}`
}
export const PopulationStatistics = () => {
const location = useLocation()
const { language, getTextIn } = useLanguage()
const courses = useSelector(store => store.populationSelectedStudentCourses.data?.coursestatistics)
const { query, queryIsSet, isLoading, selectedStudentsByYear, samples } = useSelector(makePopulationsToData)
const currentSemester = useCurrentSemester()
const { fullAccessToStudentData, programmeRights } = useGetAuthorizedUserQuery()
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const onlyIamRights = !fullAccessToStudentData && fullStudyProgrammeRights.length === 0
useTitle('Class statistics')
const { data: allSemesters } = useGetSemestersQuery()
const programmeCode = query?.studyRights?.programme
const combinedProgrammeCode = query?.studyRights?.combinedProgramme ? query?.studyRights?.combinedProgramme : ''
const { data: studyProgrammes } = useGetProgrammesQuery()
const programmes = {
...studyProgrammes,
'KH90_001+MH90_001': {
code: 'KH90_001+MH90_001',
name: {
fi: 'Eläinlääketieteen kandiohjelma ja lisensiaatin koulutusohjelma',
en: "Bachelor's and Degree Programme in Vetenary Medicine",
sv: 'Kandidats- och Utbildningsprogrammet i veterinärmedicin',
},
},
}
const programmeName = programmes && programmeCode ? programmes[programmeCode]?.name : ''
const combinedProgrammeName = programmes && combinedProgrammeCode ? programmes[combinedProgrammeCode]?.name : ''
const showBachelorAndMaster = query?.showBachelorAndMaster === 'true' || combinedProgrammeCode !== ''
const filters = [
!onlyIamRights ? ageFilter : null,
citizenshipFilter,
courseFilter({ courses }),
creditDateFilter,
creditsEarnedFilter,
curriculumPeriodFilter,
enrollmentStatusFilter({
allSemesters: allSemesters?.semesters ?? [],
programme: programmeCode,
}),
genderFilter,
graduatedFromProgrammeFilter({
code: programmeCode,
combinedProgrammeCode,
showBachelorAndMaster,
}),
hopsFilter({ programmeCode, combinedProgrammeCode }),
studentNumberFilter,
startYearAtUniFilter,
studyTrackFilter({ code: programmeCode }),
studyRightStatusFilter({ code: programmeCode, combinedProgrammeCode, currentSemester, showBachelorAndMaster }),
tagsFilter,
transferredToProgrammeFilter,
].filter(Boolean)
const degreeProgrammeType = useDegreeProgrammeTypes([programmeCode])
if (programmeCode && degreeProgrammeType[programmeCode] === 'urn:code:degree-program-type:masters-degree') {
filters.push(
studyRightTypeFilter({
programme: programmeCode,
year: query?.year,
})
)
}
// For combined programme admission type is the same as they started in bachelor programme
if (parseInt(query?.year, 10) >= 2020) {
filters.push(
admissionTypeFilter({
programme: programmeCode,
})
)
}
const programmeCodes = []
if (programmeCode) programmeCodes.push(programmeCode)
if (combinedProgrammeCode) programmeCodes.push(combinedProgrammeCode)
const students = useMemo(() => {
if (!samples) {
return []
}
return samples.map(student => {
const hopsCredits =
student.studyplans?.find(plan => plan.programme_code === programmeCode)?.completed_credits ?? 0
const hopsCombinedProgrammeCredits =
student.studyplans?.find(plan => plan.programme_code === combinedProgrammeCode)?.completed_credits ?? 0
const studyrightStartDate = new Date(student.studyrightStart)
const courses = student.courses.filter(({ date }) => new Date(date) >= studyrightStartDate)
const credits = getStudentTotalCredits({ courses })
return {
...student,
allCredits: student.credits,
hopsCredits,
hopsCombinedProgrammeCredits,
credits,
}
})
}, [samples, programmeCode, combinedProgrammeCode])
const programmeText =
query?.studyRights?.combinedProgramme !== '' && query?.studyRights?.combinedProgramme !== undefined
? getCombinedProgrammeName(getTextIn(programmeName), getTextIn(combinedProgrammeName), language)
: getTextIn(programmeName)
const title = location.search === '' ? 'Class statistics' : `${programmeText} ${getYearText(query?.year)}`
const noStudentsMessage = () => (
<div style={{ maxWidth: '80%' }}>
<Message
content={`
Choose “Advanced settings” below and make sure you have the correct student groups included
in the class. For example, if you are looking for students of a specialist training in
medicine or dentistry, you must choose “Students with non-degree study right”.`}
header="Not seeing any students?"
icon="question"
info
size="large"
/>
</div>
)
return (
<FilterView
displayTray={location.search !== ''}
filters={filters}
initialOptions={{
[transferredToProgrammeFilter.key]: {
transferred: false,
},
[hopsFilter.key]: {
studyStart: (students[0] || {}).studyrightStart,
},
[studyTrackFilter.key]: {
selected: query?.studyRights?.studyTrack ? [query?.studyRights?.studyTrack] : [],
},
[tagsFilter.key]: {
includedTags: query?.tag ? [query.tag] : [],
excludedTags: [],
},
}}
name="PopulationStatistics"
students={students}
>
{filteredStudents => (
<div className="segmentContainer" style={{ flexGrow: 1 }}>
<Header align="center" className="segmentTitle" size="large">
{title} {query?.showBachelorAndMaster === 'true' && ' (Bachelor + Master view)'}
{location.search !== '' && query?.studyRights?.studyTrack && (
<Header.Subheader>studytrack {query.studyRights.studyTrack}</Header.Subheader>
)}
{location.search !== '' && (
<Header.Subheader>
studytime {query?.months} months, class size {students.length} students
</Header.Subheader>
)}
</Header>
{students?.length === 0 && location.search !== '' && !isLoading && noStudentsMessage()}
<Segment className="contentSegment">
<PopulationSearch combinedProgrammeCode={combinedProgrammeCode} />
{location.search !== '' && (
<PopulationDetails
filteredStudents={filteredStudents}
isLoading={isLoading}
programmeCodes={programmeCodes}
query={query}
queryIsSet={queryIsSet}
selectedStudentsByYear={selectedStudentsByYear}
/>
)}
</Segment>
</div>
)}
</FilterView>
)
}
@@ -0,0 +1,267 @@
import { useMemo } from 'react'
import { useLocation } from 'react-router'
import { Header, Message, Segment } from 'semantic-ui-react'
import { getStudentTotalCredits } from '@/common'
import { FilterView } from '@/components/FilterView'
import {
admissionTypeFilter,
ageFilter,
citizenshipFilter,
courseFilter,
creditDateFilter,
creditsEarnedFilter,
curriculumPeriodFilter,
enrollmentStatusFilter,
genderFilter,
graduatedFromProgrammeFilter,
hopsFilter,
startYearAtUniFilter,
studentNumberFilter,
studyRightStatusFilter,
studyRightTypeFilter,
studyTrackFilter,
tagsFilter,
transferredToProgrammeFilter,
} from '@/components/FilterView/filters'
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 { useAppSelector } from '@/redux/hooks'
import { useGetProgrammesQuery, useGetPopulationStatisticsQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
import { DegreeProgramme } from '@/types/api/faculty'
import { getFullStudyProgrammeRights } from '@/util/access'
import { getCombinedProgrammeName } from '@/util/combinedProgramme'
type Query = {
months: string
year: string
years?: string[]
semesters: string[]
studentStatuses: string[]
studyRights: {
programme: string
combinedProgramme: string
studyTrack?: string
}
showBachelorAndMaster?: string
tag?: any
}
const getYearText = (year: string) => {
const yearIsNumber = !isNaN(+year)
return yearIsNumber ? `${year} - ${+year + 1}` : ''
}
import { parseQueryParams } from '@/util/queryparams'
import { getMonths } from '../PopulationSearch/common'
const parseQueryFromUrl = (location): [boolean, Query] => {
const skipQuery = !location.search
const { studyRights, ...rest } = parseQueryParams(location.search)
if (studyRights === null || Array.isArray(studyRights)) throw Error()
const query: Query = {
months: getMonths('2017', 'FALL').toString(),
year: '2017',
semesters: ['FALL', 'SPRING'],
studentStatuses: [],
studyRights: studyRights ? JSON.parse(studyRights) : { programme: '', combinedProgramme: '' },
showBachelorAndMaster: 'false',
...rest,
}
return [skipQuery, query]
}
const mapStudentDataToStudents = (samples, programmeCode, combinedProgrammeCode) =>
samples.map(student => {
const hopsCredits = student.studyplans?.find(plan => plan.programme_code === programmeCode)?.completed_credits ?? 0
const hopsCombinedProgrammeCredits =
student.studyplans?.find(plan => plan.programme_code === combinedProgrammeCode)?.completed_credits ?? 0
const studyrightStartDate = new Date(student.studyrightStart)
const courses = student.courses.filter(({ date }) => studyrightStartDate <= new Date(date))
const credits = getStudentTotalCredits({ courses })
return {
...student,
allCredits: student.credits,
hopsCredits,
hopsCombinedProgrammeCredits,
credits,
}
})
const useUserHasRestrictedAccess = (): boolean => {
const { fullAccessToStudentData, programmeRights } = useGetAuthorizedUserQuery()
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
return !fullAccessToStudentData && !fullStudyProgrammeRights.length
}
const useGetProgrammes = (): Record<string, DegreeProgramme> => {
const { data: studyProgrammes } = useGetProgrammesQuery()
return {
...studyProgrammes,
'KH90_001+MH90_001': {
code: 'KH90_001+MH90_001',
curriculumPeriodIds: [],
degreeProgrammeType: '',
progId: '',
name: {
fi: 'Eläinlääketieteen kandiohjelma ja lisensiaatin koulutusohjelma',
en: "Bachelor's and Degree Programme in Vetenary Medicine",
sv: 'Kandidats- och Utbildningsprogrammet i veterinärmedicin',
},
},
}
}
const useGetProgrammeText = (programmeCode: string, combinedProgrammeCode: string): string => {
const { language, getTextIn } = useLanguage()
const programmes = useGetProgrammes()
const programmeName = getTextIn(programmes[programmeCode]?.name) ?? ''
const combinedProgrammeName = getTextIn(programmes[combinedProgrammeCode]?.name) ?? ''
return combinedProgrammeCode
? getCombinedProgrammeName(programmeName, combinedProgrammeName, language)
: programmeName
}
export const PopulationStatistics = () => {
useTitle('Class statistics')
const [skipQuery, query] = parseQueryFromUrl(useLocation())
const { data: allSemesters } = useGetSemestersQuery()
const currentSemester = useCurrentSemester()
const { data: population, isFetching } = useGetPopulationStatisticsQuery(query, { skip: skipQuery })
const courses = useAppSelector(store => store.populationSelectedStudentCourses.data?.coursestatistics)
const { programme: programmeCode, combinedProgramme: combinedProgrammeCode, studyTrack } = query.studyRights
const students = useMemo(
() => mapStudentDataToStudents(population?.students ?? [], programmeCode, combinedProgrammeCode),
[population, programmeCode, combinedProgrammeCode]
)
const showBachelorAndMaster = !!combinedProgrammeCode || query?.showBachelorAndMaster === 'true'
const programmeText = useGetProgrammeText(programmeCode, combinedProgrammeCode)
const filters = [
!useUserHasRestrictedAccess() ? ageFilter : null,
citizenshipFilter,
courseFilter({ courses }),
creditDateFilter,
creditsEarnedFilter,
curriculumPeriodFilter,
enrollmentStatusFilter({
allSemesters: allSemesters?.semesters ?? [],
programme: programmeCode,
}),
genderFilter,
graduatedFromProgrammeFilter({
code: programmeCode,
combinedProgrammeCode,
showBachelorAndMaster,
}),
hopsFilter({ programmeCode, combinedProgrammeCode }),
studentNumberFilter,
startYearAtUniFilter,
studyTrackFilter({ code: programmeCode }),
studyRightStatusFilter({ code: programmeCode, combinedProgrammeCode, currentSemester, showBachelorAndMaster }),
tagsFilter,
transferredToProgrammeFilter,
useDegreeProgrammeTypes([programmeCode])?.[programmeCode] === 'urn:code:degree-program-type:masters-degree'
? studyRightTypeFilter({ programme: programmeCode, year: query.year })
: null,
// For combined programme admission type is the same as they started in bachelor programme
parseInt(query.year, 10) >= 2020 ? admissionTypeFilter({ programme: programmeCode }) : null,
].filter(item => !!item)
const initialOptions = {
[transferredToProgrammeFilter.key]: {
transferred: false,
},
[hopsFilter.key]: {
studyStart: (students[0] ?? {})?.studyrightStart,
},
[studyTrackFilter.key]: {
selected: studyTrack ? [studyTrack] : [],
},
[tagsFilter.key]: {
includedTags: query.tag ? [query.tag] : [],
excludedTags: [],
},
}
const showNoStudentsMessage = students?.length === 0 && !isFetching
const noStudentsMessage = () => (
<div style={{ maxWidth: '80%' }}>
<Message
content={`
Choose “Advanced settings” below and make sure you have the correct student groups included
in the class. For example, if you are looking for students of a specialist training in
medicine or dentistry, you must choose “Students with non-degree study right”.`}
header="Not seeing any students?"
icon="question"
info
size="large"
/>
</div>
)
const title = skipQuery ? 'Class statistics' : `${programmeText} ${getYearText(query.year)}`
return (
<FilterView
displayTray={!skipQuery}
filters={filters}
initialOptions={initialOptions}
name="PopulationStatistics"
students={students}
>
{filteredStudents => (
<div className="segmentContainer" style={{ flexGrow: 1 }}>
<Header align="center" className="segmentTitle" size="large">
{title} {!skipQuery && showBachelorAndMaster && '(Bachelor + Master view)'}
{!skipQuery && studyTrack && <Header.Subheader>studytrack {studyTrack}</Header.Subheader>}
{!skipQuery && (
<Header.Subheader>
studytime {query.months} months, class size {students.length} students
</Header.Subheader>
)}
</Header>
{!skipQuery && showNoStudentsMessage && noStudentsMessage()}
<Segment className="contentSegment" loading={isFetching}>
<PopulationSearch
combinedProgrammeCode={combinedProgrammeCode}
isLoading={isFetching}
populationFound={!!students.length}
query={query}
skipQuery={skipQuery}
/>
{!skipQuery && (
<PopulationDetails
filteredStudents={filteredStudents}
isLoading={isFetching}
programmeCodes={[programmeCode, combinedProgrammeCode]}
query={query}
/>
)}
</Segment>
</div>
)}
</FilterView>
)
}
@@ -1,4 +1,5 @@
import { FormControlLabel, Switch } from '@mui/material'
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
export const IncludeSubstitutionsToggle = ({ includeSubstitutions, toggleIncludeSubstitutions }) => (
<FormControlLabel
@@ -1,5 +1,5 @@
import { useSelector } from 'react-redux'
import { Tab } from 'semantic-ui-react'
import { useAppSelector } from '@/redux/hooks'
import { useGetStudyGuidanceGroupPopulationCoursesQuery } from '@/redux/studyGuidanceGroups'
@@ -11,7 +11,7 @@ export const CoursesTabContainer = ({ curriculum, includeSubstitutions, students
year: studyGuidanceGroup?.tags?.year,
})
const { data: populationCourses } = useSelector(state => state?.populationSelectedStudentCourses)
const { data: populationCourses } = useAppSelector(state => state.populationSelectedStudentCourses)
const studyGuidanceGroupLoading = isLoading || !curriculum || !students
const loading = !curriculum || !students || !populationCourses
@@ -1,19 +1,24 @@
import { Tooltip, Typography } from '@mui/material'
import { type MRT_ColumnDef } from 'material-react-table'
import CheckIcon from '@mui/icons-material/Check'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { createMRTColumnHelper } from 'material-react-table'
import { useMemo } from 'react'
import { StudentInfoItem } from '@/components/material/StudentInfoItem'
import { TableHeaderWithTooltip } from '@/components/material/TableHeaderWithTooltip'
import { muiTableBodyCellPropsDefaultSx } from '@/util/getDefaultMRTOptions'
import { FormattedStudentData } from '.'
import { DynamicColumnTitles } from './GeneralTab'
export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
return useMemo<MRT_ColumnDef<any>[]>(
const columnHelper = createMRTColumnHelper<FormattedStudentData>()
return useMemo(
() => [
{
accessorKey: 'studentNumber',
columnHelper.accessor('studentNumber', {
header: 'Student number',
Header: () => <Typography fontWeight="bold">Student number</Typography>,
Cell: ({ cell }) => {
const studentNumber = cell.getValue<string>()
const studentNumber = cell.getValue()
return studentNumber === 'Hidden' ? (
'Hidden'
) : (
@@ -22,27 +27,22 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
},
filterFn: 'startsWith',
enableClickToCopy: true,
},
{
accessorKey: 'lastName',
}),
columnHelper.accessor('lastName', {
header: 'Last name',
},
{
accessorKey: 'firstNames',
}),
columnHelper.accessor('firstNames', {
header: 'Given names',
},
{
accessorKey: 'email',
header: 'Email',
enableClickToCopy: true,
},
{
accessorKey: 'phoneNumber',
header: 'Phone number',
},
{
accessorKey: 'primaryProgramme',
}),
columnHelper.accessor('primaryProgramme', {
header: 'Primary study programme',
muiTableBodyCellProps: {
sx: {
...muiTableBodyCellPropsDefaultSx,
width: '250px',
maxWidth: '380px',
},
},
Header: (
<TableHeaderWithTooltip
header="Primary study programme"
@@ -50,52 +50,58 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
/>
),
Cell: ({ cell }) => {
const value = cell.getValue<string>()
const formattedValue = value.length > 45 ? `${value.substring(0, 43)}...` : value
const programmeName = cell.getValue()
if (!programmeName) return null
const indexToSplitAt = programmeName.lastIndexOf(' ')
const formattedValue = ['(Graduated)', '(Inactive)'].includes(programmeName.slice(indexToSplitAt + 1)) ? (
(() => {
const start = programmeName.slice(0, indexToSplitAt)
const end = programmeName.slice(indexToSplitAt + 1)
return (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{start}</span>
<span style={{ paddingLeft: '0.5em' }}>{end}</span>
</div>
)
})()
) : (
<span style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{programmeName}</span>
)
return (
<Tooltip arrow title={value}>
<span>{formattedValue}</span>
<Tooltip arrow title={programmeName}>
{formattedValue}
</Tooltip>
)
},
},
{
accessorKey: 'creditsTotal',
}),
columnHelper.accessor('creditsTotal', {
header: 'All credits',
},
{
accessorKey: 'creditsHops',
}),
columnHelper.accessor('creditsHops', {
header: 'Credits in HOPS',
},
{
accessorKey: 'creditsCombinedProg',
}),
columnHelper.accessor('creditsCombinedProg', {
header: `${dynamicTitles.creditsCombinedProg}`,
},
{
accessorKey: 'creditsSince',
}),
columnHelper.accessor('creditsSince', {
header: `${dynamicTitles.creditsSince}`,
},
{
accessorKey: 'grade',
}),
columnHelper.accessor('grade', {
header: 'Grade',
},
{
accessorKey: 'studyTrack',
}),
columnHelper.accessor('studyTrack', {
header: 'Study track',
},
{
accessorKey: 'studyRightStart',
}),
columnHelper.accessor('studyRightStart', {
header: 'Start of study right',
},
{
accessorKey: 'programmeStart',
}),
columnHelper.accessor('programmeStart', {
header: 'Started in programme',
},
{
accessorKey: 'option',
}),
columnHelper.accessor('option', {
header: `${dynamicTitles.option}`,
Cell: ({ cell }) => {
const value = cell.getValue<string>()
const value = cell.getValue()
const formattedValue = value.length > 45 ? `${value.substring(0, 43)}...` : value
return (
<Tooltip arrow title={value}>
@@ -103,30 +109,24 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
</Tooltip>
)
},
},
{
accessorKey: 'semesterEnrollments',
}),
columnHelper.accessor('semesterEnrollments', {
header: 'Semesters present',
Cell: ({ cell }) => {
// @ts-expect-error add typing
const { content } = cell.getValue()
return content ?? null
},
},
{
accessorKey: 'graduationDate',
}),
columnHelper.accessor('graduationDate', {
header: `${dynamicTitles.primaryEndDate}`,
},
{
accessorKey: 'graduationDateCombinedProg',
}),
columnHelper.accessor('graduationDateCombinedProg', {
header: `${dynamicTitles.secondaryEndDate}`,
},
{
accessorKey: 'startYearAtUniversity',
}),
columnHelper.accessor('startYearAtUniversity', {
header: 'Start year at uni',
},
{
accessorKey: 'programmes',
}),
columnHelper.accessor('programmes', {
header: `${dynamicTitles.programmes}`,
Header: (
<TableHeaderWithTooltip
@@ -135,7 +135,6 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
/>
),
Cell: ({ cell }) => {
// @ts-expect-error add typing
const { programmes, programmeList } = cell.getValue()
if (programmes.length === 0) return null
@@ -148,25 +147,20 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
</Tooltip>
)
},
},
{
accessorKey: 'attainmentDate',
}),
columnHelper.accessor('attainmentDate', {
header: 'Attainment date',
},
{
accessorKey: 'enrollmentDate',
}),
columnHelper.accessor('enrollmentDate', {
header: 'Enrollment date',
},
{
accessorKey: 'language',
}),
columnHelper.accessor('language', {
header: 'Language',
},
{
accessorKey: 'transferredFrom',
}),
columnHelper.accessor('transferredFrom', {
header: 'Transferred from',
},
{
accessorKey: 'admissionType',
}),
columnHelper.accessor('admissionType', {
header: 'Admission type',
Header: (
<TableHeaderWithTooltip
@@ -174,21 +168,36 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
tooltipText="Not available for study rights granted prior to 2020"
/>
),
},
{
accessorKey: 'gender',
}),
columnHelper.accessor('gender', {
header: 'Gender',
},
{
accessorKey: 'citizenships',
}),
columnHelper.accessor('citizenships', {
header: 'Citizenships',
},
{
accessorKey: 'curriculumPeriod',
}),
columnHelper.accessor('tvex', {
header: 'TVEX',
Header: (
<TableHeaderWithTooltip
header="TVEX"
tooltipText="Student is enrolled to a bilingual programme (kaksikielinen tutkinto, tvåspråkig examen)"
/>
),
Cell: ({ cell }) => {
const isTVEX = cell.getValue()
return (
isTVEX && (
<span style={{ display: 'flex', justifyContent: 'center' }}>
<CheckIcon />
</span>
)
)
},
}),
columnHelper.accessor('curriculumPeriod', {
header: 'Curriculum period',
},
{
accessorKey: 'mostRecentAttainment',
}),
columnHelper.accessor('mostRecentAttainment', {
header: 'Latest attainment date',
Header: (
<TableHeaderWithTooltip
@@ -196,19 +205,16 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
tooltipText="Date of the most recent course completion that is included in the HOPS"
/>
),
},
{
accessorKey: 'extent',
}),
columnHelper.accessor('extent', {
header: 'Extent',
},
{
accessorKey: 'tags',
}),
columnHelper.accessor('tags', {
header: 'Tags',
},
{
accessorKey: 'updatedAt',
}),
columnHelper.accessor('updatedAt', {
header: 'Last updated at',
},
}),
],
[dynamicTitles]
)
@@ -1,7 +1,7 @@
import {
type MRT_VisibilityState,
type MRT_ColumnDef,
MaterialReactTable,
createMRTColumnHelper,
useMaterialReactTable,
} from 'material-react-table'
import { useEffect, useMemo, useState } from 'react'
@@ -54,8 +54,8 @@ export const GeneralTab = ({
const [columnVisibility, setColumnVisibility] = useState<MRT_VisibilityState>({
lastName: namesVisible,
firstNames: namesVisible,
email: namesVisible,
phoneNumber: namesVisible,
email: false,
phoneNumber: false,
studyTrack: studyTrackVisible,
admissionType: admissionTypeVisible,
semesterEnrollmentExport: false,
@@ -66,8 +66,8 @@ export const GeneralTab = ({
setColumnVisibility({
lastName: namesVisible,
firstNames: namesVisible,
email: namesVisible,
phoneNumber: namesVisible,
email: false,
phoneNumber: false,
studyTrack: studyTrackVisible,
admissionType: admissionTypeVisible,
semesterEnrollmentExport: false,
@@ -75,16 +75,7 @@ export const GeneralTab = ({
})
}, [namesVisible, studyTrackVisible, admissionTypeVisible])
const baseColumns = [
'lastName',
'firstNames',
'email',
'phoneNumber',
'creditsTotal',
'studentNumber',
'tags',
'updatedAt',
]
const baseColumns = ['lastName', 'firstNames', 'creditsTotal', 'studentNumber', 'tvex', 'tags', 'updatedAt']
const adminColumns = ['extent', 'updatedAt']
@@ -189,22 +180,32 @@ export const GeneralTab = ({
return isIncluded
})
const exportExclusiveColumns = [
{
accessorKey: 'semesterEnrollments.exportValue',
id: 'semesterEnrollmentExport',
header: 'Semester enrollment amount',
visibleInShowHideMenu: false,
},
{
accessorKey: 'programmes.exportValue',
id: 'programmesExport',
header: `${dynamicTitles.programmes}`,
visibleInShowHideMenu: false,
},
]
const columnHelper = createMRTColumnHelper<FormattedStudentData>()
const exportExclusiveColumns = useMemo(
() => [
columnHelper.accessor('semesterEnrollments.exportValue', {
id: 'semesterEnrollmentExport',
header: 'Semester enrollment amount',
visibleInShowHideMenu: false,
}),
columnHelper.accessor('programmes.exportValue', {
id: 'programmesExport',
header: `${dynamicTitles.programmes}`,
visibleInShowHideMenu: false,
}),
columnHelper.accessor('email', {
header: 'Email',
visibleInShowHideMenu: false,
}),
columnHelper.accessor('phoneNumber', {
header: 'Phone number',
visibleInShowHideMenu: false,
}),
],
[]
)
const columns = useMemo<MRT_ColumnDef<any>[]>(
const columns = useMemo(
() => [...visibleColumns, ...exportExclusiveColumns],
[
exportExclusiveColumns,
@@ -217,7 +218,7 @@ export const GeneralTab = ({
]
)
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
const defaultOptions = getDefaultMRTOptions<FormattedStudentData>(setExportData, setExportModalOpen, language)
const table = useMaterialReactTable({
...defaultOptions,
@@ -236,7 +237,7 @@ export const GeneralTab = ({
return (
<>
<ExportToExcelDialog
<ExportToExcelDialog<FormattedStudentData>
exportColumns={exportCols}
exportData={exportData}
featureName="general_student_information"
@@ -1,4 +1,3 @@
import { useSelector } from 'react-redux'
import { getStudentTotalCredits, getHighestGradeOfCourseBetweenRange } from '@/common'
import { creditDateFilter } from '@/components/FilterView/filters'
import { useFilters } from '@/components/FilterView/useFilters'
@@ -49,6 +48,7 @@ export type FormattedStudentData = {
attainmentDate?: string
enrollmentDate?: string
language?: string
tvex?: boolean
}
export const GeneralTabContainer = ({
@@ -60,6 +60,8 @@ export const GeneralTabContainer = ({
courseCode,
from,
to,
studyRights,
showBachelorAndMaster,
}) => {
const { getTextIn } = useLanguage()
const { isAdmin } = useGetAuthorizedUserQuery()
@@ -75,17 +77,12 @@ export const GeneralTabContainer = ({
const { useFilterSelector } = useFilters()
// @ts-expect-error add typing
const { query } = useSelector(({ populations }) => populations)
const queryStudyrights = Object.values(query?.studyRights ?? {}).filter(studyright => !!studyright) as string[]
const queryStudyrights = Object.values(studyRights ?? {}).filter(studyright => !!studyright) as string[]
const degreeProgrammeTypes = useDegreeProgrammeTypes(queryStudyrights)
const creditDateFilterOptions = useFilterSelector(creditDateFilter.selectors.selectOptions)
const creditDateFilterOptions = useFilterSelector(creditDateFilter.selectors.selectOptions())
const studyGuidanceGroupProgrammes = group?.tags?.studyProgramme?.includes('+')
? group?.tags?.studyProgramme.split('+')
: [group?.tags?.studyProgramme]
const programmeCode = query?.studyRights?.programme || studyGuidanceGroupProgrammes[0] || customPopulationProgramme
const [sggProgramme, sggCombinedProgramme] = group?.tags?.studyProgramme?.split('+') ?? []
const programmeCode = studyRights?.programme ?? sggProgramme ?? customPopulationProgramme
const isMastersProgramme = degreeProgrammeTypes[programmeCode] === 'urn:code:degree-program-type:masters-degree'
@@ -104,7 +101,7 @@ export const GeneralTabContainer = ({
const getTransferredFrom = (student: any) =>
getTextIn(programmes[student.transferSource]?.name) ?? student.transferSource
const showBachelorAndMaster = query?.showBachelorAndMaster === 'true'
const shouldShowBachelorAndMaster = showBachelorAndMaster === 'true'
const getStudyRight = student => {
const code = programmeCode ?? studentToPrimaryProgrammeMap[student.studentNumber]?.code
@@ -133,11 +130,7 @@ export const GeneralTabContainer = ({
return acc
}, {})
const combinedProgrammeCode = query?.studyRights?.combinedProgramme
? query.studyRights.combinedProgramme
: studyGuidanceGroupProgrammes.length > 1
? studyGuidanceGroupProgrammes[1]
: null
const combinedProgrammeCode = studyRights?.combinedProgramme ?? sggCombinedProgramme ?? null
const getCombinedProgrammeCredits = student =>
student.studyplans?.find(plan => {
@@ -151,7 +144,7 @@ export const GeneralTabContainer = ({
})?.completed_credits ?? 0
const shouldShowAdmissionType =
parseInt(query?.year, 10) >= 2020 || parseInt(group?.tags?.year, 10) >= 2020 || variant === 'customPopulation'
parseInt(year, 10) >= 2020 || parseInt(group?.tags?.year, 10) >= 2020 || variant === 'customPopulation'
const getAdmissiontype = student => {
const studyRight = getStudyRight(student)
const admissionType = studyRight?.admissionType ?? 'Ei valintatapaa'
@@ -183,7 +176,7 @@ export const GeneralTabContainer = ({
}
const parseTags = tags => {
const studentTags = tags?.map(tag => tag.ta.tagname)
const studentTags = tags?.map(studentTag => studentTag.tag.tagname)
return studentTags.join(', ')
}
@@ -346,7 +339,7 @@ export const GeneralTabContainer = ({
year,
currentSemester?.semestercode,
getTextIn,
showBachelorAndMaster
shouldShowBachelorAndMaster
)
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = getSemestersPresentFunctions({
@@ -358,10 +351,11 @@ export const GeneralTabContainer = ({
studentToSecondStudyrightEndMap,
studentToStudyrightEndMap,
year,
semestersToAddToStart: showBachelorAndMaster && isMastersProgramme ? 6 : 0,
semestersToAddToStart: shouldShowBachelorAndMaster && isMastersProgramme ? 6 : 0,
})
const formatStudent = (student: any): FormattedStudentData => {
const correctStudyRight = getStudyRight(student)
const result: FormattedStudentData = {
firstNames: student.firstnames,
lastName: student.lastname,
@@ -384,17 +378,18 @@ export const GeneralTabContainer = ({
startYearAtUniversity: getStartingYear(student),
programmes: getStudyProgrammes(student),
transferredFrom: student.transferredStudyRight ?? getTransferredFrom(student),
admissionType: shouldShowAdmissionType && getAdmissiontype(student),
admissionType: shouldShowAdmissionType ? getAdmissiontype(student) : null,
gender: getGender(student.gender_code),
citizenships: student.citizenships?.map(getTextIn).sort().join(', ') ?? null,
curriculumPeriod: student.curriculumVersion,
mostRecentAttainment: getMostRecentAttainment(student),
tvex: !!correctStudyRight?.tvex,
tags: parseTags(student.tags) ?? null,
extent: isAdmin && getExtent(student),
updatedAt: isAdmin && formatDate(student.updatedAt, DateFormat.ISO_DATE_DEV),
}
if (combinedProgrammeCode || showBachelorAndMaster) {
if (combinedProgrammeCode || shouldShowBachelorAndMaster) {
const secondaryStudyRightEnd = studentToSecondStudyrightEndMap[student.studentNumber]
result.creditsCombinedProg = getCombinedProgrammeCredits(student)
result.graduationDateCombinedProg = secondaryStudyRightEnd
@@ -403,7 +398,15 @@ export const GeneralTabContainer = ({
}
if (variant === 'customPopulation' && !programmeCode) {
result.primaryProgramme = getTextIn(studentToPrimaryProgrammeMap[student.studentNumber]?.name) ?? ''
const primaryProgramme = studentToPrimaryProgrammeMap[student.studentNumber]
if (primaryProgramme) {
const primaryProgrammeName = getTextIn(primaryProgramme.name) ?? ''
result.primaryProgramme = primaryProgramme.graduated
? `${primaryProgrammeName} (Graduated)`
: !primaryProgramme.active
? `${primaryProgrammeName} (Inactive)`
: primaryProgrammeName
}
}
if (variant === 'coursePopulation') {
@@ -437,7 +440,7 @@ export const GeneralTabContainer = ({
}}
formattedData={formattedData}
group={group}
isCombinedProg={!!combinedProgrammeCode || showBachelorAndMaster}
isCombinedProg={!!combinedProgrammeCode || shouldShowBachelorAndMaster}
showAdminColumns={isAdmin}
studyTrackVisible={containsStudyTracks}
variant={variant}
@@ -1,9 +1,14 @@
import { Check as CheckIcon, CropSquare as CropSquareIcon } from '@mui/icons-material'
import { Box, Divider, Tooltip, Typography } from '@mui/material'
import CheckIcon from '@mui/icons-material/Check'
import CropSquareIcon from '@mui/icons-material/CropSquare'
import Box from '@mui/material/Box'
import Divider from '@mui/material/Divider'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import {
type MRT_ColumnDef,
type MRT_VisibilityState,
MaterialReactTable,
createMRTColumnHelper,
useMaterialReactTable,
} from 'material-react-table'
import { useEffect, useMemo, useState } from 'react'
@@ -58,104 +63,121 @@ export const ModulesTab = ({
const isLoading = Object.keys(formattedModules).length === 0
const staticColumns = useMemo<MRT_ColumnDef<any>[]>(
const columnHelper = createMRTColumnHelper<FormattedStudent>()
const staticColumns = useMemo(
() => [
{
accessorKey: 'lastName',
columnHelper.accessor('lastName', {
header: 'Last name',
Footer: () => <DividedTableCell />,
},
{
accessorKey: 'firstNames',
}),
columnHelper.accessor('firstNames', {
header: 'First names',
Footer: () => <DividedTableCell />,
},
{
accessorKey: 'studentNumber',
}),
columnHelper.accessor('studentNumber', {
header: 'Student number',
Header: () => <Typography fontWeight="bold">Student number</Typography>,
Cell: ({ cell }) => (
<StudentInfoItem sisPersonId={cell.row.original.sis_person_id} studentNumber={cell.getValue<string>()} />
<StudentInfoItem sisPersonId={cell.row.original.sisPersonID} studentNumber={cell.getValue()} />
),
filterFn: 'startsWith',
Footer: () => <DividedTableCell bottom="Completed" top="Planned" />,
},
}),
columnHelper.accessor('studentNumber', {
header: 'Student number',
Header: () => <Typography fontWeight="bold">Student number</Typography>,
Cell: ({ cell }) => (
<StudentInfoItem sisPersonId={cell.row.original.sisPersonID} studentNumber={cell.getValue()} />
),
filterFn: 'startsWith',
Footer: () => <DividedTableCell bottom="Completed" top="Planned" />,
}),
],
[]
)
const dynamicColumns = useMemo<MRT_ColumnDef<any>[]>(() => {
const dynamicColumns = useMemo(() => {
if (!formattedModules) return []
return Object.keys(formattedModules).map(code => ({
accessorFn: (row: FormattedStudent) => {
const studyModule = getModuleIfExists(row, code)
return studyModule ? (studyModule.completed ? 1 : 0) : null // 1 for completed, 0 for in HOPS
},
Footer: ({ table, column }) => {
const { planned, completed } = table.getFilteredRowModel().rows.reduce(
(acc, row) => {
const value = row.getValue<number>(column.id)
if (value === 0) {
acc.planned += 1
} else if (value === 1) {
acc.completed += 1
}
return acc
return Object.keys(formattedModules).map(code =>
columnHelper.accessor(
row => {
const studyModule = getModuleIfExists(row, code)
return studyModule ? (studyModule.completed ? 1 : 0) : null // 1 for completed, 0 for in HOPS
},
{
Footer: ({ table, column }) => {
const { planned, completed } = table.getFilteredRowModel().rows.reduce(
(acc, row) => {
const value = row.getValue(column.id)
if (value === 0) {
acc.planned += 1
} else if (value === 1) {
acc.completed += 1
}
return acc
},
{ planned: 0, completed: 0 }
)
return <DividedTableCell bottom={completed} top={planned} />
},
{ planned: 0, completed: 0 }
)
return <DividedTableCell bottom={completed} top={planned} />
},
header: `${code} – ${getTextIn(formattedModules[code])}`,
Header: () => (
<Tooltip title={`${code} – ${getTextIn(formattedModules[code])}`}>
<Box
sx={{
display: 'inline flow-root',
writingMode: 'vertical-rl',
textOrientation: 'mixed',
whiteSpace: 'normal',
overflow: 'visible',
maxWidth: '15em',
minHeight: '160px',
maxHeight: '240px',
padding: '0.4em 0',
}}
>
<>
<Typography fontWeight="bold">{code}</Typography>
<Typography
header: `${code} – ${getTextIn(formattedModules[code])}`,
Header: () => (
<Tooltip title={`${code} – ${getTextIn(formattedModules[code])}`}>
<Box
sx={{
color: 'text.secondary',
fontWeight: 'normal',
display: 'inline flow-root',
writingMode: 'vertical-rl',
textOrientation: 'mixed',
whiteSpace: 'normal',
overflow: 'visible',
maxWidth: '15em',
minHeight: '160px',
maxHeight: '240px',
padding: '0.4em 0',
}}
>
{getTextIn(formattedModules[code])}
</Typography>
</>
</Box>
</Tooltip>
),
Cell: ({ cell }) => {
const studyModule = getModuleIfExists(cell.row.original, code)
if (!studyModule) return <Box />
return studyModule.completed ? (
<Box sx={{ display: 'flex', justifyContent: 'center' }} title={`Completed on ${studyModule.completionDate}`}>
<CheckIcon color="success" />
</Box>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center' }} title="Has the module in their primary study plan">
<CropSquareIcon sx={{ color: 'grey.500' }} />
</Box>
)
},
filterFn: 'empty',
}))
<>
<Typography fontWeight="bold">{code}</Typography>
<Typography
sx={{
color: 'text.secondary',
fontWeight: 'normal',
}}
>
{getTextIn(formattedModules[code])}
</Typography>
</>
</Box>
</Tooltip>
),
Cell: ({ cell }) => {
const studyModule = getModuleIfExists(cell.row.original, code)
if (!studyModule) return <Box />
return studyModule.completed ? (
<Box
sx={{ display: 'flex', justifyContent: 'center' }}
title={`Completed on ${studyModule.completionDate}`}
>
<CheckIcon color="success" />
</Box>
) : (
<Box
sx={{ display: 'flex', justifyContent: 'center' }}
title="Has the module in their primary study plan"
>
<CropSquareIcon sx={{ color: 'grey.500' }} />
</Box>
)
},
filterFn: 'empty',
}
)
)
}, [formattedModules, getTextIn])
const columns = useMemo(() => [...staticColumns, ...dynamicColumns], [staticColumns, dynamicColumns])
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
const defaultOptions = getDefaultMRTOptions<FormattedStudent>(setExportData, setExportModalOpen, language)
const table = useMaterialReactTable({
...defaultOptions,
@@ -200,7 +222,7 @@ export const ModulesTab = ({
return (
<>
<ExportToExcelDialog
<ExportToExcelDialog<FormattedStudent>
exportColumns={columns}
exportData={exportData}
featureName="modules_of_students"
@@ -9,7 +9,7 @@ export type FormattedStudent = {
lastName: string
studentNumber: string
studyModulesInHOPS: StudyModuleData[]
sis_person_id: string
sisPersonID: string
}
export type FormattedModules = Record<string, Record<string, string>>
@@ -52,7 +52,7 @@ const formatStudent = (student: any, degreeProgrammeCodes: string[]): FormattedS
lastName: student.lastname,
studentNumber: student.studentNumber,
studyModulesInHOPS: getModulesFromRelevantStudyPlan(student, degreeProgrammeCodes),
sis_person_id: student.sis_person_id,
sisPersonID: student.sis_person_id,
}
}
@@ -91,5 +91,5 @@ export const ModulesTabContainer = ({ curriculum, students }) => {
[students, degreeProgrammeCodes]
)
return ModulesTab({ formattedModules, formattedStudents })
return <ModulesTab formattedModules={formattedModules} formattedStudents={formattedStudents} />
}
@@ -1,6 +1,5 @@
import moment from 'moment'
import { useRef } from 'react'
import { useSelector } from 'react-redux'
import { Tab } from 'semantic-ui-react'
import { coursePopulationToolTips, populationStatisticsToolTips } from '@/common/InfoToolTips'
@@ -35,6 +34,8 @@ const Panes = ({
variant,
visiblePanes,
year,
studyRights,
showBachelorAndMaster,
}) => {
const { handleTabChange, showSubstitutionToggle } = useTabChangeAnalytics()
const [includeSubstitutions, toggleIncludeSubstitutions] = useToggle(false)
@@ -53,6 +54,8 @@ const Panes = ({
filteredStudents={filteredStudents}
from={from}
group={studyGuidanceGroup}
showBachelorAndMaster={showBachelorAndMaster}
studyRights={studyRights}
to={to}
variant={variant}
year={year}
@@ -133,15 +136,18 @@ const PopulationStudents = ({
filteredStudents,
from,
to,
months: initMonths,
studyRights,
studyGuidanceGroup,
variant,
year,
showBachelorAndMaster,
}) => {
const studentRef = useRef()
const { query } = useSelector(({ populations }) => populations)
let mainProgramme = query?.studyRights?.programme || ''
let combinedProgramme = query?.studyRights?.combinedProgramme || ''
let months = query ? query.months : 0
let mainProgramme = studyRights?.programme || ''
let combinedProgramme = studyRights?.combinedProgramme || ''
let months = initMonths
if (studyGuidanceGroup && studyGuidanceGroup?.tags?.year) {
months = moment().diff(moment(`${studyGuidanceGroup?.tags?.year}-08-01`), 'months')
}
@@ -178,7 +184,9 @@ const PopulationStudents = ({
from={from}
mainProgramme={mainProgramme}
months={months}
showBachelorAndMaster={showBachelorAndMaster}
studyGuidanceGroup={studyGuidanceGroup}
studyRights={studyRights}
to={to}
variant={variant}
visiblePanes={contentToInclude.panesToInclude}
@@ -1,5 +1,9 @@
import { DoNotDisturb as DoNotDisturbIcon } from '@mui/icons-material'
import { Alert, AlertTitle, Box, Container, Link } from '@mui/material'
import DoNotDisturbIcon from '@mui/icons-material/DoNotDisturb'
import Alert from '@mui/material/Alert'
import AlertTitle from '@mui/material/AlertTitle'
import Box from '@mui/material/Box'
import Container from '@mui/material/Container'
import Link from '@mui/material/Link'
import { PageTitle } from '@/components/material/PageTitle'
import { useTitle } from '@/hooks/title'
@@ -59,8 +59,8 @@ const SingleStudyGroupContent = ({ filteredStudents, group }) => {
const coursesAreLoading = isLoading || isFetching
const creditDateFilterActive = useFilterSelector(creditDateFilter.selectors.isActive)
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive)
const creditDateFilterActive = useFilterSelector(creditDateFilter.selectors.isActive())
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive())
const toggleCreditDateFilter = () => {
if (creditDateFilterActive) {
@@ -1,5 +1,12 @@
import { Logout as LogoutIcon, Refresh as RefreshIcon } from '@mui/icons-material'
import { Backdrop, Box, Button, Container, Stack, Typography } from '@mui/material'
import LogoutIcon from '@mui/icons-material/Logout'
import RefreshIcon from '@mui/icons-material/Refresh'
import Backdrop from '@mui/material/Backdrop'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Container from '@mui/material/Container'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { random } from 'lodash'
import { useEffect, useState } from 'react'
import ReactHighchart from 'react-highcharts'
@@ -1,5 +1,5 @@
import { Check as CheckIcon } from '@mui/icons-material'
import { Box } from '@mui/material'
import CheckIcon from '@mui/icons-material/Check'
import Box from '@mui/material/Box'
export const CheckIconWithTitle = ({ visible, title }: { visible: boolean; title?: string }) => (
<Box sx={{ display: 'flex', justifyContent: 'center' }} title={title}>
@@ -1,4 +1,7 @@
import { FormControl, MenuItem, Select } from '@mui/material'
import FormControl from '@mui/material/FormControl'
import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select'
import { useEffect, useState } from 'react'
import { useGetCurriculumsQuery, useGetCurriculumOptionsQuery } from '@/redux/curriculum'
@@ -1,4 +1,9 @@
import { TableBody, TableCell, TableContainer, TableHead, TableRow, Paper } from '@mui/material'
import Paper from '@mui/material/Paper'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import { StyledTable } from '@/components/material/StyledTable'
@@ -1,8 +1,10 @@
import {
KeyboardArrowDown as KeyboardArrowDownIcon,
KeyboardArrowRight as KeyboardArrowRightIcon,
} from '@mui/icons-material'
import { Box, IconButton, TableCell, TableRow, Typography } from '@mui/material'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'
import Box from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import TableCell from '@mui/material/TableCell'
import TableRow from '@mui/material/TableRow'
import Typography from '@mui/material/Typography'
const roundValue = (value: number | string | undefined) => {
return typeof value === 'number' && !Number.isInteger(value) ? value.toFixed(1) : value
@@ -1,23 +1,22 @@
import {
Alert,
Button,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Typography,
} from '@mui/material'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogContentText from '@mui/material/DialogContentText'
import DialogTitle from '@mui/material/DialogTitle'
import Typography from '@mui/material/Typography'
import { pick, sampleSize } from 'lodash'
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
import { MaterialReactTable, MRT_ColumnDef, MRT_RowData, useMaterialReactTable } from 'material-react-table'
import { useCallback, useMemo } from 'react'
import { utils, writeFile } from 'xlsx'
import { ISO_DATE_FORMAT } from '@/constants/date'
import { getTimestamp, reformatDate } from '@/util/timeAndDate'
export const ExportToExcelDialog = ({
export const ExportToExcelDialog = <T extends MRT_RowData>({
open,
onClose,
exportColumns,
@@ -26,7 +25,7 @@ export const ExportToExcelDialog = ({
}: {
open: boolean
onClose: () => void
exportColumns: MRT_ColumnDef<any>[]
exportColumns: MRT_ColumnDef<T, any>[]
exportData: Record<string, unknown>[]
featureName: string
}) => {
@@ -1,5 +1,5 @@
import { OpenInNew as OpenInNewIcon } from '@mui/icons-material'
import { Link, LinkOwnProps } from '@mui/material'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import Link, { LinkOwnProps } from '@mui/material/Link'
/**
* Link that opens an external website in a new tab
@@ -1,4 +1,4 @@
import { Stack } from '@mui/material'
import Stack from '@mui/material/Stack'
import { GetAllGraduationStatsResponse } from '@/types/api/university'
import { GraduationTimes } from './GraduationTimes'
@@ -1,4 +1,4 @@
import { Stack } from '@mui/material'
import Stack from '@mui/material/Stack'
import { facultyToolTips } from '@/common/InfoToolTips'
import { FacultyProgressTable } from '@/components/material/FacultyProgressTable'
@@ -1,5 +1,6 @@
/* eslint-disable react/no-this-in-sfc */
import { Typography } from '@mui/material'
import Typography from '@mui/material/Typography'
import HighCharts from 'highcharts'
import accessibility from 'highcharts/modules/accessibility'
import exportData from 'highcharts/modules/export-data'
@@ -1,4 +1,10 @@
import { Collapse, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'
import Collapse from '@mui/material/Collapse'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import { Fragment, useState } from 'react'
import { ExpandableRow } from '@/components/material/ExpandableRow'
@@ -1,4 +1,5 @@
import { FormControlLabel, Switch } from '@mui/material'
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
export const FilterOldProgrammesToggle = ({ checked, onChange }: { checked: boolean; onChange: () => void }) => {
return (
@@ -1,4 +1,5 @@
import { Link } from '@mui/material'
import Link from '@mui/material/Link'
import { NavLink } from 'react-router'
/**
@@ -1,4 +1,5 @@
import { Stack, Typography } from '@mui/material'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { images } from '@/common'

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