mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
[Progress] combined programme criteria fix
thanks claude opus 5.5 high
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export const FilterDegreeCoursesModal = ({
|
||||
<Dialog maxWidth="md" onClose={() => setModalOpen(false)} open={modalOpen}>
|
||||
<DialogTitle>Degree programme settings</DialogTitle>
|
||||
<DialogContent>
|
||||
<DegreeCoursesTab combinedProgramme="" degreeProgramme={degreeProgramme} initialTab={initialTab} />
|
||||
<DegreeCoursesTab degreeProgramme={degreeProgramme} initialTab={initialTab} />
|
||||
<Button color="primary" onClick={() => setModalOpen(false)} style={{ marginTop: '10px' }} variant="contained">
|
||||
Close
|
||||
</Button>
|
||||
|
||||
+103
-63
@@ -28,21 +28,31 @@ 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 [expandedModules, setExpandedModules] = useState<Record<string, Record<string, boolean>>>({})
|
||||
@@ -60,7 +70,6 @@ export const DegreeCourseTable = ({
|
||||
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
|
||||
|
||||
@@ -68,74 +77,93 @@ export const DegreeCourseTable = ({
|
||||
setModules(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 firstCourse = getModule(moduleCode).courses[0]
|
||||
return firstCourse?.parent_name ? getTextIn(firstCourse.parent_name)! : moduleCode
|
||||
const getModuleName = (module: DegreeModule) => {
|
||||
const firstCourse = module.courses[0]
|
||||
return firstCourse?.parent_name ? getTextIn(firstCourse.parent_name)! : module.code
|
||||
}
|
||||
|
||||
const setCoursesVisibility = (courseCodes: string[], newVisibility: boolean) => {
|
||||
// Same course can be in many modules, so change them all
|
||||
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,
|
||||
courses: module.courses.map(course =>
|
||||
courseCodes.includes(course.code)
|
||||
? { ...course, visible: { ...course.visible, visibility: newVisibility } }
|
||||
: course
|
||||
),
|
||||
}))
|
||||
prevModules.map(module =>
|
||||
module.programmeCode !== programmeCode
|
||||
? module
|
||||
: {
|
||||
...module,
|
||||
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 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
|
||||
)
|
||||
const changeVisibility = (
|
||||
programmeCode: string,
|
||||
courses: ProgrammeCourse[],
|
||||
newVisibility: boolean,
|
||||
successMessage: string
|
||||
) => {
|
||||
const request = {
|
||||
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
|
||||
.unwrap()
|
||||
.then(() => setStatusNotification(`${successMessage} ${reloadNote}`, 'success'))
|
||||
.catch(() => {
|
||||
setCoursesVisibility(
|
||||
courses.map(course => course.code),
|
||||
!newVisibility
|
||||
)
|
||||
setCoursesVisibility(programmeCode, courseCodes, !newVisibility)
|
||||
setStatusNotification('Failed to change visibility', 'error')
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCourseVisibility = (course: ProgrammeCourse) => {
|
||||
const toggleCourseVisibility = (module: DegreeModule, course: ProgrammeCourse) => {
|
||||
const newVisibility = !course.visible.visibility
|
||||
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 { courses } = getModule(moduleCode)
|
||||
const visibility = getVisibility(moduleCode)
|
||||
const name = getModuleName(moduleCode)
|
||||
const toggleModuleVisibility = (module: DegreeModule) => {
|
||||
const visibility = getVisibility(module)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (visibility === CourseVisibility.PARTIAL) {
|
||||
setModuleToHide(moduleCode)
|
||||
setModuleToHide(getModuleKey(module))
|
||||
return
|
||||
}
|
||||
hideModule(moduleCode)
|
||||
hideModule(module)
|
||||
}
|
||||
|
||||
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 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 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({
|
||||
...expandedModules,
|
||||
[version]: { ...expandedModules[version], [moduleCode]: !isModuleExpanded(moduleCode) },
|
||||
[version]: { ...expandedModules[version], [moduleKey]: !isModuleExpanded(moduleKey) },
|
||||
})
|
||||
}
|
||||
|
||||
const getVisibility = (moduleCode: string) => {
|
||||
const module = getModule(moduleCode)
|
||||
const getVisibility = (module: DegreeModule) => {
|
||||
if (!module.courses?.length) {
|
||||
return CourseVisibility.NO_COURSES
|
||||
}
|
||||
@@ -188,9 +215,10 @@ export const DegreeCourseTable = ({
|
||||
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
|
||||
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 (
|
||||
<>
|
||||
@@ -217,12 +245,24 @@ export const DegreeCourseTable = ({
|
||||
)}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{modules.map(({ code: moduleCode, courses }) => {
|
||||
const moduleName = getModuleName(moduleCode)
|
||||
const expanded = isModuleExpanded(moduleCode)
|
||||
{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={`${moduleCode}-fragment`}>
|
||||
<TableRow hover onClick={() => toggleExpanded(moduleCode)} sx={{ cursor: 'pointer' }}>
|
||||
<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
|
||||
@@ -243,8 +283,8 @@ export const DegreeCourseTable = ({
|
||||
<VisibilitySwitch
|
||||
ariaLabel={`Show all courses of ${moduleName}`}
|
||||
cypress={`visibility-switch-${moduleCode}`}
|
||||
onChange={() => toggleModuleVisibility(moduleCode)}
|
||||
visibility={getVisibility(moduleCode)}
|
||||
onChange={() => toggleModuleVisibility(module)}
|
||||
visibility={getVisibility(module)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
@@ -271,7 +311,7 @@ export const DegreeCourseTable = ({
|
||||
.map(course => {
|
||||
const courseName = getTextIn(course.name)
|
||||
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>{course.code}</TableCell>
|
||||
{showVisibility && (
|
||||
@@ -279,7 +319,7 @@ export const DegreeCourseTable = ({
|
||||
<VisibilitySwitch
|
||||
ariaLabel={`Show ${courseName}`}
|
||||
cypress={`visibility-switch-${course.code}`}
|
||||
onChange={() => toggleCourseVisibility(course)}
|
||||
onChange={() => toggleCourseVisibility(module, course)}
|
||||
visibility={
|
||||
course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN
|
||||
}
|
||||
@@ -309,8 +349,8 @@ export const DegreeCourseTable = ({
|
||||
<DialogTitle>Hide the whole module?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{moduleToHide &&
|
||||
`Some courses in ${getModuleName(moduleToHide)} are already hidden. ` +
|
||||
{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>
|
||||
@@ -320,7 +360,7 @@ export const DegreeCourseTable = ({
|
||||
<Button
|
||||
data-cy="confirm-hide-module-button"
|
||||
onClick={() => {
|
||||
hideModule(moduleToHide!)
|
||||
hideModule(pendingHideModule!)
|
||||
setModuleToHide(null)
|
||||
}}
|
||||
variant="contained"
|
||||
|
||||
@@ -4,22 +4,43 @@ 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',
|
||||
@@ -32,82 +53,55 @@ const sectionTexts = {
|
||||
}
|
||||
|
||||
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 })
|
||||
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 hasModules = !!defaultProgrammeModules.length || !!secondProgrammeModules.length
|
||||
|
||||
const renderCourseTables = (tab: DegreeSettingsTab) =>
|
||||
hasModules && (
|
||||
const renderCourseTable = (tab: DegreeSettingsTab) =>
|
||||
!!modules.length && (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
{!!secondProgrammeModules.length && (
|
||||
<DegreeCourseTable
|
||||
combinedProgramme={combinedProgramme}
|
||||
criteria={criteria}
|
||||
curriculumVersion={curriculum.periodIds}
|
||||
modules={secondProgrammeModules}
|
||||
showCriteria={tab === 'criteria'}
|
||||
showVisibility={tab === 'visibility'}
|
||||
studyProgramme={degreeProgramme}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<DegreeCourseTable
|
||||
criteria={criteria}
|
||||
curriculumVersion={curriculum.periodIds}
|
||||
modules={modules}
|
||||
programmeNames={programmeNames}
|
||||
showCriteria={tab === 'criteria'}
|
||||
showVisibility={tab === 'visibility'}
|
||||
studyProgramme={degreeProgramme}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
|
||||
@@ -118,7 +112,7 @@ export const DegreeCoursesTab = ({
|
||||
const visibilityPanel = (
|
||||
<Stack gap={2}>
|
||||
{curriculumSection}
|
||||
{renderCourseTables('visibility')}
|
||||
{renderCourseTable('visibility')}
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -140,7 +134,7 @@ export const DegreeCoursesTab = ({
|
||||
<Stack gap={2}>
|
||||
{curriculumSection}
|
||||
<CreditCriteriaSection criteria={criteria} degreeProgramme={degreeProgramme} />
|
||||
{renderCourseTables('criteria')}
|
||||
{renderCourseTable('criteria')}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box data-cy="visibility-panel" hidden={currentTab !== 'visibility'}>
|
||||
|
||||
@@ -138,7 +138,7 @@ export const StudyProgramme = () => {
|
||||
label: 'Degree programme settings',
|
||||
component: (
|
||||
<CurriculumProvider key="DegreeCoursesTabContent" programmeCode={programmeId} year={new Date().getFullYear()}>
|
||||
<DegreeCoursesTab combinedProgramme={secondProgrammeId} degreeProgramme={programmeId} />
|
||||
<DegreeCoursesTab degreeProgramme={programmeId} />
|
||||
</CurriculumProvider>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -34,6 +34,40 @@ test.describe('DegreeCourseTable', () => {
|
||||
'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 }) => {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { DegreeCourseTable } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable'
|
||||
import { Module, ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
|
||||
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) =>
|
||||
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: 'Basic studies', en: 'Basic studies' },
|
||||
parent_name: { fi: moduleName, en: moduleName },
|
||||
visible: { id: null, visibility: visible },
|
||||
}) 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.
|
||||
* 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: Module[] = [
|
||||
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
|
||||
combinedProgramme=""
|
||||
criteria={criteria}
|
||||
curriculumVersion={['period-1']}
|
||||
modules={modules}
|
||||
programmeNames={{ [studyProgramme]: 'First programme', MH90_001: 'Second programme' }}
|
||||
showCriteria={showCriteria}
|
||||
showVisibility={showVisibility}
|
||||
studyProgramme={studyProgramme}
|
||||
|
||||
@@ -18,6 +18,14 @@ 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' }] })
|
||||
)
|
||||
@@ -48,7 +56,7 @@ test.describe('DegreeCoursesTab', () => {
|
||||
|
||||
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')).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()
|
||||
@@ -74,7 +82,7 @@ test.describe('DegreeCoursesTab', () => {
|
||||
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 component.getByRole('tab', { name: 'Course & module visibility' }).click()
|
||||
|
||||
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.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
|
||||
component={
|
||||
<CurriculumProvider programmeCode={programme} year={2024}>
|
||||
<DegreeCoursesTab combinedProgramme="" degreeProgramme={programme} initialTab={initialTab} />
|
||||
<DegreeCoursesTab degreeProgramme={programme} initialTab={initialTab} />
|
||||
</CurriculumProvider>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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