mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
[Course refactor] Course stats work
Moving from using only codes to working with group_ids
This commit is contained in:
committed by
Aaro Koivukangas
parent
9c40be64e8
commit
b7934d642b
@@ -39,11 +39,9 @@ export type CourseYearlyStatsResBody = CourseYearlyStats[]
|
||||
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
|
||||
'/courseyearlystats',
|
||||
async (req, res) => {
|
||||
const { courses, combineSubstitutions, separate, fromYearCode, toYearCode } = req.query
|
||||
const { courses, substitutions, separate, fromYearCode, toYearCode } = req.query
|
||||
|
||||
const courseGroupIds = handleQueryArrays(courses)
|
||||
|
||||
if (!courseGroupIds.length) {
|
||||
if (!courses || !courses.length) {
|
||||
return res.status(422).send({ error: 'Missing required query parameters' })
|
||||
}
|
||||
|
||||
@@ -62,14 +60,13 @@ router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody,
|
||||
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
|
||||
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
|
||||
|
||||
const useCombineSubstitutions = combineSubstitutions !== 'false'
|
||||
const useSeparate = separate === 'true'
|
||||
|
||||
const results = await getCourseYearlyStats(
|
||||
courseGroupIds,
|
||||
handleQueryArrays(courses),
|
||||
useSeparate,
|
||||
anonymizationSalt,
|
||||
useCombineSubstitutions,
|
||||
substitutions === 'true',
|
||||
fromYearCode,
|
||||
toYearCode
|
||||
)
|
||||
|
||||
@@ -56,6 +56,11 @@ export const getCreditsForCourses = async (
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: CourseModel,
|
||||
attributes: ['groupId'],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -169,6 +174,7 @@ export const getEnrollmentsForCourses = async (
|
||||
{
|
||||
model: CourseModel,
|
||||
attributes: ['groupId'],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -190,38 +190,30 @@ export const getYearlyStatsOfNew =
|
||||
primaryCourse: Pick<CourseModel, 'id' | 'groupId' | 'code' | 'name' | 'substitutionGroups'>,
|
||||
separate: boolean,
|
||||
anonymizationSalt: string | null,
|
||||
combineSubstitutions: boolean,
|
||||
substitutions: boolean,
|
||||
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>,
|
||||
from: Date,
|
||||
to: Date
|
||||
) =>
|
||||
async (unification: Unification): Promise<CourseYearlyStats['openStats']> => {
|
||||
const { id, groupId, name, code: courseCode } = primaryCourse
|
||||
const { groupId, name, code: courseCode } = primaryCourse
|
||||
|
||||
// Includes main course code and substitutions (if enabled)
|
||||
const creditGroupIds = combineSubstitutions
|
||||
? [[groupId]].concat(primaryCourse.substitutionGroups ?? [])
|
||||
: [[groupId]]
|
||||
|
||||
const filteredCreditGroupIds =
|
||||
unification === Unification.REGULAR
|
||||
? // FIXME: isOpenUniCourseCode does not work yet with groupIds
|
||||
creditGroupIds.filter(group => group.every(course => !isOpenUniCourseCode(course)))
|
||||
: creditGroupIds
|
||||
const creditGroupIds = substitutions ? [[groupId]].concat(primaryCourse.substitutionGroups ?? []) : [[groupId]]
|
||||
|
||||
const allCourseIds = (
|
||||
await CourseModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['id'],
|
||||
where: { groupId: { [Op.in]: filteredCreditGroupIds.flat() } },
|
||||
where: { groupId: { [Op.in]: creditGroupIds.flat() } },
|
||||
})
|
||||
).map(({ id }) => id)
|
||||
|
||||
const { semesters, years } = await getSemestersAndYears()
|
||||
|
||||
const [creditGroups, enrollmentGroups] = await Promise.all([
|
||||
getCreditsForCourses(filteredCreditGroupIds, allCourseIds, unification, from, to),
|
||||
getEnrollmentsForCourses(filteredCreditGroupIds, allCourseIds, unification, from, to),
|
||||
getCreditsForCourses(creditGroupIds, allCourseIds, unification, from, to),
|
||||
getEnrollmentsForCourses(creditGroupIds, allCourseIds, unification, from, to),
|
||||
])
|
||||
|
||||
const counter = new CourseYearlyStatsCounter()
|
||||
@@ -346,7 +338,7 @@ export const getYearlyStatsOfNew =
|
||||
const statistics = await counter.getFinalStatistics(anonymizationSalt)
|
||||
|
||||
const substitutionGroups =
|
||||
combineSubstitutions && primaryCourse.substitutionGroups?.length
|
||||
substitutions && primaryCourse.substitutionGroups?.length
|
||||
? await getSubstitutionGroupDetails(primaryCourse.substitutionGroups)
|
||||
: [[{ code: courseCode, name, groupId }]]
|
||||
|
||||
@@ -398,7 +390,7 @@ export const getCourseYearlyStats = async (
|
||||
courseGroupIds: string[],
|
||||
separate: boolean,
|
||||
anonymizationSalt: string | null,
|
||||
combineSubstitutions: boolean,
|
||||
substitutions: boolean,
|
||||
fromYearCode = yearToYearCode(1950).toString(),
|
||||
toYearCode: string = yearToYearCode(now().getFullYear()).toString()
|
||||
) => {
|
||||
@@ -409,7 +401,7 @@ export const getCourseYearlyStats = async (
|
||||
console.log('groupIds', courseGroupIds)
|
||||
console.log('separate', separate)
|
||||
console.log('anonymizationSalt', anonymizationSalt)
|
||||
console.log('combineSubstitutions', combineSubstitutions)
|
||||
console.log('substitutions', substitutions)
|
||||
console.log('fromYearCode ', fromYearCode)
|
||||
console.log('toYearCode', toYearCode)
|
||||
|
||||
@@ -448,8 +440,6 @@ export const getCourseYearlyStats = async (
|
||||
}),
|
||||
])
|
||||
|
||||
console.log(from, to, credits.length, enrollments.length)
|
||||
|
||||
const studentNumbers = new Set<string>()
|
||||
|
||||
credits.forEach(credit => {
|
||||
@@ -462,8 +452,6 @@ export const getCourseYearlyStats = async (
|
||||
|
||||
const studentNumberToSrElementsMap = await getStudentNumberToSrElementsMap([...studentNumbers])
|
||||
|
||||
console.log('studentnumbers', [...studentNumbers].join(','))
|
||||
|
||||
const stats = await Promise.all(
|
||||
courseGroupIds.map(async groupId => {
|
||||
const primaryCourse: Pick<CourseModel, 'id' | 'groupId' | 'code' | 'name' | 'substitutionGroups'> | null =
|
||||
@@ -474,7 +462,7 @@ export const getCourseYearlyStats = async (
|
||||
})
|
||||
|
||||
if (!primaryCourse) {
|
||||
logger.error(`Primary course for course stats not found with groupId: ${groupId}`)
|
||||
logger.error(`Primary course for course stats not found for: ${groupId}`)
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -482,7 +470,7 @@ export const getCourseYearlyStats = async (
|
||||
primaryCourse,
|
||||
separate,
|
||||
anonymizationSalt,
|
||||
combineSubstitutions,
|
||||
substitutions,
|
||||
studentNumberToSrElementsMap,
|
||||
from,
|
||||
to
|
||||
|
||||
@@ -39,14 +39,13 @@ import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { useGetCourseDetailsQuery } from '@/redux/courseStats'
|
||||
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { parseQueryParams } from '@/util/queryparams'
|
||||
import { useParseQueryParams } from '@/util/queryparams'
|
||||
import { SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import { FormattedStudent } from '@oodikone/shared/types'
|
||||
|
||||
export const CoursePopulation = () => {
|
||||
useTitle('Course population')
|
||||
|
||||
const location = useLocation()
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const [codes, setCodes] = useState<{ allCodes: string[]; mainCodes: string[] }>({
|
||||
@@ -54,14 +53,21 @@ export const CoursePopulation = () => {
|
||||
mainCodes: [],
|
||||
})
|
||||
|
||||
const { coursecodes, from, to, separate, unifyCourses, includeSubstitutions } = parseQueryParams(location.search)
|
||||
const {
|
||||
coursecodes,
|
||||
from: [from],
|
||||
to: [to],
|
||||
separate: [separate],
|
||||
unifyCourses: [unifyCourses],
|
||||
includeSubstitutions: [includeSubstitutions],
|
||||
} = useParseQueryParams()
|
||||
|
||||
const { data: courseDetails } = useGetCourseDetailsQuery(
|
||||
{ codes: codes.mainCodes },
|
||||
{ skip: !codes.mainCodes.length }
|
||||
)
|
||||
const { data: population } = useGetPopulationStatisticsByCourseQuery({
|
||||
coursecodes,
|
||||
coursecodes: coursecodes[0],
|
||||
from,
|
||||
to,
|
||||
separate,
|
||||
@@ -70,8 +76,7 @@ export const CoursePopulation = () => {
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const parsedCourseCodes = JSON.parse(coursecodes)
|
||||
if (parsedCourseCodes.length) setCodes({ ...codes, mainCodes: parsedCourseCodes })
|
||||
if (coursecodes.length) setCodes({ ...codes, mainCodes: coursecodes })
|
||||
}, [coursecodes])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -106,7 +111,7 @@ export const CoursePopulation = () => {
|
||||
}
|
||||
|
||||
const isSeparate = separate ? JSON.parse(separate) : false
|
||||
const { dateFrom, dateTo } = getFromToDates(from, to, isSeparate)
|
||||
const { dateFrom, dateTo } = getFromToDates(Number(from), Number(to), isSeparate)
|
||||
|
||||
// Dates must be set
|
||||
if (!dateFrom || !dateTo) return null
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ProgressTable as ProgressTab } from '@/components/PopulationComponents/
|
||||
import { TagsTab } from '@/components/PopulationComponents/Students/Table/TagsTab'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { parseQueryParams } from '@/util/queryparams'
|
||||
import { useParseQueryParams } from '@/util/queryparams'
|
||||
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
|
||||
import { FormattedStudent } from '@oodikone/shared/types'
|
||||
|
||||
@@ -74,9 +74,10 @@ export const PopulationStudents = ({
|
||||
if (!['population', 'customPopulation', 'coursePopulation', 'studyGuidanceGroupPopulation'].includes(variant))
|
||||
throw new Error(`${variant} is not a proper variant!`)
|
||||
|
||||
const { years } = parseQueryParams(location.search)
|
||||
const { stringYears } = useParseQueryParams()
|
||||
const years = stringYears.map(Number)
|
||||
const months = years
|
||||
? dayjs().diff(dayjs(`${Math.min(years)}-08-01`), 'months')
|
||||
? dayjs().diff(dayjs(`${Math.min(...years)}-08-01`), 'months')
|
||||
: studyGuidanceGroup?.tags?.year
|
||||
? dayjs().diff(dayjs(`${studyGuidanceGroup?.tags?.year}-08-01`), 'months')
|
||||
: undefined
|
||||
|
||||
@@ -46,7 +46,7 @@ import type { PopulationQuery } from '@/types/populationSearch'
|
||||
|
||||
import { getFullStudyProgrammeRights } from '@/util/access'
|
||||
import { getCombinedProgrammeName } from '@/util/combinedProgramme'
|
||||
import { parseQueryParams } from '@/util/queryparams'
|
||||
import { useParseQueryParams } from '@/util/queryparams'
|
||||
import { DegreeProgrammeType } from '@oodikone/shared/types'
|
||||
import { formatToArray } from '@oodikone/shared/util'
|
||||
|
||||
@@ -55,7 +55,7 @@ const getYearText = (years: number[]) => (years.length >= 1 ? `${years[0]} - ${y
|
||||
const parseQueryFromUrl = (location: Location): [boolean, PopulationQuery] => {
|
||||
const skipQuery = !location.search
|
||||
const { years, semesters, programme, studentStatuses, combinedProgramme, studyTrack, showBachelorAndMaster, tag } =
|
||||
parseQueryParams(location.search)
|
||||
useParseQueryParams(location.search)
|
||||
|
||||
const dirtyQuery = {
|
||||
years: formatToArray(years).map(year => parseInt(year, 10)),
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// * Increment this if search history code changes so it will be reset for all users
|
||||
export const SEARCH_HISTORY_VERSION = '1.1'
|
||||
export const SEARCH_HISTORY_VERSION = '1.2'
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import { parseQueryParams, queryParamsToString } from '@/util/queryparams'
|
||||
import { useParseQueryParams, queryParamsToString } from '@/util/queryparams'
|
||||
|
||||
export const useTabs = (totalTabs: number, prefix?: string) => {
|
||||
'use memo'
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const params = useParseQueryParams()
|
||||
|
||||
const id = prefix ? `${prefix}-tab` : 'tab'
|
||||
|
||||
@@ -16,14 +17,11 @@ export const useTabs = (totalTabs: number, prefix?: string) => {
|
||||
return Number.isNaN(tabIndex) || tabIndex < 0 || tabIndex >= totalTabs ? initialTab : tabIndex
|
||||
}
|
||||
|
||||
const tab = useMemo(() => {
|
||||
return normalizeTab(parseQueryParams(location.search)[id])
|
||||
}, [location.search, id, normalizeTab])
|
||||
const tab = useMemo(() => normalizeTab(params[id]), [id, normalizeTab])
|
||||
|
||||
const switchToTab = (newTab: number) => {
|
||||
const nextTab = normalizeTab(newTab)
|
||||
|
||||
const params = parseQueryParams(location.search)
|
||||
const currentTab = normalizeTab(params[id])
|
||||
|
||||
if (currentTab === nextTab) return
|
||||
|
||||
@@ -5,10 +5,6 @@ import MenuItem from '@mui/material/MenuItem'
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { useAppDispatch } from '@/redux/hooks'
|
||||
|
||||
import { setSelectedCourse } from '@/redux/selectedCourse'
|
||||
|
||||
export const CourseSelector = ({
|
||||
courses,
|
||||
selected,
|
||||
@@ -18,16 +14,11 @@ export const CourseSelector = ({
|
||||
selected: string
|
||||
setSelected: (courseCode: string) => void
|
||||
}) => {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const onCourseChange = (event: SelectChangeEvent<string>) => {
|
||||
const selectedCourse = event.target.value
|
||||
setSelected(selectedCourse)
|
||||
dispatch(setSelectedCourse(selectedCourse))
|
||||
}
|
||||
|
||||
console.log(courses, selected)
|
||||
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Select course</InputLabel>
|
||||
|
||||
+5
-11
@@ -8,7 +8,7 @@ import type { CourseSearchState } from '@/pages/CourseStatistics'
|
||||
import { ResultTab } from '@/pages/CourseStatistics/CourseTab/SingleCourseStats/ResultTabs/tabs/ResultTab'
|
||||
import { PersonIcon, RefreshIcon } from '@/theme'
|
||||
import { AvailableStats, ProgrammeStats } from '@/types/courseStat'
|
||||
import { parseQueryParams, queryParamsToString } from '@/util/queryparams'
|
||||
import { useParseQueryParams, queryParamsToString } from '@/util/queryparams'
|
||||
|
||||
export const ResultTabs = ({
|
||||
availableStats,
|
||||
@@ -34,26 +34,20 @@ export const ResultTabs = ({
|
||||
courseCodes: string[]
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [tab, setTab] = useState(0)
|
||||
|
||||
const params = useParseQueryParams()
|
||||
|
||||
if (!primary) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updateSeparate = (separate: boolean) => {
|
||||
if (!location.pathname.includes('coursestatistics')) {
|
||||
return
|
||||
}
|
||||
|
||||
const { courses, ...params } = parseQueryParams(location.search)
|
||||
const query = {
|
||||
const newQueryParams = {
|
||||
...params,
|
||||
courses: JSON.parse(courses as string),
|
||||
separate,
|
||||
}
|
||||
const queryToString = { ...query, courses: JSON.stringify(query.courses) }
|
||||
void navigate({ search: queryParamsToString(queryToString) }, { replace: true })
|
||||
void navigate({ search: queryParamsToString(newQueryParams) }, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ export const AttemptsTable = ({
|
||||
const queryObject = {
|
||||
from: yearCode,
|
||||
to: yearCode,
|
||||
coursecodes: JSON.stringify(courseCodes),
|
||||
courses: courseCodes,
|
||||
separate,
|
||||
unifyCourses: openOrRegular,
|
||||
includeSubstitutions: combineSubstitutions,
|
||||
|
||||
+2
-1
@@ -62,6 +62,7 @@ export const StudentsTable = ({
|
||||
combineSubstitutions: boolean
|
||||
courseCodes: string[]
|
||||
}) => {
|
||||
'use memo'
|
||||
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
|
||||
|
||||
const showPopulation = useCallback(
|
||||
@@ -69,7 +70,7 @@ export const StudentsTable = ({
|
||||
const queryObject = {
|
||||
from: yearCode,
|
||||
to: yearCode,
|
||||
coursecodes: JSON.stringify(courseCodes),
|
||||
courses: courseCodes,
|
||||
separate,
|
||||
unifyCourses: openOrRegular,
|
||||
includeSubstitutions: combineSubstitutions,
|
||||
|
||||
+63
-114
@@ -5,7 +5,7 @@ import Tooltip from '@mui/material/Tooltip'
|
||||
|
||||
import { difference, flatten, max, min, pickBy, uniq } from 'lodash-es'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { ProgrammeDropdown } from '@/components/CourseStatistics/ProgrammeDropdown'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
@@ -17,32 +17,26 @@ import { countTotalStats } from '@/pages/CourseStatistics/CourseTab/SingleCourse
|
||||
import { ResultTabs } from '@/pages/CourseStatistics/CourseTab/SingleCourseStats/ResultTabs'
|
||||
import { YearFilter } from '@/pages/CourseStatistics/CourseTab/SingleCourseStats/YearFilter'
|
||||
import { ALL, CourseStudyProgramme } from '@/pages/CourseStatistics/util'
|
||||
import { useGetCourseStatsQuery } from '@/redux/courseStats'
|
||||
import { useAppDispatch } from '@/redux/hooks'
|
||||
import { useGetMaxYearsToCreatePopulationFromQuery } from '@/redux/populations'
|
||||
import { setSelectedCourse, clearSelectedCourse } from '@/redux/selectedCourse'
|
||||
import { DoNotDisturbIcon } from '@/theme'
|
||||
import { AvailableStats, FormattedStats, ProgrammeStats } from '@/types/courseStat'
|
||||
import { DropdownOption } from '@/types/dropdownOption'
|
||||
import { parseQueryParams, queryParamsToString } from '@/util/queryparams'
|
||||
import { useParseQueryParams, queryParamsToString } from '@/util/queryparams'
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { enrollmentTimeDateThresholdYearCode, yearToYearCode } from '@oodikone/shared/util'
|
||||
import { Attempts, Enrollment, Students } from '@oodikone/shared/types/courseYearlyStats'
|
||||
import { Attempts, CourseStat, Enrollment, Students } from '@oodikone/shared/types/courseYearlyStats'
|
||||
|
||||
const countFilteredStudents = (stat: Record<string, string[]>, filter: (studentNumber: string) => boolean) => {
|
||||
if (!stat) {
|
||||
return {} as Record<string, number>
|
||||
const countFilteredStudents = (
|
||||
stat: Record<string, string[]>,
|
||||
filter: (studentNumber: string) => boolean
|
||||
): Record<string, number> => {
|
||||
if (!stat) return {}
|
||||
|
||||
const result = {}
|
||||
for (const [category, students] of Object.entries(stat)) {
|
||||
result[category] = students.filter(filter).length
|
||||
}
|
||||
return Object.entries(stat).reduce(
|
||||
(acc, entry) => {
|
||||
const [category, students] = entry
|
||||
return {
|
||||
...acc,
|
||||
[category]: students.filter(filter).length,
|
||||
}
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export const SingleCourseStats = ({
|
||||
@@ -50,67 +44,68 @@ export const SingleCourseStats = ({
|
||||
availableStats,
|
||||
userHasAccessToAllStats,
|
||||
|
||||
stats,
|
||||
loading,
|
||||
toggleOpenAndRegularCourses,
|
||||
openOrRegular,
|
||||
programmes,
|
||||
combineSubstitutions,
|
||||
substitutions,
|
||||
toYearCode,
|
||||
fromYearCode,
|
||||
setToYearCode,
|
||||
setFromYearCode,
|
||||
}: {
|
||||
courseGroupId: string
|
||||
availableStats: AvailableStats
|
||||
userHasAccessToAllStats: boolean
|
||||
|
||||
stats: Record<string, CourseStat>
|
||||
loading: boolean
|
||||
toggleOpenAndRegularCourses: (state: CourseSearchState) => void
|
||||
openOrRegular: CourseSearchState
|
||||
programmes: CourseStudyProgramme[]
|
||||
combineSubstitutions: boolean
|
||||
substitutions: boolean
|
||||
toYearCode: number
|
||||
fromYearCode: number
|
||||
setToYearCode: any
|
||||
setFromYearCode: any
|
||||
}) => {
|
||||
'use memo'
|
||||
const [primary, setPrimary] = useState<string[]>([ALL.value])
|
||||
const [comparison, setComparison] = useState<string[]>([])
|
||||
|
||||
// These are "incorrectly" reversed to allow them to be replaced with min/max when stats arrive
|
||||
const [minFromYearCode, setMinFromYearCode] = useState(yearToYearCode(new Date().getFullYear()))
|
||||
const [maxToYearCode, setMaxToYearCode] = useState(yearToYearCode(1950))
|
||||
|
||||
const [fromYearCode, setFromYearCode] = useState(yearToYearCode(1950))
|
||||
const [toYearCode, setToYearCode] = useState(yearToYearCode(new Date().getFullYear()))
|
||||
const [separate, setSeparate] = useState<boolean>(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dispatch = useAppDispatch()
|
||||
const { getTextIn } = useLanguage()
|
||||
const { semesters, years: semesterYears } = useSemesters()
|
||||
const {
|
||||
data: courseStatistics,
|
||||
isFetching: isLoading,
|
||||
isSuccess,
|
||||
} = useGetCourseStatsQuery(
|
||||
{
|
||||
courses: [courseGroupId],
|
||||
separate,
|
||||
combineSubstitutions,
|
||||
fromYearCode: fromYearCode.toString(),
|
||||
toYearCode: toYearCode.toString(),
|
||||
},
|
||||
{ skip: loading }
|
||||
)
|
||||
|
||||
const params = useParseQueryParams()
|
||||
const separate = params.separate?.[0] === 'true'
|
||||
|
||||
const courseStats = stats[courseGroupId]
|
||||
|
||||
const minYearCode = min(courseStats.statistics.map(r => r.yearCode))!
|
||||
const maxYearCode = max(courseStats.statistics.map(r => r.yearCode))!
|
||||
|
||||
const [minFromYearCode, setMinFromYearCode] = useState(minYearCode)
|
||||
const [maxToYearCode, setMaxToYearCode] = useState(maxYearCode)
|
||||
|
||||
// Initialize filters with proper yearcodes
|
||||
useEffect(() => {
|
||||
setFromYearCode(minYearCode)
|
||||
setToYearCode(maxYearCode)
|
||||
}, [])
|
||||
|
||||
const uniqueCourseCodes = [
|
||||
...new Set(
|
||||
[courseGroupId].concat(
|
||||
courseStatistics?.[courseGroupId]?.[openOrRegular]?.substitutionGroups.flatMap(group =>
|
||||
group.flatMap(({ code }) => code)
|
||||
) ?? []
|
||||
courseStats?.substitutionGroups.flatMap(group => group.flatMap(({ groupId }) => groupId)) ?? []
|
||||
)
|
||||
),
|
||||
]
|
||||
const { data: maxYears } = useGetMaxYearsToCreatePopulationFromQuery(
|
||||
{ courseCodes: JSON.stringify(uniqueCourseCodes) },
|
||||
{ skip: isLoading || !isSuccess }
|
||||
)
|
||||
|
||||
const { data: maxYears } = useGetMaxYearsToCreatePopulationFromQuery({
|
||||
courseCodes: JSON.stringify(uniqueCourseCodes),
|
||||
})
|
||||
|
||||
const semestersReversed = Object.values(semesters ?? [])
|
||||
.map(({ semestercode, name, yearcode }) => ({
|
||||
@@ -128,16 +123,6 @@ export const SingleCourseStats = ({
|
||||
}))
|
||||
.reverse()
|
||||
|
||||
const parseQueryFromUrl = () => {
|
||||
const { separate } = parseQueryParams(location.search)
|
||||
return {
|
||||
separate: JSON.parse((separate ?? 'false') as string),
|
||||
}
|
||||
}
|
||||
|
||||
// Undefined if query is still querying
|
||||
const stats = courseStatistics?.[courseGroupId]?.[openOrRegular]
|
||||
|
||||
let maxYearsToCreatePopulationFrom = 0
|
||||
if (maxYears) {
|
||||
switch (openOrRegular) {
|
||||
@@ -152,36 +137,6 @@ export const SingleCourseStats = ({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (location.search) {
|
||||
const { separate } = parseQueryFromUrl()
|
||||
setSeparate([true, false].includes(separate) ? separate : false)
|
||||
}
|
||||
dispatch(setSelectedCourse(courseGroupId))
|
||||
|
||||
const yearCodes = stats?.statistics.map(stat => stat.yearCode)
|
||||
const initFromYear = min(yearCodes) ?? yearToYearCode(1950)
|
||||
const initToYear = max(yearCodes) ?? yearToYearCode(new Date().getFullYear())
|
||||
setFromYearCode(initFromYear)
|
||||
setToYearCode(initToYear)
|
||||
|
||||
if (yearCodes) {
|
||||
setMaxToYearCode(max([maxToYearCode, ...yearCodes]))
|
||||
setMinFromYearCode(min([minFromYearCode, ...yearCodes]))
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(clearSelectedCourse())
|
||||
}
|
||||
}, [isSuccess])
|
||||
|
||||
useEffect(() => {
|
||||
if (location.search) {
|
||||
const { separate } = parseQueryFromUrl()
|
||||
setSeparate([true, false].includes(separate) ? separate : false)
|
||||
}
|
||||
}, [location.search])
|
||||
|
||||
useEffect(() => {
|
||||
if (primary.every(course => !programmes.map(programme => programme.key).includes(course))) {
|
||||
setPrimary([ALL.value])
|
||||
@@ -195,7 +150,7 @@ export const SingleCourseStats = ({
|
||||
if (programmeCode === 'EXCLUDED') {
|
||||
return 'Excluded'
|
||||
}
|
||||
const name = stats?.programmes[programmeCode]['name']
|
||||
const name = courseStats?.programmes[programmeCode]['name']
|
||||
return getTextIn(name)!
|
||||
}
|
||||
|
||||
@@ -216,7 +171,7 @@ export const SingleCourseStats = ({
|
||||
return () => true
|
||||
}
|
||||
|
||||
const programmes = stats?.programmes
|
||||
const programmes = courseStats?.programmes
|
||||
const studentNumbers = new Set()
|
||||
codes.forEach(code => {
|
||||
if (programmes?.[code]) {
|
||||
@@ -229,24 +184,18 @@ export const SingleCourseStats = ({
|
||||
}
|
||||
|
||||
const isValidProgrammeCode = (code: string) => {
|
||||
return stats?.programmes[code] || code === ALL.value || code === 'EXCLUDED'
|
||||
return courseStats?.programmes[code] || code === ALL.value || code === 'EXCLUDED'
|
||||
}
|
||||
|
||||
const filteredYearsAndSemesters = () => {
|
||||
const getFilteredYears = () => {
|
||||
const from = minFromYearCode
|
||||
const to = maxToYearCode
|
||||
|
||||
if (from == null || to == null) {
|
||||
return {
|
||||
filteredYears: semesterYearsReversed,
|
||||
filteredSemesters: semestersReversed,
|
||||
}
|
||||
if (!from || !to) {
|
||||
return semesterYearsReversed
|
||||
}
|
||||
const timeFilter = ({ value }: { value: number }) => value >= from && value <= to
|
||||
return {
|
||||
filteredYears: semesterYearsReversed.filter(timeFilter),
|
||||
filteredSemesters: semestersReversed.filter(timeFilter),
|
||||
}
|
||||
return semesterYearsReversed.filter(timeFilter)
|
||||
}
|
||||
|
||||
const isStatInYearRange = ({ name }: { name: Name | string }) => {
|
||||
@@ -334,10 +283,10 @@ export const SingleCourseStats = ({
|
||||
}
|
||||
|
||||
const statsForProgrammes = (programmeCodes: string[], name: string) => {
|
||||
if (programmeCodes.length === 0 || !stats) {
|
||||
if (programmeCodes.length === 0 || !courseStats) {
|
||||
return undefined
|
||||
}
|
||||
const { statistics } = stats
|
||||
const { statistics } = courseStats
|
||||
const filter = belongsToAtLeastOneProgramme(programmeCodes)
|
||||
|
||||
const allStudents = statistics.filter(isStatInYearRange).reduce(
|
||||
@@ -375,7 +324,7 @@ export const SingleCourseStats = ({
|
||||
const parsedName = separate ? getTextIn(name as Name)! : name
|
||||
|
||||
return {
|
||||
name: parsedName,
|
||||
name: getTextIn(parsedName) ?? '',
|
||||
students: { ...studentStats, ...studentsEnrollments },
|
||||
attempts: { ...attemptStats, totalEnrollments },
|
||||
enrollments: filteredEnrollments,
|
||||
@@ -483,10 +432,10 @@ export const SingleCourseStats = ({
|
||||
const queryObject = {
|
||||
from: fromYearCode,
|
||||
to: toYearCode,
|
||||
coursecodes: JSON.stringify([courseGroupId]),
|
||||
courses: courseGroupId,
|
||||
separate,
|
||||
unifyCourses: openOrRegular,
|
||||
includeSubstitutions: combineSubstitutions,
|
||||
substitutions: substitutions,
|
||||
}
|
||||
const searchString = queryParamsToString(queryObject)
|
||||
void navigate(`/coursepopulation?${searchString}`)
|
||||
@@ -504,7 +453,7 @@ export const SingleCourseStats = ({
|
||||
}
|
||||
|
||||
const statistics = filteredProgrammeStatistics()
|
||||
const { filteredYears } = filteredYearsAndSemesters()
|
||||
const filteredYears = getFilteredYears()
|
||||
|
||||
const timeFilter = (_, value: string) => Number(value) >= fromYearCode && Number(value) <= toYearCode
|
||||
const filteredProgrammes = programmes
|
||||
@@ -514,7 +463,7 @@ export const SingleCourseStats = ({
|
||||
})
|
||||
.filter(programme => programme.size > 0)
|
||||
|
||||
if (!stats?.statistics || stats.statistics.length < 1) {
|
||||
if (!courseStats?.statistics.length) {
|
||||
return <Section>No data for selected course</Section>
|
||||
}
|
||||
|
||||
@@ -588,12 +537,12 @@ export const SingleCourseStats = ({
|
||||
</Stack>
|
||||
</Section>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
{loading ? (
|
||||
<LoadingSkeleton />
|
||||
) : (
|
||||
<ResultTabs
|
||||
availableStats={availableStats}
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
combineSubstitutions={substitutions}
|
||||
comparison={statistics.comparison}
|
||||
courseCodes={[courseGroupId]}
|
||||
loading={loading}
|
||||
|
||||
@@ -24,9 +24,13 @@ export const CourseTab = ({
|
||||
openOrRegular,
|
||||
stats,
|
||||
availableStats,
|
||||
combineSubstitutions,
|
||||
substitutions,
|
||||
substitutionGroups,
|
||||
programmes,
|
||||
toYearCode,
|
||||
fromYearCode,
|
||||
setToYearCode,
|
||||
setFromYearCode,
|
||||
}: {
|
||||
selected: string | undefined
|
||||
setSelected: (courseCode: string) => void
|
||||
@@ -37,9 +41,14 @@ export const CourseTab = ({
|
||||
openOrRegular: CourseSearchState
|
||||
stats: Record<string, CourseStat>
|
||||
availableStats: AvailableStats
|
||||
combineSubstitutions: boolean
|
||||
substitutions: boolean
|
||||
substitutionGroups: { code: string; name: Name; groupId: string }[][]
|
||||
programmes: CourseStudyProgramme[]
|
||||
|
||||
toYearCode: number
|
||||
fromYearCode: number
|
||||
setToYearCode: any
|
||||
setFromYearCode: any
|
||||
}) => {
|
||||
'use memo'
|
||||
const { getTextIn } = useLanguage()
|
||||
@@ -52,7 +61,7 @@ export const CourseTab = ({
|
||||
groupId,
|
||||
}))
|
||||
|
||||
if (courses.length === 0) return null
|
||||
if (!courses.length) return null
|
||||
const multipleCourses = courses.length > 1
|
||||
|
||||
return (
|
||||
@@ -68,30 +77,37 @@ export const CourseTab = ({
|
||||
name={getTextIn(stats[selected].name)!}
|
||||
/>
|
||||
</Box>
|
||||
{substitutionGroups.length ? (
|
||||
{substitutionGroups.length && (
|
||||
<>
|
||||
<Typography component="h6" variant="subtitle2">
|
||||
Substitution groups
|
||||
{`Substitution${!substitutions ? 's not enabled' : ' groups'}`}
|
||||
</Typography>
|
||||
<Grid container spacing={1}>
|
||||
{substitutionGroups.map(group => (
|
||||
<SecondaryCourseLabel getTextIn={getTextIn} group={group} key={JSON.stringify(group)} />
|
||||
))}
|
||||
</Grid>
|
||||
{substitutions && (
|
||||
<Grid container spacing={1}>
|
||||
{substitutionGroups.map(group => (
|
||||
<SecondaryCourseLabel getTextIn={getTextIn} group={group} key={JSON.stringify(group)} />
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
<SingleCourseStats
|
||||
availableStats={availableStats}
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
substitutions={substitutions}
|
||||
courseGroupId={selected}
|
||||
loading={loading}
|
||||
stats={stats}
|
||||
openOrRegular={openOrRegular}
|
||||
programmes={programmes}
|
||||
toggleOpenAndRegularCourses={toggleOpenAndRegularCourses}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
toYearCode={toYearCode}
|
||||
fromYearCode={fromYearCode}
|
||||
setToYearCode={setToYearCode}
|
||||
setFromYearCode={setFromYearCode}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -33,9 +33,10 @@ export const SearchForm = () => {
|
||||
'use memo'
|
||||
const { getTextIn } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const [combineSubstitutions, toggleCombineSubstitutions] = useToggle(true)
|
||||
const [substitutions, toggleSubstitutions] = useToggle(true)
|
||||
const [selectMultipleCourses, toggleSelectMultipleCourses] = useToggle(false)
|
||||
const [includeSpecialCourses, setIncludeSpecialCourses] = useToggle(false)
|
||||
const [includeSpecialCourses, toggleIncludeSpecialCourses] = useToggle(false)
|
||||
|
||||
const [courseName, setCourseName] = useState('')
|
||||
const [courseCode, setCourseCode] = useState('')
|
||||
const [debouncedCourseName, setDebouncedCourseName] = useDebouncedState('')
|
||||
@@ -82,11 +83,9 @@ export const SearchForm = () => {
|
||||
}
|
||||
|
||||
const pushQueryToUrl = query => {
|
||||
const { courses, ...rest } = query
|
||||
const queryObject = {
|
||||
...rest,
|
||||
courses: JSON.stringify(courses),
|
||||
combineSubstitutions: JSON.stringify(combineSubstitutions),
|
||||
...query,
|
||||
substitutions,
|
||||
}
|
||||
const searchString = queryParamsToString(queryObject)
|
||||
void navigate({ search: searchString })
|
||||
@@ -101,7 +100,7 @@ export const SearchForm = () => {
|
||||
const params = {
|
||||
courses: groupIds,
|
||||
separate: false,
|
||||
combineSubstitutions,
|
||||
substitutions,
|
||||
}
|
||||
const searchHistoryText = groupIds.map(
|
||||
groupId => `${getTextIn(selectedCourses[groupId].name)} ${selectedCourses[groupId].code}`
|
||||
@@ -115,16 +114,15 @@ export const SearchForm = () => {
|
||||
|
||||
// If course clicked on single course mode, call submit hook
|
||||
useEffect(() => {
|
||||
if (Object.keys(selectedCourses).length === 0 || selectMultipleCourses) {
|
||||
return
|
||||
if (!selectMultipleCourses && Object.keys(selectedCourses).length === 1) {
|
||||
onSubmitFormClick()
|
||||
}
|
||||
onSubmitFormClick()
|
||||
}, [selectedCourses])
|
||||
|
||||
// Reset state after toggle
|
||||
useEffect(() => {
|
||||
setSelectedCourses({})
|
||||
}, [combineSubstitutions, selectMultipleCourses])
|
||||
}, [substitutions, selectMultipleCourses])
|
||||
|
||||
const courses = matchingCourses
|
||||
.filter(course => !selectedCourses[course.groupId])
|
||||
@@ -145,7 +143,7 @@ export const SearchForm = () => {
|
||||
|
||||
const addAllCourses = () => {
|
||||
const newSelectedCourses = courses.reduce<Record<string, SearchResultCourse>>((newSelected, course) => {
|
||||
newSelected[course.code] = { ...course }
|
||||
newSelected[course.groupId] = course
|
||||
return newSelected
|
||||
}, {})
|
||||
|
||||
@@ -207,10 +205,10 @@ export const SearchForm = () => {
|
||||
onChange={toggleSelectMultipleCourses}
|
||||
/>
|
||||
<ToggleWithTooltip
|
||||
checked={combineSubstitutions}
|
||||
checked={substitutions}
|
||||
cypress="combine-substitutions-toggle"
|
||||
label="Combine substitutions"
|
||||
onChange={toggleCombineSubstitutions}
|
||||
onChange={toggleSubstitutions}
|
||||
tooltipText={getTextIn({
|
||||
fi: 'Jos "Combine substitutions" on valittuna (oletuksena), niin kurssi ja leikkaavat kurssit yhdistetään tilastoissa.',
|
||||
en: 'If "Combine substitutions" is on (default behavior), then course and its substitutions are combined in the statistics.',
|
||||
@@ -220,7 +218,7 @@ export const SearchForm = () => {
|
||||
checked={includeSpecialCourses}
|
||||
cypress="show-special-courses-toggle"
|
||||
label="Show special courses"
|
||||
onChange={setIncludeSpecialCourses}
|
||||
onChange={toggleIncludeSpecialCourses}
|
||||
tooltipText={getTextIn({
|
||||
fi: 'Sisällyttää hakuun hyväksiluettujen suoritusten pohjalta luodut kurssit',
|
||||
en: 'Includes course instances formed by transferred credits and other special courses',
|
||||
@@ -240,7 +238,7 @@ export const SearchForm = () => {
|
||||
{selectMultipleCourses ? (
|
||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||
<CourseTable
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
combineSubstitutions={substitutions}
|
||||
courses={selected}
|
||||
hidden={noSelectedCourses}
|
||||
onSelectCourse={onSelectCourse}
|
||||
@@ -255,7 +253,7 @@ export const SearchForm = () => {
|
||||
</Stack>
|
||||
) : null}
|
||||
<CourseTable
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
combineSubstitutions={substitutions}
|
||||
courses={courses}
|
||||
hidden={false}
|
||||
onSelectCourse={onSelectCourse}
|
||||
|
||||
@@ -5,7 +5,6 @@ import Tab from '@mui/material/Tab'
|
||||
import Tabs from '@mui/material/Tabs'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { PageTitle } from '@/components/common/PageTitle'
|
||||
@@ -27,11 +26,13 @@ import {
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { useGetCourseStatsQuery } from '@/redux/courseStats'
|
||||
import { checkUserAccess, getFullStudyProgrammeRights, hasAccessToAllCourseStats } from '@/util/access'
|
||||
import { parseQueryParams } from '@/util/queryparams'
|
||||
import { useParseQueryParams } from '@/util/queryparams'
|
||||
import { yearToYearCode } from '@oodikone/shared/util'
|
||||
|
||||
export type CourseSearchState = 'openStats' | 'regularStats' | 'unifyStats'
|
||||
|
||||
// TODO: This view would probably benefit from using a context
|
||||
// (We do _a little bit_ of prop drilling)
|
||||
export const CourseStatistics = () => {
|
||||
'use memo'
|
||||
const { programmeRights, roles } = useGetAuthorizedUserQuery()
|
||||
@@ -39,41 +40,48 @@ export const CourseStatistics = () => {
|
||||
const userHasAccessToAllStats = hasAccessToAllCourseStats(roles, fullStudyProgrammeRights)
|
||||
|
||||
const [courseSummaryFormProgrammes, setCourseSummaryFormProgrammes] = useState<string[]>([ALL.value])
|
||||
const [openOrRegular, toggleOpenAndRegularCourses] = useState<CourseSearchState>('unifyStats')
|
||||
const [openOrRegular, setOpenOrRegular] = useState<CourseSearchState>('unifyStats')
|
||||
const [tab, setTab] = useTabs(/* max tabs */ 3)
|
||||
|
||||
const location = useLocation()
|
||||
const { courses, separate, combineSubstitutions } = parseQueryParams(location.search)
|
||||
const [fromYearCode, setFromYearCode] = useState(1)
|
||||
const [toYearCode, setToYearCode] = useState(yearToYearCode(new Date().getFullYear()))
|
||||
|
||||
const courseGroupIds: string[] = JSON.parse(courses ?? '[]')
|
||||
const [initialCourseCode] = courseGroupIds
|
||||
const singleCourseStats = courseGroupIds.length === 1
|
||||
const { courses, separate, substitutions } = useParseQueryParams()
|
||||
const initialCourseCode = courses?.[0] ?? ''
|
||||
const singleCourseStats = courses?.length === 1
|
||||
|
||||
const [selected, setSelected] = useState<string>('')
|
||||
const [selected, setSelected] = useState(initialCourseCode)
|
||||
|
||||
const shouldShowSearchForm = !initialCourseCode
|
||||
const coursesKey = courses?.join(',') ?? 'empty'
|
||||
|
||||
// Prevent state from becoming stale
|
||||
useEffect(() => {
|
||||
setSelected(current => {
|
||||
if (courses?.includes(current)) return current
|
||||
return courses?.[0] ?? ''
|
||||
})
|
||||
}, [coursesKey])
|
||||
|
||||
const noCourseProvided = !initialCourseCode
|
||||
|
||||
const {
|
||||
data: courseStatsData = {},
|
||||
isFetching: isLoading,
|
||||
isFetching,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
} = useGetCourseStatsQuery(
|
||||
{
|
||||
courses: courseGroupIds,
|
||||
separate: separate === 'true',
|
||||
combineSubstitutions: combineSubstitutions === 'true',
|
||||
fromYearCode: '1',
|
||||
toYearCode: yearToYearCode(new Date().getFullYear()).toString(),
|
||||
courses: courses!,
|
||||
separate: separate?.[0] === 'true',
|
||||
substitutions: substitutions?.[0] === 'true',
|
||||
fromYearCode: fromYearCode.toString(),
|
||||
toYearCode: toYearCode.toString(),
|
||||
},
|
||||
{ skip: shouldShowSearchForm }
|
||||
{ skip: noCourseProvided }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(initialCourseCode)
|
||||
}, [initialCourseCode])
|
||||
|
||||
useTitle(
|
||||
selected && !isLoading
|
||||
selected && !isFetching
|
||||
? `${courseStatsData[selected]?.regularStats.courseCode} - Course statistics`
|
||||
: 'Course statistics'
|
||||
)
|
||||
@@ -82,7 +90,7 @@ export const CourseStatistics = () => {
|
||||
return <AccessDeniedMessage />
|
||||
}
|
||||
|
||||
if (shouldShowSearchForm)
|
||||
if (noCourseProvided)
|
||||
return (
|
||||
<PageLayout maxWidth="lg">
|
||||
<PageTitle title="Course statistics" />
|
||||
@@ -103,14 +111,10 @@ export const CourseStatistics = () => {
|
||||
|
||||
const availableStats = getAvailableStats(courseStatsData)
|
||||
|
||||
const switchToCourse = (groupId: string) => {
|
||||
setSelected(groupId)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout maxWidth="lg">
|
||||
<Backdrop
|
||||
open={isLoading}
|
||||
open={isLoading || !stats[selected]}
|
||||
sx={theme => ({ color: theme.palette.grey[300], zIndex: theme => theme.zIndex.drawer + 1 })}
|
||||
>
|
||||
<CircularProgress color="inherit" size="3em" />
|
||||
@@ -128,21 +132,25 @@ export const CourseStatistics = () => {
|
||||
<CourseTab
|
||||
substitutionGroups={substitutionGroups}
|
||||
availableStats={availableStats[selected]}
|
||||
combineSubstitutions={combineSubstitutions === 'true'}
|
||||
loading={isLoading || !isSuccess}
|
||||
substitutions={substitutions?.[0] === 'true'}
|
||||
loading={isFetching || !isSuccess}
|
||||
openOrRegular={openOrRegular}
|
||||
programmes={programmes}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
stats={stats}
|
||||
toggleOpenAndRegularCourses={toggleOpenAndRegularCourses}
|
||||
toggleOpenAndRegularCourses={setOpenOrRegular}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
toYearCode={toYearCode}
|
||||
fromYearCode={fromYearCode}
|
||||
setToYearCode={setToYearCode}
|
||||
setFromYearCode={setFromYearCode}
|
||||
/>
|
||||
)}
|
||||
{tab === 1 && !singleCourseStats && (
|
||||
<SummaryTab
|
||||
courseSummaryFormProgrammes={courseSummaryFormProgrammes}
|
||||
onClickCourse={switchToCourse}
|
||||
onClickCourse={groupId => setSelected(groupId)}
|
||||
programmes={programmes}
|
||||
setCourseSummaryFormProgrammes={setCourseSummaryFormProgrammes}
|
||||
statistics={summaryStatistics}
|
||||
|
||||
@@ -7,18 +7,18 @@ const courseStatsApi = RTKApi.injectEndpoints({
|
||||
query: ({
|
||||
courses,
|
||||
separate,
|
||||
combineSubstitutions,
|
||||
substitutions,
|
||||
fromYearCode,
|
||||
toYearCode,
|
||||
}: {
|
||||
courses: string[]
|
||||
separate?: boolean
|
||||
combineSubstitutions?: boolean
|
||||
substitutions?: boolean
|
||||
fromYearCode: string
|
||||
toYearCode: string
|
||||
}) => ({
|
||||
url: '/courseyearlystats',
|
||||
params: { courses, separate, combineSubstitutions, fromYearCode, toYearCode },
|
||||
params: { courses, separate, substitutions, fromYearCode, toYearCode },
|
||||
}),
|
||||
transformResponse: (courseStats: CourseYearlyStats[]) => {
|
||||
const data: Record<string, CourseYearlyStats> = {}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
export const parseQueryParams = <T extends object = Record<any, any>>(input: string): T => {
|
||||
const params = new URLSearchParams(input)
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
const construct = Array.from(params.keys())
|
||||
.map(key => [key, params.getAll(key)])
|
||||
// This is done on purpose to mimic query-strings behavior
|
||||
.map(([key, val]) => [key, val.length <= 1 ? val[0] : val])
|
||||
/** Reactive query param parser.
|
||||
*
|
||||
* @returns all query parameters in object, destructure as needed.
|
||||
*
|
||||
* By default all values are string arrays. Array can also be destuctured as follows.
|
||||
*
|
||||
* @example
|
||||
* const args = useParseQueryParams()
|
||||
* const string1 = args.item1?.[0]
|
||||
* const string2 = [args.item2!]
|
||||
* const arrayType = args.noDestructuring
|
||||
*/
|
||||
export const useParseQueryParams = (): Record<string, undefined | string[]> => {
|
||||
const location = useLocation()
|
||||
const searchString = location.search
|
||||
const params = new URLSearchParams(searchString)
|
||||
|
||||
return Object.fromEntries(construct)
|
||||
return Object.fromEntries([...params.keys()].map(key => [key, params.getAll(key)]))
|
||||
}
|
||||
|
||||
const setQueryParams = (input: Record<any, any>) => {
|
||||
|
||||
@@ -11,9 +11,9 @@ export type CoursesMultiQuery = {
|
||||
|
||||
export type CourseYearlyStatsReqBody = never
|
||||
export type CourseYearlyStatsQuery = {
|
||||
courses: string[]
|
||||
courses: string | string[] // Non-array when only one course
|
||||
separate: string
|
||||
combineSubstitutions: string
|
||||
substitutions: string
|
||||
fromYearCode: string
|
||||
toYearCode: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user