Compare commits

...
64 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
joonas-a a8fe237e3d [Kone] Progress criteria migration to group id step 1 2026-09-29 12:38:40 +03:00
joonas-a fb3ca8b699 [Kone] Course exclusion migration to group id step 1 2026-09-29 12:18:34 +03:00
joonas-a 8d7966b93f [Scripts] add kone-db to ok.sh 2026-09-29 11:50:51 +03:00
Aaro Koivukangas ed631a4d3b [GithubActions] Install s3cmd in each db step 2026-09-28 15:43:15 +03:00
Aaro Koivukangas 2459f73f56 [GithubActions] Enable db scripts 2026-09-28 14:00:24 +03:00
Aaro Koivukangas 1ac138a494 [Scripts] Greatly improve db scripts 2026-09-28 13:57:35 +03:00
Aaro Koivukangas 351310c8d6 [Format] Format fixes 2026-09-24 16:05:43 +03:00
joonas-a 484dbdbe40 [E2E] fix old infobox text test 2026-09-22 16:49:32 +03:00
joonas-a a85b886167 [Course criterion table] bättre användbarhet och layout 2026-09-22 16:14:45 +03:00
joonas-a 6f404658d1 [Class stats] manage visibility button add to all relevant places, tweak warning 2026-09-22 15:50:46 +03:00
joonas-a e0e47554a5 [Courses tab] add button to open course visibility modal 2026-09-22 15:24:02 +03:00
joonas-a 3ae0cb0249 [Courses shown] rewording + more emphasis on warning 2026-09-22 15:23:42 +03:00
joonas-a 30d8b03485 [Courses tab] Do not show duplicate courses under same module in rare instances 2026-09-22 14:05:35 +03:00
joonas-a 0126c33ff8 [TSX] Toggle substitution switch 2026-09-22 13:52:27 +03:00
joonas-a 0ee5b514f6 [Ooditable] reusable "copy studentnumbers" button
+ add to progress tab
2026-09-22 13:45:22 +03:00
joonas-a 7a8e258b7c [Progress] Make infobox viewport-width-efficient
and tsx
2026-09-22 13:31:30 +03:00
joonas-a b5d3fabe71 [Infobox] i18n and rephrasing 2026-09-22 12:39:12 +03:00
joonas-a 5e25423230 [Degree programme settings] Clarify modal and more infobox 2026-09-22 12:33:09 +03:00
Aaro Koivukangas b6c06c663a [ComponentTests] Add check that Progress tab is not lost 2026-09-22 11:56:08 +03:00
joonas-a 81bce184a4 [Progress tab] fix tab never shown 2026-09-22 11:32:13 +03:00
Aaro Koivukangas 1b45a089b0 [Backend] Add older doctoral programmes back to "Update meta" 2026-09-22 11:29:46 +03:00
joonas-a a1560898ad [Manage courses shown] Clarify in modal what it does 2026-09-22 11:15:16 +03:00
outisa fd5fe1189c [Degree programmes] Fix sort by valid from 2026-09-22 10:04:08 +03:00
outisa 9e98fc1a6a [Updater meta] Update relevant phd programmes 2026-09-22 10:03:03 +03:00
outisa 7eb7d82088 Update programme codes 2026-09-22 09:41:29 +03:00
Aaro Koivukangas 83cd46f565 [Navbar] Change Courses to Courses / Modules 2026-09-22 09:39:50 +03:00
118 changed files with 3669 additions and 1834 deletions
+2
View File
@@ -26,3 +26,5 @@ jobs:
secrets:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
s3_access_key: ${{ secrets.S3_ACCESS_KEY }}
s3_secret_key: ${{ secrets.S3_SECRET_KEY }}
+2
View File
@@ -23,6 +23,8 @@ jobs:
secrets:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
s3_access_key: ${{ secrets.S3_ACCESS_KEY }}
s3_secret_key: ${{ secrets.S3_SECRET_KEY }}
release_backend:
name: 'Build and release backend'
+30 -7
View File
@@ -15,6 +15,12 @@ on:
password:
required: true
description: 'Toska registry password'
s3_access_key:
required: true
description: 'S3 access key for the test database dumps'
s3_secret_key:
required: true
description: 'S3 secret key for the test database dumps'
jobs:
# === Static tests / linting ===
@@ -107,10 +113,14 @@ jobs:
password: ${{ secrets.password }}
- name: 'Let sis-updater-worker run migrations, then push new version of image to toska hub'
env:
ACCESS_KEY: ${{ secrets.s3_access_key }}
SECRET_KEY: ${{ secrets.s3_secret_key }}
run: |
docker compose --file docker-compose.ci.yml up --detach --quiet-pull sis-updater-worker
sleep 30 # wait for sis-updater-worker to start and run migrations
# ./anonyymioodi/db_utils.sh local sis-db
sudo apt update -y && sudo apt install s3cmd -y
./anonyymioodi/manage_test_dbs.sh release sis-db
- name: Teardown containers started by compose
if: always()
@@ -143,10 +153,14 @@ jobs:
password: ${{ secrets.password }}
- name: 'Let backend run migrations, then push new version of image to toska hub'
env:
ACCESS_KEY: ${{ secrets.s3_access_key }}
SECRET_KEY: ${{ secrets.s3_secret_key }}
run: |
docker compose --file docker-compose.ci.yml up --detach --quiet-pull backend --wait
sleep 30 # wait for backend to start and run migrations
# ./anonyymioodi/db_utils.sh local kone-db
sudo apt update -y && sudo apt install s3cmd -y
./anonyymioodi/manage_test_dbs.sh release kone-db
- name: Teardown containers started by compose
if: always()
@@ -179,10 +193,14 @@ jobs:
password: ${{ secrets.password }}
- name: 'Let backend run migrations, then push new version of image to toska hub'
env:
ACCESS_KEY: ${{ secrets.s3_access_key }}
SECRET_KEY: ${{ secrets.s3_secret_key }}
run: |
docker compose --file docker-compose.ci.yml up --detach --quiet-pull backend --wait
sleep 30 # wait for backend to start and run migrations
# ./anonyymioodi/db_utils.sh local user-db
sudo apt update -y && sudo apt install s3cmd -y
./anonyymioodi/manage_test_dbs.sh release user-db
- name: Teardown containers started by compose
if: always()
@@ -249,10 +267,15 @@ jobs:
EXIT_AFTER_IMMEDIATES: yes
# TODO: Push the updated sis-db to toska-hub when updater is deterministic with test-data
# - name: Push newly build db to toska hub
# if: |
# success() && inputs.skip_db_update != 'true'
# run: ./anonyymioodi/db_utils.sh local sis-db
- name: Push newly build db to toska hub
if: |
success() && inputs.skip_db_update != 'true'
env:
ACCESS_KEY: ${{ secrets.s3_access_key }}
SECRET_KEY: ${{ secrets.s3_secret_key }}
run: |
sudo apt update -y && sudo apt install s3cmd -y
./anonyymioodi/manage_test_dbs.sh release sis-db
- name: Dump logs on failure
if: failure()
+24
View File
@@ -4,6 +4,30 @@ This folder contains a guide and scripts to modify or create anonymous database
The quick guide should be enough for most purposes: updating schema, data, or the PostgreSQL version. If you need to generate completely new `sis-importer-db` and `sis-db` images (which shouldn't happen too often), follow the instructions in the section [Generate new sis-importer-db and sis-db completely from scratch](#generate-new-sis-importer-db-and-sis-db-completely-from-scratch).
## Scripts
`manage_test_dbs.sh` is the front end for the test database tooling. Run it without arguments for an interactive menu, or give it an action and one or more databases (or `all`) to run it directly:
```bash
./manage_test_dbs.sh dump sis-db # dump sis-db from its running container
./manage_test_dbs.sh upload sis-db # upload the current dump to s3
./manage_test_dbs.sh release all # dump, upload and push images for every database
```
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` |
| `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.
## Quick guide to updating a test database
1. Ensure you have the latest test-db. Just pulling images might not replace your existing volume, so you may need to remove it manually. This command might work: `docker volume rm oodikone-sis-db-data` (replace `oodikone-sis-db-data` with the correct volume name). The new image, including data, should be pulled the next time you launch Oodikone normally.
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Build database images from the dumps in .databasedumps/test and push them to
# the Toska registry.
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
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") <database>... | all
Builds an image of each given database and pushes it to $REGISTRY.
The dump is read from .databasedumps/test/<database>.sql, so run
dump_local_db.sh or download_dump_from_s3.sh first.
Databases: ${DATABASES[*]}
EOF
}
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
usage
exit 0
fi
if [[ $# -eq 0 ]]; then
usage
die "No database given"
fi
build_and_push_image() {
local database="$1"
local image="$REGISTRY/$database:latest"
local dump_file="$TEST_DUMP_DIR/$database.sql"
[[ -f "$dump_file" ]] || die "No dump found at $dump_file, run dump_local_db.sh or download_dump_from_s3.sh first"
infomsg "Building $image"
docker build "$PROJECT_ROOT" -f "$PROJECT_ROOT/db.Dockerfile" -t "$image" --build-arg DB_NAME="$database"
infomsg "Pushing $image"
docker push "$image"
successmsg "Pushed $image"
}
resolve_databases "$@"
for database in "${selected_databases[@]}"; do
build_and_push_image "$database"
done
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Create the s3 bucket used for the test database dumps, if it doesn't exist yet.
# Doing nothing when the bucket is already there, so this is safe to run every time.
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
usage() {
cat >&2 <<EOF
Usage: $(basename "$0")
Creates the bucket $S3_BUCKET if it doesn't exist yet, and does nothing if it does.
EOF
}
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
usage
exit 0
fi
require_s3_config
# s3cmd info exits with an error when the bucket doesn't exist
if s3 info "$S3_BUCKET" > /dev/null 2>&1; then
infomsg "Bucket $S3_BUCKET already exists"
exit 0
fi
warningmsg "Bucket $S3_BUCKET doesn't exist, creating it"
s3 mb "$S3_BUCKET"
successmsg "Created bucket $S3_BUCKET"
-235
View File
@@ -1,235 +0,0 @@
#!/usr/bin/env bash
# === Config ===
# Quit if failing
set -euo pipefail
# Print messages and logs that are not script output to stderr
msg() {
echo >&2 -e "${1-}"
}
# Some special types of messages with colours
successmsg() {
msg "${GREEN}$1${NOFORMAT}"
}
errormsg() {
msg "${RED}$1${NOFORMAT}"
}
infomsg() {
msg "${BLUE}$1${NOFORMAT}"
}
warningmsg() {
msg "${ORANGE}$1${NOFORMAT}"
}
# Setup colors for messages if running interactive shell
if [[ -t 2 ]] && [[ "${TERM-}" != "dumb" ]]; then
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m'
else
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE=''
fi
# === #
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
get_project_relative_path () {
local absolute_path="$1"
local root="$2"
realpath -s --relative-to="${root:-$PROJECT_ROOT}" "$absolute_path"
}
LOCAL_DUMP_DIR="$(get_project_relative_path ".databasedumps/local_containers" "$PWD")" # Dumps from running docker containers
S3_DUMP_DIR="$(get_project_relative_path ".databasedumps/s3" "$PWD")" # Dumps from s3
TEST_DUMP_DIR="$(get_project_relative_path ".databasedumps/test" "$PWD")" # Location where .sql files are pulled while building the new image
S3_CONFIG_FILE=~/.s3cfg
S3_BUCKET="s3://oodikone-test"
REGISTRY="registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi"
## Following the naming convention in docker compose, these are names for database
## services. The real data databases inside service will have names suffixed by "-real".
KONE_DB_NAME="kone-db"
SIS_DB_NAME="sis-db"
SIS_IMPORTER_DB_NAME="sis-importer-db"
USER_DB_NAME="user-db"
JAMI_DB_NAME="jami-db"
DATABASES=("$USER_DB_NAME" "$KONE_DB_NAME" "$SIS_DB_NAME" "$SIS_IMPORTER_DB_NAME" "$JAMI_DB_NAME")
mkdir -p "$TEST_DUMP_DIR"
mkdir -p "$S3_DUMP_DIR"
mkdir -p "$LOCAL_DUMP_DIR"
# === Handle s3 bucket ===
# Use s3 bucket as the dump save/pull location
download_s3_db () {
local database="$1"
# TODO: Decompress compressed files
s3cmd -c "$S3_CONFIG_FILE" get "$S3_BUCKET/$database.sql" "$S3_DUMP_DIR/$database.sql"
cp --remove-destination -r "$S3_DUMP_DIR" "$TEST_DUMP_DIR" # Move dumps to the place where docker build pulls .sql files from
}
upload_s3_db () {
local database="$1"
if [[ $(s3cmd ls s3:// | grep -q oodikone-test; echo $?) -eq 0 ]]
then
infomsg "OODIKONE-TEST bucket exists, skipping creation";
else
warningmsg "OODIKONE-TEST bucket doesn't exist, creating new bucket...";
s3cmd -c "$S3_CONFIG_FILE" mb "s3://oodikone-test"
fi
infomsg "Pushing $TEST_DUMP_DIR/$database.sql to $S3_BUCKET"
# TODO: Also compress files
s3cmd -c "$S3_CONFIG_FILE" put "$TEST_DUMP_DIR/$database.sql" "$S3_BUCKET/$database.sql"
}
# === Build images ===
# Update registry with an existing dump located in .databasedumps/test/$1.sql
build_and_push_image () {
local database="$1"
infomsg "Building docker image for $database\n"
docker build "$(get_project_relative_path "$PROJECT_ROOT" "$PWD")" -t "$REGISTRY/$database:latest" -f db.Dockerfile --build-arg DB_NAME="$database"
successmsg "$database docker image built"
infomsg "Pushing $database to $REGISTRY\n"
infomsg "$REGISTRY/$database"
docker push "$REGISTRY/$database"
successmsg "Image $REGISTRY/$database pushed successfully"
}
# === Handle dumping local dbs ===
# To conveniently update all dbs, build all with docker-compose.db.yml and then run dump_all_local_dbs
dump_local_db () {
local database="$1"
local container_name="oodikone-$1"
local database_name="$database"
if [[ $database == "jami-db" ]]; then database_name="postgres"; fi
infomsg "Trying to dump $database to $LOCAL_DUMP_DIR/$database.sql"
docker exec -i "$container_name" pg_dump -Fp -U postgres -d "$database_name" > "$LOCAL_DUMP_DIR/$database.sql"
successmsg "Dumped $database to $LOCAL_DUMP_DIR/$database.sql"
cp --remove-destination -r "$LOCAL_DUMP_DIR/$database.sql" "$TEST_DUMP_DIR" # Move dump to the place where docker build pulls the .sql file from
}
# === Helpers ===
do_for_all () {
local cmd="$1"
shift # Removes $1 from "$@"
infomsg "Running $cmd for all databases"
for database in "${DATABASES[@]}"; do
"$cmd" "$database" "$@"
done
successmsg "$cmd ran successfully"
}
ask_for_input () {
local cmd="$1"
shift # Removes $1 from "$@"
echo
init_database_menu
select database in "${options[@]}"; do
"$cmd" "$database" "$@"
break
done
}
init_action_menu () {
PS3="Please enter your choice: "
options=(
"Pull dumps from s3"
"Dump from running docker images"
"Update dumps in s3"
"Push new images to registry"
)
}
init_mode_menu () {
PS3="Select command mode: "
options=(
"Run command for all databases"
"Select single database"
)
}
init_database_menu () {
PS3="Select database: "
options=("${DATABASES[@]}")
}
# === RUN ===
# For running as non-interactive CI-mode
# $1: Command to be run
# $2: Database name
case "$1" in
"local")
if [ "$#" -lt 2 ]; then echo "Missing database name"; exit 1; fi
dump_local_db "$2"
build_and_push_image "$2"
exit 0;;
# TODO: NOT YET IMPLEMENTED, should use s3 storage
"ci")
exit 1
# if [ "$#" -lt 2 ]; then echo "Missing database name"; exit 1; fi
# download_s3_db "$2"
# build_and_push_image "$2"
# exit 0;;
esac
# For interactive running
while true; do
init_mode_menu
select mode in "${options[@]}"; do
case "$mode" in
"Run command for all databases")
cmd="do_for_all"
break;;
"Select single database")
cmd="ask_for_input"
break;;
esac
done
echo
echo
init_action_menu
select opt in "${options[@]}"; do
case "$opt" in
"Pull dumps from s3")
"$cmd" download_s3_db "$S3_DUMP_DIR"
break;;
"Dump from running docker images")
"$cmd" dump_local_db "$LOCAL_DUMP_DIR"
break;;
"Update dumps in s3")
"$cmd" upload_s3_db
break;;
"Push new images to registry")
"$cmd" build_and_push_image
break;;
esac
done
echo
done
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Download database dumps from s3 into .databasedumps/s3, and copy them to
# .databasedumps/test where db.Dockerfile reads them from.
#
# Dumps are uploaded as <db_name>-<git_branch>-<datetime>.sql (see
# upload_dump_to_s3.sh), so the newest matching dump is the one downloaded.
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
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") [-b|--branch <branch>] <database>... | all
Downloads the newest dump of each given database from $S3_TARGET.
Databases: ${DATABASES[*]}
Options:
-b, --branch <branch> Only consider dumps uploaded from this git branch
-h, --help Show this help
EOF
}
branch=""
arguments=()
while [[ $# -gt 0 ]]; do
case "$1" in
-b | --branch)
branch="${2-}"
[[ -n "$branch" ]] || {
usage
die "Missing value for $1"
}
shift 2
;;
-h | --help)
usage
exit 0
;;
-*)
usage
die "Unknown option: $1"
;;
*)
arguments+=("$1")
shift
;;
esac
done
# Newest object in s3 for the given database, or empty if there are none
newest_dump_in_s3() {
local database="$1"
local prefix="$S3_TARGET/$database-"
if [[ -n "$branch" ]]; then prefix="$prefix$(sanitize_branch_name "$branch")-"; fi
# s3cmd ls prints "<date> <time> <size> <object>", so sorting by the first two
# columns in reverse puts the newest dump first
s3 ls "$prefix" | sort -rk1,2 | awk 'NR == 1 { print $4 }'
}
download_database() {
local database="$1"
local dump_file="$S3_DUMP_DIR/$database.sql"
local newest_dump
newest_dump="$(newest_dump_in_s3 "$database")"
[[ -n "$newest_dump" ]] || die "No dumps found for $database in $S3_TARGET${branch:+ (branch $branch)}"
infomsg "Downloading $newest_dump"
s3 get --force "$newest_dump" "$dump_file"
cp --remove-destination "$dump_file" "$TEST_DUMP_DIR/$database.sql"
successmsg "Downloaded $newest_dump to $dump_file"
}
if [[ ${#arguments[@]} -eq 0 ]]; then
usage
die "No database given"
fi
resolve_databases "${arguments[@]}"
require_s3_config
make_dump_dirs
for database in "${selected_databases[@]}"; do
download_database "$database"
done
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Dump one or more databases from the running docker containers into
# .databasedumps/local_containers, and copy them to .databasedumps/test where
# db.Dockerfile reads them from.
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
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") <database>... | all
Dumps the given databases from their running containers.
Databases: ${DATABASES[*]}
EOF
}
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
usage
exit 0
fi
if [[ $# -eq 0 ]]; then
usage
die "No database given"
fi
dump_database() {
local database="$1"
local container_name="oodikone-$database"
local database_name
database_name="$(postgres_database_name "$database")"
local dump_file="$LOCAL_DUMP_DIR/$database.sql"
infomsg "Dumping $database from container $container_name"
# Dump to a temporary file first, so that a failed dump doesn't overwrite the previous one
docker exec -i "$container_name" pg_dump -Fp -U postgres -d "$database_name" > "$dump_file.tmp"
mv "$dump_file.tmp" "$dump_file"
cp --remove-destination "$dump_file" "$TEST_DUMP_DIR/$database.sql"
successmsg "Dumped $database to $dump_file"
}
resolve_databases "$@"
make_dump_dirs
for database in "${selected_databases[@]}"; do
dump_database "$database"
done
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Shared configuration and helpers for the anonyymioodi database scripts.
# Meant to be sourced by the other scripts in this folder, not run on its own.
# The configuration below is used by the sourcing scripts, not by this file
# shellcheck disable=SC2034
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
source "$PROJECT_ROOT"/scripts/utils.sh
# Dumps taken from running docker containers
LOCAL_DUMP_DIR="$PROJECT_ROOT/.databasedumps/local_containers"
# Dumps downloaded from s3
S3_DUMP_DIR="$PROJECT_ROOT/.databasedumps/s3"
# Where db.Dockerfile copies the .sql files from when an image is built
TEST_DUMP_DIR="$PROJECT_ROOT/.databasedumps/test"
# Used as a fallback when ACCESS_KEY and SECRET_KEY aren't set in the environment
S3_CONFIG_FILE=~/.s3cfg
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"
# Databases, named after their docker compose services
DATABASES=("user-db" "kone-db" "sis-db" "sis-importer-db" "jami-db")
# Set by resolve_databases, read by the scripts sourcing this file
selected_databases=()
# The name of the postgres database inside the container. It matches the service
# name for every database except jami-db.
postgres_database_name() {
local database="$1"
if [[ "$database" == "jami-db" ]]; then
echo "postgres"
else
echo "$database"
fi
}
# Validates the given database names and expands "all" to every database.
# The result is set to the global array $selected_databases, because dying
# inside a subshell wouldn't stop the calling script.
resolve_databases() {
selected_databases=()
if [[ $# -eq 0 ]]; then
die "No database given, expected one or more of: ${DATABASES[*]} all"
fi
for argument in "$@"; do
if [[ "$argument" == "all" ]]; then
selected_databases=("${DATABASES[@]}")
return
fi
# Check if given databases are correct ones
local known=false
for database in "${DATABASES[@]}"; do
if [[ "$argument" == "$database" ]]; then known=true; fi
done
$known || die "Unknown database: $argument. Expected one or more of: ${DATABASES[*]} all"
selected_databases+=("$argument")
done
}
# Dumps in s3 are named after the branch they were uploaded from, so the branch
# name has to be usable as a part of a file name. E.g. feature/foo -> feature-foo.
# feature/foo would result in a new bucket "feature" with a sub-bucket "foo"
sanitize_branch_name() {
echo "${1//[^A-Za-z0-9._-]/-}"
}
current_branch_name() {
local branch
branch="$(git rev-parse --abbrev-ref HEAD)"
if [[ "$branch" == "HEAD" ]]; then # Detached head, use the commit instead
branch="$(git rev-parse --short HEAD)"
fi
sanitize_branch_name "$branch"
}
has_s3_env_credentials() {
[[ -n "${ACCESS_KEY-}" && -n "${SECRET_KEY-}" ]]
}
# Credentials come from ACCESS_KEY and SECRET_KEY when both are set, and from
# $S3_CONFIG_FILE when neither is
require_s3_config() {
command -v s3cmd > /dev/null || die "s3cmd not found, please install it first"
if [[ -n "${ACCESS_KEY-}" || -n "${SECRET_KEY-}" ]]; then
has_s3_env_credentials || die "Only one of ACCESS_KEY and SECRET_KEY is set, set both or neither"
return
fi
[[ -f "$S3_CONFIG_FILE" ]] || die "Set ACCESS_KEY and SECRET_KEY, or add $S3_CONFIG_FILE. The \".s3cfg\" config file can be found on toska/dokumentaatio repo"
}
s3_env_config() {
cat <<EOF
[default]
access_key = $ACCESS_KEY
secret_key = $SECRET_KEY
host_base = $S3_HOST
host_bucket = $S3_HOST
use_https = True
check_ssl_certificate = True
EOF
}
# Runs s3cmd with the credentials chosen by require_s3_config. The environment
# credentials are passed through process substitution, so that they aren't
# written to disk or visible in the process list.
s3() {
if has_s3_env_credentials; then
s3cmd -c <(s3_env_config) "$@"
else
s3cmd -c "$S3_CONFIG_FILE" "$@"
fi
}
# The dump directories are created lazily by the scripts that write into them
make_dump_dirs() {
mkdir -p "$LOCAL_DUMP_DIR" "$S3_DUMP_DIR" "$TEST_DUMP_DIR"
}
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# Front end for the test database scripts in this folder. Run it without
# arguments for an interactive menu, or give it an action and a database to run
# it directly, e.g. in CI.
#
# Each action is just a call to one of the scripts next to this one, so they can
# also be used on their own.
set -euo pipefail
# Here BASH_SOURCE[0] is used instead of $0 because it works
# consistently when ran normally and when sourced
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR"/lib/common.sh
ACTIONS=("download" "dump" "upload" "push" "prune" "release" "rebuild")
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") [<action> <database>... | all]
Runs without arguments for an interactive menu.
Actions:
download Download the newest dumps from s3
dump Dump the databases from the running docker containers
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, prune and push (shorthand)
rebuild Download the dumps from s3, then push (shorthand)
Databases: ${DATABASES[*]} all
EOF
}
# === Actions ===
download_dump() {
"$SCRIPT_DIR"/download_dump_from_s3.sh "$1"
}
dump_database() {
"$SCRIPT_DIR"/dump_local_db.sh "$1"
}
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"
}
run_action() {
local action="$1"
local database="$2"
case "$action" in
download) download_dump "$database" ;;
dump) dump_database "$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)
download_dump "$database"
push_image "$database"
;;
*)
usage
die "Unknown action: $action"
;;
esac
}
run_action_for_all() {
local action="$1"
shift
# Check if command was a proper command
local known_action=false
for known in "${ACTIONS[@]}"; do
if [[ "$action" == "$known" ]]; then known_action=true; fi
done
if ! $known_action; then
usage
die "Unknown action: $action"
fi
resolve_databases "$@"
for database in "${selected_databases[@]}"; do
run_action "$action" "$database"
done
successmsg "Action $action done for: ${selected_databases[*]}"
}
# === Interactive menu ===
ask_and_run() {
local action database
PS3="Select action: "
select action in "${ACTIONS[@]}" "quit"; do
[[ -n "${action-}" ]] || continue
if [[ "$action" == "quit" ]]; then exit 0; fi
break
done
PS3="Select database: "
select database in "${DATABASES[@]}" "all"; do
[[ -n "${database-}" ]] || continue
break
done
run_action_for_all "$action" "$database"
}
# === Run ===
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
usage
exit 0
fi
if [[ $# -eq 0 ]]; then
while true; do
ask_and_run
echo
done
fi
if [[ $# -lt 2 ]]; then
usage
die "Both an action and a database are required"
fi
run_action_for_all "$@"
+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
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Upload database dumps from .databasedumps/test to s3, naming the objects
# <database>-<git_branch>-<datetime>.sql so that dumps don't overwrite each other.
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
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") [-n|--dry-run] <database>... | all
Uploads the dumps in .databasedumps/test to $S3_TARGET
as <database>-<git_branch>-<datetime>.sql. Run dump_local_db.sh first to
create the dumps.
Databases: ${DATABASES[*]}
Options:
-n, --dry-run Print the s3cmd command instead of running it
-h, --help Show this help
EOF
}
dry_run=false
arguments=()
while [[ $# -gt 0 ]]; do
case "$1" in
-n | --dry-run)
dry_run=true
shift
;;
-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
upload_database() {
local database="$1"
local dump_file="$TEST_DUMP_DIR/$database.sql"
[[ -f "$dump_file" ]] || die "No dump found at $dump_file, run dump_local_db.sh first"
[[ -s "$dump_file" ]] || die "Dump is empty: $dump_file"
local datetime
datetime="$(date +%Y%m%d-%H%M%S)"
local target="$S3_TARGET/$database-$branch-$datetime.sql"
if [[ "$dry_run" == true ]]; then
infomsg "Dry run, would run: s3cmd put $dump_file $target"
return
fi
infomsg "Pushing $dump_file to $target"
s3 put "$dump_file" "$target"
successmsg "Uploaded $dump_file to $target"
}
resolve_databases "${arguments[@]}"
require_s3_config
if [[ "$dry_run" == false ]]; then
"$SCRIPT_DIR"/create_s3_bucket.sh
fi
branch="$(current_branch_name)"
for database in "${selected_databases[@]}"; do
upload_database "$database"
done
+12 -8
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,9 +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('Here you can change')
).toBeVisible()
await expect(page.getByTestId('credit-criteria-info-box-content').getByText('Tässä asetetut')).toBeVisible()
})
test.skip('changing the credit criteria works', () => {
@@ -677,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
+107 -24
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
}
@@ -95,8 +107,9 @@ database_menu_opts() {
msg "suffix: $dbsuffix"
msg "1) Oodikone"
msg "2) Importer"
msg "3) Toggle data source (test/real)"
msg "4) Go back"
msg "3) Kone"
msg "4) Toggle data source (test/real)"
msg "5) Go back"
msg ""
}
@@ -143,11 +156,16 @@ database_menu() {
$pgclient -h localhost -p 12346 -U postgres -d "sis-importer-db$dbsuffix" -w
database_menu_opts
;;
3|t|T)
3|k|K)
msg "Kone-db"
$pgclient -h localhost -p 4321 -U postgres -d "kone-db$dbsuffix" -w
database_menu_opts
;;
4|t|T)
msg "Toggling environment..."
database_menu_opts
;;
4|q|Q)
5|q|Q)
msg ""
break
;;
@@ -185,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() {
@@ -194,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)"
@@ -265,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
@@ -285,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
@@ -103,13 +103,13 @@ export const programmeCodes = {
KH74_001: 'KSV',
KH80_001: 'MAATK',
KH80_002: 'METSAK',
KH80_003: 'ETK',
KH80_003: 'ERK',
KH80_004: 'YET',
MH80_001: 'AGRI',
MH80_002: 'AGERE',
MH80_003: 'FOR',
MH80_004: 'FOOD',
MH80_005: 'HNFB',
MH80_005: 'NUTR',
MH80_006: 'EKM',
MH80_007: 'MMB',
MH80_008: 'emPlant',
@@ -0,0 +1,14 @@
const { STRING } = require('sequelize')
// Migration step 1 of 2
module.exports = {
up: async ({ context: queryInterface }) => {
await queryInterface.addColumn('excluded_courses', 'course_group_id', {
type: STRING,
})
},
down: async ({ context: queryInterface }) => {
await queryInterface.removeColumn('excluded_courses', 'course_group_id')
},
}
@@ -0,0 +1,20 @@
const { ARRAY, STRING } = require('sequelize')
const YEARS = ['one', 'two', 'three', 'four', 'five', 'six']
// Migration step 1 of 2
module.exports = {
up: async ({ context: queryInterface }) => {
for (const year of YEARS) {
await queryInterface.addColumn('progress_criteria', `course_group_ids_year_${year}`, {
type: ARRAY(STRING),
})
}
},
down: async ({ context: queryInterface }) => {
for (const year of YEARS) {
await queryInterface.removeColumn('progress_criteria', `course_group_ids_year_${year}`)
}
},
}
@@ -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 }
)
}
})
},
}
@@ -17,7 +17,7 @@ export class ExcludedCourseModel extends Model implements ExcludedCourse {
declare programme_code: ExcludedCourse['programme_code']
@Column(DataType.STRING)
declare course_code: ExcludedCourse['course_code']
declare course_group_id: ExcludedCourse['course_group_id']
@Column(DataType.STRING)
declare curriculum_version: ExcludedCourse['curriculum_version']
@@ -17,22 +17,22 @@ export class ProgressCriteriaModel extends Model implements ProgressCriteria {
declare curriculumVersion: ProgressCriteria['curriculumVersion']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearOne: ProgressCriteria['coursesYearOne']
declare courseGroupIdsYearOne: ProgressCriteria['courseGroupIdsYearOne']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearTwo: ProgressCriteria['coursesYearTwo']
declare courseGroupIdsYearTwo: ProgressCriteria['courseGroupIdsYearTwo']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearThree: ProgressCriteria['coursesYearThree']
declare courseGroupIdsYearThree: ProgressCriteria['courseGroupIdsYearThree']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearFour: ProgressCriteria['coursesYearFour']
declare courseGroupIdsYearFour: ProgressCriteria['courseGroupIdsYearFour']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearFive: ProgressCriteria['coursesYearFive']
declare courseGroupIdsYearFive: ProgressCriteria['courseGroupIdsYearFive']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearSix: ProgressCriteria['coursesYearSix']
declare courseGroupIdsYearSix: ProgressCriteria['courseGroupIdsYearSix']
@Column(DataType.INTEGER)
declare creditsYearOne: ProgressCriteria['creditsYearOne']
@@ -7,7 +7,7 @@ import { hasFullAccessToStudentData } from '../util'
const router = Router()
router.post<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (req, res) => {
const { programmeCode, courseCodes, curriculumVersion } = req.body
const { programmeCode, courseGroupIds, curriculumVersion } = req.body
const { roles, programmeRights } = req.user
const hasFullAccess = hasFullAccessToStudentData(roles)
@@ -15,7 +15,7 @@ router.post<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (r
if (!hasFullAccess && !hasAccessToProgramme) return res.status(403).end()
const result = await addExcludedCourses(courseCodes, curriculumVersion, programmeCode)
const result = await addExcludedCourses(courseGroupIds, curriculumVersion, programmeCode)
if (!result) {
res.status(400).end()
return
@@ -24,7 +24,7 @@ router.post<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (r
})
router.delete<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (req, res) => {
const { programmeCode, courseCodes, curriculumVersion } = req.body
const { programmeCode, courseGroupIds, curriculumVersion } = req.body
const { roles, programmeRights } = req.user
const hasFullAccess = hasFullAccessToStudentData(roles)
@@ -32,7 +32,7 @@ router.delete<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async
if (!hasFullAccess && !hasAccessToProgramme) return res.status(403).end()
const result = await removeExcludedCourses(courseCodes, curriculumVersion, programmeCode)
const result = await removeExcludedCourses(courseGroupIds, curriculumVersion, programmeCode)
if (!result) {
res.status(400).end()
return
@@ -26,17 +26,17 @@ router.get('/', async (req: StudyProgrammeCriteriaRequest, res: Response) => {
interface CriteriaCoursesRequest extends Request {
body: {
code: string
courses: string[]
courseGroupIds: string[]
year: number
}
}
router.post('/courses', async (req: CriteriaCoursesRequest, res: Response) => {
const { code, courses, year } = req.body
if (!code || !courses || !year) {
const { code, courseGroupIds, year } = req.body
if (!code || !courseGroupIds || !year) {
return res.status(400).end()
}
const studyProgrammeCriteria = await saveYearlyCourseCriteria(code, courses, year)
const studyProgrammeCriteria = await saveYearlyCourseCriteria(code, courseGroupIds, year)
if (!studyProgrammeCriteria) {
return res.status(404).end()
}
@@ -2,18 +2,35 @@ import { Op } from 'sequelize'
import { ExcludedCourseModel } from '../models/kone'
export const addExcludedCourses = async (courseCodes: string[], curriculumVersion: string, programmeCode: string) => {
return ExcludedCourseModel.bulkCreate(
courseCodes.map(courseCode => ({
export const addExcludedCourses = async (
courseGroupIds: string[],
curriculumVersion: string,
programmeCode: string
) => {
const existing = await ExcludedCourseModel.findAll({
attributes: ['course_group_id'],
where: {
programme_code: programmeCode,
curriculum_version: curriculumVersion,
course_code: courseCode,
}))
course_group_id: { [Op.in]: courseGroupIds },
},
raw: true,
})
const alreadyExcluded = new Set(existing.map(({ course_group_id }) => course_group_id))
return ExcludedCourseModel.bulkCreate(
courseGroupIds
.filter(courseGroupId => !alreadyExcluded.has(courseGroupId))
.map(courseGroupId => ({
programme_code: programmeCode,
curriculum_version: curriculumVersion,
course_group_id: courseGroupId,
}))
)
}
export const removeExcludedCourses = async (
courseCodes: string[],
courseGroupIds: string[],
curriculumVersion: string,
programmeCode: string
) => {
@@ -21,7 +38,7 @@ export const removeExcludedCourses = async (
where: {
programme_code: programmeCode,
curriculum_version: curriculumVersion,
course_code: { [Op.in]: courseCodes },
course_group_id: { [Op.in]: courseGroupIds },
},
})
}
@@ -59,7 +59,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
progId:
programme.code in programmeCodes ? programmeCodes[programme.code as keyof typeof programmeCodes] : programme.code,
}))
const programmesGroupedByCode = groupBy(orderBy(programmesWithProgIds, ['valid_from'], ['desc']), prog => prog.code)
const programmesGroupedByCode = groupBy(orderBy(programmesWithProgIds, ['validFrom'], ['desc']), prog => prog.code)
const curriculumPeriods = await getCurriculumPeriods()
const relevantProgrammes: ProgrammeModuleWithRelevantAttributes[] = []
@@ -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({
@@ -47,7 +47,7 @@ export const getCurriculumOptions = async (code: string) => {
}
}
type ModuleWithChildren = Pick<ProgrammeModule, 'id' | 'type' | 'code' | 'name'> & {
type ModuleWithChildren = Pick<ProgrammeModule, 'id' | 'group_id' | 'type' | 'code' | 'name'> & {
// TODO: This is supposed to be in the ProgrammeModule
degree_programme_type: DegreeProgrammeType | null
@@ -90,6 +90,7 @@ const recursivelyGetModuleAndChildren = async (code: string, curriculum_period_i
GROUP BY pm.id, c.name, c.code, c.order, c.id
) SELECT
id,
group_id,
type,
module_order,
parent_code,
@@ -141,7 +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_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 }
@@ -180,6 +181,7 @@ const getCoursesAndModulesForProgramme = async (code: string, periodIds: string)
return {
courses: labelProgrammes(modifiedCourses, excludedCourses).map(mod => ({
group_id: mod.group_id,
code: mod.code,
name: mod.name,
degree_programme_type: mod.degree_programme_type,
@@ -1,3 +1,5 @@
import { Op } from 'sequelize'
import { ProgressCriteria } from '@oodikone/shared/types'
import { CourseModel } from '../../models'
import { ProgressCriteriaModel } from '../../models/kone'
@@ -11,31 +13,42 @@ const getCriteriaByStudyProgramme = async (code: string): Promise<CriteriaWithou
where: { code },
})
/** Construct course_code => substitutionGroups object */
const getSubstitutions = async (codes: string[]) => {
const courses: Array<Pick<CourseModel, 'code' | 'substitutionGroups'>> = await CourseModel.findAll({
attributes: ['code', 'substitutionGroups'],
where: { code: codes },
/** Construct course_group_id => substitutionGroups object */
const getSubstitutions = async (courseGroupIds: string[]) => {
const courses: Array<Pick<CourseModel, 'groupId' | 'substitutionGroups' | 'isPrimary'>> = await CourseModel.findAll({
attributes: ['groupId', 'substitutionGroups', 'isPrimary'],
where: { groupId: { [Op.in]: courseGroupIds } },
raw: true,
})
// A group can have several rows (one per course code), prefer the primary one
const substitutionsByGroupId = new Map<string, string[][]>()
for (const { groupId, substitutionGroups, isPrimary } of courses) {
if (isPrimary || !substitutionsByGroupId.has(groupId)) {
substitutionsByGroupId.set(groupId, substitutionGroups)
}
}
// Sort substitutionGroups by length, because (usually) the shortest substitution is the "correct" one
return Object.fromEntries(
courses.map(({ code, substitutionGroups }) => [code, substitutionGroups.sort((a, b) => b.length - a.length)])
[...substitutionsByGroupId].map(([groupId, substitutionGroups]) => [
groupId,
[...substitutionGroups].sort((a, b) => b.length - a.length),
])
)
}
const formatCriteria = async (criteria: CriteriaWithoutCurriculumVersion | null) => {
const yearOne = criteria?.coursesYearOne ?? []
const yearTwo = criteria?.coursesYearTwo ?? []
const yearThree = criteria?.coursesYearThree ?? []
const yearFour = criteria?.coursesYearFour ?? []
const yearFive = criteria?.coursesYearFive ?? []
const yearSix = criteria?.coursesYearSix ?? []
const courseCodes = [...yearOne, ...yearTwo, ...yearThree, ...yearFour, ...yearFive, ...yearSix]
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 = {
allCourseGroups: await getSubstitutions(courseCodes),
allCourseGroups: await getSubstitutions(courseGroupIds),
courses: { yearOne, yearTwo, yearThree, yearFour, yearFive, yearSix },
credits: {
yearOne: criteria?.creditsYearOne ?? 0,
@@ -56,12 +69,12 @@ const createCriteria = async (
) => {
const newProgrammeCriteria = {
code: studyProgramme,
coursesYearOne: courses.year1,
coursesYearTwo: courses.year2,
coursesYearThree: courses.year3,
coursesYearFour: courses.year4,
coursesYearFive: courses.year5,
coursesYearSix: courses.year6,
courseGroupIdsYearOne: courses.year1,
courseGroupIdsYearTwo: courses.year2,
courseGroupIdsYearThree: courses.year3,
courseGroupIdsYearFour: courses.year4,
courseGroupIdsYearFive: courses.year5,
courseGroupIdsYearSix: courses.year6,
creditsYearOne: credits.year1,
creditsYearTwo: credits.year2,
creditsYearThree: credits.year3,
@@ -109,35 +122,35 @@ export const saveYearlyCreditCriteria = async (studyProgramme: string, credits:
}
}
export const saveYearlyCourseCriteria = async (studyProgramme: string, courses: string[], year: number) => {
export const saveYearlyCourseCriteria = async (studyProgramme: string, courseGroupIds: string[], year: number) => {
const studyProgrammeToUpdate = await getCriteriaByStudyProgramme(studyProgramme)
if (!studyProgrammeToUpdate) {
const courseObj: Record<string, string[]> = { year1: [], year2: [], year3: [], year4: [], year5: [], year6: [] }
const creditObj = { year1: 0, year2: 0, year3: 0, year4: 0, year5: 0, year6: 0 }
if (year === 1) {
courseObj.year1 = courses
courseObj.year1 = courseGroupIds
} else if (year === 2) {
courseObj.year2 = courses
courseObj.year2 = courseGroupIds
} else if (year === 3) {
courseObj.year3 = courses
courseObj.year3 = courseGroupIds
} else if (year === 4) {
courseObj.year4 = courses
courseObj.year4 = courseGroupIds
} else if (year === 5) {
courseObj.year5 = courses
courseObj.year5 = courseGroupIds
} else {
courseObj.year6 = courses
courseObj.year6 = courseGroupIds
}
return await createCriteria(studyProgramme, courseObj, creditObj)
}
try {
const years = {
1: 'coursesYearOne',
2: 'coursesYearTwo',
3: 'coursesYearThree',
4: 'coursesYearFour',
5: 'coursesYearFive',
6: 'coursesYearSix',
1: 'courseGroupIdsYearOne',
2: 'courseGroupIdsYearTwo',
3: 'courseGroupIdsYearThree',
4: 'courseGroupIdsYearFour',
5: 'courseGroupIdsYearFive',
6: 'courseGroupIdsYearSix',
} as const
if (!(year in years)) {
throw new Error(`Invalid year: ${year}`)
@@ -145,7 +158,7 @@ export const saveYearlyCourseCriteria = async (studyProgramme: string, courses:
const yearToUpdate = years[year as keyof typeof years]
const updatedCriteria = await studyProgrammeToUpdate.update({ [yearToUpdate]: courses })
const updatedCriteria = await studyProgrammeToUpdate.update({ [yearToUpdate]: courseGroupIds })
return await formatCriteria(updatedCriteria)
} catch (error) {
logger.error(`Updating yearly credit criteria failed: ${error}`)
@@ -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
@@ -312,7 +313,9 @@ export const getGoal = async (programme?: string) => {
return ((minimumCredits / 60) * 12) / 6 // 60 Credits per year divided to semesters
}
export const isRelevantProgramme = (code: string) => /^(KH|MH)\d{2}_\d{3}$/.test(code) || /^T\d{6}$/.test(code)
/** Returns true for all programme codes that are determined to be newer than 2017 */
export const isRelevantProgramme = (code: string) =>
/^(KH|MH)\d{2}_\d{3}$/.test(code) || /^T\d{6}$/.test(code) || /^DP\d{3}$/.test(code)
export const getStudyRightElementsWithPhase = (studyRight: Pick<SISStudyRight, 'studyRightElements'>, phase: Phase) => {
return orderBy(
@@ -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 .
@@ -190,16 +190,35 @@ However this is optional and you can create a new tag without selecting year.
},
degreeCoursesTab: {
creditCriteria: `
Here you can change visibility of degree courses as and set course and credits criteria, for each year their
own. Credits criteria is computed as follows: for the first academic year the credits are taken into account
if they are completed during the first 12 months. For the second year, we take into account the completions
during the first 24 months, for the third year the first 36 months.
creditCriteria: {
en: `The targets set here will affect the Class Statistics → Students tab → Progress tab view.
The progress of the students by these criteria will be shown in class statistics 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ä 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.
`,
},
requiredCourses: {
en: `Click a module to see its courses in the selected curriculum.
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.
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.
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.
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
notCompleted: `
Sisältää opiskelijat, jotka ovat ilmoittautuneet kurssille, mutta eivät ole suorittaneet sitä ja opiskelijat, jotka ovat saaneet hylätyn arvosanan.
@@ -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)
@@ -117,9 +117,7 @@ export const courseFilter = createFilter<Options, Args, Precompute>({
const toGroupIds = (courseIds: string[]) => courseIds.map(id => options.idToGroupIdMap[id]).filter(Boolean)
const passedCourseGroupIds = toGroupIds(
courses
.filter(({ credittypecode }) => credittypecode !== CreditTypeCode.FAILED)
.map(({ course_id }) => course_id)
courses.filter(({ credittypecode }) => credittypecode !== CreditTypeCode.FAILED).map(({ course_id }) => course_id)
)
const courseGroupIds = toGroupIds(courses.map(({ course_id }) => course_id))
@@ -130,14 +128,13 @@ export const courseFilter = createFilter<Options, Args, Precompute>({
let foundAttainment = false
let foundEnrollment = false
;[
[mainGroupId],
...(options.includeSubstitutions ? (options.substitutedBy?.[mainGroupId] ?? []) : []),
].forEach(group => {
foundPassed = foundPassed ? true : group.every(groupId => passedCourseGroupIds.includes(groupId))
foundAttainment = foundAttainment ? true : group.every(groupId => courseGroupIds.includes(groupId))
foundEnrollment = foundEnrollment ? true : group.every(groupId => enrollmentGroupIds.includes(groupId))
})
;[[mainGroupId], ...(options.includeSubstitutions ? (options.substitutedBy?.[mainGroupId] ?? []) : [])].forEach(
group => {
foundPassed = foundPassed ? true : group.every(groupId => passedCourseGroupIds.includes(groupId))
foundAttainment = foundAttainment ? true : group.every(groupId => courseGroupIds.includes(groupId))
foundEnrollment = foundEnrollment ? true : group.every(groupId => enrollmentGroupIds.includes(groupId))
}
)
switch (filterType) {
case FilterType.ALL:
@@ -160,7 +157,8 @@ export const courseFilter = createFilter<Options, Args, Precompute>({
selectors: {
// NOTE: Remember FilterType.ALL === 0 when checking if courseFilters[course] exists
isCourseSelected: ({ courseFilters }, courseGroupId) => Object.values(FilterType).includes(courseFilters[courseGroupId]),
isCourseSelected: ({ courseFilters }, courseGroupId) =>
Object.values(FilterType).includes(courseFilters[courseGroupId]),
selectedCourseName: ({ courses }, courseGroupIds) => courses[courseGroupIds[0]]?.name,
},
@@ -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,7 +17,7 @@ export const navigationItems: Record<string, NavigationItem> = {
{ key: 'overview', label: 'Overview', path: '/study-programme' },
],
},
courseStatistics: { key: 'courseStatistics', label: 'Courses', path: '/coursestatistics' },
courseStatistics: { key: 'courseStatistics', label: 'Courses / Modules', path: '/coursestatistics' },
students: { key: 'students', label: 'Students', path: '/students' },
teachers: { key: 'teachers', label: 'Teachers', path: '/teachers' },
studyGuidanceGroups: {
@@ -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)} />}
/>
```
@@ -0,0 +1,43 @@
import Box from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import Tooltip from '@mui/material/Tooltip'
import { Table } from '@tanstack/react-table'
import { handleClipboardCopy } from '@/components/OodiTable/utils'
import { useStatusNotification } from '@/components/StatusNotification/Context'
import { ContentCopyIcon } from '@/theme'
/** To be paired with Class stats student tables */
export const CopyStudentNumbersHeader = <TData,>({
table,
getStudentNumber,
label = 'Student number',
}: {
table: Table<TData>
getStudentNumber: (row: TData) => string | null | undefined
label?: string
}) => {
const { setStatusNotification, closeNotification } = useStatusNotification()
const allStudentNumbers = table
.getFilteredRowModel()
.rows.map(row => getStudentNumber(row.original))
.filter((studentNumber): studentNumber is string => !!studentNumber)
const copyText = `Copied ${allStudentNumbers.length} student numbers`
return (
<Stack direction="row" spacing={1} sx={{ verticalAlign: 'middle' }}>
<Box sx={{ alignSelf: 'center' }}>{label}</Box>
<Tooltip title="Copy all student numbers to clipboard">
<IconButton
onClick={event =>
void handleClipboardCopy(event, allStudentNumbers, copyText, setStatusNotification, closeNotification)
}
>
<ContentCopyIcon color="action" />
</IconButton>
</Tooltip>
</Stack>
)
}
@@ -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>
)
}
@@ -3,35 +3,41 @@ import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import { useState, Fragment } from 'react'
import { useState } from 'react'
import { DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
import { DegreeSettingsTab, DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
import { VisibilityIcon } from '@/theme'
export const FilterDegreeCoursesModal = ({ degreeProgramme }: { degreeProgramme: string }) => {
const [open, setOpen] = useState(false)
const defaultText = 'Manage course visibility'
const defaultIcon = <VisibilityIcon />
const setModalOpenState = (state: boolean) => setOpen(state)
export const FilterDegreeCoursesModal = ({
degreeProgramme,
initialTab,
text,
icon,
}: {
degreeProgramme: string
initialTab: DegreeSettingsTab
text?: string
icon?: React.ReactNode
}) => {
const [modalOpen, setModalOpen] = useState(false)
return (
<Fragment>
<Button onClick={() => setModalOpenState(true)} startIcon={<VisibilityIcon />} variant="outlined">
Manage Courses Shown
<>
<Button onClick={() => setModalOpen(true)} startIcon={icon ?? defaultIcon} variant="outlined">
{text ?? defaultText}
</Button>
<Dialog className="MyDialogThings" maxWidth="md" onClose={() => setModalOpenState(false)} open={open}>
<DialogTitle>Hide degree courses</DialogTitle>
<DialogContent className="MyDialogContent">
<DegreeCoursesTab combinedProgramme="" degreeProgramme={degreeProgramme} />
<Button
color="primary"
onClick={() => setModalOpenState(false)}
style={{ marginTop: '10px' }}
variant="contained"
>
<Dialog maxWidth="md" onClose={() => setModalOpen(false)} open={modalOpen}>
<DialogTitle>Degree programme settings</DialogTitle>
<DialogContent>
<DegreeCoursesTab degreeProgramme={degreeProgramme} initialTab={initialTab} />
<Button color="primary" onClick={() => setModalOpen(false)} style={{ marginTop: '10px' }} variant="contained">
Close
</Button>
</DialogContent>
</Dialog>
</Fragment>
</>
)
}
@@ -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
@@ -38,14 +35,19 @@ export const PopulationCourses = ({
return (
<>
<Stack direction="row" spacing={2} sx={{ my: 1, justifyContent: 'space-between' }}>
{query.programme && !onlyIamRights ? <FilterDegreeCoursesModal degreeProgramme={query.programme} /> : null}
{query.programme && !onlyIamRights ? (
<FilterDegreeCoursesModal
degreeProgramme={query.programme}
initialTab="visibility"
text="Manage course / module visibility"
/>
) : null}
<InfoBox content={tooltipText} />
</Stack>
<SegmentDimmer isLoading={isLoading} />
{courseTableMode === 'curriculum' ? (
<PopulationCourseStats
courseTableMode={courseTableMode}
curriculum={curriculum}
filteredCourses={filteredCourses}
onlyIamRights={onlyIamRights}
pending={isLoading}
@@ -1,10 +1,16 @@
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
export const IncludeSubstitutionsToggle = ({ includeSubstitutions, toggleIncludeSubstitutions }) => (
export const IncludeSubstitutionsToggle = ({
includeSubstitutions,
toggleIncludeSubstitutions,
}: {
includeSubstitutions: boolean
toggleIncludeSubstitutions: () => void
}) => (
<FormControlLabel
control={<Switch checked={includeSubstitutions} onChange={toggleIncludeSubstitutions} />}
label="Include substitutions"
sx={{ margin: 0 }}
sx={{ m: 0 }}
/>
)
@@ -1,19 +1,13 @@
import Box from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import { useTheme } from '@mui/material/styles'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { ColumnDef, createColumnHelper } from '@tanstack/react-table'
import { useMemo } from 'react'
import { StudentInfoItem } from '@/components/common/StudentInfoItem'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { handleClipboardCopy } from '@/components/OodiTable/utils'
import { CopyStudentNumbersHeader } from '@/components/OodiTable/common/CopyStudentNumbersHeader'
import { Courses, CourseTabModule, CourseTabStudent } from '@/components/PopulationComponents/Students/Table/CoursesTab'
import { useStatusNotification } from '@/components/StatusNotification/Context'
import { DateFormat } from '@/constants/date'
import { CheckIcon, ContentCopyIcon, CropSquareIcon, RemoveIcon, SwapHorizIcon } from '@/theme'
import { CheckIcon, CropSquareIcon, RemoveIcon, SwapHorizIcon } from '@/theme'
import { formatDate, isWithinSixMonths } from '@/util/timeAndDate'
import { CreditTypeCode } from '@oodikone/shared/types'
@@ -29,38 +23,11 @@ const getSortingValue = (course: Courses['number'] | undefined) => {
export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>): ColumnDef<CourseTabStudent, any>[] => {
const { getTextIn } = useLanguage()
const theme = useTheme()
const { setStatusNotification, closeNotification } = useStatusNotification()
return useMemo(() => {
return [
// Static columns
columnHelper.accessor('studentNumber', {
header: ({ table }) => {
const allStudentNumbers = table
.getFilteredRowModel()
.rows.map(row => row.original.studentNumber)
.filter(Boolean)
const copyText = `Copied ${allStudentNumbers.length} student numbers`
return (
<Stack direction="row" spacing={1} sx={{ verticalAlign: 'middle' }}>
<Box sx={{ alignSelf: 'center' }}>Student number</Box>
<Tooltip title="Copy all student numbers to system clipboard">
<IconButton
onClick={event =>
void handleClipboardCopy(
event,
allStudentNumbers,
copyText,
setStatusNotification,
closeNotification
)
}
>
<ContentCopyIcon color="action" />
</IconButton>
</Tooltip>
</Stack>
)
},
header: ({ table }) => <CopyStudentNumbersHeader getStudentNumber={row => row.studentNumber} table={table} />,
cell: cell => {
const studentNumber = cell.getValue()
if (studentNumber === 'Hidden') return studentNumber
@@ -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,17 +5,23 @@ 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>
programme: string
}
export type SubstitutedByEntry = {
@@ -54,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>
@@ -63,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
@@ -94,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
}
@@ -123,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 => ({
@@ -162,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] = {
@@ -261,7 +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 }: CoursesTabContainerProps) => {
export const CoursesTabContainer = ({ students, courses, idToGroupIdMap, programme }: CoursesTabContainerProps) => {
const { curriculum } = useCurriculum()
const { visible: namesVisible } = useStudentNameVisibility()
const [includeSubstitutions, toggleIncludeSubstitutions] = useToggle(true)
@@ -271,25 +272,21 @@ export const CoursesTabContainer = ({ curriculum, students, courses, idToGroupId
)
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
@@ -313,11 +310,12 @@ export const CoursesTabContainer = ({ curriculum, students, courses, idToGroupId
if (!acc.has(parent)) {
acc.set(parent, { name: course.parent_name, courses: [] })
}
acc.get(parent)!.courses.push({
code: course.code,
name: course.name,
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)) {
studyModule.courses.push({ code: course.code, name: course.name, groupId })
}
}
return acc
}, new Map<string, CourseTabModule>())
@@ -329,7 +327,7 @@ export const CoursesTabContainer = ({ curriculum, students, courses, idToGroupId
// 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
@@ -342,13 +340,13 @@ export const CoursesTabContainer = ({ curriculum, students, courses, idToGroupId
studentMapper(
student,
includeSubstitutions,
curriculumGroupIds,
curriculumCourseGroupIds,
substitutionsForGroupId,
idToGroupIdMap,
groupIdToCode
)
),
[students, includeSubstitutions, curriculumGroupIds, substitutionsForGroupId, idToGroupIdMap, groupIdToCode]
[students, includeSubstitutions, curriculumCourseGroupIds, substitutionsForGroupId, idToGroupIdMap, groupIdToCode]
)
const columns = useGetColumnDefinitions(coursesByParentModule)
@@ -367,16 +365,24 @@ export const CoursesTabContainer = ({ curriculum, students, courses, idToGroupId
},
}
// 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
@@ -386,12 +392,14 @@ export const CoursesTabContainer = ({ curriculum, students, courses, idToGroupId
options={tableOptions}
toolbarContent={
<>
<OodiTableExcelExport data={formattedStudents} exportColumnKeys={keysForExport} />
<OodiTableExcelExport data={exportData} exportColumnKeys={keysForExport} />
<FilterDegreeCoursesModal degreeProgramme={programme} initialTab="visibility" />
<StudentNameVisibilityToggle />
<IncludeSubstitutionsToggle
includeSubstitutions={includeSubstitutions}
toggleIncludeSubstitutions={toggleIncludeSubstitutions}
/>
<HiddenCourseAlert ooditable />
</>
}
/>
@@ -1,6 +1,4 @@
import Box from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import Tooltip from '@mui/material/Tooltip'
import type { ColumnDef } from '@tanstack/react-table'
@@ -13,12 +11,11 @@ import { TableHeaderWithTooltip } from '@/components/common/TableHeaderWithToolt
import { creditDateFilter } from '@/components/FilterView/filters'
import { useFilters } from '@/components/FilterView/useFilters'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { handleClipboardCopy } from '@/components/OodiTable/utils'
import { CopyStudentNumbersHeader } from '@/components/OodiTable/common/CopyStudentNumbersHeader'
import { FormattedStudentData } from '@/components/PopulationComponents/Students/Table/GeneralTab'
import { joinProgrammes } from '@/components/PopulationComponents/Students/Table/GeneralTab/util'
import { useStatusNotification } from '@/components/StatusNotification/Context'
import { DateFormat } from '@/constants/date'
import { CheckIcon, ContentCopyIcon } from '@/theme'
import { CheckIcon } from '@/theme'
import { formatDate } from '@/util/timeAndDate'
const columnHelper = createColumnHelper<FormattedStudentData>()
@@ -40,36 +37,12 @@ export const useGetColumnDefinitions = ({
}: GeneralTabColDefProps): ColumnDef<FormattedStudentData, any>[] => {
const { getTextIn } = useLanguage()
const { useFilterSelector } = useFilters()
const { setStatusNotification, closeNotification } = useStatusNotification()
const creditDateFilterOptions = useFilterSelector(creditDateFilter.selectors.selectOptions(undefined))
return useMemo(
() => [
columnHelper.accessor('studentNumber', {
header: ({ table }) => {
const allStudentNumbers = table.getFilteredRowModel().rows.map(row => row.original.studentNumber)
const copyText = `Copied ${allStudentNumbers.length} student numbers`
return (
<Stack direction="row" spacing={1} sx={{ verticalAlign: 'middle' }}>
<Box sx={{ alignSelf: 'center' }}>Student number</Box>
<Tooltip title="Copy all student numbers to clipboard">
<IconButton
onClick={event =>
void handleClipboardCopy(
event,
allStudentNumbers,
copyText,
setStatusNotification,
closeNotification
)
}
>
<ContentCopyIcon color="action" />
</IconButton>
</Tooltip>
</Stack>
)
},
header: ({ table }) => <CopyStudentNumbersHeader getStudentNumber={row => row.studentNumber} table={table} />,
cell: cell => {
const studentNumber = cell.getValue()
if (studentNumber === 'Hidden') return studentNumber
@@ -1,6 +1,4 @@
import Box from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import { useTheme } from '@mui/material/styles'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
@@ -11,11 +9,12 @@ import { StudentInfoItem } from '@/components/common/StudentInfoItem'
import { StudentNameVisibilityToggle, useStudentNameVisibility } from '@/components/common/StudentNameVisibilityToggle'
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 { handleClipboardCopy } from '@/components/OodiTable/utils'
import type { FormattedModules, ModuleTabStudent } from '@/components/PopulationComponents/Students/Table/ModulesTab'
import { useStatusNotification } from '@/components/StatusNotification/Context'
import { CheckIcon, ContentCopyIcon, CropSquareIcon } from '@/theme'
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
@@ -25,42 +24,20 @@ const ooditableColumnHelper = createColumnHelper<ModuleTabStudent>()
export const ModulesTab = ({
formattedModules,
formattedStudents,
programme,
}: {
formattedModules: FormattedModules
formattedStudents: ModuleTabStudent[]
programme: string
}) => {
const { getTextIn } = useLanguage()
const { visible: namesVisible } = useStudentNameVisibility()
const theme = useTheme()
const { setStatusNotification, closeNotification } = useStatusNotification()
const ooditableStaticColumns = useMemo(
() => [
ooditableColumnHelper.accessor('studentNumber', {
header: ({ table }) => {
const allStudentNumbers = table.getFilteredRowModel().rows.map(row => row.original.studentNumber)
const copyText = `Copied ${allStudentNumbers.length} student numbers`
return (
<Stack direction="row" spacing={1} sx={{ verticalAlign: 'middle' }}>
<Box sx={{ alignSelf: 'center' }}>Student number</Box>
<Tooltip title="Copy all student numbers to clipboard">
<IconButton
onClick={event =>
void handleClipboardCopy(
event,
allStudentNumbers,
copyText,
setStatusNotification,
closeNotification
)
}
>
<ContentCopyIcon color="action" />
</IconButton>
</Tooltip>
</Stack>
)
},
header: ({ table }) => <CopyStudentNumbersHeader getStudentNumber={row => row.studentNumber} table={table} />,
cell: cell => <StudentInfoItem sisPersonId={cell.row.original.sisPersonID} studentNumber={cell.getValue()} />,
filterFn: (row, columnId, filterValue) => {
const value: string = row.getValue(columnId)
@@ -216,7 +193,13 @@ export const ModulesTab = ({
toolbarContent={
<>
<OodiTableExcelExport data={exportData} exportColumnKeys={accessorKeys} />
<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,16 +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]
@@ -119,5 +120,5 @@ export const ModulesTabContainer = ({
[students, degreeProgrammeCodes, idToGroupIdMap, groupIdToCode]
)
return <ModulesTab formattedModules={formattedModules} formattedStudents={formattedStudents} />
return <ModulesTab formattedModules={formattedModules} formattedStudents={formattedStudents} programme={programme} />
}
@@ -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'
@@ -13,129 +10,64 @@ import { StudentInfoItem } from '@/components/common/StudentInfoItem'
import { StudentNameVisibilityToggle, useStudentNameVisibility } from '@/components/common/StudentNameVisibilityToggle'
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, 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 { CheckIcon, CloseIcon, EditIcon, RemoveIcon, SwapHorizIcon } from '@/theme'
import { FormattedStudent, ProgrammeCourse, ProgressCriteria, SemesterEnrollment } from '@oodikone/shared/types'
import { keyBy } from '@oodikone/shared/util'
import '@/components/PopulationComponents/Students/Table/ProgressTab/index.css'
dayjsExtend(isBetween)
dayjsExtend(isSameOrBefore)
dayjsExtend(isSameOrAfter)
const columnHelper = createColumnHelper<FormattedStudent>()
/** Return course + all of it's substitution groups */
const getCourseGroups = (
courseCode: string,
criteria: ProgressCriteria | undefined,
student: FormattedStudent
): StudentCourse[][] => {
const studentCourseCodes = student.courses.map(({ course_code }) => course_code)
// Return groups that have all courses in student.courses
const courseGroups: StudentCourse[][] = []
for (const group of [[courseCode]].concat(criteria?.allCourseGroups[courseCode] ?? [])) {
if (group.every(code => studentCourseCodes.includes(code))) {
// We know all of the group's courses exist, we just checked that
courseGroups.push(
...group.map(code =>
student.courses
.filter(course => course.course_code === code)
.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 course codes */
const hasEnrolled = (student: FormattedStudent, courseCode: string) =>
student.enrollments?.map(course => course.course_code).includes(courseCode)
const getEnrollment = (student: FormattedStudent, courseCode: string) =>
student.enrollments.filter(enrollment => enrollment.course_code === courseCode)
const getRowContent = (
student: FormattedStudent,
courseCode: string,
label: Label,
year: string,
start: dayjs.Dayjs,
end: dayjs.Dayjs,
criteria: ProgressCriteria | undefined
criteria: ProgressCriteria | undefined,
idToGroupId: IdToGroupId
) => {
if (courseCode.includes('Credits')) {
if (label.code === 'Credits') {
if (student.criteriaProgress[year]?.credits) {
return <CheckIcon color="success" />
}
return null
}
if (!label.groupId) return null
const courses = getCourseGroups(courseCode, criteria, student)
const courses = getCourseGroups(label.groupId, criteria, student, idToGroupId)
if (hasCreditTransfer(courses)) {
return <SwapHorizIcon color="success" />
@@ -153,74 +85,58 @@ const getRowContent = (
return <CloseIcon color="error" />
}
if (hasEnrolled(student, courseCode)) {
if (hasEnrolled(student, label.groupId, idToGroupId)) {
return <RemoveIcon color="disabled" />
}
return null
}
const getExcelText = (
courseCode: string,
criteria: ProgressCriteria | undefined,
student: FormattedStudent,
year: string
const getSemesterEnrollmentStatus = (
semesterEnrollments: SemesterEnrollment[] | null | undefined,
semesters: number[]
) => {
if (courseCode.includes('Credits')) {
return student.criteriaProgress[year]?.credits ? 'Passed' : ''
}
const courseGroups = getCourseGroups(courseCode, criteria, student)
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, courseCode)) {
const enrollment = getEnrollment(student, courseCode)
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 = {
code: 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
programme: string
@@ -228,153 +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, 'code')
const coursesSecondProgramme = keyBy(curriculum?.secondProgrammeCourses, 'code')
const getCourseName = (courseCode: string): Name | null => {
if (defaultCourses[courseCode]) {
return defaultCourses[courseCode].name
const labelCriteria = useMemo(() => {
const courses: Record<string, ProgrammeCourse | undefined> = {
...keyBy(curriculum?.secondProgrammeCourses, 'group_id'),
...keyBy(curriculum?.defaultProgrammeCourses, 'group_id'),
}
if (coursesSecondProgramme[courseCode]) {
return coursesSecondProgramme[courseCode].name
}
return null
}
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] ?? [])]
.sort((a: string, b: string) => a.localeCompare(b))
.map((courseCode: string) => ({
code: courseCode,
name: getCourseName(courseCode),
})),
{
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
const criteriaCourses = student.criteriaProgress[year]?.coursesSatisfied
const course = criteriaCourses?.[label.code]
if (course) return -1
if (student.courses.find(course => course.course_code === label.code)?.passed === false) return 0
if (!course && hasEnrolled(student, label.code)) 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.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.code, criteria, student, year)
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.code, year, start, end, criteria)
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}>
@@ -390,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)
}
@@ -410,138 +344,17 @@ 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 => course.course_code === label.code)
const enrollment = student.enrollments.find(enrollment => enrollment.course_code === label.code)
result = [
[
getTextIn(label.name),
course?.passed ? 'Passed' : enrollment?.state === EnrollmentState.ENROLLED ? 'Enrolled' : '',
],
]
break
}
}
return result
}) ?? []
)
})
.filter(([key, _]) => !!key)
),
})),
[students, allSemesters, programme, labelCriteria]
)
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: [
columnHelper.accessor('studentNumber', {
header: 'Student number',
header: ({ table }) => (
<CopyStudentNumbersHeader getStudentNumber={row => row.studentNumber} table={table} />
),
cell: ({ row }) => (
<StudentInfoItem sisPersonId={row.original.sis_person_id} studentNumber={row.original.studentNumber} />
),
@@ -560,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])
}, [
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 (
<>
@@ -601,27 +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} />
<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>
</>
)
@@ -1,42 +0,0 @@
import Alert from '@mui/material/Alert'
import Typography from '@mui/material/Typography'
import { CheckIcon, CloseIcon, RemoveIcon, SwapHorizIcon } from '@/theme'
export const TableInfo = () => (
<Alert severity="primary" sx={{ my: 2 }} variant="outlined">
<Typography variant="h6">Criteria:</Typography>
<Typography sx={{ display: 'flex' }}>
<CheckIcon color="success" />: Student has passed the course in the academic year.
</Typography>
<Typography sx={{ display: 'flex' }}>
<CheckIcon color="disabled" />: Student has passed the course outside of the corresponding academic year.
</Typography>
<Typography sx={{ display: 'flex' }}>
<SwapHorizIcon color="success" />: Student has credit transfer for the course.
</Typography>
<Typography sx={{ display: 'flex' }}>
<CloseIcon color="error" />: Student has failed the course.
</Typography>
<Typography sx={{ display: 'flex' }}>
<RemoveIcon color="disabled" />: Student has enrolled, but has not received any grade from the course.
</Typography>
<Typography variant="h6">Semester enrollments:</Typography>
<Typography sx={{ display: 'flex' }}>
<span className="enrollment-label label-present" style={{ margin: 'auto 0' }} />: Student has an active semester
enrollment.
</Typography>
<Typography sx={{ display: 'flex' }}>
<span className="enrollment-label label-absent" style={{ margin: 'auto 0' }} />: Student has enrolled as absent.
</Typography>
<Typography sx={{ display: 'flex' }}>
<span className="enrollment-label label-passive" style={{ margin: 'auto 0' }} />: Passive: Student did not enroll
at all.
</Typography>
<Typography sx={{ display: 'flex' }}>
<span className="enrollment-label label-none" style={{ margin: 'auto 0' }} />: Student has no enrollment, but also
no study right for the semester. <br />
</Typography>
</Alert>
)
@@ -0,0 +1,50 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography'
import { CheckIcon, CloseIcon, RemoveIcon, SwapHorizIcon } from '@/theme'
export const TableInfo = () => (
<Alert severity="info" sx={{ my: 2 }} variant="outlined">
<Box sx={{ display: 'grid', columnGap: 4, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' } }}>
<Box>
<Typography variant="h6">Criteria:</Typography>
<Typography sx={{ display: 'flex' }}>
<CheckIcon color="success" />: Student has passed the course in the academic year.
</Typography>
<Typography sx={{ display: 'flex' }}>
<CheckIcon color="disabled" />: Student has passed the course outside of the corresponding academic year.
</Typography>
<Typography sx={{ display: 'flex' }}>
<SwapHorizIcon color="success" />: Student has credit transfer for the course.
</Typography>
<Typography sx={{ display: 'flex' }}>
<CloseIcon color="error" />: Student has failed the course.
</Typography>
<Typography sx={{ display: 'flex' }}>
<RemoveIcon color="disabled" />: Student has enrolled, but has not received any grade from the course.
</Typography>
</Box>
<Box>
<Typography variant="h6">Semester enrollments:</Typography>
<Typography sx={{ display: 'flex' }}>
<span className="enrollment-label label-present" style={{ margin: 'auto 0' }} />: Student has an active
semester enrollment.
</Typography>
<Typography sx={{ display: 'flex' }}>
<span className="enrollment-label label-absent" style={{ margin: 'auto 0' }} />: Student has enrolled as
absent.
</Typography>
<Typography sx={{ display: 'flex' }}>
<span className="enrollment-label label-passive" style={{ margin: 'auto 0' }} />: Passive: Student did not
enroll at all.
</Typography>
<Typography sx={{ display: 'flex' }}>
<span className="enrollment-label label-none" style={{ margin: 'auto 0' }} />: Student has no enrollment, but
also no study right for the semester.
</Typography>
</Box>
</Box>
</Alert>
)
@@ -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,23 +97,23 @@ 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}
/>
),
Modules: (
<ModulesTab
courses={filteredCourses ?? []}
curriculum={curriculum}
idToGroupIdMap={idToGroupIdMap ?? {}}
students={filteredStudents}
programme={programme}
/>
),
Tags: <TagsTab combinedProgramme={combinedProgramme} programme={programme} students={filteredStudents} />,
Progress: (
<ProgressTab
curriculum={curriculum}
idToGroupIdMap={idToGroupIdMap ?? {}}
months={months ?? Infinity}
programme={programme}
students={filteredStudents}
@@ -135,7 +131,7 @@ export const PopulationStudents = ({
> = {
population: {
tabs:
Array.isArray(years) || (programme && !isBachelorOrLicentiateProgramme(programme))
years.length > 1 || (programme && !isBachelorOrLicentiateProgramme(programme))
? ['General', 'Courses', 'Modules', 'Tags']
: ['General', 'Courses', 'Modules', 'Tags', 'Progress'],
tooltip: populationStudentsToolTips.studentsClass,
@@ -4,13 +4,7 @@ import { FilterToggleIcon } from '@/components/common/FilterToggleIcon'
import { isCourseSelected, toggleCourseSelection } from '@/components/FilterView/filters/courses'
import { useFilters } from '@/components/FilterView/useFilters'
export const CourseFilterToggle = ({
courseGroupId,
courseName,
}: {
courseGroupId: string
courseName: string
}) => {
export const CourseFilterToggle = ({ courseGroupId, courseName }: { courseGroupId: string; courseName: string }) => {
const { useFilterSelector, useFilterDispatch: filterDispatch } = useFilters()
const isActive = useFilterSelector(isCourseSelected(courseGroupId))
@@ -24,10 +18,7 @@ export const CourseFilterToggle = ({
return (
<Tooltip arrow placement="top" title={title}>
<div>
<FilterToggleIcon
isActive={isActive}
onClick={() => filterDispatch(toggleCourseSelection(courseGroupId))}
/>
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(courseGroupId))} />
</div>
</Tooltip>
)
@@ -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,8 +12,9 @@ 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'
const emptyCredits = {
year1: null,
@@ -32,6 +33,7 @@ export const CreditCriteriaSection = ({
degreeProgramme: string
}) => {
const [creditLimits, setCreditLimits] = useState<Record<string, null | number>>(emptyCredits)
const { getTextIn } = useLanguage()
const [addProgressCriteriaCredits, { isError, isSuccess }] = useAddProgressCriteriaCreditsMutation()
const { setStatusNotification } = useStatusNotification()
@@ -66,51 +68,19 @@ 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)
return (
<Section
cypress="credit-criteria"
infoBoxContent={studyProgrammeToolTips.degreeCoursesTab.creditCriteria}
title="Credit criteria"
infoBoxContent={getTextIn(studyProgrammeToolTips.degreeCoursesTab.creditCriteria) ?? ''}
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.code))
setSelectedOptions(selected)
}, [criteria, course.code])
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 courses: string[]
if (selectedOptions.includes(key)) {
courses = criteria.courses[key]?.filter((courseCode: string) => courseCode !== course.code)
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courses, year })
} else {
courses = criteria.courses ? [...criteria.courses[key], course.code] : [course.code]
}
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courses, 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,12 +1,14 @@
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 Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Typography from '@mui/material/Typography'
@@ -14,135 +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 { 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({
courseCodes: [course.code],
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({
courseCodes: module.courses.filter(course => course.visible.visibility).map(course => course.code),
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({
courseCodes: [course.code],
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({
courseCodes: module.courses.map(course => course.code),
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)) {
@@ -154,99 +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 (
<TableContainer component={Paper} variant="outlined">
<Table data-cy="degree-course-table" size="small">
<>
<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>Actions</TableCell>
<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>
)}
</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>
<ToggleVisibilityButton
onClick={() =>
getVisibility(moduleCode) === CourseVisibility.HIDDEN
? deleteAll(moduleCode)
: excludeAll(moduleCode)
}
visible={getVisibility(moduleCode) !== CourseVisibility.HIDDEN}
/>
</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>
<TableCell>
<VisibilityChip
visibility={course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN}
/>
{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>
<VisibilitySwitch
ariaLabel={`Show all courses of ${moduleName}`}
cypress={`visibility-switch-${moduleCode}`}
onChange={() => toggleModuleVisibility(module)}
visibility={getVisibility(module)}
/>
</TableCell>
)}
{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>
{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.code) && (
<Chip color="primary" key={year} label={`year ${index + 1}`} size="small" />
)
)}
</Stack>
) : (
<Typography color="text.secondary" variant="body2">
No labels set
</Typography>
)
})}
</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>
)}
</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>
))}
{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>
</Table>
</TableContainer>
</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>
</>
)
}
@@ -0,0 +1,20 @@
import { StyledMessage } from '@/components/common/StyledMessage'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
const textContent = {
fi: `Nämä asetukset eivät ole henkilökohtaisia.
Esim. kurssin tai moduulin piilottaminen piilottaa sen myös muilta Oodikoneen käyttäjiltä.
Muutetut asetukset astuvat voimaan sivun uudelleenlatauksen jälkeen.`,
en: `These settings are non-personal.
Example: hiding a course or a module will also hide it from other Oodikone users.
Changed settings will require a page reload to take effect.`,
}
export const ManageCoursesShownInfo = () => {
const { getTextIn } = useLanguage()
return (
<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,83 +1,149 @@
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} />
<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>
)}
{defaultProgrammeModules.length > 1 && curriculum ? (
<DegreeCourseTable
combinedProgramme=""
criteria={criteria}
curriculumVersion={curriculum.periodIds}
modules={defaultProgrammeModules}
studyProgramme={degreeProgramme}
/>
) : null}
{secondProgrammeModules.length > 0 && curriculum ? (
<DegreeCourseTable
combinedProgramme={combinedProgramme}
criteria={criteria}
curriculumVersion={curriculum.periodIds}
modules={secondProgrammeModules}
studyProgramme={degreeProgramme}
/>
) : null}
</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>
)
@@ -5,17 +5,17 @@ import type { ExcludedCoursesResBody, ExcludedCoursesReqBody } from '@oodikone/s
const courseExclusionsApi = RTKApi.injectEndpoints({
endpoints: builder => ({
setCourseExclusion: builder.mutation<ExcludedCoursesResBody, ExcludedCoursesReqBody>({
query: ({ courseCodes, curriculumVersion, programmeCode }) => ({
query: ({ courseGroupIds, curriculumVersion, programmeCode }) => ({
url: `/course-exclusions/`,
method: 'POST',
body: { courseCodes, curriculumVersion, programmeCode },
body: { courseGroupIds, curriculumVersion, programmeCode },
}),
}),
removeCourseExclusion: builder.mutation<ExcludedCoursesResBody, ExcludedCoursesReqBody>({
query: ({ courseCodes, curriculumVersion, programmeCode }) => ({
query: ({ courseGroupIds, curriculumVersion, programmeCode }) => ({
url: `/course-exclusions/`,
method: 'DELETE',
body: { courseCodes, curriculumVersion, programmeCode },
body: { courseGroupIds, curriculumVersion, programmeCode },
}),
}),
}),
@@ -39,8 +39,7 @@ export const getProgressCriteria = (
studyRightStartDate: string,
hops: StudentStudyPlan | undefined,
credits: PopulationCourseStatsCredit[],
idToCode: Record<string, string>,
groupIdToCode: Record<string, string>
idToGroupId: Record<string, string>
) => {
const [thereAreCriteria, criteriaChecked] = getCriteriaBase(criteria)
if (!thereAreCriteria) return criteriaChecked
@@ -50,41 +49,41 @@ export const getProgressCriteria = (
/** Number of credits completed during each academic year */
const academicYears = { year1: 0, year2: 0, year3: 0, year4: 0, year5: 0, year6: 0 }
/** Credits produced by a student, resolved from their (possibly historical) course_id to the course's current code */
/** Credits produced by a student, resolved from their (possibly historical) course_id to the course's group id */
const courses = credits
.map(({ attainment_date, course_id, credits, credittypecode }) => ({
course_code: idToCode[course_id],
group_id: idToGroupId[course_id],
credits,
credittypecode,
date: attainment_date,
}))
.filter((course): course is typeof course & { course_code: string } => !!course.course_code)
.filter((course): course is typeof course & { group_id: string } => !!course.group_id)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
Object.entries(criteria.allCourseGroups).map(([mainCourseCode, substitutionGroups]) => {
Object.entries(criteria.allCourseGroups).map(([mainGroupId, substitutionGroups]) => {
const mainCourse = courses.find(
course => course.course_code === mainCourseCode && passedCreditTypeCodes.includes(course.credittypecode)
course => course.group_id === mainGroupId && passedCreditTypeCodes.includes(course.credittypecode)
)
yearMap.forEach(([yearToAdd, criteriaYear]) => {
if (criteria.courses[criteriaYear].includes(mainCourseCode)) {
const currentDate = criteriaChecked[yearToAdd].coursesSatisfied[mainCourseCode]
if (criteria.courses[criteriaYear].includes(mainGroupId)) {
const currentDate = criteriaChecked[yearToAdd].coursesSatisfied[mainGroupId]
// Credit found, course was passed normally
if (mainCourse) {
const courseDate = new Date(mainCourse.date)
// Add date to courses that have been passed
if (!currentDate || courseDate < new Date(currentDate)) {
criteriaChecked[yearToAdd].coursesSatisfied[mainCourseCode] = mainCourse.date.toLocaleString()
criteriaChecked[yearToAdd].coursesSatisfied[mainGroupId] = mainCourse.date.toLocaleString()
}
} else {
// Credit for mainCourseCode not found, checking substitution groups (each entry is a groupId, not a code)
const passedCourseCodes = courses
// Credit for mainGroupId not found, checking substitution groups
const passedGroupIds = courses
.filter(course => passedCreditTypeCodes.includes(course.credittypecode))
.map(({ course_code }) => course_code)
.map(({ group_id }) => group_id)
for (const group of substitutionGroups) {
// Add date to the course that has a completed substitution group
if (group.every(groupId => passedCourseCodes.includes(groupIdToCode[groupId]))) {
criteriaChecked[yearToAdd].coursesSatisfied[mainCourseCode] = 'substituted'
if (group.every(groupId => passedGroupIds.includes(groupId))) {
criteriaChecked[yearToAdd].coursesSatisfied[mainGroupId] = 'substituted'
}
}
}
@@ -98,17 +97,15 @@ export const getProgressCriteria = (
const courseDate = new Date(course.date)
if (!(studyRightStartDateFromISO < courseDate)) return
const mainCourseCodes = Object.keys(criteria.allCourseGroups).filter(mainCourseCode => {
if (mainCourseCode === course.course_code) return true
return criteria.allCourseGroups[mainCourseCode].some(group =>
group.some(groupId => groupIdToCode[groupId] === course.course_code)
)
const mainGroupIds = Object.keys(criteria.allCourseGroups).filter(mainGroupId => {
if (mainGroupId === course.group_id) return true
return criteria.allCourseGroups[mainGroupId].some(group => group.includes(course.group_id))
})
// included_courses holds course ids, with a rare fallback to a raw code for "custom" entries
const hopsCodes = hops.included_courses.map(idOrCode => idToCode[idOrCode] ?? idOrCode)
// included_courses holds course ids, resolved to group ids
const hopsGroupIds = hops.included_courses.map(id => idToGroupId[id]).filter(Boolean)
const isInStudyPlan =
hopsCodes.includes(course.course_code) || mainCourseCodes.some(code => hopsCodes.includes(code))
hopsGroupIds.includes(course.group_id) || mainGroupIds.some(groupId => hopsGroupIds.includes(groupId))
if (!isInStudyPlan) return
Object.keys(academicYears)
@@ -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>(
@@ -29,13 +27,6 @@ export const formatPopulationData = <T extends RequiredFields>(
) => {
const code = query?.programme ?? ''
const groupIdToCode = Object.fromEntries(coursestatistics.courses.map(({ code, groupId }) => [groupId, code]))
const idToCode = Object.fromEntries(
Object.entries(coursestatistics.idToGroupIdMap)
.map(([id, groupId]) => [id, groupIdToCode[groupId]])
.filter((entry): entry is [string, string] => !!entry[1])
)
// TODO: These are typed incorrectly with attainment_date and enrollment_date_time as Dates (they are strings)
const credits = coursestatistics.credits.sort(
(a, b) => new Date(b.attainment_date).getTime() - new Date(a.attainment_date).getTime()
@@ -45,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
@@ -70,8 +59,7 @@ export const formatPopulationData = <T extends RequiredFields>(
student.studyrightStart,
hops,
credits,
idToCode,
groupIdToCode
coursestatistics.idToGroupIdMap
),
courses: credits.map(credit => {
const passed = [CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(credit.credittypecode)
@@ -79,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.',
@@ -97,7 +86,6 @@ export const formatPopulationData = <T extends RequiredFields>(
students: formattedStudents,
coursestatistics: {
...coursestatistics,
dataByStudent: creditsAndEnrollmentsByStudent,
},
...otherParams,
}
@@ -7,13 +7,16 @@ const programmeProgressCriteria = RTKApi.injectEndpoints({
query: ({ programmeCode }) => `/programme-criteria?programmeCode=${programmeCode}`,
providesTags: ['ProgressCriteria'],
}),
addProgressCriteriaCourse: builder.mutation<void, { courses: string[]; programmeCode: string; year: number }>({
query: ({ courses, programmeCode, year }) => ({
addProgressCriteriaCourse: builder.mutation<
void,
{ courseGroupIds: string[]; programmeCode: string; year: number }
>({
query: ({ courseGroupIds, programmeCode, year }) => ({
url: '/programme-criteria/courses',
method: 'POST',
body: {
code: programmeCode,
courses,
courseGroupIds,
year,
},
}),
+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 []
@@ -296,9 +295,10 @@ export const filterCourses = (
}
}
return Array.from(coursestats.values()).map((course): FilteredCourse =>
Object.assign(course, {
stats: getFinalStats(course, filteredStudents.length),
})
return Array.from(coursestats.values()).map(
(course): FilteredCourse =>
Object.assign(course, {
stats: getFinalStats(course, filteredStudents.length),
})
)
}
@@ -0,0 +1,31 @@
import { PopulationStudents } from '@/components/PopulationComponents/Students'
import { ReduxWrapper } from '../../../ReduxWrapper'
// Partial<FormattedStudent>[]
const students = [{ studentNumber: '010000001' }] as any
const columnFunction = (): [string[], string[]] => [['studentNumber'], []]
const formattingFunction = () => [{ studentNumber: '010000001' }]
/**
* Mounts `PopulationStudents` in the `population` variant.
*
* The selected years are read from the query params, so navigate the page to eg. `/?years=2020` before mounting.
*/
export const PopulationStudentsWrapper = ({ programme }: { programme: string }) => (
<ReduxWrapper
component={
<PopulationStudents
curriculum={null}
filteredCourses={[]}
filteredStudents={students}
generalTabColumnFunction={columnFunction}
generalTabFormattingFunction={formattingFunction}
idToGroupIdMap={{}}
programme={programme}
variant="population"
/>
}
/>
)
@@ -0,0 +1,34 @@
import { test, expect } from '@playwright/experimental-ct-react'
import { PopulationStudentsWrapper } from './PopulationStudentsWrapper'
test.describe('PopulationStudents', () => {
test.beforeEach(async ({ router }) => {
await router.route('**/api/populationstatistics/studyprogrammes', route =>
route.fulfill({ json: { filteredProgrammes: {}, allProgrammes: {} } })
)
await router.route('**/api/login', route => route.fulfill({ json: { user: { roles: [] } } }))
await router.route('**/api/banners', route => route.fulfill({ json: [] }))
})
test('shows the Progress tab for a single year of a bachelor programme', async ({ mount, page }) => {
await page.goto('/?years=2020')
const component = await mount(<PopulationStudentsWrapper programme="KH50_005" />)
await expect(component.getByRole('tab')).toHaveText(['General', 'Courses', 'Modules', 'Tags', 'Progress'])
})
test('hides the Progress tab when multiple years are selected', async ({ mount, page }) => {
await page.goto('/?years=2020&years=2021')
const component = await mount(<PopulationStudentsWrapper programme="KH50_005" />)
await expect(component.getByRole('tab')).toHaveText(['General', 'Courses', 'Modules', 'Tags'])
})
test('hides the Progress tab for a non-bachelor programme', async ({ mount, page }) => {
await page.goto('/?years=2020')
const component = await mount(<PopulationStudentsWrapper programme="MH50_001" />)
await expect(component.getByRole('tab')).toHaveText(['General', 'Courses', 'Modules', 'Tags'])
})
})
@@ -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>
}
/>
)

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