From fabb15913e3e5e52fa374c4fa580057c24fa7793 Mon Sep 17 00:00:00 2001 From: karilint Date: Thu, 23 Apr 2026 11:41:40 +0300 Subject: [PATCH] feat: column filter mode selection --- .../src/components/TableView/TableView.tsx | 102 +++++++++++++++++- .../components/TableViewFilterModes.test.tsx | 97 +++++++++++++++++ .../src/tests/mocks/material-react-table.d.ts | 7 ++ .../src/tests/mocks/materialReactTable.tsx | 8 +- 4 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 frontend/src/tests/components/TableViewFilterModes.test.tsx create mode 100644 frontend/src/tests/mocks/material-react-table.d.ts diff --git a/frontend/src/components/TableView/TableView.tsx b/frontend/src/components/TableView/TableView.tsx index fa54fe5f2..8ad392bfb 100755 --- a/frontend/src/components/TableView/TableView.tsx +++ b/frontend/src/components/TableView/TableView.tsx @@ -32,6 +32,9 @@ import type { ColumnVisibilityGroup } from './TableToolBar' type TableStateInUrl = 'sorting' | 'columnfilters' | 'pagination' +const TEXT_FILTER_MODE_OPTIONS = ['equals', 'contains', 'startsWith'] as const +type TextFilterModeOption = (typeof TEXT_FILTER_MODE_OPTIONS)[number] + const isEmptyFilterValue = (value: unknown): boolean => { if (Array.isArray(value)) { return value.every(item => item === '' || item === null || item === undefined) @@ -58,6 +61,91 @@ const sanitizeColumnFilters = (filters: MRT_ColumnFiltersState): MRT_ColumnFilte return filters.filter(filter => !isEmptyFilterValue(filter.value)) } +const getColumnId = (column: MRT_ColumnDef) => { + if (column.id) return String(column.id) + if (typeof column.accessorKey === 'string') return column.accessorKey + if (column.accessorKey !== undefined) return String(column.accessorKey) + return undefined +} + +const isIdLikeColumnId = (columnId: string) => { + const lastSegment = columnId.split('.').pop() ?? columnId + return lastSegment === 'id' || lastSegment === 'rid' || lastSegment === 'lid' || lastSegment.endsWith('_id') +} + +const isRangeLikeFilterVariant = (variant: MRT_ColumnDef['filterVariant']) => { + return ( + variant === 'range' || + variant === 'range-slider' || + variant === 'date-range' || + variant === 'datetime-range' || + variant === 'time-range' + ) +} + +const applyColumnFilterModeDefaults = ( + columns: MRT_ColumnDef[], + idFieldName: keyof T +): { + columns: MRT_ColumnDef[] + columnFilterFns: Record +} => { + const columnFilterFns: Record = {} + + const mapColumn = (column: MRT_ColumnDef): MRT_ColumnDef => { + if (column.columns) { + return { + ...column, + columns: column.columns.map(mapColumn), + } + } + + const columnId = getColumnId(column) + if (!columnId) return column + + const idFieldId = String(idFieldName) + const hasCustomFilterFn = typeof column.filterFn === 'function' + const isRangeFilter = isRangeLikeFilterVariant(column.filterVariant) + + if (columnId === idFieldId) { + columnFilterFns[columnId] = 'equals' + return { + ...column, + filterFn: typeof column.filterFn === 'function' ? column.filterFn : column.filterFn ?? 'equals', + enableColumnFilterModes: false, + columnFilterModeOptions: ['equals'], + } + } + + if (isIdLikeColumnId(columnId)) { + columnFilterFns[columnId] = 'equals' + return { + ...column, + filterFn: typeof column.filterFn === 'function' ? column.filterFn : column.filterFn ?? 'equals', + enableColumnFilterModes: false, + columnFilterModeOptions: ['equals'], + } + } + + if (hasCustomFilterFn || isRangeFilter) { + return { + ...column, + enableColumnFilterModes: false, + columnFilterModeOptions: null, + } + } + + columnFilterFns[columnId] = 'equals' + return { + ...column, + enableColumnFilterModes: true, + columnFilterModeOptions: [...TEXT_FILTER_MODE_OPTIONS], + } + } + + return { columns: columns.map(mapColumn), columnFilterFns } +} + /* TableView takes in the data and columns of a table, and handles rendering the actual table and saving & loading its state via url. @@ -137,6 +225,13 @@ export const TableView = ({ const user = useUser() const { setIdList } = usePageContext() + const { columns: preparedColumns, columnFilterFns: initialColumnFilterFns } = useMemo( + () => applyColumnFilterModeDefaults(columns, idFieldName), + [columns, idFieldName] + ) + + const allowColumnFilterModes = (enableColumnFilterModes ?? true) && !serverSidePagination + useEffect(() => { setSqlLimit(pagination.pageSize) setSqlOffset(pagination.pageIndex * pagination.pageSize) @@ -282,7 +377,7 @@ export const TableView = ({ } const table = useMaterialReactTable({ - columns: columns, + columns: preparedColumns, data: data || [], muiTableProps: { sx: { @@ -339,6 +434,7 @@ export const TableView = ({ }, initialState: { columnVisibility: visibleColumns, + columnFilterFns: initialColumnFilterFns, }, onColumnFiltersChange: setColumnFilters, /** @@ -440,8 +536,8 @@ export const TableView = ({ }, enableDensityToggle: false, enableGlobalFilter: false, - enableColumnFilterModes: enableColumnFilterModes && !serverSidePagination, - columnFilterModeOptions: ['fuzzy', 'contains', 'startsWith', 'endsWith', 'equals'], + enableColumnFilterModes: allowColumnFilterModes, + columnFilterModeOptions: [...TEXT_FILTER_MODE_OPTIONS], enableColumnActions: false, enableHiding: true, enableTopToolbar: false, diff --git a/frontend/src/tests/components/TableViewFilterModes.test.tsx b/frontend/src/tests/components/TableViewFilterModes.test.tsx new file mode 100644 index 000000000..9a6ce1c8a --- /dev/null +++ b/frontend/src/tests/components/TableViewFilterModes.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it } from '@jest/globals' +import '@testing-library/jest-dom' +import { render } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { Provider } from 'react-redux' +import { store } from '@/redux/store' +import { PageContextProvider } from '@/components/Page' +import { TableView } from '@/components/TableView/TableView' +import { getLastMaterialReactTableOptions } from 'material-react-table' +import { NotificationContextProvider } from '@/components/Notification' + +type CapturedMrtOptions = { + enableColumnFilterModes?: boolean + columnFilterModeOptions?: string[] + columns: Array<{ + accessorKey?: string + enableColumnFilterModes?: boolean + columnFilterModeOptions?: string[] | null + filterVariant?: string + }> + initialState?: { + columnFilterFns?: Record + } +} + +type Row = { + rid: number + title: string + year: number +} + +const renderTable = (columns: React.ComponentProps>['columns']) => { + render( + + + + ''} + createSubtitle={() => ''} + > + + title="Test" + idFieldName="rid" + columns={columns} + visibleColumns={{}} + data={[]} + isFetching={false} + selectorFn={() => {}} + /> + + + + + ) +} + +describe('TableView filter modes', () => { + it('defaults to equals and exposes allowed filter modes for non-id columns', () => { + renderTable([ + { accessorKey: 'rid', header: 'Id' }, + { accessorKey: 'title', header: 'Title' }, + { accessorKey: 'year', header: 'Year', filterVariant: 'range' }, + ]) + + const options = getLastMaterialReactTableOptions() + expect(options).toBeTruthy() + if (!options) return + + expect(options.enableColumnFilterModes).toEqual(true) + expect(options.columnFilterModeOptions).toEqual(['equals', 'contains', 'startsWith']) + + const ridColumn = options.columns.find(c => c.accessorKey === 'rid') + expect(ridColumn).toBeTruthy() + if (!ridColumn) return + expect(ridColumn.enableColumnFilterModes).toEqual(false) + expect(ridColumn.columnFilterModeOptions).toEqual(['equals']) + + const titleColumn = options.columns.find(c => c.accessorKey === 'title') + expect(titleColumn).toBeTruthy() + if (!titleColumn) return + expect(titleColumn.enableColumnFilterModes).toEqual(true) + expect(titleColumn.columnFilterModeOptions).toEqual(['equals', 'contains', 'startsWith']) + + const yearColumn = options.columns.find(c => c.accessorKey === 'year') + expect(yearColumn).toBeTruthy() + if (!yearColumn) return + expect(yearColumn.enableColumnFilterModes).toEqual(false) + expect(yearColumn.columnFilterModeOptions).toEqual(null) + + expect(options.initialState?.columnFilterFns?.rid).toEqual('equals') + expect(options.initialState?.columnFilterFns?.title).toEqual('equals') + expect(options.initialState?.columnFilterFns?.year).toBeUndefined() + }) +}) diff --git a/frontend/src/tests/mocks/material-react-table.d.ts b/frontend/src/tests/mocks/material-react-table.d.ts new file mode 100644 index 000000000..aec160be4 --- /dev/null +++ b/frontend/src/tests/mocks/material-react-table.d.ts @@ -0,0 +1,7 @@ +import 'material-react-table' + +declare module 'material-react-table' { + export const getLastMaterialReactTableOptions: () => T | null +} + +export {} diff --git a/frontend/src/tests/mocks/materialReactTable.tsx b/frontend/src/tests/mocks/materialReactTable.tsx index 038ce8a77..9196a97d5 100644 --- a/frontend/src/tests/mocks/materialReactTable.tsx +++ b/frontend/src/tests/mocks/materialReactTable.tsx @@ -14,8 +14,14 @@ type MockTable = { getColumn: (id: string) => { columnDef: { header: string } } } +let lastMaterialReactTableOptions: unknown = null + +export const getLastMaterialReactTableOptions = (): T | null => { + return (lastMaterialReactTableOptions as T | null) ?? null +} + export const useMaterialReactTable = (options: T): T & MockTable => ({ - ...options, + ...(lastMaterialReactTableOptions = options), getPrePaginationRowModel: () => ({ rows: [] }), getColumn: (id: string) => ({ columnDef: { header: id } }), })