mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d66fea3d7b | ||
|
|
dcfac28fa6 | ||
|
|
09b743fb9b | ||
|
|
36159c7305 | ||
|
|
1c84948f3c | ||
|
|
631b652553 | ||
|
|
41d027e054 | ||
|
|
caa7739f37 | ||
|
|
be91975591 | ||
|
|
f62984143a | ||
|
|
1645f568f8 | ||
|
|
15dac7c158 | ||
|
|
edc6182b20 | ||
|
|
183a4cd782 | ||
|
|
c3f776c9ea | ||
|
|
5f96b20936 | ||
|
|
50e37c8ef6 | ||
|
|
8287a4a37f | ||
|
|
1e46a27712 | ||
|
|
fe72648a77 | ||
|
|
7c6fb10521 | ||
|
|
f394a1515f | ||
|
|
f0cf3a12a5 | ||
|
|
9e53a739f6 | ||
|
|
74e3e59cd8 | ||
|
|
8a14efa403 | ||
|
|
a77c70150c | ||
|
|
89dd5f1322 | ||
|
|
75d4c78adb | ||
|
|
25bdf0cf8f | ||
|
|
ca16291e37 | ||
|
|
1ced256f43 | ||
|
|
a00fb4b78e | ||
|
|
874c8c6d00 | ||
|
|
fef7faa8e7 | ||
|
|
fa21354b8b | ||
|
|
ce2a035111 | ||
|
|
1a48b7a028 | ||
|
|
a8fe237e3d | ||
|
|
fb3ca8b699 | ||
|
|
8d7966b93f | ||
|
|
ed631a4d3b | ||
|
|
2459f73f56 | ||
|
|
1ac138a494 | ||
|
|
351310c8d6 | ||
|
|
484dbdbe40 | ||
|
|
a85b886167 | ||
|
|
6f404658d1 | ||
|
|
e0e47554a5 | ||
|
|
3ae0cb0249 | ||
|
|
30d8b03485 | ||
|
|
0126c33ff8 | ||
|
|
0ee5b514f6 | ||
|
|
7a8e258b7c | ||
|
|
b5d3fabe71 | ||
|
|
5e25423230 | ||
|
|
b6c06c663a | ||
|
|
81bce184a4 | ||
|
|
1b45a089b0 | ||
|
|
a1560898ad | ||
|
|
fd5fe1189c | ||
|
|
9e98fc1a6a | ||
|
|
7eb7d82088 | ||
|
|
83cd46f565 |
@@ -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 }}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+54
@@ -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
|
||||
Executable
+36
@@ -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"
|
||||
@@ -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
|
||||
Executable
+93
@@ -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
|
||||
Executable
+53
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+155
@@ -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 "$@"
|
||||
Executable
+113
@@ -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
|
||||
Executable
+90
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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',
|
||||
|
||||
+14
@@ -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')
|
||||
},
|
||||
}
|
||||
+20
@@ -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}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
+94
@@ -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] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+25
-19
@@ -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}
|
||||
|
||||
+8
-2
@@ -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 }}
|
||||
/>
|
||||
)
|
||||
+3
-36
@@ -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
|
||||
|
||||
+27
@@ -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]))
|
||||
+54
-46
@@ -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 />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
+3
-30
@@ -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
|
||||
|
||||
+13
-30
@@ -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 />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
+5
-4
@@ -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} />
|
||||
}
|
||||
|
||||
+266
-459
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
-42
@@ -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>
|
||||
)
|
||||
+50
@@ -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>
|
||||
)
|
||||
+151
@@ -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}
|
||||
|
||||
+31
-13
@@ -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>
|
||||
|
||||
+1
-3
@@ -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]
|
||||
}
|
||||
|
||||
+10
-40
@@ -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}>
|
||||
|
||||
-114
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-14
@@ -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>
|
||||
)
|
||||
}
|
||||
-17
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
+52
@@ -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 }}
|
||||
/>
|
||||
)
|
||||
+297
-175
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
+31
@@ -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
Reference in New Issue
Block a user