From b73b50c66c401dc7aef4878b53f4fc0798fff0cf Mon Sep 17 00:00:00 2001 From: Kari Lintulaakso Date: Thu, 21 May 2026 13:46:47 +0300 Subject: [PATCH] Shorten shared table state URLs --- .../src/components/TableView/TableView.tsx | 106 +++++++++++++----- 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/TableView/TableView.tsx b/frontend/src/components/TableView/TableView.tsx index 13ef3b8e..8f0b3b38 100755 --- a/frontend/src/components/TableView/TableView.tsx +++ b/frontend/src/components/TableView/TableView.tsx @@ -31,9 +31,16 @@ import { resolveErrorMessage, resolveErrorStatus } from './errorUtils' import type { ColumnVisibilityGroup } from './TableToolBar' type TableStateInUrl = 'sorting' | 'columnfilters' | 'pagination' +type StoredTableState = { + columnfilters: MRT_ColumnFiltersState + sorting: MRT_SortingState + pagination: MRT_PaginationState +} 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 isEmptyFilterValue = (value: unknown): boolean => { if (Array.isArray(value)) { @@ -61,6 +68,11 @@ const sanitizeColumnFilters = (filters: MRT_ColumnFiltersState): MRT_ColumnFilte return filters.filter(filter => !isEmptyFilterValue(filter.value)) } +const createTableStateId = () => { + if (window.crypto?.randomUUID) return window.crypto.randomUUID() + return `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + const getColumnId = (column: MRT_ColumnDef) => { if (column.id) return String(column.id) if (typeof column.accessorKey === 'string') return column.accessorKey @@ -224,6 +236,10 @@ export const TableView = ({ const [pagination, setPagination] = useState( selectorFn ? defaultPaginationSmall : defaultPagination ) + const [tableStateId] = useState(() => { + const searchParams = new URLSearchParams(location.search) + return searchParams.get(TABLE_STATE_URL_PARAM) ?? createTableStateId() + }) const user = useUser() const { setIdList } = usePageContext() @@ -287,33 +303,79 @@ export const TableView = ({ return typeof candidate.pageIndex === 'number' && typeof candidate.pageSize === 'number' } + const normalizeColumnFilters = (filters: MRT_ColumnFiltersState): MRT_ColumnFiltersState => { + // The range filters are written as "null" if they are empty because undefined is not valid JSON. + // This changes those to empty strings when loading stored state. + return filters.map(columnFilter => { + if (Array.isArray(columnFilter.value)) { + return { + ...columnFilter, + value: columnFilter.value.map((val: string | number | null) => (val === null ? '' : val)), + } + } + return columnFilter + }) + } + + const isStoredTableState = (value: unknown): value is StoredTableState => { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Partial + return ( + isColumnFiltersState(candidate.columnfilters) && + isSortingState(candidate.sorting) && + isPaginationState(candidate.pagination) + ) + } + + const getTableStateStorageKey = (stateId: string) => `${TABLE_STATE_STORAGE_PREFIX}:${stateId}` + + const loadStoredTableState = (stateId: string) => { + const storedValue = window.sessionStorage.getItem(getTableStateStorageKey(stateId)) + if (!storedValue) return + + const parsed = safeJsonParse(storedValue) + return isStoredTableState(parsed) ? parsed : undefined + } + + const saveStoredTableState = (state: StoredTableState) => { + window.sessionStorage.setItem(getTableStateStorageKey(tableStateId), JSON.stringify(state)) + } + + const buildTableStateUrl = () => `${location.pathname}?${TABLE_STATE_URL_PARAM}=${encodeURIComponent(tableStateId)}` + const loadStateFromUrl = ( state: TableStateInUrl, defaultState: TState ): TState => { const searchParams = new URLSearchParams(location.search) + const stateIdFromUrl = searchParams.get(TABLE_STATE_URL_PARAM) + const storedState = stateIdFromUrl ? loadStoredTableState(stateIdFromUrl) : undefined + const stateFromStorage = storedState?.[state] const stateFromUrl = searchParams.get(state) + + if (stateFromStorage) { + if (state === 'columnfilters') { + return normalizeColumnFilters(stateFromStorage as MRT_ColumnFiltersState) as unknown as TState + } + if (state === 'sorting') { + const fallback = defaultState as unknown as MRT_SortingState + if ((stateFromStorage as MRT_SortingState).length === 0 && fallback.length > 0) { + return defaultState + } + } + return stateFromStorage as unknown as TState + } + if (!stateFromUrl) return defaultState const parsed = safeJsonParse(stateFromUrl) if (parsed === undefined) { return defaultState } if (state === 'columnfilters') { - // The range filters are written as "null" if they are empty because undefined is not valid JSON. - // This changes those to empty strings when loading the url to state. if (!isColumnFiltersState(parsed)) { return defaultState } - const normalizedFilters = parsed.map(columnFilter => { - if (Array.isArray(columnFilter.value)) { - return { - ...columnFilter, - value: columnFilter.value.map((val: string | number | null) => (val === null ? '' : val)), - } - } - return columnFilter - }) - return normalizedFilters as unknown as TState + return normalizeColumnFilters(parsed) as unknown as TState } if (state === 'sorting') { if (!isSortingState(parsed)) { @@ -360,16 +422,10 @@ export const TableView = ({ return } - const sanitizedFilters = sanitizeColumnFilters(columnFilters) - const columnFilterToUrl = `columnfilters=${JSON.stringify(sanitizedFilters)}` - const sortingToUrl = `sorting=${JSON.stringify(sorting)}` - const paginationToUrl = `pagination=${JSON.stringify(pagination)}` - setPreviousTableUrls([ - ...previousTableUrls, - `${location.pathname}?&${columnFilterToUrl}&${sortingToUrl}&${paginationToUrl}`, - ]) + const tableStateUrl = buildTableStateUrl() + setPreviousTableUrls([...previousTableUrls, tableStateUrl]) navigate(resolveDetailPath(row.original), { - state: { returnTo: `${location.pathname}${location.search}` }, + state: { returnTo: tableStateUrl }, }) }, sx: { @@ -565,14 +621,12 @@ export const TableView = ({ useEffect(() => { if (selectorFn) return const sanitizedFilters = sanitizeColumnFilters(columnFilters) - const columnFilterToUrl = `columnfilters=${JSON.stringify(sanitizedFilters)}` - const sortingToUrl = `sorting=${JSON.stringify(sorting)}` - const paginationToUrl = `pagination=${JSON.stringify(pagination)}` - navigate(`${location.pathname}?&${columnFilterToUrl}&${sortingToUrl}&${paginationToUrl}`, { + saveStoredTableState({ columnfilters: sanitizedFilters, sorting, pagination }) + navigate(buildTableStateUrl(), { replace: true, }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [columnFilters, sorting, pagination, selectorFn, table, idFieldName, navigate]) + }, [columnFilters, sorting, pagination, selectorFn, table, idFieldName, navigate, tableStateId]) useEffect(() => { if (selectorFn) {