[Frontend][OT] -UNSAFE- Add basic filtering capabilities

This commit is contained in:
Santeri Rajamäki
2026-10-06 10:32:28 +03:00
committed by joonas-a
parent 8346c3eee2
commit 945c57d8eb
9 changed files with 244 additions and 58 deletions
@@ -9,9 +9,10 @@ type FilterRangeProps = {
max: number
range: [number, number]
setRange: (value: any) => void
hideIncrements?: true
}
export const FilterRange = ({ text, min, max, range, setRange }: FilterRangeProps) => (
export const FilterRange = ({ text, min, max, range, setRange, hideIncrements }: FilterRangeProps) => (
<Stack spacing={2}>
<Typography>{text}</Typography>
<Slider
@@ -27,7 +28,7 @@ export const FilterRange = ({ text, min, max, range, setRange }: FilterRangeProp
data-cy="FilterRangeStart"
onChange={({ target }) => setRange([target.value === '' || +target.value, range[1]])}
size="small"
type="number"
type={hideIncrements ? "text" : "number"}
value={range[0]}
/>
<Typography component="span" sx={{ alignContent: 'center' }} variant="h6">
@@ -37,7 +38,7 @@ export const FilterRange = ({ text, min, max, range, setRange }: FilterRangeProp
data-cy="FilterRangeEnd"
onChange={({ target }) => setRange([range[0], target.value === '' || +target.value])}
size="small"
type="number"
type={hideIncrements ? "text" : "number"}
value={range[1]}
/>
</Stack>
@@ -9,6 +9,7 @@ import { ReactNode } from 'react'
import { OodiTableDataRow } from '@/components/OodiTable/components/Cell'
import { OodiTableHeaderGroup } from '@/components/OodiTable/components/Header'
import { OodiTablePagination } from '@/components/OodiTable/components/Pagination'
import { OodiTableFilters } from './components/Filter'
export const OodiTableContainer = <OTData extends RowData>({
table,
@@ -70,6 +71,7 @@ export const OodiTableContainer = <OTData extends RowData>({
},
}}
>
{OodiTableFilters(table)}
{table.getAggregationRowModel().rows.map(row => OodiTableDataRow(row, true))}
{table.getRowModel().rows.map(row => OodiTableDataRow(row))}
</TableBody>
@@ -0,0 +1,20 @@
import { TableCell } from "@mui/material"
import TableRow from "@mui/material/TableRow"
import type { Table } from "@tanstack/react-table"
export const OodiTableFilters = <TData,>(table: Table<TData>) => {
if (!table.options.enableFilters) return null
return (
<TableRow>
{
// NOTE: getVisibleLeafColumns is used as they should be the only ones with filtering capabilities
table.getVisibleLeafColumns().map(column => (
<TableCell key={column.id}>
{ column.getCanFilter() && column.columnDef.meta?.filterComponent?.() }
</TableCell>
))
}
</TableRow>
)
}
@@ -40,4 +40,8 @@ declare module '@tanstack/react-table' {
interface Row {
getIsAggregationRow?: () => boolean
}
interface ColumnMeta<TData extends RowData, TValue> {
filterComponent?: ReactNode
}
}
@@ -1,5 +1,5 @@
import type { ColumnDef, RowData, Table, TableOptions } from '@tanstack/react-table'
import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel } from '@tanstack/react-table'
import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from '@tanstack/react-table'
import { useState, type ReactNode } from 'react'
import { AggregationRowFeature, VerticalHeaderFeature, ZebrastripesFeature } from '@/components/OodiTable/features'
@@ -45,6 +45,7 @@ export const OodiTable = <TData extends RowData>({
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
},
options
)
@@ -97,7 +97,7 @@ export const getSemesterEnrollmentsContent =
firstSemester: number
}) =>
(
student: { studentNumber: string; studyrightEnd: Date | null; secondStudyrightEnd: Date | null },
student: { studentNumber: string; studyrightEnd?: Date | null; secondStudyrightEnd: Date | null },
studyright: StudentStudyRight | undefined
) => {
if (!studyright) return []
@@ -0,0 +1,58 @@
import Box from "@mui/material/Box"
import TextField from "@mui/material/TextField"
import { FilterSelect } from "@/components/FilterView/filters/common/FilterSelect"
import { useDebounce } from "@/hooks/debounce"
import { FilterRange } from "@/components/FilterView/filters/common/FilterRange"
export const TextSelector = ({ label, value, setValue }) => {
const [debouncedValue, setDebounced] = useDebounce<string>(value, setValue)
return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<TextField
label={label ?? ''}
onChange={({ target }) => setDebounced(target.value)}
size="small"
value={debouncedValue}
/>
</Box>
)
}
export const CheckBoxSelector = () => {
return <p>CheckBox selector</p>
}
export const MultiSelector = ({ value, setValue, options }) => <FilterSelect
filterKey={'key'}
label="Choose stuff"
multiple
onChange={({ target }) => setValue(target.value as string[])}
value={value as any}
options={options}
/>
export const RangeSelector = ({ value, setValue, options }) => {
const maxVal = Math.max(...options) || 0
const minVal = Math.min(...options) || 0
const [debouncedValue, setDebounced] = useDebounce<[number, number]>(value ?? [minVal, maxVal], setValue)
return (
<Box sx={{ minWidth: '20em' }}>
<FilterRange
text={""}
min={minVal}
max={maxVal}
range={debouncedValue}
setRange={setDebounced}
hideIncrements
/>
</Box>
)
}
export const DateRangeSelector = () => {
return <p>Date range selector</p>
}
@@ -1,7 +1,6 @@
import Box from '@mui/material/Box'
import Tab from '@mui/material/Tab'
import Tabs from '@mui/material/Tabs'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { useEffect, useMemo, useState } from 'react'
@@ -26,7 +25,10 @@ import { getFullLanguage, Language } from '@oodikone/shared/language'
import { range } from '@oodikone/shared/util'
import { OodiTable } from '@/components/OodiTable'
import { CloseToGraduationData } from '@oodikone/shared/routes/populations'
import { createColumnHelper, TableOptions } from '@tanstack/react-table'
import { createColumnHelper, type TableOptions } from '@tanstack/react-table'
import { CheckBoxSelector, DateRangeSelector, MultiSelector, RangeSelector, TextSelector } from './filters'
import uniqBy from 'lodash-es/uniqBy'
import Tooltip from '@mui/material/Tooltip'
const NUMBER_OF_DISPLAYED_SEMESTERS = 6
@@ -45,7 +47,7 @@ export const CloseToGraduation = () => {
const { semesters, currentSemester } = semesterData
const [selectedTab, setSelectedTab] = useState(0)
const { getTextIn } = useLanguage()
const { getTextIn, language } = useLanguage()
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = useMemo(
() =>
getSemestersPresentFunctions({
@@ -71,9 +73,70 @@ export const CloseToGraduation = () => {
[currentSemesterCode]
)
const [columnFilters, setColumnFilters] = useState({
curriculumPeriod: [],
faculty: [],
programme: [],
studyTrack: [],
'student.studentNumber': '',
'credits.hops': [0, 1000],
'credits.all': [0, 1000],
numberOfAbsentSemesters: [],
numberOfUsedSemesters: [],
})
const displayedData = (selectedTab === 0 ? students?.bachelor : students?.masterAndLicentiate) ?? []
const curriculumPeriods = [...new Set(
displayedData.map(({ curriculumPeriod }) => curriculumPeriod).filter(curr => curr !== null)
)].map(val => ({
key: val,
value: val,
text: val,
}))
.sort(({ key: a }, { key: b }) => b.localeCompare(a))
const studyProgrammes = uniqBy(
displayedData.map(({ programme }) => programme).filter(curr => curr !== null),
'code'
).map(({ code, name }) => ({
key: code,
value: code,
text: `${code} - ${getTextIn(name)}`,
}))
.sort(({ key: a }, { key: b }) => a.localeCompare(b))
const studyTracks = uniqBy(
displayedData.map(({ programme }) => programme.studyTrack).filter(curr => curr !== null),
language
)
.map(name => getTextIn(name))
.map(val => ({
key: val,
value: val,
text: val,
}))
.sort(({ key: a }, { key: b }) => a?.localeCompare(b ?? '') ?? 0)
const faculties = uniqBy(
displayedData.map(({ faculty }) => faculty).filter(curr => curr !== null),
language
)
.map(name => getTextIn(name))
.map(val => ({
key: val,
value: val,
text: val,
}))
.sort(({ key: a }, { key: b }) => a?.localeCompare(b ?? '') ?? 0)
const hopsCredits = displayedData.map(({ credits }) => credits.hops).filter(Number)
const allCredits = displayedData.map(({ credits }) => credits.all).filter(Number)
const ooditableColumns = useMemo(
() => [
columnHelper.accessor('student.studentNumber', {
id: 'student.studentNumber',
header: 'Student number',
cell: cell => (
<StudentInfoItem
@@ -81,46 +144,65 @@ export const CloseToGraduation = () => {
studentNumber={cell.getValue<string>()}
/>
),
// filterFn: 'startsWith',
meta: {
filterComponent: () => <TextSelector label="Student number" value={columnFilters['student.studentNumber']} setValue={(val) => setColumnFilters(prev => ({ ...prev, 'student.studentNumber': val }))} />
}
}),
columnHelper.accessor('student.name', {
id: 'student.name',
header: 'Name',
// filterFn: 'startsWith',
}),
columnHelper.accessor('student.phoneNumber', {
id: 'student.phoneNumber',
header: 'Phone number',
}),
columnHelper.accessor('student.email', {
id: 'student.email',
header: 'Email',
}),
columnHelper.accessor('student.secondaryEmail', {
id: 'student.secondaryEmail',
header: 'Secondary email',
}),
columnHelper.accessor(row => getFullLanguage(row.student.preferredLanguage as Language), {
id: 'preferredLanguage',
header: 'Preferred language',
// filterVariant: 'multi-select',
filterFn: (row, columnId, filterValue) => !filterValue.length || filterValue.includes(row.original[columnId]),
meta: {
filterComponent: () => <MultiSelector value={[]} setValue={(val) => setColumnFilters(prev => ({ ...prev, preferredLanguage: val }))} options={[]} />
}
}),
columnHelper.accessor(row => getTextIn(row.faculty), {
header: 'Faculty',
id: 'faculty',
// filterVariant: 'multi-select',
filterFn: (row, columnId, filterValue) => !filterValue.length || filterValue.includes(getTextIn(row.original[columnId])),
meta: {
filterComponent: () => <MultiSelector value={columnFilters.faculty} setValue={(val) => setColumnFilters(prev => ({ ...prev, faculty: val }))} options={faculties} />
}
}),
columnHelper.accessor(row => getTextIn(row.programme.name), {
header: 'Programme',
id: 'programme',
// filterVariant: 'multi-select',
filterFn: (row, columnId, filterValue) => !filterValue.length || filterValue.includes(row.original[columnId]?.code),
meta: {
filterComponent: () => <MultiSelector value={columnFilters.programme} setValue={(val) => setColumnFilters(prev => ({ ...prev, programme: val }))} options={studyProgrammes} />
}
}),
columnHelper.accessor(row => getTextIn(row.programme.studyTrack), {
id: 'studyTrack',
header: 'Study track',
// filterVariant: 'multi-select',
filterFn: (row, _, filterValue) => !filterValue.length || filterValue.includes(getTextIn(row.original.programme?.studyTrack)),
meta: {
filterComponent: () => <MultiSelector value={columnFilters.studyTrack} setValue={(val) => setColumnFilters(prev => ({ ...prev, studyTrack: val }))} options={studyTracks} />
}
}),
columnHelper.accessor(row => new Date(row.studyright.startDate), {
id: 'startOfStudyRight',
cell: ({ cell }) => reformatDate(cell.getValue<Date>(), DateFormat.ISO_DATE),
header: 'Start of study right',
// filterVariant: 'date-range',
meta: {
filterComponent: () => <DateRangeSelector />
}
}),
columnHelper.accessor(row => new Date(row.programme.startedAt), {
id: 'startedInProgramme',
@@ -131,27 +213,37 @@ export const CloseToGraduation = () => {
tooltipText="For students with only a study right in the master’s programme, this date is the same as 'Start of study right'. For students with study rights in both the bachelor’s and master’s programmes, this date represents when they started in the master’s programme (i.e. one day after graduating from the bachelor’s programme)."
/>
),
// filterVariant: 'date-range',
meta: {
filterComponent: () => <DateRangeSelector />
}
}),
columnHelper.accessor('credits.hops', {
id: 'credits.hops',
header: 'Completed credits – HOPS',
// accessorKey: 'credits.hops',
// filterVariant: 'range',
// muiTableBodyCellProps: {
// align: 'right',
// },
filterFn: (row, _, filterValue) => {
const [min, max] = filterValue
const val = row.original.credits?.hops
return min <= val && val <= max
},
meta: {
filterComponent: () => <RangeSelector value={columnFilters['credits.hops']} setValue={(val) => setColumnFilters(prev => ({ ...prev, 'credits.hops': val }))} options={hopsCredits} />
},
}),
columnHelper.accessor('credits.all', {
id: 'credits.all',
header: 'Completed credits – Total',
// accessorKey: 'credits.all',
// filterVariant: 'range',
// muiTableBodyCellProps: {
// align: 'right',
// },
filterFn: (row, _, filterValue) => {
const [min, max] = filterValue
const val = row.original.credits?.all
return min <= val && val <= max
},
meta: {
filterComponent: () => <RangeSelector value={columnFilters['credits.all']} setValue={(val) => setColumnFilters(prev => ({ ...prev, 'credits.all': val }))} options={allCredits} />
},
}),
columnHelper.accessor('studyright.isBaMa', {
// accessorKey: 'studyright.isBaMa',
// filterVariant: 'checkbox',
cell: cell => <CheckIconWithTitle visible={cell.getValue<boolean>()} />,
header: () => (
<TableHeaderWithTooltip
@@ -159,16 +251,22 @@ export const CloseToGraduation = () => {
tooltipText="Indicates whether the student has been granted the study right to complete both a bachelor's and a master's degree."
/>
),
meta: {
filterComponent: () => <CheckBoxSelector />
},
}),
columnHelper.accessor('curriculumPeriod', {
// accessorKey: 'curriculumPeriod',
// filterVariant: 'multi-select',
header: () => (
<TableHeaderWithTooltip
header="Curriculum period"
tooltipText="The curriculum period the student has chosen for their primary study plan"
/>
),
enableColumnFilter: true,
filterFn: (row, columnId, filterValue) => !filterValue.length || filterValue.includes(row.original[columnId]),
meta: {
filterComponent: () => <MultiSelector value={columnFilters.curriculumPeriod} setValue={(val) => setColumnFilters(prev => ({ ...prev, curriculumPeriod: val }))} options={curriculumPeriods} />
},
}),
columnHelper.accessor(row => getSemesterEnrollmentsVal(row.studyright), {
header: 'Semester enrollments',
@@ -188,34 +286,29 @@ export const CloseToGraduation = () => {
id: 'semesterEnrollments',
}),
columnHelper.accessor('numberOfAbsentSemesters', {
// accessorKey: 'numberOfAbsentSemesters',
// filterVariant: 'range',
header: () => (
<TableHeaderWithTooltip
header="Semesters absent"
tooltipText="The number of semesters the student has been absent (both statutory (*lakiperusteinen*) and non-statutory absences) during their study right. The current semester is included."
/>
),
// muiTableBodyCellProps: {
// align: 'right',
// },
meta: {
filterComponent: () => <RangeSelector value={columnFilters.numberOfAbsentSemesters} setValue={(val) => setColumnFilters(prev => ({ ...prev, numberOfAbsentSemesters: val }))} options={[]} />
},
}),
columnHelper.accessor('numberOfUsedSemesters', {
// accessorKey: 'numberOfUsedSemesters',
// filterVariant: 'range',
header: () => (
<TableHeaderWithTooltip
header="Semesters used"
tooltipText="The number of semesters the student has either been enrolled as present (*läsnäoleva*) or the enrollment was neglected (*laiminlyöty*) during their study right. The current semester is included."
/>
),
// muiTableBodyCellProps: {
// align: 'right',
// },
meta: {
filterComponent: () => <RangeSelector value={columnFilters.numberOfUsedSemesters} setValue={(val) => setColumnFilters(prev => ({ ...prev, numberOfUsedSemesters: val }))} options={[]} />
},
}),
columnHelper.accessor(row => row.thesisInfo != null, {
id: 'thesisCompleted',
// filterVariant: 'checkbox',
cell: cell => (
<CheckIconWithTitle
title={
@@ -236,6 +329,9 @@ export const CloseToGraduation = () => {
tooltipText="The thesis attainment must be linked to the correct study right. You can see the attainment date, course code, and grade by hovering over the check mark."
/>
),
meta: {
filterComponent: () => <CheckBoxSelector />
},
}),
columnHelper.accessor(row => new Date(row.attainmentDates.latestHops), {
id: 'latestAttainmentDateHops',
@@ -246,7 +342,9 @@ export const CloseToGraduation = () => {
tooltipText="The date when the student last completed a course in their primary study plan"
/>
),
// filterVariant: 'date-range',
meta: {
filterComponent: () => <DateRangeSelector />
}
}),
columnHelper.accessor(row => new Date(row.attainmentDates.latestTotal), {
id: 'latestAttainmentDateTotal',
@@ -257,7 +355,9 @@ export const CloseToGraduation = () => {
tooltipText="The date when the student last completed any course at the university"
/>
),
// filterVariant: 'date-range',
meta: {
filterComponent: () => <DateRangeSelector />
}
}),
columnHelper.accessor(row => new Date(row.attainmentDates.earliestHops), {
id: 'earlistAttainmentDateHops',
@@ -268,7 +368,9 @@ export const CloseToGraduation = () => {
tooltipText="The date when the student first completed a course in their primary study plan"
/>
),
// filterVariant: 'date-range',
meta: {
filterComponent: () => <DateRangeSelector />
}
}),
...semestersToInclude.map(semester => columnHelper.accessor(row => {
if (!row.studyright.semesterEnrollments) {
@@ -281,7 +383,7 @@ export const CloseToGraduation = () => {
header: `Enrollment status – ${getTextIn(semesters[`${semester}`]?.name)}`,
})),
],
[semesters, semestersToInclude]
[columnFilters, students, semesters, semestersToInclude]
)
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({
@@ -302,36 +404,34 @@ export const CloseToGraduation = () => {
setColumnVisibility(prev => ({ ...prev, ...Object.fromEntries(hiddenColumns.map(key => [key, false])) }))
}, [ooditableColumns])
const displayedData = (selectedTab === 0 ? students?.bachelor : students?.masterAndLicentiate) ?? []
console.log(columnFilters)
const ooditable: Partial<TableOptions<CloseToGraduationData>> = {
initialState: {
sorting: [{ id: 'programme', desc: false }],
},
enableFilters: true,
state: {
columnVisibility,
columnFilters: Object.entries(columnFilters).map(([id, value]) => ({ id, value })),
},
}
return (
<PageLayout maxWidth="lg">
<PageTitle title="Students close to graduation" />
<Box sx={{ my: 3, textAlign: 'center' }}>
<InfoBox content={closeToGraduationToolTips} />
</Box>
<PageTitle title="Students close to graduation">
<Box sx={{ my: 3, textAlign: 'center' }}>
<InfoBox content={closeToGraduationToolTips} />
</Box>
</PageTitle>
<Tabs centered onChange={(_event, value) => setSelectedTab(value)} value={selectedTab}>
<Tab label="Bachelor's programmes" />
<Tab label="Master's and licentiate's programmes" />
</Tabs>
{isFetching ? <LoadingSection /> : <OodiTable options={ooditable} data={displayedData} columns={ooditableColumns} />}
<Box sx={{ minHeight: '1.25rem' }}>
{students?.lastUpdated ? (
{!!students?.lastUpdated && (
<Typography color="text.secondary" variant="body2">
Last updated: {reformatDate(students.lastUpdated, DateFormat.LONG_DATE_TIME)}
</Typography>
) : null}
)}
</Box>
{isFetching ? <LoadingSection /> : <OodiTable options={ooditable} data={displayedData} columns={ooditableColumns} />}
</PageLayout>
)
}