Compare commits

..
38 Commits
Author SHA1 Message Date
Aaro Koivukangas d66fea3d7b [E2E] Fix test infobox wording 2026-10-12 15:31:34 +03:00
joonas-a dcfac28fa6 [Progress] combined programme criteria fix
thanks claude opus 5.5 high
2026-10-12 15:20:23 +03:00
joonas-a 09b743fb9b [Degree programme settings] separate criteria from visibility 2026-10-12 15:20:23 +03:00
joonas-a 36159c7305 [Progress tab] refactoring
- many logic fixes
- new: display also course code in addition to name
- better naming for columns
2026-10-12 15:20:23 +03:00
Aaro Koivukangas 1c84948f3c [ok.sh] Add more aliases for npm run both:real 2026-10-12 12:43:04 +03:00
joonas-a 631b652553 [Progress tab] Show when credit criteria is set, even when no course criteria 2026-10-07 11:15:37 +03:00
joonas-a 41d027e054 [Monthly credit accml] fix crash caused by date change mid-computation
uses months to compute keys rather than dates
2026-10-07 11:07:50 +03:00
joonas-a caa7739f37 [Ooditable] allow toolbar to optinally receive table state 2026-10-06 12:00:58 +03:00
joonas-a be91975591 [Users] bring back email copy button 2026-10-06 11:53:44 +03:00
joonas-a f62984143a [Users] extract component to own file 2026-10-06 11:36:37 +03:00
joonas-a 1645f568f8 [Users] Show loading component when waiting for data 2026-10-06 11:32:51 +03:00
Santeri Rajamäkiandjoonas-a 15dac7c158 [Frontend][Users] MRT to OT 2026-10-06 11:14:27 +03:00
joonas-a edc6182b20 [User page] layout fix 2026-10-06 10:44:08 +03:00
joonas-a 183a4cd782 [Frontend] fix singular/plural student(s) 2026-10-06 10:26:35 +03:00
Aaro Koivukangas c3f776c9ea [Vite] Add vite cache dir to frontend to fix permission errors 2026-10-06 09:06:33 +03:00
Aaro Koivukangas 5f96b20936 [ok.sh] Add option to append flags to commands 2026-10-06 09:06:33 +03:00
Aaro Koivukangas 50e37c8ef6 [Dockerfile] Remove unnecessary flags from npm ci 2026-10-06 09:06:33 +03:00
Aaro Koivukangas 8287a4a37f [Docker] Remove local .npm cache files from containers 2026-10-06 09:06:33 +03:00
joonas-a 1e46a27712 [Unit tests] test course visibility alert feature 2026-10-05 15:59:40 +03:00
joonas-a fe72648a77 [Modules table] show hidden modules if any 2026-10-05 15:59:40 +03:00
joonas-a 7c6fb10521 [Courses of class] add hidden course indicator to table 2026-10-05 15:59:40 +03:00
joonas-a f394a1515f [Frontend] margin consistency fix 2026-10-05 15:59:40 +03:00
joonas-a f0cf3a12a5 [Frontend][Courses tab] display when courses are hidden in table 2026-10-05 15:59:40 +03:00
Aaro Koivukangas 9e53a739f6 [ok.sh] Add more scripts to ok 2026-09-30 15:44:01 +03:00
Aaro Koivukangas 74e3e59cd8 [Tests][Updater] Fix tests failing when tests in dist run 2026-09-30 15:44:01 +03:00
joonas-a 8a14efa403 [Test] test 2026-09-30 15:42:05 +03:00
joonas-a a77c70150c [Custom pop course export] fix code/name incorrect in exported file 2026-09-30 15:41:58 +03:00
joonas-a 89dd5f1322 [Tests] Test course tab export 2026-09-30 15:29:20 +03:00
joonas-a 75d4c78adb [Courses tab] Fix export using incorrect column keys 2026-09-30 15:29:20 +03:00
Aaro Koivukangas 25bdf0cf8f [Scripts] Add dump cleaner so that images don't accumulate over time too much 2026-09-30 13:58:30 +03:00
joonas-a ca16291e37 [Kone] Migration step 2 of 2
- Remove old fields from db
- Not null enforced
- Remove dead code
2026-09-29 17:05:31 +03:00
joonas-a 1ced256f43 [Tests] add missing field due to type changes 2026-09-29 16:14:10 +03:00
joonas-a a00fb4b78e [Courses tab] Move away from idToGroupId map
more work to do still
2026-09-29 16:09:20 +03:00
joonas-a 874c8c6d00 [Class stats][RTKapi] drop unneeded data from response 2026-09-29 15:08:45 +03:00
joonas-a fef7faa8e7 [Backend][statisticsOf] add course groupId to student credits 2026-09-29 14:58:21 +03:00
joonas-a fa21354b8b [Hooks] make use of curriculum context 2026-09-29 13:51:15 +03:00
joonas-a ce2a035111 [SGG] better infobox regarding the curricula selector 2026-09-29 13:28:19 +03:00
joonas-a 1a48b7a028 [Hooks] Add curriculum context
use to avoid excess prop drilling and desynced state between the view
and editor modal
2026-09-29 13:21:10 +03:00
89 changed files with 2561 additions and 1483 deletions
+10 -9
View File
@@ -14,16 +14,17 @@ The quick guide should be enough for most purposes: updating schema, data, or th
./manage_test_dbs.sh release all # dump, upload and push images for every database
```
Actions are `download`, `dump`, `upload`, `push`, `release` (dump + upload + push) and `rebuild` (download + push). Each one is a thin call to a script in this folder, which can also be used on its own:
Actions are `download`, `dump`, `upload` (upload + prune), `push`, `prune`, `release` (dump + upload + prune + push) and `rebuild` (download + push). Each one is a thin call to a script in this folder, which can also be used on its own:
| Script | What it does |
| -------------------------- | ----------------------------------------------------------------------------------------------------- |
| `dump_local_db.sh` | `pg_dump`s the databases from the running containers into `.databasedumps/` |
| `download_dump_from_s3.sh` | Downloads the newest dump of each database from s3 (`-b <branch>` to pick dumps from a single branch) |
| `upload_dump_to_s3.sh` | Uploads the dumps in `.databasedumps/test` to s3 as `<database>-<git_branch>-<datetime>.sql` |
| `build_and_push_image.sh` | Builds database images from the current dumps and pushes them to the Toska registry |
| `create_s3_bucket.sh` | Creates the s3 bucket if it doesn't exist yet, run automatically before an upload |
| `lib/common.sh` | Shared configuration (dump directories, s3 bucket, registry, database names) |
| Script | What it does |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `dump_local_db.sh` | `pg_dump`s the databases from the running containers into `.databasedumps/` |
| `download_dump_from_s3.sh` | Downloads the newest dump of each database from s3 (`-b <branch>` to pick dumps from a single branch) |
| `upload_dump_to_s3.sh` | Uploads the dumps in `.databasedumps/test` to s3 as `<database>-<git_branch>-<datetime>.sql` |
| `prune_s3_dumps.sh` | Deletes dumps older than 30 days from s3, always keeping the newest `master` dump of each database (`-d <days>`, `-n`) |
| `build_and_push_image.sh` | Builds database images from the current dumps and pushes them to the Toska registry |
| `create_s3_bucket.sh` | Creates the s3 bucket if it doesn't exist yet, run automatically before an upload |
| `lib/common.sh` | Shared configuration (dump directories, s3 bucket, registry, database names) |
Dumps end up in `.databasedumps/test/<database>.sql`, which is where `db.Dockerfile` reads them from when an image is built. The s3 commands read their credentials from the `ACCESS_KEY` and `SECRET_KEY` environment variables (e.g. in CI). If neither is set, they fall back to `~/.s3cfg`, which can be found on the toska/dokumentaatio repo.
+2
View File
@@ -22,6 +22,8 @@ S3_HOST="s3.datacloud.helsinki.fi"
S3_BUCKET="s3://oodikone"
# Dumps live under a prefix of their own inside the bucket
S3_TARGET="$S3_BUCKET/test-dbs"
# Dumps older than this are pruned from s3, except for the newest dump of each database
S3_DUMP_RETENTION_DAYS=30
REGISTRY="registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi"
+14 -4
View File
@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR"/lib/common.sh
ACTIONS=("download" "dump" "upload" "push" "release" "rebuild")
ACTIONS=("download" "dump" "upload" "push" "prune" "release" "rebuild")
usage() {
cat >&2 <<EOF
@@ -28,10 +28,11 @@ Actions:
download Download the newest dumps from s3
dump Dump the databases from the running docker containers
upload Upload the current dumps to s3
upload Upload the current dumps to s3, then prune old dumps
push Build images from the current dumps and push them to the registry
prune Delete dumps older than $S3_DUMP_RETENTION_DAYS days from s3, keeping the newest one
release Dump from the running containers, then upload and push (shorthand)
release Dump from the running containers, then upload, prune and push (shorthand)
rebuild Download the dumps from s3, then push (shorthand)
Databases: ${DATABASES[*]} all
@@ -52,6 +53,10 @@ upload_dump() {
"$SCRIPT_DIR"/upload_dump_to_s3.sh "$1"
}
prune_dumps() {
"$SCRIPT_DIR"/prune_s3_dumps.sh "$1"
}
push_image() {
"$SCRIPT_DIR"/build_and_push_image.sh "$1"
}
@@ -63,11 +68,16 @@ run_action() {
case "$action" in
download) download_dump "$database" ;;
dump) dump_database "$database" ;;
upload) upload_dump "$database" ;;
upload)
upload_dump "$database"
prune_dumps "$database"
;;
push) push_image "$database" ;;
prune) prune_dumps "$database" ;;
release)
dump_database "$database"
upload_dump "$database"
prune_dumps "$database"
push_image "$database"
;;
rebuild)
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Delete old database dumps from s3. Dumps older than the retention period are
# removed, but the newest master dump of each database is always kept, so that a
# database which hasn't changed in a while can still be downloaded. Dumps from
# other branches aren't kept, so that old branches don't accumulate dumps.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR"/lib/common.sh
# The newest dump of each database from this branch is never pruned
MASTER_BRANCH="master"
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") [-n|--dry-run] [-d|--days <days>] <database>... | all
Deletes the dumps in $S3_TARGET older than the given number of days
(default $S3_DUMP_RETENTION_DAYS). The newest $MASTER_BRANCH dump of each database is
always kept, or the newest dump of any branch if the database has no $MASTER_BRANCH dumps.
Databases: ${DATABASES[*]}
Options:
-n, --dry-run Print the dumps that would be deleted instead of deleting them
-d, --days <days> Delete dumps older than this many days
-h, --help Show this help
EOF
}
dry_run=false
days="$S3_DUMP_RETENTION_DAYS"
arguments=()
while [[ $# -gt 0 ]]; do
case "$1" in
-n | --dry-run)
dry_run=true
shift
;;
-d | --days)
days="${2-}"
[[ "$days" =~ ^[0-9]+$ ]] || {
usage
die "Expected a number of days for $1, got: $days"
}
shift 2
;;
-h | --help)
usage
exit 0
;;
-*)
usage
die "Unknown option: $1"
;;
*)
arguments+=("$1")
shift
;;
esac
done
if [[ ${#arguments[@]} -eq 0 ]]; then
usage
die "No database given"
fi
prune_database() {
local database="$1"
local deleted=0
# s3cmd ls prints "<date> <time> <size> <object>", so sorting by the first two
# columns in reverse puts the newest dump first
local dumps
dumps="$(s3 ls "$S3_TARGET/$database-" | sort -rk1,2)"
# Find the newest master dump or in the case of it missing, the newest dump in general
local kept
kept="$(awk -v pattern="/$database-$MASTER_BRANCH-[0-9]+-[0-9]+[.]sql$" '$4 ~ pattern { print $4; exit }' <<< "$dumps")"
[[ -n "$kept" ]] || kept="$(awk 'NR == 1 { print $4 }' <<< "$dumps")"
# Remove any dumps older than the dump retention date (or -d)
# except for master for which at least one dump is saved
while read -r date time _ dump; do
[[ -n "$dump" && "$dump" != "$kept" && "$date $time" < "$cutoff" ]] || continue
if [[ "$dry_run" == true ]]; then
infomsg "Dry run, would delete $dump"
else
s3 del "$dump"
fi
deleted=$((deleted + 1))
done <<< "$dumps"
if [[ "$dry_run" == true ]]; then
successmsg "Would have pruned $deleted dump(s) of $database older than $days days"
else
successmsg "Pruned $deleted dump(s) of $database older than $days days"
fi
}
resolve_databases "${arguments[@]}"
require_s3_config
cutoff="$(date -u -d "-$days days" '+%Y-%m-%d %H:%M')"
for database in "${selected_databases[@]}"; do
prune_database "$database"
done
+12 -6
View File
@@ -650,10 +650,14 @@ test.describe('Degree programme overview', () => {
})
test.describe('Curriculum section', () => {
test('is shown', async ({ page }) => {
await expect(page.getByTestId('curriculum-section')).toBeVisible()
await expect(page.getByText('Select curriculum to edit:')).toBeVisible()
await expect(page.getByTestId('curriculum-picker').getByText('2023–2026')).toBeVisible()
test('is shown in both settings tabs', async ({ page }) => {
for (const tab of ['criteria', 'visibility']) {
await page.getByTestId(`${tab}-tab`).click()
const panel = page.getByTestId(`${tab}-panel`)
await expect(panel.getByTestId('curriculum-section')).toBeVisible()
await expect(panel.getByText('Select curriculum to edit:')).toBeVisible()
await expect(panel.getByTestId('curriculum-picker').getByText('2023–2026')).toBeVisible()
}
})
test.skip('can be changed', () => {
return
@@ -664,7 +668,7 @@ test.describe('Degree programme overview', () => {
test('info box', async ({ page }) => {
await expect(page.getByTestId('credit-criteria-section')).toBeVisible()
await page.getByTestId('credit-criteria-info-box-button').click()
await expect(page.getByTestId('credit-criteria-info-box-content').getByText('Tässä asetettu')).toBeVisible()
await expect(page.getByTestId('credit-criteria-info-box-content').getByText('Tässä asetetut')).toBeVisible()
})
test.skip('changing the credit criteria works', () => {
@@ -675,7 +679,9 @@ test.describe('Degree programme overview', () => {
})
test('Degree course table', async ({ page }) => {
await expect(page.getByTestId('degree-course-table')).toBeVisible()
await expect(page.getByTestId('criteria-panel').getByTestId('degree-course-table')).toBeVisible()
await page.getByTestId('visibility-tab').click()
await expect(page.getByTestId('visibility-panel').getByTestId('degree-course-table')).toBeVisible()
// TODO: Test clicking the arrow buttons
// TODO: Test toggling visibility for MODULES and COURSES
// TODO: Test changing criterion labels
+97 -20
View File
@@ -28,16 +28,28 @@ Run without a command to open the interactive menu.
Available commands:
up, u Start Oodikone with real data
rs Restart Oodikone with real data (down, then up)
test, t Start Oodikone with test/anon data
down, d Stop Oodikone
logs, l Follow oodikone service logs
logs:updater, lu Follow sis-updater service logs
importer, i Open a psql/pgcli shell to the importer database
updater Open a psql/pgcli shell to the updater (sis) database
install Add the 'ok' shell function to your shell rc file
-h, --help Print this help and exit
up, u, r, real Start Oodikone with real data
rs, rl Restart Oodikone with real data (down, then up)
anon, a Start Oodikone with anonymous data
down, d Stop Oodikone
logs, l Follow oodikone service logs
logs:updater, lu Follow sis-updater service logs
lint, li Lint the project or the path given as parameter
format, fmt, f Format the project or the path given as parameter
test:backend, tb Run backend unit tests
test:frontend, tf Run frontend unit tests
test:updater, tu Run updater (worker) unit tests
test:services, ts Run backend service tests
test:api, ta Run backend api tests
test:component, tc Run frontend component tests tests
test:e2e, te Run all e2e tests (add --ui to run in headed mode)
importer, i Open a psql/pgcli shell to the importer database
updater Open a psql/pgcli shell to the updater (sis) database
install Add the 'ok' shell function to your shell rc file
-h, --help Print this help and exit
EOF
exit
}
@@ -191,7 +203,7 @@ main_loop() {
# === Direct (non-interactive) commands ===
run_up() {
infomsg "Starting Oodikone with real data..."
npm run both:real
npm run both:real "$@"
}
run_restart() {
@@ -200,24 +212,53 @@ run_restart() {
npm run both:real
}
run_test() {
infomsg "Starting Oodikone with test data..."
npm run both
run_anon() {
infomsg "Starting Oodikone with anonymous data..."
npm run both "$@"
}
run_down() {
infomsg "Stopping Oodikone..."
npm run docker:down
npm run docker:down "$@"
}
run_logs() {
npm run logs
npm run logs "$@"
}
run_lint() {
npm run lint -- "$@"
}
run_format() {
npm run fmt -- "$@"
}
run_unit_tests() {
npm run test:unit --prefix "$@"
}
run_component_tests() {
npm run test:component --prefix services/frontend "$@"
}
run_service_tests() {
npm run test:services --prefix services/backend "$@"
}
run_api_tests() {
npm run test:api --prefix services/backend "$@"
}
run_e2e_tests() {
npm run playwright -- "$@"
}
run_logs_updater() {
docker compose --file docker-compose.yml logs sis-updater-worker sis-updater-scheduler -f
}
run_importer_db() {
local pgclient
pgclient="$(get_pgclient)"
@@ -271,17 +312,17 @@ cmd_install() {
setup_colors
case "${1-}" in
up|u)
up|u|r|real)
shift
run_up "$@"
;;
rs)
rs|rl)
shift
run_restart "$@"
;;
test|t)
anon|a)
shift
run_test "$@"
run_anon "$@"
;;
down|d)
shift
@@ -291,6 +332,42 @@ case "${1-}" in
shift
run_logs "$@"
;;
lint|li)
shift
run_lint "$@"
;;
format|fmt|f)
shift
run_format "$@"
;;
test:backend|tb)
shift
run_unit_tests services/backend "$@"
;;
test:frontend|tf)
shift
run_unit_tests services/frontend "$@"
;;
test:updater|tu)
shift
run_unit_tests updater/sis-updater-worker "$@"
;;
test:services|ts)
shift
run_service_tests "$@"
;;
test:api|ta)
shift
run_api_tests "$@"
;;
test:component|tc)
shift
run_component_tests "$@"
;;
test:e2e|te)
shift
run_e2e_tests "$@"
;;
logs:updater|lu)
shift
run_logs_updater "$@"
+1
View File
@@ -114,3 +114,4 @@ cypress/
**/*Dockerfile
**/build
**/dist
**/.npm
-4
View File
@@ -4,8 +4,6 @@ import { baseUrl, backendPort } from './config'
import { initializeDatabaseConnection, dbConnections } from './database/connection'
import { startCron } from './events'
import routes from './routes'
import { backfillExcludedCourseGroupIds } from './services/excludedCourses'
import { backfillProgressCriteriaGroupIds } from './services/studyProgramme/studyProgrammeCriteria'
import logger from './util/logger'
import './worker/worker'
@@ -15,8 +13,6 @@ initializeDatabaseConnection()
dbConnections.on('connect', () => {
logger.info('Connected to sis db successfully')
backfillExcludedCourseGroupIds().catch(error => logger.error(error))
backfillProgressCriteriaGroupIds().catch(error => logger.error(error))
})
dbConnections.on('error', () => {
@@ -0,0 +1,94 @@
/* eslint-disable no-console */
const { ARRAY, STRING } = require('sequelize')
const YEARS = ['one', 'two', 'three', 'four', 'five', 'six']
const UNIQUE_CONSTRAINT = 'excluded_courses_programme_version_group_key'
// Migration step 2 of 2
// Rows the backfill could not map to a course group id are removed, as they no longer reference a course.
// NOTE: down only restores the schema, the dropped course codes cannot be restored.
module.exports = {
up: async ({ context: queryInterface }) => {
await queryInterface.sequelize.transaction(async transaction => {
const [, unmapped] = await queryInterface.sequelize.query(
'DELETE FROM excluded_courses WHERE course_group_id IS NULL',
{ transaction }
)
console.log(`Deleted ${unmapped.rowCount} excluded courses without a course group id`)
const [, duplicates] = await queryInterface.sequelize.query(
`DELETE FROM excluded_courses a
USING excluded_courses b
WHERE a.id > b.id
AND a.programme_code = b.programme_code
AND a.curriculum_version IS NOT DISTINCT FROM b.curriculum_version
AND a.course_group_id = b.course_group_id`,
{ transaction }
)
console.log(`Deleted ${duplicates.rowCount} duplicate excluded courses`)
await queryInterface.removeColumn('excluded_courses', 'course_code', { transaction })
await queryInterface.changeColumn(
'excluded_courses',
'course_group_id',
{ type: STRING, allowNull: false },
{ transaction }
)
await queryInterface.addConstraint('excluded_courses', {
fields: ['programme_code', 'curriculum_version', 'course_group_id'],
type: 'unique',
name: UNIQUE_CONSTRAINT,
transaction,
})
for (const year of YEARS) {
const [[{ count }]] = await queryInterface.sequelize.query(
`SELECT COUNT(*) AS count FROM progress_criteria
WHERE course_group_ids_year_${year} IS NULL AND cardinality(courses_year_${year}) > 0`,
{ transaction }
)
console.log(`Emptied year ${year} of ${count} progress criteria without course group ids`)
await queryInterface.sequelize.query(
`UPDATE progress_criteria SET course_group_ids_year_${year} = '{}' WHERE course_group_ids_year_${year} IS NULL`,
{ transaction }
)
await queryInterface.removeColumn('progress_criteria', `courses_year_${year}`, { transaction })
await queryInterface.changeColumn(
'progress_criteria',
`course_group_ids_year_${year}`,
{ type: ARRAY(STRING), allowNull: false, defaultValue: [] },
{ transaction }
)
}
})
},
down: async ({ context: queryInterface }) => {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeConstraint('excluded_courses', UNIQUE_CONSTRAINT, { transaction })
await queryInterface.changeColumn(
'excluded_courses',
'course_group_id',
{ type: STRING, allowNull: true },
{ transaction }
)
await queryInterface.addColumn('excluded_courses', 'course_code', { type: STRING }, { transaction })
for (const year of YEARS) {
await queryInterface.changeColumn(
'progress_criteria',
`course_group_ids_year_${year}`,
{ type: ARRAY(STRING), allowNull: true, defaultValue: null },
{ transaction }
)
await queryInterface.addColumn(
'progress_criteria',
`courses_year_${year}`,
{ type: ARRAY(STRING) },
{ transaction }
)
}
})
},
}
@@ -16,9 +16,6 @@ export class ExcludedCourseModel extends Model implements ExcludedCourse {
@Column(DataType.STRING)
declare programme_code: ExcludedCourse['programme_code']
@Column(DataType.STRING)
declare course_code: ExcludedCourse['course_code']
@Column(DataType.STRING)
declare course_group_id: ExcludedCourse['course_group_id']
@@ -16,24 +16,6 @@ export class ProgressCriteriaModel extends Model implements ProgressCriteria {
@Column(DataType.STRING)
declare curriculumVersion: ProgressCriteria['curriculumVersion']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearOne: ProgressCriteria['coursesYearOne']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearTwo: ProgressCriteria['coursesYearTwo']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearThree: ProgressCriteria['coursesYearThree']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearFour: ProgressCriteria['coursesYearFour']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearFive: ProgressCriteria['coursesYearFive']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearSix: ProgressCriteria['coursesYearSix']
@Column(DataType.ARRAY(DataType.STRING))
declare courseGroupIdsYearOne: ProgressCriteria['courseGroupIdsYearOne']
@@ -1,8 +1,6 @@
import { Op } from 'sequelize'
import { CourseModel } from '../models'
import { ExcludedCourseModel } from '../models/kone'
import logger from '../util/logger'
export const addExcludedCourses = async (
courseGroupIds: string[],
@@ -44,42 +42,3 @@ export const removeExcludedCourses = async (
},
})
}
/**
* Fills course_group_id for rows that only have the legacy course_code. Safe to run repeatedly.
* Rows whose code cannot be found in the course table are left untouched and reported.
*/
export const backfillExcludedCourseGroupIds = async () => {
const legacyRows = await ExcludedCourseModel.findAll({
attributes: ['course_code'],
where: { course_group_id: null, course_code: { [Op.ne]: null } },
group: ['course_code'],
raw: true,
})
if (legacyRows.length === 0) return
const courseCodes = legacyRows.map(({ course_code }) => course_code!)
const courses = await CourseModel.findAll({
attributes: ['code', 'groupId'],
where: { code: { [Op.in]: courseCodes } },
raw: true,
})
const groupIdByCode = new Map(courses.map(({ code, groupId }) => [code, groupId]))
let updated = 0
for (const courseCode of courseCodes) {
const groupId = groupIdByCode.get(courseCode)
if (!groupId) continue
const [count] = await ExcludedCourseModel.update(
{ course_group_id: groupId },
{ where: { course_code: courseCode, course_group_id: null } }
)
updated += count
}
const unmapped = courseCodes.filter(code => !groupIdByCode.has(code))
logger.info(`Backfilled course_group_id for ${updated} excluded courses`)
if (unmapped.length > 0) {
logger.warn(`Could not find a course group id for excluded course codes: ${unmapped.join(', ')}`)
}
}
@@ -1,4 +1,4 @@
import { Op } from 'sequelize'
import { col, Op } from 'sequelize'
import { SISStudyRight, SISStudyRightElement, Student, Studyplan } from '@oodikone/shared/models'
import { Tag, TagStudent } from '@oodikone/shared/models/kone'
@@ -12,6 +12,7 @@ import {
StudyplanModel,
SISStudyRightModel,
SISStudyRightElementModel,
CourseModel,
} from '../../models'
import { TagModel, TagStudentModel } from '../../models/kone'
import { StudentCredit, StudentEnrollment } from '@oodikone/shared/types/studentData'
@@ -190,11 +191,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
},
}).then(data => data.map(x => x.get({ plain: true })))
export const getCredits = async (
studentNumbers: string[],
startDate: string,
endDate: string
): Promise<StudentCredit[]> =>
export const getCredits = async (studentNumbers: string[], startDate: string, endDate: string) =>
CreditModel.findAll({
raw: true,
attributes: [
@@ -208,12 +205,19 @@ export const getCredits = async (
'course_code',
'language',
'studyright_id',
[col('course.group_id'), 'courseGroupId'],
],
include: {
model: CourseModel,
attributes: [],
as: 'course',
required: true,
},
where: {
student_studentnumber: { [Op.in]: studentNumbers },
attainment_date: { [Op.between]: [startDate, endDate] },
},
})
}) as unknown as Promise<StudentCredit[]>
export const getStudyRightElementsForStudyRight = async (studentNumbers: string[], code: string) =>
SISStudyRightElementModel.findAll({
@@ -142,9 +142,7 @@ const labelProgrammes = (modules: ModuleWithChildren[], excludedCourses: Exclude
label: `${module.parent_code}\n${module.parent_name?.fi}`,
orderNumber: module.module_order,
}
const foundCourse = excludedCourses.find(course =>
course.course_group_id ? course.course_group_id === module.group_id : course.course_code === module.code
)
const foundCourse = excludedCourses.find(course => course.course_group_id === module.group_id)
const visible = { visibility: !foundCourse, id: foundCourse?.id ?? null }
return { ...module, label, visible }
@@ -1,21 +0,0 @@
export const CRITERIA_YEARS = ['One', 'Two', 'Three', 'Four', 'Five', 'Six'] as const
export type CriteriaYear = (typeof CRITERIA_YEARS)[number]
/**
* Converts course codes to course group ids, keeping the order and dropping duplicates.
* Codes that are not in the mapping are returned in `unmapped`.
*/
export const codesToGroupIds = (codes: readonly string[], groupIdByCode: ReadonlyMap<string, string>) => {
const groupIds = new Set<string>()
const unmapped: string[] = []
for (const code of codes) {
const groupId = groupIdByCode.get(code)
if (groupId) {
groupIds.add(groupId)
} else {
unmapped.push(code)
}
}
return { groupIds: [...groupIds], unmapped }
}
@@ -4,7 +4,6 @@ import { ProgressCriteria } from '@oodikone/shared/types'
import { CourseModel } from '../../models'
import { ProgressCriteriaModel } from '../../models/kone'
import logger from '../../util/logger'
import { CRITERIA_YEARS, codesToGroupIds } from './criteriaCourseGroupIds'
type CriteriaWithoutCurriculumVersion = Omit<ProgressCriteriaModel, 'curriculumVersion'>
@@ -14,16 +13,6 @@ const getCriteriaByStudyProgramme = async (code: string): Promise<CriteriaWithou
where: { code },
})
const getGroupIdByCode = async (codes: string[]) => {
if (codes.length === 0) return new Map<string, string>()
const courses: Array<Pick<CourseModel, 'code' | 'groupId'>> = await CourseModel.findAll({
attributes: ['code', 'groupId'],
where: { code: { [Op.in]: codes } },
raw: true,
})
return new Map(courses.map(({ code, groupId }) => [code, groupId]))
}
/** Construct course_group_id => substitutionGroups object */
const getSubstitutions = async (courseGroupIds: string[]) => {
const courses: Array<Pick<CourseModel, 'groupId' | 'substitutionGroups' | 'isPrimary'>> = await CourseModel.findAll({
@@ -49,28 +38,13 @@ const getSubstitutions = async (courseGroupIds: string[]) => {
)
}
/**
* TODO: Remove the fallback to coursesYear* (course codes) once all rows have courseGroupIdsYear*
* populated and the old columns are dropped.
*/
const formatCriteria = async (criteria: CriteriaWithoutCurriculumVersion | null) => {
const legacyCodes = CRITERIA_YEARS.flatMap(year =>
criteria && !criteria[`courseGroupIdsYear${year}`] ? (criteria[`coursesYear${year}`] ?? []) : []
)
const groupIdByCode = await getGroupIdByCode(legacyCodes)
const getYear = (year: (typeof CRITERIA_YEARS)[number]) => {
const courseGroupIds = criteria?.[`courseGroupIdsYear${year}`]
if (courseGroupIds) return courseGroupIds
return codesToGroupIds(criteria?.[`coursesYear${year}`] ?? [], groupIdByCode).groupIds
}
const yearOne = getYear('One')
const yearTwo = getYear('Two')
const yearThree = getYear('Three')
const yearFour = getYear('Four')
const yearFive = getYear('Five')
const yearSix = getYear('Six')
const yearOne = criteria?.courseGroupIdsYearOne ?? []
const yearTwo = criteria?.courseGroupIdsYearTwo ?? []
const yearThree = criteria?.courseGroupIdsYearThree ?? []
const yearFour = criteria?.courseGroupIdsYearFour ?? []
const yearFive = criteria?.courseGroupIdsYearFive ?? []
const yearSix = criteria?.courseGroupIdsYearSix ?? []
const courseGroupIds = [...yearOne, ...yearTwo, ...yearThree, ...yearFour, ...yearFive, ...yearSix]
const formattedCriteria: ProgressCriteria = {
@@ -197,39 +171,3 @@ export const getCriteria = async (studyProgramme: string) => {
return await formatCriteria(studyProgrammeCriteria)
}
/**
* Fills courseGroupIdsYear* for rows that only have the legacy coursesYear* (course codes). Safe to run repeatedly.
* A year whose codes cannot all be found in the course table is left untouched and reported.
*/
export const backfillProgressCriteriaGroupIds = async () => {
const rows = await ProgressCriteriaModel.findAll()
const rowsToBackfill = rows.filter(row =>
CRITERIA_YEARS.some(year => !row[`courseGroupIdsYear${year}`] && row[`coursesYear${year}`])
)
if (rowsToBackfill.length === 0) return
const allCodes = rowsToBackfill.flatMap(row => CRITERIA_YEARS.flatMap(year => row[`coursesYear${year}`] ?? []))
const groupIdByCode = await getGroupIdByCode([...new Set(allCodes)])
let updatedYears = 0
for (const row of rowsToBackfill) {
const updates: Partial<Record<`courseGroupIdsYear${(typeof CRITERIA_YEARS)[number]}`, string[]>> = {}
for (const year of CRITERIA_YEARS) {
const codes = row[`coursesYear${year}`]
if (row[`courseGroupIdsYear${year}`] || !codes) continue
const { groupIds, unmapped } = codesToGroupIds(codes, groupIdByCode)
if (unmapped.length > 0) {
logger.warn(
`Could not find a course group id for progress criteria codes of ${row.code} (${year}): ${unmapped.join(', ')}`
)
continue
}
updates[`courseGroupIdsYear${year}`] = groupIds
updatedYears++
}
if (Object.keys(updates).length > 0) await row.update(updates)
}
logger.info(`Backfilled course group ids for ${updatedYears} progress criteria years`)
}
@@ -139,17 +139,19 @@ export const getYearlyMonthlyCreditsObj = () => {
return yearlyMonthlyCredits
}
const getMonthlyCreditsObj = (academicStartYear?: number) => {
const getMonthlyCreditsObj = (academicStartYear = 2017) => {
const today = now()
const time = getStartDate(true, academicStartYear)
/* Compare whole months instead of dates, so the result does not depend on the time of day.
Academic year starts in August (month index 7). */
const firstMonth = academicStartYear * 12 + 7
const lastMonth = today.getFullYear() * 12 + today.getMonth()
const monthlyCredits: Record<string, number[]> = {}
today.setDate(1)
/* NB: JS months start at 0. We need them to start at 1. */
while (time < today) {
monthlyCredits[`${time.getFullYear()}-${time.getMonth() + 1}`] = []
time.setMonth(time.getMonth() + 1)
for (let month = firstMonth; month <= lastMonth; month++) {
monthlyCredits[`${Math.floor(month / 12)}-${(month % 12) + 1}`] = []
}
return monthlyCredits
@@ -191,8 +193,7 @@ export const computePercentiles = (
return values
}
// In the object programmes should be {bachelorCode: masterCode}
export const combinedStudyProgrammes = { KH90_001: 'MH90_001' } as const
export { combinedStudyProgrammes } from '@oodikone/shared/util/studyProgramme'
// There are 9 course_unit_types
// 1. urn:code:course-unit-type:regular
@@ -1,5 +1,12 @@
import { describe, it, assert, vi, beforeAll } from 'vitest'
import { getPercentage, getYearsArray, getYearsObject } from '@/services/studyProgramme/studyProgrammeHelpers'
import {
getMonthlyCredits,
getPercentage,
getYearlyMonthlyCreditsObj,
getYearsArray,
getYearsObject,
} from '@/services/studyProgramme/studyProgrammeHelpers'
import { clockStorage } from '@/util/clock'
void describe('Get years object', () => {
it('should return nothing with empty years', () => {
@@ -95,3 +102,36 @@ void describe('Get percentage', () => {
assert.strictEqual(getPercentage(-5, 10), '-50.0 %')
})
})
void describe('Get monthly credits', () => {
const atTime = <T>(date: string, fn: () => T) => clockStorage.run({ now: new Date(date) }, fn)
it('should include the current month regardless of the time of day', () => {
const lastKey = (date: string) => Object.keys(atTime(date, () => getYearlyMonthlyCreditsObj())[2024]).at(-1)
assert.strictEqual(lastKey('2026-10-07T00:05:00'), '2026-10')
assert.strictEqual(lastKey('2026-10-07T23:40:00'), '2026-10')
})
it('should not throw when computation continues past midnight', () => {
const monthlyCreditsByStartingYear = atTime('2026-10-06T23:40:00', () => getYearlyMonthlyCreditsObj())
assert.doesNotThrow(() =>
atTime('2026-10-07T00:05:00', () =>
getMonthlyCredits([], new Date('2024-08-01'), 2024, monthlyCreditsByStartingYear[2024])
)
)
})
it('should accumulate credits cumulatively by month', () => {
const monthlyCredits = atTime('2024-10-15T12:00:00', () => getYearlyMonthlyCreditsObj())[2024]
const credits = [
{ attainment_date: new Date('2024-08-20'), credits: 5 },
{ attainment_date: new Date('2024-10-01'), credits: 3 },
] as Parameters<typeof getMonthlyCredits>[0]
atTime('2024-10-15T12:00:00', () => getMonthlyCredits(credits, new Date('2024-08-01'), 2024, monthlyCredits))
assert.deepStrictEqual(monthlyCredits, { '2024-8': [5], '2024-9': [5], '2024-10': [8] })
})
})
+3
View File
@@ -1,6 +1,9 @@
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal
ENV TZ="Europe/Helsinki"
# Default install location is node_modules/.vite which is not writable
# by user 1001 when binding the local files to the container
ENV VITE_CACHE_DIR=/tmp/vite
WORKDIR /opt/app-root/shared
COPY ./shared .
@@ -191,28 +191,32 @@ However this is optional and you can create a new tag without selecting year.
degreeCoursesTab: {
creditCriteria: {
en: `The criteria set here will affect the Class Statistics → Students tab → Progress tab view.
en: `The targets set here will affect the Class Statistics → Students tab → Progress tab view.
Credit criteria is computed as follows: for the first academic year, the credits are counted
if they are completed during the first 12 months. For the second year, completions
within the first 24 months. Third year, the first 36 months, and so on.
`,
fi: `Tässä asetettu "criteria" vaikuttaa Class Statistics → Students tab → Progress tab näkymään.
fi: `Tässä asetetut tavoitteet vaikuttavat Class Statistics → Students tab → Progress tab näkymään.
Laskenta toimii seuraavasti: ensimmäisen akateemisen vuoden opintopisteet lasketaan, jos ne ovat suoritettu ensimmäisen 12 kuukauden ajalla. Toisen vuoden opintopisteet, jos ne ovat suoritettu ensimmäisen 24 kuukauden aikana. Kolmas, ensimmäiset 36kk, ja niin edelleen.
`,
},
programmeCriteriaAndVisibility: {
en: `Click the arrow next to the module to view courses belonging to the module in the curriculum. Setting a criterion label for a course will affect which academic year the course is placed under in Class Statistics → Students → Progress tab.
requiredCourses: {
en: `Click a module to see its courses in the selected curriculum.
Courses' visibilities can also be toggled here; hiding a course here will additionally hide it in the Courses of Class view and Students → Courses tab.
Tick a year to make the course a required course for that academic year in Class Statistics → Students → Progress tab. A course can be required for several years. The numbers on a module row show how many of its courses are required for each year.`,
fi: `Klikkaa opintokokonaisuutta nähdäksesi sen kurssit valitussa opetussuunnitelmassa.
Note: If hiding a module, all courses belonging to the module will also be hidden.`,
fi: `Klikkaa nuolta opintokokonaisuuden nimen vieressä näyttääksesi opetussuunitelmassa opintokokonaisuuden alle sijoitetut kurssit. Kursseille voi asettaa "Criterion label":eita, jotka vaikuttavat minkä akateemisen vuoden alla kurssi näytetään Class Statistics → Students → Progress tab näkymässä.
Valitse vuosi, jolloin kurssi on tarkoitus suorittaa. Kurssi näytetään kyseisen akateemisen vuoden alla Class Statistics → Students → Progress tab -näkymässä. Kurssi voi olla pakollinen useammalle vuodelle. Opintokokonaisuuden rivillä olevat luvut kertovat, kuinka moni sen kursseista on pakollinen kunakin vuonna. Huom. kurssi voi kuulua useaan opintokokonaisuuteen.`,
},
courseVisibility: {
en: `Click a module to see its courses in the selected curriculum.
Kurssien näkyvyyttä voidaan myös muuttaa tällä sivulla. Kurssin piilottaminen piilottaa sen myös Courses of Class ja Students → Courses tab näkymissä.
Hiding a course also hides it in the Courses of Class view and Students → Courses tab for all users. Hiding a module hides all of its courses.`,
fi: `Klikkaa opintokokonaisuutta nähdäksesi sen kurssit valitussa opetussuunnitelmassa.
Huom: Opintokokonaisuuden piilottaminen piilottaa myös kaikki sen alla olevat kurssit.`,
Kurssin piilottaminen piilottaa sen kaikilta käyttäjiltä myös Courses of Class ja Students → Courses tab -näkymissä. Opintokokonaisuuden piilottaminen piilottaa kaikki sen kurssit.`,
},
},
// Not used
@@ -20,10 +20,10 @@ export const CourseMatrixExport = ({
const { getTextIn } = useLanguage()
const getXlsx = () => {
const courseInfoById = new Map(
courses.map(({ course }) => [course.id, { code: course.code, name: getTextIn(course.name) ?? '' }])
const courseInfoByGroupId = new Map(
courses.map(({ course }) => [course.groupId, { code: course.code, name: getTextIn(course.name) ?? '' }])
)
const { completedCoursesRows, courseCounterRows } = calculateExcelData(students, courseInfoById)
const { completedCoursesRows, courseCounterRows } = calculateExcelData(students, courseInfoByGroupId)
// Columns: Student number, Name, student's attainments in the format: "course name (course code)"
const completedCoursesSheet = utils.aoa_to_sheet([['Student number', 'Name'], ...completedCoursesRows])
@@ -63,7 +63,7 @@ export const CustomPopulationContent = ({
const panels = [
{
title: `Credit accumulation (for ${filteredStudents.length} students)`,
title: `Credit accumulation (for ${filteredStudents.length} student${filteredStudents.length > 1 ? 's' : ''})`,
content: (
<CreditAccumulationGraph
programmeCodes={[associatedProgramme].filter(Boolean) as string[]}
@@ -21,39 +21,39 @@ export type CourseMatrixAggregate = {
/**
* Passed completions of the student that are included in one of the student's study plans (HOPS).
* Returns the latest completion per course id, sorted by course id.
* Returns the latest completion per course group, sorted by course group id.
*/
export const getHopsCourses = (student: FormattedStudent): StudentCourse[] => {
const hopsCourseIds = new Set(student.studyplans?.flatMap(plan => plan.included_courses ?? []) ?? [])
const passedCoursesInHops = student.courses.filter(course => course.passed && hopsCourseIds.has(course.course_id))
const latestById = new Map<string, StudentCourse>()
const latestByGroupId = new Map<string, StudentCourse>()
for (const course of passedCoursesInHops) {
if (!course.course_id) continue
if (!course.courseGroupId) continue
const existing = latestById.get(course.course_id)
const existing = latestByGroupId.get(course.courseGroupId)
if (!existing || new Date(course.date).getTime() > new Date(existing.date).getTime()) {
latestById.set(course.course_id, course)
latestByGroupId.set(course.courseGroupId, course)
}
}
return [...latestById.values()].sort((a, b) => a.course_id.localeCompare(b.course_id))
return [...latestByGroupId.values()].sort((a, b) => a.courseGroupId.localeCompare(b.courseGroupId))
}
export const calculateExcelData = (
students: FormattedStudent[],
courseInfoById: Map<string, { code: string; name: string }>
courseInfoByGroupId: Map<string, { code: string; name: string }>
) => {
// Keyed by course_id internally so different courses are never accidentally merged;
// code/name are only for display.
// Keyed by course groupId internally so different courses are never accidentally merged,
// while different versions of the same course are; code/name are only for display.
const counters = new Map<string, { code: string; name: string; credits: number; students: Set<string> }>()
const rows = students.map(student => {
const courses = getHopsCourses(student).map(course => {
const id = course.course_id
const { code, name } = courseInfoById.get(id) ?? { code: id, name: '' }
const counter = counters.get(id) ?? { code, name, credits: 0, students: new Set() }
counters.set(id, counter)
const groupId = course.courseGroupId
const { code, name } = courseInfoByGroupId.get(groupId) ?? { code: course.course_code, name: '' }
const counter = counters.get(groupId) ?? { code, name, credits: 0, students: new Set() }
counters.set(groupId, counter)
counter.credits += course.credits ?? 0
counter.students.add(student.studentNumber)
@@ -7,10 +7,10 @@ import { FilterViewContext } from '@/components/FilterView/context'
import type { FilterContext, FilterOptions, GenericFilter } from '@/components/FilterView/filters/createFilter'
import { FilterTray } from '@/components/FilterView/FilterTray'
import { useFilterStorage } from '@/components/FilterView/useFilterStorage'
import type { ExpandedCourseStats } from '@/redux/populations/util'
import type { FilteredCourse } from '@/util/coursesOfPopulation'
import { filterCourses } from '@/util/coursesOfPopulation'
import type { FormattedStudent as Student } from '@oodikone/shared/types/studentData'
import type { PopulationCourseStats } from '@oodikone/shared/routes/populations'
export const FilterView = <Options extends FilterOptions>({
children,
@@ -20,7 +20,7 @@ export const FilterView = <Options extends FilterOptions>({
initialOptions,
}: {
students: Student[]
coursestatistics: ExpandedCourseStats | undefined
coursestatistics: PopulationCourseStats | undefined
children: (filteredStudents: Student[], filteredCourses: FilteredCourse[]) => ReactNode
filters: GenericFilter<Options>[]
initialOptions: Options
@@ -0,0 +1,43 @@
import { StyledMessage } from '@/components/common/StyledMessage'
import { getHiddenCourseCodes, getHiddenModuleCodes } from '@/components/HiddenCourseAlert/util'
import { useCurriculum } from '@/hooks/useCurriculum'
import Tooltip from '@mui/material/Tooltip'
/**
* Displays if course visibility is modified in the degree programme settings
*/
export const HiddenCourseAlert = ({
ooditable,
modules,
}: {
/** aligns to the right edge in toolbar */
ooditable?: true
/** show only modules whose courses are all hidden */
modules?: true
}) => {
const { curriculum } = useCurriculum()
const curriculumCourses = curriculum
? [...curriculum.defaultProgrammeCourses, ...curriculum.secondProgrammeCourses]
: []
const hiddenCodes = modules ? getHiddenModuleCodes(curriculumCourses) : getHiddenCourseCodes(curriculumCourses)
const count = hiddenCodes.length
if (!count) return null
const [singular, plural] = modules ? ['module', 'modules'] : ['course', 'courses']
const alertText = `${count} ${count > 1 ? plural : singular} hidden`
const tooltipText = `${hiddenCodes.slice(0, Math.min(count, 10)).join(', ')} ${count > 10 ? '+ ' + (count - 10).toString() + ' others' : ''}`
return (
<Tooltip title={tooltipText}>
<span style={ooditable ? { marginLeft: 'auto' } : {}}>
<StyledMessage showIcon severity="warning" sx={{ py: 0, px: 1, m: 0 }}>
{alertText}
</StyledMessage>
</span>
</Tooltip>
)
}
@@ -0,0 +1,18 @@
import type { ProgrammeCourse } from '@oodikone/shared/types'
export const getHiddenCourseCodes = (courses: ProgrammeCourse[]): string[] =>
courses.filter(course => course.visible.visibility === false).map(course => course.code)
/**
* A module is hidden if every course that has it as the parent is hidden
*/
export const getHiddenModuleCodes = (courses: ProgrammeCourse[]): string[] => {
const moduleVisibility = new Map<string, boolean>()
for (const course of courses) {
if (!course.parent_code) continue
const visible = course.visible.visibility !== false
moduleVisibility.set(course.parent_code, (moduleVisibility.get(course.parent_code) ?? false) || visible)
}
return [...moduleVisibility.entries()].filter(([, visible]) => !visible).map(([code]) => code)
}
@@ -17,18 +17,19 @@ export const OodiTableContainer = <OTData extends RowData>({
cy,
}: {
table: TableType<OTData>
toolbarContent?: ReactNode
toolbarContent?: ReactNode | ((table: TableType<OTData>) => ReactNode)
isExportView?: true
cy?: string
}) => {
const verticalHeaders = table.getState().useVerticalHeaders ?? []
const zebraStriped = table.getState().useZebrastripes
const toolbar = typeof toolbarContent === 'function' ? toolbarContent(table) : toolbarContent
return (
<Paper data-cy={cy} sx={{ my: 2, borderRadius: 0 }} variant="outlined">
{!!toolbarContent && (
<Stack direction="row" spacing={3} sx={{ p: 2, justifyContent: 'flex-start' }}>
{toolbarContent}
{!!toolbar && (
<Stack direction="row" spacing={3} sx={{ p: 2 }} useFlexGap>
{toolbar}
</Stack>
)}
<TableContainer
@@ -72,3 +72,17 @@ const tableOptions = {
},
}
```
## Toolbar
`toolbarContent` is rendered above the table. Pass a function instead of a node when the toolbar needs the table
instance, e.g. to act on the currently filtered rows. Keep filtering in the table (column/global filters) and read the
result from it rather than reimplementing the filter outside.
```tsx
<OodiTable
columns={columns}
data={data}
toolbarContent={table => <CopyButton rows={table.getFilteredRowModel().rows.map(row => row.original)} />}
/>
```
@@ -13,7 +13,7 @@ import { OodiTableContainer } from '@/components/OodiTable/OodiTable'
* @param data - Array of data objects of type TData
* @param columns - Array of column definitions for type TData
* @param isExportView (default false) Is table used in an export modal to provide preview
* @param toolbarContent - Shown above the table
* @param toolbarContent - Shown above the table. Pass a function to read the table instance, e.g. filtered rows
* @param options - Optional: Additional TableOptions
* @param cy - Optional: data-cy tag provided to the wrapper around the TableContainer
* @returns Table instance
@@ -31,7 +31,7 @@ export const OodiTable = <TData extends RowData>({
columns: ColumnDef<TData, any>[]
isExportView?: true
options?: Partial<TableOptions<TData>>
toolbarContent?: ReactNode
toolbarContent?: ReactNode | ((table: Table<TData>) => ReactNode)
cy?: string
}) => {
const [fallbackData] = useState()
@@ -6,12 +6,11 @@ import Typography from '@mui/material/Typography'
import type { Dispatch, SetStateAction } from 'react'
import { StudentAmountLimiter } from '@/components/common/StudentAmountLimiter'
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
import { useCurriculum } from '@/hooks/useCurriculum'
type CourseTableModeSelectorProps = {
courseTableMode: 'curriculum' | 'all'
setCourseTableMode: Dispatch<SetStateAction<'curriculum' | 'all'>>
curriculum: ExtendedCurriculumDetails | null
studentAmountLimit: number
onStudentAmountLimitChange: (input: string) => void
}
@@ -20,47 +19,50 @@ export const CourseTableModeSelector = ({
courseTableMode,
onStudentAmountLimitChange,
setCourseTableMode,
curriculum,
studentAmountLimit,
}: CourseTableModeSelectorProps) => (
<RadioGroup>
<FormControlLabel
control={
<Radio
checked={courseTableMode === 'curriculum'}
onChange={() => setCourseTableMode('curriculum')}
size="small"
/>
}
label={
<Stack flexDirection="row" gap={1} sx={{ alignContent: 'center' }}>
<Typography fontWeight={500}>Curriculum</Typography>
<Typography
fontWeight={800}
sx={{
color: courseTableMode === 'curriculum' ? 'text.primary' : 'text.disabled',
}}
>
{curriculum?.name ?? 'unavailable'}
</Typography>
</Stack>
}
/>
<Stack direction="row" sx={{ alignItems: 'center', mt: '0.5em' }}>
}: CourseTableModeSelectorProps) => {
const { curriculum } = useCurriculum()
return (
<RadioGroup>
<FormControlLabel
control={
<>
<Radio checked={courseTableMode === 'all'} onChange={() => setCourseTableMode('all')} size="small" />
<StudentAmountLimiter
disabled={courseTableMode !== 'all'}
onStudentAmountLimitChange={value => onStudentAmountLimitChange(value.toString())}
studentAmountLimit={studentAmountLimit}
/>
</>
<Radio
checked={courseTableMode === 'curriculum'}
onChange={() => setCourseTableMode('curriculum')}
size="small"
/>
}
label={
<Stack flexDirection="row" gap={1} sx={{ alignContent: 'center' }}>
<Typography fontWeight={500}>Curriculum</Typography>
<Typography
fontWeight={800}
sx={{
color: courseTableMode === 'curriculum' ? 'text.primary' : 'text.disabled',
}}
>
{curriculum?.name ?? 'unavailable'}
</Typography>
</Stack>
}
label=""
value={'all'}
/>
</Stack>
</RadioGroup>
)
<Stack direction="row" sx={{ alignItems: 'center', mt: '0.5em' }}>
<FormControlLabel
control={
<>
<Radio checked={courseTableMode === 'all'} onChange={() => setCourseTableMode('all')} size="small" />
<StudentAmountLimiter
disabled={courseTableMode !== 'all'}
onStudentAmountLimitChange={value => onStudentAmountLimitChange(value.toString())}
studentAmountLimit={studentAmountLimit}
/>
</>
}
label=""
value={'all'}
/>
</Stack>
</RadioGroup>
)
}
@@ -5,19 +5,20 @@ import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import { useState } from 'react'
import { DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
import { DegreeSettingsTab, DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
import { VisibilityIcon } from '@/theme'
import { ManageCoursesShownInfo } from '@/pages/StudyProgramme/DegreeCoursesTab/InfoBox'
const defaultText = 'Manage course visibility'
const defaultIcon = <VisibilityIcon />
export const FilterDegreeCoursesModal = ({
degreeProgramme,
initialTab,
text,
icon,
}: {
degreeProgramme: string
initialTab: DegreeSettingsTab
text?: string
icon?: React.ReactNode
}) => {
@@ -31,8 +32,7 @@ export const FilterDegreeCoursesModal = ({
<Dialog maxWidth="md" onClose={() => setModalOpen(false)} open={modalOpen}>
<DialogTitle>Degree programme settings</DialogTitle>
<DialogContent>
<ManageCoursesShownInfo />
<DegreeCoursesTab combinedProgramme="" degreeProgramme={degreeProgramme} />
<DegreeCoursesTab degreeProgramme={degreeProgramme} initialTab={initialTab} />
<Button color="primary" onClick={() => setModalOpen(false)} style={{ marginTop: '10px' }} variant="contained">
Close
</Button>
@@ -5,7 +5,6 @@ import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/Popu
import { PopulationCourseStats } from '@/components/PopulationCourseStats'
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
import { SegmentDimmer } from '@/components/SegmentDimmer'
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
import { PopulationQuery } from '@/types/populationSearch'
import { FilteredCourse } from '@/util/coursesOfPopulation'
@@ -14,7 +13,6 @@ export const PopulationCourses = ({
query,
filteredCourses,
onlyIamRights,
curriculum,
courseTableMode,
studentAmountLimit,
setShowModules,
@@ -24,7 +22,6 @@ export const PopulationCourses = ({
query: Pick<PopulationQuery, 'programme' | 'years'>
filteredCourses: FilteredCourse[]
onlyIamRights: boolean
curriculum: ExtendedCurriculumDetails
courseTableMode: 'curriculum' | 'all'
studentAmountLimit: number
setShowModules: (input: boolean) => void
@@ -39,7 +36,11 @@ export const PopulationCourses = ({
<>
<Stack direction="row" spacing={2} sx={{ my: 1, justifyContent: 'space-between' }}>
{query.programme && !onlyIamRights ? (
<FilterDegreeCoursesModal degreeProgramme={query.programme} text="Manage course / module visibility" />
<FilterDegreeCoursesModal
degreeProgramme={query.programme}
initialTab="visibility"
text="Manage course / module visibility"
/>
) : null}
<InfoBox content={tooltipText} />
</Stack>
@@ -47,7 +48,6 @@ export const PopulationCourses = ({
{courseTableMode === 'curriculum' ? (
<PopulationCourseStats
courseTableMode={courseTableMode}
curriculum={curriculum}
filteredCourses={filteredCourses}
onlyIamRights={onlyIamRights}
pending={isLoading}
@@ -0,0 +1,27 @@
type ExportModule = {
courses: { code: string; groupId: string }[]
}
/**
* Student rows are keyed by course groupId, which is not readable.
* Maps each course column's groupId to its course code for use as a key (and header) in the Excel export.
*/
export const getGroupIdToExportKey = (modules: Map<string, ExportModule>): Record<string, string> => {
const groupIdToExportKey: Record<string, string> = {}
const usedKeys = new Set<string>()
for (const { courses } of modules.values()) {
for (const { code, groupId } of courses) {
if (groupId in groupIdToExportKey) continue
const key = usedKeys.has(code) ? `${code} (${groupId})` : code
groupIdToExportKey[groupId] = key
usedKeys.add(key)
}
}
return groupIdToExportKey
}
export const renameKeys = <T extends object>(row: T, keyMap: Record<string, string>): Record<string, unknown> =>
Object.fromEntries(Object.entries(row).map(([key, value]) => [keyMap[key] ?? key, value]))
@@ -5,15 +5,19 @@ import { OodiTable } from '@/components/OodiTable'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { IncludeSubstitutionsToggle } from '@/components/PopulationComponents/Students/IncludeSubstitutionsToggle'
import { useGetColumnDefinitions } from '@/components/PopulationComponents/Students/Table/CoursesTab/columnDefinitions'
import {
getGroupIdToExportKey,
renameKeys,
} from '@/components/PopulationComponents/Students/Table/CoursesTab/excelExport'
import { useToggle } from '@/hooks/toggle'
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
import { useCurriculum } from '@/hooks/useCurriculum'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { CreditTypeCode, Name, ProgrammeCourse } from '@oodikone/shared/types'
import { FormattedStudent, StudentCourse } from '@oodikone/shared/types/studentData'
import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/PopulationCourses/FilterDegreeCoursesModal'
import { HiddenCourseAlert } from '@/components/HiddenCourseAlert'
type CoursesTabContainerProps = {
curriculum: ExtendedCurriculumDetails
students: FormattedStudent[]
courses: FilteredCourse[]
idToGroupIdMap: Record<string, string>
@@ -56,7 +60,7 @@ export type CourseTabModule = {
const studentMapper = (
student: FormattedStudent,
includeSubstitutions: boolean,
curriculumGroupIds: string[],
curriculumGroupIds: Set<string>,
substitutionsForGroupId: Record<string, string[][]>,
idToGroupIdMap: Record<string, string>,
groupIdToCode: Record<string, string>
@@ -65,25 +69,20 @@ const studentMapper = (
// NB: there can be many attainments/enrollments for each course group
// All passed courses that are included in curriculum
const passedCourses = student.courses.filter(
course => curriculumGroupIds.includes(idToGroupIdMap[course.course_id]) && course.passed
)
const passedCourses = student.courses.filter(course => curriculumGroupIds.has(course.courseGroupId) && course.passed)
const enrollments = student.enrollments.filter(enrollment =>
curriculumGroupIds.includes(idToGroupIdMap[enrollment.course_id])
curriculumGroupIds.has(idToGroupIdMap[enrollment.course_id])
)
const hopsGroupIds = student.studyplans.flatMap(studyPlan =>
studyPlan.included_courses
.map(id => idToGroupIdMap[id] ?? id)
.filter(groupId => curriculumGroupIds.includes(groupId))
studyPlan.included_courses.map(id => idToGroupIdMap[id] ?? id).filter(groupId => curriculumGroupIds.has(groupId))
)
// All passed courses including random AY-codes etc, translated to their groupId
const allPassedGroupIds = student.courses
.filter(course => course.passed)
.map(course => idToGroupIdMap[course.course_id])
.filter(Boolean)
// All passed courses including random AY-codes etc
const allPassedGroupIds = new Set(
student.courses.filter(({ passed }) => passed).map(({ courseGroupId }) => courseGroupId)
)
const substitutionsToCurriculumGroups = curriculumGroupIds.reduce<Record<string, string[][]>>((acc, groupId) => {
const substitutionsToCurriculumGroups = [...curriculumGroupIds].reduce<Record<string, string[][]>>((acc, groupId) => {
const substitutionsToCurriculumGroup = substitutionsForGroupId[groupId]
if (substitutionsToCurriculumGroup) {
acc[groupId] = substitutionsToCurriculumGroup
@@ -96,19 +95,18 @@ const studentMapper = (
Record<string, SubstitutedByEntry[]>
>((acc, groupId) => {
const passedSubstitutionGroups = substitutionsToCurriculumGroups[groupId].filter(substGroup =>
substGroup.every(sgGroupId => allPassedGroupIds.includes(sgGroupId))
substGroup.every(sgGroupId => allPassedGroupIds.has(sgGroupId))
)
// TODO: Implement better logic to select the most optimal substitution_groups, now we select shortest and first group
// Also this .find (and at(0)!) should never be undefined because the groupIds are student's completed courses => they exist under student.courses
const passedSubstitutionGroupCourses = passedSubstitutionGroups
.map(sg =>
sg.map((sgGroupId): SubstitutedByEntry => {
const course = student.courses.find(c => idToGroupIdMap[c.course_id] === sgGroupId)!
const course = student.courses.find(({ courseGroupId }) => courseGroupId === sgGroupId)!
return { code: groupIdToCode[sgGroupId] ?? sgGroupId, grade: course.grade, date: course.date }
})
)
.toSorted((a, b) => b.length - a.length)
.at(0)! // We know that this will exist
.toSorted((a, b) => b.length - a.length)[0] // We know that this will exist
if (passedSubstitutionGroups.length) {
acc[groupId] = passedSubstitutionGroupCourses
}
@@ -125,8 +123,8 @@ const studentMapper = (
// TODO: Same as above
const enrolledSubstitutionGroupEnrollments = enrolledSubstitutionGroups
.map(sg => sg.map(sgGroupId => student.enrollments.find(e => idToGroupIdMap[e.course_id] === sgGroupId)!))
.toSorted((a, b) => b.length - a.length)
.at(0)! // We know that this will exist
.toSorted((a, b) => b.length - a.length)[0]
// We know that this will exist
if (enrolledSubstitutionGroupEnrollments?.length) {
acc[groupId] = {
codes: enrolledSubstitutionGroupEnrollments.map(e => ({
@@ -164,7 +162,7 @@ const studentMapper = (
const mapCourses = (coursesToAdd: typeof passedCourses) => {
for (const course of coursesToAdd) {
const groupId = idToGroupIdMap[course.course_id]
const groupId = course.courseGroupId
if (!courseMap[groupId] || compareCourseGrades(courseMap[groupId], course)) {
courseMap[groupId] = {
@@ -263,13 +261,8 @@ const gradeOrdering = ['0', 'Hyl.', 'TT', 'HT', '1', '2', '3', '4', '5', 'Hyv.']
const compareCourseGrades = (previous: StudentCourse, current: StudentCourse) =>
gradeOrdering.indexOf(previous.grade) <= gradeOrdering.indexOf(current.grade)
export const CoursesTabContainer = ({
curriculum,
students,
courses,
idToGroupIdMap,
programme,
}: CoursesTabContainerProps) => {
export const CoursesTabContainer = ({ students, courses, idToGroupIdMap, programme }: CoursesTabContainerProps) => {
const { curriculum } = useCurriculum()
const { visible: namesVisible } = useStudentNameVisibility()
const [includeSubstitutions, toggleIncludeSubstitutions] = useToggle(true)
@@ -279,25 +272,21 @@ export const CoursesTabContainer = ({
)
const curriculumCourses = useMemo(
() => [...curriculum.defaultProgrammeCourses, ...curriculum.secondProgrammeCourses].filter(nonVisible),
() =>
curriculum
? [...curriculum.defaultProgrammeCourses, ...curriculum.secondProgrammeCourses].filter(nonVisible)
: [],
[curriculum]
)
// Curriculum courses only carry a code, so resolve each to the groupId of the matching course in this
// population (if the course has no data in the population there is nothing to resolve, so fall back to
// the code itself - it is only ever used as a unique key at that point, never for matching student data).
const codeToGroupId: Record<string, string> = useMemo(
() => Object.fromEntries(courses.map(({ course }) => [course.code, course.groupId])),
[courses]
)
const groupIdToCode: Record<string, string> = useMemo(
() => Object.fromEntries(courses.map(({ course }) => [course.groupId, course.code])),
[courses]
)
const curriculumGroupIds = useMemo(
() => curriculumCourses.map(course => codeToGroupId[course.code] ?? course.code),
[curriculumCourses, codeToGroupId]
const curriculumCourseGroupIds = useMemo(
() => new Set(curriculumCourses.map(course => course.group_id)),
[curriculumCourses]
)
// All substitutionGroups (already groupIds) for a given curriculum course's groupId
@@ -321,7 +310,7 @@ export const CoursesTabContainer = ({
if (!acc.has(parent)) {
acc.set(parent, { name: course.parent_name, courses: [] })
}
const groupId = codeToGroupId[course.code] ?? course.code
const groupId = course.group_id
const studyModule = acc.get(parent)!
// Prevent course from appearing under the same module twice
if (!studyModule.courses.some(existing => existing.groupId === groupId)) {
@@ -338,7 +327,7 @@ export const CoursesTabContainer = ({
// Returns fully sorted map
return new Map(Array.from(unsorted.entries()).sort())
}, [curriculumCourses, codeToGroupId])
}, [curriculumCourses])
/**
* Adds passed courses by the highest grade / most recent enrollments / hops status of courses
@@ -351,13 +340,13 @@ export const CoursesTabContainer = ({
studentMapper(
student,
includeSubstitutions,
curriculumGroupIds,
curriculumCourseGroupIds,
substitutionsForGroupId,
idToGroupIdMap,
groupIdToCode
)
),
[students, includeSubstitutions, curriculumGroupIds, substitutionsForGroupId, idToGroupIdMap, groupIdToCode]
[students, includeSubstitutions, curriculumCourseGroupIds, substitutionsForGroupId, idToGroupIdMap, groupIdToCode]
)
const columns = useGetColumnDefinitions(coursesByParentModule)
@@ -376,16 +365,24 @@ export const CoursesTabContainer = ({
},
}
// Export courses by their code instead of the groupId
const groupIdToExportKey = useMemo(() => getGroupIdToExportKey(coursesByParentModule), [coursesByParentModule])
const keysForExport: string[] = useMemo(() => {
const squashGroups = column => {
if (column.columns) return column.columns.flatMap(squashGroups)
return [column.accessorKey ?? column.id?.split(';')?.[1]]
}
const keys = columns.flatMap(squashGroups)
const keys = columns.flatMap(squashGroups).map(key => groupIdToExportKey[key] ?? key)
// Remove duplicates preserving order of initial occurences
return keys.filter((key, idx) => keys.indexOf(key) === idx)
}, [columns])
}, [columns, groupIdToExportKey])
const exportData = useMemo(
() => formattedStudents.map(student => renameKeys(student, groupIdToExportKey)),
[formattedStudents, groupIdToExportKey]
)
return (
<OodiTable
@@ -395,13 +392,14 @@ export const CoursesTabContainer = ({
options={tableOptions}
toolbarContent={
<>
<OodiTableExcelExport data={formattedStudents} exportColumnKeys={keysForExport} />
<FilterDegreeCoursesModal degreeProgramme={programme} />
<OodiTableExcelExport data={exportData} exportColumnKeys={keysForExport} />
<FilterDegreeCoursesModal degreeProgramme={programme} initialTab="visibility" />
<StudentNameVisibilityToggle />
<IncludeSubstitutionsToggle
includeSubstitutions={includeSubstitutions}
toggleIncludeSubstitutions={toggleIncludeSubstitutions}
/>
<HiddenCourseAlert ooditable />
</>
}
/>
@@ -14,6 +14,7 @@ import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import type { FormattedModules, ModuleTabStudent } from '@/components/PopulationComponents/Students/Table/ModulesTab'
import { CheckIcon, CropSquareIcon } from '@/theme'
import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/PopulationCourses/FilterDegreeCoursesModal'
import { HiddenCourseAlert } from '@/components/HiddenCourseAlert'
const getModuleIfExists = (student: ModuleTabStudent, moduleCode: string) =>
student.studyModulesInHOPS.find(studyModule => studyModule.code === moduleCode) ?? null
@@ -192,8 +193,13 @@ export const ModulesTab = ({
toolbarContent={
<>
<OodiTableExcelExport data={exportData} exportColumnKeys={accessorKeys} />
<FilterDegreeCoursesModal degreeProgramme={programme} text="Manage module visibility" />
<FilterDegreeCoursesModal
degreeProgramme={programme}
initialTab="visibility"
text="Manage module visibility"
/>
<StudentNameVisibilityToggle />
<HiddenCourseAlert ooditable modules />
</>
}
/>
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { ModulesTab } from '@/components/PopulationComponents/Students/Table/ModulesTab/ModulesTable'
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
import { useCurriculum } from '@/hooks/useCurriculum'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { createLocaleComparator } from '@/util/comparator'
import { formatISODate } from '@/util/timeAndDate'
@@ -86,18 +86,17 @@ const getDegreeProgrammeCodes = (curriculumModules): string[] => {
}
export const ModulesTabContainer = ({
curriculum,
students,
courses,
idToGroupIdMap,
programme,
}: {
curriculum: ExtendedCurriculumDetails | null | undefined
students: Student[]
courses: FilteredCourse[]
idToGroupIdMap: Record<string, string>
programme: string
}) => {
const { curriculum } = useCurriculum()
const curriculumModules = useMemo(
() => (curriculum ? [...curriculum.defaultProgrammeModules, ...curriculum.secondProgrammeModules] : []),
[curriculum]
@@ -2,10 +2,7 @@ import Box from '@mui/material/Box'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { createColumnHelper } from '@tanstack/react-table'
import dayjs, { extend as dayjsExtend } from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import dayjs from 'dayjs'
import { useMemo } from 'react'
import { Link } from '@/components/common/Link'
@@ -15,116 +12,44 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { OodiTable } from '@/components/OodiTable'
import { CopyStudentNumbersHeader } from '@/components/OodiTable/common/CopyStudentNumbersHeader'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/PopulationCourses/FilterDegreeCoursesModal'
import { TableInfo } from '@/components/PopulationComponents/Students/Table/ProgressTab/info'
import { DateFormat } from '@/constants/date'
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
import {
getCellText,
getCourseGroups,
getCriteriaHeaders,
hasCreditTransfer,
hasEnrolled,
hasFailed,
hasPassedDuringAcademicYear,
hasPassedOutsideAcademicYear,
IdToGroupId,
Label,
} from '@/components/PopulationComponents/Students/Table/ProgressTab/util'
import { useCurriculum } from '@/hooks/useCurriculum'
import { useSemesters } from '@/hooks/useSemesters'
import { useGetProgressCriteriaQuery } from '@/redux/progressCriteria'
import { CheckIcon, CloseIcon, EditIcon, RemoveIcon, SwapHorizIcon } from '@/theme'
import { isMedicalProgramme } from '@/util/studyProgramme'
import { formatDate } from '@/util/timeAndDate'
import {
CreditTypeCode,
EnrollmentState,
FormattedStudent,
Name,
ProgressCriteria,
SemesterEnrollment,
} from '@oodikone/shared/types'
import { StudentCourse } from '@oodikone/shared/types/studentData'
import { keyBy, range } from '@oodikone/shared/util'
import { FormattedStudent, ProgrammeCourse, ProgressCriteria, SemesterEnrollment } from '@oodikone/shared/types'
import { keyBy } from '@oodikone/shared/util'
import '@/components/PopulationComponents/Students/Table/ProgressTab/index.css'
import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/PopulationCourses/FilterDegreeCoursesModal'
dayjsExtend(isBetween)
dayjsExtend(isSameOrBefore)
dayjsExtend(isSameOrAfter)
const columnHelper = createColumnHelper<FormattedStudent>()
type IdToGroupId = Record<string, string>
/** Return course + all of it's substitution groups (courses are identified by their course group id) */
const getCourseGroups = (
courseGroupId: string,
criteria: ProgressCriteria | undefined,
student: FormattedStudent,
idToGroupId: IdToGroupId
): StudentCourse[][] => {
const studentGroupIds = student.courses.map(({ course_id }) => idToGroupId[course_id])
// Return groups that have all courses in student.courses
const courseGroups: StudentCourse[][] = []
for (const group of [[courseGroupId]].concat(criteria?.allCourseGroups[courseGroupId] ?? [])) {
if (group.every(groupId => studentGroupIds.includes(groupId))) {
// We know all of the group's courses exist, we just checked that
courseGroups.push(
...group.map(groupId =>
student.courses
.filter(course => idToGroupId[course.course_id] === groupId)
.toSorted((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
)
)
}
}
return courseGroups
const helpTexts = {
0: 'No information',
1: 'Active',
2: 'Absent',
3: 'Passive',
}
/**
Filter out failed courses that don't "break" a given group eg. remove the only instance of a course code
NOTE: Does not automatically remove all failed courses!
*/
const filterFailed = (group: StudentCourse[]) => (course: StudentCourse) =>
course.passed || !group.find(otherCourse => course.course_code === otherCourse.course_code && otherCourse.passed)
const hasCreditTransfer = (courseGroups: StudentCourse[][]) =>
courseGroups.some(group => {
const passedCredits = group.filter(filterFailed(group))
return passedCredits.length === 0
? false
: passedCredits.some(course => course.credittypecode === CreditTypeCode.APPROVED)
})
/** Check if any courseGroup has
1. all courses passed
2. *LATEST* attainment gotten during the wanted academic year
3. All attainments gotten before the wanted academic year's end
*/
const hasPassedDuringAcademicYear = (courseGroups: StudentCourse[][], start: dayjs.Dayjs, end: dayjs.Dayjs) => {
return courseGroups.some(group => {
const passedCredits = group.filter(filterFailed(group))
return passedCredits.length === 0
? false
: passedCredits.some(credit => dayjs(credit.date).isBetween(dayjs(start), dayjs(end))) &&
passedCredits.every(credit => dayjs(credit.date).isBefore(dayjs(end)))
})
const enrollmentClassNames = {
0: 'label-none',
1: 'label-present',
2: 'label-absent',
3: 'label-passive',
}
/** Check if any group has only passed courses */
const hasPassedOutsideAcademicYear = (courseGroups: StudentCourse[][]) =>
courseGroups.some(group => {
const passedCredits = group.filter(filterFailed(group))
return passedCredits.length === 0 ? false : passedCredits.every(credit => credit.passed)
})
/**
Check if there is a failed course that was not removed by the filterFailed eg. there is
a failed course that has no passed attainment
*/
const hasFailed = (courseGroups: StudentCourse[][]) =>
courseGroups.some(group => {
const passedCredits = group.filter(filterFailed(group))
return passedCredits.length === 0 ? false : passedCredits.some(course => !course.passed)
})
/** Return enrollments only for main courses */
const hasEnrolled = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
student.enrollments?.some(enrollment => idToGroupId[enrollment.course_id] === courseGroupId)
const getEnrollment = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
student.enrollments.filter(enrollment => idToGroupId[enrollment.course_id] === courseGroupId)
const getRowContent = (
student: FormattedStudent,
label: Label,
@@ -134,7 +59,7 @@ const getRowContent = (
criteria: ProgressCriteria | undefined,
idToGroupId: IdToGroupId
) => {
if (label.code.includes('Credits')) {
if (label.code === 'Credits') {
if (student.criteriaProgress[year]?.credits) {
return <CheckIcon color="success" />
}
@@ -167,73 +92,50 @@ const getRowContent = (
return null
}
const getExcelText = (
label: Label,
criteria: ProgressCriteria | undefined,
student: FormattedStudent,
year: string,
idToGroupId: IdToGroupId
const getSemesterEnrollmentStatus = (
semesterEnrollments: SemesterEnrollment[] | null | undefined,
semesters: number[]
) => {
if (label.code.includes('Credits')) {
return student.criteriaProgress[year]?.credits ? 'Passed' : ''
}
if (!label.groupId) return ''
const courseGroups = getCourseGroups(label.groupId, criteria, student, idToGroupId)
if (hasPassedOutsideAcademicYear(courseGroups)) {
const latestCompletedGroup = courseGroups.find(group => group.every(course => course.passed))
return `Passed ${latestCompletedGroup?.length === 1 ? formatDate(latestCompletedGroup[0]?.date, DateFormat.ISO_DATE) : '(substituted)'}`
}
if (hasFailed(courseGroups)) {
const latestFailedCourse = courseGroups
.find(group => group.some(course => !course.passed))
?.find(course => !course.passed)
return `Failed ${formatDate(latestFailedCourse ? latestFailedCourse.date : '(substituted)', DateFormat.ISO_DATE)}`
}
if (hasEnrolled(student, label.groupId, idToGroupId)) {
const enrollment = getEnrollment(student, label.groupId, idToGroupId)
return `Enrollment ${formatDate(enrollment[0].enrollment_date_time, DateFormat.ISO_DATE)}`
}
return ''
const fall = semesterEnrollments?.find(semester => semester.semester === Math.min(...semesters))?.type ?? 0
const spring = semesterEnrollments?.find(semester => semester.semester === Math.max(...semesters))?.type ?? 0
return { fall, spring }
}
const getCriteriaHeaders = (months: number, programme: string) => {
const criteriaHeaders = [
{ title: months < 12 ? 'Academic year 1 (in progress)' : 'Academic year 1', year: 'year1', label: 'yearOne' },
{ title: months < 24 ? 'Academic year 2 (in progress)' : 'Academic year 2', year: 'year2', label: 'yearTwo' },
{ title: months < 36 ? 'Academic year 3 (in progress)' : 'Academic year 3', year: 'year3', label: 'yearThree' },
]
if (isMedicalProgramme(programme)) {
criteriaHeaders.push(
{ title: months < 48 ? 'Academic year 4 (in progress)' : 'Academic year 4', year: 'year4', label: 'yearFour' },
{ title: months < 60 ? 'Academic year 5 (in progress)' : 'Academic year 5', year: 'year5', label: 'yearFive' },
{ title: months < 72 ? 'Academic year 6 (in progress)' : 'Academic year 6', year: 'year6', label: 'yearSix' }
)
}
return criteriaHeaders
const getSemesterEnrollmentContent = (
semesterEnrollments: SemesterEnrollment[] | null | undefined,
semesters: number[]
) => {
const { fall, spring } = getSemesterEnrollmentStatus(semesterEnrollments, semesters)
return (
<div style={{ display: 'flex', justifyContent: 'center', gap: '0.2rem' }}>
<div className={`enrollment-label ${enrollmentClassNames[fall]}`} title={`Fall: ${helpTexts[fall]}`} />
<div className={`enrollment-label ${enrollmentClassNames[spring]}`} title={`Spring: ${helpTexts[spring]}`} />
</div>
)
}
type Label = {
/** Course code for course labels, otherwise a special value ('Credits', 'Criteria', 'Enrollment') */
code: string
/** Set for course labels */
groupId?: string
name: Name | null
}
/** Start and end of the student's nth (0-indexed) academic year, counting from the population's start */
const getAcademicYear = (months: number, yearIndex: number) => ({
start: dayjs()
.subtract(months - 1, 'months')
.startOf('month')
.add(yearIndex, 'years'),
end: dayjs()
.subtract(months - 12, 'months')
.endOf('month')
.add(yearIndex, 'years'),
})
/** Column title in the Excel export, unique over all years */
const getExcelKey = (label: Label, yearIndex: number) => `Year ${yearIndex + 1}: ${label.name}`
export const ProgressTable = ({
curriculum,
idToGroupIdMap,
students,
months,
programme,
studyGuidanceGroupProgramme,
}: {
curriculum?: ExtendedCurriculumDetails | null
idToGroupIdMap: IdToGroupId
students: FormattedStudent[]
months: number
@@ -242,146 +144,163 @@ export const ProgressTable = ({
}) => {
const { visible: namesVisible } = useStudentNameVisibility()
const { getTextIn } = useLanguage()
const { curriculum } = useCurriculum()
const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: programme }, { skip: !programme })
const { semesters: allSemesters } = useSemesters()
const isStudyGuidanceGroupProgramme = studyGuidanceGroupProgramme !== ''
const creditMonths = [12, 24, 36, 48, 60, 72]
const defaultCourses = keyBy(curriculum?.defaultProgrammeCourses, 'group_id')
const coursesSecondProgramme = keyBy(curriculum?.secondProgrammeCourses, 'group_id')
const getCourse = (courseGroupId: string) => defaultCourses[courseGroupId] ?? coursesSecondProgramme[courseGroupId]
const labelCriteria = useMemo(() => {
const courses: Record<string, ProgrammeCourse | undefined> = {
...keyBy(curriculum?.secondProgrammeCourses, 'group_id'),
...keyBy(curriculum?.defaultProgrammeCourses, 'group_id'),
}
const labelCriteria = Object.keys(criteria?.courses ?? {}).reduce<Record<string, Label[]>>((acc, year, index) => {
acc[year] = [
{
code: 'Credits',
name: {
fi: `${creditMonths[index]} mos.: ${criteria?.credits[year]}`,
en: `${creditMonths[index]} mos.: ${criteria?.credits[year]}`,
sv: `${creditMonths[index]} mos.: ${criteria?.credits[year]}`,
return Object.keys(criteria?.courses ?? {}).reduce<Record<string, Label[]>>((acc, year, index) => {
acc[year] = [
{
code: 'Credits',
name: `At least ${criteria?.credits[year] ?? 0} credits during first ${(index + 1) * 12} months`,
},
},
...(criteria?.courses[year] ?? [])
.map((courseGroupId: string): Label => {
const course = getCourse(courseGroupId)
return { code: course?.code ?? courseGroupId, groupId: courseGroupId, name: course?.name ?? null }
})
.sort((a, b) => a.code.localeCompare(b.code)),
{
code: 'Criteria',
name: {
fi: `Year ${index + 1}: Fullfilled`,
en: `Year ${index + 1}: Fullfilled`,
sv: `Year ${index + 1}: Fullfilled`,
...(criteria?.courses[year] ?? [])
.map((courseGroupId: string): Label => {
const course = courses[courseGroupId]
const code = course?.code ?? courseGroupId
return {
code,
groupId: courseGroupId,
name: `${code} ${course ? getTextIn(course.name) : 'Unknown course'}`,
}
})
.sort((a, b) => a.code.localeCompare(b.code)),
{
code: 'Criteria',
name: `Year ${index + 1} criteria fulfilled`,
},
},
{
code: 'Enrollment',
name: { en: `Year ${index + 1}` },
},
]
return acc
}, {})
const criteriaHeaders = getCriteriaHeaders(months, programme)
const studentNumberToSemesterEnrollmentsMap = students.reduce<Record<string, SemesterEnrollment[] | null>>(
(acc, student) => {
const correctStudyRight = student.studyRights.find(studyRight =>
studyRight.studyRightElements.some(element => element.code === programme)
)
if (correctStudyRight) {
acc[student.studentNumber] = correctStudyRight.semesterEnrollments
}
{
code: 'Enrollment',
name: 'Semester enrollments',
},
]
return acc
},
{}
}, {})
}, [criteria, curriculum, getTextIn])
const criteriaHeaders = useMemo(
() => getCriteriaHeaders(months, programme).filter((_, yearIndex) => yearIndex === 0 || yearIndex * 12 < months),
[months, programme]
)
const helpTexts = {
0: 'No information',
1: 'Active',
2: 'Absent',
3: 'Passive',
}
const semesterEnrollmentsByStudent = useMemo(
() =>
students.reduce<Record<string, SemesterEnrollment[] | null>>((acc, student) => {
const correctStudyRight = student.studyRights.find(studyRight =>
studyRight.studyRightElements.some(element => element.code === programme)
)
if (correctStudyRight) {
acc[student.studentNumber] = correctStudyRight.semesterEnrollments
}
return acc
}, {}),
[students, programme]
)
const getSemesterEnrollmentStatus = (student: FormattedStudent, semesters: number[]) => {
const fall =
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
semester => semester.semester === Math.min(...semesters)
)?.type ?? 0
/** Academic year date range and its semester codes for each shown year */
const academicYears = useMemo(
() =>
criteriaHeaders.map((_, yearIndex) => {
const { start, end } = getAcademicYear(months, yearIndex)
const semesters = Object.values(allSemesters)
.filter(
semester => dayjs(semester.startdate).isSameOrAfter(start) && dayjs(semester.enddate).isSameOrBefore(end)
)
.map(semester => semester.semestercode)
return { start, end, semesters }
}),
[criteriaHeaders, months, allSemesters]
)
const spring =
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
semester => semester.semester === Math.max(...semesters)
)?.type ?? 0
const excelData = useMemo(
() =>
students.map(student => ({
'Student number': student.studentNumber,
'Last name': student.lastname,
'First names': student.firstnames,
...Object.fromEntries(
criteriaHeaders.flatMap(({ year, label: yearLabel }, yearIndex) =>
(labelCriteria[yearLabel] ?? []).flatMap(label => {
const key = getExcelKey(label, yearIndex)
switch (label.code) {
case 'Criteria':
return [[key, student.criteriaProgress[year]?.totalSatisfied ?? 0]]
return { fall, spring }
}
case 'Enrollment': {
const { fall, spring } = getSemesterEnrollmentStatus(
semesterEnrollmentsByStudent[student.studentNumber],
academicYears[yearIndex].semesters
)
return [
[`${key} - FALL`, helpTexts[fall]],
[`${key} - SPRING`, helpTexts[spring]],
]
}
const getSemesterEnrollmentVal = (student: FormattedStudent, semesters: number[]): [string, string] => {
const { fall, spring } = getSemesterEnrollmentStatus(student, semesters)
return [helpTexts[fall], helpTexts[spring]]
}
default:
return [[key, getCellText(label, criteria, student, year, idToGroupIdMap)]]
}
})
)
),
})),
[students, criteriaHeaders, labelCriteria, semesterEnrollmentsByStudent, academicYears, criteria, idToGroupIdMap]
)
const getSemesterEnrollmentContent = (student: FormattedStudent, semesters: number[]) => {
const enrollmentTypes = {
0: { className: 'label-none' },
1: { className: 'label-present' },
2: { className: 'label-absent' },
3: { className: 'label-passive' },
const accessorKeys = useMemo(
() => [
'Student number',
'Last name',
'First names',
...criteriaHeaders.flatMap(({ label: yearLabel }, yearIndex) =>
(labelCriteria[yearLabel] ?? []).flatMap(label => {
const key = getExcelKey(label, yearIndex)
return label.code === 'Enrollment' ? [`${key} - FALL`, `${key} - SPRING`] : [key]
})
),
],
[criteriaHeaders, labelCriteria]
)
const columns = useMemo(() => {
const getSortingValue = (student: FormattedStudent, label: Label, year: string) => {
// Values:
// -1 Course completed
// 0 Failed
// 1 Enrolled
// 2 None of above
if (!label.groupId) return 2
if (student.criteriaProgress[year]?.coursesSatisfied?.[label.groupId]) return -1
if (student.courses.find(course => idToGroupIdMap[course.course_id] === label.groupId)?.passed === false) return 0
if (hasEnrolled(student, label.groupId, idToGroupIdMap)) return 1
return 2
}
const fall =
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
semester => semester.semester === Math.min(...semesters)
)?.type ?? 0
const spring =
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
semester => semester.semester === Math.max(...semesters)
)?.type ?? 0
const fallText = `Fall: ${helpTexts[fall]}`
const springText = `Spring: ${helpTexts[spring]}`
return (
<div style={{ display: 'flex', justifyContent: 'center', gap: '0.2rem' }}>
<div className={`enrollment-label ${enrollmentTypes[fall]?.className}`} title={fallText} />
<div className={`enrollment-label ${enrollmentTypes[spring]?.className}`} title={springText} />
</div>
)
}
const getSortingValue = (student: FormattedStudent, label: Label, year: string) => {
// Values:
// -1 Course completed
// 0 Failed
// 1 Enrolled
// 2 None of aove
if (!label.groupId) return 2
const criteriaCourses = student.criteriaProgress[year]?.coursesSatisfied
const course = criteriaCourses?.[label.groupId]
if (course) return -1
if (student.courses.find(course => idToGroupIdMap[course.course_id] === label.groupId)?.passed === false) return 0
if (!course && hasEnrolled(student, label.groupId, idToGroupIdMap)) return 1
return 2
}
const createContent = (labels: Label[], year: string, start: dayjs.Dayjs, end: dayjs.Dayjs, semesters: number[]) => {
return (
labels?.map(label =>
const createContent = (labels: Label[], year: string, yearIndex: number) => {
const { start, end, semesters } = academicYears[yearIndex]
return labels.map(label =>
columnHelper.accessor(() => undefined, {
id: `${year}-${label.groupId ?? label.code}-${label.name?.fi ?? ''}`,
header: getTextIn(label.name) ?? label.code,
id: `${year}-${label.groupId ?? label.code}`,
header: label.name,
cell: ({ row: { original: student } }) => {
const title = label.code.includes('Criteria')
? `${student.criteriaProgress[year]?.totalSatisfied ?? 0} criteria fullfilled`
: getExcelText(label, criteria, student, year, idToGroupIdMap)
const title =
label.code === 'Criteria'
? `${student.criteriaProgress[year]?.totalSatisfied ?? 0} criteria fulfilled`
: getCellText(label, criteria, student, year, idToGroupIdMap)
const content = label.code.includes('Criteria')
? (student.criteriaProgress[year]?.totalSatisfied ?? 0)
: label.code.includes('Enrollment')
? getSemesterEnrollmentContent(student, semesters)
: getRowContent(student, label, year, start, end, criteria, idToGroupIdMap)
const content =
label.code === 'Criteria'
? (student.criteriaProgress[year]?.totalSatisfied ?? 0)
: label.code === 'Enrollment'
? getSemesterEnrollmentContent(semesterEnrollmentsByStudent[student.studentNumber], semesters)
: getRowContent(student, label, year, start, end, criteria, idToGroupIdMap)
return (
<Tooltip title={title}>
@@ -397,18 +316,26 @@ export const ProgressTable = ({
case 'Credits':
// NOTE: Convert boolean to int: (true -> 1, false -> 0) with + operator
// Also notice rowB rowA order
return +rowB.original.criteriaProgress[year].credits - +rowA.original.criteriaProgress[year].credits
return (
+!!rowB.original.criteriaProgress[year]?.credits - +!!rowA.original.criteriaProgress[year]?.credits
)
case 'Criteria':
// NOTE: rowB rowA order
return (
rowB.original.criteriaProgress[year].totalSatisfied -
rowA.original.criteriaProgress[year].totalSatisfied
(rowB.original.criteriaProgress[year]?.totalSatisfied ?? 0) -
(rowA.original.criteriaProgress[year]?.totalSatisfied ?? 0)
)
case 'Enrollment': {
const semestersA = getSemesterEnrollmentStatus(rowA.original, semesters)
const semestersB = getSemesterEnrollmentStatus(rowB.original, semesters)
const semestersA = getSemesterEnrollmentStatus(
semesterEnrollmentsByStudent[rowA.original.studentNumber],
semesters
)
const semestersB = getSemesterEnrollmentStatus(
semesterEnrollmentsByStudent[rowB.original.studentNumber],
semesters
)
return semestersA.fall + semestersA.spring - (semestersB.fall + semestersB.spring)
}
@@ -417,135 +344,10 @@ export const ProgressTable = ({
}
},
})
) ?? []
)
}
const generateYearColumns = (startYear: dayjs.Dayjs, endYear: dayjs.Dayjs, criteriaIndex: number) => {
const semesters = Object.values(allSemesters)
.filter(
semester =>
dayjs(semester.startdate).isSameOrAfter(startYear) && dayjs(semester.enddate).isSameOrBefore(endYear)
)
.map(semester => semester.semestercode)
}
return columnHelper.group({
id: criteriaHeaders[criteriaIndex].title,
header: criteriaHeaders[criteriaIndex].title,
columns: createContent(
labelCriteria[criteriaHeaders[criteriaIndex].label],
criteriaHeaders[criteriaIndex].year,
startYear,
endYear,
semesters
),
})
}
const academicYearStart = dayjs()
.subtract(months - 1, 'months')
.startOf('month')
const academicYearEnd = dayjs()
.subtract(months - 12, 'months')
.endOf('month')
// HACK: Have fun with this one.
const excelData = useMemo(
() =>
students.map(student => ({
'Student number': student.studentNumber,
'Last name': student.lastname,
'First names': student.firstnames,
...Object.fromEntries(
range(0, isMedicalProgramme(programme) ? 6 : 3)
.filter(year => year * 12 < months)
.flatMap(year => {
const startYear = dayjs(academicYearStart).add(year, 'years')
const endYear = dayjs(academicYearEnd).add(year, 'years')
const semesters = Object.values(allSemesters)
.filter(
semester =>
dayjs(semester.startdate).isSameOrAfter(startYear) &&
dayjs(semester.enddate).isSameOrBefore(endYear)
)
.map(semester => semester.semestercode)
return (
labelCriteria[criteriaHeaders[year].label]?.flatMap(label => {
let result: (string | number | null | undefined)[][] = []
switch (label.code) {
case 'Criteria': {
result = [
[getTextIn(label.name), student.criteriaProgress[`year${year + 1}`]?.totalSatisfied ?? 0],
]
break
}
case 'Enrollment': {
result = (() => {
const [fall, spring] = getSemesterEnrollmentVal(student, semesters)
return [
[`${getTextIn(label.name)} - FALL`, fall],
[`${getTextIn(label.name)} - SPRING`, spring],
]
})()
break
}
case 'Credits': {
result = [
[getTextIn(label.name), student.criteriaProgress[`year${year + 1}`]?.credits ? 'Passed' : ''],
]
break
}
default: {
const course = student.courses.find(course => idToGroupIdMap[course.course_id] === label.groupId)
const enrollment = student.enrollments.find(
enrollment => idToGroupIdMap[enrollment.course_id] === label.groupId
)
result = [
[
getTextIn(label.name),
course?.passed ? 'Passed' : enrollment?.state === EnrollmentState.ENROLLED ? 'Enrolled' : '',
],
]
break
}
}
return result
}) ?? []
)
})
.filter(([key, _]) => !!key)
),
})),
[students, allSemesters, programme, labelCriteria, idToGroupIdMap]
)
const accessorKeys = useMemo(
() => [
'Student number',
'Last name',
'First names',
...range(0, isMedicalProgramme(programme) ? 6 : 3)
.filter(year => year * 12 < months)
.flatMap(year => {
return (
labelCriteria[criteriaHeaders[year].label]?.flatMap(label => {
const labelName = getTextIn(label.name) ?? ''
return label.code === 'Enrollment' ? [`${labelName} - FALL`, `${labelName} - SPRING`] : [labelName]
}) ?? []
)
})
.filter(key => !!key),
],
[programme, labelCriteria]
)
const columns = useMemo(() => {
const columns = [
return [
columnHelper.group({
header: 'Student',
columns: [
@@ -571,37 +373,28 @@ export const ProgressTable = ({
: []),
],
}),
generateYearColumns(academicYearStart, academicYearEnd, 0),
...criteriaHeaders.map(({ title, year, label }, yearIndex) =>
columnHelper.group({
id: title,
header: title,
columns: createContent(labelCriteria[label] ?? [], year, yearIndex),
})
),
]
const addYearColumns = (year: number) => {
if (months > year * 12) {
columns.push(
generateYearColumns(
dayjs(academicYearStart).add(year, 'years'),
dayjs(academicYearEnd).add(year, 'years'),
year
)
)
}
}
addYearColumns(1)
addYearColumns(2)
if (isMedicalProgramme(programme)) {
addYearColumns(3)
addYearColumns(4)
addYearColumns(5)
}
return columns
}, [criteria, students, curriculum, getTextIn, namesVisible, idToGroupIdMap])
}, [
criteria,
criteriaHeaders,
labelCriteria,
academicYears,
semesterEnrollmentsByStudent,
namesVisible,
idToGroupIdMap,
])
const isCriteriaSet =
criteria && Object.keys(criteria.courses).some(yearCourses => criteria.courses[yearCourses].length > 0)
const tableOptions = {}
criteria &&
(Object.keys(criteria.courses).some(yearCourses => criteria.courses[yearCourses].length > 0) ||
Object.values(criteria.credits).some(creditCriteria => creditCriteria > 0))
return (
<>
@@ -612,28 +405,30 @@ export const ProgressTable = ({
</h5>
)}
<TableInfo />
<Box sx={{ display: 'flex' }}>
<Box sx={{ width: '100%' }}>
{isCriteriaSet ? (
<OodiTable
columns={columns}
data={students}
options={tableOptions}
toolbarContent={
<>
<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />
<FilterDegreeCoursesModal degreeProgramme={programme} text="Modify criteria" icon={<EditIcon />} />
<StudentNameVisibilityToggle />
</>
}
/>
) : (
<Typography variant="h6">
There is no criteria set for this programme. You can create one{' '}
<Link to={`/study-programme/${programme}?tab=3`}>here</Link>.
</Typography>
)}
</Box>
<Box sx={{ width: '100%' }}>
{isCriteriaSet ? (
<OodiTable
columns={columns}
data={students}
toolbarContent={
<>
<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />
<FilterDegreeCoursesModal
degreeProgramme={programme}
initialTab="criteria"
icon={<EditIcon />}
text="Modify criteria"
/>
<StudentNameVisibilityToggle />
</>
}
/>
) : (
<Typography variant="h6">
There is no criteria set for this programme. You can create one{' '}
<Link to={`/study-programme/${programme}?tab=3`}>here</Link>.
</Typography>
)}
</Box>
</>
)
@@ -0,0 +1,151 @@
import dayjs, { extend as dayjsExtend } from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import { DateFormat } from '@/constants/date'
import { isMedicalProgramme } from '@/util/studyProgramme'
import { formatDate } from '@/util/timeAndDate'
import { CreditTypeCode, EnrollmentState, FormattedStudent, ProgressCriteria } from '@oodikone/shared/types'
import { StudentCourse } from '@oodikone/shared/types/studentData'
dayjsExtend(isBetween)
dayjsExtend(isSameOrBefore)
dayjsExtend(isSameOrAfter)
export type IdToGroupId = Record<string, string>
export type Label = {
/** Course code for course labels, otherwise a special value ('Credits', 'Criteria', 'Enrollment') */
code: string
/** Set for course labels */
groupId?: string
name: string
}
/**
Return the attainments of the course and of each of its substitution groups, newest first. One entry per group;
a group is only included if the student has attainments for all of its courses.
*/
export const getCourseGroups = (
courseGroupId: string,
criteria: ProgressCriteria | undefined,
student: FormattedStudent,
idToGroupId: IdToGroupId
): StudentCourse[][] => {
const studentGroupIds = new Set(student.courses.map(({ course_id }) => idToGroupId[course_id]))
return [[courseGroupId]]
.concat(criteria?.allCourseGroups[courseGroupId] ?? [])
.filter(group => group.every(groupId => studentGroupIds.has(groupId)))
.map(group =>
student.courses
.filter(course => group.includes(idToGroupId[course.course_id]))
.toSorted((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
)
}
/**
Filter out failed courses that don't "break" a given group eg. remove the only instance of a course code
NOTE: Does not automatically remove all failed courses!
*/
const filterFailed = (group: StudentCourse[]) => (course: StudentCourse) =>
course.passed || !group.find(otherCourse => course.course_code === otherCourse.course_code && otherCourse.passed)
const isGroupPassed = (group: StudentCourse[]) => {
const passedCredits = group.filter(filterFailed(group))
return passedCredits.length > 0 && passedCredits.every(credit => credit.passed)
}
export const hasCreditTransfer = (courseGroups: StudentCourse[][]) =>
courseGroups.some(
group => isGroupPassed(group) && group.some(course => course.credittypecode === CreditTypeCode.APPROVED)
)
/** Check if any courseGroup has
1. all courses passed
2. *LATEST* attainment gotten during the wanted academic year
3. All attainments gotten before the wanted academic year's end
*/
export const hasPassedDuringAcademicYear = (courseGroups: StudentCourse[][], start: dayjs.Dayjs, end: dayjs.Dayjs) =>
courseGroups.some(group => {
if (!isGroupPassed(group)) return false
const passedCredits = group.filter(credit => credit.passed)
return (
passedCredits.some(credit => dayjs(credit.date).isBetween(start, end)) &&
passedCredits.every(credit => dayjs(credit.date).isBefore(end))
)
})
/** Check if any group has only passed courses */
export const hasPassedOutsideAcademicYear = (courseGroups: StudentCourse[][]) => courseGroups.some(isGroupPassed)
/**
Check if there is a failed course that was not removed by the filterFailed eg. there is
a failed course that has no passed attainment
*/
export const hasFailed = (courseGroups: StudentCourse[][]) =>
courseGroups.some(group => group.filter(filterFailed(group)).some(course => !course.passed))
/** Return enrollments only for main courses */
export const getEnrollments = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
(student.enrollments ?? []).filter(
enrollment => enrollment.state === EnrollmentState.ENROLLED && idToGroupId[enrollment.course_id] === courseGroupId
)
export const hasEnrolled = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
getEnrollments(student, courseGroupId, idToGroupId).length > 0
/** Text representation of a cell, used for the tooltip and the Excel export */
export const getCellText = (
label: Label,
criteria: ProgressCriteria | undefined,
student: FormattedStudent,
year: string,
idToGroupId: IdToGroupId
) => {
if (label.code === 'Credits') {
return student.criteriaProgress[year]?.credits ? 'Passed' : ''
}
if (!label.groupId) return ''
const courseGroups = getCourseGroups(label.groupId, criteria, student, idToGroupId)
const passedGroup = courseGroups.find(isGroupPassed)
if (passedGroup) {
const isMainCourse = passedGroup.every(course => idToGroupId[course.course_id] === label.groupId)
const latestPassed = passedGroup.find(course => course.passed)
return `Passed ${isMainCourse ? formatDate(latestPassed?.date, DateFormat.ISO_DATE) : '(substituted)'}`
}
if (hasFailed(courseGroups)) {
const latestFailedCourse = courseGroups
.flatMap(group => group.filter(filterFailed(group)))
.find(course => !course.passed)
return `Failed ${latestFailedCourse ? formatDate(latestFailedCourse.date, DateFormat.ISO_DATE) : '(substituted)'}`
}
const [enrollment] = getEnrollments(student, label.groupId, idToGroupId)
if (enrollment) {
return `Enrollment ${formatDate(enrollment.enrollment_date_time, DateFormat.ISO_DATE)}`
}
return ''
}
const yearKeys = [
{ year: 'year1', label: 'yearOne' },
{ year: 'year2', label: 'yearTwo' },
{ year: 'year3', label: 'yearThree' },
{ year: 'year4', label: 'yearFour' },
{ year: 'year5', label: 'yearFive' },
{ year: 'year6', label: 'yearSix' },
] as const
export const getCriteriaHeaders = (months: number, programme: string) =>
yearKeys.slice(0, isMedicalProgramme(programme) ? 6 : 3).map(({ year, label }, index) => ({
title: `Academic year ${index + 1}${months < (index + 1) * 12 ? ' (in progress)' : ''}`,
year,
label,
}))
@@ -13,7 +13,6 @@ import { type FormattedStudentData, GeneralTab } from '@/components/PopulationCo
import { ModulesTabContainer as ModulesTab } from '@/components/PopulationComponents/Students/Table/ModulesTab'
import { ProgressTable as ProgressTab } from '@/components/PopulationComponents/Students/Table/ProgressTab'
import { TagsTab } from '@/components/PopulationComponents/Students/Table/TagsTab'
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { useParseQueryParams } from '@/util/queryparams'
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
@@ -29,7 +28,6 @@ type PopulationDetails = CommonProps & {
variant: 'population'
programme: string
combinedProgramme?: string
curriculum: ExtendedCurriculumDetails | null
filteredCourses: FilteredCourse[]
idToGroupIdMap: Record<string, string>
}
@@ -40,7 +38,6 @@ type CoursePopulation = CommonProps & {
type StudyGuidanceGroup = CommonProps & {
variant: 'studyGuidanceGroupPopulation'
curriculum: ExtendedCurriculumDetails | null
studyGuidanceGroup: any
year: string
filteredCourses: FilteredCourse[]
@@ -63,7 +60,6 @@ export const PopulationStudents = ({
variant,
programme,
combinedProgramme,
curriculum,
filteredStudents,
filteredCourses,
idToGroupIdMap,
@@ -101,7 +97,6 @@ export const PopulationStudents = ({
Courses: (
<CoursesTab
courses={filteredCourses ?? []}
curriculum={curriculum} // TODO: add guard for missing curriculum (it should never be missing)
idToGroupIdMap={idToGroupIdMap ?? {}}
students={filteredStudents}
programme={programme}
@@ -110,7 +105,6 @@ export const PopulationStudents = ({
Modules: (
<ModulesTab
courses={filteredCourses ?? []}
curriculum={curriculum}
idToGroupIdMap={idToGroupIdMap ?? {}}
students={filteredStudents}
programme={programme}
@@ -119,7 +113,6 @@ export const PopulationStudents = ({
Tags: <TagsTab combinedProgramme={combinedProgramme} programme={programme} students={filteredStudents} />,
Progress: (
<ProgressTab
curriculum={curriculum}
idToGroupIdMap={idToGroupIdMap ?? {}}
months={months ?? Infinity}
programme={programme}
@@ -12,6 +12,7 @@ import { CourseFilterToggle } from '@/components/PopulationCourseStats/CourseFil
import { ModuleCourseToggle } from '@/components/PopulationCourseStats/ModuleCourseToggle'
import { NorthEastIcon, KeyboardArrowRightIcon } from '@/theme'
import { range } from '@oodikone/shared/util'
import { HiddenCourseAlert } from '@/components/HiddenCourseAlert'
const mapCourseData = course =>
course.courses
@@ -182,6 +183,7 @@ export const GradeDistribution = ({
{courseTableMode === 'all' && (
<ModuleCourseToggle setShowModules={setShowModules} showModules={showModules} />
)}
{courseTableMode === 'curriculum' ? <HiddenCourseAlert ooditable /> : null}
</>
}
/>
@@ -14,6 +14,7 @@ import { ModuleCourseToggle } from '@/components/PopulationCourseStats/ModuleCou
import { NorthEastIcon, KeyboardArrowRightIcon } from '@/theme'
import { CourseModule, UnionOfFilteredModuleCourse } from '@/util/coursesOfPopulation'
import { Unarray } from '@oodikone/shared/types'
import { HiddenCourseAlert } from '@/components/HiddenCourseAlert'
const columnHelper = createColumnHelper<Unarray<UnionOfFilteredModuleCourse>>()
@@ -248,6 +249,7 @@ export const PassFailEnrollments = ({
setShowModules !== undefined && ( // Check if optional fields are set
<ModuleCourseToggle setShowModules={setShowModules} showModules={showModules} />
)}
{courseTableMode === 'curriculum' ? <HiddenCourseAlert ooditable /> : null}
</>
}
/>
@@ -13,6 +13,7 @@ import { CourseFilterToggle } from '@/components/PopulationCourseStats/CourseFil
import { ModuleCourseToggle } from '@/components/PopulationCourseStats/ModuleCourseToggle'
import { NorthEastIcon, KeyboardArrowRightIcon } from '@/theme'
import { range } from '@oodikone/shared/util'
import { HiddenCourseAlert } from '@/components/HiddenCourseAlert'
const columnHelper = createColumnHelper()
@@ -183,12 +184,13 @@ export const PassingSemesters = ({ onlyIamRights, courseStatistics, courseTableM
toolbarContent={
<>
<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />
<Button onClick={() => setCumulativeStats(!cumulativeStats)} sx={{ my: 'auto', mx: 1 }} variant="outlined">
<Button onClick={() => setCumulativeStats(!cumulativeStats)} variant="outlined">
{cumulativeStats ? 'Show yearly stats' : 'Show cumulative stats'}
</Button>
{courseTableMode === 'all' && (
<ModuleCourseToggle setShowModules={setShowModules} showModules={showModules} />
)}
{courseTableMode === 'curriculum' ? <HiddenCourseAlert ooditable /> : null}
</>
}
/>
@@ -5,7 +5,7 @@ import { useState, useEffect } from 'react'
import { GradeDistribution } from '@/components/PopulationCourseStats/GradeDistribution'
import { PassFailEnrollments } from '@/components/PopulationCourseStats/PassFailEnrollments'
import { PassingSemesters } from '@/components/PopulationCourseStats/PassingSemesters'
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
import { useCurriculum } from '@/hooks/useCurriculum'
import { FilteredCourse, CourseModule, FilteredProgrammeCourse } from '@/util/coursesOfPopulation'
import type { CourseStats } from '@oodikone/shared/routes/populations'
import type { Module, CurriculumDetails } from '@oodikone/shared/types'
@@ -14,7 +14,6 @@ type PopulationCourseStatsProps = {
filteredCourses: FilteredCourse[]
pending: boolean
onlyIamRights: boolean
curriculum: ExtendedCurriculumDetails
courseTableMode: 'all' | 'curriculum'
showModules: boolean
setShowModules: (input: boolean) => void
@@ -33,15 +32,17 @@ export const PopulationCourseStats = ({
filteredCourses,
pending,
onlyIamRights,
curriculum,
courseTableMode,
showModules,
setShowModules,
}: PopulationCourseStatsProps) => {
const { curriculum } = useCurriculum()
const [modules, setModules] = useState<CourseModule[]>([])
const [tab, setTab] = useState(0)
useEffect(() => {
if (!curriculum) return
// Change courses type from FilteredCourse[] to FilteredProgrammeCourse[] + add module-field to object so we can deconstruct it below
const modules: Record<
string,
@@ -31,7 +31,7 @@ import { AdvancedSettings } from '@/components/PopulationDetails/AdvancedSetting
import { PopulationQueryCard } from '@/components/PopulationDetails/PopulationQueryCard'
import { useColumns as columnsGeneralTab } from '@/components/PopulationDetails/studentColumns'
import { useDebouncedState } from '@/hooks/debouncedState'
import { useCurriculumState } from '@/hooks/useCurriculums'
import { CurriculumProvider, useCurriculum } from '@/hooks/useCurriculum'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { KeyboardBackspaceIcon } from '@/theme'
import { PopulationQuery } from '@/types/populationSearch'
@@ -48,7 +48,13 @@ type PopulationDetailsProps = {
idToGroupIdMap: Record<string, string>
}
export const PopulationDetails = ({
export const PopulationDetails = (props: PopulationDetailsProps) => (
<CurriculumProvider programmeCode={props.query.programme} year={props.query?.years?.[0]}>
<PopulationDetailsContent {...props} />
</CurriculumProvider>
)
const PopulationDetailsContent = ({
isLoading,
query,
populationTags,
@@ -64,7 +70,7 @@ export const PopulationDetails = ({
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false)
const [courseTableMode, setCourseTableMode] = useState<'curriculum' | 'all'>('curriculum')
const [studentAmountLimit, setStudentAmountLimit] = useDebouncedState(0, 1000)
const [curriculum, curriculumList, setCurriculum] = useCurriculumState(programme, query?.years?.[0])
const { curriculum, curriculumList, setSelectedCurriculum: setCurriculum } = useCurriculum()
const [showModules, setShowModules] = useState(false) // Shows courses if modules not selected
const primaryHopsCreditFilter = useFilterSelector(hopsFilter.selectors.isPrimarySelected(undefined))
@@ -93,7 +99,7 @@ export const PopulationDetails = ({
const panels = [
{
title: `Credit accumulation (for ${filteredStudents.length} students)`,
title: `Credit accumulation (for ${filteredStudents.length} student${filteredStudents.length > 1 ? 's' : ''})`,
content: filteredStudents.length ? (
// TODO: make wrapper for when no data.
<CreditAccumulationGraph
@@ -122,14 +128,12 @@ export const PopulationDetails = ({
<>
<CourseTableModeSelector
courseTableMode={courseTableMode}
curriculum={curriculum}
onStudentAmountLimitChange={onStudentAmountLimitChange}
setCourseTableMode={setCourseTableMode}
studentAmountLimit={studentAmountLimit}
/>
<PopulationCourses
courseTableMode={courseTableMode}
curriculum={curriculum}
filteredCourses={filteredCourses}
isLoading={isLoading}
onlyIamRights={onlyIamRights}
@@ -147,7 +151,6 @@ export const PopulationDetails = ({
content: (
<PopulationStudents
combinedProgramme={combinedProgramme}
curriculum={curriculum}
filteredCourses={filteredCourses}
filteredStudents={filteredStudents}
idToGroupIdMap={idToGroupIdMap}
@@ -21,26 +21,46 @@ import { useFormat as formatGeneralTab } from '@/components/PopulationComponents
import { useColumns as columnsGeneralTab } from '@/components/StudyGuidanceGroups/studentColumns'
import { StudyGuidanceGroupPopulationCourses } from '@/components/StudyGuidanceGroups/StudyGuidanceGroupPopulationCourses'
import { createAcademicYearStartDate } from '@/components/StudyGuidanceGroups/utils'
import { useCurriculumState } from '@/hooks/useCurriculums'
import { CurriculumProvider, useCurriculum } from '@/hooks/useCurriculum'
import { KeyboardBackspaceIcon } from '@/theme'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { FormattedStudent } from '@oodikone/shared/types'
import { GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
import { useLanguage } from '../LanguagePicker/useLanguage'
dayjsExtend(isBetween)
export const SingleStudyGuidanceGroupPanels = ({
filteredStudents,
filteredCourses,
group,
idToGroupIdMap,
}: {
type SingleStudyGuidanceGroupPanelsProps = {
filteredStudents: FormattedStudent[]
filteredCourses: FilteredCourse[]
group: GroupsWithTags
idToGroupIdMap: Record<string, string>
}) => {
}
const curriculumInfoBoxContent = {
fi: 'Valitsee tarkasteltavan populaation opetussuunnitelman. Opetussuunnitelman valitseminen edellyttää koulutusohjelman asettamista populaatiolle.',
en: 'Selects the curriculum to be used for the population. Setting a curriculum requires a degree programme to be set for the population.',
}
export const SingleStudyGuidanceGroupPanels = (props: SingleStudyGuidanceGroupPanelsProps) => {
const [programme] = props.group.tags?.studyProgramme?.split('+') ?? []
const year = props.group.tags?.year ?? undefined
return (
<CurriculumProvider programmeCode={programme} year={year}>
<SingleStudyGuidanceGroupPanelsContent {...props} />
</CurriculumProvider>
)
}
const SingleStudyGuidanceGroupPanelsContent = ({
filteredStudents,
filteredCourses,
group,
idToGroupIdMap,
}: SingleStudyGuidanceGroupPanelsProps) => {
const { useFilterSelector, useFilterDispatch: filterDispatch } = useFilters()
const { getTextIn } = useLanguage()
const groupYear = group.tags?.year
const groupProgramme = group.tags?.studyProgramme
@@ -53,7 +73,7 @@ export const SingleStudyGuidanceGroupPanels = ({
years: groupYear ? [Number(groupYear)] : [],
}
const [curriculum, curriculumList, setCurriculum] = useCurriculumState(programme, groupYear!) // TODO: fix year
const { curriculum, curriculumList, setSelectedCurriculum: setCurriculum } = useCurriculum()
const creditDateFilterActive = useFilterSelector(creditDateFilter.selectors.isActive(undefined))
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive(undefined))
@@ -72,7 +92,7 @@ export const SingleStudyGuidanceGroupPanels = ({
}
const panels = [
{
title: `Credit accumulation (for ${filteredStudents.length} students)`,
title: `Credit accumulation (for ${filteredStudents.length} student${filteredStudents.length > 1 ? 's' : ''})`,
content: (
<>
{!!group.tags?.year && (
@@ -104,7 +124,6 @@ export const SingleStudyGuidanceGroupPanels = ({
title: 'Courses of population',
content: (
<StudyGuidanceGroupPopulationCourses
curriculum={curriculum}
filteredCourses={filteredCourses}
studyProgramme={group.tags?.studyProgramme ? programme : null}
year={groupYear}
@@ -116,7 +135,6 @@ export const SingleStudyGuidanceGroupPanels = ({
content: (
// @ts-expect-error FIX typing
<PopulationStudents
curriculum={curriculum}
filteredCourses={filteredCourses}
filteredStudents={filteredStudents}
generalTabColumnFunction={() => columnsGeneralTab({ group })}
@@ -165,7 +183,7 @@ export const SingleStudyGuidanceGroupPanels = ({
curriculumList={curriculumList}
setCurriculum={setCurriculum}
/>
<InfoBox content={'Valitsee tarkasteltavan populaation opetussuunnitelman.'} mini />
<InfoBox content={getTextIn(curriculumInfoBoxContent) ?? ''} mini />
</Stack>
</Stack>
</FormGroup>
@@ -6,7 +6,7 @@ import { PopulationCourseStats } from '@/components/PopulationCourseStats'
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
import { useDebouncedState } from '@/hooks/debouncedState'
export const StudyGuidanceGroupPopulationCourses = ({ filteredCourses, studyProgramme, year, curriculum }) => {
export const StudyGuidanceGroupPopulationCourses = ({ filteredCourses, studyProgramme, year }) => {
const [studentAmountLimit, setStudentAmountLimit] = useDebouncedState(0, 1000)
const curriculumsAvailable = studyProgramme && year
const [courseTableMode, setCourseTableMode] = useState<'all' | 'curriculum'>(
@@ -23,7 +23,6 @@ export const StudyGuidanceGroupPopulationCourses = ({ filteredCourses, studyProg
{curriculumsAvailable ? (
<CourseTableModeSelector
courseTableMode={courseTableMode}
curriculum={curriculum}
onStudentAmountLimitChange={onStudentAmountLimitChange}
setCourseTableMode={setCourseTableMode}
studentAmountLimit={studentAmountLimit}
@@ -37,7 +36,6 @@ export const StudyGuidanceGroupPopulationCourses = ({ filteredCourses, studyProg
{courseTableMode === 'curriculum' ? (
<PopulationCourseStats
courseTableMode={courseTableMode}
curriculum={curriculum}
filteredCourses={filteredCourses}
onlyIamRights={onlyIamRights}
pending={false}
@@ -19,6 +19,7 @@ export const StudentNameVisibilityToggle = () => {
return (
<FormControlLabel
sx={{ m: 0 }}
control={<Switch checked={visible} data-cy="toggleStudentNames" onChange={() => toggle()} />}
label="Show student names"
/>
@@ -0,0 +1,40 @@
import { createContext, Dispatch, ReactNode, SetStateAction, useContext, useMemo } from 'react'
import { ExtendedCurriculumDetails, useCurriculumState } from '@/hooks/useCurriculums'
import { CurriculumOption } from '@oodikone/shared/types'
type CurriculumContextType = {
curriculum: ExtendedCurriculumDetails | null
curriculumList: CurriculumOption[]
setSelectedCurriculum: Dispatch<SetStateAction<CurriculumOption | null>>
}
const CurriculumContext = createContext<CurriculumContextType | null>(null)
CurriculumContext.displayName = 'Curriculum'
export const CurriculumProvider = ({
programmeCode,
year,
children,
}: {
programmeCode: string
year: string | number | undefined
children: ReactNode
}) => {
const [curriculum, curriculumList, setSelectedCurriculum] = useCurriculumState(programmeCode, year)
const value = useMemo(
() => ({ curriculum, curriculumList, setSelectedCurriculum }),
[curriculum, curriculumList, setSelectedCurriculum]
)
return <CurriculumContext.Provider value={value}>{children}</CurriculumContext.Provider>
}
/** Context-aware variant of the curriculum hook. Use this in population statistics views. */
export const useCurriculum = () => {
const context = useContext(CurriculumContext)
if (!context) {
throw new Error('useCurriculum must be used within a CurriculumProvider')
}
return context
}
@@ -1,4 +1,4 @@
import { useEffect, useState, Dispatch, SetStateAction } from 'react'
import { useMemo, useState, Dispatch, SetStateAction } from 'react'
import { useGetCurriculumsQuery, useGetCurriculumOptionsQuery } from '@/redux/curriculum'
import { CurriculumOption, CurriculumDetails } from '@oodikone/shared/types'
@@ -7,10 +7,9 @@ export type ExtendedCurriculumDetails = CurriculumDetails & CurriculumOption
export const useCurriculumState = (
programmeCode: string,
year: string | number
year: string | number | undefined
): [ExtendedCurriculumDetails | null, CurriculumOption[], Dispatch<SetStateAction<CurriculumOption | null>>] => {
const [selectedCurriculum, setSelectedCurriculum] = useState<CurriculumOption | null>(null)
const [curriculum, setCurriculum] = useState<ExtendedCurriculumDetails | null>(null)
const { data: curriculumList = [], isFetching: curriculumsLoading } = useGetCurriculumOptionsQuery(
{ code: programmeCode },
@@ -19,7 +18,7 @@ export const useCurriculumState = (
const chosenCurriculum: CurriculumOption | null =
selectedCurriculum ??
curriculumList.find(curriculum => new Date(curriculum.validFrom) <= new Date(`${year}-08-01`)) ??
(year ? curriculumList.find(curriculum => new Date(curriculum.validFrom) <= new Date(`${year}-08-01`)) : null) ??
curriculumList[0] ??
null
@@ -31,11 +30,10 @@ export const useCurriculumState = (
{ skip: curriculumsLoading || !chosenCurriculum?.periodIds }
)
useEffect(() => {
if (curriculumData) {
setCurriculum({ ...curriculumData, ...chosenCurriculum })
}
}, [curriculumData, chosenCurriculum])
const curriculum = useMemo<ExtendedCurriculumDetails | null>(
() => (curriculumData ? { ...curriculumData, ...chosenCurriculum } : null),
[curriculumData, chosenCurriculum]
)
return [curriculum, curriculumList, setSelectedCurriculum]
}
@@ -12,7 +12,7 @@ import { Section } from '@/components/Section'
import { useStatusNotification } from '@/components/StatusNotification/Context'
import { useAddProgressCriteriaCreditsMutation } from '@/redux/progressCriteria'
import { SaveIcon } from '@/theme'
import { isMedicalProgramme } from '@/util/studyProgramme'
import { getCriteriaYears } from '@/pages/StudyProgramme/DegreeCoursesTab/criteriaYears'
import { ProgressCriteria } from '@oodikone/shared/types'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
@@ -68,43 +68,11 @@ export const CreditCriteriaSection = ({
}
}, [isError])
const inputs = [
{
key: 'year1',
label: 'First',
previousValue: criteria.credits.yearOne,
},
{
key: 'year2',
label: 'Second',
previousValue: criteria.credits.yearTwo,
},
{
key: 'year3',
label: 'Third',
previousValue: criteria.credits.yearThree,
},
]
if (isMedicalProgramme(degreeProgramme)) {
inputs.push(
{
key: 'year4',
label: 'Fourth',
previousValue: criteria.credits.yearFour,
},
{
key: 'year5',
label: 'Fifth',
previousValue: criteria.credits.yearFive,
},
{
key: 'year6',
label: 'Sixth',
previousValue: criteria.credits.yearSix,
}
)
}
const inputs = getCriteriaYears(degreeProgramme).map(({ key, label, year }) => ({
key: `year${year}`,
label,
previousValue: criteria.credits[key],
}))
const disabled = Object.values(creditLimits).every(value => value === null || value < 0)
@@ -112,7 +80,7 @@ export const CreditCriteriaSection = ({
<Section
cypress="credit-criteria"
infoBoxContent={getTextIn(studyProgrammeToolTips.degreeCoursesTab.creditCriteria) ?? ''}
title="Credit criteria"
title="Credit targets"
>
<Stack gap={2}>
<Grid container spacing={1}>
@@ -1,114 +0,0 @@
import Checkbox from '@mui/material/Checkbox'
import IconButton from '@mui/material/IconButton'
import ListItemText from '@mui/material/ListItemText'
import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem'
import Tooltip from '@mui/material/Tooltip'
import { useEffect, useState } from 'react'
import { useAddProgressCriteriaCourseMutation } from '@/redux/progressCriteria'
import { FormatListNumberedIcon } from '@/theme'
import { isMedicalProgramme } from '@/util/studyProgramme'
import { ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
export const CriterionLabelSelectButton = ({
course,
criteria,
studyProgramme,
}: {
course: ProgrammeCourse
criteria: ProgressCriteria
studyProgramme: string
}) => {
const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation()
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const [selectedOptions, setSelectedOptions] = useState<string[]>([])
useEffect(() => {
const selected = Object.keys(criteria.courses).filter(key => criteria.courses[key].includes(course.group_id))
setSelectedOptions(selected)
}, [criteria, course.group_id])
const options = [
{
key: 'yearOne',
label: 'First',
year: 1,
},
{
key: 'yearTwo',
label: 'Second',
year: 2,
},
{
key: 'yearThree',
label: 'Third',
year: 3,
},
]
if (isMedicalProgramme(studyProgramme)) {
options.push(
{
key: 'yearFour',
label: 'Fourth',
year: 4,
},
{
key: 'yearFive',
year: 5,
label: 'Fifth',
},
{
key: 'yearSix',
year: 6,
label: 'Sixth',
}
)
}
const handleCheckboxChange = (key: string, year: number) => {
let courseGroupIds: string[]
if (selectedOptions.includes(key)) {
courseGroupIds = criteria.courses[key]?.filter((groupId: string) => groupId !== course.group_id)
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
} else {
courseGroupIds = criteria.courses ? [...criteria.courses[key], course.group_id] : [course.group_id]
}
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
setSelectedOptions(prevSelected =>
prevSelected.includes(key) ? prevSelected.filter(item => item !== key) : [...prevSelected, key]
)
}
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
return (
<>
<Tooltip arrow placement="top" title={'Modify criterion labels'}>
<IconButton data-cy="toggle-visibility-button" onClick={handleMenuOpen}>
<FormatListNumberedIcon />
</IconButton>
</Tooltip>
<Menu anchorEl={anchorEl} onClose={handleMenuClose} open={Boolean(anchorEl)}>
{options.map(option => (
<MenuItem key={option.key} onClick={() => handleCheckboxChange(option.key, option.year)}>
<Checkbox checked={selectedOptions.includes(option.key)} />
<ListItemText primary={`${option.label} year`} />
</MenuItem>
))}
</Menu>
</>
)
}
@@ -1,14 +0,0 @@
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { VisibilityIcon, VisibilityOffIcon } from '@/theme'
export const ToggleVisibilityButton = ({ onClick, visible }: { onClick: any; visible: boolean }) => {
return (
<Tooltip arrow placement="top" title={visible ? 'Set hidden' : 'Set visible'}>
<IconButton data-cy="toggle-visibility-button" onClick={onClick}>
{visible ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</Tooltip>
)
}
@@ -1,17 +0,0 @@
import Chip from '@mui/material/Chip'
import { CourseVisibility } from '@/types/courseVisibility'
export const VisibilityChip = ({ visibility }: { visibility: CourseVisibility }) => {
return (
<Chip
label={visibility}
size="small"
sx={{
color: theme => theme.palette.courseVisibility[visibility],
borderColor: theme => theme.palette.courseVisibility[visibility],
}}
variant="outlined"
/>
)
}
@@ -0,0 +1,52 @@
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
import { CourseVisibility } from '@/types/courseVisibility'
const labels: Record<CourseVisibility, string> = {
[CourseVisibility.NO_COURSES]: 'No courses',
[CourseVisibility.HIDDEN]: 'Hidden',
[CourseVisibility.PARTIAL]: 'Partly shown',
[CourseVisibility.VISIBLE]: 'Shown',
}
const colors = {
[CourseVisibility.NO_COURSES]: 'noCourses',
[CourseVisibility.HIDDEN]: 'hidden',
[CourseVisibility.PARTIAL]: 'partial',
[CourseVisibility.VISIBLE]: 'visible',
} as const
export const VisibilitySwitch = ({
ariaLabel,
cypress,
onChange,
visibility,
}: {
ariaLabel: string
cypress: string
onChange: () => void
visibility: CourseVisibility
}) => (
<FormControlLabel
control={
<Switch
checked={visibility === CourseVisibility.VISIBLE || visibility === CourseVisibility.PARTIAL}
disabled={visibility === CourseVisibility.NO_COURSES}
onChange={onChange}
size="small"
inputProps={{ 'aria-label': ariaLabel }}
/>
}
data-cy={cypress}
label={labels[visibility]}
onClick={event => event.stopPropagation()}
slotProps={{
typography: {
variant: 'body2',
sx: { color: theme => theme.palette.courseVisibility[colors[visibility]], whiteSpace: 'nowrap' },
},
}}
sx={{ mr: 0 }}
/>
)
@@ -1,7 +1,12 @@
import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
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 IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
@@ -11,136 +16,190 @@ import Typography from '@mui/material/Typography'
import { Fragment, useEffect, useState } from 'react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { CriterionLabelSelectButton } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/CriterionLabelSelectButton'
import { ToggleVisibilityButton } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/ToggleVisibilityButton'
import { VisibilityChip } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/VisibilityChip'
import { useStatusNotification } from '@/components/StatusNotification/Context'
import { CriteriaYearOption, getCriteriaYears } from '@/pages/StudyProgramme/DegreeCoursesTab/criteriaYears'
import { VisibilitySwitch } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/VisibilitySwitch'
import { useRemoveCourseExclusionMutation, useSetCourseExclusionMutation } from '@/redux/courseExclusions'
import { FormatListNumberedIcon, KeyboardArrowDownIcon, KeyboardArrowRightIcon } from '@/theme'
import { useAddProgressCriteriaCourseMutation } from '@/redux/progressCriteria'
import { KeyboardArrowDownIcon, KeyboardArrowRightIcon } from '@/theme'
import { CourseVisibility } from '@/types/courseVisibility'
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
import { Module, ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
import { StyledTable } from '@/components/common/StyledTable'
const reloadNote = 'Reload the page to see the change in other views.'
export type DegreeModule = Module & {
/** Programme whose curriculum the module is from. Course visibility is saved separately for each programme. */
programmeCode: string
}
const getModuleKey = (module: DegreeModule) => `${module.programmeCode}/${module.code}`
export const DegreeCourseTable = ({
combinedProgramme,
criteria,
curriculumVersion,
modules: initialModules,
programmeNames,
showCriteria,
showVisibility,
studyProgramme,
}: {
combinedProgramme: string
criteria: ProgressCriteria
curriculumVersion: string[]
modules: Module[]
/** Modules of the first programme first, then those of the second programme of a combined programme */
modules: DegreeModule[]
/** Shown above each programme's modules when the modules are from more than one programme */
programmeNames: Record<string, string>
showCriteria: boolean
showVisibility: boolean
/** Programme whose progress criteria are edited */
studyProgramme: string
}) => {
const [moduleCoursesVisible, setModuleCoursesVisible] = useState<Record<string, Record<string, boolean>>>({})
const [expandedModules, setExpandedModules] = useState<Record<string, Record<string, boolean>>>({})
const { getTextIn } = useLanguage()
const { setStatusNotification } = useStatusNotification()
const [modules, setModules] = useState(initialModules)
// Unsaved criteria changes, discarded when updated criteria arrive from the server
const [pendingCriteria, setPendingCriteria] = useState<{
courses: ProgressCriteria['courses']
source: ProgressCriteria
} | null>(null)
const [moduleToHide, setModuleToHide] = useState<string | null>(null)
const [setExclusion] = useSetCourseExclusionMutation()
const [removeExclusion] = useRemoveCourseExclusionMutation()
const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation()
const version = curriculumVersion.join(',')
const criteriaYears = showCriteria ? getCriteriaYears(studyProgramme) : []
const criteriaCourses = pendingCriteria?.source === criteria ? pendingCriteria.courses : criteria.courses
useEffect(() => {
setModules(initialModules)
}, [initialModules])
const getModule = (moduleCode: string) => modules.find(module => module.code === moduleCode)!
const getModule = (moduleKey: string) => modules.find(module => getModuleKey(module) === moduleKey)!
const setModuleVisibility = (moduleCode: string, newVisibility: boolean) => {
// Same course can be in many modules, so change them all
const courseCodes = getModule(moduleCode).courses.map(course => course.code)
setModules(
modules.map(module => ({
...module,
courses: module.courses.map(course => {
if (courseCodes.includes(course.code)) {
return {
...course,
visible: { ...course.visible, visibility: newVisibility },
const getModuleName = (module: DegreeModule) => {
const firstCourse = module.courses[0]
return firstCourse?.parent_name ? getTextIn(firstCourse.parent_name)! : module.code
}
const setCoursesVisibility = (programmeCode: string, courseCodes: string[], newVisibility: boolean) => {
// Same course can be in many modules of the programme, so change them all
setModules(prevModules =>
prevModules.map(module =>
module.programmeCode !== programmeCode
? module
: {
...module,
courses: module.courses.map(course =>
courseCodes.includes(course.code)
? { ...course, visible: { ...course.visible, visibility: newVisibility } }
: course
),
}
}
return course
}),
}))
)
)
}
const setCourseVisibility = (moduleCode: string, newVisibility: boolean) => {
setModules(
modules.map(module => ({
...module,
courses: module.courses.map(course => {
if (course.code !== moduleCode) {
return course
}
return {
...course,
visible: { ...course.visible, visibility: newVisibility },
}
}),
}))
)
}
const areModuleCoursesVisible = (moduleCode: string) => {
if (!moduleCoursesVisible[version]) {
setModuleCoursesVisible(prev => ({ ...prev, [version]: {} }))
return false
const changeVisibility = (
programmeCode: string,
courses: ProgrammeCourse[],
newVisibility: boolean,
successMessage: string
) => {
const request = {
courseGroupIds: courses.map(course => course.group_id),
curriculumVersion: version,
programmeCode,
}
return moduleCoursesVisible[version][moduleCode]
const mutation = newVisibility ? removeExclusion(request) : setExclusion(request)
const courseCodes = courses.map(course => course.code)
setCoursesVisibility(programmeCode, courseCodes, newVisibility)
mutation
.unwrap()
.then(() => setStatusNotification(`${successMessage} ${reloadNote}`, 'success'))
.catch(() => {
setCoursesVisibility(programmeCode, courseCodes, !newVisibility)
setStatusNotification('Failed to change visibility', 'error')
})
}
const excludeOne = (course: ProgrammeCourse) => {
void setExclusion({
courseGroupIds: [course.group_id],
curriculumVersion: version,
programmeCode: combinedProgramme === '' ? studyProgramme : combinedProgramme,
})
setCourseVisibility(course.code, false)
const toggleCourseVisibility = (module: DegreeModule, course: ProgrammeCourse) => {
const newVisibility = !course.visible.visibility
const name = getTextIn(course.name)
changeVisibility(
module.programmeCode,
[course],
newVisibility,
`${name} is now ${newVisibility ? 'shown' : 'hidden'}.`
)
}
const excludeAll = (moduleCode: string) => {
const module = getModule(moduleCode)
const excludeFromProgramme = combinedProgramme === '' ? studyProgramme : combinedProgramme
void setExclusion({
courseGroupIds: module.courses.filter(course => course.visible.visibility).map(course => course.group_id),
curriculumVersion: version,
programmeCode: excludeFromProgramme,
})
setModuleVisibility(moduleCode, false)
const toggleModuleVisibility = (module: DegreeModule) => {
const visibility = getVisibility(module)
if (visibility === CourseVisibility.HIDDEN) {
changeVisibility(
module.programmeCode,
module.courses,
true,
`All courses in ${getModuleName(module)} are now shown.`
)
return
}
if (visibility === CourseVisibility.PARTIAL) {
setModuleToHide(getModuleKey(module))
return
}
hideModule(module)
}
const removeOne = (course: ProgrammeCourse) => {
void removeExclusion({
courseGroupIds: [course.group_id],
curriculumVersion: version,
programmeCode: studyProgramme,
})
setCourseVisibility(course.code, true)
const hideModule = (module: DegreeModule) => {
const visibleCourses = module.courses.filter(course => course.visible.visibility)
changeVisibility(
module.programmeCode,
visibleCourses,
false,
`All courses in ${getModuleName(module)} are now hidden.`
)
}
const deleteAll = (moduleCode: string) => {
const module = getModule(moduleCode)
void removeExclusion({
courseGroupIds: module.courses.map(course => course.group_id),
curriculumVersion: version,
programmeCode: studyProgramme,
})
setModuleVisibility(moduleCode, true)
const toggleCriteriaYear = (course: ProgrammeCourse, { key, year }: CriteriaYearOption) => {
const current = criteriaCourses[key] ?? []
const isRequired = current.includes(course.group_id)
const courseGroupIds = isRequired
? current.filter(groupId => groupId !== course.group_id)
: [...current, course.group_id]
const name = getTextIn(course.name)
setPendingCriteria({ courses: { ...criteriaCourses, [key]: courseGroupIds }, source: criteria })
addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
.unwrap()
.then(() =>
setStatusNotification(
isRequired ? `${name} removed from year ${year} criteria` : `${name} added to year ${year} criteria`,
'success'
)
)
.catch(() => {
setPendingCriteria(null)
setStatusNotification('Failed to update criteria', 'error')
})
}
const toggleVisible = (moduleCode: string) => {
const newState = !areModuleCoursesVisible(moduleCode)
setModuleCoursesVisible({
...moduleCoursesVisible,
[version]: { ...moduleCoursesVisible[version], [moduleCode]: newState },
const isModuleExpanded = (moduleKey: string) => expandedModules[version]?.[moduleKey] ?? false
const toggleExpanded = (moduleKey: string) => {
setExpandedModules({
...expandedModules,
[version]: { ...expandedModules[version], [moduleKey]: !isModuleExpanded(moduleKey) },
})
}
const getVisibility = (moduleCode: string) => {
const module = getModule(moduleCode)
if (!module.courses) {
const getVisibility = (module: DegreeModule) => {
if (!module.courses?.length) {
return CourseVisibility.NO_COURSES
}
if (module.courses.every(course => course.visible.visibility)) {
@@ -152,104 +211,164 @@ export const DegreeCourseTable = ({
return CourseVisibility.PARTIAL
}
const countRequired = (courses: ProgrammeCourse[], { key }: CriteriaYearOption) =>
courses.filter(course => criteriaCourses[key]?.includes(course.group_id)).length
const headerRowSpan = showCriteria ? 2 : 1
const columnCount = 2 + Number(showVisibility) + criteriaYears.length
const showProgrammeRows = new Set(modules.map(module => module.programmeCode)).size > 1
const pendingHideModule = moduleToHide ? getModule(moduleToHide) : null
const remainingToHide = pendingHideModule?.courses.filter(course => course.visible.visibility).length ?? 0
return (
<StyledTable showCellBorders data-cy="degree-course-table" size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Code</TableCell>
<TableCell>Visibility</TableCell>
{isBachelorOrLicentiateProgramme(studyProgramme) && <TableCell>Criterion labels</TableCell>}
<TableCell colSpan={2}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{modules.map(({ code: moduleCode, courses }) => (
<Fragment key={`${moduleCode}-fragment`}>
<TableRow key={moduleCode}>
<TableCell>
<Box alignItems="center" display="flex" justifyContent="left">
<IconButton onClick={() => toggleVisible(moduleCode)} size="small">
{areModuleCoursesVisible(moduleCode) ? <KeyboardArrowDownIcon /> : <KeyboardArrowRightIcon />}
</IconButton>
<Typography fontWeight="bold" variant="body2">
{courses[0] && courses[0].parent_name ? getTextIn(courses[0].parent_name) : moduleCode}
</Typography>
</Box>
</TableCell>
<TableCell>{moduleCode}</TableCell>
<TableCell>
<VisibilityChip visibility={getVisibility(moduleCode)} />
</TableCell>
{isBachelorOrLicentiateProgramme(studyProgramme) && <TableCell />}
<TableCell>
<Stack direction="row" gap={1}>
<ToggleVisibilityButton
onClick={() =>
getVisibility(moduleCode) === CourseVisibility.HIDDEN
? deleteAll(moduleCode)
: excludeAll(moduleCode)
}
visible={getVisibility(moduleCode) !== CourseVisibility.HIDDEN}
/>
{isBachelorOrLicentiateProgramme(studyProgramme) && (
<IconButton disabled sx={{ visibility: 'hidden' }}>
<FormatListNumberedIcon />
</IconButton>
)}
</Stack>
<>
<StyledTable showCellBorders data-cy="degree-course-table" size="small">
<TableHead>
<TableRow>
<TableCell rowSpan={headerRowSpan}>Name</TableCell>
<TableCell rowSpan={headerRowSpan}>Code</TableCell>
{showVisibility && <TableCell rowSpan={headerRowSpan}>Visibility</TableCell>}
{showCriteria && (
<TableCell align="center" colSpan={criteriaYears.length}>
Required for year criteria
</TableCell>
)}
</TableRow>
{showCriteria && (
<TableRow>
{criteriaYears.map(({ year }) => (
<TableCell align="center" key={year}>
Year {year}
</TableCell>
))}
</TableRow>
{areModuleCoursesVisible(moduleCode) &&
courses
.sort((a, b) => a.code.localeCompare(b.code))
.map(course => (
<TableRow key={`${moduleCode}/${course.code}`} sx={{ bgcolor: 'grey.50' }}>
<TableCell>{getTextIn(course.name)}</TableCell>
<TableCell>{course.code}</TableCell>
)}
</TableHead>
<TableBody>
{modules.map((module, index) => {
const { code: moduleCode, courses, programmeCode } = module
const moduleKey = getModuleKey(module)
const moduleName = getModuleName(module)
const expanded = isModuleExpanded(moduleKey)
const startsProgramme = index === 0 || modules[index - 1].programmeCode !== programmeCode
return (
<Fragment key={moduleKey}>
{showProgrammeRows && startsProgramme && (
<TableRow data-cy={`programme-row-${programmeCode}`}>
<TableCell colSpan={columnCount} sx={{ bgcolor: 'grey.100' }}>
<Typography fontWeight="bold" variant="body2">
{programmeNames[programmeCode] ?? programmeCode} ({programmeCode})
</Typography>
</TableCell>
</TableRow>
)}
<TableRow hover onClick={() => toggleExpanded(moduleKey)} sx={{ cursor: 'pointer' }}>
<TableCell>
<Box alignItems="center" display="flex" justifyContent="left">
<IconButton
aria-expanded={expanded}
aria-label={expanded ? `Hide courses of ${moduleName}` : `Show courses of ${moduleName}`}
size="small"
>
{expanded ? <KeyboardArrowDownIcon /> : <KeyboardArrowRightIcon />}
</IconButton>
<Typography fontWeight="bold" variant="body2">
{moduleName}
</Typography>
</Box>
</TableCell>
<TableCell>{moduleCode}</TableCell>
{showVisibility && (
<TableCell>
<VisibilityChip
visibility={course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN}
<VisibilitySwitch
ariaLabel={`Show all courses of ${moduleName}`}
cypress={`visibility-switch-${moduleCode}`}
onChange={() => toggleModuleVisibility(module)}
visibility={getVisibility(module)}
/>
</TableCell>
{isBachelorOrLicentiateProgramme(studyProgramme) && (
<TableCell>
{Object.values(criteria.courses).length > 0 ? (
<Stack direction="row" gap={1}>
{Object.keys(criteria.courses).map(
(year, index) =>
criteria.courses[year].includes(course.group_id) && (
<Chip color="primary" key={year} label={`year ${index + 1}`} size="small" />
)
)}
</Stack>
) : (
<Typography color="text.secondary" variant="body2">
No labels set
)}
{criteriaYears.map(option => {
const requiredCount = countRequired(courses, option)
return (
<TableCell align="center" key={option.year}>
{requiredCount > 0 && (
<Typography
color="text.secondary"
title={`${requiredCount} of ${courses.length} courses required for year ${option.year}`}
variant="body2"
>
{requiredCount} / {courses.length}
</Typography>
)}
</TableCell>
)}
<TableCell>
<Stack direction="row" gap={1}>
<ToggleVisibilityButton
onClick={() => (course.visible.visibility ? excludeOne(course) : removeOne(course))}
visible={course.visible.visibility}
/>
{isBachelorOrLicentiateProgramme(studyProgramme) && (
<CriterionLabelSelectButton
course={course}
criteria={criteria}
studyProgramme={studyProgramme}
/>
)}
</Stack>
</TableCell>
</TableRow>
))}
</Fragment>
))}
</TableBody>
</StyledTable>
)
})}
</TableRow>
{expanded &&
[...courses]
.sort((a, b) => a.code.localeCompare(b.code))
.map(course => {
const courseName = getTextIn(course.name)
return (
<TableRow key={`${moduleKey}/${course.code}`} sx={{ bgcolor: 'grey.50' }}>
<TableCell sx={{ pl: 6 }}>{courseName}</TableCell>
<TableCell>{course.code}</TableCell>
{showVisibility && (
<TableCell>
<VisibilitySwitch
ariaLabel={`Show ${courseName}`}
cypress={`visibility-switch-${course.code}`}
onChange={() => toggleCourseVisibility(module, course)}
visibility={
course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN
}
/>
</TableCell>
)}
{criteriaYears.map(option => (
<TableCell align="center" key={option.year} padding="checkbox">
<Checkbox
checked={criteriaCourses[option.key]?.includes(course.group_id) ?? false}
data-cy={`criteria-checkbox-${option.year}-${course.code}`}
inputProps={{ 'aria-label': `${courseName} required for year ${option.year}` }}
onChange={() => toggleCriteriaYear(course, option)}
size="small"
/>
</TableCell>
))}
</TableRow>
)
})}
</Fragment>
)
})}
</TableBody>
</StyledTable>
<Dialog onClose={() => setModuleToHide(null)} open={moduleToHide !== null}>
<DialogTitle>Hide the whole module?</DialogTitle>
<DialogContent>
<DialogContentText>
{pendingHideModule &&
`Some courses in ${getModuleName(pendingHideModule)} are already hidden. ` +
`Hiding the module hides its remaining ${remainingToHide} ` +
`${remainingToHide === 1 ? 'course' : 'courses'} for all Oodikone users.`}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setModuleToHide(null)}>Cancel</Button>
<Button
data-cy="confirm-hide-module-button"
onClick={() => {
hideModule(pendingHideModule!)
setModuleToHide(null)
}}
variant="contained"
>
Hide module
</Button>
</DialogActions>
</Dialog>
</>
)
}
@@ -13,7 +13,7 @@ const textContent = {
export const ManageCoursesShownInfo = () => {
const { getTextIn } = useLanguage()
return (
<StyledMessage showIcon variant="outlined" severity="warning" sx={{ mb: 2, maxWidth: '100%' }}>
<StyledMessage showIcon variant="outlined" severity="warning" sx={{ maxWidth: '100%' }}>
{getTextIn(textContent)}
</StyledMessage>
)
@@ -0,0 +1,23 @@
import { isMedicalProgramme } from '@/util/studyProgramme'
import { ProgressCriteria } from '@oodikone/shared/types'
export type CriteriaYearKey = keyof ProgressCriteria['courses']
export type CriteriaYearOption = {
key: CriteriaYearKey
label: string
year: number
}
const allCriteriaYears: CriteriaYearOption[] = [
{ key: 'yearOne', label: 'First', year: 1 },
{ key: 'yearTwo', label: 'Second', year: 2 },
{ key: 'yearThree', label: 'Third', year: 3 },
{ key: 'yearFour', label: 'Fourth', year: 4 },
{ key: 'yearFive', label: 'Fifth', year: 5 },
{ key: 'yearSix', label: 'Sixth', year: 6 },
]
/** Academic years that can have progress criteria in the given programme (six for medicine, otherwise three) */
export const getCriteriaYears = (programmeCode: string) =>
isMedicalProgramme(programmeCode) ? allCriteriaYears : allCriteriaYears.slice(0, 3)
@@ -1,93 +1,148 @@
import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import Tab from '@mui/material/Tab'
import Tabs from '@mui/material/Tabs'
import { orderBy } from 'lodash-es'
import { useEffect, useState } from 'react'
import { useMemo, useState } from 'react'
import { useCurriculumState } from '@/hooks/useCurriculums'
import { useCurriculum } from '@/hooks/useCurriculum'
import { CreditCriteriaSection } from '@/pages/StudyProgramme/DegreeCoursesTab/CreditCriteriaSection'
import { CurriculumSection } from '@/pages/StudyProgramme/DegreeCoursesTab/CurriculumSection'
import { DegreeCourseTable } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable'
import { DegreeCourseTable, DegreeModule } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable'
import { ManageCoursesShownInfo } from '@/pages/StudyProgramme/DegreeCoursesTab/InfoBox'
import { useGetProgrammesQuery } from '@/redux/populations'
import { useGetProgressCriteriaQuery } from '@/redux/progressCriteria'
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
import { Module, ProgrammeCourse } from '@oodikone/shared/types'
import { ProgrammeCourse } from '@oodikone/shared/types'
import { getCombinedProgramme } from '@oodikone/shared/util/studyProgramme'
import { Section } from '@/components/Section'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { studyProgrammeToolTips } from '@/common/InfoToolTips'
export type DegreeSettingsTab = 'criteria' | 'visibility'
const getModules = (courses: ProgrammeCourse[], programmeCode: string): DegreeModule[] => {
const modules: Record<string, ProgrammeCourse[]> = {}
courses.forEach(course => {
const code = course.parent_code!
modules[code] ??= []
modules[code].push(course)
})
return orderBy(
Object.entries(modules).map(([code, courses]) => ({
code,
courses,
order: courses[0].module_order,
programmeCode,
})),
module => module.code
)
}
const sectionTexts = {
criteria: {
title: 'Course completion academic year targets',
toolTip: studyProgrammeToolTips.degreeCoursesTab.requiredCourses,
},
visibility: {
title: 'Alter course and module visibility',
toolTip: studyProgrammeToolTips.degreeCoursesTab.courseVisibility,
},
}
export const DegreeCoursesTab = ({
combinedProgramme,
degreeProgramme,
initialTab = 'criteria',
}: {
combinedProgramme: string
degreeProgramme: string
initialTab?: DegreeSettingsTab
}) => {
const [defaultProgrammeModules, setDefaultProgrammeModules] = useState<Module[]>([])
const [secondProgrammeModules, setSecondProgrammeModules] = useState<Module[]>([])
const [curriculum, curriculumList, setCurriculum] = useCurriculumState(degreeProgramme, new Date().getFullYear())
const [currentTab, setCurrentTab] = useState<DegreeSettingsTab>(initialTab)
const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: degreeProgramme })
const { data: programmes } = useGetProgrammesQuery()
const { getTextIn } = useLanguage()
const { curriculum, curriculumList, setSelectedCurriculum: setCurriculum } = useCurriculum()
const secondProgramme = getCombinedProgramme(degreeProgramme)
const getModules = (courses: ProgrammeCourse[]): Module[] => {
const modules: Record<string, ProgrammeCourse[]> = {}
courses.forEach(course => {
const code = course.parent_code!
modules[code] ??= []
modules[code].push(course)
})
// A combined programme has courses from two curriculums. Show them in one table, first programme's modules first.
const modules = useMemo(
() =>
curriculum
? [
...getModules(curriculum.defaultProgrammeCourses ?? [], degreeProgramme),
...(secondProgramme ? getModules(curriculum.secondProgrammeCourses ?? [], secondProgramme) : []),
]
: [],
[curriculum, degreeProgramme, secondProgramme]
)
return orderBy(
Object.entries(modules).map(([code, courses]) => ({
code,
courses,
order: courses[0].module_order,
})),
module => module.code
)
}
useEffect(() => {
if (!curriculum?.defaultProgrammeCourses?.length) {
return
}
setDefaultProgrammeModules(getModules(curriculum.defaultProgrammeCourses))
if (combinedProgramme) {
setSecondProgrammeModules(getModules(curriculum.secondProgrammeCourses))
}
}, [combinedProgramme, curriculum])
const programmeNames = Object.fromEntries(
[degreeProgramme, secondProgramme]
.filter(code => code !== undefined)
.map(code => {
const programme = programmes?.allProgrammes?.[code] ?? programmes?.filteredProgrammes?.[code]
return [code, programme ? getTextIn(programme.name)! : code]
})
)
if (!curriculum || !criteria) return null
const hasCriteria = isBachelorOrLicentiateProgramme(degreeProgramme)
const renderCourseTable = (tab: DegreeSettingsTab) =>
!!modules.length && (
<Section infoBoxContent={getTextIn(sectionTexts[tab].toolTip) ?? ''} title={sectionTexts[tab].title}>
<DegreeCourseTable
criteria={criteria}
curriculumVersion={curriculum.periodIds}
modules={modules}
programmeNames={programmeNames}
showCriteria={tab === 'criteria'}
showVisibility={tab === 'visibility'}
studyProgramme={degreeProgramme}
/>
</Section>
)
const curriculumSection = (
<CurriculumSection curriculum={curriculum} curriculumList={curriculumList} setCurriculum={setCurriculum} />
)
const visibilityPanel = (
<Stack gap={2}>
{curriculumSection}
{renderCourseTable('visibility')}
</Stack>
)
return (
<Stack gap={2}>
<CurriculumSection curriculum={curriculum} curriculumList={curriculumList} setCurriculum={setCurriculum} />
{isBachelorOrLicentiateProgramme(degreeProgramme) && (
<CreditCriteriaSection criteria={criteria} degreeProgramme={degreeProgramme} />
)}
{curriculum && (!!defaultProgrammeModules.length || !!secondProgrammeModules.length) && (
<Section
title="Course & module criterion and visibility"
infoBoxContent={getTextIn(studyProgrammeToolTips.degreeCoursesTab.programmeCriteriaAndVisibility) ?? ''}
>
{!!defaultProgrammeModules.length && (
<DegreeCourseTable
combinedProgramme=""
criteria={criteria}
curriculumVersion={curriculum.periodIds}
modules={defaultProgrammeModules}
studyProgramme={degreeProgramme}
/>
)}
{!!secondProgrammeModules.length && (
<DegreeCourseTable
combinedProgramme={combinedProgramme}
criteria={criteria}
curriculumVersion={curriculum.periodIds}
modules={secondProgrammeModules}
studyProgramme={degreeProgramme}
/>
)}
</Section>
<ManageCoursesShownInfo />
{hasCriteria ? (
<>
<Tabs
data-cy="degree-settings-tabs"
onChange={(_event, newTab: DegreeSettingsTab) => setCurrentTab(newTab)}
value={currentTab}
>
<Tab data-cy="criteria-tab" label="Progress tab criteria" value="criteria" />
<Tab data-cy="visibility-tab" label="Course & module visibility" value="visibility" />
</Tabs>
{/* Both panels stay mounted so unsaved table state (visibility, expanded modules) survives tab switches */}
<Box data-cy="criteria-panel" hidden={currentTab !== 'criteria'}>
<Stack gap={2}>
{curriculumSection}
<CreditCriteriaSection criteria={criteria} degreeProgramme={degreeProgramme} />
{renderCourseTable('criteria')}
</Stack>
</Box>
<Box data-cy="visibility-panel" hidden={currentTab !== 'visibility'}>
{visibilityPanel}
</Box>
</>
) : (
<Box data-cy="visibility-panel">{visibilityPanel}</Box>
)}
</Stack>
)
@@ -8,6 +8,7 @@ import { PageLayout } from '@/components/common/PageLayout'
import { PageTitle } from '@/components/common/PageTitle'
import { GetTextIn, useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useTabs } from '@/hooks/tabs'
import { CurriculumProvider } from '@/hooks/useCurriculum'
import { useTitle } from '@/hooks/title'
import { BasicInformationTab } from '@/pages/StudyProgramme/BasicInformationTab'
import { DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
@@ -134,13 +135,11 @@ export const StudyProgramme = () => {
tabs.push(
{
key: 'DegreeCoursesTab',
label: 'Degree courses',
label: 'Degree programme settings',
component: (
<DegreeCoursesTab
combinedProgramme={secondProgrammeId}
degreeProgramme={programmeId}
key="DegreeCoursesTabContent"
/>
<CurriculumProvider key="DegreeCoursesTabContent" programmeCode={programmeId} year={new Date().getFullYear()}>
<DegreeCoursesTab degreeProgramme={programmeId} />
</CurriculumProvider>
),
},
{
@@ -32,7 +32,7 @@ export const UserPage = ({ userId }: { userId: string }) => {
}
return (
<PageLayout maxWidth="lg">
<PageLayout maxWidth="md" sx={{ width: '100%' }}>
<Stack direction="column" gap={2}>
<MissingIdAlert visible={!user.sisPersonId} />
<InfoCard user={user} />
@@ -1,23 +0,0 @@
import Button from '@mui/material/Button'
import Tooltip from '@mui/material/Tooltip'
import { CopyAllIcon } from '@/theme'
export const CopyEmailAddressesButton = ({ userEmails }: { userEmails: string[] }) => {
const copyEmailsToClipboard = () => {
void navigator.clipboard.writeText(userEmails.join('; '))
}
return (
<Tooltip arrow placement="right" title="Copy the email addresses of all users to clipboard">
<Button
data-cy="copy-email-addresses-button"
onClick={copyEmailsToClipboard}
startIcon={<CopyAllIcon />}
variant="contained"
>
Copy email addresses
</Button>
</Tooltip>
)
}
@@ -0,0 +1,30 @@
import Button from '@mui/material/Button'
import Tooltip from '@mui/material/Tooltip'
import { useStatusNotification } from '@/components/StatusNotification/Context'
import { ContentCopyIcon } from '@/theme'
import { User } from '@/types/api/users'
export const CopyEmailAddressesButton = ({ users }: { users: User[] }) => {
const { setStatusNotification, closeNotification } = useStatusNotification()
const copyEmailsToClipboard = async () => {
const emails = users.map(user => user.email).filter(Boolean)
await navigator.clipboard.writeText(emails.join('; '))
setStatusNotification(`Copied ${emails.length} email addresses`)
setTimeout(() => closeNotification(), 5000)
}
return (
<Tooltip arrow placement="right" title="Copy the email addresses of the listed users to clipboard">
<Button
data-cy="copy-email-addresses-button"
onClick={() => void copyEmailsToClipboard()}
startIcon={<ContentCopyIcon />}
variant="contained"
>
Copy email addresses
</Button>
</Tooltip>
)
}
@@ -0,0 +1,22 @@
import { SearchIcon, theme } from '@/theme'
import TextField from '@mui/material/TextField'
import { useState } from 'react'
export const NameSearch = ({ setFilter }: { setFilter: React.Dispatch<React.SetStateAction<string>> }) => {
const [textField, setTextField] = useState('')
return (
<TextField
label="Search by name or username"
onChange={event => {
setTextField(event.target.value)
setFilter(event.target.value)
}}
size="small"
slotProps={{
input: { endAdornment: <SearchIcon fontSize="small" htmlColor={theme.palette.grey[700]} sx={{ ml: 2 }} /> },
}}
value={textField}
/>
)
}
@@ -1,21 +0,0 @@
import Typography from '@mui/material/Typography'
export const StatusMessage = ({ isError, isLoading }: { isError: boolean; isLoading: boolean }) => {
if (isError) {
return (
<Typography color="error" variant="body1">
Failed to load users, try again
</Typography>
)
}
if (isLoading) {
return (
<Typography color="info" variant="body1">
Loading users...
</Typography>
)
}
return null
}
@@ -1,40 +1,36 @@
import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import Stack from '@mui/material/Stack'
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
import { createColumnHelper, getFilteredRowModel, type TableOptions } from '@tanstack/react-table'
import { useCallback, useMemo } from 'react'
import { Link } from '@/components/common/Link'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { OodiTable } from '@/components/OodiTable'
import { LoadingSkeleton } from '@/components/Section/LoadingSkeleton'
import { MockButton } from '@/components/Users/MockButton'
import { RoleChip } from '@/components/Users/RoleChip'
import { DateFormat } from '@/constants/date'
import { CopyEmailAddressesButton } from '@/pages/Users/UsersTable/CopyEmailAddressesButton'
import { StatusMessage } from '@/pages/Users/UsersTable/StatusMessage'
import { useDebouncedState } from '@/hooks/debouncedState'
import { CopyEmailAddressesButton } from '@/pages/Users/UsersTable/EmailCopy'
import { NameSearch } from '@/pages/Users/UsersTable/NameSearch'
import { useGetProgrammesQuery } from '@/redux/populations'
import { useGetRolesQuery } from '@/redux/users'
import { User } from '@/types/api/users'
import { reformatDate } from '@/util/timeAndDate'
import { DetailedProgrammeRights, Role } from '@oodikone/shared/types'
import { DetailedProgrammeRights } from '@oodikone/shared/types'
export const UsersTable = ({
getAllUsersQuery,
isError,
isLoading,
users,
}: {
getAllUsersQuery: any // TODO: What is the type?
isError: boolean
isLoading: boolean
users: User[]
}) => {
const columnHelper = createColumnHelper<User>()
const matchesSearch = (user: User, search: string) => {
const lowerCaseSearch = search.toLowerCase()
return user.name.toLowerCase().includes(lowerCaseSearch) || user.username.toLowerCase().includes(lowerCaseSearch)
}
export const UsersTable = ({ isLoading, users }: { isLoading: boolean; users: User[] | undefined }) => {
'use memo'
const { getTextIn } = useLanguage()
const { data: roles = [] } = useGetRolesQuery()
const { data } = useGetProgrammesQuery()
const studyProgrammes = data?.filteredProgrammes ?? {}
const iamGroups = [...new Set(users.map(user => user.iamGroups).flat())]
const studyProgrammes = useMemo(() => data?.filteredProgrammes ?? {}, [data])
const formatProgrammeRights = useCallback(
(programmeRights: DetailedProgrammeRights[]) => {
@@ -57,29 +53,26 @@ export const UsersTable = ({
[getTextIn, studyProgrammes]
)
const columns = useMemo<MRT_ColumnDef<any>[]>(
const ooditableColumns = useMemo(
() => [
{
accessorKey: 'name',
columnHelper.accessor('name', {
header: 'Name',
Cell: ({ cell }) => cell.getValue<string>(),
},
{
accessorKey: 'username',
cell: cell => cell.getValue(),
}),
columnHelper.accessor('username', {
header: 'Username',
Cell: ({ cell, row }) => (
<Link data-cy={`user-page-button-${row.original.username}`} to={`/users/${row.original.id}`}>
{cell.getValue<string>()}
cell: cell => (
<Link data-cy={`user-page-button-${cell.row.original.username}`} to={`/users/${cell.row.original.id}`}>
{cell.getValue()}
</Link>
),
},
{
accessorKey: 'roles',
}),
columnHelper.accessor('roles', {
header: 'Roles',
Cell: ({ cell }) => (
<Box display="flex" flexWrap="wrap" gap={1}>
cell: cell => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, my: 1 }}>
{cell
.getValue<Role[]>()
.getValue()
.toSorted((a, b) => a.localeCompare(b))
.map(role => (
<RoleChip key={role} role={role} />
@@ -88,22 +81,18 @@ export const UsersTable = ({
),
enableSorting: false,
size: 350,
filterVariant: 'multi-select',
filterSelectOptions: roles,
},
{
accessorKey: 'programmeRights',
}),
columnHelper.accessor('programmeRights', {
header: 'Programmes',
Cell: ({ cell }) => formatProgrammeRights(cell.getValue<DetailedProgrammeRights[]>()),
cell: cell => formatProgrammeRights(cell.getValue()),
enableSorting: false,
},
{
accessorKey: 'iamGroups',
}),
columnHelper.accessor('iamGroups', {
header: 'IAM groups',
Cell: ({ cell }) => (
<Box display="flex" flexWrap="wrap" gap={1}>
cell: cell => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, my: 1 }}>
{cell
.getValue<string[]>()
.getValue()
.toSorted((a, b) => a.localeCompare(b))
.map(iamGroup => (
<Chip key={iamGroup} label={iamGroup} size="small" />
@@ -112,63 +101,48 @@ export const UsersTable = ({
),
enableSorting: false,
size: 350,
filterVariant: 'multi-select',
filterSelectOptions: iamGroups,
},
{
accessorKey: 'lastLogin',
}),
columnHelper.accessor('lastLogin', {
header: 'Last login',
Cell: ({ cell }) => reformatDate(cell.getValue<string>(), DateFormat.DISPLAY_DATE),
enableColumnFilter: false,
},
{
cell: cell => reformatDate(cell.getValue(), DateFormat.DISPLAY_DATE),
}),
columnHelper.display({
id: 'actions',
header: 'Actions',
Cell: ({ row }) => (
cell: cell => (
<Stack direction="row" gap={1}>
<MockButton username={row.original.username} />
<MockButton username={cell.row.original.username} />
</Stack>
),
enableSorting: false,
},
}),
],
[formatProgrammeRights, getAllUsersQuery, iamGroups, roles]
[formatProgrammeRights]
)
const table = useMaterialReactTable({
columns,
data: users,
defaultColumn: { size: 0 },
enableBottomToolbar: false,
enableColumnOrdering: false,
enableDensityToggle: false,
enableHiding: false,
enablePagination: false,
initialState: {
showGlobalFilter: true,
},
const [filter, setFilter] = useDebouncedState('', 250)
const ooditable: Partial<TableOptions<User>> = {
getFilteredRowModel: getFilteredRowModel(),
globalFilterFn: (row, _, filterValue) => matchesSearch(row.original, String(filterValue)),
state: {
columnOrder: ['name', 'username', 'roles', 'programmeRights', 'iamGroups', 'lastLogin', 'actions'],
columnVisibility: {
name: true,
username: true,
roles: true,
programmeRights: true,
iamGroups: true,
lastLogin: true,
},
globalFilter: filter,
},
renderTopToolbarCustomActions: ({ table }) => {
const visibleEmails = table.getPrePaginationRowModel().rows.map(row => row.original.email)
}
return (
<Stack alignItems="center" direction="row" gap={1}>
<CopyEmailAddressesButton userEmails={visibleEmails} />
<StatusMessage isError={isError} isLoading={isLoading} />
</Stack>
)
},
})
if (isLoading || !users) return <LoadingSkeleton />
return <MaterialReactTable table={table} />
return (
<OodiTable
columns={ooditableColumns}
data={users}
options={ooditable}
toolbarContent={table => (
<>
<CopyEmailAddressesButton users={table.getFilteredRowModel().rows.map(row => row.original)} />
<NameSearch setFilter={setFilter} />
</>
)}
/>
)
}
+3 -14
View File
@@ -1,5 +1,4 @@
import Stack from '@mui/material/Stack'
import { useEffect } from 'react'
import { useParams } from 'react-router'
import { PageLayout } from '@/components/common/PageLayout'
@@ -7,28 +6,18 @@ import { PageTitle } from '@/components/common/PageTitle'
import { useTitle } from '@/hooks/title'
import { UserPage } from '@/pages/Users/UserPage'
import { UsersTable } from '@/pages/Users/UsersTable'
import { useLazyGetAllUsersQuery } from '@/redux/users'
import { useGetAllUsersQuery } from '@/redux/users'
export const Users = () => {
useTitle('Users')
const { userid } = useParams()
const [getAllUsersQuery, { data: users = [], isLoading, isError }] = useLazyGetAllUsersQuery()
useEffect(() => {
if (!userid) {
void getAllUsersQuery()
}
}, [userid, getAllUsersQuery])
const { data: users, isLoading } = useGetAllUsersQuery(undefined, { skip: !!userid })
return (
<PageLayout maxWidth="lg">
<PageTitle title="Users" />
<Stack gap={2}>
{userid ? (
<UserPage userId={userid} />
) : (
<UsersTable getAllUsersQuery={getAllUsersQuery} isError={isError} isLoading={isLoading} users={users} />
)}
{userid ? <UserPage userId={userid} /> : <UsersTable isLoading={isLoading} users={users} />}
</Stack>
</PageLayout>
)
@@ -7,9 +7,7 @@ import type { FormattedStudent, ProgressCriteria, Unarray } from '@oodikone/shar
type PopulationCourseStatsEnrollment = Omit<Unarray<PopulationCourseStats['enrollments']>, 'studentnumber'>
export type PopulationCourseStatsCredit = Omit<Unarray<PopulationCourseStats['credits']>, 'student_studentnumber'>
export interface ExpandedCourseStats extends PopulationCourseStats {
dataByStudent: Map<string, [PopulationCourseStatsEnrollment[], PopulationCourseStatsCredit[]]>
}
type DataByStudent = Map<string, [PopulationCourseStatsEnrollment[], PopulationCourseStatsCredit[]]>
type RequiredFields = {
students: Omit<FormattedStudent, 'criteriaProgress' | 'courses' | 'enrollments'>[]
@@ -19,7 +17,7 @@ type RequiredFields = {
export type Output<T> = T & {
students: FormattedStudent[]
coursestatistics: ExpandedCourseStats
coursestatistics: PopulationCourseStats
}
export const formatPopulationData = <T extends RequiredFields>(
@@ -38,9 +36,7 @@ export const formatPopulationData = <T extends RequiredFields>(
)
const studentNumbers = students.map(({ studentNumber }) => studentNumber)
const creditsAndEnrollmentsByStudent: ExpandedCourseStats['dataByStudent'] = new Map(
studentNumbers.map(n => [n, [[], []]])
)
const creditsAndEnrollmentsByStudent: DataByStudent = new Map(studentNumbers.map(n => [n, [[], []]]))
for (const enrollment of enrollments) {
const { studentnumber, ...rest } = enrollment
@@ -71,6 +67,7 @@ export const formatPopulationData = <T extends RequiredFields>(
return {
course_id: credit.course_id,
course_code: credit.course_code,
courseGroupId: credit.courseGroupId,
date: credit.attainment_date,
passed,
grade: passed ? credit.grade : 'Hyl.',
@@ -89,7 +86,6 @@ export const formatPopulationData = <T extends RequiredFields>(
students: formattedStudents,
coursestatistics: {
...coursestatistics,
dataByStudent: creditsAndEnrollmentsByStudent,
},
...otherParams,
}
+1 -1
View File
@@ -62,7 +62,7 @@ const usersApi = RTKApi.injectEndpoints({
export const {
useGetUserQuery,
useGetRolesQuery,
useLazyGetAllUsersQuery,
useGetAllUsersQuery,
useModifyRolesMutation,
useAddUserUnitsMutation,
useRemoveUserUnitsMutation,
@@ -1,5 +1,4 @@
import { ExpandedCourseStats } from '@/redux/populations/util'
import { CourseStats } from '@oodikone/shared/routes/populations'
import type { CourseStats, PopulationCourseStats } from '@oodikone/shared/routes/populations'
import {
CreditTypeCode,
EnrollmentState,
@@ -230,7 +229,7 @@ const courseBaseStats = {
}
export const filterCourses = (
courseStatistics: ExpandedCourseStats | undefined,
courseStatistics: PopulationCourseStats | undefined,
filteredStudents: FormattedStudent[]
): FilteredCourse[] => {
if (!courseStatistics) return []
@@ -0,0 +1,139 @@
import { test, expect } from '@playwright/experimental-ct-react'
import { DegreeCourseTableWrapper } from './DegreeCourseTableWrapper'
test.describe('DegreeCourseTable', () => {
let criteriaRequests: unknown[]
let exclusionRequests: { method: string; body: unknown }[]
test.beforeEach(async ({ router }) => {
criteriaRequests = []
exclusionRequests = []
await router.route('**/api/login', route => route.fulfill({ json: { user: { roles: [] } } }))
await router.route('**/api/banners', route => route.fulfill({ json: [] }))
await router.route('**/api/programme-criteria/courses', route => {
criteriaRequests.push(route.request().postDataJSON())
return route.fulfill({ json: {} })
})
await router.route('**/api/course-exclusions/', route => {
exclusionRequests.push({ method: route.request().method(), body: route.request().postDataJSON() })
return route.fulfill({ json: [] })
})
})
test('shows year columns and required course counts instead of criterion labels', async ({ mount }) => {
const component = await mount(<DegreeCourseTableWrapper />)
await expect(component.getByRole('columnheader')).toHaveText([
'Name',
'Code',
'Visibility',
'Required for year criteria',
'Year 1',
'Year 2',
'Year 3',
])
await expect(component.getByText('1 / 2')).toBeVisible()
await expect(component.locator('[data-cy^="programme-row-"]')).toHaveCount(0)
})
test('combined programme shows both curriculums in one table, first programme first', async ({ mount, page }) => {
const component = await mount(<DegreeCourseTableWrapper combined studyProgramme="KH90_001" />)
await expect(page.locator('[data-cy="degree-course-table"]')).toHaveCount(1)
await expect(component.getByRole('columnheader', { name: 'Year 6' })).toBeVisible()
await expect(component.locator('tbody tr')).toHaveText([
'First programme (KH90_001)',
/^Basic studies/,
'Second programme (MH90_001)',
/^Clinical studies/,
])
})
test('hiding a course of the second programme only hides it there', async ({ mount }) => {
const component = await mount(<DegreeCourseTableWrapper combined showCriteria={false} studyProgramme="KH90_001" />)
await component.getByText('Clinical studies').click()
await component.getByRole('checkbox', { name: 'Show Calculus I' }).uncheck()
await component.getByText('Basic studies').click()
const calculusSwitches = component.getByRole('checkbox', { name: 'Show Calculus I' })
await expect(calculusSwitches.first()).toBeChecked()
await expect(calculusSwitches.last()).not.toBeChecked()
await expect
.poll(() => exclusionRequests)
.toEqual([
{
method: 'POST',
body: { courseGroupIds: ['group-MAT101'], curriculumVersion: 'period-1', programmeCode: 'MH90_001' },
},
])
})
test('shows six years for medicine', async ({ mount }) => {
const component = await mount(<DegreeCourseTableWrapper studyProgramme="KH90_001" />)
await expect(component.getByRole('columnheader', { name: 'Year 6' })).toBeVisible()
})
test('checking a year saves the criteria once', async ({ mount }) => {
const component = await mount(<DegreeCourseTableWrapper />)
await component.getByText('Basic studies').click()
const checkbox = component.getByRole('checkbox', { name: 'Linear algebra required for year 1' })
await checkbox.check()
await expect(checkbox).toBeChecked()
await expect(component.getByText('2 / 2')).toBeVisible()
expect(criteriaRequests).toEqual([{ code: 'KH50_001', courseGroupIds: ['group-MAT101', 'group-MAT102'], year: 1 }])
})
test('unchecking a year saves the criteria once', async ({ mount }) => {
const component = await mount(<DegreeCourseTableWrapper />)
await component.getByText('Basic studies').click()
await component.getByRole('checkbox', { name: 'Calculus I required for year 1' }).uncheck()
await expect.poll(() => criteriaRequests).toEqual([{ code: 'KH50_001', courseGroupIds: [], year: 1 }])
})
test('visibility-only table hides criteria columns and toggles course visibility', async ({ mount }) => {
const component = await mount(<DegreeCourseTableWrapper showCriteria={false} />)
await expect(component.getByRole('columnheader')).toHaveText(['Name', 'Code', 'Visibility'])
await component.getByText('Basic studies').click()
await component.getByRole('checkbox', { name: 'Show Calculus I' }).uncheck()
await expect(component.getByText('Partly shown')).toBeVisible()
await expect
.poll(() => exclusionRequests)
.toEqual([
{
method: 'POST',
body: { courseGroupIds: ['group-MAT101'], curriculumVersion: 'period-1', programmeCode: 'KH50_001' },
},
])
})
test('hiding a partly hidden module asks for confirmation', async ({ mount, page }) => {
const component = await mount(<DegreeCourseTableWrapper partlyHidden />)
// The switch stays on until the user confirms
await component.getByRole('checkbox', { name: 'Show all courses of Basic studies' }).click()
await expect(page.getByRole('dialog')).toContainText('remaining 1 course for all')
expect(exclusionRequests).toEqual([])
await page.getByRole('button', { name: 'Hide module' }).click()
await expect(component.getByText('Hidden', { exact: true })).toBeVisible()
await expect
.poll(() => exclusionRequests)
.toEqual([
{
method: 'POST',
body: { courseGroupIds: ['group-MAT101'], curriculumVersion: 'period-1', programmeCode: 'KH50_001' },
},
])
})
})
@@ -0,0 +1,78 @@
import { DegreeCourseTable, DegreeModule } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable'
import { ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
import { ReduxWrapper } from '../../ReduxWrapper'
const course = (code: string, name: string, visible = true, moduleName = 'Basic studies') =>
({
code,
group_id: `group-${code}`,
module_order: 0,
name: { fi: name, en: name },
parent_code: 'MOD1',
parent_name: { fi: moduleName, en: moduleName },
visible: { id: null, visibility: visible },
}) as unknown as ProgrammeCourse
const criteria: ProgressCriteria = {
allCourseGroups: {},
courses: {
yearOne: ['group-MAT101'],
yearTwo: [],
yearThree: [],
yearFour: [],
yearFive: [],
yearSix: [],
},
credits: { yearOne: 0, yearTwo: 0, yearThree: 0, yearFour: 0, yearFive: 0, yearSix: 0 },
}
/**
* Mounts `DegreeCourseTable` with one module of two courses. MAT101 is required for year 1.
* With `partlyHidden`, MAT102 is hidden.
* With `combined`, a second programme (MH90_001) adds a module with the same code that also contains MAT101.
*/
export const DegreeCourseTableWrapper = ({
combined = false,
partlyHidden = false,
showCriteria = true,
showVisibility = true,
studyProgramme = 'KH50_001',
}: {
combined?: boolean
partlyHidden?: boolean
showCriteria?: boolean
showVisibility?: boolean
studyProgramme?: string
}) => {
const modules: DegreeModule[] = [
{
code: 'MOD1',
courses: [course('MAT101', 'Calculus I'), course('MAT102', 'Linear algebra', !partlyHidden)],
order: 0,
programmeCode: studyProgramme,
},
]
if (combined) {
modules.push({
code: 'MOD1',
courses: [course('MAT101', 'Calculus I', true, 'Clinical studies')],
order: 0,
programmeCode: 'MH90_001',
})
}
return (
<ReduxWrapper
component={
<DegreeCourseTable
criteria={criteria}
curriculumVersion={['period-1']}
modules={modules}
programmeNames={{ [studyProgramme]: 'First programme', MH90_001: 'Second programme' }}
showCriteria={showCriteria}
showVisibility={showVisibility}
studyProgramme={studyProgramme}
/>
}
/>
)
}
@@ -0,0 +1,131 @@
import { test, expect } from '@playwright/experimental-ct-react'
import { DegreeCoursesTabWrapper } from './DegreeCoursesTabWrapper'
const course = {
code: 'MAT101',
group_id: 'group-MAT101',
module_order: 0,
name: { fi: 'Calculus I', en: 'Calculus I' },
parent_code: 'MOD1',
parent_name: { fi: 'Basic studies', en: 'Basic studies' },
visible: { id: null, visibility: true },
}
const emptyYears = { yearOne: [], yearTwo: [], yearThree: [], yearFour: [], yearFive: [], yearSix: [] }
test.describe('DegreeCoursesTab', () => {
test.beforeEach(async ({ router }) => {
await router.route('**/api/login', route => route.fulfill({ json: { user: { roles: [] } } }))
await router.route('**/api/banners', route => route.fulfill({ json: [] }))
await router.route('**/api/populationstatistics/studyprogrammes', route =>
route.fulfill({
json: {
allProgrammes: { KH90_001: { name: { fi: 'Lääketieteen kandiohjelma', en: 'Medicine (bachelor)' } } },
filteredProgrammes: {},
},
})
)
await router.route('**/api/curriculum-options/*', route =>
route.fulfill({ json: [{ id: 'cur-1', name: '2023–2026', periodIds: ['period-1'], validFrom: '2023-08-01' }] })
)
await router.route('**/api/curriculum/*/*', route =>
route.fulfill({
json: {
defaultProgrammeCourses: [course],
defaultProgrammeModules: [],
secondProgrammeCourses: [],
secondProgrammeModules: [],
},
})
)
await router.route('**/api/programme-criteria?*', route =>
route.fulfill({
json: {
allCourseGroups: {},
courses: emptyYears,
credits: { yearOne: 0, yearTwo: 0, yearThree: 0, yearFour: 0, yearFive: 0, yearSix: 0 },
},
})
)
await router.route('**/api/course-exclusions/', route => route.fulfill({ json: [] }))
})
test('shows the global settings warning and opens the criteria tab by default', async ({ mount }) => {
const component = await mount(<DegreeCoursesTabWrapper programme="KH50_001" />)
await expect(component.getByRole('alert')).toContainText(/non-personal|eivät ole henkilökohtaisia/)
await expect(component.locator('[data-cy="criteria-panel"] [data-cy="curriculum-section"]')).toBeVisible()
await expect(component.getByRole('tab')).toHaveText(['Progress tab criteria', 'Course & module visibility'])
await expect(component.getByRole('tab', { name: 'Progress tab criteria' })).toHaveAttribute('aria-selected', 'true')
await expect(component.locator('[data-cy="credit-criteria-section"]')).toBeVisible()
await expect(component.locator('[data-cy="visibility-panel"]')).toBeHidden()
})
test('opens the given tab and still allows switching to the other one', async ({ mount }) => {
const component = await mount(<DegreeCoursesTabWrapper initialTab="visibility" programme="KH50_001" />)
await expect(component.locator('[data-cy="visibility-panel"]')).toBeVisible()
await expect(component.locator('[data-cy="criteria-panel"]')).toBeHidden()
await expect(component.locator('[data-cy="visibility-panel"] [data-cy="curriculum-section"]')).toBeVisible()
await component.getByRole('tab', { name: 'Progress tab criteria' }).click()
await expect(component.locator('[data-cy="criteria-panel"]')).toBeVisible()
await expect(component.locator('[data-cy="visibility-panel"]')).toBeHidden()
})
test('keeps visibility changes when switching tabs', async ({ mount }) => {
const component = await mount(<DegreeCoursesTabWrapper initialTab="visibility" programme="KH50_001" />)
const visibilityPanel = component.locator('[data-cy="visibility-panel"]')
await visibilityPanel.getByText('Basic studies').click()
await visibilityPanel.getByRole('checkbox', { name: 'Show Calculus I' }).uncheck()
await component.getByRole('tab', { name: 'Progress tab criteria' }).click()
await component.getByRole('tab', { name: 'Course & module visibility' }).click()
await expect(visibilityPanel.getByRole('checkbox', { name: 'Show Calculus I' })).not.toBeChecked()
})
test('has no tabs for a programme without progress criteria', async ({ mount }) => {
const component = await mount(<DegreeCoursesTabWrapper programme="MH50_001" />)
await expect(component.locator('[data-cy="visibility-panel"]')).toBeVisible()
await expect(component.getByRole('tab')).toHaveCount(0)
await expect(component.locator('[data-cy="credit-criteria-section"]')).toHaveCount(0)
})
test('combined programme shows both curriculums in one table', async ({ mount, router }) => {
await router.route('**/api/curriculum/*/*', route =>
route.fulfill({
json: {
defaultProgrammeCourses: [course],
defaultProgrammeModules: [],
secondProgrammeCourses: [
{
...course,
code: 'LK101',
group_id: 'group-LK101',
name: { fi: 'Anatomy', en: 'Anatomy' },
parent_code: 'MOD2',
parent_name: { fi: 'Clinical studies', en: 'Clinical studies' },
},
],
secondProgrammeModules: [],
},
})
)
const component = await mount(<DegreeCoursesTabWrapper programme="KH90_001" />)
const criteriaPanel = component.locator('[data-cy="criteria-panel"]')
await expect(criteriaPanel.locator('[data-cy="degree-course-table"]')).toHaveCount(1)
await expect(criteriaPanel.locator('[data-cy="programme-row-KH90_001"]')).toContainText(
/Lääketieteen kandiohjelma|Medicine \(bachelor\)/
)
// Programme without a known name falls back to its code
await expect(criteriaPanel.locator('[data-cy="programme-row-MH90_001"]')).toHaveText('MH90_001 (MH90_001)')
await expect(criteriaPanel.getByText('Basic studies')).toBeVisible()
await expect(criteriaPanel.getByText('Clinical studies')).toBeVisible()
await expect(criteriaPanel.getByRole('columnheader', { name: 'Year 6' })).toBeVisible()
})
})
@@ -0,0 +1,22 @@
import { CurriculumProvider } from '@/hooks/useCurriculum'
import { DegreeCoursesTab, DegreeSettingsTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
import { ReduxWrapper } from '../../ReduxWrapper'
/**
* Mounts `DegreeCoursesTab` with a curriculum provider. Mock the curriculum and criteria APIs before mounting.
*/
export const DegreeCoursesTabWrapper = ({
initialTab,
programme,
}: {
initialTab?: DegreeSettingsTab
programme: string
}) => (
<ReduxWrapper
component={
<CurriculumProvider programmeCode={programme} year={2024}>
<DegreeCoursesTab degreeProgramme={programme} initialTab={initialTab} />
</CurriculumProvider>
}
/>
)
@@ -2,16 +2,28 @@ import { assert, describe, it } from 'vitest'
import { calculateExcelData, getHopsCourses } from '@/components/CustomPopulation/courseMatrix'
import { CreditTypeCode } from '@oodikone/shared/types'
import type { StudentCourse } from '@oodikone/shared/types/studentData'
import { createCourse as createBaseCourse, createStudent, createStudyPlan } from '@oodikone/shared/test/utils'
// Each course id is its own course group unless overridden
const createCourse = (
course_id: string,
credits: number,
passed: boolean,
date: Date,
credittypecode: CreditTypeCode = passed ? CreditTypeCode.PASSED : CreditTypeCode.FAILED
) => createBaseCourse({ course_id, date, passed, grade: passed ? '5' : 'Hyl.', credits, credittypecode })
overrides: Partial<StudentCourse> = {}
) =>
createBaseCourse({
course_id,
courseGroupId: course_id,
date,
passed,
grade: passed ? '5' : 'Hyl.',
credits,
credittypecode: passed ? CreditTypeCode.PASSED : CreditTypeCode.FAILED,
...overrides,
})
const createHops = (includedCourses: string[]) => createStudyPlan({ included_courses: includedCourses })
@@ -52,6 +64,21 @@ void describe('getHopsCourses', () => {
assert.equal(result.length, 1)
assert.equal(result[0].credits, 6)
})
void it('dedupes completions of different versions of the same course group keeping the latest', () => {
const student = createStudent({
courses: [
createCourse('A-2023', 5, true, new Date('2024-09-01'), { courseGroupId: 'group-a' }),
createCourse('A-2025', 6, true, new Date('2025-01-15'), { courseGroupId: 'group-a' }),
],
studyplans: [createHops(['A-2023', 'A-2025'])],
})
const result = getHopsCourses(student)
assert.equal(result.length, 1)
assert.equal(result[0].course_id, 'A-2025')
})
})
void describe('calculateExcelData', () => {
@@ -71,12 +98,12 @@ void describe('calculateExcelData', () => {
studyplans: [createHops(['course-a', 'course-b'])],
}),
]
const courseInfoById = new Map([
const courseInfoByGroupId = new Map([
['course-a', { code: 'A', name: 'Course A' }],
['course-b', { code: 'B', name: 'Course B' }],
])
const data = calculateExcelData(students, courseInfoById)
const data = calculateExcelData(students, courseInfoByGroupId)
assert.deepEqual(data.completedCoursesRows, [
['1', 'Testi Opiskelija', 'Course A (A)'],
@@ -88,18 +115,42 @@ void describe('calculateExcelData', () => {
])
})
void it('falls back to id as the code for courses missing from the course map', () => {
void it('displays the course group code for other versions and aggregates versions of the same group', () => {
const students = [
createStudent({
studentNumber: '1',
courses: [createCourse('unknown-course-id', 5, true, new Date('2024-09-01'))],
courses: [createCourse('course-a-v1', 5, true, new Date('2024-09-01'), { courseGroupId: 'group-a' })],
studyplans: [createHops(['course-a-v1'])],
}),
createStudent({
studentNumber: '2',
courses: [createCourse('course-a-v2', 5, true, new Date('2025-09-01'), { courseGroupId: 'group-a' })],
studyplans: [createHops(['course-a-v2'])],
}),
]
const courseInfoByGroupId = new Map([['group-a', { code: 'A', name: 'Course A' }]])
const data = calculateExcelData(students, courseInfoByGroupId)
assert.deepEqual(data.completedCoursesRows, [
['1', 'Testi Opiskelija', 'Course A (A)'],
['2', 'Testi Opiskelija', 'Course A (A)'],
])
assert.deepEqual(data.courseCounterRows, [['A', 'Course A', '2', '10']])
})
void it('falls back to the course code of the credit for courses missing from the course map', () => {
const students = [
createStudent({
studentNumber: '1',
courses: [createCourse('unknown-course-id', 5, true, new Date('2024-09-01'), { course_code: 'UNKNOWN01' })],
studyplans: [createHops(['unknown-course-id'])],
}),
]
const data = calculateExcelData(students, new Map())
assert.deepEqual(data.completedCoursesRows, [['1', 'Testi Opiskelija', ' (unknown-course-id)']])
assert.deepEqual(data.courseCounterRows, [['unknown-course-id', '', '1', '5']])
assert.deepEqual(data.completedCoursesRows, [['1', 'Testi Opiskelija', ' (UNKNOWN01)']])
assert.deepEqual(data.courseCounterRows, [['UNKNOWN01', '', '1', '5']])
})
})
@@ -0,0 +1,70 @@
import { assert, describe, it } from 'vitest'
import {
getGroupIdToExportKey,
renameKeys,
} from '@/components/PopulationComponents/Students/Table/CoursesTab/excelExport'
const createModules = (modules: Record<string, [code: string, groupId: string][]>) =>
new Map(
Object.entries(modules).map(([parentCode, courses]) => [
parentCode,
{ courses: courses.map(([code, groupId]) => ({ code, groupId })) },
])
)
void describe('getGroupIdToExportKey', () => {
void it('maps each course groupId to its course code', () => {
const modules = createModules({
MOD1: [
['TKT10001', 'otm-1'],
['TKT10002', 'otm-2'],
],
MOD2: [['MAT11001', 'otm-3']],
})
assert.deepEqual(getGroupIdToExportKey(modules), {
'otm-1': 'TKT10001',
'otm-2': 'TKT10002',
'otm-3': 'MAT11001',
})
})
void it('uses the first code when the same groupId appears under several modules', () => {
const modules = createModules({
MOD1: [['TKT10001', 'otm-1']],
MOD2: [['TKT10001-OLD', 'otm-1']],
})
assert.deepEqual(getGroupIdToExportKey(modules), { 'otm-1': 'TKT10001' })
})
void it('keeps keys unique when separate groupIds share a code', () => {
const modules = createModules({
MOD1: [['TKT10001', 'otm-1']],
MOD2: [['TKT10001', 'otm-2']],
})
assert.deepEqual(getGroupIdToExportKey(modules), {
'otm-1': 'TKT10001',
'otm-2': 'TKT10001 (otm-2)',
})
})
void it('returns an empty map when there are no modules', () => {
assert.deepEqual(getGroupIdToExportKey(new Map()), {})
})
})
void describe('renameKeys', () => {
void it('renames mapped keys and keeps other keys and all values unchanged', () => {
const course = { grade: '5', exportValue: '5' }
const row = { studentNumber: '123456789', totalPassed: 1, 'otm-1': course }
assert.deepEqual(renameKeys(row, { 'otm-1': 'TKT10001' }), {
studentNumber: '123456789',
totalPassed: 1,
TKT10001: course,
})
})
})
@@ -48,8 +48,10 @@ const createCredit = (
credits: number,
credittypecode: CreditTypeCode,
date: Date
): PopulationCourseStatsCredit =>
createBaseCredit({ grade: '', credits, credittypecode, attainment_date: date, course_id: courseId })
): PopulationCourseStatsCredit => ({
...createBaseCredit({ grade: '', credits, credittypecode, attainment_date: date, course_id: courseId }),
courseGroupId: idToGroupId[courseId],
})
const STUDY_RIGHT_START = new Date('2024-08-01')
@@ -0,0 +1,82 @@
import { assert, describe, it } from 'vitest'
import { getHiddenCourseCodes, getHiddenModuleCodes } from '@/components/HiddenCourseAlert/util'
import type { ProgrammeCourse } from '@oodikone/shared/types'
const createCourse = (code: string, parentCode: string | null, visibility: boolean) =>
({ code, parent_code: parentCode, visible: { id: null, visibility } }) as ProgrammeCourse
void describe('getHiddenCourseCodes', () => {
void it('returns codes of hidden courses', () => {
const courses = [createCourse('TKT1', 'MOD1', false), createCourse('TKT2', 'MOD1', true)]
assert.deepEqual(getHiddenCourseCodes(courses), ['TKT1'])
})
void it('returns nothing when all courses are visible', () => {
const courses = [createCourse('TKT1', 'MOD1', true), createCourse('TKT2', 'MOD2', true)]
assert.deepEqual(getHiddenCourseCodes(courses), [])
})
void it('returns nothing when there are no courses', () => {
assert.deepEqual(getHiddenCourseCodes([]), [])
})
void it('returns hidden courses even if their module still has visible courses', () => {
const courses = [
createCourse('TKT1', 'MOD1', false),
createCourse('TKT2', 'MOD1', true),
createCourse('TKT3', 'MOD2', false),
createCourse('TKT4', 'MOD2', false),
]
assert.deepEqual(getHiddenCourseCodes(courses), ['TKT1', 'TKT3', 'TKT4'])
})
void it('includes hidden courses without a parent', () => {
const courses = [createCourse('TKT1', null, false), createCourse('TKT2', null, true)]
assert.deepEqual(getHiddenCourseCodes(courses), ['TKT1'])
})
void it('keeps the original course order', () => {
const courses = [
createCourse('TKT3', 'MOD1', false),
createCourse('TKT1', 'MOD2', false),
createCourse('TKT2', 'MOD1', false),
]
assert.deepEqual(getHiddenCourseCodes(courses), ['TKT3', 'TKT1', 'TKT2'])
})
})
void describe('getHiddenModuleCodes', () => {
void it('returns modules whose courses are all hidden', () => {
const courses = [
createCourse('TKT1', 'MOD1', false),
createCourse('TKT2', 'MOD1', false),
createCourse('TKT3', 'MOD2', false),
createCourse('TKT4', 'MOD2', true),
createCourse('TKT5', 'MOD3', true),
]
assert.deepEqual(getHiddenModuleCodes(courses), ['MOD1'])
})
void it('does not hide a module if a visible course comes after hidden ones', () => {
const courses = [
createCourse('TKT1', 'MOD1', false),
createCourse('TKT2', 'MOD1', true),
createCourse('TKT3', 'MOD1', false),
]
assert.deepEqual(getHiddenModuleCodes(courses), [])
})
void it('ignores courses without a parent', () => {
const courses = [createCourse('TKT1', null, false)]
assert.deepEqual(getHiddenModuleCodes(courses), [])
})
})
@@ -0,0 +1,140 @@
import dayjs from 'dayjs'
import { assert, describe, it } from 'vitest'
import {
getCellText,
getCourseGroups,
hasFailed,
hasPassedDuringAcademicYear,
hasPassedOutsideAcademicYear,
Label,
} from '@/components/PopulationComponents/Students/Table/ProgressTab/util'
import { CreditTypeCode, EnrollmentState, FormattedStudent, ProgressCriteria } from '@oodikone/shared/types'
import { StudentCourse } from '@oodikone/shared/types/studentData'
const COURSES = {
CS001: { id: 'id-CS001', groupId: 'group-CS001', code: 'CS001' },
CS002: { id: 'id-CS002', groupId: 'group-CS002', code: 'CS002' },
CS002A: { id: 'id-CS002A', groupId: 'group-CS002A', code: 'CS002A' },
CS002B: { id: 'id-CS002B', groupId: 'group-CS002B', code: 'CS002B' },
} as const
const idToGroupId = Object.fromEntries(Object.values(COURSES).map(({ id, groupId }) => [id, groupId]))
const criteria = {
allCourseGroups: {
[COURSES.CS001.groupId]: [],
[COURSES.CS002.groupId]: [[COURSES.CS002A.groupId, COURSES.CS002B.groupId]],
},
} as unknown as ProgressCriteria
const createCourse = (
course: keyof typeof COURSES,
date: string,
passed = true,
credittypecode = CreditTypeCode.PASSED
) =>
({
course_id: COURSES[course].id,
course_code: COURSES[course].code,
date: new Date(date),
passed,
credittypecode,
}) as StudentCourse
const createStudent = (courses: StudentCourse[], enrollments: FormattedStudent['enrollments'] = []) =>
({ courses, enrollments, criteriaProgress: {} }) as unknown as FormattedStudent
const label = (course: keyof typeof COURSES): Label => ({
code: COURSES[course].code,
groupId: COURSES[course].groupId,
name: COURSES[course].code,
})
const getGroups = (student: FormattedStudent, course: keyof typeof COURSES) =>
getCourseGroups(COURSES[course].groupId, criteria, student, idToGroupId)
void describe('getCourseGroups', () => {
void it('returns one group per substitution group', () => {
const student = createStudent([createCourse('CS002A', '2024-01-01'), createCourse('CS002B', '2024-02-01')])
const groups = getGroups(student, 'CS002')
assert.equal(groups.length, 1)
assert.deepEqual(
groups[0].map(course => course.course_code),
['CS002B', 'CS002A']
)
})
void it('omits substitution groups the student has not attempted fully', () => {
const student = createStudent([createCourse('CS002A', '2024-01-01')])
assert.deepEqual(getGroups(student, 'CS002'), [])
})
})
void describe('passed checks', () => {
const start = dayjs('2023-08-01')
const end = dayjs('2024-07-31')
void it('does not count a substitution group as passed when one course is failed', () => {
const student = createStudent([createCourse('CS002A', '2024-01-01'), createCourse('CS002B', '2024-02-01', false)])
const groups = getGroups(student, 'CS002')
assert.isFalse(hasPassedOutsideAcademicYear(groups))
assert.isFalse(hasPassedDuringAcademicYear(groups, start, end))
assert.isTrue(hasFailed(groups))
})
void it('counts a course passed after a failed attempt', () => {
const student = createStudent([createCourse('CS001', '2023-10-01', false), createCourse('CS001', '2024-01-01')])
const groups = getGroups(student, 'CS001')
assert.isTrue(hasPassedDuringAcademicYear(groups, start, end))
assert.isFalse(hasFailed(groups))
})
void it('does not count a course passed after the academic year as passed during it', () => {
const student = createStudent([createCourse('CS001', '2024-09-01')])
const groups = getGroups(student, 'CS001')
assert.isFalse(hasPassedDuringAcademicYear(groups, start, end))
assert.isTrue(hasPassedOutsideAcademicYear(groups))
})
})
void describe('getCellText', () => {
const text = (student: FormattedStudent, course: keyof typeof COURSES) =>
getCellText(label(course), criteria, student, 'year1', idToGroupId)
void it('shows the date of the latest passed attempt', () => {
const student = createStudent([createCourse('CS001', '2023-10-01', false), createCourse('CS001', '2024-01-02')])
assert.equal(text(student, 'CS001'), 'Passed 2024-01-02')
})
void it('marks substitutions', () => {
const student = createStudent([createCourse('CS002A', '2024-01-01'), createCourse('CS002B', '2024-02-01')])
assert.equal(text(student, 'CS002'), 'Passed (substituted)')
})
void it('shows the failed date', () => {
const student = createStudent([createCourse('CS001', '2023-10-01', false)])
assert.equal(text(student, 'CS001'), 'Failed 2023-10-01')
})
void it('shows enrollments, ignoring rejected ones', () => {
const enrollment = (state: EnrollmentState, date: string) => ({
course_id: COURSES.CS001.id,
state,
enrollment_date_time: date,
})
const enrolled = createStudent([], [enrollment(EnrollmentState.ENROLLED, '2023-09-01')] as never)
const rejected = createStudent([], [enrollment(EnrollmentState.REJECTED, '2023-09-01')] as never)
assert.equal(text(enrolled, 'CS001'), 'Enrollment 2023-09-01')
assert.equal(text(rejected, 'CS001'), '')
})
})
+3
View File
@@ -6,6 +6,7 @@ import path from 'path'
import { defineConfig } from 'vite'
const inStaging = process.env.REACT_APP_STAGING === 'true'
const viteCacheDir = process.env.VITE_CACHE_DIR ?? 'node_modules/.vite'
// https://vitejs.dev/config/
// eslint-disable-next-line import-x/no-unused-modules, import-x/no-default-export
@@ -38,6 +39,8 @@ export default defineConfig({
}),
],
base: inStaging ? '/oodikone' : '/',
// Docker runs as DOCKER_UID, which may not have write access to the node_modules volume
cacheDir: viteCacheDir,
server: {
proxy: {
'/api/': {
@@ -5,9 +5,7 @@ export type ExcludedCourseCreation = Optional<ExcludedCourse, 'id' | 'createdAt'
export type ExcludedCourse = {
id: number
programme_code: string
// TODO: Remove course_code once all rows have been migrated to course_group_id
course_code: string | null
course_group_id: string | null
course_group_id: string
curriculum_version: string
createdAt: Date
updatedAt: Date
@@ -5,19 +5,12 @@ export type ProgressCriteriaCreation = Optional<ProgressCriteria, 'curriculumVer
export type ProgressCriteria = {
code: string
curriculumVersion: string
// TODO: Remove coursesYear* once all rows have been migrated to courseGroupIdsYear*
coursesYearOne: string[]
coursesYearTwo: string[]
coursesYearThree: string[]
coursesYearFour: string[]
coursesYearFive: string[]
coursesYearSix: string[]
courseGroupIdsYearOne: string[] | null
courseGroupIdsYearTwo: string[] | null
courseGroupIdsYearThree: string[] | null
courseGroupIdsYearFour: string[] | null
courseGroupIdsYearFive: string[] | null
courseGroupIdsYearSix: string[] | null
courseGroupIdsYearOne: string[]
courseGroupIdsYearTwo: string[]
courseGroupIdsYearThree: string[]
courseGroupIdsYearFour: string[]
courseGroupIdsYearFive: string[]
courseGroupIdsYearSix: string[]
creditsYearOne: number
creditsYearTwo: number
creditsYearThree: number
+1
View File
@@ -71,6 +71,7 @@ export const createStudyRight = (overrides: Partial<StudentStudyRight> = {}): St
export const createCourse = (overrides: Partial<StudentCourse> = {}): StudentCourse => ({
course_id: 'hy-CU-000000001-2024-08-01',
course_code: 'MAT001',
courseGroupId: 'hy-CU-000000001',
date: new Date('2024-08-01'),
passed: true,
grade: '5',
+3 -2
View File
@@ -1,4 +1,4 @@
import type { Credit, Enrollment, SISStudyRight, SISStudyRightElement, Student, Studyplan } from '../models'
import type { Course, Credit, Enrollment, SISStudyRight, SISStudyRightElement, Student, Studyplan } from '../models'
import type { Tag, TagStudent } from '../models/kone'
import type { CriteriaYear, CreditTypeCode, Name, ProgrammeModule } from '../types'
@@ -115,6 +115,7 @@ export type StudentCourse = {
credittypecode: CreditTypeCode
language: string
studyright_id: string
courseGroupId: Course['groupId']
}
export type StudentCredit = Pick<
@@ -129,7 +130,7 @@ export type StudentCredit = Pick<
| 'course_code'
| 'language'
| 'studyright_id'
>
> & { courseGroupId: Course['groupId'] }
export type StudentEnrollment = Pick<
Enrollment,
+7
View File
@@ -2,6 +2,13 @@ import { StudyProgrammeCourse } from '../types'
export type YearStats = StudyProgrammeCourse['years'][number]
// In the object programmes should be {bachelorCode: masterCode}
export const combinedStudyProgrammes = { KH90_001: 'MH90_001' } as const
/** Second programme of a combined programme, eg. the licentiate programme of a combined bachelor programme */
export const getCombinedProgramme = (programmeCode: string): string | undefined =>
combinedStudyProgrammes[programmeCode as keyof typeof combinedStudyProgrammes]
export const createEmptyStats = (isStudyModule = false): YearStats => ({
allCredits: 0,
allStudents: 0,
+4 -1
View File
@@ -108,7 +108,10 @@ cypress/
# additional inclusions here
# exclusions here
*/.eslintrc.json
**/.dockerignore
**/.gitignore
**/.eslintrc.json
**/*Dockerfile
**/build
**/dist
**/.npm
+1 -1
View File
@@ -7,7 +7,7 @@ ENV TZ="Europe/Helsinki"
ENV NODE_ENV=development
COPY . .
RUN npm ci --include=dev
RUN npm ci
EXPOSE 8082
+4 -1
View File
@@ -109,7 +109,10 @@ cypress/
!./src/updater/updateStudents/excludedPartialAttainments.csv
# exclusions here
*/.eslintrc.json
**/.dockerignore
**/.gitignore
**/.eslintrc.json
**/*Dockerfile
**/build
**/dist
**/.npm
+1 -1
View File
@@ -7,6 +7,6 @@ ENV TZ="Europe/Helsinki"
ENV NODE_ENV=development
COPY . .
RUN npm ci --include=dev
RUN npm ci
CMD ["node_modules/.bin/nodemon", "--exec", "node src/index.js", "--watch", "src", "--watch", "--ext", "ts, js, json", "--legacy-watch"]
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
},
},
test: {
exclude: ['node_modules'],
exclude: ['node_modules', 'dist'],
testTimeout: 10_000,
},
})