diff --git a/frontend/src/components/TableView/TableView.test.tsx b/frontend/src/components/TableView/TableView.test.tsx index 80366567..ac123d77 100644 --- a/frontend/src/components/TableView/TableView.test.tsx +++ b/frontend/src/components/TableView/TableView.test.tsx @@ -1,15 +1,28 @@ 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 + onColumnVisibilityChange?: (visibility: Record) => void + state?: { + columnVisibility?: Record + } +} + +let lastMaterialReactTableOptions: CapturedTableOptions | null = null + jest.mock('material-react-table', () => ({ - useMaterialReactTable: () => ({ - getPrePaginationRowModel: () => ({ rows: [] }), - }), + useMaterialReactTable: (options: CapturedTableOptions) => { + lastMaterialReactTableOptions = options + return { + getPrePaginationRowModel: () => ({ rows: [] }), + } + }, MaterialReactTable: () =>
, })) @@ -37,6 +50,7 @@ jest.mock('./TableToolBar', () => ({ type TestRow = { id: string name: string + basin?: string full_count?: number } @@ -48,6 +62,8 @@ describe('TableView table help integration', () => { mockLocationSearch = '' mockNavigate = jest.fn() window.sessionStorage.clear() + window.localStorage.clear() + lastMaterialReactTableOptions = null mockUsePageContext.mockReturnValue({ editRights: {}, idList: [], @@ -233,6 +249,158 @@ 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('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" + 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, + }, + ]} + 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, + }, + ]} + url="test" + isFetching={false} + /> + ) + + act(() => { + lastMaterialReactTableOptions?.onColumnVisibilityChange?.({ name: true, id: true, basin: true }) + lastMaterialReactTableOptions?.onColumnFiltersChange?.([{ id: 'basin', value: 'Basin A' }]) + }) + + await waitFor(() => { + 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 }, + }) + }) + }) + 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..3fe3a2cc 100755 --- a/frontend/src/components/TableView/TableView.tsx +++ b/frontend/src/components/TableView/TableView.tsx @@ -36,11 +36,16 @@ 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] 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)) { @@ -248,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() @@ -340,7 +346,34 @@ 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}` const loadStoredTableState = (stateId: string) => { const storedValue = window.sessionStorage.getItem(getTableStateStorageKey(stateId)) @@ -350,10 +383,41 @@ export const TableView = ({ return isStoredTableState(parsed) ? parsed : undefined } + const loadPersistentTablePreferences = (): StoredTablePreferences | undefined => { + const storedValue = window.localStorage.getItem(getPersistentColumnFiltersStorageKey()) + if (!storedValue) return + + const parsed = safeJsonParse(storedValue) + 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 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)}` const loadStateFromUrl = ( @@ -379,7 +443,13 @@ export const TableView = ({ return stateFromStorage as unknown as TState } - if (!stateFromUrl) return defaultState + if (!stateFromUrl) { + if (state === 'columnfilters') { + return (loadPersistentTablePreferences()?.columnfilters ?? defaultState) as TState + } + + return defaultState + } const parsed = safeJsonParse(stateFromUrl) if (parsed === undefined) { return defaultState @@ -507,6 +577,7 @@ export const TableView = ({ : undefined, enableStickyHeader: Boolean(tableContainerMaxHeight), state: { + columnVisibility, columnFilters, showColumnFilters: true, isLoading: isFetching, @@ -515,10 +586,10 @@ export const TableView = ({ density: 'compact', }, initialState: { - columnVisibility: visibleColumns, columnFilterFns: initialColumnFilterFns, }, onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, /** * Row action audit (Task T1): * @@ -633,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)) @@ -645,11 +720,23 @@ export const TableView = ({ if (selectorFn || !hasLoadedTableState) return const sanitizedFilters = sanitizeColumnFilters(columnFilters) saveStoredTableState({ columnfilters: sanitizedFilters, sorting, pagination }) + 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) {