mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Merge pull request #1171 from nowcommunity/feature/380-persist-table-filters
Persist table column filters
This commit is contained in:
@@ -1,15 +1,28 @@
|
|||||||
import { describe, expect, it, jest, beforeEach } from '@jest/globals'
|
import { describe, expect, it, jest, beforeEach } from '@jest/globals'
|
||||||
import '@testing-library/jest-dom'
|
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 { Role } from '@/shared/types'
|
||||||
import { TableView } from './TableView'
|
import { TableView } from './TableView'
|
||||||
import { usePageContext } from '../Page'
|
import { usePageContext } from '../Page'
|
||||||
import { useUser } from '@/hooks/user'
|
import { useUser } from '@/hooks/user'
|
||||||
|
|
||||||
|
type CapturedTableOptions = {
|
||||||
|
onColumnFiltersChange?: (filters: Array<{ id: string; value: unknown }>) => void
|
||||||
|
onColumnVisibilityChange?: (visibility: Record<string, boolean>) => void
|
||||||
|
state?: {
|
||||||
|
columnVisibility?: Record<string, boolean>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastMaterialReactTableOptions: CapturedTableOptions | null = null
|
||||||
|
|
||||||
jest.mock('material-react-table', () => ({
|
jest.mock('material-react-table', () => ({
|
||||||
useMaterialReactTable: () => ({
|
useMaterialReactTable: (options: CapturedTableOptions) => {
|
||||||
getPrePaginationRowModel: () => ({ rows: [] }),
|
lastMaterialReactTableOptions = options
|
||||||
}),
|
return {
|
||||||
|
getPrePaginationRowModel: () => ({ rows: [] }),
|
||||||
|
}
|
||||||
|
},
|
||||||
MaterialReactTable: () => <div data-testid="material-react-table" />,
|
MaterialReactTable: () => <div data-testid="material-react-table" />,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -37,6 +50,7 @@ jest.mock('./TableToolBar', () => ({
|
|||||||
type TestRow = {
|
type TestRow = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
basin?: string
|
||||||
full_count?: number
|
full_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +62,8 @@ describe('TableView table help integration', () => {
|
|||||||
mockLocationSearch = ''
|
mockLocationSearch = ''
|
||||||
mockNavigate = jest.fn()
|
mockNavigate = jest.fn()
|
||||||
window.sessionStorage.clear()
|
window.sessionStorage.clear()
|
||||||
|
window.localStorage.clear()
|
||||||
|
lastMaterialReactTableOptions = null
|
||||||
mockUsePageContext.mockReturnValue({
|
mockUsePageContext.mockReturnValue({
|
||||||
editRights: {},
|
editRights: {},
|
||||||
idList: [],
|
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(
|
||||||
|
<TableView<TestRow>
|
||||||
|
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(
|
||||||
|
<TableView<TestRow>
|
||||||
|
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(
|
||||||
|
<TableView<TestRow>
|
||||||
|
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', () => {
|
it('shows help with multi-sort guidance for regular tables', () => {
|
||||||
const { getByRole, getByText } = render(
|
const { getByRole, getByText } = render(
|
||||||
<TableView<TestRow>
|
<TableView<TestRow>
|
||||||
|
|||||||
@@ -36,11 +36,16 @@ type StoredTableState = {
|
|||||||
sorting: MRT_SortingState
|
sorting: MRT_SortingState
|
||||||
pagination: MRT_PaginationState
|
pagination: MRT_PaginationState
|
||||||
}
|
}
|
||||||
|
type StoredTablePreferences = {
|
||||||
|
columnfilters: MRT_ColumnFiltersState
|
||||||
|
columnVisibility: MRT_VisibilityState
|
||||||
|
}
|
||||||
|
|
||||||
const TEXT_FILTER_MODE_OPTIONS = ['equals', 'contains', 'startsWith'] as const
|
const TEXT_FILTER_MODE_OPTIONS = ['equals', 'contains', 'startsWith'] as const
|
||||||
type TextFilterModeOption = (typeof TEXT_FILTER_MODE_OPTIONS)[number]
|
type TextFilterModeOption = (typeof TEXT_FILTER_MODE_OPTIONS)[number]
|
||||||
const TABLE_STATE_URL_PARAM = 'tableState'
|
const TABLE_STATE_URL_PARAM = 'tableState'
|
||||||
const TABLE_STATE_STORAGE_PREFIX = 'nowdatabase-table-state'
|
const TABLE_STATE_STORAGE_PREFIX = 'nowdatabase-table-state'
|
||||||
|
const TABLE_FILTERS_STORAGE_PREFIX = 'nowdatabase-table-columnfilters'
|
||||||
|
|
||||||
const isEmptyFilterValue = (value: unknown): boolean => {
|
const isEmptyFilterValue = (value: unknown): boolean => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@@ -248,6 +253,7 @@ export const TableView = <T extends MRT_RowData>({
|
|||||||
const [pagination, setPagination] = useState<MRT_PaginationState>(
|
const [pagination, setPagination] = useState<MRT_PaginationState>(
|
||||||
selectorFn ? defaultPaginationSmall : defaultPagination
|
selectorFn ? defaultPaginationSmall : defaultPagination
|
||||||
)
|
)
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<MRT_VisibilityState>(visibleColumns)
|
||||||
const [tableStateId] = useState(() => {
|
const [tableStateId] = useState(() => {
|
||||||
const searchParams = new URLSearchParams(location.search)
|
const searchParams = new URLSearchParams(location.search)
|
||||||
return searchParams.get(TABLE_STATE_URL_PARAM) ?? createTableStateId()
|
return searchParams.get(TABLE_STATE_URL_PARAM) ?? createTableStateId()
|
||||||
@@ -340,7 +346,34 @@ export const TableView = <T extends MRT_RowData>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<StoredTablePreferences>
|
||||||
|
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<MRT_VisibilityState>(
|
||||||
|
(nextVisibility, filter) => ({
|
||||||
|
...nextVisibility,
|
||||||
|
[filter.id]: true,
|
||||||
|
}),
|
||||||
|
visibility
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const getTableStateStorageKey = (stateId: string) => `${TABLE_STATE_STORAGE_PREFIX}:${stateId}`
|
const getTableStateStorageKey = (stateId: string) => `${TABLE_STATE_STORAGE_PREFIX}:${stateId}`
|
||||||
|
const getPersistentColumnFiltersStorageKey = () => `${TABLE_FILTERS_STORAGE_PREFIX}:${url ?? location.pathname}`
|
||||||
|
|
||||||
const loadStoredTableState = (stateId: string) => {
|
const loadStoredTableState = (stateId: string) => {
|
||||||
const storedValue = window.sessionStorage.getItem(getTableStateStorageKey(stateId))
|
const storedValue = window.sessionStorage.getItem(getTableStateStorageKey(stateId))
|
||||||
@@ -350,10 +383,41 @@ export const TableView = <T extends MRT_RowData>({
|
|||||||
return isStoredTableState(parsed) ? parsed : undefined
|
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) => {
|
const saveStoredTableState = (state: StoredTableState) => {
|
||||||
window.sessionStorage.setItem(getTableStateStorageKey(tableStateId), JSON.stringify(state))
|
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 buildTableStateUrl = () => `${location.pathname}?${TABLE_STATE_URL_PARAM}=${encodeURIComponent(tableStateId)}`
|
||||||
|
|
||||||
const loadStateFromUrl = <TState extends MRT_ColumnFiltersState | MRT_SortingState | MRT_PaginationState>(
|
const loadStateFromUrl = <TState extends MRT_ColumnFiltersState | MRT_SortingState | MRT_PaginationState>(
|
||||||
@@ -379,7 +443,13 @@ export const TableView = <T extends MRT_RowData>({
|
|||||||
return stateFromStorage as unknown as TState
|
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)
|
const parsed = safeJsonParse(stateFromUrl)
|
||||||
if (parsed === undefined) {
|
if (parsed === undefined) {
|
||||||
return defaultState
|
return defaultState
|
||||||
@@ -507,6 +577,7 @@ export const TableView = <T extends MRT_RowData>({
|
|||||||
: undefined,
|
: undefined,
|
||||||
enableStickyHeader: Boolean(tableContainerMaxHeight),
|
enableStickyHeader: Boolean(tableContainerMaxHeight),
|
||||||
state: {
|
state: {
|
||||||
|
columnVisibility,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
showColumnFilters: true,
|
showColumnFilters: true,
|
||||||
isLoading: isFetching,
|
isLoading: isFetching,
|
||||||
@@ -515,10 +586,10 @@ export const TableView = <T extends MRT_RowData>({
|
|||||||
density: 'compact',
|
density: 'compact',
|
||||||
},
|
},
|
||||||
initialState: {
|
initialState: {
|
||||||
columnVisibility: visibleColumns,
|
|
||||||
columnFilterFns: initialColumnFilterFns,
|
columnFilterFns: initialColumnFilterFns,
|
||||||
},
|
},
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
/**
|
/**
|
||||||
* Row action audit (Task T1):
|
* Row action audit (Task T1):
|
||||||
*
|
*
|
||||||
@@ -633,6 +704,10 @@ export const TableView = <T extends MRT_RowData>({
|
|||||||
// Load state from url only on first render
|
// Load state from url only on first render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectorFn) return
|
if (selectorFn) return
|
||||||
|
const persistentPreferences = loadPersistentTablePreferences()
|
||||||
|
if (persistentPreferences) {
|
||||||
|
setColumnVisibility(persistentPreferences.columnVisibility)
|
||||||
|
}
|
||||||
setColumnFilters(loadStateFromUrl('columnfilters', []))
|
setColumnFilters(loadStateFromUrl('columnfilters', []))
|
||||||
setSorting(loadStateFromUrl('sorting', defaultSorting ?? []))
|
setSorting(loadStateFromUrl('sorting', defaultSorting ?? []))
|
||||||
setPagination(loadStateFromUrl('pagination', defaultPagination))
|
setPagination(loadStateFromUrl('pagination', defaultPagination))
|
||||||
@@ -645,11 +720,23 @@ export const TableView = <T extends MRT_RowData>({
|
|||||||
if (selectorFn || !hasLoadedTableState) return
|
if (selectorFn || !hasLoadedTableState) return
|
||||||
const sanitizedFilters = sanitizeColumnFilters(columnFilters)
|
const sanitizedFilters = sanitizeColumnFilters(columnFilters)
|
||||||
saveStoredTableState({ columnfilters: sanitizedFilters, sorting, pagination })
|
saveStoredTableState({ columnfilters: sanitizedFilters, sorting, pagination })
|
||||||
|
savePersistentTablePreferences(sanitizedFilters, columnVisibility)
|
||||||
navigate(buildTableStateUrl(), {
|
navigate(buildTableStateUrl(), {
|
||||||
replace: true,
|
replace: true,
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (selectorFn) {
|
if (selectorFn) {
|
||||||
|
|||||||
Reference in New Issue
Block a user