Compare commits

...
5 Commits
Author SHA1 Message Date
Aaro Koivukangas d66fea3d7b [E2E] Fix test infobox wording 2026-10-12 15:31:34 +03:00
joonas-a dcfac28fa6 [Progress] combined programme criteria fix
thanks claude opus 5.5 high
2026-10-12 15:20:23 +03:00
joonas-a 09b743fb9b [Degree programme settings] separate criteria from visibility 2026-10-12 15:20:23 +03:00
joonas-a 36159c7305 [Progress tab] refactoring
- many logic fixes
- new: display also course code in addition to name
- better naming for columns
2026-10-12 15:20:23 +03:00
Aaro Koivukangas 1c84948f3c [ok.sh] Add more aliases for npm run both:real 2026-10-12 12:43:04 +03:00
26 changed files with 1460 additions and 909 deletions
+12 -6
View File
@@ -650,10 +650,14 @@ test.describe('Degree programme overview', () => {
})
test.describe('Curriculum section', () => {
test('is shown', async ({ page }) => {
await expect(page.getByTestId('curriculum-section')).toBeVisible()
await expect(page.getByText('Select curriculum to edit:')).toBeVisible()
await expect(page.getByTestId('curriculum-picker').getByText('2023–2026')).toBeVisible()
test('is shown in both settings tabs', async ({ page }) => {
for (const tab of ['criteria', 'visibility']) {
await page.getByTestId(`${tab}-tab`).click()
const panel = page.getByTestId(`${tab}-panel`)
await expect(panel.getByTestId('curriculum-section')).toBeVisible()
await expect(panel.getByText('Select curriculum to edit:')).toBeVisible()
await expect(panel.getByTestId('curriculum-picker').getByText('2023–2026')).toBeVisible()
}
})
test.skip('can be changed', () => {
return
@@ -664,7 +668,7 @@ test.describe('Degree programme overview', () => {
test('info box', async ({ page }) => {
await expect(page.getByTestId('credit-criteria-section')).toBeVisible()
await page.getByTestId('credit-criteria-info-box-button').click()
await expect(page.getByTestId('credit-criteria-info-box-content').getByText('Tässä asetettu')).toBeVisible()
await expect(page.getByTestId('credit-criteria-info-box-content').getByText('Tässä asetetut')).toBeVisible()
})
test.skip('changing the credit criteria works', () => {
@@ -675,7 +679,9 @@ test.describe('Degree programme overview', () => {
})
test('Degree course table', async ({ page }) => {
await expect(page.getByTestId('degree-course-table')).toBeVisible()
await expect(page.getByTestId('criteria-panel').getByTestId('degree-course-table')).toBeVisible()
await page.getByTestId('visibility-tab').click()
await expect(page.getByTestId('visibility-panel').getByTestId('degree-course-table')).toBeVisible()
// TODO: Test clicking the arrow buttons
// TODO: Test toggling visibility for MODULES and COURSES
// TODO: Test changing criterion labels
+3 -3
View File
@@ -28,8 +28,8 @@ Run without a command to open the interactive menu.
Available commands:
up, u Start Oodikone with real data
rs, rl Restart Oodikone with real data (down, then up)
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
@@ -312,7 +312,7 @@ cmd_install() {
setup_colors
case "${1-}" in
up|u)
up|u|r|real)
shift
run_up "$@"
;;
@@ -193,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
@@ -191,28 +191,32 @@ However this is optional and you can create a new tag without selecting year.
degreeCoursesTab: {
creditCriteria: {
en: `The criteria set here will affect the Class Statistics → Students tab → Progress tab view.
en: `The targets set here will affect the Class Statistics → Students tab → Progress tab view.
Credit criteria is computed as follows: for the first academic year, the credits are counted
if they are completed during the first 12 months. For the second year, completions
within the first 24 months. Third year, the first 36 months, and so on.
`,
fi: `Tässä asetettu "criteria" vaikuttaa Class Statistics → Students tab → Progress tab näkymään.
fi: `Tässä asetetut tavoitteet vaikuttavat Class Statistics → Students tab → Progress tab näkymään.
Laskenta toimii seuraavasti: ensimmäisen akateemisen vuoden opintopisteet lasketaan, jos ne ovat suoritettu ensimmäisen 12 kuukauden ajalla. Toisen vuoden opintopisteet, jos ne ovat suoritettu ensimmäisen 24 kuukauden aikana. Kolmas, ensimmäiset 36kk, ja niin edelleen.
`,
},
programmeCriteriaAndVisibility: {
en: `Click the arrow next to the module to view courses belonging to the module in the curriculum. Setting a criterion label for a course will affect which academic year the course is placed under in Class Statistics → Students → Progress tab.
requiredCourses: {
en: `Click a module to see its courses in the selected curriculum.
Courses' visibilities can also be toggled here; hiding a course here will additionally hide it in the Courses of Class view and Students → Courses tab.
Tick a year to make the course a required course for that academic year in Class Statistics → Students → Progress tab. A course can be required for several years. The numbers on a module row show how many of its courses are required for each year.`,
fi: `Klikkaa opintokokonaisuutta nähdäksesi sen kurssit valitussa opetussuunnitelmassa.
Note: If hiding a module, all courses belonging to the module will also be hidden.`,
fi: `Klikkaa nuolta opintokokonaisuuden nimen vieressä näyttääksesi opetussuunitelmassa opintokokonaisuuden alle sijoitetut kurssit. Kursseille voi asettaa "Criterion label":eita, jotka vaikuttavat minkä akateemisen vuoden alla kurssi näytetään Class Statistics → Students → Progress tab näkymässä.
Valitse vuosi, jolloin kurssi on tarkoitus suorittaa. Kurssi näytetään kyseisen akateemisen vuoden alla Class Statistics → Students → Progress tab -näkymässä. Kurssi voi olla pakollinen useammalle vuodelle. Opintokokonaisuuden rivillä olevat luvut kertovat, kuinka moni sen kursseista on pakollinen kunakin vuonna. Huom. kurssi voi kuulua useaan opintokokonaisuuteen.`,
},
courseVisibility: {
en: `Click a module to see its courses in the selected curriculum.
Kurssien näkyvyyttä voidaan myös muuttaa tällä sivulla. Kurssin piilottaminen piilottaa sen myös Courses of Class ja Students → Courses tab näkymissä.
Hiding a course also hides it in the Courses of Class view and Students → Courses tab for all users. Hiding a module hides all of its courses.`,
fi: `Klikkaa opintokokonaisuutta nähdäksesi sen kurssit valitussa opetussuunnitelmassa.
Huom: Opintokokonaisuuden piilottaminen piilottaa myös kaikki sen alla olevat kurssit.`,
Kurssin piilottaminen piilottaa sen kaikilta käyttäjiltä myös Courses of Class ja Students → Courses tab -näkymissä. Opintokokonaisuuden piilottaminen piilottaa kaikki sen kurssit.`,
},
},
// Not used
@@ -5,19 +5,20 @@ import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import { useState } from 'react'
import { DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
import { DegreeSettingsTab, DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
import { VisibilityIcon } from '@/theme'
import { ManageCoursesShownInfo } from '@/pages/StudyProgramme/DegreeCoursesTab/InfoBox'
const defaultText = 'Manage course visibility'
const defaultIcon = <VisibilityIcon />
export const FilterDegreeCoursesModal = ({
degreeProgramme,
initialTab,
text,
icon,
}: {
degreeProgramme: string
initialTab: DegreeSettingsTab
text?: string
icon?: React.ReactNode
}) => {
@@ -31,8 +32,7 @@ export const FilterDegreeCoursesModal = ({
<Dialog maxWidth="md" onClose={() => setModalOpen(false)} open={modalOpen}>
<DialogTitle>Degree programme settings</DialogTitle>
<DialogContent>
<ManageCoursesShownInfo />
<DegreeCoursesTab combinedProgramme="" degreeProgramme={degreeProgramme} />
<DegreeCoursesTab degreeProgramme={degreeProgramme} initialTab={initialTab} />
<Button color="primary" onClick={() => setModalOpen(false)} style={{ marginTop: '10px' }} variant="contained">
Close
</Button>
@@ -36,7 +36,11 @@ export const PopulationCourses = ({
<>
<Stack direction="row" spacing={2} sx={{ my: 1, justifyContent: 'space-between' }}>
{query.programme && !onlyIamRights ? (
<FilterDegreeCoursesModal degreeProgramme={query.programme} text="Manage course / module visibility" />
<FilterDegreeCoursesModal
degreeProgramme={query.programme}
initialTab="visibility"
text="Manage course / module visibility"
/>
) : null}
<InfoBox content={tooltipText} />
</Stack>
@@ -393,7 +393,7 @@ export const CoursesTabContainer = ({ students, courses, idToGroupIdMap, program
toolbarContent={
<>
<OodiTableExcelExport data={exportData} exportColumnKeys={keysForExport} />
<FilterDegreeCoursesModal degreeProgramme={programme} />
<FilterDegreeCoursesModal degreeProgramme={programme} initialTab="visibility" />
<StudentNameVisibilityToggle />
<IncludeSubstitutionsToggle
includeSubstitutions={includeSubstitutions}
@@ -193,7 +193,11 @@ export const ModulesTab = ({
toolbarContent={
<>
<OodiTableExcelExport data={exportData} exportColumnKeys={accessorKeys} />
<FilterDegreeCoursesModal degreeProgramme={programme} text="Manage module visibility" />
<FilterDegreeCoursesModal
degreeProgramme={programme}
initialTab="visibility"
text="Manage module visibility"
/>
<StudentNameVisibilityToggle />
<HiddenCourseAlert ooditable modules />
</>
@@ -2,10 +2,7 @@ import Box from '@mui/material/Box'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { createColumnHelper } from '@tanstack/react-table'
import dayjs, { extend as dayjsExtend } from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import dayjs from 'dayjs'
import { useMemo } from 'react'
import { Link } from '@/components/common/Link'
@@ -15,116 +12,44 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { OodiTable } from '@/components/OodiTable'
import { CopyStudentNumbersHeader } from '@/components/OodiTable/common/CopyStudentNumbersHeader'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/PopulationCourses/FilterDegreeCoursesModal'
import { TableInfo } from '@/components/PopulationComponents/Students/Table/ProgressTab/info'
import { DateFormat } from '@/constants/date'
import {
getCellText,
getCourseGroups,
getCriteriaHeaders,
hasCreditTransfer,
hasEnrolled,
hasFailed,
hasPassedDuringAcademicYear,
hasPassedOutsideAcademicYear,
IdToGroupId,
Label,
} from '@/components/PopulationComponents/Students/Table/ProgressTab/util'
import { useCurriculum } from '@/hooks/useCurriculum'
import { useSemesters } from '@/hooks/useSemesters'
import { useGetProgressCriteriaQuery } from '@/redux/progressCriteria'
import { CheckIcon, CloseIcon, EditIcon, RemoveIcon, SwapHorizIcon } from '@/theme'
import { isMedicalProgramme } from '@/util/studyProgramme'
import { formatDate } from '@/util/timeAndDate'
import {
CreditTypeCode,
EnrollmentState,
FormattedStudent,
Name,
ProgressCriteria,
SemesterEnrollment,
} from '@oodikone/shared/types'
import { StudentCourse } from '@oodikone/shared/types/studentData'
import { keyBy, range } from '@oodikone/shared/util'
import { FormattedStudent, ProgrammeCourse, ProgressCriteria, SemesterEnrollment } from '@oodikone/shared/types'
import { keyBy } from '@oodikone/shared/util'
import '@/components/PopulationComponents/Students/Table/ProgressTab/index.css'
import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/PopulationCourses/FilterDegreeCoursesModal'
dayjsExtend(isBetween)
dayjsExtend(isSameOrBefore)
dayjsExtend(isSameOrAfter)
const columnHelper = createColumnHelper<FormattedStudent>()
type IdToGroupId = Record<string, string>
/** Return course + all of it's substitution groups (courses are identified by their course group id) */
const getCourseGroups = (
courseGroupId: string,
criteria: ProgressCriteria | undefined,
student: FormattedStudent,
idToGroupId: IdToGroupId
): StudentCourse[][] => {
const studentGroupIds = student.courses.map(({ course_id }) => idToGroupId[course_id])
// Return groups that have all courses in student.courses
const courseGroups: StudentCourse[][] = []
for (const group of [[courseGroupId]].concat(criteria?.allCourseGroups[courseGroupId] ?? [])) {
if (group.every(groupId => studentGroupIds.includes(groupId))) {
// We know all of the group's courses exist, we just checked that
courseGroups.push(
...group.map(groupId =>
student.courses
.filter(course => idToGroupId[course.course_id] === groupId)
.toSorted((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
)
)
}
}
return courseGroups
const helpTexts = {
0: 'No information',
1: 'Active',
2: 'Absent',
3: 'Passive',
}
/**
Filter out failed courses that don't "break" a given group eg. remove the only instance of a course code
NOTE: Does not automatically remove all failed courses!
*/
const filterFailed = (group: StudentCourse[]) => (course: StudentCourse) =>
course.passed || !group.find(otherCourse => course.course_code === otherCourse.course_code && otherCourse.passed)
const hasCreditTransfer = (courseGroups: StudentCourse[][]) =>
courseGroups.some(group => {
const passedCredits = group.filter(filterFailed(group))
return passedCredits.length === 0
? false
: passedCredits.some(course => course.credittypecode === CreditTypeCode.APPROVED)
})
/** Check if any courseGroup has
1. all courses passed
2. *LATEST* attainment gotten during the wanted academic year
3. All attainments gotten before the wanted academic year's end
*/
const hasPassedDuringAcademicYear = (courseGroups: StudentCourse[][], start: dayjs.Dayjs, end: dayjs.Dayjs) => {
return courseGroups.some(group => {
const passedCredits = group.filter(filterFailed(group))
return passedCredits.length === 0
? false
: passedCredits.some(credit => dayjs(credit.date).isBetween(dayjs(start), dayjs(end))) &&
passedCredits.every(credit => dayjs(credit.date).isBefore(dayjs(end)))
})
const enrollmentClassNames = {
0: 'label-none',
1: 'label-present',
2: 'label-absent',
3: 'label-passive',
}
/** Check if any group has only passed courses */
const hasPassedOutsideAcademicYear = (courseGroups: StudentCourse[][]) =>
courseGroups.some(group => {
const passedCredits = group.filter(filterFailed(group))
return passedCredits.length === 0 ? false : passedCredits.every(credit => credit.passed)
})
/**
Check if there is a failed course that was not removed by the filterFailed eg. there is
a failed course that has no passed attainment
*/
const hasFailed = (courseGroups: StudentCourse[][]) =>
courseGroups.some(group => {
const passedCredits = group.filter(filterFailed(group))
return passedCredits.length === 0 ? false : passedCredits.some(course => !course.passed)
})
/** Return enrollments only for main courses */
const hasEnrolled = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
student.enrollments?.some(enrollment => idToGroupId[enrollment.course_id] === courseGroupId)
const getEnrollment = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
student.enrollments.filter(enrollment => idToGroupId[enrollment.course_id] === courseGroupId)
const getRowContent = (
student: FormattedStudent,
label: Label,
@@ -134,7 +59,7 @@ const getRowContent = (
criteria: ProgressCriteria | undefined,
idToGroupId: IdToGroupId
) => {
if (label.code.includes('Credits')) {
if (label.code === 'Credits') {
if (student.criteriaProgress[year]?.credits) {
return <CheckIcon color="success" />
}
@@ -167,63 +92,42 @@ const getRowContent = (
return null
}
const getExcelText = (
label: Label,
criteria: ProgressCriteria | undefined,
student: FormattedStudent,
year: string,
idToGroupId: IdToGroupId
const getSemesterEnrollmentStatus = (
semesterEnrollments: SemesterEnrollment[] | null | undefined,
semesters: number[]
) => {
if (label.code.includes('Credits')) {
return student.criteriaProgress[year]?.credits ? 'Passed' : ''
}
if (!label.groupId) return ''
const courseGroups = getCourseGroups(label.groupId, criteria, student, idToGroupId)
if (hasPassedOutsideAcademicYear(courseGroups)) {
const latestCompletedGroup = courseGroups.find(group => group.every(course => course.passed))
return `Passed ${latestCompletedGroup?.length === 1 ? formatDate(latestCompletedGroup[0]?.date, DateFormat.ISO_DATE) : '(substituted)'}`
}
if (hasFailed(courseGroups)) {
const latestFailedCourse = courseGroups
.find(group => group.some(course => !course.passed))
?.find(course => !course.passed)
return `Failed ${formatDate(latestFailedCourse ? latestFailedCourse.date : '(substituted)', DateFormat.ISO_DATE)}`
}
if (hasEnrolled(student, label.groupId, idToGroupId)) {
const enrollment = getEnrollment(student, label.groupId, idToGroupId)
return `Enrollment ${formatDate(enrollment[0].enrollment_date_time, DateFormat.ISO_DATE)}`
}
return ''
const fall = semesterEnrollments?.find(semester => semester.semester === Math.min(...semesters))?.type ?? 0
const spring = semesterEnrollments?.find(semester => semester.semester === Math.max(...semesters))?.type ?? 0
return { fall, spring }
}
const getCriteriaHeaders = (months: number, programme: string) => {
const criteriaHeaders = [
{ title: months < 12 ? 'Academic year 1 (in progress)' : 'Academic year 1', year: 'year1', label: 'yearOne' },
{ title: months < 24 ? 'Academic year 2 (in progress)' : 'Academic year 2', year: 'year2', label: 'yearTwo' },
{ title: months < 36 ? 'Academic year 3 (in progress)' : 'Academic year 3', year: 'year3', label: 'yearThree' },
]
if (isMedicalProgramme(programme)) {
criteriaHeaders.push(
{ title: months < 48 ? 'Academic year 4 (in progress)' : 'Academic year 4', year: 'year4', label: 'yearFour' },
{ title: months < 60 ? 'Academic year 5 (in progress)' : 'Academic year 5', year: 'year5', label: 'yearFive' },
{ title: months < 72 ? 'Academic year 6 (in progress)' : 'Academic year 6', year: 'year6', label: 'yearSix' }
)
}
return criteriaHeaders
const getSemesterEnrollmentContent = (
semesterEnrollments: SemesterEnrollment[] | null | undefined,
semesters: number[]
) => {
const { fall, spring } = getSemesterEnrollmentStatus(semesterEnrollments, semesters)
return (
<div style={{ display: 'flex', justifyContent: 'center', gap: '0.2rem' }}>
<div className={`enrollment-label ${enrollmentClassNames[fall]}`} title={`Fall: ${helpTexts[fall]}`} />
<div className={`enrollment-label ${enrollmentClassNames[spring]}`} title={`Spring: ${helpTexts[spring]}`} />
</div>
)
}
type Label = {
/** Course code for course labels, otherwise a special value ('Credits', 'Criteria', 'Enrollment') */
code: string
/** Set for course labels */
groupId?: string
name: Name | null
}
/** Start and end of the student's nth (0-indexed) academic year, counting from the population's start */
const getAcademicYear = (months: number, yearIndex: number) => ({
start: dayjs()
.subtract(months - 1, 'months')
.startOf('month')
.add(yearIndex, 'years'),
end: dayjs()
.subtract(months - 12, 'months')
.endOf('month')
.add(yearIndex, 'years'),
})
/** Column title in the Excel export, unique over all years */
const getExcelKey = (label: Label, yearIndex: number) => `Year ${yearIndex + 1}: ${label.name}`
export const ProgressTable = ({
idToGroupIdMap,
@@ -244,143 +148,159 @@ export const ProgressTable = ({
const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: programme }, { skip: !programme })
const { semesters: allSemesters } = useSemesters()
const isStudyGuidanceGroupProgramme = studyGuidanceGroupProgramme !== ''
const creditMonths = [12, 24, 36, 48, 60, 72]
const defaultCourses = keyBy(curriculum?.defaultProgrammeCourses, 'group_id')
const coursesSecondProgramme = keyBy(curriculum?.secondProgrammeCourses, 'group_id')
const getCourse = (courseGroupId: string) => defaultCourses[courseGroupId] ?? coursesSecondProgramme[courseGroupId]
const labelCriteria = useMemo(() => {
const courses: Record<string, ProgrammeCourse | undefined> = {
...keyBy(curriculum?.secondProgrammeCourses, 'group_id'),
...keyBy(curriculum?.defaultProgrammeCourses, 'group_id'),
}
const labelCriteria = Object.keys(criteria?.courses ?? {}).reduce<Record<string, Label[]>>((acc, year, index) => {
acc[year] = [
{
code: 'Credits',
name: {
fi: `${creditMonths[index]} mos.: ${criteria?.credits[year]}`,
en: `${creditMonths[index]} mos.: ${criteria?.credits[year]}`,
sv: `${creditMonths[index]} mos.: ${criteria?.credits[year]}`,
return Object.keys(criteria?.courses ?? {}).reduce<Record<string, Label[]>>((acc, year, index) => {
acc[year] = [
{
code: 'Credits',
name: `At least ${criteria?.credits[year] ?? 0} credits during first ${(index + 1) * 12} months`,
},
},
...(criteria?.courses[year] ?? [])
.map((courseGroupId: string): Label => {
const course = getCourse(courseGroupId)
return { code: course?.code ?? courseGroupId, groupId: courseGroupId, name: course?.name ?? null }
})
.sort((a, b) => a.code.localeCompare(b.code)),
{
code: 'Criteria',
name: {
fi: `Year ${index + 1}: Fullfilled`,
en: `Year ${index + 1}: Fullfilled`,
sv: `Year ${index + 1}: Fullfilled`,
...(criteria?.courses[year] ?? [])
.map((courseGroupId: string): Label => {
const course = courses[courseGroupId]
const code = course?.code ?? courseGroupId
return {
code,
groupId: courseGroupId,
name: `${code} ${course ? getTextIn(course.name) : 'Unknown course'}`,
}
})
.sort((a, b) => a.code.localeCompare(b.code)),
{
code: 'Criteria',
name: `Year ${index + 1} criteria fulfilled`,
},
},
{
code: 'Enrollment',
name: { en: `Year ${index + 1}` },
},
]
return acc
}, {})
const criteriaHeaders = getCriteriaHeaders(months, programme)
const studentNumberToSemesterEnrollmentsMap = students.reduce<Record<string, SemesterEnrollment[] | null>>(
(acc, student) => {
const correctStudyRight = student.studyRights.find(studyRight =>
studyRight.studyRightElements.some(element => element.code === programme)
)
if (correctStudyRight) {
acc[student.studentNumber] = correctStudyRight.semesterEnrollments
}
{
code: 'Enrollment',
name: 'Semester enrollments',
},
]
return acc
},
{}
}, {})
}, [criteria, curriculum, getTextIn])
const criteriaHeaders = useMemo(
() => getCriteriaHeaders(months, programme).filter((_, yearIndex) => yearIndex === 0 || yearIndex * 12 < months),
[months, programme]
)
const helpTexts = {
0: 'No information',
1: 'Active',
2: 'Absent',
3: 'Passive',
}
const semesterEnrollmentsByStudent = useMemo(
() =>
students.reduce<Record<string, SemesterEnrollment[] | null>>((acc, student) => {
const correctStudyRight = student.studyRights.find(studyRight =>
studyRight.studyRightElements.some(element => element.code === programme)
)
if (correctStudyRight) {
acc[student.studentNumber] = correctStudyRight.semesterEnrollments
}
return acc
}, {}),
[students, programme]
)
const getSemesterEnrollmentStatus = (student: FormattedStudent, semesters: number[]) => {
const fall =
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
semester => semester.semester === Math.min(...semesters)
)?.type ?? 0
/** Academic year date range and its semester codes for each shown year */
const academicYears = useMemo(
() =>
criteriaHeaders.map((_, yearIndex) => {
const { start, end } = getAcademicYear(months, yearIndex)
const semesters = Object.values(allSemesters)
.filter(
semester => dayjs(semester.startdate).isSameOrAfter(start) && dayjs(semester.enddate).isSameOrBefore(end)
)
.map(semester => semester.semestercode)
return { start, end, semesters }
}),
[criteriaHeaders, months, allSemesters]
)
const spring =
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
semester => semester.semester === Math.max(...semesters)
)?.type ?? 0
const excelData = useMemo(
() =>
students.map(student => ({
'Student number': student.studentNumber,
'Last name': student.lastname,
'First names': student.firstnames,
...Object.fromEntries(
criteriaHeaders.flatMap(({ year, label: yearLabel }, yearIndex) =>
(labelCriteria[yearLabel] ?? []).flatMap(label => {
const key = getExcelKey(label, yearIndex)
switch (label.code) {
case 'Criteria':
return [[key, student.criteriaProgress[year]?.totalSatisfied ?? 0]]
return { fall, spring }
}
case 'Enrollment': {
const { fall, spring } = getSemesterEnrollmentStatus(
semesterEnrollmentsByStudent[student.studentNumber],
academicYears[yearIndex].semesters
)
return [
[`${key} - FALL`, helpTexts[fall]],
[`${key} - SPRING`, helpTexts[spring]],
]
}
const getSemesterEnrollmentVal = (student: FormattedStudent, semesters: number[]): [string, string] => {
const { fall, spring } = getSemesterEnrollmentStatus(student, semesters)
return [helpTexts[fall], helpTexts[spring]]
}
default:
return [[key, getCellText(label, criteria, student, year, idToGroupIdMap)]]
}
})
)
),
})),
[students, criteriaHeaders, labelCriteria, semesterEnrollmentsByStudent, academicYears, criteria, idToGroupIdMap]
)
const getSemesterEnrollmentContent = (student: FormattedStudent, semesters: number[]) => {
const enrollmentTypes = {
0: { className: 'label-none' },
1: { className: 'label-present' },
2: { className: 'label-absent' },
3: { className: 'label-passive' },
const accessorKeys = useMemo(
() => [
'Student number',
'Last name',
'First names',
...criteriaHeaders.flatMap(({ label: yearLabel }, yearIndex) =>
(labelCriteria[yearLabel] ?? []).flatMap(label => {
const key = getExcelKey(label, yearIndex)
return label.code === 'Enrollment' ? [`${key} - FALL`, `${key} - SPRING`] : [key]
})
),
],
[criteriaHeaders, labelCriteria]
)
const columns = useMemo(() => {
const getSortingValue = (student: FormattedStudent, label: Label, year: string) => {
// Values:
// -1 Course completed
// 0 Failed
// 1 Enrolled
// 2 None of above
if (!label.groupId) return 2
if (student.criteriaProgress[year]?.coursesSatisfied?.[label.groupId]) return -1
if (student.courses.find(course => idToGroupIdMap[course.course_id] === label.groupId)?.passed === false) return 0
if (hasEnrolled(student, label.groupId, idToGroupIdMap)) return 1
return 2
}
const fall =
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
semester => semester.semester === Math.min(...semesters)
)?.type ?? 0
const spring =
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
semester => semester.semester === Math.max(...semesters)
)?.type ?? 0
const fallText = `Fall: ${helpTexts[fall]}`
const springText = `Spring: ${helpTexts[spring]}`
return (
<div style={{ display: 'flex', justifyContent: 'center', gap: '0.2rem' }}>
<div className={`enrollment-label ${enrollmentTypes[fall]?.className}`} title={fallText} />
<div className={`enrollment-label ${enrollmentTypes[spring]?.className}`} title={springText} />
</div>
)
}
const getSortingValue = (student: FormattedStudent, label: Label, year: string) => {
// Values:
// -1 Course completed
// 0 Failed
// 1 Enrolled
// 2 None of aove
if (!label.groupId) return 2
const criteriaCourses = student.criteriaProgress[year]?.coursesSatisfied
const course = criteriaCourses?.[label.groupId]
if (course) return -1
if (student.courses.find(course => idToGroupIdMap[course.course_id] === label.groupId)?.passed === false) return 0
if (!course && hasEnrolled(student, label.groupId, idToGroupIdMap)) return 1
return 2
}
const createContent = (labels: Label[], year: string, start: dayjs.Dayjs, end: dayjs.Dayjs, semesters: number[]) => {
return (
labels?.map(label =>
const createContent = (labels: Label[], year: string, yearIndex: number) => {
const { start, end, semesters } = academicYears[yearIndex]
return labels.map(label =>
columnHelper.accessor(() => undefined, {
id: `${year}-${label.groupId ?? label.code}-${label.name?.fi ?? ''}`,
header: getTextIn(label.name) ?? label.code,
id: `${year}-${label.groupId ?? label.code}`,
header: label.name,
cell: ({ row: { original: student } }) => {
const title = label.code.includes('Criteria')
? `${student.criteriaProgress[year]?.totalSatisfied ?? 0} criteria fullfilled`
: getExcelText(label, criteria, student, year, idToGroupIdMap)
const title =
label.code === 'Criteria'
? `${student.criteriaProgress[year]?.totalSatisfied ?? 0} criteria fulfilled`
: getCellText(label, criteria, student, year, idToGroupIdMap)
const content = label.code.includes('Criteria')
? (student.criteriaProgress[year]?.totalSatisfied ?? 0)
: label.code.includes('Enrollment')
? getSemesterEnrollmentContent(student, semesters)
: getRowContent(student, label, year, start, end, criteria, idToGroupIdMap)
const content =
label.code === 'Criteria'
? (student.criteriaProgress[year]?.totalSatisfied ?? 0)
: label.code === 'Enrollment'
? getSemesterEnrollmentContent(semesterEnrollmentsByStudent[student.studentNumber], semesters)
: getRowContent(student, label, year, start, end, criteria, idToGroupIdMap)
return (
<Tooltip title={title}>
@@ -396,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)
}
@@ -416,135 +344,10 @@ export const ProgressTable = ({
}
},
})
) ?? []
)
}
const generateYearColumns = (startYear: dayjs.Dayjs, endYear: dayjs.Dayjs, criteriaIndex: number) => {
const semesters = Object.values(allSemesters)
.filter(
semester =>
dayjs(semester.startdate).isSameOrAfter(startYear) && dayjs(semester.enddate).isSameOrBefore(endYear)
)
.map(semester => semester.semestercode)
}
return columnHelper.group({
id: criteriaHeaders[criteriaIndex].title,
header: criteriaHeaders[criteriaIndex].title,
columns: createContent(
labelCriteria[criteriaHeaders[criteriaIndex].label],
criteriaHeaders[criteriaIndex].year,
startYear,
endYear,
semesters
),
})
}
const academicYearStart = dayjs()
.subtract(months - 1, 'months')
.startOf('month')
const academicYearEnd = dayjs()
.subtract(months - 12, 'months')
.endOf('month')
// HACK: Have fun with this one.
const excelData = useMemo(
() =>
students.map(student => ({
'Student number': student.studentNumber,
'Last name': student.lastname,
'First names': student.firstnames,
...Object.fromEntries(
range(0, isMedicalProgramme(programme) ? 6 : 3)
.filter(year => year * 12 < months)
.flatMap(year => {
const startYear = dayjs(academicYearStart).add(year, 'years')
const endYear = dayjs(academicYearEnd).add(year, 'years')
const semesters = Object.values(allSemesters)
.filter(
semester =>
dayjs(semester.startdate).isSameOrAfter(startYear) &&
dayjs(semester.enddate).isSameOrBefore(endYear)
)
.map(semester => semester.semestercode)
return (
labelCriteria[criteriaHeaders[year].label]?.flatMap(label => {
let result: (string | number | null | undefined)[][] = []
switch (label.code) {
case 'Criteria': {
result = [
[getTextIn(label.name), student.criteriaProgress[`year${year + 1}`]?.totalSatisfied ?? 0],
]
break
}
case 'Enrollment': {
result = (() => {
const [fall, spring] = getSemesterEnrollmentVal(student, semesters)
return [
[`${getTextIn(label.name)} - FALL`, fall],
[`${getTextIn(label.name)} - SPRING`, spring],
]
})()
break
}
case 'Credits': {
result = [
[getTextIn(label.name), student.criteriaProgress[`year${year + 1}`]?.credits ? 'Passed' : ''],
]
break
}
default: {
const course = student.courses.find(course => idToGroupIdMap[course.course_id] === label.groupId)
const enrollment = student.enrollments.find(
enrollment => idToGroupIdMap[enrollment.course_id] === label.groupId
)
result = [
[
getTextIn(label.name),
course?.passed ? 'Passed' : enrollment?.state === EnrollmentState.ENROLLED ? 'Enrolled' : '',
],
]
break
}
}
return result
}) ?? []
)
})
.filter(([key, _]) => !!key)
),
})),
[students, allSemesters, programme, labelCriteria, idToGroupIdMap]
)
const accessorKeys = useMemo(
() => [
'Student number',
'Last name',
'First names',
...range(0, isMedicalProgramme(programme) ? 6 : 3)
.filter(year => year * 12 < months)
.flatMap(year => {
return (
labelCriteria[criteriaHeaders[year].label]?.flatMap(label => {
const labelName = getTextIn(label.name) ?? ''
return label.code === 'Enrollment' ? [`${labelName} - FALL`, `${labelName} - SPRING`] : [labelName]
}) ?? []
)
})
.filter(key => !!key),
],
[programme, labelCriteria]
)
const columns = useMemo(() => {
const columns = [
return [
columnHelper.group({
header: 'Student',
columns: [
@@ -570,40 +373,29 @@ export const ProgressTable = ({
: []),
],
}),
generateYearColumns(academicYearStart, academicYearEnd, 0),
...criteriaHeaders.map(({ title, year, label }, yearIndex) =>
columnHelper.group({
id: title,
header: title,
columns: createContent(labelCriteria[label] ?? [], year, yearIndex),
})
),
]
const addYearColumns = (year: number) => {
if (months > year * 12) {
columns.push(
generateYearColumns(
dayjs(academicYearStart).add(year, 'years'),
dayjs(academicYearEnd).add(year, 'years'),
year
)
)
}
}
addYearColumns(1)
addYearColumns(2)
if (isMedicalProgramme(programme)) {
addYearColumns(3)
addYearColumns(4)
addYearColumns(5)
}
return columns
}, [criteria, students, curriculum, getTextIn, namesVisible, idToGroupIdMap])
}, [
criteria,
criteriaHeaders,
labelCriteria,
academicYears,
semesterEnrollmentsByStudent,
namesVisible,
idToGroupIdMap,
])
const isCriteriaSet =
criteria &&
(Object.keys(criteria.courses).some(yearCourses => criteria.courses[yearCourses].length > 0) ||
Object.values(criteria.credits).some(creditCriteria => creditCriteria > 0))
const tableOptions = {}
return (
<>
{!isStudyGuidanceGroupProgramme && (
@@ -613,28 +405,30 @@ export const ProgressTable = ({
</h5>
)}
<TableInfo />
<Box sx={{ display: 'flex' }}>
<Box sx={{ width: '100%' }}>
{isCriteriaSet ? (
<OodiTable
columns={columns}
data={students}
options={tableOptions}
toolbarContent={
<>
<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />
<FilterDegreeCoursesModal degreeProgramme={programme} text="Modify criteria" icon={<EditIcon />} />
<StudentNameVisibilityToggle />
</>
}
/>
) : (
<Typography variant="h6">
There is no criteria set for this programme. You can create one{' '}
<Link to={`/study-programme/${programme}?tab=3`}>here</Link>.
</Typography>
)}
</Box>
<Box sx={{ width: '100%' }}>
{isCriteriaSet ? (
<OodiTable
columns={columns}
data={students}
toolbarContent={
<>
<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />
<FilterDegreeCoursesModal
degreeProgramme={programme}
initialTab="criteria"
icon={<EditIcon />}
text="Modify criteria"
/>
<StudentNameVisibilityToggle />
</>
}
/>
) : (
<Typography variant="h6">
There is no criteria set for this programme. You can create one{' '}
<Link to={`/study-programme/${programme}?tab=3`}>here</Link>.
</Typography>
)}
</Box>
</>
)
@@ -0,0 +1,151 @@
import dayjs, { extend as dayjsExtend } from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import { DateFormat } from '@/constants/date'
import { isMedicalProgramme } from '@/util/studyProgramme'
import { formatDate } from '@/util/timeAndDate'
import { CreditTypeCode, EnrollmentState, FormattedStudent, ProgressCriteria } from '@oodikone/shared/types'
import { StudentCourse } from '@oodikone/shared/types/studentData'
dayjsExtend(isBetween)
dayjsExtend(isSameOrBefore)
dayjsExtend(isSameOrAfter)
export type IdToGroupId = Record<string, string>
export type Label = {
/** Course code for course labels, otherwise a special value ('Credits', 'Criteria', 'Enrollment') */
code: string
/** Set for course labels */
groupId?: string
name: string
}
/**
Return the attainments of the course and of each of its substitution groups, newest first. One entry per group;
a group is only included if the student has attainments for all of its courses.
*/
export const getCourseGroups = (
courseGroupId: string,
criteria: ProgressCriteria | undefined,
student: FormattedStudent,
idToGroupId: IdToGroupId
): StudentCourse[][] => {
const studentGroupIds = new Set(student.courses.map(({ course_id }) => idToGroupId[course_id]))
return [[courseGroupId]]
.concat(criteria?.allCourseGroups[courseGroupId] ?? [])
.filter(group => group.every(groupId => studentGroupIds.has(groupId)))
.map(group =>
student.courses
.filter(course => group.includes(idToGroupId[course.course_id]))
.toSorted((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
)
}
/**
Filter out failed courses that don't "break" a given group eg. remove the only instance of a course code
NOTE: Does not automatically remove all failed courses!
*/
const filterFailed = (group: StudentCourse[]) => (course: StudentCourse) =>
course.passed || !group.find(otherCourse => course.course_code === otherCourse.course_code && otherCourse.passed)
const isGroupPassed = (group: StudentCourse[]) => {
const passedCredits = group.filter(filterFailed(group))
return passedCredits.length > 0 && passedCredits.every(credit => credit.passed)
}
export const hasCreditTransfer = (courseGroups: StudentCourse[][]) =>
courseGroups.some(
group => isGroupPassed(group) && group.some(course => course.credittypecode === CreditTypeCode.APPROVED)
)
/** Check if any courseGroup has
1. all courses passed
2. *LATEST* attainment gotten during the wanted academic year
3. All attainments gotten before the wanted academic year's end
*/
export const hasPassedDuringAcademicYear = (courseGroups: StudentCourse[][], start: dayjs.Dayjs, end: dayjs.Dayjs) =>
courseGroups.some(group => {
if (!isGroupPassed(group)) return false
const passedCredits = group.filter(credit => credit.passed)
return (
passedCredits.some(credit => dayjs(credit.date).isBetween(start, end)) &&
passedCredits.every(credit => dayjs(credit.date).isBefore(end))
)
})
/** Check if any group has only passed courses */
export const hasPassedOutsideAcademicYear = (courseGroups: StudentCourse[][]) => courseGroups.some(isGroupPassed)
/**
Check if there is a failed course that was not removed by the filterFailed eg. there is
a failed course that has no passed attainment
*/
export const hasFailed = (courseGroups: StudentCourse[][]) =>
courseGroups.some(group => group.filter(filterFailed(group)).some(course => !course.passed))
/** Return enrollments only for main courses */
export const getEnrollments = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
(student.enrollments ?? []).filter(
enrollment => enrollment.state === EnrollmentState.ENROLLED && idToGroupId[enrollment.course_id] === courseGroupId
)
export const hasEnrolled = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
getEnrollments(student, courseGroupId, idToGroupId).length > 0
/** Text representation of a cell, used for the tooltip and the Excel export */
export const getCellText = (
label: Label,
criteria: ProgressCriteria | undefined,
student: FormattedStudent,
year: string,
idToGroupId: IdToGroupId
) => {
if (label.code === 'Credits') {
return student.criteriaProgress[year]?.credits ? 'Passed' : ''
}
if (!label.groupId) return ''
const courseGroups = getCourseGroups(label.groupId, criteria, student, idToGroupId)
const passedGroup = courseGroups.find(isGroupPassed)
if (passedGroup) {
const isMainCourse = passedGroup.every(course => idToGroupId[course.course_id] === label.groupId)
const latestPassed = passedGroup.find(course => course.passed)
return `Passed ${isMainCourse ? formatDate(latestPassed?.date, DateFormat.ISO_DATE) : '(substituted)'}`
}
if (hasFailed(courseGroups)) {
const latestFailedCourse = courseGroups
.flatMap(group => group.filter(filterFailed(group)))
.find(course => !course.passed)
return `Failed ${latestFailedCourse ? formatDate(latestFailedCourse.date, DateFormat.ISO_DATE) : '(substituted)'}`
}
const [enrollment] = getEnrollments(student, label.groupId, idToGroupId)
if (enrollment) {
return `Enrollment ${formatDate(enrollment.enrollment_date_time, DateFormat.ISO_DATE)}`
}
return ''
}
const yearKeys = [
{ year: 'year1', label: 'yearOne' },
{ year: 'year2', label: 'yearTwo' },
{ year: 'year3', label: 'yearThree' },
{ year: 'year4', label: 'yearFour' },
{ year: 'year5', label: 'yearFive' },
{ year: 'year6', label: 'yearSix' },
] as const
export const getCriteriaHeaders = (months: number, programme: string) =>
yearKeys.slice(0, isMedicalProgramme(programme) ? 6 : 3).map(({ year, label }, index) => ({
title: `Academic year ${index + 1}${months < (index + 1) * 12 ? ' (in progress)' : ''}`,
year,
label,
}))
@@ -12,7 +12,7 @@ import { Section } from '@/components/Section'
import { useStatusNotification } from '@/components/StatusNotification/Context'
import { useAddProgressCriteriaCreditsMutation } from '@/redux/progressCriteria'
import { SaveIcon } from '@/theme'
import { isMedicalProgramme } from '@/util/studyProgramme'
import { getCriteriaYears } from '@/pages/StudyProgramme/DegreeCoursesTab/criteriaYears'
import { ProgressCriteria } from '@oodikone/shared/types'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
@@ -68,43 +68,11 @@ export const CreditCriteriaSection = ({
}
}, [isError])
const inputs = [
{
key: 'year1',
label: 'First',
previousValue: criteria.credits.yearOne,
},
{
key: 'year2',
label: 'Second',
previousValue: criteria.credits.yearTwo,
},
{
key: 'year3',
label: 'Third',
previousValue: criteria.credits.yearThree,
},
]
if (isMedicalProgramme(degreeProgramme)) {
inputs.push(
{
key: 'year4',
label: 'Fourth',
previousValue: criteria.credits.yearFour,
},
{
key: 'year5',
label: 'Fifth',
previousValue: criteria.credits.yearFive,
},
{
key: 'year6',
label: 'Sixth',
previousValue: criteria.credits.yearSix,
}
)
}
const inputs = getCriteriaYears(degreeProgramme).map(({ key, label, year }) => ({
key: `year${year}`,
label,
previousValue: criteria.credits[key],
}))
const disabled = Object.values(creditLimits).every(value => value === null || value < 0)
@@ -112,7 +80,7 @@ export const CreditCriteriaSection = ({
<Section
cypress="credit-criteria"
infoBoxContent={getTextIn(studyProgrammeToolTips.degreeCoursesTab.creditCriteria) ?? ''}
title="Credit criteria"
title="Credit targets"
>
<Stack gap={2}>
<Grid container spacing={1}>
@@ -1,114 +0,0 @@
import Checkbox from '@mui/material/Checkbox'
import IconButton from '@mui/material/IconButton'
import ListItemText from '@mui/material/ListItemText'
import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem'
import Tooltip from '@mui/material/Tooltip'
import { useEffect, useState } from 'react'
import { useAddProgressCriteriaCourseMutation } from '@/redux/progressCriteria'
import { FormatListNumberedIcon } from '@/theme'
import { isMedicalProgramme } from '@/util/studyProgramme'
import { ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
export const CriterionLabelSelectButton = ({
course,
criteria,
studyProgramme,
}: {
course: ProgrammeCourse
criteria: ProgressCriteria
studyProgramme: string
}) => {
const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation()
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const [selectedOptions, setSelectedOptions] = useState<string[]>([])
useEffect(() => {
const selected = Object.keys(criteria.courses).filter(key => criteria.courses[key].includes(course.group_id))
setSelectedOptions(selected)
}, [criteria, course.group_id])
const options = [
{
key: 'yearOne',
label: 'First',
year: 1,
},
{
key: 'yearTwo',
label: 'Second',
year: 2,
},
{
key: 'yearThree',
label: 'Third',
year: 3,
},
]
if (isMedicalProgramme(studyProgramme)) {
options.push(
{
key: 'yearFour',
label: 'Fourth',
year: 4,
},
{
key: 'yearFive',
year: 5,
label: 'Fifth',
},
{
key: 'yearSix',
year: 6,
label: 'Sixth',
}
)
}
const handleCheckboxChange = (key: string, year: number) => {
let courseGroupIds: string[]
if (selectedOptions.includes(key)) {
courseGroupIds = criteria.courses[key]?.filter((groupId: string) => groupId !== course.group_id)
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
} else {
courseGroupIds = criteria.courses ? [...criteria.courses[key], course.group_id] : [course.group_id]
}
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
setSelectedOptions(prevSelected =>
prevSelected.includes(key) ? prevSelected.filter(item => item !== key) : [...prevSelected, key]
)
}
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
return (
<>
<Tooltip arrow placement="top" title={'Modify criterion labels'}>
<IconButton data-cy="toggle-visibility-button" onClick={handleMenuOpen}>
<FormatListNumberedIcon />
</IconButton>
</Tooltip>
<Menu anchorEl={anchorEl} onClose={handleMenuClose} open={Boolean(anchorEl)}>
{options.map(option => (
<MenuItem key={option.key} onClick={() => handleCheckboxChange(option.key, option.year)}>
<Checkbox checked={selectedOptions.includes(option.key)} />
<ListItemText primary={`${option.label} year`} />
</MenuItem>
))}
</Menu>
</>
)
}
@@ -1,14 +0,0 @@
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { VisibilityIcon, VisibilityOffIcon } from '@/theme'
export const ToggleVisibilityButton = ({ onClick, visible }: { onClick: any; visible: boolean }) => {
return (
<Tooltip arrow placement="top" title={visible ? 'Set hidden' : 'Set visible'}>
<IconButton data-cy="toggle-visibility-button" onClick={onClick}>
{visible ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</Tooltip>
)
}
@@ -1,17 +0,0 @@
import Chip from '@mui/material/Chip'
import { CourseVisibility } from '@/types/courseVisibility'
export const VisibilityChip = ({ visibility }: { visibility: CourseVisibility }) => {
return (
<Chip
label={visibility}
size="small"
sx={{
color: theme => theme.palette.courseVisibility[visibility],
borderColor: theme => theme.palette.courseVisibility[visibility],
}}
variant="outlined"
/>
)
}
@@ -0,0 +1,52 @@
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
import { CourseVisibility } from '@/types/courseVisibility'
const labels: Record<CourseVisibility, string> = {
[CourseVisibility.NO_COURSES]: 'No courses',
[CourseVisibility.HIDDEN]: 'Hidden',
[CourseVisibility.PARTIAL]: 'Partly shown',
[CourseVisibility.VISIBLE]: 'Shown',
}
const colors = {
[CourseVisibility.NO_COURSES]: 'noCourses',
[CourseVisibility.HIDDEN]: 'hidden',
[CourseVisibility.PARTIAL]: 'partial',
[CourseVisibility.VISIBLE]: 'visible',
} as const
export const VisibilitySwitch = ({
ariaLabel,
cypress,
onChange,
visibility,
}: {
ariaLabel: string
cypress: string
onChange: () => void
visibility: CourseVisibility
}) => (
<FormControlLabel
control={
<Switch
checked={visibility === CourseVisibility.VISIBLE || visibility === CourseVisibility.PARTIAL}
disabled={visibility === CourseVisibility.NO_COURSES}
onChange={onChange}
size="small"
inputProps={{ 'aria-label': ariaLabel }}
/>
}
data-cy={cypress}
label={labels[visibility]}
onClick={event => event.stopPropagation()}
slotProps={{
typography: {
variant: 'body2',
sx: { color: theme => theme.palette.courseVisibility[colors[visibility]], whiteSpace: 'nowrap' },
},
}}
sx={{ mr: 0 }}
/>
)
@@ -1,7 +1,12 @@
import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogContentText from '@mui/material/DialogContentText'
import DialogTitle from '@mui/material/DialogTitle'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
@@ -11,136 +16,190 @@ import Typography from '@mui/material/Typography'
import { Fragment, useEffect, useState } from 'react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { CriterionLabelSelectButton } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/CriterionLabelSelectButton'
import { ToggleVisibilityButton } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/ToggleVisibilityButton'
import { VisibilityChip } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/VisibilityChip'
import { useStatusNotification } from '@/components/StatusNotification/Context'
import { CriteriaYearOption, getCriteriaYears } from '@/pages/StudyProgramme/DegreeCoursesTab/criteriaYears'
import { VisibilitySwitch } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/VisibilitySwitch'
import { useRemoveCourseExclusionMutation, useSetCourseExclusionMutation } from '@/redux/courseExclusions'
import { FormatListNumberedIcon, KeyboardArrowDownIcon, KeyboardArrowRightIcon } from '@/theme'
import { useAddProgressCriteriaCourseMutation } from '@/redux/progressCriteria'
import { KeyboardArrowDownIcon, KeyboardArrowRightIcon } from '@/theme'
import { CourseVisibility } from '@/types/courseVisibility'
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
import { Module, ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
import { StyledTable } from '@/components/common/StyledTable'
const reloadNote = 'Reload the page to see the change in other views.'
export type DegreeModule = Module & {
/** Programme whose curriculum the module is from. Course visibility is saved separately for each programme. */
programmeCode: string
}
const getModuleKey = (module: DegreeModule) => `${module.programmeCode}/${module.code}`
export const DegreeCourseTable = ({
combinedProgramme,
criteria,
curriculumVersion,
modules: initialModules,
programmeNames,
showCriteria,
showVisibility,
studyProgramme,
}: {
combinedProgramme: string
criteria: ProgressCriteria
curriculumVersion: string[]
modules: Module[]
/** Modules of the first programme first, then those of the second programme of a combined programme */
modules: DegreeModule[]
/** Shown above each programme's modules when the modules are from more than one programme */
programmeNames: Record<string, string>
showCriteria: boolean
showVisibility: boolean
/** Programme whose progress criteria are edited */
studyProgramme: string
}) => {
const [moduleCoursesVisible, setModuleCoursesVisible] = useState<Record<string, Record<string, boolean>>>({})
const [expandedModules, setExpandedModules] = useState<Record<string, Record<string, boolean>>>({})
const { getTextIn } = useLanguage()
const { setStatusNotification } = useStatusNotification()
const [modules, setModules] = useState(initialModules)
// Unsaved criteria changes, discarded when updated criteria arrive from the server
const [pendingCriteria, setPendingCriteria] = useState<{
courses: ProgressCriteria['courses']
source: ProgressCriteria
} | null>(null)
const [moduleToHide, setModuleToHide] = useState<string | null>(null)
const [setExclusion] = useSetCourseExclusionMutation()
const [removeExclusion] = useRemoveCourseExclusionMutation()
const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation()
const version = curriculumVersion.join(',')
const criteriaYears = showCriteria ? getCriteriaYears(studyProgramme) : []
const criteriaCourses = pendingCriteria?.source === criteria ? pendingCriteria.courses : criteria.courses
useEffect(() => {
setModules(initialModules)
}, [initialModules])
const getModule = (moduleCode: string) => modules.find(module => module.code === moduleCode)!
const getModule = (moduleKey: string) => modules.find(module => getModuleKey(module) === moduleKey)!
const setModuleVisibility = (moduleCode: string, newVisibility: boolean) => {
// Same course can be in many modules, so change them all
const courseCodes = getModule(moduleCode).courses.map(course => course.code)
setModules(
modules.map(module => ({
...module,
courses: module.courses.map(course => {
if (courseCodes.includes(course.code)) {
return {
...course,
visible: { ...course.visible, visibility: newVisibility },
const getModuleName = (module: DegreeModule) => {
const firstCourse = module.courses[0]
return firstCourse?.parent_name ? getTextIn(firstCourse.parent_name)! : module.code
}
const setCoursesVisibility = (programmeCode: string, courseCodes: string[], newVisibility: boolean) => {
// Same course can be in many modules of the programme, so change them all
setModules(prevModules =>
prevModules.map(module =>
module.programmeCode !== programmeCode
? module
: {
...module,
courses: module.courses.map(course =>
courseCodes.includes(course.code)
? { ...course, visible: { ...course.visible, visibility: newVisibility } }
: course
),
}
}
return course
}),
}))
)
)
}
const setCourseVisibility = (moduleCode: string, newVisibility: boolean) => {
setModules(
modules.map(module => ({
...module,
courses: module.courses.map(course => {
if (course.code !== moduleCode) {
return course
}
return {
...course,
visible: { ...course.visible, visibility: newVisibility },
}
}),
}))
)
}
const areModuleCoursesVisible = (moduleCode: string) => {
if (!moduleCoursesVisible[version]) {
setModuleCoursesVisible(prev => ({ ...prev, [version]: {} }))
return false
const changeVisibility = (
programmeCode: string,
courses: ProgrammeCourse[],
newVisibility: boolean,
successMessage: string
) => {
const request = {
courseGroupIds: courses.map(course => course.group_id),
curriculumVersion: version,
programmeCode,
}
return moduleCoursesVisible[version][moduleCode]
const mutation = newVisibility ? removeExclusion(request) : setExclusion(request)
const courseCodes = courses.map(course => course.code)
setCoursesVisibility(programmeCode, courseCodes, newVisibility)
mutation
.unwrap()
.then(() => setStatusNotification(`${successMessage} ${reloadNote}`, 'success'))
.catch(() => {
setCoursesVisibility(programmeCode, courseCodes, !newVisibility)
setStatusNotification('Failed to change visibility', 'error')
})
}
const excludeOne = (course: ProgrammeCourse) => {
void setExclusion({
courseGroupIds: [course.group_id],
curriculumVersion: version,
programmeCode: combinedProgramme === '' ? studyProgramme : combinedProgramme,
})
setCourseVisibility(course.code, false)
const toggleCourseVisibility = (module: DegreeModule, course: ProgrammeCourse) => {
const newVisibility = !course.visible.visibility
const name = getTextIn(course.name)
changeVisibility(
module.programmeCode,
[course],
newVisibility,
`${name} is now ${newVisibility ? 'shown' : 'hidden'}.`
)
}
const excludeAll = (moduleCode: string) => {
const module = getModule(moduleCode)
const excludeFromProgramme = combinedProgramme === '' ? studyProgramme : combinedProgramme
void setExclusion({
courseGroupIds: module.courses.filter(course => course.visible.visibility).map(course => course.group_id),
curriculumVersion: version,
programmeCode: excludeFromProgramme,
})
setModuleVisibility(moduleCode, false)
const toggleModuleVisibility = (module: DegreeModule) => {
const visibility = getVisibility(module)
if (visibility === CourseVisibility.HIDDEN) {
changeVisibility(
module.programmeCode,
module.courses,
true,
`All courses in ${getModuleName(module)} are now shown.`
)
return
}
if (visibility === CourseVisibility.PARTIAL) {
setModuleToHide(getModuleKey(module))
return
}
hideModule(module)
}
const removeOne = (course: ProgrammeCourse) => {
void removeExclusion({
courseGroupIds: [course.group_id],
curriculumVersion: version,
programmeCode: studyProgramme,
})
setCourseVisibility(course.code, true)
const hideModule = (module: DegreeModule) => {
const visibleCourses = module.courses.filter(course => course.visible.visibility)
changeVisibility(
module.programmeCode,
visibleCourses,
false,
`All courses in ${getModuleName(module)} are now hidden.`
)
}
const deleteAll = (moduleCode: string) => {
const module = getModule(moduleCode)
void removeExclusion({
courseGroupIds: module.courses.map(course => course.group_id),
curriculumVersion: version,
programmeCode: studyProgramme,
})
setModuleVisibility(moduleCode, true)
const toggleCriteriaYear = (course: ProgrammeCourse, { key, year }: CriteriaYearOption) => {
const current = criteriaCourses[key] ?? []
const isRequired = current.includes(course.group_id)
const courseGroupIds = isRequired
? current.filter(groupId => groupId !== course.group_id)
: [...current, course.group_id]
const name = getTextIn(course.name)
setPendingCriteria({ courses: { ...criteriaCourses, [key]: courseGroupIds }, source: criteria })
addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
.unwrap()
.then(() =>
setStatusNotification(
isRequired ? `${name} removed from year ${year} criteria` : `${name} added to year ${year} criteria`,
'success'
)
)
.catch(() => {
setPendingCriteria(null)
setStatusNotification('Failed to update criteria', 'error')
})
}
const toggleVisible = (moduleCode: string) => {
const newState = !areModuleCoursesVisible(moduleCode)
setModuleCoursesVisible({
...moduleCoursesVisible,
[version]: { ...moduleCoursesVisible[version], [moduleCode]: newState },
const isModuleExpanded = (moduleKey: string) => expandedModules[version]?.[moduleKey] ?? false
const toggleExpanded = (moduleKey: string) => {
setExpandedModules({
...expandedModules,
[version]: { ...expandedModules[version], [moduleKey]: !isModuleExpanded(moduleKey) },
})
}
const getVisibility = (moduleCode: string) => {
const module = getModule(moduleCode)
if (!module.courses) {
const getVisibility = (module: DegreeModule) => {
if (!module.courses?.length) {
return CourseVisibility.NO_COURSES
}
if (module.courses.every(course => course.visible.visibility)) {
@@ -152,104 +211,164 @@ export const DegreeCourseTable = ({
return CourseVisibility.PARTIAL
}
const countRequired = (courses: ProgrammeCourse[], { key }: CriteriaYearOption) =>
courses.filter(course => criteriaCourses[key]?.includes(course.group_id)).length
const headerRowSpan = showCriteria ? 2 : 1
const columnCount = 2 + Number(showVisibility) + criteriaYears.length
const showProgrammeRows = new Set(modules.map(module => module.programmeCode)).size > 1
const pendingHideModule = moduleToHide ? getModule(moduleToHide) : null
const remainingToHide = pendingHideModule?.courses.filter(course => course.visible.visibility).length ?? 0
return (
<StyledTable showCellBorders data-cy="degree-course-table" size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Code</TableCell>
<TableCell>Visibility</TableCell>
{isBachelorOrLicentiateProgramme(studyProgramme) && <TableCell>Criterion labels</TableCell>}
<TableCell colSpan={2}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{modules.map(({ code: moduleCode, courses }) => (
<Fragment key={`${moduleCode}-fragment`}>
<TableRow key={moduleCode}>
<TableCell>
<Box alignItems="center" display="flex" justifyContent="left">
<IconButton onClick={() => toggleVisible(moduleCode)} size="small">
{areModuleCoursesVisible(moduleCode) ? <KeyboardArrowDownIcon /> : <KeyboardArrowRightIcon />}
</IconButton>
<Typography fontWeight="bold" variant="body2">
{courses[0] && courses[0].parent_name ? getTextIn(courses[0].parent_name) : moduleCode}
</Typography>
</Box>
</TableCell>
<TableCell>{moduleCode}</TableCell>
<TableCell>
<VisibilityChip visibility={getVisibility(moduleCode)} />
</TableCell>
{isBachelorOrLicentiateProgramme(studyProgramme) && <TableCell />}
<TableCell>
<Stack direction="row" gap={1}>
<ToggleVisibilityButton
onClick={() =>
getVisibility(moduleCode) === CourseVisibility.HIDDEN
? deleteAll(moduleCode)
: excludeAll(moduleCode)
}
visible={getVisibility(moduleCode) !== CourseVisibility.HIDDEN}
/>
{isBachelorOrLicentiateProgramme(studyProgramme) && (
<IconButton disabled sx={{ visibility: 'hidden' }}>
<FormatListNumberedIcon />
</IconButton>
)}
</Stack>
<>
<StyledTable showCellBorders data-cy="degree-course-table" size="small">
<TableHead>
<TableRow>
<TableCell rowSpan={headerRowSpan}>Name</TableCell>
<TableCell rowSpan={headerRowSpan}>Code</TableCell>
{showVisibility && <TableCell rowSpan={headerRowSpan}>Visibility</TableCell>}
{showCriteria && (
<TableCell align="center" colSpan={criteriaYears.length}>
Required for year criteria
</TableCell>
)}
</TableRow>
{showCriteria && (
<TableRow>
{criteriaYears.map(({ year }) => (
<TableCell align="center" key={year}>
Year {year}
</TableCell>
))}
</TableRow>
{areModuleCoursesVisible(moduleCode) &&
courses
.sort((a, b) => a.code.localeCompare(b.code))
.map(course => (
<TableRow key={`${moduleCode}/${course.code}`} sx={{ bgcolor: 'grey.50' }}>
<TableCell>{getTextIn(course.name)}</TableCell>
<TableCell>{course.code}</TableCell>
)}
</TableHead>
<TableBody>
{modules.map((module, index) => {
const { code: moduleCode, courses, programmeCode } = module
const moduleKey = getModuleKey(module)
const moduleName = getModuleName(module)
const expanded = isModuleExpanded(moduleKey)
const startsProgramme = index === 0 || modules[index - 1].programmeCode !== programmeCode
return (
<Fragment key={moduleKey}>
{showProgrammeRows && startsProgramme && (
<TableRow data-cy={`programme-row-${programmeCode}`}>
<TableCell colSpan={columnCount} sx={{ bgcolor: 'grey.100' }}>
<Typography fontWeight="bold" variant="body2">
{programmeNames[programmeCode] ?? programmeCode} ({programmeCode})
</Typography>
</TableCell>
</TableRow>
)}
<TableRow hover onClick={() => toggleExpanded(moduleKey)} sx={{ cursor: 'pointer' }}>
<TableCell>
<Box alignItems="center" display="flex" justifyContent="left">
<IconButton
aria-expanded={expanded}
aria-label={expanded ? `Hide courses of ${moduleName}` : `Show courses of ${moduleName}`}
size="small"
>
{expanded ? <KeyboardArrowDownIcon /> : <KeyboardArrowRightIcon />}
</IconButton>
<Typography fontWeight="bold" variant="body2">
{moduleName}
</Typography>
</Box>
</TableCell>
<TableCell>{moduleCode}</TableCell>
{showVisibility && (
<TableCell>
<VisibilityChip
visibility={course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN}
<VisibilitySwitch
ariaLabel={`Show all courses of ${moduleName}`}
cypress={`visibility-switch-${moduleCode}`}
onChange={() => toggleModuleVisibility(module)}
visibility={getVisibility(module)}
/>
</TableCell>
{isBachelorOrLicentiateProgramme(studyProgramme) && (
<TableCell>
{Object.values(criteria.courses).length > 0 ? (
<Stack direction="row" gap={1}>
{Object.keys(criteria.courses).map(
(year, index) =>
criteria.courses[year].includes(course.group_id) && (
<Chip color="primary" key={year} label={`year ${index + 1}`} size="small" />
)
)}
</Stack>
) : (
<Typography color="text.secondary" variant="body2">
No labels set
)}
{criteriaYears.map(option => {
const requiredCount = countRequired(courses, option)
return (
<TableCell align="center" key={option.year}>
{requiredCount > 0 && (
<Typography
color="text.secondary"
title={`${requiredCount} of ${courses.length} courses required for year ${option.year}`}
variant="body2"
>
{requiredCount} / {courses.length}
</Typography>
)}
</TableCell>
)}
<TableCell>
<Stack direction="row" gap={1}>
<ToggleVisibilityButton
onClick={() => (course.visible.visibility ? excludeOne(course) : removeOne(course))}
visible={course.visible.visibility}
/>
{isBachelorOrLicentiateProgramme(studyProgramme) && (
<CriterionLabelSelectButton
course={course}
criteria={criteria}
studyProgramme={studyProgramme}
/>
)}
</Stack>
</TableCell>
</TableRow>
))}
</Fragment>
))}
</TableBody>
</StyledTable>
)
})}
</TableRow>
{expanded &&
[...courses]
.sort((a, b) => a.code.localeCompare(b.code))
.map(course => {
const courseName = getTextIn(course.name)
return (
<TableRow key={`${moduleKey}/${course.code}`} sx={{ bgcolor: 'grey.50' }}>
<TableCell sx={{ pl: 6 }}>{courseName}</TableCell>
<TableCell>{course.code}</TableCell>
{showVisibility && (
<TableCell>
<VisibilitySwitch
ariaLabel={`Show ${courseName}`}
cypress={`visibility-switch-${course.code}`}
onChange={() => toggleCourseVisibility(module, course)}
visibility={
course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN
}
/>
</TableCell>
)}
{criteriaYears.map(option => (
<TableCell align="center" key={option.year} padding="checkbox">
<Checkbox
checked={criteriaCourses[option.key]?.includes(course.group_id) ?? false}
data-cy={`criteria-checkbox-${option.year}-${course.code}`}
inputProps={{ 'aria-label': `${courseName} required for year ${option.year}` }}
onChange={() => toggleCriteriaYear(course, option)}
size="small"
/>
</TableCell>
))}
</TableRow>
)
})}
</Fragment>
)
})}
</TableBody>
</StyledTable>
<Dialog onClose={() => setModuleToHide(null)} open={moduleToHide !== null}>
<DialogTitle>Hide the whole module?</DialogTitle>
<DialogContent>
<DialogContentText>
{pendingHideModule &&
`Some courses in ${getModuleName(pendingHideModule)} are already hidden. ` +
`Hiding the module hides its remaining ${remainingToHide} ` +
`${remainingToHide === 1 ? 'course' : 'courses'} for all Oodikone users.`}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setModuleToHide(null)}>Cancel</Button>
<Button
data-cy="confirm-hide-module-button"
onClick={() => {
hideModule(pendingHideModule!)
setModuleToHide(null)
}}
variant="contained"
>
Hide module
</Button>
</DialogActions>
</Dialog>
</>
)
}
@@ -13,7 +13,7 @@ const textContent = {
export const ManageCoursesShownInfo = () => {
const { getTextIn } = useLanguage()
return (
<StyledMessage showIcon variant="outlined" severity="warning" sx={{ mb: 2, maxWidth: '100%' }}>
<StyledMessage showIcon variant="outlined" severity="warning" sx={{ maxWidth: '100%' }}>
{getTextIn(textContent)}
</StyledMessage>
)
@@ -0,0 +1,23 @@
import { isMedicalProgramme } from '@/util/studyProgramme'
import { ProgressCriteria } from '@oodikone/shared/types'
export type CriteriaYearKey = keyof ProgressCriteria['courses']
export type CriteriaYearOption = {
key: CriteriaYearKey
label: string
year: number
}
const allCriteriaYears: CriteriaYearOption[] = [
{ key: 'yearOne', label: 'First', year: 1 },
{ key: 'yearTwo', label: 'Second', year: 2 },
{ key: 'yearThree', label: 'Third', year: 3 },
{ key: 'yearFour', label: 'Fourth', year: 4 },
{ key: 'yearFive', label: 'Fifth', year: 5 },
{ key: 'yearSix', label: 'Sixth', year: 6 },
]
/** Academic years that can have progress criteria in the given programme (six for medicine, otherwise three) */
export const getCriteriaYears = (programmeCode: string) =>
isMedicalProgramme(programmeCode) ? allCriteriaYears : allCriteriaYears.slice(0, 3)
@@ -1,93 +1,148 @@
import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import Tab from '@mui/material/Tab'
import Tabs from '@mui/material/Tabs'
import { orderBy } from 'lodash-es'
import { useEffect, useState } from 'react'
import { useMemo, useState } from 'react'
import { 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 [currentTab, setCurrentTab] = useState<DegreeSettingsTab>(initialTab)
const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: degreeProgramme })
const { data: programmes } = useGetProgrammesQuery()
const { getTextIn } = useLanguage()
const { curriculum, curriculumList, setSelectedCurriculum: setCurriculum } = useCurriculum()
const secondProgramme = getCombinedProgramme(degreeProgramme)
const getModules = (courses: ProgrammeCourse[]): Module[] => {
const modules: Record<string, ProgrammeCourse[]> = {}
courses.forEach(course => {
const code = course.parent_code!
modules[code] ??= []
modules[code].push(course)
})
// A combined programme has courses from two curriculums. Show them in one table, first programme's modules first.
const modules = useMemo(
() =>
curriculum
? [
...getModules(curriculum.defaultProgrammeCourses ?? [], degreeProgramme),
...(secondProgramme ? getModules(curriculum.secondProgrammeCourses ?? [], secondProgramme) : []),
]
: [],
[curriculum, degreeProgramme, secondProgramme]
)
return orderBy(
Object.entries(modules).map(([code, courses]) => ({
code,
courses,
order: courses[0].module_order,
})),
module => module.code
)
}
useEffect(() => {
if (!curriculum?.defaultProgrammeCourses?.length) {
return
}
setDefaultProgrammeModules(getModules(curriculum.defaultProgrammeCourses))
if (combinedProgramme) {
setSecondProgrammeModules(getModules(curriculum.secondProgrammeCourses))
}
}, [combinedProgramme, curriculum])
const programmeNames = Object.fromEntries(
[degreeProgramme, secondProgramme]
.filter(code => code !== undefined)
.map(code => {
const programme = programmes?.allProgrammes?.[code] ?? programmes?.filteredProgrammes?.[code]
return [code, programme ? getTextIn(programme.name)! : code]
})
)
if (!curriculum || !criteria) return null
const hasCriteria = isBachelorOrLicentiateProgramme(degreeProgramme)
const renderCourseTable = (tab: DegreeSettingsTab) =>
!!modules.length && (
<Section infoBoxContent={getTextIn(sectionTexts[tab].toolTip) ?? ''} title={sectionTexts[tab].title}>
<DegreeCourseTable
criteria={criteria}
curriculumVersion={curriculum.periodIds}
modules={modules}
programmeNames={programmeNames}
showCriteria={tab === 'criteria'}
showVisibility={tab === 'visibility'}
studyProgramme={degreeProgramme}
/>
</Section>
)
const curriculumSection = (
<CurriculumSection curriculum={curriculum} curriculumList={curriculumList} setCurriculum={setCurriculum} />
)
const visibilityPanel = (
<Stack gap={2}>
{curriculumSection}
{renderCourseTable('visibility')}
</Stack>
)
return (
<Stack gap={2}>
<CurriculumSection curriculum={curriculum} curriculumList={curriculumList} setCurriculum={setCurriculum} />
{isBachelorOrLicentiateProgramme(degreeProgramme) && (
<CreditCriteriaSection criteria={criteria} degreeProgramme={degreeProgramme} />
)}
{curriculum && (!!defaultProgrammeModules.length || !!secondProgrammeModules.length) && (
<Section
title="Course & module criterion and visibility"
infoBoxContent={getTextIn(studyProgrammeToolTips.degreeCoursesTab.programmeCriteriaAndVisibility) ?? ''}
>
{!!defaultProgrammeModules.length && (
<DegreeCourseTable
combinedProgramme=""
criteria={criteria}
curriculumVersion={curriculum.periodIds}
modules={defaultProgrammeModules}
studyProgramme={degreeProgramme}
/>
)}
{!!secondProgrammeModules.length && (
<DegreeCourseTable
combinedProgramme={combinedProgramme}
criteria={criteria}
curriculumVersion={curriculum.periodIds}
modules={secondProgrammeModules}
studyProgramme={degreeProgramme}
/>
)}
</Section>
<ManageCoursesShownInfo />
{hasCriteria ? (
<>
<Tabs
data-cy="degree-settings-tabs"
onChange={(_event, newTab: DegreeSettingsTab) => setCurrentTab(newTab)}
value={currentTab}
>
<Tab data-cy="criteria-tab" label="Progress tab criteria" value="criteria" />
<Tab data-cy="visibility-tab" label="Course & module visibility" value="visibility" />
</Tabs>
{/* Both panels stay mounted so unsaved table state (visibility, expanded modules) survives tab switches */}
<Box data-cy="criteria-panel" hidden={currentTab !== 'criteria'}>
<Stack gap={2}>
{curriculumSection}
<CreditCriteriaSection criteria={criteria} degreeProgramme={degreeProgramme} />
{renderCourseTable('criteria')}
</Stack>
</Box>
<Box data-cy="visibility-panel" hidden={currentTab !== 'visibility'}>
{visibilityPanel}
</Box>
</>
) : (
<Box data-cy="visibility-panel">{visibilityPanel}</Box>
)}
</Stack>
)
@@ -135,10 +135,10 @@ export const StudyProgramme = () => {
tabs.push(
{
key: 'DegreeCoursesTab',
label: 'Degree courses',
label: 'Degree programme settings',
component: (
<CurriculumProvider key="DegreeCoursesTabContent" programmeCode={programmeId} year={new Date().getFullYear()}>
<DegreeCoursesTab combinedProgramme={secondProgrammeId} degreeProgramme={programmeId} />
<DegreeCoursesTab degreeProgramme={programmeId} />
</CurriculumProvider>
),
},
@@ -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>
}
/>
)
@@ -0,0 +1,140 @@
import dayjs from 'dayjs'
import { assert, describe, it } from 'vitest'
import {
getCellText,
getCourseGroups,
hasFailed,
hasPassedDuringAcademicYear,
hasPassedOutsideAcademicYear,
Label,
} from '@/components/PopulationComponents/Students/Table/ProgressTab/util'
import { CreditTypeCode, EnrollmentState, FormattedStudent, ProgressCriteria } from '@oodikone/shared/types'
import { StudentCourse } from '@oodikone/shared/types/studentData'
const COURSES = {
CS001: { id: 'id-CS001', groupId: 'group-CS001', code: 'CS001' },
CS002: { id: 'id-CS002', groupId: 'group-CS002', code: 'CS002' },
CS002A: { id: 'id-CS002A', groupId: 'group-CS002A', code: 'CS002A' },
CS002B: { id: 'id-CS002B', groupId: 'group-CS002B', code: 'CS002B' },
} as const
const idToGroupId = Object.fromEntries(Object.values(COURSES).map(({ id, groupId }) => [id, groupId]))
const criteria = {
allCourseGroups: {
[COURSES.CS001.groupId]: [],
[COURSES.CS002.groupId]: [[COURSES.CS002A.groupId, COURSES.CS002B.groupId]],
},
} as unknown as ProgressCriteria
const createCourse = (
course: keyof typeof COURSES,
date: string,
passed = true,
credittypecode = CreditTypeCode.PASSED
) =>
({
course_id: COURSES[course].id,
course_code: COURSES[course].code,
date: new Date(date),
passed,
credittypecode,
}) as StudentCourse
const createStudent = (courses: StudentCourse[], enrollments: FormattedStudent['enrollments'] = []) =>
({ courses, enrollments, criteriaProgress: {} }) as unknown as FormattedStudent
const label = (course: keyof typeof COURSES): Label => ({
code: COURSES[course].code,
groupId: COURSES[course].groupId,
name: COURSES[course].code,
})
const getGroups = (student: FormattedStudent, course: keyof typeof COURSES) =>
getCourseGroups(COURSES[course].groupId, criteria, student, idToGroupId)
void describe('getCourseGroups', () => {
void it('returns one group per substitution group', () => {
const student = createStudent([createCourse('CS002A', '2024-01-01'), createCourse('CS002B', '2024-02-01')])
const groups = getGroups(student, 'CS002')
assert.equal(groups.length, 1)
assert.deepEqual(
groups[0].map(course => course.course_code),
['CS002B', 'CS002A']
)
})
void it('omits substitution groups the student has not attempted fully', () => {
const student = createStudent([createCourse('CS002A', '2024-01-01')])
assert.deepEqual(getGroups(student, 'CS002'), [])
})
})
void describe('passed checks', () => {
const start = dayjs('2023-08-01')
const end = dayjs('2024-07-31')
void it('does not count a substitution group as passed when one course is failed', () => {
const student = createStudent([createCourse('CS002A', '2024-01-01'), createCourse('CS002B', '2024-02-01', false)])
const groups = getGroups(student, 'CS002')
assert.isFalse(hasPassedOutsideAcademicYear(groups))
assert.isFalse(hasPassedDuringAcademicYear(groups, start, end))
assert.isTrue(hasFailed(groups))
})
void it('counts a course passed after a failed attempt', () => {
const student = createStudent([createCourse('CS001', '2023-10-01', false), createCourse('CS001', '2024-01-01')])
const groups = getGroups(student, 'CS001')
assert.isTrue(hasPassedDuringAcademicYear(groups, start, end))
assert.isFalse(hasFailed(groups))
})
void it('does not count a course passed after the academic year as passed during it', () => {
const student = createStudent([createCourse('CS001', '2024-09-01')])
const groups = getGroups(student, 'CS001')
assert.isFalse(hasPassedDuringAcademicYear(groups, start, end))
assert.isTrue(hasPassedOutsideAcademicYear(groups))
})
})
void describe('getCellText', () => {
const text = (student: FormattedStudent, course: keyof typeof COURSES) =>
getCellText(label(course), criteria, student, 'year1', idToGroupId)
void it('shows the date of the latest passed attempt', () => {
const student = createStudent([createCourse('CS001', '2023-10-01', false), createCourse('CS001', '2024-01-02')])
assert.equal(text(student, 'CS001'), 'Passed 2024-01-02')
})
void it('marks substitutions', () => {
const student = createStudent([createCourse('CS002A', '2024-01-01'), createCourse('CS002B', '2024-02-01')])
assert.equal(text(student, 'CS002'), 'Passed (substituted)')
})
void it('shows the failed date', () => {
const student = createStudent([createCourse('CS001', '2023-10-01', false)])
assert.equal(text(student, 'CS001'), 'Failed 2023-10-01')
})
void it('shows enrollments, ignoring rejected ones', () => {
const enrollment = (state: EnrollmentState, date: string) => ({
course_id: COURSES.CS001.id,
state,
enrollment_date_time: date,
})
const enrolled = createStudent([], [enrollment(EnrollmentState.ENROLLED, '2023-09-01')] as never)
const rejected = createStudent([], [enrollment(EnrollmentState.REJECTED, '2023-09-01')] as never)
assert.equal(text(enrolled, 'CS001'), 'Enrollment 2023-09-01')
assert.equal(text(rejected, 'CS001'), '')
})
})
+7
View File
@@ -2,6 +2,13 @@ import { StudyProgrammeCourse } from '../types'
export type YearStats = StudyProgrammeCourse['years'][number]
// In the object programmes should be {bachelorCode: masterCode}
export const combinedStudyProgrammes = { KH90_001: 'MH90_001' } as const
/** Second programme of a combined programme, eg. the licentiate programme of a combined bachelor programme */
export const getCombinedProgramme = (programmeCode: string): string | undefined =>
combinedStudyProgrammes[programmeCode as keyof typeof combinedStudyProgrammes]
export const createEmptyStats = (isStudyModule = false): YearStats => ({
allCredits: 0,
allStudents: 0,