mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
Compare commits
5
Commits
631b652553
...
d66fea3d7b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d66fea3d7b | ||
|
|
dcfac28fa6 | ||
|
|
09b743fb9b | ||
|
|
36159c7305 | ||
|
|
1c84948f3c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-4
@@ -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>
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+5
-1
@@ -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 />
|
||||
</>
|
||||
|
||||
+249
-455
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import isBetween from 'dayjs/plugin/isBetween'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { isMedicalProgramme } from '@/util/studyProgramme'
|
||||
import { formatDate } from '@/util/timeAndDate'
|
||||
import { CreditTypeCode, EnrollmentState, FormattedStudent, ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { StudentCourse } from '@oodikone/shared/types/studentData'
|
||||
|
||||
dayjsExtend(isBetween)
|
||||
dayjsExtend(isSameOrBefore)
|
||||
dayjsExtend(isSameOrAfter)
|
||||
|
||||
export type IdToGroupId = Record<string, string>
|
||||
|
||||
export type Label = {
|
||||
/** Course code for course labels, otherwise a special value ('Credits', 'Criteria', 'Enrollment') */
|
||||
code: string
|
||||
/** Set for course labels */
|
||||
groupId?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
Return the attainments of the course and of each of its substitution groups, newest first. One entry per group;
|
||||
a group is only included if the student has attainments for all of its courses.
|
||||
*/
|
||||
export const getCourseGroups = (
|
||||
courseGroupId: string,
|
||||
criteria: ProgressCriteria | undefined,
|
||||
student: FormattedStudent,
|
||||
idToGroupId: IdToGroupId
|
||||
): StudentCourse[][] => {
|
||||
const studentGroupIds = new Set(student.courses.map(({ course_id }) => idToGroupId[course_id]))
|
||||
|
||||
return [[courseGroupId]]
|
||||
.concat(criteria?.allCourseGroups[courseGroupId] ?? [])
|
||||
.filter(group => group.every(groupId => studentGroupIds.has(groupId)))
|
||||
.map(group =>
|
||||
student.courses
|
||||
.filter(course => group.includes(idToGroupId[course.course_id]))
|
||||
.toSorted((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
Filter out failed courses that don't "break" a given group eg. remove the only instance of a course code
|
||||
|
||||
NOTE: Does not automatically remove all failed courses!
|
||||
*/
|
||||
const filterFailed = (group: StudentCourse[]) => (course: StudentCourse) =>
|
||||
course.passed || !group.find(otherCourse => course.course_code === otherCourse.course_code && otherCourse.passed)
|
||||
|
||||
const isGroupPassed = (group: StudentCourse[]) => {
|
||||
const passedCredits = group.filter(filterFailed(group))
|
||||
return passedCredits.length > 0 && passedCredits.every(credit => credit.passed)
|
||||
}
|
||||
|
||||
export const hasCreditTransfer = (courseGroups: StudentCourse[][]) =>
|
||||
courseGroups.some(
|
||||
group => isGroupPassed(group) && group.some(course => course.credittypecode === CreditTypeCode.APPROVED)
|
||||
)
|
||||
|
||||
/** Check if any courseGroup has
|
||||
1. all courses passed
|
||||
2. *LATEST* attainment gotten during the wanted academic year
|
||||
3. All attainments gotten before the wanted academic year's end
|
||||
*/
|
||||
export const hasPassedDuringAcademicYear = (courseGroups: StudentCourse[][], start: dayjs.Dayjs, end: dayjs.Dayjs) =>
|
||||
courseGroups.some(group => {
|
||||
if (!isGroupPassed(group)) return false
|
||||
const passedCredits = group.filter(credit => credit.passed)
|
||||
return (
|
||||
passedCredits.some(credit => dayjs(credit.date).isBetween(start, end)) &&
|
||||
passedCredits.every(credit => dayjs(credit.date).isBefore(end))
|
||||
)
|
||||
})
|
||||
|
||||
/** Check if any group has only passed courses */
|
||||
export const hasPassedOutsideAcademicYear = (courseGroups: StudentCourse[][]) => courseGroups.some(isGroupPassed)
|
||||
|
||||
/**
|
||||
Check if there is a failed course that was not removed by the filterFailed eg. there is
|
||||
a failed course that has no passed attainment
|
||||
*/
|
||||
export const hasFailed = (courseGroups: StudentCourse[][]) =>
|
||||
courseGroups.some(group => group.filter(filterFailed(group)).some(course => !course.passed))
|
||||
|
||||
/** Return enrollments only for main courses */
|
||||
export const getEnrollments = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
|
||||
(student.enrollments ?? []).filter(
|
||||
enrollment => enrollment.state === EnrollmentState.ENROLLED && idToGroupId[enrollment.course_id] === courseGroupId
|
||||
)
|
||||
|
||||
export const hasEnrolled = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
|
||||
getEnrollments(student, courseGroupId, idToGroupId).length > 0
|
||||
|
||||
/** Text representation of a cell, used for the tooltip and the Excel export */
|
||||
export const getCellText = (
|
||||
label: Label,
|
||||
criteria: ProgressCriteria | undefined,
|
||||
student: FormattedStudent,
|
||||
year: string,
|
||||
idToGroupId: IdToGroupId
|
||||
) => {
|
||||
if (label.code === 'Credits') {
|
||||
return student.criteriaProgress[year]?.credits ? 'Passed' : ''
|
||||
}
|
||||
if (!label.groupId) return ''
|
||||
|
||||
const courseGroups = getCourseGroups(label.groupId, criteria, student, idToGroupId)
|
||||
|
||||
const passedGroup = courseGroups.find(isGroupPassed)
|
||||
if (passedGroup) {
|
||||
const isMainCourse = passedGroup.every(course => idToGroupId[course.course_id] === label.groupId)
|
||||
const latestPassed = passedGroup.find(course => course.passed)
|
||||
return `Passed ${isMainCourse ? formatDate(latestPassed?.date, DateFormat.ISO_DATE) : '(substituted)'}`
|
||||
}
|
||||
|
||||
if (hasFailed(courseGroups)) {
|
||||
const latestFailedCourse = courseGroups
|
||||
.flatMap(group => group.filter(filterFailed(group)))
|
||||
.find(course => !course.passed)
|
||||
return `Failed ${latestFailedCourse ? formatDate(latestFailedCourse.date, DateFormat.ISO_DATE) : '(substituted)'}`
|
||||
}
|
||||
|
||||
const [enrollment] = getEnrollments(student, label.groupId, idToGroupId)
|
||||
if (enrollment) {
|
||||
return `Enrollment ${formatDate(enrollment.enrollment_date_time, DateFormat.ISO_DATE)}`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const yearKeys = [
|
||||
{ year: 'year1', label: 'yearOne' },
|
||||
{ year: 'year2', label: 'yearTwo' },
|
||||
{ year: 'year3', label: 'yearThree' },
|
||||
{ year: 'year4', label: 'yearFour' },
|
||||
{ year: 'year5', label: 'yearFive' },
|
||||
{ year: 'year6', label: 'yearSix' },
|
||||
] as const
|
||||
|
||||
export const getCriteriaHeaders = (months: number, programme: string) =>
|
||||
yearKeys.slice(0, isMedicalProgramme(programme) ? 6 : 3).map(({ year, label }, index) => ({
|
||||
title: `Academic year ${index + 1}${months < (index + 1) * 12 ? ' (in progress)' : ''}`,
|
||||
year,
|
||||
label,
|
||||
}))
|
||||
+7
-39
@@ -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}>
|
||||
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Menu from '@mui/material/Menu'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useAddProgressCriteriaCourseMutation } from '@/redux/progressCriteria'
|
||||
import { FormatListNumberedIcon } from '@/theme'
|
||||
import { isMedicalProgramme } from '@/util/studyProgramme'
|
||||
import { ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
|
||||
|
||||
export const CriterionLabelSelectButton = ({
|
||||
course,
|
||||
criteria,
|
||||
studyProgramme,
|
||||
}: {
|
||||
course: ProgrammeCourse
|
||||
criteria: ProgressCriteria
|
||||
studyProgramme: string
|
||||
}) => {
|
||||
const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation()
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const selected = Object.keys(criteria.courses).filter(key => criteria.courses[key].includes(course.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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
|
||||
import { VisibilityIcon, VisibilityOffIcon } from '@/theme'
|
||||
|
||||
export const ToggleVisibilityButton = ({ onClick, visible }: { onClick: any; visible: boolean }) => {
|
||||
return (
|
||||
<Tooltip arrow placement="top" title={visible ? 'Set hidden' : 'Set visible'}>
|
||||
<IconButton data-cy="toggle-visibility-button" onClick={onClick}>
|
||||
{visible ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import Chip from '@mui/material/Chip'
|
||||
|
||||
import { CourseVisibility } from '@/types/courseVisibility'
|
||||
|
||||
export const VisibilityChip = ({ visibility }: { visibility: CourseVisibility }) => {
|
||||
return (
|
||||
<Chip
|
||||
label={visibility}
|
||||
size="small"
|
||||
sx={{
|
||||
color: theme => theme.palette.courseVisibility[visibility],
|
||||
borderColor: theme => theme.palette.courseVisibility[visibility],
|
||||
}}
|
||||
variant="outlined"
|
||||
/>
|
||||
)
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Switch from '@mui/material/Switch'
|
||||
|
||||
import { CourseVisibility } from '@/types/courseVisibility'
|
||||
|
||||
const labels: Record<CourseVisibility, string> = {
|
||||
[CourseVisibility.NO_COURSES]: 'No courses',
|
||||
[CourseVisibility.HIDDEN]: 'Hidden',
|
||||
[CourseVisibility.PARTIAL]: 'Partly shown',
|
||||
[CourseVisibility.VISIBLE]: 'Shown',
|
||||
}
|
||||
|
||||
const colors = {
|
||||
[CourseVisibility.NO_COURSES]: 'noCourses',
|
||||
[CourseVisibility.HIDDEN]: 'hidden',
|
||||
[CourseVisibility.PARTIAL]: 'partial',
|
||||
[CourseVisibility.VISIBLE]: 'visible',
|
||||
} as const
|
||||
|
||||
export const VisibilitySwitch = ({
|
||||
ariaLabel,
|
||||
cypress,
|
||||
onChange,
|
||||
visibility,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
cypress: string
|
||||
onChange: () => void
|
||||
visibility: CourseVisibility
|
||||
}) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={visibility === CourseVisibility.VISIBLE || visibility === CourseVisibility.PARTIAL}
|
||||
disabled={visibility === CourseVisibility.NO_COURSES}
|
||||
onChange={onChange}
|
||||
size="small"
|
||||
inputProps={{ 'aria-label': ariaLabel }}
|
||||
/>
|
||||
}
|
||||
data-cy={cypress}
|
||||
label={labels[visibility]}
|
||||
onClick={event => event.stopPropagation()}
|
||||
slotProps={{
|
||||
typography: {
|
||||
variant: 'body2',
|
||||
sx: { color: theme => theme.palette.courseVisibility[colors[visibility]], whiteSpace: 'nowrap' },
|
||||
},
|
||||
}}
|
||||
sx={{ mr: 0 }}
|
||||
/>
|
||||
)
|
||||
+297
-178
@@ -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'), '')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user