[Progress] combined programme criteria fix

thanks claude opus 5.5 high
This commit is contained in:
joonas-a
2026-10-12 15:20:23 +03:00
parent 09b743fb9b
commit dcfac28fa6
10 changed files with 268 additions and 140 deletions
@@ -193,8 +193,7 @@ export const computePercentiles = (
return values return values
} }
// In the object programmes should be {bachelorCode: masterCode} export { combinedStudyProgrammes } from '@oodikone/shared/util/studyProgramme'
export const combinedStudyProgrammes = { KH90_001: 'MH90_001' } as const
// There are 9 course_unit_types // There are 9 course_unit_types
// 1. urn:code:course-unit-type:regular // 1. urn:code:course-unit-type:regular
@@ -32,7 +32,7 @@ export const FilterDegreeCoursesModal = ({
<Dialog maxWidth="md" onClose={() => setModalOpen(false)} open={modalOpen}> <Dialog maxWidth="md" onClose={() => setModalOpen(false)} open={modalOpen}>
<DialogTitle>Degree programme settings</DialogTitle> <DialogTitle>Degree programme settings</DialogTitle>
<DialogContent> <DialogContent>
<DegreeCoursesTab combinedProgramme="" degreeProgramme={degreeProgramme} initialTab={initialTab} /> <DegreeCoursesTab degreeProgramme={degreeProgramme} initialTab={initialTab} />
<Button color="primary" onClick={() => setModalOpen(false)} style={{ marginTop: '10px' }} variant="contained"> <Button color="primary" onClick={() => setModalOpen(false)} style={{ marginTop: '10px' }} variant="contained">
Close Close
</Button> </Button>
@@ -28,21 +28,31 @@ import { StyledTable } from '@/components/common/StyledTable'
const reloadNote = 'Reload the page to see the change in other views.' 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 = ({ export const DegreeCourseTable = ({
combinedProgramme,
criteria, criteria,
curriculumVersion, curriculumVersion,
modules: initialModules, modules: initialModules,
programmeNames,
showCriteria, showCriteria,
showVisibility, showVisibility,
studyProgramme, studyProgramme,
}: { }: {
combinedProgramme: string
criteria: ProgressCriteria criteria: ProgressCriteria
curriculumVersion: string[] 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 showCriteria: boolean
showVisibility: boolean showVisibility: boolean
/** Programme whose progress criteria are edited */
studyProgramme: string studyProgramme: string
}) => { }) => {
const [expandedModules, setExpandedModules] = useState<Record<string, Record<string, boolean>>>({}) const [expandedModules, setExpandedModules] = useState<Record<string, Record<string, boolean>>>({})
@@ -60,7 +70,6 @@ export const DegreeCourseTable = ({
const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation() const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation()
const version = curriculumVersion.join(',') const version = curriculumVersion.join(',')
const exclusionProgramme = combinedProgramme === '' ? studyProgramme : combinedProgramme
const criteriaYears = showCriteria ? getCriteriaYears(studyProgramme) : [] const criteriaYears = showCriteria ? getCriteriaYears(studyProgramme) : []
const criteriaCourses = pendingCriteria?.source === criteria ? pendingCriteria.courses : criteria.courses const criteriaCourses = pendingCriteria?.source === criteria ? pendingCriteria.courses : criteria.courses
@@ -68,74 +77,93 @@ export const DegreeCourseTable = ({
setModules(initialModules) setModules(initialModules)
}, [initialModules]) }, [initialModules])
const getModule = (moduleCode: string) => modules.find(module => module.code === moduleCode)! const getModule = (moduleKey: string) => modules.find(module => getModuleKey(module) === moduleKey)!
const getModuleName = (moduleCode: string) => { const getModuleName = (module: DegreeModule) => {
const firstCourse = getModule(moduleCode).courses[0] const firstCourse = module.courses[0]
return firstCourse?.parent_name ? getTextIn(firstCourse.parent_name)! : moduleCode return firstCourse?.parent_name ? getTextIn(firstCourse.parent_name)! : module.code
} }
const setCoursesVisibility = (courseCodes: string[], newVisibility: boolean) => { const setCoursesVisibility = (programmeCode: string, courseCodes: string[], newVisibility: boolean) => {
// Same course can be in many modules, so change them all // Same course can be in many modules of the programme, so change them all
setModules(prevModules => setModules(prevModules =>
prevModules.map(module => ({ prevModules.map(module =>
...module, module.programmeCode !== programmeCode
courses: module.courses.map(course => ? module
courseCodes.includes(course.code) : {
? { ...course, visible: { ...course.visible, visibility: newVisibility } } ...module,
: course courses: module.courses.map(course =>
), courseCodes.includes(course.code)
})) ? { ...course, visible: { ...course.visible, visibility: newVisibility } }
: course
),
}
)
) )
} }
const changeVisibility = (courses: ProgrammeCourse[], newVisibility: boolean, successMessage: string) => { const changeVisibility = (
const request = { courseGroupIds: courses.map(course => course.group_id), curriculumVersion: version } programmeCode: string,
const mutation = newVisibility courses: ProgrammeCourse[],
? removeExclusion({ ...request, programmeCode: exclusionProgramme }) newVisibility: boolean,
: setExclusion({ ...request, programmeCode: exclusionProgramme }) successMessage: string
setCoursesVisibility( ) => {
courses.map(course => course.code), const request = {
newVisibility courseGroupIds: courses.map(course => course.group_id),
) curriculumVersion: version,
programmeCode,
}
const mutation = newVisibility ? removeExclusion(request) : setExclusion(request)
const courseCodes = courses.map(course => course.code)
setCoursesVisibility(programmeCode, courseCodes, newVisibility)
mutation mutation
.unwrap() .unwrap()
.then(() => setStatusNotification(`${successMessage} ${reloadNote}`, 'success')) .then(() => setStatusNotification(`${successMessage} ${reloadNote}`, 'success'))
.catch(() => { .catch(() => {
setCoursesVisibility( setCoursesVisibility(programmeCode, courseCodes, !newVisibility)
courses.map(course => course.code),
!newVisibility
)
setStatusNotification('Failed to change visibility', 'error') setStatusNotification('Failed to change visibility', 'error')
}) })
} }
const toggleCourseVisibility = (course: ProgrammeCourse) => { const toggleCourseVisibility = (module: DegreeModule, course: ProgrammeCourse) => {
const newVisibility = !course.visible.visibility const newVisibility = !course.visible.visibility
const name = getTextIn(course.name) const name = getTextIn(course.name)
changeVisibility([course], newVisibility, `${name} is now ${newVisibility ? 'shown' : 'hidden'}.`) changeVisibility(
module.programmeCode,
[course],
newVisibility,
`${name} is now ${newVisibility ? 'shown' : 'hidden'}.`
)
} }
const toggleModuleVisibility = (moduleCode: string) => { const toggleModuleVisibility = (module: DegreeModule) => {
const { courses } = getModule(moduleCode) const visibility = getVisibility(module)
const visibility = getVisibility(moduleCode)
const name = getModuleName(moduleCode)
if (visibility === CourseVisibility.HIDDEN) { if (visibility === CourseVisibility.HIDDEN) {
changeVisibility(courses, true, `All courses in ${name} are now shown.`) changeVisibility(
module.programmeCode,
module.courses,
true,
`All courses in ${getModuleName(module)} are now shown.`
)
return return
} }
if (visibility === CourseVisibility.PARTIAL) { if (visibility === CourseVisibility.PARTIAL) {
setModuleToHide(moduleCode) setModuleToHide(getModuleKey(module))
return return
} }
hideModule(moduleCode) hideModule(module)
} }
const hideModule = (moduleCode: string) => { const hideModule = (module: DegreeModule) => {
const visibleCourses = getModule(moduleCode).courses.filter(course => course.visible.visibility) const visibleCourses = module.courses.filter(course => course.visible.visibility)
changeVisibility(visibleCourses, false, `All courses in ${getModuleName(moduleCode)} are now hidden.`) changeVisibility(
module.programmeCode,
visibleCourses,
false,
`All courses in ${getModuleName(module)} are now hidden.`
)
} }
const toggleCriteriaYear = (course: ProgrammeCourse, { key, year }: CriteriaYearOption) => { const toggleCriteriaYear = (course: ProgrammeCourse, { key, year }: CriteriaYearOption) => {
@@ -161,17 +189,16 @@ export const DegreeCourseTable = ({
}) })
} }
const isModuleExpanded = (moduleCode: string) => expandedModules[version]?.[moduleCode] ?? false const isModuleExpanded = (moduleKey: string) => expandedModules[version]?.[moduleKey] ?? false
const toggleExpanded = (moduleCode: string) => { const toggleExpanded = (moduleKey: string) => {
setExpandedModules({ setExpandedModules({
...expandedModules, ...expandedModules,
[version]: { ...expandedModules[version], [moduleCode]: !isModuleExpanded(moduleCode) }, [version]: { ...expandedModules[version], [moduleKey]: !isModuleExpanded(moduleKey) },
}) })
} }
const getVisibility = (moduleCode: string) => { const getVisibility = (module: DegreeModule) => {
const module = getModule(moduleCode)
if (!module.courses?.length) { if (!module.courses?.length) {
return CourseVisibility.NO_COURSES return CourseVisibility.NO_COURSES
} }
@@ -188,9 +215,10 @@ export const DegreeCourseTable = ({
courses.filter(course => criteriaCourses[key]?.includes(course.group_id)).length courses.filter(course => criteriaCourses[key]?.includes(course.group_id)).length
const headerRowSpan = showCriteria ? 2 : 1 const headerRowSpan = showCriteria ? 2 : 1
const remainingToHide = moduleToHide const columnCount = 2 + Number(showVisibility) + criteriaYears.length
? getModule(moduleToHide).courses.filter(course => course.visible.visibility).length const showProgrammeRows = new Set(modules.map(module => module.programmeCode)).size > 1
: 0 const pendingHideModule = moduleToHide ? getModule(moduleToHide) : null
const remainingToHide = pendingHideModule?.courses.filter(course => course.visible.visibility).length ?? 0
return ( return (
<> <>
@@ -217,12 +245,24 @@ export const DegreeCourseTable = ({
)} )}
</TableHead> </TableHead>
<TableBody> <TableBody>
{modules.map(({ code: moduleCode, courses }) => { {modules.map((module, index) => {
const moduleName = getModuleName(moduleCode) const { code: moduleCode, courses, programmeCode } = module
const expanded = isModuleExpanded(moduleCode) const moduleKey = getModuleKey(module)
const moduleName = getModuleName(module)
const expanded = isModuleExpanded(moduleKey)
const startsProgramme = index === 0 || modules[index - 1].programmeCode !== programmeCode
return ( return (
<Fragment key={`${moduleCode}-fragment`}> <Fragment key={moduleKey}>
<TableRow hover onClick={() => toggleExpanded(moduleCode)} sx={{ cursor: 'pointer' }}> {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> <TableCell>
<Box alignItems="center" display="flex" justifyContent="left"> <Box alignItems="center" display="flex" justifyContent="left">
<IconButton <IconButton
@@ -243,8 +283,8 @@ export const DegreeCourseTable = ({
<VisibilitySwitch <VisibilitySwitch
ariaLabel={`Show all courses of ${moduleName}`} ariaLabel={`Show all courses of ${moduleName}`}
cypress={`visibility-switch-${moduleCode}`} cypress={`visibility-switch-${moduleCode}`}
onChange={() => toggleModuleVisibility(moduleCode)} onChange={() => toggleModuleVisibility(module)}
visibility={getVisibility(moduleCode)} visibility={getVisibility(module)}
/> />
</TableCell> </TableCell>
)} )}
@@ -271,7 +311,7 @@ export const DegreeCourseTable = ({
.map(course => { .map(course => {
const courseName = getTextIn(course.name) const courseName = getTextIn(course.name)
return ( return (
<TableRow key={`${moduleCode}/${course.code}`} sx={{ bgcolor: 'grey.50' }}> <TableRow key={`${moduleKey}/${course.code}`} sx={{ bgcolor: 'grey.50' }}>
<TableCell sx={{ pl: 6 }}>{courseName}</TableCell> <TableCell sx={{ pl: 6 }}>{courseName}</TableCell>
<TableCell>{course.code}</TableCell> <TableCell>{course.code}</TableCell>
{showVisibility && ( {showVisibility && (
@@ -279,7 +319,7 @@ export const DegreeCourseTable = ({
<VisibilitySwitch <VisibilitySwitch
ariaLabel={`Show ${courseName}`} ariaLabel={`Show ${courseName}`}
cypress={`visibility-switch-${course.code}`} cypress={`visibility-switch-${course.code}`}
onChange={() => toggleCourseVisibility(course)} onChange={() => toggleCourseVisibility(module, course)}
visibility={ visibility={
course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN
} }
@@ -309,8 +349,8 @@ export const DegreeCourseTable = ({
<DialogTitle>Hide the whole module?</DialogTitle> <DialogTitle>Hide the whole module?</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
{moduleToHide && {pendingHideModule &&
`Some courses in ${getModuleName(moduleToHide)} are already hidden. ` + `Some courses in ${getModuleName(pendingHideModule)} are already hidden. ` +
`Hiding the module hides its remaining ${remainingToHide} ` + `Hiding the module hides its remaining ${remainingToHide} ` +
`${remainingToHide === 1 ? 'course' : 'courses'} for all Oodikone users.`} `${remainingToHide === 1 ? 'course' : 'courses'} for all Oodikone users.`}
</DialogContentText> </DialogContentText>
@@ -320,7 +360,7 @@ export const DegreeCourseTable = ({
<Button <Button
data-cy="confirm-hide-module-button" data-cy="confirm-hide-module-button"
onClick={() => { onClick={() => {
hideModule(moduleToHide!) hideModule(pendingHideModule!)
setModuleToHide(null) setModuleToHide(null)
}} }}
variant="contained" variant="contained"
@@ -4,22 +4,43 @@ import Tab from '@mui/material/Tab'
import Tabs from '@mui/material/Tabs' import Tabs from '@mui/material/Tabs'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import { useEffect, useState } from 'react' import { useMemo, useState } from 'react'
import { useCurriculum } from '@/hooks/useCurriculum' import { useCurriculum } from '@/hooks/useCurriculum'
import { CreditCriteriaSection } from '@/pages/StudyProgramme/DegreeCoursesTab/CreditCriteriaSection' import { CreditCriteriaSection } from '@/pages/StudyProgramme/DegreeCoursesTab/CreditCriteriaSection'
import { CurriculumSection } from '@/pages/StudyProgramme/DegreeCoursesTab/CurriculumSection' 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 { ManageCoursesShownInfo } from '@/pages/StudyProgramme/DegreeCoursesTab/InfoBox'
import { useGetProgrammesQuery } from '@/redux/populations'
import { useGetProgressCriteriaQuery } from '@/redux/progressCriteria' import { useGetProgressCriteriaQuery } from '@/redux/progressCriteria'
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme' 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 { Section } from '@/components/Section'
import { useLanguage } from '@/components/LanguagePicker/useLanguage' import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { studyProgrammeToolTips } from '@/common/InfoToolTips' import { studyProgrammeToolTips } from '@/common/InfoToolTips'
export type DegreeSettingsTab = 'criteria' | 'visibility' 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 = { const sectionTexts = {
criteria: { criteria: {
title: 'Course completion academic year targets', title: 'Course completion academic year targets',
@@ -32,82 +53,55 @@ const sectionTexts = {
} }
export const DegreeCoursesTab = ({ export const DegreeCoursesTab = ({
combinedProgramme,
degreeProgramme, degreeProgramme,
initialTab = 'criteria', initialTab = 'criteria',
}: { }: {
combinedProgramme: string
degreeProgramme: string degreeProgramme: string
initialTab?: DegreeSettingsTab initialTab?: DegreeSettingsTab
}) => { }) => {
const [currentTab, setCurrentTab] = useState<DegreeSettingsTab>(initialTab) const [currentTab, setCurrentTab] = useState<DegreeSettingsTab>(initialTab)
const [defaultProgrammeModules, setDefaultProgrammeModules] = useState<Module[]>([])
const [secondProgrammeModules, setSecondProgrammeModules] = useState<Module[]>([])
const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: degreeProgramme }) const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: degreeProgramme })
const { data: programmes } = useGetProgrammesQuery()
const { getTextIn } = useLanguage() const { getTextIn } = useLanguage()
const { curriculum, curriculumList, setSelectedCurriculum: setCurriculum } = useCurriculum() const { curriculum, curriculumList, setSelectedCurriculum: setCurriculum } = useCurriculum()
const secondProgramme = getCombinedProgramme(degreeProgramme)
const getModules = (courses: ProgrammeCourse[]): Module[] => { // A combined programme has courses from two curriculums. Show them in one table, first programme's modules first.
const modules: Record<string, ProgrammeCourse[]> = {} const modules = useMemo(
courses.forEach(course => { () =>
const code = course.parent_code! curriculum
modules[code] ??= [] ? [
modules[code].push(course) ...getModules(curriculum.defaultProgrammeCourses ?? [], degreeProgramme),
}) ...(secondProgramme ? getModules(curriculum.secondProgrammeCourses ?? [], secondProgramme) : []),
]
: [],
[curriculum, degreeProgramme, secondProgramme]
)
return orderBy( const programmeNames = Object.fromEntries(
Object.entries(modules).map(([code, courses]) => ({ [degreeProgramme, secondProgramme]
code, .filter(code => code !== undefined)
courses, .map(code => {
order: courses[0].module_order, const programme = programmes?.allProgrammes?.[code] ?? programmes?.filteredProgrammes?.[code]
})), return [code, programme ? getTextIn(programme.name)! : code]
module => module.code })
) )
}
useEffect(() => {
if (!curriculum?.defaultProgrammeCourses?.length) {
return
}
setDefaultProgrammeModules(getModules(curriculum.defaultProgrammeCourses))
if (combinedProgramme) {
setSecondProgrammeModules(getModules(curriculum.secondProgrammeCourses))
}
}, [combinedProgramme, curriculum])
if (!curriculum || !criteria) return null if (!curriculum || !criteria) return null
const hasCriteria = isBachelorOrLicentiateProgramme(degreeProgramme) const hasCriteria = isBachelorOrLicentiateProgramme(degreeProgramme)
const hasModules = !!defaultProgrammeModules.length || !!secondProgrammeModules.length const renderCourseTable = (tab: DegreeSettingsTab) =>
!!modules.length && (
const renderCourseTables = (tab: DegreeSettingsTab) =>
hasModules && (
<Section infoBoxContent={getTextIn(sectionTexts[tab].toolTip) ?? ''} title={sectionTexts[tab].title}> <Section infoBoxContent={getTextIn(sectionTexts[tab].toolTip) ?? ''} title={sectionTexts[tab].title}>
<Stack gap={2}> <DegreeCourseTable
{!!defaultProgrammeModules.length && ( criteria={criteria}
<DegreeCourseTable curriculumVersion={curriculum.periodIds}
combinedProgramme="" modules={modules}
criteria={criteria} programmeNames={programmeNames}
curriculumVersion={curriculum.periodIds} showCriteria={tab === 'criteria'}
modules={defaultProgrammeModules} showVisibility={tab === 'visibility'}
showCriteria={tab === 'criteria'} studyProgramme={degreeProgramme}
showVisibility={tab === 'visibility'} />
studyProgramme={degreeProgramme}
/>
)}
{!!secondProgrammeModules.length && (
<DegreeCourseTable
combinedProgramme={combinedProgramme}
criteria={criteria}
curriculumVersion={curriculum.periodIds}
modules={secondProgrammeModules}
showCriteria={tab === 'criteria'}
showVisibility={tab === 'visibility'}
studyProgramme={degreeProgramme}
/>
)}
</Stack>
</Section> </Section>
) )
@@ -118,7 +112,7 @@ export const DegreeCoursesTab = ({
const visibilityPanel = ( const visibilityPanel = (
<Stack gap={2}> <Stack gap={2}>
{curriculumSection} {curriculumSection}
{renderCourseTables('visibility')} {renderCourseTable('visibility')}
</Stack> </Stack>
) )
@@ -140,7 +134,7 @@ export const DegreeCoursesTab = ({
<Stack gap={2}> <Stack gap={2}>
{curriculumSection} {curriculumSection}
<CreditCriteriaSection criteria={criteria} degreeProgramme={degreeProgramme} /> <CreditCriteriaSection criteria={criteria} degreeProgramme={degreeProgramme} />
{renderCourseTables('criteria')} {renderCourseTable('criteria')}
</Stack> </Stack>
</Box> </Box>
<Box data-cy="visibility-panel" hidden={currentTab !== 'visibility'}> <Box data-cy="visibility-panel" hidden={currentTab !== 'visibility'}>
@@ -138,7 +138,7 @@ export const StudyProgramme = () => {
label: 'Degree programme settings', label: 'Degree programme settings',
component: ( component: (
<CurriculumProvider key="DegreeCoursesTabContent" programmeCode={programmeId} year={new Date().getFullYear()}> <CurriculumProvider key="DegreeCoursesTabContent" programmeCode={programmeId} year={new Date().getFullYear()}>
<DegreeCoursesTab combinedProgramme={secondProgrammeId} degreeProgramme={programmeId} /> <DegreeCoursesTab degreeProgramme={programmeId} />
</CurriculumProvider> </CurriculumProvider>
), ),
}, },
@@ -34,6 +34,40 @@ test.describe('DegreeCourseTable', () => {
'Year 3', 'Year 3',
]) ])
await expect(component.getByText('1 / 2')).toBeVisible() 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 }) => { test('shows six years for medicine', async ({ mount }) => {
@@ -1,15 +1,15 @@
import { DegreeCourseTable } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable' import { DegreeCourseTable, DegreeModule } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable'
import { Module, ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types' import { ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
import { ReduxWrapper } from '../../ReduxWrapper' import { ReduxWrapper } from '../../ReduxWrapper'
const course = (code: string, name: string, visible = true) => const course = (code: string, name: string, visible = true, moduleName = 'Basic studies') =>
({ ({
code, code,
group_id: `group-${code}`, group_id: `group-${code}`,
module_order: 0, module_order: 0,
name: { fi: name, en: name }, name: { fi: name, en: name },
parent_code: 'MOD1', parent_code: 'MOD1',
parent_name: { fi: 'Basic studies', en: 'Basic studies' }, parent_name: { fi: moduleName, en: moduleName },
visible: { id: null, visibility: visible }, visible: { id: null, visibility: visible },
}) as unknown as ProgrammeCourse }) as unknown as ProgrammeCourse
@@ -29,33 +29,45 @@ const criteria: ProgressCriteria = {
/** /**
* Mounts `DegreeCourseTable` with one module of two courses. MAT101 is required for year 1. * Mounts `DegreeCourseTable` with one module of two courses. MAT101 is required for year 1.
* With `partlyHidden`, MAT102 is hidden. * 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 = ({ export const DegreeCourseTableWrapper = ({
combined = false,
partlyHidden = false, partlyHidden = false,
showCriteria = true, showCriteria = true,
showVisibility = true, showVisibility = true,
studyProgramme = 'KH50_001', studyProgramme = 'KH50_001',
}: { }: {
combined?: boolean
partlyHidden?: boolean partlyHidden?: boolean
showCriteria?: boolean showCriteria?: boolean
showVisibility?: boolean showVisibility?: boolean
studyProgramme?: string studyProgramme?: string
}) => { }) => {
const modules: Module[] = [ const modules: DegreeModule[] = [
{ {
code: 'MOD1', code: 'MOD1',
courses: [course('MAT101', 'Calculus I'), course('MAT102', 'Linear algebra', !partlyHidden)], courses: [course('MAT101', 'Calculus I'), course('MAT102', 'Linear algebra', !partlyHidden)],
order: 0, order: 0,
programmeCode: studyProgramme,
}, },
] ]
if (combined) {
modules.push({
code: 'MOD1',
courses: [course('MAT101', 'Calculus I', true, 'Clinical studies')],
order: 0,
programmeCode: 'MH90_001',
})
}
return ( return (
<ReduxWrapper <ReduxWrapper
component={ component={
<DegreeCourseTable <DegreeCourseTable
combinedProgramme=""
criteria={criteria} criteria={criteria}
curriculumVersion={['period-1']} curriculumVersion={['period-1']}
modules={modules} modules={modules}
programmeNames={{ [studyProgramme]: 'First programme', MH90_001: 'Second programme' }}
showCriteria={showCriteria} showCriteria={showCriteria}
showVisibility={showVisibility} showVisibility={showVisibility}
studyProgramme={studyProgramme} studyProgramme={studyProgramme}
@@ -18,6 +18,14 @@ test.describe('DegreeCoursesTab', () => {
test.beforeEach(async ({ router }) => { test.beforeEach(async ({ router }) => {
await router.route('**/api/login', route => route.fulfill({ json: { user: { roles: [] } } })) await router.route('**/api/login', route => route.fulfill({ json: { user: { roles: [] } } }))
await router.route('**/api/banners', route => route.fulfill({ json: [] })) 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 => await router.route('**/api/curriculum-options/*', route =>
route.fulfill({ json: [{ id: 'cur-1', name: '2023–2026', periodIds: ['period-1'], validFrom: '2023-08-01' }] }) route.fulfill({ json: [{ id: 'cur-1', name: '2023–2026', periodIds: ['period-1'], validFrom: '2023-08-01' }] })
) )
@@ -48,7 +56,7 @@ test.describe('DegreeCoursesTab', () => {
await expect(component.getByRole('alert')).toContainText(/non-personal|eivät ole henkilökohtaisia/) 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.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')).toHaveText(['Progress tab criteria', 'Course & module visibility'])
await expect(component.getByRole('tab', { name: 'Progress tab criteria' })).toHaveAttribute('aria-selected', 'true') 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="credit-criteria-section"]')).toBeVisible()
await expect(component.locator('[data-cy="visibility-panel"]')).toBeHidden() await expect(component.locator('[data-cy="visibility-panel"]')).toBeHidden()
@@ -74,7 +82,7 @@ test.describe('DegreeCoursesTab', () => {
await visibilityPanel.getByText('Basic studies').click() await visibilityPanel.getByText('Basic studies').click()
await visibilityPanel.getByRole('checkbox', { name: 'Show Calculus I' }).uncheck() await visibilityPanel.getByRole('checkbox', { name: 'Show Calculus I' }).uncheck()
await component.getByRole('tab', { name: 'Progress tab criteria' }).click() await component.getByRole('tab', { name: 'Progress tab criteria' }).click()
await component.getByRole('tab', { name: 'Course visibility' }).click() await component.getByRole('tab', { name: 'Course & module visibility' }).click()
await expect(visibilityPanel.getByRole('checkbox', { name: 'Show Calculus I' })).not.toBeChecked() await expect(visibilityPanel.getByRole('checkbox', { name: 'Show Calculus I' })).not.toBeChecked()
}) })
@@ -86,4 +94,38 @@ test.describe('DegreeCoursesTab', () => {
await expect(component.getByRole('tab')).toHaveCount(0) await expect(component.getByRole('tab')).toHaveCount(0)
await expect(component.locator('[data-cy="credit-criteria-section"]')).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()
})
}) })
@@ -15,7 +15,7 @@ export const DegreeCoursesTabWrapper = ({
<ReduxWrapper <ReduxWrapper
component={ component={
<CurriculumProvider programmeCode={programme} year={2024}> <CurriculumProvider programmeCode={programme} year={2024}>
<DegreeCoursesTab combinedProgramme="" degreeProgramme={programme} initialTab={initialTab} /> <DegreeCoursesTab degreeProgramme={programme} initialTab={initialTab} />
</CurriculumProvider> </CurriculumProvider>
} }
/> />
+7
View File
@@ -2,6 +2,13 @@ import { StudyProgrammeCourse } from '../types'
export type YearStats = StudyProgrammeCourse['years'][number] 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 => ({ export const createEmptyStats = (isStudyModule = false): YearStats => ({
allCredits: 0, allCredits: 0,
allStudents: 0, allStudents: 0,