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
|
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
|
||||||
|
|||||||
+1
-1
@@ -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>
|
||||||
|
|||||||
+103
-63
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user