[Degree programme settings] separate criteria from visibility

This commit is contained in:
joonas-a
2026-10-12 15:20:23 +03:00
parent 36159c7305
commit 09b743fb9b
21 changed files with 733 additions and 390 deletions
@@ -191,28 +191,32 @@ However this is optional and you can create a new tag without selecting year.
degreeCoursesTab: {
creditCriteria: {
en: `The criteria set here will affect the Class Statistics → Students tab → Progress tab view.
en: `The targets set here will affect the Class Statistics → Students tab → Progress tab view.
Credit criteria is computed as follows: for the first academic year, the credits are counted
if they are completed during the first 12 months. For the second year, completions
within the first 24 months. Third year, the first 36 months, and so on.
`,
fi: `Tässä asetettu "criteria" vaikuttaa Class Statistics → Students tab → Progress tab näkymään.
fi: `Tässä asetetut tavoitteet vaikuttavat Class Statistics → Students tab → Progress tab näkymään.
Laskenta toimii seuraavasti: ensimmäisen akateemisen vuoden opintopisteet lasketaan, jos ne ovat suoritettu ensimmäisen 12 kuukauden ajalla. Toisen vuoden opintopisteet, jos ne ovat suoritettu ensimmäisen 24 kuukauden aikana. Kolmas, ensimmäiset 36kk, ja niin edelleen.
`,
},
programmeCriteriaAndVisibility: {
en: `Click the arrow next to the module to view courses belonging to the module in the curriculum. Setting a criterion label for a course will affect which academic year the course is placed under in Class Statistics → Students → Progress tab.
requiredCourses: {
en: `Click a module to see its courses in the selected curriculum.
Courses' visibilities can also be toggled here; hiding a course here will additionally hide it in the Courses of Class view and Students → Courses tab.
Tick a year to make the course a required course for that academic year in Class Statistics → Students → Progress tab. A course can be required for several years. The numbers on a module row show how many of its courses are required for each year.`,
fi: `Klikkaa opintokokonaisuutta nähdäksesi sen kurssit valitussa opetussuunnitelmassa.
Note: If hiding a module, all courses belonging to the module will also be hidden.`,
fi: `Klikkaa nuolta opintokokonaisuuden nimen vieressä näyttääksesi opetussuunitelmassa opintokokonaisuuden alle sijoitetut kurssit. Kursseille voi asettaa "Criterion label":eita, jotka vaikuttavat minkä akateemisen vuoden alla kurssi näytetään Class Statistics → Students → Progress tab näkymässä.
Valitse vuosi, jolloin kurssi on tarkoitus suorittaa. Kurssi näytetään kyseisen akateemisen vuoden alla Class Statistics → Students → Progress tab -näkymässä. Kurssi voi olla pakollinen useammalle vuodelle. Opintokokonaisuuden rivillä olevat luvut kertovat, kuinka moni sen kursseista on pakollinen kunakin vuonna. Huom. kurssi voi kuulua useaan opintokokonaisuuteen.`,
},
courseVisibility: {
en: `Click a module to see its courses in the selected curriculum.
Kurssien näkyvyyttä voidaan myös muuttaa tällä sivulla. Kurssin piilottaminen piilottaa sen myös Courses of Class ja Students → Courses tab näkymissä.
Hiding a course also hides it in the Courses of Class view and Students → Courses tab for all users. Hiding a module hides all of its courses.`,
fi: `Klikkaa opintokokonaisuutta nähdäksesi sen kurssit valitussa opetussuunnitelmassa.
Huom: Opintokokonaisuuden piilottaminen piilottaa myös kaikki sen alla olevat kurssit.`,
Kurssin piilottaminen piilottaa sen kaikilta käyttäjiltä myös Courses of Class ja Students → Courses tab -näkymissä. Opintokokonaisuuden piilottaminen piilottaa kaikki sen kurssit.`,
},
},
// Not used
@@ -5,19 +5,20 @@ import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import { useState } from 'react'
import { DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
import { DegreeSettingsTab, DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
import { VisibilityIcon } from '@/theme'
import { ManageCoursesShownInfo } from '@/pages/StudyProgramme/DegreeCoursesTab/InfoBox'
const defaultText = 'Manage course visibility'
const defaultIcon = <VisibilityIcon />
export const FilterDegreeCoursesModal = ({
degreeProgramme,
initialTab,
text,
icon,
}: {
degreeProgramme: string
initialTab: DegreeSettingsTab
text?: string
icon?: React.ReactNode
}) => {
@@ -31,8 +32,7 @@ export const FilterDegreeCoursesModal = ({
<Dialog maxWidth="md" onClose={() => setModalOpen(false)} open={modalOpen}>
<DialogTitle>Degree programme settings</DialogTitle>
<DialogContent>
<ManageCoursesShownInfo />
<DegreeCoursesTab combinedProgramme="" degreeProgramme={degreeProgramme} />
<DegreeCoursesTab combinedProgramme="" degreeProgramme={degreeProgramme} initialTab={initialTab} />
<Button color="primary" onClick={() => setModalOpen(false)} style={{ marginTop: '10px' }} variant="contained">
Close
</Button>
@@ -36,7 +36,11 @@ export const PopulationCourses = ({
<>
<Stack direction="row" spacing={2} sx={{ my: 1, justifyContent: 'space-between' }}>
{query.programme && !onlyIamRights ? (
<FilterDegreeCoursesModal degreeProgramme={query.programme} text="Manage course / module visibility" />
<FilterDegreeCoursesModal
degreeProgramme={query.programme}
initialTab="visibility"
text="Manage course / module visibility"
/>
) : null}
<InfoBox content={tooltipText} />
</Stack>
@@ -393,7 +393,7 @@ export const CoursesTabContainer = ({ students, courses, idToGroupIdMap, program
toolbarContent={
<>
<OodiTableExcelExport data={exportData} exportColumnKeys={keysForExport} />
<FilterDegreeCoursesModal degreeProgramme={programme} />
<FilterDegreeCoursesModal degreeProgramme={programme} initialTab="visibility" />
<StudentNameVisibilityToggle />
<IncludeSubstitutionsToggle
includeSubstitutions={includeSubstitutions}
@@ -193,7 +193,11 @@ export const ModulesTab = ({
toolbarContent={
<>
<OodiTableExcelExport data={exportData} exportColumnKeys={accessorKeys} />
<FilterDegreeCoursesModal degreeProgramme={programme} text="Manage module visibility" />
<FilterDegreeCoursesModal
degreeProgramme={programme}
initialTab="visibility"
text="Manage module visibility"
/>
<StudentNameVisibilityToggle />
<HiddenCourseAlert ooditable modules />
</>
@@ -413,7 +413,12 @@ export const ProgressTable = ({
toolbarContent={
<>
<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />
<FilterDegreeCoursesModal degreeProgramme={programme} text="Modify criteria" icon={<EditIcon />} />
<FilterDegreeCoursesModal
degreeProgramme={programme}
initialTab="criteria"
icon={<EditIcon />}
text="Modify criteria"
/>
<StudentNameVisibilityToggle />
</>
}
@@ -12,7 +12,7 @@ import { Section } from '@/components/Section'
import { useStatusNotification } from '@/components/StatusNotification/Context'
import { useAddProgressCriteriaCreditsMutation } from '@/redux/progressCriteria'
import { SaveIcon } from '@/theme'
import { isMedicalProgramme } from '@/util/studyProgramme'
import { getCriteriaYears } from '@/pages/StudyProgramme/DegreeCoursesTab/criteriaYears'
import { ProgressCriteria } from '@oodikone/shared/types'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
@@ -68,43 +68,11 @@ export const CreditCriteriaSection = ({
}
}, [isError])
const inputs = [
{
key: 'year1',
label: 'First',
previousValue: criteria.credits.yearOne,
},
{
key: 'year2',
label: 'Second',
previousValue: criteria.credits.yearTwo,
},
{
key: 'year3',
label: 'Third',
previousValue: criteria.credits.yearThree,
},
]
if (isMedicalProgramme(degreeProgramme)) {
inputs.push(
{
key: 'year4',
label: 'Fourth',
previousValue: criteria.credits.yearFour,
},
{
key: 'year5',
label: 'Fifth',
previousValue: criteria.credits.yearFive,
},
{
key: 'year6',
label: 'Sixth',
previousValue: criteria.credits.yearSix,
}
)
}
const inputs = getCriteriaYears(degreeProgramme).map(({ key, label, year }) => ({
key: `year${year}`,
label,
previousValue: criteria.credits[key],
}))
const disabled = Object.values(creditLimits).every(value => value === null || value < 0)
@@ -112,7 +80,7 @@ export const CreditCriteriaSection = ({
<Section
cypress="credit-criteria"
infoBoxContent={getTextIn(studyProgrammeToolTips.degreeCoursesTab.creditCriteria) ?? ''}
title="Credit criteria"
title="Credit targets"
>
<Stack gap={2}>
<Grid container spacing={1}>
@@ -1,114 +0,0 @@
import Checkbox from '@mui/material/Checkbox'
import IconButton from '@mui/material/IconButton'
import ListItemText from '@mui/material/ListItemText'
import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem'
import Tooltip from '@mui/material/Tooltip'
import { useEffect, useState } from 'react'
import { useAddProgressCriteriaCourseMutation } from '@/redux/progressCriteria'
import { FormatListNumberedIcon } from '@/theme'
import { isMedicalProgramme } from '@/util/studyProgramme'
import { ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
export const CriterionLabelSelectButton = ({
course,
criteria,
studyProgramme,
}: {
course: ProgrammeCourse
criteria: ProgressCriteria
studyProgramme: string
}) => {
const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation()
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const [selectedOptions, setSelectedOptions] = useState<string[]>([])
useEffect(() => {
const selected = Object.keys(criteria.courses).filter(key => criteria.courses[key].includes(course.group_id))
setSelectedOptions(selected)
}, [criteria, course.group_id])
const options = [
{
key: 'yearOne',
label: 'First',
year: 1,
},
{
key: 'yearTwo',
label: 'Second',
year: 2,
},
{
key: 'yearThree',
label: 'Third',
year: 3,
},
]
if (isMedicalProgramme(studyProgramme)) {
options.push(
{
key: 'yearFour',
label: 'Fourth',
year: 4,
},
{
key: 'yearFive',
year: 5,
label: 'Fifth',
},
{
key: 'yearSix',
year: 6,
label: 'Sixth',
}
)
}
const handleCheckboxChange = (key: string, year: number) => {
let courseGroupIds: string[]
if (selectedOptions.includes(key)) {
courseGroupIds = criteria.courses[key]?.filter((groupId: string) => groupId !== course.group_id)
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
} else {
courseGroupIds = criteria.courses ? [...criteria.courses[key], course.group_id] : [course.group_id]
}
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
setSelectedOptions(prevSelected =>
prevSelected.includes(key) ? prevSelected.filter(item => item !== key) : [...prevSelected, key]
)
}
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
return (
<>
<Tooltip arrow placement="top" title={'Modify criterion labels'}>
<IconButton data-cy="toggle-visibility-button" onClick={handleMenuOpen}>
<FormatListNumberedIcon />
</IconButton>
</Tooltip>
<Menu anchorEl={anchorEl} onClose={handleMenuClose} open={Boolean(anchorEl)}>
{options.map(option => (
<MenuItem key={option.key} onClick={() => handleCheckboxChange(option.key, option.year)}>
<Checkbox checked={selectedOptions.includes(option.key)} />
<ListItemText primary={`${option.label} year`} />
</MenuItem>
))}
</Menu>
</>
)
}
@@ -1,14 +0,0 @@
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { VisibilityIcon, VisibilityOffIcon } from '@/theme'
export const ToggleVisibilityButton = ({ onClick, visible }: { onClick: any; visible: boolean }) => {
return (
<Tooltip arrow placement="top" title={visible ? 'Set hidden' : 'Set visible'}>
<IconButton data-cy="toggle-visibility-button" onClick={onClick}>
{visible ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</Tooltip>
)
}
@@ -1,17 +0,0 @@
import Chip from '@mui/material/Chip'
import { CourseVisibility } from '@/types/courseVisibility'
export const VisibilityChip = ({ visibility }: { visibility: CourseVisibility }) => {
return (
<Chip
label={visibility}
size="small"
sx={{
color: theme => theme.palette.courseVisibility[visibility],
borderColor: theme => theme.palette.courseVisibility[visibility],
}}
variant="outlined"
/>
)
}
@@ -0,0 +1,52 @@
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
import { CourseVisibility } from '@/types/courseVisibility'
const labels: Record<CourseVisibility, string> = {
[CourseVisibility.NO_COURSES]: 'No courses',
[CourseVisibility.HIDDEN]: 'Hidden',
[CourseVisibility.PARTIAL]: 'Partly shown',
[CourseVisibility.VISIBLE]: 'Shown',
}
const colors = {
[CourseVisibility.NO_COURSES]: 'noCourses',
[CourseVisibility.HIDDEN]: 'hidden',
[CourseVisibility.PARTIAL]: 'partial',
[CourseVisibility.VISIBLE]: 'visible',
} as const
export const VisibilitySwitch = ({
ariaLabel,
cypress,
onChange,
visibility,
}: {
ariaLabel: string
cypress: string
onChange: () => void
visibility: CourseVisibility
}) => (
<FormControlLabel
control={
<Switch
checked={visibility === CourseVisibility.VISIBLE || visibility === CourseVisibility.PARTIAL}
disabled={visibility === CourseVisibility.NO_COURSES}
onChange={onChange}
size="small"
inputProps={{ 'aria-label': ariaLabel }}
/>
}
data-cy={cypress}
label={labels[visibility]}
onClick={event => event.stopPropagation()}
slotProps={{
typography: {
variant: 'body2',
sx: { color: theme => theme.palette.courseVisibility[colors[visibility]], whiteSpace: 'nowrap' },
},
}}
sx={{ mr: 0 }}
/>
)
@@ -1,7 +1,12 @@
import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogContentText from '@mui/material/DialogContentText'
import DialogTitle from '@mui/material/DialogTitle'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
@@ -11,136 +16,163 @@ 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 const DegreeCourseTable = ({
combinedProgramme,
criteria,
curriculumVersion,
modules: initialModules,
showCriteria,
showVisibility,
studyProgramme,
}: {
combinedProgramme: string
criteria: ProgressCriteria
curriculumVersion: string[]
modules: Module[]
showCriteria: boolean
showVisibility: boolean
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 exclusionProgramme = combinedProgramme === '' ? studyProgramme : combinedProgramme
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 setModuleVisibility = (moduleCode: string, newVisibility: boolean) => {
const getModuleName = (moduleCode: string) => {
const firstCourse = getModule(moduleCode).courses[0]
return firstCourse?.parent_name ? getTextIn(firstCourse.parent_name)! : moduleCode
}
const setCoursesVisibility = (courseCodes: 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 => ({
setModules(prevModules =>
prevModules.map(module => ({
...module,
courses: module.courses.map(course => {
if (courseCodes.includes(course.code)) {
return {
...course,
visible: { ...course.visible, visibility: newVisibility },
}
}
return course
}),
courses: module.courses.map(course =>
courseCodes.includes(course.code)
? { ...course, visible: { ...course.visible, visibility: newVisibility } }
: 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 changeVisibility = (courses: ProgrammeCourse[], newVisibility: boolean, successMessage: string) => {
const request = { courseGroupIds: courses.map(course => course.group_id), curriculumVersion: version }
const mutation = newVisibility
? removeExclusion({ ...request, programmeCode: exclusionProgramme })
: setExclusion({ ...request, programmeCode: exclusionProgramme })
setCoursesVisibility(
courses.map(course => course.code),
newVisibility
)
mutation
.unwrap()
.then(() => setStatusNotification(`${successMessage} ${reloadNote}`, 'success'))
.catch(() => {
setCoursesVisibility(
courses.map(course => course.code),
!newVisibility
)
setStatusNotification('Failed to change visibility', 'error')
})
}
const areModuleCoursesVisible = (moduleCode: string) => {
if (!moduleCoursesVisible[version]) {
setModuleCoursesVisible(prev => ({ ...prev, [version]: {} }))
return false
const toggleCourseVisibility = (course: ProgrammeCourse) => {
const newVisibility = !course.visible.visibility
const name = getTextIn(course.name)
changeVisibility([course], newVisibility, `${name} is now ${newVisibility ? 'shown' : 'hidden'}.`)
}
const toggleModuleVisibility = (moduleCode: string) => {
const { courses } = getModule(moduleCode)
const visibility = getVisibility(moduleCode)
const name = getModuleName(moduleCode)
if (visibility === CourseVisibility.HIDDEN) {
changeVisibility(courses, true, `All courses in ${name} are now shown.`)
return
}
return moduleCoursesVisible[version][moduleCode]
if (visibility === CourseVisibility.PARTIAL) {
setModuleToHide(moduleCode)
return
}
hideModule(moduleCode)
}
const excludeOne = (course: ProgrammeCourse) => {
void setExclusion({
courseGroupIds: [course.group_id],
curriculumVersion: version,
programmeCode: combinedProgramme === '' ? studyProgramme : combinedProgramme,
})
setCourseVisibility(course.code, false)
const hideModule = (moduleCode: string) => {
const visibleCourses = getModule(moduleCode).courses.filter(course => course.visible.visibility)
changeVisibility(visibleCourses, false, `All courses in ${getModuleName(moduleCode)} are now 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 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 removeOne = (course: ProgrammeCourse) => {
void removeExclusion({
courseGroupIds: [course.group_id],
curriculumVersion: version,
programmeCode: studyProgramme,
})
setCourseVisibility(course.code, true)
}
const isModuleExpanded = (moduleCode: string) => expandedModules[version]?.[moduleCode] ?? false
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 toggleVisible = (moduleCode: string) => {
const newState = !areModuleCoursesVisible(moduleCode)
setModuleCoursesVisible({
...moduleCoursesVisible,
[version]: { ...moduleCoursesVisible[version], [moduleCode]: newState },
const toggleExpanded = (moduleCode: string) => {
setExpandedModules({
...expandedModules,
[version]: { ...expandedModules[version], [moduleCode]: !isModuleExpanded(moduleCode) },
})
}
const getVisibility = (moduleCode: string) => {
const module = getModule(moduleCode)
if (!module.courses) {
if (!module.courses?.length) {
return CourseVisibility.NO_COURSES
}
if (module.courses.every(course => course.visible.visibility)) {
@@ -152,104 +184,151 @@ 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 remainingToHide = moduleToHide
? getModule(moduleToHide).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(({ code: moduleCode, courses }) => {
const moduleName = getModuleName(moduleCode)
const expanded = isModuleExpanded(moduleCode)
return (
<Fragment key={`${moduleCode}-fragment`}>
<TableRow hover onClick={() => toggleExpanded(moduleCode)} 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(moduleCode)}
visibility={getVisibility(moduleCode)}
/>
</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={`${moduleCode}/${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(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>
{moduleToHide &&
`Some courses in ${getModuleName(moduleToHide)} 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(moduleToHide!)
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,4 +1,7 @@
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'
@@ -7,6 +10,7 @@ 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 { ManageCoursesShownInfo } from '@/pages/StudyProgramme/DegreeCoursesTab/InfoBox'
import { useGetProgressCriteriaQuery } from '@/redux/progressCriteria'
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
import { Module, ProgrammeCourse } from '@oodikone/shared/types'
@@ -14,13 +18,29 @@ import { Section } from '@/components/Section'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { studyProgrammeToolTips } from '@/common/InfoToolTips'
export type DegreeSettingsTab = 'criteria' | 'visibility'
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 [currentTab, setCurrentTab] = useState<DegreeSettingsTab>(initialTab)
const [defaultProgrammeModules, setDefaultProgrammeModules] = useState<Module[]>([])
const [secondProgrammeModules, setSecondProgrammeModules] = useState<Module[]>([])
const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: degreeProgramme })
@@ -58,23 +78,21 @@ export const DegreeCoursesTab = ({
if (!curriculum || !criteria) return null
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) ?? ''}
>
const hasCriteria = isBachelorOrLicentiateProgramme(degreeProgramme)
const hasModules = !!defaultProgrammeModules.length || !!secondProgrammeModules.length
const renderCourseTables = (tab: DegreeSettingsTab) =>
hasModules && (
<Section infoBoxContent={getTextIn(sectionTexts[tab].toolTip) ?? ''} title={sectionTexts[tab].title}>
<Stack gap={2}>
{!!defaultProgrammeModules.length && (
<DegreeCourseTable
combinedProgramme=""
criteria={criteria}
curriculumVersion={curriculum.periodIds}
modules={defaultProgrammeModules}
showCriteria={tab === 'criteria'}
showVisibility={tab === 'visibility'}
studyProgramme={degreeProgramme}
/>
)}
@@ -84,10 +102,53 @@ export const DegreeCoursesTab = ({
criteria={criteria}
curriculumVersion={curriculum.periodIds}
modules={secondProgrammeModules}
showCriteria={tab === 'criteria'}
showVisibility={tab === 'visibility'}
studyProgramme={degreeProgramme}
/>
)}
</Section>
</Stack>
</Section>
)
const curriculumSection = (
<CurriculumSection curriculum={curriculum} curriculumList={curriculumList} setCurriculum={setCurriculum} />
)
const visibilityPanel = (
<Stack gap={2}>
{curriculumSection}
{renderCourseTables('visibility')}
</Stack>
)
return (
<Stack gap={2}>
<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} />
{renderCourseTables('criteria')}
</Stack>
</Box>
<Box data-cy="visibility-panel" hidden={currentTab !== 'visibility'}>
{visibilityPanel}
</Box>
</>
) : (
<Box data-cy="visibility-panel">{visibilityPanel}</Box>
)}
</Stack>
)
@@ -135,7 +135,7 @@ 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} />
@@ -0,0 +1,105 @@
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()
})
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,66 @@
import { DegreeCourseTable } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable'
import { Module, ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
import { ReduxWrapper } from '../../ReduxWrapper'
const course = (code: string, name: string, visible = true) =>
({
code,
group_id: `group-${code}`,
module_order: 0,
name: { fi: name, en: name },
parent_code: 'MOD1',
parent_name: { fi: 'Basic studies', en: 'Basic studies' },
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.
*/
export const DegreeCourseTableWrapper = ({
partlyHidden = false,
showCriteria = true,
showVisibility = true,
studyProgramme = 'KH50_001',
}: {
partlyHidden?: boolean
showCriteria?: boolean
showVisibility?: boolean
studyProgramme?: string
}) => {
const modules: Module[] = [
{
code: 'MOD1',
courses: [course('MAT101', 'Calculus I'), course('MAT102', 'Linear algebra', !partlyHidden)],
order: 0,
},
]
return (
<ReduxWrapper
component={
<DegreeCourseTable
combinedProgramme=""
criteria={criteria}
curriculumVersion={['period-1']}
modules={modules}
showCriteria={showCriteria}
showVisibility={showVisibility}
studyProgramme={studyProgramme}
/>
}
/>
)
}
@@ -0,0 +1,89 @@
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/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 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 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)
})
})
@@ -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 combinedProgramme="" degreeProgramme={programme} initialTab={initialTab} />
</CurriculumProvider>
}
/>
)