diff --git a/backend/src/api-tests/crossSearch/export.test.ts b/backend/src/api-tests/crossSearch/export.test.ts index c5a60d4c..1ef773b5 100644 --- a/backend/src/api-tests/crossSearch/export.test.ts +++ b/backend/src/api-tests/crossSearch/export.test.ts @@ -16,6 +16,12 @@ describe('Getting cross-search export', () => { expect(response.body).toEqual({ error: 'ColumnFilters is not an array.' }) }) + it('with invalid POST column filters type does not work', async () => { + const response = await send(`crosssearch/export`, 'POST', { columnFilters: '"not an array"', sorting: [] }) + expect(response.status).toEqual(403) + expect(response.body).toEqual({ error: 'ColumnFilters is not an array.' }) + }) + it('with invalid column filters content does not work', async () => { const { body: responseBody1, status: responseStatus1 } = await send( `crosssearch/export/[{"id": "not_a_column", "value": "Dmanisi"}]/[]`, diff --git a/backend/src/api-tests/crossSearch/get.test.ts b/backend/src/api-tests/crossSearch/get.test.ts index 098c0dfa..0003438c 100644 --- a/backend/src/api-tests/crossSearch/get.test.ts +++ b/backend/src/api-tests/crossSearch/get.test.ts @@ -62,6 +62,19 @@ describe('Getting cross-search data', () => { expect(response2.body.length).toEqual(10) }) + it('Requesting get all with POST body has correct data', async () => { + const { body, status } = await send(`crosssearch/all`, 'POST', { + limit: 20, + offset: 0, + columnFilters: [{ id: 'country', value: 'Spain' }], + sorting: [{ id: 'loc_name', desc: false }], + }) + + expect(status).toEqual(200) + expect(body).toHaveLength(10) + expect(body.every(row => row.country === 'Spain')).toBe(true) + }) + it('Requesting get all with column filters has correct data', async () => { const { body: responseBody1, status: responseStatus1 } = await send( `crosssearch/all/20/0/[{"id": "lid_now_loc", "value": "21050"}]/[]`, @@ -132,6 +145,18 @@ describe('Getting cross-search data', () => { expect(lastCountries).toEqual(['Japan', 'Japan', 'Japan', 'Japan', 'Japan']) }) + it('Requesting localities with POST body has correct data', async () => { + const legacyResponse = await send(`crosssearch/localities/[{"id": "country", "value": "Spain"}]/[]`, 'GET') + const { body, status } = await send(`crosssearch/localities`, 'POST', { + columnFilters: [{ id: 'country', value: 'Spain' }], + sorting: [], + }) + + expect(status).toEqual(200) + expect(body.length).toBeGreaterThan(0) + expect(body).toEqual(legacyResponse.body) + }) + it('Requesting get all with column filtering does not allow invalid arrays or column names', async () => { const { body: responseBody1, status: responseStatus1 } = await send( `crosssearch/all/20/0/[{"id": "invalid_column", "value": "21050"}]/[]`, diff --git a/backend/src/routes/crossSearch.ts b/backend/src/routes/crossSearch.ts index 2fd771f2..c5a2917c 100644 --- a/backend/src/routes/crossSearch.ts +++ b/backend/src/routes/crossSearch.ts @@ -1,8 +1,9 @@ -import { Router } from 'express' +import { Request, Response, Router } from 'express' import { getCrossSearchRawSql, getCrossSearchLocalitiesRawSql, parseAndValidateCrossSearchRouteParameters, + CrossSearchRequestParameters, } from '../services/crossSearch' import { fixBigInt } from '../utils/common' import { format, FormatterRow, FormatterRowTransformFunction } from 'fast-csv' @@ -32,82 +33,101 @@ const transformFunction = (row: CrossSearch & { full_count?: number }) => { return transformedRow } -router.get(`/all/:limit/:offset/:columnfilters/:sorting`, async (req, res) => { - let validatedValues +const getValidatedValues = (parameters: CrossSearchRequestParameters) => { + const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters(parameters) + const validatedValues = values + if (validationErrors.length > 0) { + return { validationErrors } + } + return { validatedValues } +} + +const handleCrossSearchValidationError = (error: unknown, res: Response) => { + if (error instanceof Error) return res.status(403).send({ error: error.message }) + return res.status(403).send('Unknown error') +} + +const sendCrossSearchRows = async (req: Request, res: Response, parameters: CrossSearchRequestParameters) => { + let parsedValues try { - const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters({ - limit: req.params.limit, - offset: req.params.offset, - columnFilters: req.params.columnfilters, - sorting: req.params.sorting, - }) - validatedValues = values - if (validationErrors.length > 0) { - return res.status(403).send(validationErrors) - } + parsedValues = getValidatedValues(parameters) } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) + } + if ('validationErrors' in parsedValues) { + return res.status(403).send(parsedValues.validationErrors) } try { const result = await getCrossSearchRawSql( req.user, - validatedValues.validatedLimit, - validatedValues.validatedOffset, - validatedValues.validatedColumnFilters, - validatedValues.validatedSorting + parsedValues.validatedValues.validatedLimit, + parsedValues.validatedValues.validatedOffset, + parsedValues.validatedValues.validatedColumnFilters, + parsedValues.validatedValues.validatedSorting ) return res.status(200).send(fixBigInt(result)) } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) } -}) +} -router.get(`/localities/:columnfilters/:sorting`, async (req, res) => { - let validatedValues +const sendCrossSearchLocalities = async (req: Request, res: Response, parameters: CrossSearchRequestParameters) => { + let parsedValues try { - const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters({ - columnFilters: req.params.columnfilters, - sorting: req.params.sorting, - }) - validatedValues = values - if (validationErrors.length > 0) { - return res.status(403).send(validationErrors) - } + parsedValues = getValidatedValues(parameters) } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) + } + if ('validationErrors' in parsedValues) { + return res.status(403).send(parsedValues.validationErrors) } try { const result = await getCrossSearchLocalitiesRawSql( req.user, - validatedValues.validatedColumnFilters, - validatedValues.validatedSorting + parsedValues.validatedValues.validatedColumnFilters, + parsedValues.validatedValues.validatedSorting ) return res.status(200).send(fixBigInt(result)) } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) } +} + +router.post(`/all`, async (req, res) => { + return sendCrossSearchRows(req, res, req.body as CrossSearchRequestParameters) }) -router.get(`/export/:columnfilters/:sorting`, async (req, res) => { - let validatedValues +router.get(`/all/:limit/:offset/:columnfilters/:sorting`, async (req, res) => { + return sendCrossSearchRows(req, res, { + limit: req.params.limit, + offset: req.params.offset, + columnFilters: req.params.columnfilters, + sorting: req.params.sorting, + }) +}) + +router.post(`/localities`, async (req, res) => { + return sendCrossSearchLocalities(req, res, req.body as CrossSearchRequestParameters) +}) + +router.get(`/localities/:columnfilters/:sorting`, async (req, res) => { + return sendCrossSearchLocalities(req, res, { + columnFilters: req.params.columnfilters, + sorting: req.params.sorting, + }) +}) + +const streamCrossSearchExport = async (req: Request, res: Response, parameters: CrossSearchRequestParameters) => { + let parsedValues try { - const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters({ - columnFilters: req.params.columnfilters, - sorting: req.params.sorting, - }) - validatedValues = values - if (validationErrors.length > 0) { - return res.status(403).send(validationErrors) - } + parsedValues = getValidatedValues(parameters) } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) + } + if ('validationErrors' in parsedValues) { + return res.status(403).send(parsedValues.validationErrors) } let dataArray: Partial @@ -116,12 +136,11 @@ router.get(`/export/:columnfilters/:sorting`, async (req, res) => { req.user, undefined, undefined, - validatedValues.validatedColumnFilters, - validatedValues.validatedSorting + parsedValues.validatedValues.validatedColumnFilters, + parsedValues.validatedValues.validatedSorting )) as Partial } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) } res.on('finish', () => { @@ -163,6 +182,17 @@ router.get(`/export/:columnfilters/:sorting`, async (req, res) => { } return stream.end() +} + +router.post(`/export`, async (req, res) => { + return streamCrossSearchExport(req, res, req.body as CrossSearchRequestParameters) +}) + +router.get(`/export/:columnfilters/:sorting`, async (req, res) => { + return streamCrossSearchExport(req, res, { + columnFilters: req.params.columnfilters, + sorting: req.params.sorting, + }) }) export default router diff --git a/backend/src/services/crossSearch.ts b/backend/src/services/crossSearch.ts index 00955d76..2dbdb680 100644 --- a/backend/src/services/crossSearch.ts +++ b/backend/src/services/crossSearch.ts @@ -13,6 +13,13 @@ import { generateCrossSearchLocalitiesSql, generateCrossSearchSql } from './quer import { ValidationObject } from '../../../frontend/src/shared/validators/validator' import { validateCrossSearchRouteParams } from '../../../frontend/src/shared/validators/crossSearch' +export type CrossSearchRequestParameters = { + limit?: string | number + offset?: string | number + columnFilters: unknown + sorting: unknown +} + const getAllowedLocalities = async (user: User) => { const usersProjects = await nowDb.now_proj_people.findMany({ where: { initials: user.initials }, @@ -195,22 +202,27 @@ export const getCrossSearchLocalitiesRawSql = async ( return result } -export const parseAndValidateCrossSearchRouteParameters = (parameters: CrossSearchRouteParameters) => { +const parseOptionalNumber = (value: string | number | undefined, name: string) => { + if (value === undefined) return undefined + const parsedValue = typeof value === 'number' ? value : parseInt(value) + if (isNaN(parsedValue)) throw new Error(`${name} is not a number.`) + return parsedValue +} + +const parseArrayParameter = (value: unknown, name: string) => { + const parsedValue: unknown = typeof value === 'string' ? (JSON.parse(value) as unknown) : value + if (!Array.isArray(parsedValue)) throw new Error(`${name} is not an array.`) + return parsedValue as unknown[] +} + +export const parseAndValidateCrossSearchRouteParameters = ( + parameters: CrossSearchRouteParameters | CrossSearchRequestParameters +) => { const { limit, offset, columnFilters, sorting } = parameters - let parsedLimit - let parsedOffset - if (limit) { - parsedLimit = parseInt(limit) - if (isNaN(parsedLimit)) throw new Error('Limit is not a number.') - } - if (offset) { - parsedOffset = parseInt(offset) - if (isNaN(parsedOffset)) throw new Error('Offset is not a number.') - } - const parsedColumnFilters = JSON.parse(columnFilters) as unknown - if (!Array.isArray(parsedColumnFilters)) throw new Error('ColumnFilters is not an array.') - const parsedSorting = JSON.parse(sorting) as unknown - if (!Array.isArray(parsedSorting)) throw new Error('Sorting is not an array.') + const parsedLimit = parseOptionalNumber(limit, 'Limit') + const parsedOffset = parseOptionalNumber(offset, 'Offset') + const parsedColumnFilters = parseArrayParameter(columnFilters, 'ColumnFilters') + const parsedSorting = parseArrayParameter(sorting, 'Sorting') const parsedParameters = { limit: parsedLimit, diff --git a/frontend/src/components/CrossSearch/CrossSearchExportMenuItem.tsx b/frontend/src/components/CrossSearch/CrossSearchExportMenuItem.tsx index dd014cad..b2d10e43 100644 --- a/frontend/src/components/CrossSearch/CrossSearchExportMenuItem.tsx +++ b/frontend/src/components/CrossSearch/CrossSearchExportMenuItem.tsx @@ -19,14 +19,20 @@ export const CrossSearchExportMenuItem = ({ handleClose }: { handleClose: () => const fetchCSVFile = async () => { setLoading(true) - const URIColumnFilters = encodeURIComponent(JSON.stringify(sqlColumnFilters)) - const URIOrderBy = encodeURIComponent(JSON.stringify(sqlOrderBy)) try { await downloadExportFileWithProgress({ - url: `${BACKEND_URL}/crosssearch/export/${URIColumnFilters}/${URIOrderBy}`, + url: `${BACKEND_URL}/crosssearch/export`, filename, - fetchOptions, + fetchOptions: { + ...fetchOptions, + method: 'POST', + headers: { + ...fetchOptions.headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ columnFilters: sqlColumnFilters, sorting: sqlOrderBy }), + }, notify, setNotificationMessage, startMessage: 'Generating cross-search CSV export...', diff --git a/frontend/src/components/TableView/TableView.test.tsx b/frontend/src/components/TableView/TableView.test.tsx index a0d98ac2..80366567 100644 --- a/frontend/src/components/TableView/TableView.test.tsx +++ b/frontend/src/components/TableView/TableView.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, jest, beforeEach } from '@jest/globals' import '@testing-library/jest-dom' -import { fireEvent, render } from '@testing-library/react' +import { fireEvent, render, waitFor } from '@testing-library/react' import { Role } from '@/shared/types' import { TableView } from './TableView' import { usePageContext } from '../Page' @@ -22,11 +22,12 @@ jest.mock('@/hooks/user', () => ({ })) let mockLocationSearch = '' +let mockNavigate = jest.fn() jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: () => ({ search: mockLocationSearch, pathname: '/table' }), - useNavigate: () => jest.fn(), + useNavigate: () => mockNavigate, })) jest.mock('./TableToolBar', () => ({ @@ -45,6 +46,8 @@ const mockUseUser = useUser as jest.Mock describe('TableView table help integration', () => { beforeEach(() => { mockLocationSearch = '' + mockNavigate = jest.fn() + window.sessionStorage.clear() mockUsePageContext.mockReturnValue({ editRights: {}, idList: [], @@ -124,6 +127,112 @@ describe('TableView table help integration', () => { expect(setSqlOrderBy).toHaveBeenCalledWith([{ id: 'name', desc: false }]) }) + it('stores table state outside the URL and navigates with a short state id', 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} + /> + ) + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled() + }) + + const navigatedUrl = mockNavigate.mock.calls + .map(call => call[0]) + .find((value): value is string => typeof value === 'string' && value.startsWith('/table?tableState=')) + + expect(navigatedUrl).toBeDefined() + expect(navigatedUrl).not.toContain('columnfilters=') + expect(navigatedUrl).not.toContain('sorting=') + expect(navigatedUrl).not.toContain('pagination=') + + const stateId = new URLSearchParams(navigatedUrl?.split('?')[1]).get('tableState') + expect(stateId).toBeTruthy() + expect(window.sessionStorage.getItem(`nowdatabase-table-state:${stateId}`)).toBeTruthy() + }) + + it('restores table state from a short URL without overwriting it with defaults first', async () => { + const setSqlColumnFilters = jest.fn() + const setSqlOrderBy = jest.fn() + const setSqlLimit = jest.fn() + const setSqlOffset = jest.fn() + const storedState = { + columnfilters: [{ id: 'name', value: 'Alpha' }], + sorting: [{ id: 'name', desc: true }], + pagination: { pageIndex: 2, pageSize: 50 }, + } + + mockLocationSearch = '?tableState=stored-state' + window.sessionStorage.setItem('nowdatabase-table-state:stored-state', JSON.stringify(storedState)) + mockUsePageContext.mockReturnValue({ + editRights: {}, + idList: [], + idFieldName: 'id', + viewName: 'test', + previousTableUrls: [], + createTitle: () => '', + createSubtitle: () => '', + sqlLimit: 25, + sqlOffset: 0, + sqlColumnFilters: [], + sqlOrderBy: [], + setIdList: jest.fn(), + setSqlLimit, + setSqlOffset, + setSqlColumnFilters, + setSqlOrderBy, + 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(storedState.columnfilters) + expect(setSqlOrderBy).toHaveBeenLastCalledWith(storedState.sorting) + expect(setSqlLimit).toHaveBeenLastCalledWith(50) + expect(setSqlOffset).toHaveBeenLastCalledWith(100) + }) + + expect(JSON.parse(window.sessionStorage.getItem('nowdatabase-table-state:stored-state') ?? '{}')).toEqual( + storedState + ) + }) + 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 13ef3b8e..ae68679f 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,11 @@ 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 [hasLoadedTableState, setHasLoadedTableState] = useState(Boolean(selectorFn)) const user = useUser() const { setIdList } = usePageContext() @@ -287,33 +304,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 +423,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: { @@ -558,21 +615,20 @@ export const TableView = ({ setColumnFilters(loadStateFromUrl('columnfilters', [])) setSorting(loadStateFromUrl('sorting', defaultSorting ?? [])) setPagination(loadStateFromUrl('pagination', defaultPagination)) + setHasLoadedTableState(true) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Save state to url whenever it changes useEffect(() => { - if (selectorFn) return + if (selectorFn || !hasLoadedTableState) 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, hasLoadedTableState]) useEffect(() => { if (selectorFn) { diff --git a/frontend/src/redux/crossSearchReducer.ts b/frontend/src/redux/crossSearchReducer.ts index e6e6edfd..21cfba55 100755 --- a/frontend/src/redux/crossSearchReducer.ts +++ b/frontend/src/redux/crossSearchReducer.ts @@ -9,7 +9,9 @@ const crossSearchApi = api.injectEndpoints({ { limit: number; offset: number; columnFilters: MRT_ColumnFiltersState; sorting: MRT_SortingState } >({ query: ({ limit, offset, columnFilters, sorting }) => ({ - url: `/crosssearch/all/${limit}/${offset}/${JSON.stringify(columnFilters)}/${JSON.stringify(sorting)}`, + url: `/crosssearch/all`, + method: 'POST', + body: { limit, offset, columnFilters, sorting }, }), providesTags: result => (result ? [{ type: 'localities' }] : []), }), @@ -18,7 +20,9 @@ const crossSearchApi = api.injectEndpoints({ { columnFilters: MRT_ColumnFiltersState; sorting: MRT_SortingState } >({ query: ({ columnFilters, sorting }) => ({ - url: `/crosssearch/localities/${JSON.stringify(columnFilters)}/${JSON.stringify(sorting)}`, + url: `/crosssearch/localities`, + method: 'POST', + body: { columnFilters, sorting }, }), providesTags: result => (result ? [{ type: 'localities' }] : []), }),