From 2749baa18abd8b3bb6a8b1d965b1d4ed751f543b Mon Sep 17 00:00:00 2001 From: karilint Date: Mon, 25 May 2026 12:42:00 +0300 Subject: [PATCH 1/2] Persist table column filters --- .../components/TableView/TableView.test.tsx | 103 +++++++++++++++++- .../src/components/TableView/TableView.tsx | 29 ++++- 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/TableView/TableView.test.tsx b/frontend/src/components/TableView/TableView.test.tsx index 80366567..43fe3f2b 100644 --- a/frontend/src/components/TableView/TableView.test.tsx +++ b/frontend/src/components/TableView/TableView.test.tsx @@ -1,15 +1,24 @@ import { describe, expect, it, jest, beforeEach } from '@jest/globals' import '@testing-library/jest-dom' -import { fireEvent, render, waitFor } from '@testing-library/react' +import { act, fireEvent, render, waitFor } from '@testing-library/react' import { Role } from '@/shared/types' import { TableView } from './TableView' import { usePageContext } from '../Page' import { useUser } from '@/hooks/user' +type CapturedTableOptions = { + onColumnFiltersChange?: (filters: Array<{ id: string; value: unknown }>) => void +} + +let lastMaterialReactTableOptions: CapturedTableOptions | null = null + jest.mock('material-react-table', () => ({ - useMaterialReactTable: () => ({ - getPrePaginationRowModel: () => ({ rows: [] }), - }), + useMaterialReactTable: (options: CapturedTableOptions) => { + lastMaterialReactTableOptions = options + return { + getPrePaginationRowModel: () => ({ rows: [] }), + } + }, MaterialReactTable: () =>
, })) @@ -48,6 +57,8 @@ describe('TableView table help integration', () => { mockLocationSearch = '' mockNavigate = jest.fn() window.sessionStorage.clear() + window.localStorage.clear() + lastMaterialReactTableOptions = null mockUsePageContext.mockReturnValue({ editRights: {}, idList: [], @@ -233,6 +244,90 @@ describe('TableView table help integration', () => { ) }) + it('restores persisted column filters for normal table visits', async () => { + const setSqlColumnFilters = jest.fn() + const persistedFilters = [{ id: 'name', value: 'Alpha' }] + + window.localStorage.setItem('nowdatabase-table-columnfilters:test', JSON.stringify(persistedFilters)) + mockUsePageContext.mockReturnValue({ + editRights: {}, + idList: [], + idFieldName: 'id', + viewName: 'test', + previousTableUrls: [], + createTitle: () => '', + createSubtitle: () => '', + sqlLimit: 25, + sqlOffset: 0, + sqlColumnFilters: [], + sqlOrderBy: [], + setIdList: jest.fn(), + setSqlLimit: jest.fn(), + setSqlOffset: jest.fn(), + setSqlColumnFilters, + setSqlOrderBy: jest.fn(), + setPreviousTableUrls: jest.fn(), + }) + + render( + + title="Test Table" + idFieldName="id" + columns={[ + { header: 'Name', accessorKey: 'name' }, + { header: 'Id', accessorKey: 'id' }, + ]} + visibleColumns={{ name: true, id: true }} + data={[ + { + id: '1', + name: 'Alpha', + full_count: 1, + }, + ]} + url="test" + isFetching={false} + /> + ) + + await waitFor(() => { + expect(setSqlColumnFilters).toHaveBeenLastCalledWith(persistedFilters) + }) + }) + + it('persists changed column filters for later table visits', async () => { + render( + + title="Test Table" + idFieldName="id" + columns={[ + { header: 'Name', accessorKey: 'name' }, + { header: 'Id', accessorKey: 'id' }, + ]} + visibleColumns={{ name: true, id: true }} + data={[ + { + id: '1', + name: 'Alpha', + full_count: 1, + }, + ]} + url="test" + isFetching={false} + /> + ) + + act(() => { + lastMaterialReactTableOptions?.onColumnFiltersChange?.([{ id: 'name', value: 'Alpha' }]) + }) + + await waitFor(() => { + expect(window.localStorage.getItem('nowdatabase-table-columnfilters:test')).toEqual( + JSON.stringify([{ id: 'name', value: 'Alpha' }]) + ) + }) + }) + it('shows help with multi-sort guidance for regular tables', () => { const { getByRole, getByText } = render( diff --git a/frontend/src/components/TableView/TableView.tsx b/frontend/src/components/TableView/TableView.tsx index df461fce..6a93275c 100755 --- a/frontend/src/components/TableView/TableView.tsx +++ b/frontend/src/components/TableView/TableView.tsx @@ -41,6 +41,7 @@ const TEXT_FILTER_MODE_OPTIONS = ['equals', 'contains', 'startsWith'] as const type TextFilterModeOption = (typeof TEXT_FILTER_MODE_OPTIONS)[number] const TABLE_STATE_URL_PARAM = 'tableState' const TABLE_STATE_STORAGE_PREFIX = 'nowdatabase-table-state' +const TABLE_FILTERS_STORAGE_PREFIX = 'nowdatabase-table-columnfilters' const isEmptyFilterValue = (value: unknown): boolean => { if (Array.isArray(value)) { @@ -341,6 +342,7 @@ export const TableView = ({ } const getTableStateStorageKey = (stateId: string) => `${TABLE_STATE_STORAGE_PREFIX}:${stateId}` + const getPersistentColumnFiltersStorageKey = () => `${TABLE_FILTERS_STORAGE_PREFIX}:${url ?? location.pathname}` const loadStoredTableState = (stateId: string) => { const storedValue = window.sessionStorage.getItem(getTableStateStorageKey(stateId)) @@ -350,10 +352,28 @@ export const TableView = ({ return isStoredTableState(parsed) ? parsed : undefined } + const loadPersistentColumnFilters = () => { + const storedValue = window.localStorage.getItem(getPersistentColumnFiltersStorageKey()) + if (!storedValue) return + + const parsed = safeJsonParse(storedValue) + return isColumnFiltersState(parsed) ? normalizeColumnFilters(parsed) : undefined + } + const saveStoredTableState = (state: StoredTableState) => { window.sessionStorage.setItem(getTableStateStorageKey(tableStateId), JSON.stringify(state)) } + const savePersistentColumnFilters = (filters: MRT_ColumnFiltersState) => { + const storageKey = getPersistentColumnFiltersStorageKey() + if (filters.length === 0) { + window.localStorage.removeItem(storageKey) + return + } + + window.localStorage.setItem(storageKey, JSON.stringify(filters)) + } + const buildTableStateUrl = () => `${location.pathname}?${TABLE_STATE_URL_PARAM}=${encodeURIComponent(tableStateId)}` const loadStateFromUrl = ( @@ -379,7 +399,13 @@ export const TableView = ({ return stateFromStorage as unknown as TState } - if (!stateFromUrl) return defaultState + if (!stateFromUrl) { + if (state === 'columnfilters') { + return (loadPersistentColumnFilters() ?? defaultState) as TState + } + + return defaultState + } const parsed = safeJsonParse(stateFromUrl) if (parsed === undefined) { return defaultState @@ -645,6 +671,7 @@ export const TableView = ({ if (selectorFn || !hasLoadedTableState) return const sanitizedFilters = sanitizeColumnFilters(columnFilters) saveStoredTableState({ columnfilters: sanitizedFilters, sorting, pagination }) + savePersistentColumnFilters(sanitizedFilters) navigate(buildTableStateUrl(), { replace: true, }) From 5c29b9d8e6a0a6c454d6949b78f09aa9cc3dad67 Mon Sep 17 00:00:00 2001 From: karilint Date: Mon, 25 May 2026 12:57:21 +0300 Subject: [PATCH 2/2] Persist table column visibility with filters --- .../components/TableView/TableView.test.tsx | 83 +++++++++++++++-- .../src/components/TableView/TableView.tsx | 88 ++++++++++++++++--- 2 files changed, 152 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/TableView/TableView.test.tsx b/frontend/src/components/TableView/TableView.test.tsx index 43fe3f2b..ac123d77 100644 --- a/frontend/src/components/TableView/TableView.test.tsx +++ b/frontend/src/components/TableView/TableView.test.tsx @@ -8,6 +8,10 @@ import { useUser } from '@/hooks/user' type CapturedTableOptions = { onColumnFiltersChange?: (filters: Array<{ id: string; value: unknown }>) => void + onColumnVisibilityChange?: (visibility: Record) => void + state?: { + columnVisibility?: Record + } } let lastMaterialReactTableOptions: CapturedTableOptions | null = null @@ -46,6 +50,7 @@ jest.mock('./TableToolBar', () => ({ type TestRow = { id: string name: string + basin?: string full_count?: number } @@ -295,7 +300,34 @@ describe('TableView table help integration', () => { }) }) - it('persists changed column filters for later table visits', async () => { + it('restores persisted column visibility with persisted column filters', async () => { + const setSqlColumnFilters = jest.fn() + const persistedPreferences = { + columnfilters: [{ id: 'basin', value: 'Basin A' }], + columnVisibility: { id: true, name: true, basin: false }, + } + + window.localStorage.setItem('nowdatabase-table-columnfilters:test', JSON.stringify(persistedPreferences)) + mockUsePageContext.mockReturnValue({ + editRights: {}, + idList: [], + idFieldName: 'id', + viewName: 'test', + previousTableUrls: [], + createTitle: () => '', + createSubtitle: () => '', + sqlLimit: 25, + sqlOffset: 0, + sqlColumnFilters: [], + sqlOrderBy: [], + setIdList: jest.fn(), + setSqlLimit: jest.fn(), + setSqlOffset: jest.fn(), + setSqlColumnFilters, + setSqlOrderBy: jest.fn(), + setPreviousTableUrls: jest.fn(), + }) + render( title="Test Table" @@ -303,12 +335,48 @@ describe('TableView table help integration', () => { columns={[ { header: 'Name', accessorKey: 'name' }, { header: 'Id', accessorKey: 'id' }, + { header: 'Basin', accessorKey: 'basin' }, ]} - visibleColumns={{ name: true, id: true }} + visibleColumns={{ name: true, id: true, basin: false }} data={[ { id: '1', name: 'Alpha', + basin: 'Basin A', + full_count: 1, + }, + ]} + url="test" + isFetching={false} + /> + ) + + await waitFor(() => { + expect(setSqlColumnFilters).toHaveBeenLastCalledWith(persistedPreferences.columnfilters) + expect(lastMaterialReactTableOptions?.state?.columnVisibility).toEqual({ + id: true, + name: true, + basin: true, + }) + }) + }) + + it('persists changed column filters and column visibility for later table visits', async () => { + render( + + title="Test Table" + idFieldName="id" + columns={[ + { header: 'Name', accessorKey: 'name' }, + { header: 'Id', accessorKey: 'id' }, + { header: 'Basin', accessorKey: 'basin' }, + ]} + visibleColumns={{ name: true, id: true, basin: false }} + data={[ + { + id: '1', + name: 'Alpha', + basin: 'Basin A', full_count: 1, }, ]} @@ -318,13 +386,18 @@ describe('TableView table help integration', () => { ) act(() => { - lastMaterialReactTableOptions?.onColumnFiltersChange?.([{ id: 'name', value: 'Alpha' }]) + lastMaterialReactTableOptions?.onColumnVisibilityChange?.({ name: true, id: true, basin: true }) + lastMaterialReactTableOptions?.onColumnFiltersChange?.([{ id: 'basin', value: 'Basin A' }]) }) await waitFor(() => { - expect(window.localStorage.getItem('nowdatabase-table-columnfilters:test')).toEqual( - JSON.stringify([{ id: 'name', value: 'Alpha' }]) + const storedValue: unknown = JSON.parse( + window.localStorage.getItem('nowdatabase-table-columnfilters:test') ?? '{}' ) + expect(storedValue).toEqual({ + columnfilters: [{ id: 'basin', value: 'Basin A' }], + columnVisibility: { name: true, id: true, basin: true }, + }) }) }) diff --git a/frontend/src/components/TableView/TableView.tsx b/frontend/src/components/TableView/TableView.tsx index 6a93275c..3fe3a2cc 100755 --- a/frontend/src/components/TableView/TableView.tsx +++ b/frontend/src/components/TableView/TableView.tsx @@ -36,6 +36,10 @@ type StoredTableState = { sorting: MRT_SortingState pagination: MRT_PaginationState } +type StoredTablePreferences = { + columnfilters: MRT_ColumnFiltersState + columnVisibility: MRT_VisibilityState +} const TEXT_FILTER_MODE_OPTIONS = ['equals', 'contains', 'startsWith'] as const type TextFilterModeOption = (typeof TEXT_FILTER_MODE_OPTIONS)[number] @@ -249,6 +253,7 @@ export const TableView = ({ const [pagination, setPagination] = useState( selectorFn ? defaultPaginationSmall : defaultPagination ) + const [columnVisibility, setColumnVisibility] = useState(visibleColumns) const [tableStateId] = useState(() => { const searchParams = new URLSearchParams(location.search) return searchParams.get(TABLE_STATE_URL_PARAM) ?? createTableStateId() @@ -341,6 +346,32 @@ export const TableView = ({ ) } + const isColumnVisibilityState = (value: unknown): value is MRT_VisibilityState => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + return Object.values(value).every(item => typeof item === 'boolean') + } + + const isStoredTablePreferences = (value: unknown): value is StoredTablePreferences => { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Partial + return isColumnFiltersState(candidate.columnfilters) && isColumnVisibilityState(candidate.columnVisibility) + } + + const showFilteredColumns = ( + visibility: MRT_VisibilityState, + filters: MRT_ColumnFiltersState + ): MRT_VisibilityState => { + if (filters.length === 0) return visibility + + return filters.reduce( + (nextVisibility, filter) => ({ + ...nextVisibility, + [filter.id]: true, + }), + visibility + ) + } + const getTableStateStorageKey = (stateId: string) => `${TABLE_STATE_STORAGE_PREFIX}:${stateId}` const getPersistentColumnFiltersStorageKey = () => `${TABLE_FILTERS_STORAGE_PREFIX}:${url ?? location.pathname}` @@ -352,26 +383,39 @@ export const TableView = ({ return isStoredTableState(parsed) ? parsed : undefined } - const loadPersistentColumnFilters = () => { + const loadPersistentTablePreferences = (): StoredTablePreferences | undefined => { const storedValue = window.localStorage.getItem(getPersistentColumnFiltersStorageKey()) if (!storedValue) return const parsed = safeJsonParse(storedValue) - return isColumnFiltersState(parsed) ? normalizeColumnFilters(parsed) : undefined + if (isStoredTablePreferences(parsed)) { + const columnfilters = normalizeColumnFilters(parsed.columnfilters) + return { + columnfilters, + columnVisibility: showFilteredColumns(parsed.columnVisibility, columnfilters), + } + } + + if (isColumnFiltersState(parsed)) { + const columnfilters = normalizeColumnFilters(parsed) + return { + columnfilters, + columnVisibility: showFilteredColumns(visibleColumns, columnfilters), + } + } + + return undefined } const saveStoredTableState = (state: StoredTableState) => { window.sessionStorage.setItem(getTableStateStorageKey(tableStateId), JSON.stringify(state)) } - const savePersistentColumnFilters = (filters: MRT_ColumnFiltersState) => { - const storageKey = getPersistentColumnFiltersStorageKey() - if (filters.length === 0) { - window.localStorage.removeItem(storageKey) - return - } - - window.localStorage.setItem(storageKey, JSON.stringify(filters)) + const savePersistentTablePreferences = (filters: MRT_ColumnFiltersState, visibility: MRT_VisibilityState) => { + window.localStorage.setItem( + getPersistentColumnFiltersStorageKey(), + JSON.stringify({ columnfilters: filters, columnVisibility: showFilteredColumns(visibility, filters) }) + ) } const buildTableStateUrl = () => `${location.pathname}?${TABLE_STATE_URL_PARAM}=${encodeURIComponent(tableStateId)}` @@ -401,7 +445,7 @@ export const TableView = ({ if (!stateFromUrl) { if (state === 'columnfilters') { - return (loadPersistentColumnFilters() ?? defaultState) as TState + return (loadPersistentTablePreferences()?.columnfilters ?? defaultState) as TState } return defaultState @@ -533,6 +577,7 @@ export const TableView = ({ : undefined, enableStickyHeader: Boolean(tableContainerMaxHeight), state: { + columnVisibility, columnFilters, showColumnFilters: true, isLoading: isFetching, @@ -541,10 +586,10 @@ export const TableView = ({ density: 'compact', }, initialState: { - columnVisibility: visibleColumns, columnFilterFns: initialColumnFilterFns, }, onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, /** * Row action audit (Task T1): * @@ -659,6 +704,10 @@ export const TableView = ({ // Load state from url only on first render useEffect(() => { if (selectorFn) return + const persistentPreferences = loadPersistentTablePreferences() + if (persistentPreferences) { + setColumnVisibility(persistentPreferences.columnVisibility) + } setColumnFilters(loadStateFromUrl('columnfilters', [])) setSorting(loadStateFromUrl('sorting', defaultSorting ?? [])) setPagination(loadStateFromUrl('pagination', defaultPagination)) @@ -671,12 +720,23 @@ export const TableView = ({ if (selectorFn || !hasLoadedTableState) return const sanitizedFilters = sanitizeColumnFilters(columnFilters) saveStoredTableState({ columnfilters: sanitizedFilters, sorting, pagination }) - savePersistentColumnFilters(sanitizedFilters) + savePersistentTablePreferences(sanitizedFilters, columnVisibility) navigate(buildTableStateUrl(), { replace: true, }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [columnFilters, sorting, pagination, selectorFn, table, idFieldName, navigate, tableStateId, hasLoadedTableState]) + }, [ + columnFilters, + sorting, + pagination, + columnVisibility, + selectorFn, + table, + idFieldName, + navigate, + tableStateId, + hasLoadedTableState, + ]) useEffect(() => { if (selectorFn) {