mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
[Degree programme settings] separate criteria from visibility
This commit is contained in:
@@ -191,28 +191,32 @@ However this is optional and you can create a new tag without selecting year.
|
||||
|
||||
degreeCoursesTab: {
|
||||
creditCriteria: {
|
||||
en: `The criteria set here will affect the Class Statistics → Students tab → Progress tab view.
|
||||
en: `The targets set here will affect the Class Statistics → Students tab → Progress tab view.
|
||||
|
||||
Credit criteria is computed as follows: for the first academic year, the credits are counted
|
||||
if they are completed during the first 12 months. For the second year, completions
|
||||
within the first 24 months. Third year, the first 36 months, and so on.
|
||||
`,
|
||||
fi: `Tässä asetettu "criteria" vaikuttaa Class Statistics → Students tab → Progress tab näkymään.
|
||||
fi: `Tässä asetetut tavoitteet vaikuttavat Class Statistics → Students tab → Progress tab näkymään.
|
||||
|
||||
Laskenta toimii seuraavasti: ensimmäisen akateemisen vuoden opintopisteet lasketaan, jos ne ovat suoritettu ensimmäisen 12 kuukauden ajalla. Toisen vuoden opintopisteet, jos ne ovat suoritettu ensimmäisen 24 kuukauden aikana. Kolmas, ensimmäiset 36kk, ja niin edelleen.
|
||||
`,
|
||||
},
|
||||
programmeCriteriaAndVisibility: {
|
||||
en: `Click the arrow next to the module to view courses belonging to the module in the curriculum. Setting a criterion label for a course will affect which academic year the course is placed under in Class Statistics → Students → Progress tab.
|
||||
requiredCourses: {
|
||||
en: `Click a module to see its courses in the selected curriculum.
|
||||
|
||||
Courses' visibilities can also be toggled here; hiding a course here will additionally hide it in the Courses of Class view and Students → Courses tab.
|
||||
Tick a year to make the course a required course for that academic year in Class Statistics → Students → Progress tab. A course can be required for several years. The numbers on a module row show how many of its courses are required for each year.`,
|
||||
fi: `Klikkaa opintokokonaisuutta nähdäksesi sen kurssit valitussa opetussuunnitelmassa.
|
||||
|
||||
Note: If hiding a module, all courses belonging to the module will also be hidden.`,
|
||||
fi: `Klikkaa nuolta opintokokonaisuuden nimen vieressä näyttääksesi opetussuunitelmassa opintokokonaisuuden alle sijoitetut kurssit. Kursseille voi asettaa "Criterion label":eita, jotka vaikuttavat minkä akateemisen vuoden alla kurssi näytetään Class Statistics → Students → Progress tab näkymässä.
|
||||
Valitse vuosi, jolloin kurssi on tarkoitus suorittaa. Kurssi näytetään kyseisen akateemisen vuoden alla Class Statistics → Students → Progress tab -näkymässä. Kurssi voi olla pakollinen useammalle vuodelle. Opintokokonaisuuden rivillä olevat luvut kertovat, kuinka moni sen kursseista on pakollinen kunakin vuonna. Huom. kurssi voi kuulua useaan opintokokonaisuuteen.`,
|
||||
},
|
||||
courseVisibility: {
|
||||
en: `Click a module to see its courses in the selected curriculum.
|
||||
|
||||
Kurssien näkyvyyttä voidaan myös muuttaa tällä sivulla. Kurssin piilottaminen piilottaa sen myös Courses of Class ja Students → Courses tab näkymissä.
|
||||
Hiding a course also hides it in the Courses of Class view and Students → Courses tab for all users. Hiding a module hides all of its courses.`,
|
||||
fi: `Klikkaa opintokokonaisuutta nähdäksesi sen kurssit valitussa opetussuunnitelmassa.
|
||||
|
||||
Huom: Opintokokonaisuuden piilottaminen piilottaa myös kaikki sen alla olevat kurssit.`,
|
||||
Kurssin piilottaminen piilottaa sen kaikilta käyttäjiltä myös Courses of Class ja Students → Courses tab -näkymissä. Opintokokonaisuuden piilottaminen piilottaa kaikki sen kurssit.`,
|
||||
},
|
||||
},
|
||||
// Not used
|
||||
|
||||
+4
-4
@@ -5,19 +5,20 @@ import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
|
||||
import { DegreeSettingsTab, DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
|
||||
import { VisibilityIcon } from '@/theme'
|
||||
import { ManageCoursesShownInfo } from '@/pages/StudyProgramme/DegreeCoursesTab/InfoBox'
|
||||
|
||||
const defaultText = 'Manage course visibility'
|
||||
const defaultIcon = <VisibilityIcon />
|
||||
|
||||
export const FilterDegreeCoursesModal = ({
|
||||
degreeProgramme,
|
||||
initialTab,
|
||||
text,
|
||||
icon,
|
||||
}: {
|
||||
degreeProgramme: string
|
||||
initialTab: DegreeSettingsTab
|
||||
text?: string
|
||||
icon?: React.ReactNode
|
||||
}) => {
|
||||
@@ -31,8 +32,7 @@ export const FilterDegreeCoursesModal = ({
|
||||
<Dialog maxWidth="md" onClose={() => setModalOpen(false)} open={modalOpen}>
|
||||
<DialogTitle>Degree programme settings</DialogTitle>
|
||||
<DialogContent>
|
||||
<ManageCoursesShownInfo />
|
||||
<DegreeCoursesTab combinedProgramme="" degreeProgramme={degreeProgramme} />
|
||||
<DegreeCoursesTab combinedProgramme="" degreeProgramme={degreeProgramme} initialTab={initialTab} />
|
||||
<Button color="primary" onClick={() => setModalOpen(false)} style={{ marginTop: '10px' }} variant="contained">
|
||||
Close
|
||||
</Button>
|
||||
|
||||
@@ -36,7 +36,11 @@ export const PopulationCourses = ({
|
||||
<>
|
||||
<Stack direction="row" spacing={2} sx={{ my: 1, justifyContent: 'space-between' }}>
|
||||
{query.programme && !onlyIamRights ? (
|
||||
<FilterDegreeCoursesModal degreeProgramme={query.programme} text="Manage course / module visibility" />
|
||||
<FilterDegreeCoursesModal
|
||||
degreeProgramme={query.programme}
|
||||
initialTab="visibility"
|
||||
text="Manage course / module visibility"
|
||||
/>
|
||||
) : null}
|
||||
<InfoBox content={tooltipText} />
|
||||
</Stack>
|
||||
|
||||
+1
-1
@@ -393,7 +393,7 @@ export const CoursesTabContainer = ({ students, courses, idToGroupIdMap, program
|
||||
toolbarContent={
|
||||
<>
|
||||
<OodiTableExcelExport data={exportData} exportColumnKeys={keysForExport} />
|
||||
<FilterDegreeCoursesModal degreeProgramme={programme} />
|
||||
<FilterDegreeCoursesModal degreeProgramme={programme} initialTab="visibility" />
|
||||
<StudentNameVisibilityToggle />
|
||||
<IncludeSubstitutionsToggle
|
||||
includeSubstitutions={includeSubstitutions}
|
||||
|
||||
+5
-1
@@ -193,7 +193,11 @@ export const ModulesTab = ({
|
||||
toolbarContent={
|
||||
<>
|
||||
<OodiTableExcelExport data={exportData} exportColumnKeys={accessorKeys} />
|
||||
<FilterDegreeCoursesModal degreeProgramme={programme} text="Manage module visibility" />
|
||||
<FilterDegreeCoursesModal
|
||||
degreeProgramme={programme}
|
||||
initialTab="visibility"
|
||||
text="Manage module visibility"
|
||||
/>
|
||||
<StudentNameVisibilityToggle />
|
||||
<HiddenCourseAlert ooditable modules />
|
||||
</>
|
||||
|
||||
+6
-1
@@ -413,7 +413,12 @@ export const ProgressTable = ({
|
||||
toolbarContent={
|
||||
<>
|
||||
<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />
|
||||
<FilterDegreeCoursesModal degreeProgramme={programme} text="Modify criteria" icon={<EditIcon />} />
|
||||
<FilterDegreeCoursesModal
|
||||
degreeProgramme={programme}
|
||||
initialTab="criteria"
|
||||
icon={<EditIcon />}
|
||||
text="Modify criteria"
|
||||
/>
|
||||
<StudentNameVisibilityToggle />
|
||||
</>
|
||||
}
|
||||
|
||||
+7
-39
@@ -12,7 +12,7 @@ import { Section } from '@/components/Section'
|
||||
import { useStatusNotification } from '@/components/StatusNotification/Context'
|
||||
import { useAddProgressCriteriaCreditsMutation } from '@/redux/progressCriteria'
|
||||
import { SaveIcon } from '@/theme'
|
||||
import { isMedicalProgramme } from '@/util/studyProgramme'
|
||||
import { getCriteriaYears } from '@/pages/StudyProgramme/DegreeCoursesTab/criteriaYears'
|
||||
import { ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
|
||||
@@ -68,43 +68,11 @@ export const CreditCriteriaSection = ({
|
||||
}
|
||||
}, [isError])
|
||||
|
||||
const inputs = [
|
||||
{
|
||||
key: 'year1',
|
||||
label: 'First',
|
||||
previousValue: criteria.credits.yearOne,
|
||||
},
|
||||
{
|
||||
key: 'year2',
|
||||
label: 'Second',
|
||||
previousValue: criteria.credits.yearTwo,
|
||||
},
|
||||
{
|
||||
key: 'year3',
|
||||
label: 'Third',
|
||||
previousValue: criteria.credits.yearThree,
|
||||
},
|
||||
]
|
||||
|
||||
if (isMedicalProgramme(degreeProgramme)) {
|
||||
inputs.push(
|
||||
{
|
||||
key: 'year4',
|
||||
label: 'Fourth',
|
||||
previousValue: criteria.credits.yearFour,
|
||||
},
|
||||
{
|
||||
key: 'year5',
|
||||
label: 'Fifth',
|
||||
previousValue: criteria.credits.yearFive,
|
||||
},
|
||||
{
|
||||
key: 'year6',
|
||||
label: 'Sixth',
|
||||
previousValue: criteria.credits.yearSix,
|
||||
}
|
||||
)
|
||||
}
|
||||
const inputs = getCriteriaYears(degreeProgramme).map(({ key, label, year }) => ({
|
||||
key: `year${year}`,
|
||||
label,
|
||||
previousValue: criteria.credits[key],
|
||||
}))
|
||||
|
||||
const disabled = Object.values(creditLimits).every(value => value === null || value < 0)
|
||||
|
||||
@@ -112,7 +80,7 @@ export const CreditCriteriaSection = ({
|
||||
<Section
|
||||
cypress="credit-criteria"
|
||||
infoBoxContent={getTextIn(studyProgrammeToolTips.degreeCoursesTab.creditCriteria) ?? ''}
|
||||
title="Credit criteria"
|
||||
title="Credit targets"
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<Grid container spacing={1}>
|
||||
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Menu from '@mui/material/Menu'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useAddProgressCriteriaCourseMutation } from '@/redux/progressCriteria'
|
||||
import { FormatListNumberedIcon } from '@/theme'
|
||||
import { isMedicalProgramme } from '@/util/studyProgramme'
|
||||
import { ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
|
||||
|
||||
export const CriterionLabelSelectButton = ({
|
||||
course,
|
||||
criteria,
|
||||
studyProgramme,
|
||||
}: {
|
||||
course: ProgrammeCourse
|
||||
criteria: ProgressCriteria
|
||||
studyProgramme: string
|
||||
}) => {
|
||||
const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation()
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const selected = Object.keys(criteria.courses).filter(key => criteria.courses[key].includes(course.group_id))
|
||||
setSelectedOptions(selected)
|
||||
}, [criteria, course.group_id])
|
||||
|
||||
const options = [
|
||||
{
|
||||
key: 'yearOne',
|
||||
label: 'First',
|
||||
year: 1,
|
||||
},
|
||||
{
|
||||
key: 'yearTwo',
|
||||
label: 'Second',
|
||||
year: 2,
|
||||
},
|
||||
{
|
||||
key: 'yearThree',
|
||||
label: 'Third',
|
||||
year: 3,
|
||||
},
|
||||
]
|
||||
|
||||
if (isMedicalProgramme(studyProgramme)) {
|
||||
options.push(
|
||||
{
|
||||
key: 'yearFour',
|
||||
label: 'Fourth',
|
||||
year: 4,
|
||||
},
|
||||
{
|
||||
key: 'yearFive',
|
||||
year: 5,
|
||||
label: 'Fifth',
|
||||
},
|
||||
{
|
||||
key: 'yearSix',
|
||||
year: 6,
|
||||
label: 'Sixth',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (key: string, year: number) => {
|
||||
let courseGroupIds: string[]
|
||||
|
||||
if (selectedOptions.includes(key)) {
|
||||
courseGroupIds = criteria.courses[key]?.filter((groupId: string) => groupId !== course.group_id)
|
||||
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
|
||||
} else {
|
||||
courseGroupIds = criteria.courses ? [...criteria.courses[key], course.group_id] : [course.group_id]
|
||||
}
|
||||
|
||||
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
|
||||
|
||||
setSelectedOptions(prevSelected =>
|
||||
prevSelected.includes(key) ? prevSelected.filter(item => item !== key) : [...prevSelected, key]
|
||||
)
|
||||
}
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip arrow placement="top" title={'Modify criterion labels'}>
|
||||
<IconButton data-cy="toggle-visibility-button" onClick={handleMenuOpen}>
|
||||
<FormatListNumberedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu anchorEl={anchorEl} onClose={handleMenuClose} open={Boolean(anchorEl)}>
|
||||
{options.map(option => (
|
||||
<MenuItem key={option.key} onClick={() => handleCheckboxChange(option.key, option.year)}>
|
||||
<Checkbox checked={selectedOptions.includes(option.key)} />
|
||||
<ListItemText primary={`${option.label} year`} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
|
||||
import { VisibilityIcon, VisibilityOffIcon } from '@/theme'
|
||||
|
||||
export const ToggleVisibilityButton = ({ onClick, visible }: { onClick: any; visible: boolean }) => {
|
||||
return (
|
||||
<Tooltip arrow placement="top" title={visible ? 'Set hidden' : 'Set visible'}>
|
||||
<IconButton data-cy="toggle-visibility-button" onClick={onClick}>
|
||||
{visible ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import Chip from '@mui/material/Chip'
|
||||
|
||||
import { CourseVisibility } from '@/types/courseVisibility'
|
||||
|
||||
export const VisibilityChip = ({ visibility }: { visibility: CourseVisibility }) => {
|
||||
return (
|
||||
<Chip
|
||||
label={visibility}
|
||||
size="small"
|
||||
sx={{
|
||||
color: theme => theme.palette.courseVisibility[visibility],
|
||||
borderColor: theme => theme.palette.courseVisibility[visibility],
|
||||
}}
|
||||
variant="outlined"
|
||||
/>
|
||||
)
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Switch from '@mui/material/Switch'
|
||||
|
||||
import { CourseVisibility } from '@/types/courseVisibility'
|
||||
|
||||
const labels: Record<CourseVisibility, string> = {
|
||||
[CourseVisibility.NO_COURSES]: 'No courses',
|
||||
[CourseVisibility.HIDDEN]: 'Hidden',
|
||||
[CourseVisibility.PARTIAL]: 'Partly shown',
|
||||
[CourseVisibility.VISIBLE]: 'Shown',
|
||||
}
|
||||
|
||||
const colors = {
|
||||
[CourseVisibility.NO_COURSES]: 'noCourses',
|
||||
[CourseVisibility.HIDDEN]: 'hidden',
|
||||
[CourseVisibility.PARTIAL]: 'partial',
|
||||
[CourseVisibility.VISIBLE]: 'visible',
|
||||
} as const
|
||||
|
||||
export const VisibilitySwitch = ({
|
||||
ariaLabel,
|
||||
cypress,
|
||||
onChange,
|
||||
visibility,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
cypress: string
|
||||
onChange: () => void
|
||||
visibility: CourseVisibility
|
||||
}) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={visibility === CourseVisibility.VISIBLE || visibility === CourseVisibility.PARTIAL}
|
||||
disabled={visibility === CourseVisibility.NO_COURSES}
|
||||
onChange={onChange}
|
||||
size="small"
|
||||
inputProps={{ 'aria-label': ariaLabel }}
|
||||
/>
|
||||
}
|
||||
data-cy={cypress}
|
||||
label={labels[visibility]}
|
||||
onClick={event => event.stopPropagation()}
|
||||
slotProps={{
|
||||
typography: {
|
||||
variant: 'body2',
|
||||
sx: { color: theme => theme.palette.courseVisibility[colors[visibility]], whiteSpace: 'nowrap' },
|
||||
},
|
||||
}}
|
||||
sx={{ mr: 0 }}
|
||||
/>
|
||||
)
|
||||
+249
-170
@@ -1,7 +1,12 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogContentText from '@mui/material/DialogContentText'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
@@ -11,136 +16,163 @@ import Typography from '@mui/material/Typography'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { CriterionLabelSelectButton } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/CriterionLabelSelectButton'
|
||||
import { ToggleVisibilityButton } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/ToggleVisibilityButton'
|
||||
import { VisibilityChip } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/VisibilityChip'
|
||||
import { useStatusNotification } from '@/components/StatusNotification/Context'
|
||||
import { CriteriaYearOption, getCriteriaYears } from '@/pages/StudyProgramme/DegreeCoursesTab/criteriaYears'
|
||||
import { VisibilitySwitch } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/VisibilitySwitch'
|
||||
import { useRemoveCourseExclusionMutation, useSetCourseExclusionMutation } from '@/redux/courseExclusions'
|
||||
import { FormatListNumberedIcon, KeyboardArrowDownIcon, KeyboardArrowRightIcon } from '@/theme'
|
||||
import { useAddProgressCriteriaCourseMutation } from '@/redux/progressCriteria'
|
||||
import { KeyboardArrowDownIcon, KeyboardArrowRightIcon } from '@/theme'
|
||||
import { CourseVisibility } from '@/types/courseVisibility'
|
||||
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
|
||||
import { Module, ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { StyledTable } from '@/components/common/StyledTable'
|
||||
|
||||
const reloadNote = 'Reload the page to see the change in other views.'
|
||||
|
||||
export const DegreeCourseTable = ({
|
||||
combinedProgramme,
|
||||
criteria,
|
||||
curriculumVersion,
|
||||
modules: initialModules,
|
||||
showCriteria,
|
||||
showVisibility,
|
||||
studyProgramme,
|
||||
}: {
|
||||
combinedProgramme: string
|
||||
criteria: ProgressCriteria
|
||||
curriculumVersion: string[]
|
||||
modules: Module[]
|
||||
showCriteria: boolean
|
||||
showVisibility: boolean
|
||||
studyProgramme: string
|
||||
}) => {
|
||||
const [moduleCoursesVisible, setModuleCoursesVisible] = useState<Record<string, Record<string, boolean>>>({})
|
||||
const [expandedModules, setExpandedModules] = useState<Record<string, Record<string, boolean>>>({})
|
||||
const { getTextIn } = useLanguage()
|
||||
const { setStatusNotification } = useStatusNotification()
|
||||
const [modules, setModules] = useState(initialModules)
|
||||
// Unsaved criteria changes, discarded when updated criteria arrive from the server
|
||||
const [pendingCriteria, setPendingCriteria] = useState<{
|
||||
courses: ProgressCriteria['courses']
|
||||
source: ProgressCriteria
|
||||
} | null>(null)
|
||||
const [moduleToHide, setModuleToHide] = useState<string | null>(null)
|
||||
const [setExclusion] = useSetCourseExclusionMutation()
|
||||
const [removeExclusion] = useRemoveCourseExclusionMutation()
|
||||
const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation()
|
||||
|
||||
const version = curriculumVersion.join(',')
|
||||
const exclusionProgramme = combinedProgramme === '' ? studyProgramme : combinedProgramme
|
||||
const criteriaYears = showCriteria ? getCriteriaYears(studyProgramme) : []
|
||||
const criteriaCourses = pendingCriteria?.source === criteria ? pendingCriteria.courses : criteria.courses
|
||||
|
||||
useEffect(() => {
|
||||
setModules(initialModules)
|
||||
}, [initialModules])
|
||||
|
||||
const getModule = (moduleCode: string) => modules.find(module => module.code === moduleCode)!
|
||||
|
||||
const setModuleVisibility = (moduleCode: string, newVisibility: boolean) => {
|
||||
const getModuleName = (moduleCode: string) => {
|
||||
const firstCourse = getModule(moduleCode).courses[0]
|
||||
return firstCourse?.parent_name ? getTextIn(firstCourse.parent_name)! : moduleCode
|
||||
}
|
||||
|
||||
const setCoursesVisibility = (courseCodes: string[], newVisibility: boolean) => {
|
||||
// Same course can be in many modules, so change them all
|
||||
const courseCodes = getModule(moduleCode).courses.map(course => course.code)
|
||||
setModules(
|
||||
modules.map(module => ({
|
||||
setModules(prevModules =>
|
||||
prevModules.map(module => ({
|
||||
...module,
|
||||
courses: module.courses.map(course => {
|
||||
if (courseCodes.includes(course.code)) {
|
||||
return {
|
||||
...course,
|
||||
visible: { ...course.visible, visibility: newVisibility },
|
||||
}
|
||||
}
|
||||
return course
|
||||
}),
|
||||
courses: module.courses.map(course =>
|
||||
courseCodes.includes(course.code)
|
||||
? { ...course, visible: { ...course.visible, visibility: newVisibility } }
|
||||
: course
|
||||
),
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const setCourseVisibility = (moduleCode: string, newVisibility: boolean) => {
|
||||
setModules(
|
||||
modules.map(module => ({
|
||||
...module,
|
||||
courses: module.courses.map(course => {
|
||||
if (course.code !== moduleCode) {
|
||||
return course
|
||||
}
|
||||
return {
|
||||
...course,
|
||||
visible: { ...course.visible, visibility: newVisibility },
|
||||
}
|
||||
}),
|
||||
}))
|
||||
const changeVisibility = (courses: ProgrammeCourse[], newVisibility: boolean, successMessage: string) => {
|
||||
const request = { courseGroupIds: courses.map(course => course.group_id), curriculumVersion: version }
|
||||
const mutation = newVisibility
|
||||
? removeExclusion({ ...request, programmeCode: exclusionProgramme })
|
||||
: setExclusion({ ...request, programmeCode: exclusionProgramme })
|
||||
setCoursesVisibility(
|
||||
courses.map(course => course.code),
|
||||
newVisibility
|
||||
)
|
||||
mutation
|
||||
.unwrap()
|
||||
.then(() => setStatusNotification(`${successMessage} ${reloadNote}`, 'success'))
|
||||
.catch(() => {
|
||||
setCoursesVisibility(
|
||||
courses.map(course => course.code),
|
||||
!newVisibility
|
||||
)
|
||||
setStatusNotification('Failed to change visibility', 'error')
|
||||
})
|
||||
}
|
||||
|
||||
const areModuleCoursesVisible = (moduleCode: string) => {
|
||||
if (!moduleCoursesVisible[version]) {
|
||||
setModuleCoursesVisible(prev => ({ ...prev, [version]: {} }))
|
||||
return false
|
||||
const toggleCourseVisibility = (course: ProgrammeCourse) => {
|
||||
const newVisibility = !course.visible.visibility
|
||||
const name = getTextIn(course.name)
|
||||
changeVisibility([course], newVisibility, `${name} is now ${newVisibility ? 'shown' : 'hidden'}.`)
|
||||
}
|
||||
|
||||
const toggleModuleVisibility = (moduleCode: string) => {
|
||||
const { courses } = getModule(moduleCode)
|
||||
const visibility = getVisibility(moduleCode)
|
||||
const name = getModuleName(moduleCode)
|
||||
|
||||
if (visibility === CourseVisibility.HIDDEN) {
|
||||
changeVisibility(courses, true, `All courses in ${name} are now shown.`)
|
||||
return
|
||||
}
|
||||
return moduleCoursesVisible[version][moduleCode]
|
||||
|
||||
if (visibility === CourseVisibility.PARTIAL) {
|
||||
setModuleToHide(moduleCode)
|
||||
return
|
||||
}
|
||||
hideModule(moduleCode)
|
||||
}
|
||||
|
||||
const excludeOne = (course: ProgrammeCourse) => {
|
||||
void setExclusion({
|
||||
courseGroupIds: [course.group_id],
|
||||
curriculumVersion: version,
|
||||
programmeCode: combinedProgramme === '' ? studyProgramme : combinedProgramme,
|
||||
})
|
||||
setCourseVisibility(course.code, false)
|
||||
const hideModule = (moduleCode: string) => {
|
||||
const visibleCourses = getModule(moduleCode).courses.filter(course => course.visible.visibility)
|
||||
changeVisibility(visibleCourses, false, `All courses in ${getModuleName(moduleCode)} are now hidden.`)
|
||||
}
|
||||
|
||||
const excludeAll = (moduleCode: string) => {
|
||||
const module = getModule(moduleCode)
|
||||
const excludeFromProgramme = combinedProgramme === '' ? studyProgramme : combinedProgramme
|
||||
void setExclusion({
|
||||
courseGroupIds: module.courses.filter(course => course.visible.visibility).map(course => course.group_id),
|
||||
curriculumVersion: version,
|
||||
programmeCode: excludeFromProgramme,
|
||||
})
|
||||
setModuleVisibility(moduleCode, false)
|
||||
const toggleCriteriaYear = (course: ProgrammeCourse, { key, year }: CriteriaYearOption) => {
|
||||
const current = criteriaCourses[key] ?? []
|
||||
const isRequired = current.includes(course.group_id)
|
||||
const courseGroupIds = isRequired
|
||||
? current.filter(groupId => groupId !== course.group_id)
|
||||
: [...current, course.group_id]
|
||||
const name = getTextIn(course.name)
|
||||
|
||||
setPendingCriteria({ courses: { ...criteriaCourses, [key]: courseGroupIds }, source: criteria })
|
||||
addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
|
||||
.unwrap()
|
||||
.then(() =>
|
||||
setStatusNotification(
|
||||
isRequired ? `${name} removed from year ${year} criteria` : `${name} added to year ${year} criteria`,
|
||||
'success'
|
||||
)
|
||||
)
|
||||
.catch(() => {
|
||||
setPendingCriteria(null)
|
||||
setStatusNotification('Failed to update criteria', 'error')
|
||||
})
|
||||
}
|
||||
|
||||
const removeOne = (course: ProgrammeCourse) => {
|
||||
void removeExclusion({
|
||||
courseGroupIds: [course.group_id],
|
||||
curriculumVersion: version,
|
||||
programmeCode: studyProgramme,
|
||||
})
|
||||
setCourseVisibility(course.code, true)
|
||||
}
|
||||
const isModuleExpanded = (moduleCode: string) => expandedModules[version]?.[moduleCode] ?? false
|
||||
|
||||
const deleteAll = (moduleCode: string) => {
|
||||
const module = getModule(moduleCode)
|
||||
void removeExclusion({
|
||||
courseGroupIds: module.courses.map(course => course.group_id),
|
||||
curriculumVersion: version,
|
||||
programmeCode: studyProgramme,
|
||||
})
|
||||
setModuleVisibility(moduleCode, true)
|
||||
}
|
||||
|
||||
const toggleVisible = (moduleCode: string) => {
|
||||
const newState = !areModuleCoursesVisible(moduleCode)
|
||||
setModuleCoursesVisible({
|
||||
...moduleCoursesVisible,
|
||||
[version]: { ...moduleCoursesVisible[version], [moduleCode]: newState },
|
||||
const toggleExpanded = (moduleCode: string) => {
|
||||
setExpandedModules({
|
||||
...expandedModules,
|
||||
[version]: { ...expandedModules[version], [moduleCode]: !isModuleExpanded(moduleCode) },
|
||||
})
|
||||
}
|
||||
|
||||
const getVisibility = (moduleCode: string) => {
|
||||
const module = getModule(moduleCode)
|
||||
if (!module.courses) {
|
||||
if (!module.courses?.length) {
|
||||
return CourseVisibility.NO_COURSES
|
||||
}
|
||||
if (module.courses.every(course => course.visible.visibility)) {
|
||||
@@ -152,104 +184,151 @@ export const DegreeCourseTable = ({
|
||||
return CourseVisibility.PARTIAL
|
||||
}
|
||||
|
||||
const countRequired = (courses: ProgrammeCourse[], { key }: CriteriaYearOption) =>
|
||||
courses.filter(course => criteriaCourses[key]?.includes(course.group_id)).length
|
||||
|
||||
const headerRowSpan = showCriteria ? 2 : 1
|
||||
const remainingToHide = moduleToHide
|
||||
? getModule(moduleToHide).courses.filter(course => course.visible.visibility).length
|
||||
: 0
|
||||
|
||||
return (
|
||||
<StyledTable showCellBorders data-cy="degree-course-table" size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Code</TableCell>
|
||||
<TableCell>Visibility</TableCell>
|
||||
{isBachelorOrLicentiateProgramme(studyProgramme) && <TableCell>Criterion labels</TableCell>}
|
||||
<TableCell colSpan={2}>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{modules.map(({ code: moduleCode, courses }) => (
|
||||
<Fragment key={`${moduleCode}-fragment`}>
|
||||
<TableRow key={moduleCode}>
|
||||
<TableCell>
|
||||
<Box alignItems="center" display="flex" justifyContent="left">
|
||||
<IconButton onClick={() => toggleVisible(moduleCode)} size="small">
|
||||
{areModuleCoursesVisible(moduleCode) ? <KeyboardArrowDownIcon /> : <KeyboardArrowRightIcon />}
|
||||
</IconButton>
|
||||
<Typography fontWeight="bold" variant="body2">
|
||||
{courses[0] && courses[0].parent_name ? getTextIn(courses[0].parent_name) : moduleCode}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{moduleCode}</TableCell>
|
||||
<TableCell>
|
||||
<VisibilityChip visibility={getVisibility(moduleCode)} />
|
||||
</TableCell>
|
||||
{isBachelorOrLicentiateProgramme(studyProgramme) && <TableCell />}
|
||||
<TableCell>
|
||||
<Stack direction="row" gap={1}>
|
||||
<ToggleVisibilityButton
|
||||
onClick={() =>
|
||||
getVisibility(moduleCode) === CourseVisibility.HIDDEN
|
||||
? deleteAll(moduleCode)
|
||||
: excludeAll(moduleCode)
|
||||
}
|
||||
visible={getVisibility(moduleCode) !== CourseVisibility.HIDDEN}
|
||||
/>
|
||||
{isBachelorOrLicentiateProgramme(studyProgramme) && (
|
||||
<IconButton disabled sx={{ visibility: 'hidden' }}>
|
||||
<FormatListNumberedIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
<>
|
||||
<StyledTable showCellBorders data-cy="degree-course-table" size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell rowSpan={headerRowSpan}>Name</TableCell>
|
||||
<TableCell rowSpan={headerRowSpan}>Code</TableCell>
|
||||
{showVisibility && <TableCell rowSpan={headerRowSpan}>Visibility</TableCell>}
|
||||
{showCriteria && (
|
||||
<TableCell align="center" colSpan={criteriaYears.length}>
|
||||
Required for year criteria
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
{showCriteria && (
|
||||
<TableRow>
|
||||
{criteriaYears.map(({ year }) => (
|
||||
<TableCell align="center" key={year}>
|
||||
Year {year}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{areModuleCoursesVisible(moduleCode) &&
|
||||
courses
|
||||
.sort((a, b) => a.code.localeCompare(b.code))
|
||||
.map(course => (
|
||||
<TableRow key={`${moduleCode}/${course.code}`} sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell>{getTextIn(course.name)}</TableCell>
|
||||
<TableCell>{course.code}</TableCell>
|
||||
)}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{modules.map(({ code: moduleCode, courses }) => {
|
||||
const moduleName = getModuleName(moduleCode)
|
||||
const expanded = isModuleExpanded(moduleCode)
|
||||
return (
|
||||
<Fragment key={`${moduleCode}-fragment`}>
|
||||
<TableRow hover onClick={() => toggleExpanded(moduleCode)} sx={{ cursor: 'pointer' }}>
|
||||
<TableCell>
|
||||
<Box alignItems="center" display="flex" justifyContent="left">
|
||||
<IconButton
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? `Hide courses of ${moduleName}` : `Show courses of ${moduleName}`}
|
||||
size="small"
|
||||
>
|
||||
{expanded ? <KeyboardArrowDownIcon /> : <KeyboardArrowRightIcon />}
|
||||
</IconButton>
|
||||
<Typography fontWeight="bold" variant="body2">
|
||||
{moduleName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{moduleCode}</TableCell>
|
||||
{showVisibility && (
|
||||
<TableCell>
|
||||
<VisibilityChip
|
||||
visibility={course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN}
|
||||
<VisibilitySwitch
|
||||
ariaLabel={`Show all courses of ${moduleName}`}
|
||||
cypress={`visibility-switch-${moduleCode}`}
|
||||
onChange={() => toggleModuleVisibility(moduleCode)}
|
||||
visibility={getVisibility(moduleCode)}
|
||||
/>
|
||||
</TableCell>
|
||||
{isBachelorOrLicentiateProgramme(studyProgramme) && (
|
||||
<TableCell>
|
||||
{Object.values(criteria.courses).length > 0 ? (
|
||||
<Stack direction="row" gap={1}>
|
||||
{Object.keys(criteria.courses).map(
|
||||
(year, index) =>
|
||||
criteria.courses[year].includes(course.group_id) && (
|
||||
<Chip color="primary" key={year} label={`year ${index + 1}`} size="small" />
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
No labels set
|
||||
)}
|
||||
{criteriaYears.map(option => {
|
||||
const requiredCount = countRequired(courses, option)
|
||||
return (
|
||||
<TableCell align="center" key={option.year}>
|
||||
{requiredCount > 0 && (
|
||||
<Typography
|
||||
color="text.secondary"
|
||||
title={`${requiredCount} of ${courses.length} courses required for year ${option.year}`}
|
||||
variant="body2"
|
||||
>
|
||||
{requiredCount} / {courses.length}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Stack direction="row" gap={1}>
|
||||
<ToggleVisibilityButton
|
||||
onClick={() => (course.visible.visibility ? excludeOne(course) : removeOne(course))}
|
||||
visible={course.visible.visibility}
|
||||
/>
|
||||
{isBachelorOrLicentiateProgramme(studyProgramme) && (
|
||||
<CriterionLabelSelectButton
|
||||
course={course}
|
||||
criteria={criteria}
|
||||
studyProgramme={studyProgramme}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</StyledTable>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
{expanded &&
|
||||
[...courses]
|
||||
.sort((a, b) => a.code.localeCompare(b.code))
|
||||
.map(course => {
|
||||
const courseName = getTextIn(course.name)
|
||||
return (
|
||||
<TableRow key={`${moduleCode}/${course.code}`} sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell sx={{ pl: 6 }}>{courseName}</TableCell>
|
||||
<TableCell>{course.code}</TableCell>
|
||||
{showVisibility && (
|
||||
<TableCell>
|
||||
<VisibilitySwitch
|
||||
ariaLabel={`Show ${courseName}`}
|
||||
cypress={`visibility-switch-${course.code}`}
|
||||
onChange={() => toggleCourseVisibility(course)}
|
||||
visibility={
|
||||
course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{criteriaYears.map(option => (
|
||||
<TableCell align="center" key={option.year} padding="checkbox">
|
||||
<Checkbox
|
||||
checked={criteriaCourses[option.key]?.includes(course.group_id) ?? false}
|
||||
data-cy={`criteria-checkbox-${option.year}-${course.code}`}
|
||||
inputProps={{ 'aria-label': `${courseName} required for year ${option.year}` }}
|
||||
onChange={() => toggleCriteriaYear(course, option)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</StyledTable>
|
||||
<Dialog onClose={() => setModuleToHide(null)} open={moduleToHide !== null}>
|
||||
<DialogTitle>Hide the whole module?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{moduleToHide &&
|
||||
`Some courses in ${getModuleName(moduleToHide)} are already hidden. ` +
|
||||
`Hiding the module hides its remaining ${remainingToHide} ` +
|
||||
`${remainingToHide === 1 ? 'course' : 'courses'} for all Oodikone users.`}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setModuleToHide(null)}>Cancel</Button>
|
||||
<Button
|
||||
data-cy="confirm-hide-module-button"
|
||||
onClick={() => {
|
||||
hideModule(moduleToHide!)
|
||||
setModuleToHide(null)
|
||||
}}
|
||||
variant="contained"
|
||||
>
|
||||
Hide module
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const textContent = {
|
||||
export const ManageCoursesShownInfo = () => {
|
||||
const { getTextIn } = useLanguage()
|
||||
return (
|
||||
<StyledMessage showIcon variant="outlined" severity="warning" sx={{ mb: 2, maxWidth: '100%' }}>
|
||||
<StyledMessage showIcon variant="outlined" severity="warning" sx={{ maxWidth: '100%' }}>
|
||||
{getTextIn(textContent)}
|
||||
</StyledMessage>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isMedicalProgramme } from '@/util/studyProgramme'
|
||||
import { ProgressCriteria } from '@oodikone/shared/types'
|
||||
|
||||
export type CriteriaYearKey = keyof ProgressCriteria['courses']
|
||||
|
||||
export type CriteriaYearOption = {
|
||||
key: CriteriaYearKey
|
||||
label: string
|
||||
year: number
|
||||
}
|
||||
|
||||
const allCriteriaYears: CriteriaYearOption[] = [
|
||||
{ key: 'yearOne', label: 'First', year: 1 },
|
||||
{ key: 'yearTwo', label: 'Second', year: 2 },
|
||||
{ key: 'yearThree', label: 'Third', year: 3 },
|
||||
{ key: 'yearFour', label: 'Fourth', year: 4 },
|
||||
{ key: 'yearFive', label: 'Fifth', year: 5 },
|
||||
{ key: 'yearSix', label: 'Sixth', year: 6 },
|
||||
]
|
||||
|
||||
/** Academic years that can have progress criteria in the given programme (six for medicine, otherwise three) */
|
||||
export const getCriteriaYears = (programmeCode: string) =>
|
||||
isMedicalProgramme(programmeCode) ? allCriteriaYears : allCriteriaYears.slice(0, 3)
|
||||
@@ -1,4 +1,7 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Tab from '@mui/material/Tab'
|
||||
import Tabs from '@mui/material/Tabs'
|
||||
|
||||
import { orderBy } from 'lodash-es'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -7,6 +10,7 @@ import { useCurriculum } from '@/hooks/useCurriculum'
|
||||
import { CreditCriteriaSection } from '@/pages/StudyProgramme/DegreeCoursesTab/CreditCriteriaSection'
|
||||
import { CurriculumSection } from '@/pages/StudyProgramme/DegreeCoursesTab/CurriculumSection'
|
||||
import { DegreeCourseTable } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable'
|
||||
import { ManageCoursesShownInfo } from '@/pages/StudyProgramme/DegreeCoursesTab/InfoBox'
|
||||
import { useGetProgressCriteriaQuery } from '@/redux/progressCriteria'
|
||||
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
|
||||
import { Module, ProgrammeCourse } from '@oodikone/shared/types'
|
||||
@@ -14,13 +18,29 @@ import { Section } from '@/components/Section'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { studyProgrammeToolTips } from '@/common/InfoToolTips'
|
||||
|
||||
export type DegreeSettingsTab = 'criteria' | 'visibility'
|
||||
|
||||
const sectionTexts = {
|
||||
criteria: {
|
||||
title: 'Course completion academic year targets',
|
||||
toolTip: studyProgrammeToolTips.degreeCoursesTab.requiredCourses,
|
||||
},
|
||||
visibility: {
|
||||
title: 'Alter course and module visibility',
|
||||
toolTip: studyProgrammeToolTips.degreeCoursesTab.courseVisibility,
|
||||
},
|
||||
}
|
||||
|
||||
export const DegreeCoursesTab = ({
|
||||
combinedProgramme,
|
||||
degreeProgramme,
|
||||
initialTab = 'criteria',
|
||||
}: {
|
||||
combinedProgramme: string
|
||||
degreeProgramme: string
|
||||
initialTab?: DegreeSettingsTab
|
||||
}) => {
|
||||
const [currentTab, setCurrentTab] = useState<DegreeSettingsTab>(initialTab)
|
||||
const [defaultProgrammeModules, setDefaultProgrammeModules] = useState<Module[]>([])
|
||||
const [secondProgrammeModules, setSecondProgrammeModules] = useState<Module[]>([])
|
||||
const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: degreeProgramme })
|
||||
@@ -58,23 +78,21 @@ export const DegreeCoursesTab = ({
|
||||
|
||||
if (!curriculum || !criteria) return null
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<CurriculumSection curriculum={curriculum} curriculumList={curriculumList} setCurriculum={setCurriculum} />
|
||||
{isBachelorOrLicentiateProgramme(degreeProgramme) && (
|
||||
<CreditCriteriaSection criteria={criteria} degreeProgramme={degreeProgramme} />
|
||||
)}
|
||||
{curriculum && (!!defaultProgrammeModules.length || !!secondProgrammeModules.length) && (
|
||||
<Section
|
||||
title="Course & module criterion and visibility"
|
||||
infoBoxContent={getTextIn(studyProgrammeToolTips.degreeCoursesTab.programmeCriteriaAndVisibility) ?? ''}
|
||||
>
|
||||
const hasCriteria = isBachelorOrLicentiateProgramme(degreeProgramme)
|
||||
const hasModules = !!defaultProgrammeModules.length || !!secondProgrammeModules.length
|
||||
|
||||
const renderCourseTables = (tab: DegreeSettingsTab) =>
|
||||
hasModules && (
|
||||
<Section infoBoxContent={getTextIn(sectionTexts[tab].toolTip) ?? ''} title={sectionTexts[tab].title}>
|
||||
<Stack gap={2}>
|
||||
{!!defaultProgrammeModules.length && (
|
||||
<DegreeCourseTable
|
||||
combinedProgramme=""
|
||||
criteria={criteria}
|
||||
curriculumVersion={curriculum.periodIds}
|
||||
modules={defaultProgrammeModules}
|
||||
showCriteria={tab === 'criteria'}
|
||||
showVisibility={tab === 'visibility'}
|
||||
studyProgramme={degreeProgramme}
|
||||
/>
|
||||
)}
|
||||
@@ -84,10 +102,53 @@ export const DegreeCoursesTab = ({
|
||||
criteria={criteria}
|
||||
curriculumVersion={curriculum.periodIds}
|
||||
modules={secondProgrammeModules}
|
||||
showCriteria={tab === 'criteria'}
|
||||
showVisibility={tab === 'visibility'}
|
||||
studyProgramme={degreeProgramme}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Stack>
|
||||
</Section>
|
||||
)
|
||||
|
||||
const curriculumSection = (
|
||||
<CurriculumSection curriculum={curriculum} curriculumList={curriculumList} setCurriculum={setCurriculum} />
|
||||
)
|
||||
|
||||
const visibilityPanel = (
|
||||
<Stack gap={2}>
|
||||
{curriculumSection}
|
||||
{renderCourseTables('visibility')}
|
||||
</Stack>
|
||||
)
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<ManageCoursesShownInfo />
|
||||
{hasCriteria ? (
|
||||
<>
|
||||
<Tabs
|
||||
data-cy="degree-settings-tabs"
|
||||
onChange={(_event, newTab: DegreeSettingsTab) => setCurrentTab(newTab)}
|
||||
value={currentTab}
|
||||
>
|
||||
<Tab data-cy="criteria-tab" label="Progress tab criteria" value="criteria" />
|
||||
<Tab data-cy="visibility-tab" label="Course & module visibility" value="visibility" />
|
||||
</Tabs>
|
||||
{/* Both panels stay mounted so unsaved table state (visibility, expanded modules) survives tab switches */}
|
||||
<Box data-cy="criteria-panel" hidden={currentTab !== 'criteria'}>
|
||||
<Stack gap={2}>
|
||||
{curriculumSection}
|
||||
<CreditCriteriaSection criteria={criteria} degreeProgramme={degreeProgramme} />
|
||||
{renderCourseTables('criteria')}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box data-cy="visibility-panel" hidden={currentTab !== 'visibility'}>
|
||||
{visibilityPanel}
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box data-cy="visibility-panel">{visibilityPanel}</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -135,7 +135,7 @@ export const StudyProgramme = () => {
|
||||
tabs.push(
|
||||
{
|
||||
key: 'DegreeCoursesTab',
|
||||
label: 'Degree courses',
|
||||
label: 'Degree programme settings',
|
||||
component: (
|
||||
<CurriculumProvider key="DegreeCoursesTabContent" programmeCode={programmeId} year={new Date().getFullYear()}>
|
||||
<DegreeCoursesTab combinedProgramme={secondProgrammeId} degreeProgramme={programmeId} />
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { test, expect } from '@playwright/experimental-ct-react'
|
||||
|
||||
import { DegreeCourseTableWrapper } from './DegreeCourseTableWrapper'
|
||||
|
||||
test.describe('DegreeCourseTable', () => {
|
||||
let criteriaRequests: unknown[]
|
||||
let exclusionRequests: { method: string; body: unknown }[]
|
||||
|
||||
test.beforeEach(async ({ router }) => {
|
||||
criteriaRequests = []
|
||||
exclusionRequests = []
|
||||
await router.route('**/api/login', route => route.fulfill({ json: { user: { roles: [] } } }))
|
||||
await router.route('**/api/banners', route => route.fulfill({ json: [] }))
|
||||
await router.route('**/api/programme-criteria/courses', route => {
|
||||
criteriaRequests.push(route.request().postDataJSON())
|
||||
return route.fulfill({ json: {} })
|
||||
})
|
||||
await router.route('**/api/course-exclusions/', route => {
|
||||
exclusionRequests.push({ method: route.request().method(), body: route.request().postDataJSON() })
|
||||
return route.fulfill({ json: [] })
|
||||
})
|
||||
})
|
||||
|
||||
test('shows year columns and required course counts instead of criterion labels', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper />)
|
||||
|
||||
await expect(component.getByRole('columnheader')).toHaveText([
|
||||
'Name',
|
||||
'Code',
|
||||
'Visibility',
|
||||
'Required for year criteria',
|
||||
'Year 1',
|
||||
'Year 2',
|
||||
'Year 3',
|
||||
])
|
||||
await expect(component.getByText('1 / 2')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows six years for medicine', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper studyProgramme="KH90_001" />)
|
||||
|
||||
await expect(component.getByRole('columnheader', { name: 'Year 6' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('checking a year saves the criteria once', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper />)
|
||||
await component.getByText('Basic studies').click()
|
||||
|
||||
const checkbox = component.getByRole('checkbox', { name: 'Linear algebra required for year 1' })
|
||||
await checkbox.check()
|
||||
|
||||
await expect(checkbox).toBeChecked()
|
||||
await expect(component.getByText('2 / 2')).toBeVisible()
|
||||
expect(criteriaRequests).toEqual([{ code: 'KH50_001', courseGroupIds: ['group-MAT101', 'group-MAT102'], year: 1 }])
|
||||
})
|
||||
|
||||
test('unchecking a year saves the criteria once', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper />)
|
||||
await component.getByText('Basic studies').click()
|
||||
|
||||
await component.getByRole('checkbox', { name: 'Calculus I required for year 1' }).uncheck()
|
||||
|
||||
await expect.poll(() => criteriaRequests).toEqual([{ code: 'KH50_001', courseGroupIds: [], year: 1 }])
|
||||
})
|
||||
|
||||
test('visibility-only table hides criteria columns and toggles course visibility', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper showCriteria={false} />)
|
||||
|
||||
await expect(component.getByRole('columnheader')).toHaveText(['Name', 'Code', 'Visibility'])
|
||||
|
||||
await component.getByText('Basic studies').click()
|
||||
await component.getByRole('checkbox', { name: 'Show Calculus I' }).uncheck()
|
||||
|
||||
await expect(component.getByText('Partly shown')).toBeVisible()
|
||||
await expect
|
||||
.poll(() => exclusionRequests)
|
||||
.toEqual([
|
||||
{
|
||||
method: 'POST',
|
||||
body: { courseGroupIds: ['group-MAT101'], curriculumVersion: 'period-1', programmeCode: 'KH50_001' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('hiding a partly hidden module asks for confirmation', async ({ mount, page }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper partlyHidden />)
|
||||
|
||||
// The switch stays on until the user confirms
|
||||
await component.getByRole('checkbox', { name: 'Show all courses of Basic studies' }).click()
|
||||
await expect(page.getByRole('dialog')).toContainText('remaining 1 course for all')
|
||||
expect(exclusionRequests).toEqual([])
|
||||
|
||||
await page.getByRole('button', { name: 'Hide module' }).click()
|
||||
|
||||
await expect(component.getByText('Hidden', { exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => exclusionRequests)
|
||||
.toEqual([
|
||||
{
|
||||
method: 'POST',
|
||||
body: { courseGroupIds: ['group-MAT101'], curriculumVersion: 'period-1', programmeCode: 'KH50_001' },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { DegreeCourseTable } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable'
|
||||
import { Module, ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { ReduxWrapper } from '../../ReduxWrapper'
|
||||
|
||||
const course = (code: string, name: string, visible = true) =>
|
||||
({
|
||||
code,
|
||||
group_id: `group-${code}`,
|
||||
module_order: 0,
|
||||
name: { fi: name, en: name },
|
||||
parent_code: 'MOD1',
|
||||
parent_name: { fi: 'Basic studies', en: 'Basic studies' },
|
||||
visible: { id: null, visibility: visible },
|
||||
}) as unknown as ProgrammeCourse
|
||||
|
||||
const criteria: ProgressCriteria = {
|
||||
allCourseGroups: {},
|
||||
courses: {
|
||||
yearOne: ['group-MAT101'],
|
||||
yearTwo: [],
|
||||
yearThree: [],
|
||||
yearFour: [],
|
||||
yearFive: [],
|
||||
yearSix: [],
|
||||
},
|
||||
credits: { yearOne: 0, yearTwo: 0, yearThree: 0, yearFour: 0, yearFive: 0, yearSix: 0 },
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts `DegreeCourseTable` with one module of two courses. MAT101 is required for year 1.
|
||||
* With `partlyHidden`, MAT102 is hidden.
|
||||
*/
|
||||
export const DegreeCourseTableWrapper = ({
|
||||
partlyHidden = false,
|
||||
showCriteria = true,
|
||||
showVisibility = true,
|
||||
studyProgramme = 'KH50_001',
|
||||
}: {
|
||||
partlyHidden?: boolean
|
||||
showCriteria?: boolean
|
||||
showVisibility?: boolean
|
||||
studyProgramme?: string
|
||||
}) => {
|
||||
const modules: Module[] = [
|
||||
{
|
||||
code: 'MOD1',
|
||||
courses: [course('MAT101', 'Calculus I'), course('MAT102', 'Linear algebra', !partlyHidden)],
|
||||
order: 0,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<ReduxWrapper
|
||||
component={
|
||||
<DegreeCourseTable
|
||||
combinedProgramme=""
|
||||
criteria={criteria}
|
||||
curriculumVersion={['period-1']}
|
||||
modules={modules}
|
||||
showCriteria={showCriteria}
|
||||
showVisibility={showVisibility}
|
||||
studyProgramme={studyProgramme}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, expect } from '@playwright/experimental-ct-react'
|
||||
|
||||
import { DegreeCoursesTabWrapper } from './DegreeCoursesTabWrapper'
|
||||
|
||||
const course = {
|
||||
code: 'MAT101',
|
||||
group_id: 'group-MAT101',
|
||||
module_order: 0,
|
||||
name: { fi: 'Calculus I', en: 'Calculus I' },
|
||||
parent_code: 'MOD1',
|
||||
parent_name: { fi: 'Basic studies', en: 'Basic studies' },
|
||||
visible: { id: null, visibility: true },
|
||||
}
|
||||
|
||||
const emptyYears = { yearOne: [], yearTwo: [], yearThree: [], yearFour: [], yearFive: [], yearSix: [] }
|
||||
|
||||
test.describe('DegreeCoursesTab', () => {
|
||||
test.beforeEach(async ({ router }) => {
|
||||
await router.route('**/api/login', route => route.fulfill({ json: { user: { roles: [] } } }))
|
||||
await router.route('**/api/banners', route => route.fulfill({ json: [] }))
|
||||
await router.route('**/api/curriculum-options/*', route =>
|
||||
route.fulfill({ json: [{ id: 'cur-1', name: '2023–2026', periodIds: ['period-1'], validFrom: '2023-08-01' }] })
|
||||
)
|
||||
await router.route('**/api/curriculum/*/*', route =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
defaultProgrammeCourses: [course],
|
||||
defaultProgrammeModules: [],
|
||||
secondProgrammeCourses: [],
|
||||
secondProgrammeModules: [],
|
||||
},
|
||||
})
|
||||
)
|
||||
await router.route('**/api/programme-criteria?*', route =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
allCourseGroups: {},
|
||||
courses: emptyYears,
|
||||
credits: { yearOne: 0, yearTwo: 0, yearThree: 0, yearFour: 0, yearFive: 0, yearSix: 0 },
|
||||
},
|
||||
})
|
||||
)
|
||||
await router.route('**/api/course-exclusions/', route => route.fulfill({ json: [] }))
|
||||
})
|
||||
|
||||
test('shows the global settings warning and opens the criteria tab by default', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCoursesTabWrapper programme="KH50_001" />)
|
||||
|
||||
await expect(component.getByRole('alert')).toContainText(/non-personal|eivät ole henkilökohtaisia/)
|
||||
await expect(component.locator('[data-cy="criteria-panel"] [data-cy="curriculum-section"]')).toBeVisible()
|
||||
await expect(component.getByRole('tab')).toHaveText(['Progress tab criteria', 'Course visibility'])
|
||||
await expect(component.getByRole('tab', { name: 'Progress tab criteria' })).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(component.locator('[data-cy="credit-criteria-section"]')).toBeVisible()
|
||||
await expect(component.locator('[data-cy="visibility-panel"]')).toBeHidden()
|
||||
})
|
||||
|
||||
test('opens the given tab and still allows switching to the other one', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCoursesTabWrapper initialTab="visibility" programme="KH50_001" />)
|
||||
|
||||
await expect(component.locator('[data-cy="visibility-panel"]')).toBeVisible()
|
||||
await expect(component.locator('[data-cy="criteria-panel"]')).toBeHidden()
|
||||
await expect(component.locator('[data-cy="visibility-panel"] [data-cy="curriculum-section"]')).toBeVisible()
|
||||
|
||||
await component.getByRole('tab', { name: 'Progress tab criteria' }).click()
|
||||
|
||||
await expect(component.locator('[data-cy="criteria-panel"]')).toBeVisible()
|
||||
await expect(component.locator('[data-cy="visibility-panel"]')).toBeHidden()
|
||||
})
|
||||
|
||||
test('keeps visibility changes when switching tabs', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCoursesTabWrapper initialTab="visibility" programme="KH50_001" />)
|
||||
const visibilityPanel = component.locator('[data-cy="visibility-panel"]')
|
||||
|
||||
await visibilityPanel.getByText('Basic studies').click()
|
||||
await visibilityPanel.getByRole('checkbox', { name: 'Show Calculus I' }).uncheck()
|
||||
await component.getByRole('tab', { name: 'Progress tab criteria' }).click()
|
||||
await component.getByRole('tab', { name: 'Course visibility' }).click()
|
||||
|
||||
await expect(visibilityPanel.getByRole('checkbox', { name: 'Show Calculus I' })).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('has no tabs for a programme without progress criteria', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCoursesTabWrapper programme="MH50_001" />)
|
||||
|
||||
await expect(component.locator('[data-cy="visibility-panel"]')).toBeVisible()
|
||||
await expect(component.getByRole('tab')).toHaveCount(0)
|
||||
await expect(component.locator('[data-cy="credit-criteria-section"]')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CurriculumProvider } from '@/hooks/useCurriculum'
|
||||
import { DegreeCoursesTab, DegreeSettingsTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
|
||||
import { ReduxWrapper } from '../../ReduxWrapper'
|
||||
|
||||
/**
|
||||
* Mounts `DegreeCoursesTab` with a curriculum provider. Mock the curriculum and criteria APIs before mounting.
|
||||
*/
|
||||
export const DegreeCoursesTabWrapper = ({
|
||||
initialTab,
|
||||
programme,
|
||||
}: {
|
||||
initialTab?: DegreeSettingsTab
|
||||
programme: string
|
||||
}) => (
|
||||
<ReduxWrapper
|
||||
component={
|
||||
<CurriculumProvider programmeCode={programme} year={2024}>
|
||||
<DegreeCoursesTab combinedProgramme="" degreeProgramme={programme} initialTab={initialTab} />
|
||||
</CurriculumProvider>
|
||||
}
|
||||
/>
|
||||
)
|
||||
Reference in New Issue
Block a user