diff --git a/packages/mantine/src/components/table/__tests__/TableColumnsSelector.spec.tsx b/packages/mantine/src/components/table/__tests__/TableColumnsSelector.spec.tsx index e666746d85..bd0efd516e 100644 --- a/packages/mantine/src/components/table/__tests__/TableColumnsSelector.spec.tsx +++ b/packages/mantine/src/components/table/__tests__/TableColumnsSelector.spec.tsx @@ -298,4 +298,55 @@ describe('TableColumnsSelector', () => { await waitFor(() => expect(screen.getByText('You can display so many patate')).toBeVisible()); }); }); + + describe('when url sync is activated', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + it('sets the current visible column ids in the url', async () => { + const user = userEvent.setup(); + const Fixture = () => { + const store = useTable({ + syncWithUrl: true, + initialState: {columnVisibility: {email: false, phone: true}}, + }); + return ( + + + + +
+ ); + }; + render(); + await user.click(screen.getByRole('button', {name: 'Edit columns'})); + const emailCheckBox = await screen.findByRole('checkbox', {name: /email/i}); + await user.click(emailCheckBox); + await user.click(screen.getByRole('checkbox', {name: /phone/i})); + expect(window.location.search).toBe('?show=email&hide=phone'); + }); + + it('determines the initial visible columns from the url', async () => { + window.history.replaceState(null, '', '?show=email%2Cphone'); + const user = userEvent.setup(); + const Fixture = () => { + const store = useTable({ + syncWithUrl: true, + initialState: {columnVisibility: {email: false, phone: false}}, + }); + return ( + + + + +
+ ); + }; + render(); + await user.click(screen.getByRole('button', {name: 'Edit columns'})); + expect(await screen.findByRole('checkbox', {name: /email/i})).toBeChecked(); + expect(screen.getByRole('checkbox', {name: /phone/i})).toBeChecked(); + }); + }); }); diff --git a/packages/mantine/src/components/table/__tests__/TableDateRangePicker.spec.tsx b/packages/mantine/src/components/table/__tests__/TableDateRangePicker.spec.tsx index 73b2db6c16..63df21da8b 100644 --- a/packages/mantine/src/components/table/__tests__/TableDateRangePicker.spec.tsx +++ b/packages/mantine/src/components/table/__tests__/TableDateRangePicker.spec.tsx @@ -1,5 +1,5 @@ import {ColumnDef, createColumnHelper} from '@tanstack/table-core'; -import {render, screen} from '@test-utils'; +import {render, screen, userEvent} from '@test-utils'; import {Table} from '../Table'; import {useTable} from '../use-table'; @@ -25,4 +25,56 @@ describe('Table.DateRangePicker', () => { expect(screen.getByRole('button', {name: /jan 01, 2022 - jan 07, 2022/i})).toBeVisible(); }); + + describe('when url sync is activated', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + it('sets the selected date range in the url', async () => { + const user = userEvent.setup(); + const Fixture = () => { + const store = useTable({ + initialState: {dateRange: [new Date(2022, 0, 1), new Date(2022, 0, 7)]}, + syncWithUrl: true, + }); + return ( + + + + +
+ ); + }; + render(); + await user.click(screen.getByRole('button', {name: /jan 01, 2022 - jan 07, 2022/i})); + await screen.findByRole('dialog'); + await user.clear(screen.getByRole('textbox', {name: /start/i})); + await user.type(screen.getByRole('textbox', {name: /start/i}), '2022-01-02'); + await user.clear(screen.getByRole('textbox', {name: /end/i})); + await user.type(screen.getByRole('textbox', {name: /end/i}), '2022-01-08'); + await user.click(screen.getByRole('button', {name: /apply/i})); + expect(window.location.search).toBe('?from=2022-01-02T00%3A00%3A00.000Z&to=2022-01-08T23%3A59%3A59.999Z'); + }); + + it('initially selects the specified date range from in the url', async () => { + window.history.replaceState(null, '', '?from=2022-01-02T00%3A00%3A00.000Z&to=2022-01-08T23%3A59%3A59.999Z'); + const user = userEvent.setup(); + const Fixture = () => { + const store = useTable({ + initialState: {dateRange: [new Date(2022, 0, 1), new Date(2022, 0, 7)]}, + syncWithUrl: true, + }); + return ( + + + + +
+ ); + }; + render(); + expect(screen.getByRole('button', {name: /jan 02, 2022 - jan 08, 2022/i})).toBeVisible(); + }); + }); }); diff --git a/packages/mantine/src/components/table/__tests__/TableFilter.spec.tsx b/packages/mantine/src/components/table/__tests__/TableFilter.spec.tsx index 09714c1cea..8e909ebb07 100644 --- a/packages/mantine/src/components/table/__tests__/TableFilter.spec.tsx +++ b/packages/mantine/src/components/table/__tests__/TableFilter.spec.tsx @@ -102,4 +102,47 @@ describe('Table.Filter', () => { expect(screen.getByRole('button', {name: /1 selected/i})).toBeInTheDocument(); }); }); + + describe('when url sync is activated', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + it('sets the current filter value in the url using the parameter "filter"', async () => { + const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime}); + const Fixture = () => { + const store = useTable({initialState: {globalFilter: ''}, syncWithUrl: true}); + return ( + + + + +
+ ); + }; + render(); + await user.type(screen.getByRole('textbox'), 'veg'); + act(() => { + // 300 ms debounce on TableFilter input + vi.advanceTimersByTime(300); + }); + expect(window.location.search).toBe('?filter=veg'); + }); + + it('determines the initial filter value from the url', async () => { + window.history.replaceState(null, '', '?filter=veg'); + const Fixture = () => { + const store = useTable({initialState: {globalFilter: ''}, syncWithUrl: true}); + return ( + + + + +
+ ); + }; + render(); + expect(screen.getByRole('textbox')).toHaveValue('veg'); + }); + }); }); diff --git a/packages/mantine/src/components/table/__tests__/TablePagination.spec.tsx b/packages/mantine/src/components/table/__tests__/TablePagination.spec.tsx index d9d48b1db4..ae03aa4407 100644 --- a/packages/mantine/src/components/table/__tests__/TablePagination.spec.tsx +++ b/packages/mantine/src/components/table/__tests__/TablePagination.spec.tsx @@ -217,4 +217,58 @@ describe('Table.Pagination', () => { expect(buttons).toHaveLength(1); expect(buttons[0]).toHaveAccessibleName('change total pages'); }); + + describe('when url sync is activated', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + it('sets the current page in the url using the parameter "page" counting from 1', async () => { + const data = [{name: 'fruit'}, {name: 'vegetable'}]; + const user = userEvent.setup(); + const Fixture = () => { + const store = useTable({ + initialState: { + pagination: {pageSize: 1, pageIndex: 0}, + totalEntries: 3, + }, + syncWithUrl: true, + }); + return ( + + + + +
+ ); + }; + render(); + await user.click(screen.queryByRole('button', {name: '2'})); + expect(window.location.search).toBe('?page=2'); + }); + + it('determines the initial page index from the url', async () => { + window.history.replaceState(null, '', '?page=2'); + const data = [{name: 'fruit'}, {name: 'vegetable'}]; + const Fixture = () => { + const store = useTable({ + initialState: { + pagination: {pageSize: 1, pageIndex: 0}, + totalEntries: 3, + }, + syncWithUrl: true, + }); + return ( + + + + + +
+ ); + }; + render(); + expect(screen.getByRole('button', {name: '2', current: 'page'})).toBeVisible(); + }); + }); }); diff --git a/packages/mantine/src/components/table/__tests__/TablePerPage.spec.tsx b/packages/mantine/src/components/table/__tests__/TablePerPage.spec.tsx index 96d85f6f71..47c244c665 100644 --- a/packages/mantine/src/components/table/__tests__/TablePerPage.spec.tsx +++ b/packages/mantine/src/components/table/__tests__/TablePerPage.spec.tsx @@ -122,4 +122,59 @@ describe('Table.PerPage', () => { render(); expect(screen.getByTestId('table-footer')).toBeEmptyDOMElement(); }); + + describe('when url sync is activated', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + it('sets the current page size in the url using the parameter "pageSize"', async () => { + const data = [{name: 'fruit'}, {name: 'vegetable'}]; + const user = userEvent.setup(); + const Fixture = () => { + const store = useTable({ + initialState: { + pagination: {pageIndex: 0, pageSize: 50}, + totalEntries: 52, + }, + syncWithUrl: true, + }); + return ( + + + + + +
+ ); + }; + render(); + await user.click(screen.getByRole('radio', {name: '100'})); + expect(window.location.search).toBe('?pageSize=100'); + }); + + it('determines the initial pageSize from the url', async () => { + window.history.replaceState(null, '', '?pageSize=100'); + const data = [{name: 'fruit'}, {name: 'vegetable'}]; + const Fixture = () => { + const store = useTable({ + initialState: { + pagination: {pageIndex: 0, pageSize: 50}, + totalEntries: 52, + }, + syncWithUrl: true, + }); + return ( + + + + + +
+ ); + }; + render(); + expect(screen.getByRole('radio', {name: '100'})).toBeChecked(); + }); + }); }); diff --git a/packages/mantine/src/components/table/__tests__/TablePredicate.spec.tsx b/packages/mantine/src/components/table/__tests__/TablePredicate.spec.tsx index 9fc95f8707..715052299a 100644 --- a/packages/mantine/src/components/table/__tests__/TablePredicate.spec.tsx +++ b/packages/mantine/src/components/table/__tests__/TablePredicate.spec.tsx @@ -11,6 +11,11 @@ const columns: Array> = [columnHelper.accessor('name', {enabl describe('Table.Predicate', () => { it('goes back to the first page when changing the predicate', async () => { + if (!HTMLElement.prototype.scrollIntoView) { + HTMLElement.prototype.scrollIntoView = () => { + vi.fn(); + }; + } const user = userEvent.setup(); const data = [{name: 'fruit'}, {name: 'vegetable'}]; const Fixture = () => { @@ -40,4 +45,68 @@ describe('Table.Predicate', () => { await user.click(screen.getByRole('option', {name: 'First'})); expect(screen.getByRole('button', {name: '1', current: 'page'})).toBeVisible(); }); + + describe('when url sync is activated', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + it('sets the current predicate value in the url using the predicate id as key', async () => { + const user = userEvent.setup(); + const data = [{name: 'fruit'}, {name: 'vegetable'}]; + const Fixture = () => { + const store = useTable({ + initialState: {predicates: {rank: 'ALL'}, pagination: {pageIndex: 0}, totalEntries: 52}, + syncWithUrl: true, + }); + return ( + + + + +
+ ); + }; + render(); + await user.click(screen.getByRole('textbox', {name: 'Rank'})); + await user.click(screen.getByRole('option', {name: 'First'})); + expect(window.location.search).toBe('?rank=first'); + }); + + it('determines the initial predicate value from the url', async () => { + window.history.replaceState(null, '', '?rank=second'); + const data = [{name: 'fruit'}, {name: 'vegetable'}]; + const Fixture = () => { + const store = useTable({ + initialState: {predicates: {rank: 'ALL'}, pagination: {pageIndex: 0}, totalEntries: 52}, + syncWithUrl: true, + }); + return ( + + + + +
+ ); + }; + render(); + expect(screen.getByRole('textbox', {name: 'Rank'})).toHaveValue('Second'); + }); + }); }); diff --git a/packages/mantine/src/components/table/__tests__/Th.spec.tsx b/packages/mantine/src/components/table/__tests__/Th.spec.tsx index d670d52ff7..5aadbcfff5 100644 --- a/packages/mantine/src/components/table/__tests__/Th.spec.tsx +++ b/packages/mantine/src/components/table/__tests__/Th.spec.tsx @@ -56,4 +56,39 @@ describe('Th', () => { expect(sortedDescRowHeader).toBeVisible(); await user.click(sortedDescRowHeader); }); + + describe('when url sync is activated', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + it('sets the sort column and direction in the url', async () => { + const user = userEvent.setup(); + const data: RowData[] = [ + {name: 'apple', type: 'fruit', colors: ['red', 'green']}, + {name: 'potato', type: 'vegetable', colors: ['brown', 'blue', 'yellow']}, + ]; + const Fixture = () => { + const store = useTable({syncWithUrl: true}); + return ; + }; + render(); + await user.click(screen.getByRole('columnheader', {name: /name doubleArrowHead/i})); + expect(window.location.search).toBe('?sortBy=name.asc'); + }); + + it('determines the initial visible columns from the url', () => { + window.history.replaceState(null, '', '?sortBy=name.desc'); + const data: RowData[] = [ + {name: 'apple', type: 'fruit', colors: ['red', 'green']}, + {name: 'potato', type: 'vegetable', colors: ['brown', 'blue', 'yellow']}, + ]; + const Fixture = () => { + const store = useTable({syncWithUrl: true}); + return
; + }; + render(); + expect(screen.getByRole('columnheader', {name: /name arrowDown/i})).toBeVisible(); + }); + }); }); diff --git a/packages/mantine/src/components/table/__tests__/use-url-synced-state.unit.spec.ts b/packages/mantine/src/components/table/__tests__/use-url-synced-state.unit.spec.ts new file mode 100644 index 0000000000..20cfe9930c --- /dev/null +++ b/packages/mantine/src/components/table/__tests__/use-url-synced-state.unit.spec.ts @@ -0,0 +1,113 @@ +import {act, renderHook} from '@test-utils'; +import {useUrlSyncedState} from '../use-url-synced-state'; + +describe('useUrlSyncedState', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + it('serializes the state value as url parameter when the state changes', () => { + const {result} = renderHook(() => + useUrlSyncedState({ + initialState: '', + serializer: (state) => [['key', state]], + deserializer: (params) => params.get('key') ?? '', + sync: true, + }), + ); + act(() => result.current[1]('value')); + expect(result.current[0]).toBe('value'); + expect(window.location.search).toBe('?key=value'); + }); + + it('allows to serialize the state value into multiple parameters', () => { + const {result} = renderHook(() => + useUrlSyncedState({ + initialState: {key1: '', key2: ''}, + serializer: (state) => [ + ['key1', state.key1], + ['key2', state.key2], + ], + deserializer: (params) => ({ + key1: params.get('key1') ?? '', + key2: params.get('key2') ?? '', + }), + sync: true, + }), + ); + act(() => result.current[1]({key1: 'value1', key2: 'value2'})); + expect(window.location.search).toBe('?key1=value1&key2=value2'); + }); + + it('removes the parameter from the url if the state serializes to the same value as the initial state', () => { + const {result} = renderHook(() => + useUrlSyncedState({ + initialState: 'initial', + serializer: (state) => [['key', state]], + deserializer: (params) => params.get('key') ?? '', + sync: true, + }), + ); + act(() => result.current[1]('value')); + expect(window.location.search).toBe('?key=value'); + act(() => result.current[1]('initial')); + expect(window.location.search).toBe(''); + }); + + it('removes the parameter from the url if the state serializes to the empty string', () => { + const {result} = renderHook(() => + useUrlSyncedState({ + initialState: 'initial', + serializer: (state) => [['key', state]], + deserializer: (params) => params.get('key') ?? '', + sync: true, + }), + ); + act(() => result.current[1]('value')); + expect(window.location.search).toBe('?key=value'); + act(() => result.current[1]('')); + expect(window.location.search).toBe(''); + }); + + it('does not sync with the url if the sync parameter is set to false', () => { + const {result} = renderHook(() => + useUrlSyncedState({ + initialState: '', + serializer: (state) => [['key', state]], + deserializer: (params) => params.get('key') ?? '', + sync: false, + }), + ); + act(() => result.current[1]('value')); + expect(result.current[0]).toBe('value'); + expect(window.location.search).toBe(''); + }); + + it('derives the initial state from the url on first render', () => { + window.history.replaceState(null, '', '?key=value'); + + const {result} = renderHook(() => + useUrlSyncedState({ + initialState: 'initial', + serializer: (state) => [['key', state]], + deserializer: (params) => params.get('key') ?? '', + sync: true, + }), + ); + expect(result.current[0]).toBe('value'); + }); + + it('does not derive the initial state from the url on first render if sync option is false', () => { + window.history.replaceState(null, '', '?key=value'); + + const {result} = renderHook(() => + useUrlSyncedState({ + initialState: 'initial', + serializer: (state) => [['key', state]], + deserializer: (params) => params.get('key') ?? '', + sync: false, + }), + ); + expect(result.current[0]).toBe('initial'); + }); +}); diff --git a/packages/mantine/src/components/table/use-table.ts b/packages/mantine/src/components/table/use-table.ts index 6fa9939d3b..3857d9f5cb 100644 --- a/packages/mantine/src/components/table/use-table.ts +++ b/packages/mantine/src/components/table/use-table.ts @@ -3,13 +3,12 @@ import {type ExpandedState, type PaginationState, type SortingState} from '@tans import defaultsDeep from 'lodash.defaultsdeep'; import {Dispatch, SetStateAction, useCallback, useMemo, useState} from 'react'; import {type DateRangePickerValue} from '../date-range-picker'; +import {useUrlSyncedState} from './use-url-synced-state'; // Create a deeply optional version of another type -type PartialDeep = T extends object - ? { - [P in keyof T]?: PartialDeep; - } - : T; +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; export interface TableState { /** @@ -166,7 +165,7 @@ export interface UseTableOptions { /** * Initial state of the table. */ - initialState?: PartialDeep>; + initialState?: DeepPartial>; /** * Whether rows can be selected. * @@ -186,12 +185,19 @@ export interface UseTableOptions { * @default false */ forceSelection?: boolean; + /** + * Whether to sync the table state with the URL. + * + * @default false + */ + syncWithUrl?: boolean; } const defaultOptions: UseTableOptions = { enableRowSelection: true, enableMultiRowSelection: false, forceSelection: false, + syncWithUrl: false, }; const defaultState: Partial = { @@ -213,21 +219,120 @@ export const useTable = (userOptions: UseTableOptions = {}): Table const options = defaultsDeep({}, userOptions, defaultOptions) as UseTableOptions; const initialState = defaultsDeep({}, options.initialState, defaultState) as TableState; - const [pagination, setPagination] = useState['pagination']>(initialState.pagination); + // synced with url + const [pagination, setPagination] = useUrlSyncedState['pagination']>({ + initialState: initialState.pagination, + serializer: ({pageIndex, pageSize}) => [ + ['page', (pageIndex + 1).toString()], + ['pageSize', pageSize.toString()], + ], + deserializer: (params) => + defaultsDeep( + { + pageIndex: params.get('page') ? parseInt(params.get('page'), 10) - 1 : undefined, + pageSize: params.get('pageSize') ? parseInt(params.get('pageSize'), 10) : undefined, + }, + initialState.pagination, + ), + sync: options.syncWithUrl, + }); + const [sorting, setSorting] = useUrlSyncedState['sorting']>({ + initialState: initialState.sorting, + serializer: (_sorting) => [ + ['sortBy', _sorting.map(({id, desc}) => `${id}.${desc ? 'desc' : 'asc'}`).join(',')], + ], + deserializer: (params) => { + if (!params.has('sortBy')) { + return initialState.sorting; + } + const sorts = params.get('sortBy')?.split(',') ?? []; + return sorts.map((sort) => { + const [id, order] = sort.split('.'); + return {id, desc: order === 'desc'}; + }); + }, + sync: options.syncWithUrl, + }); + const [globalFilter, setGlobalFilter] = useUrlSyncedState['globalFilter']>({ + initialState: initialState.globalFilter, + serializer: (filter) => [['filter', filter]], + deserializer: (params) => params.get('filter') ?? initialState.globalFilter, + sync: options.syncWithUrl, + }); + const [predicates, setPredicates] = useUrlSyncedState['predicates']>({ + initialState: initialState.predicates, + serializer: (_predicates) => Object.entries(_predicates).map(([key, value]) => [key, value]), + deserializer: (params) => + Object.keys(initialState.predicates).reduce( + (acc, predicateKey) => { + acc[predicateKey] = params.get(predicateKey) ?? initialState.predicates[predicateKey]; + return acc; + }, + {} as TableState['predicates'], + ), + sync: options.syncWithUrl, + }); + const [layout, setLayout] = useUrlSyncedState['layout']>({ + initialState: initialState.layout, + serializer: (_layout) => [['layout', _layout]], + deserializer: (params) => params.get('layout') ?? initialState.layout, + sync: options.syncWithUrl, + }); + const [dateRange, setDateRange] = useUrlSyncedState['dateRange']>({ + initialState: initialState.dateRange, + serializer: ([from, to]) => [ + ['from', from?.toISOString() ?? ''], + ['to', to?.toISOString() ?? ''], + ], + deserializer: (params) => [ + params.get('from') ? new Date(params.get('from') as string) : initialState.dateRange[0], + params.get('to') ? new Date(params.get('to') as string) : initialState.dateRange[1], + ], + sync: options.syncWithUrl, + }); + const [columnVisibility, setColumnVisibility] = useUrlSyncedState['columnVisibility']>({ + initialState: initialState.columnVisibility, + serializer: (columns) => [ + [ + 'show', + Object.entries(columns) + .filter(([, visible]) => visible === true) + .map(([columnName]) => columnName) + .join(','), + ], + [ + 'hide', + Object.entries(columns) + .filter(([, visible]) => visible === false) + .map(([columnName]) => columnName) + .join(','), + ], + ], + deserializer: (params) => { + if (!params.has('show') && !params.has('hide')) { + return initialState.columnVisibility; + } + const visible = params.get('show')?.split(',') ?? []; + const invisible = params.get('hide')?.split(',') ?? []; + const columns = {} as TableState['columnVisibility']; + visible.forEach((column) => { + columns[column] = true; + }); + invisible.forEach((column) => { + columns[column] = false; + }); + return columns; + }, + sync: options.syncWithUrl, + }); + + // unsynced const [totalEntries, _setTotalEntries] = useState['totalEntries']>(initialState.totalEntries); const [unfilteredTotalEntries, setUnfilteredTotalEntries] = useState['totalEntries']>( initialState.totalEntries, ); - const [sorting, setSorting] = useState['sorting']>(initialState.sorting); - const [globalFilter, setGlobalFilter] = useState['globalFilter']>(initialState.globalFilter); const [expanded, setExpanded] = useState['expanded']>(initialState.expanded); - const [predicates, setPredicates] = useState['predicates']>(initialState.predicates); - const [layout, setLayout] = useState['layout']>(initialState.layout); - const [dateRange, setDateRange] = useState['dateRange']>(initialState.dateRange); const [rowSelection, setRowSelection] = useState['rowSelection']>(initialState.rowSelection); - const [columnVisibility, setColumnVisibility] = useState['columnVisibility']>( - initialState.columnVisibility, - ); const isFiltered = !!globalFilter || diff --git a/packages/mantine/src/components/table/use-url-synced-state.ts b/packages/mantine/src/components/table/use-url-synced-state.ts new file mode 100644 index 0000000000..b9608cdc6b --- /dev/null +++ b/packages/mantine/src/components/table/use-url-synced-state.ts @@ -0,0 +1,70 @@ +import {useMemo, useState} from 'react'; + +const setSearchParam = (key: string, value: string, initial: string) => { + const url = new URL(window.location.href); + if (value === '' || value === initial) { + url.searchParams.delete(key); + } else { + url.searchParams.set(key, value); + } + window.history.replaceState(null, '', url.toString()); +}; + +const getSearchParams = (): URLSearchParams => { + const url = new URL(window.location.href); + return url.searchParams; +}; + +export interface UseUrlSyncedStateOptions { + /** + * The initial state + */ + initialState: T; + /** + * The serializer function is used to determine how the state is translated to url search parameters. + * Called each time the state changes. + * @param stateValue The new state value to serialize. + * @returns A list of [key, value] to set as url search parameters. + * @example (filterValue) => [['filter', filterValue]] // ?filter=filterValue + */ + serializer: (stateValue: T) => Array<[string, string]>; + /** + * The deserializer function is used to determine how the url parameters influence the initial state. + * Called only once when initializing the state. + * @param params All the search parameters of the current url. + * @returns The initial state based on the current url. + * @example (params) => params.get('filter') ?? '', + */ + deserializer: (params: URLSearchParams) => T; + /** + * Whether the state should be synced with the url. + * When set to false, the hook behaves just like a regular useState hook from react. + */ + sync?: boolean; +} + +export const useUrlSyncedState = ({initialState, serializer, deserializer, sync}: UseUrlSyncedStateOptions) => { + const [state, setState] = useState(sync ? deserializer(getSearchParams()) : initialState); + const enhancedSetState = useMemo(() => { + if (sync) { + const initialSerialized = serializer(initialState).reduce( + (acc, [key, value]) => { + acc[key] = value; + return acc; + }, + {} as Record, + ); + return (updater: T | ((old: T) => T)) => { + setState((old) => { + const newValue = updater instanceof Function ? updater(old) : updater; + serializer(newValue).forEach(([key, value]) => setSearchParam(key, value, initialSerialized[key])); + return newValue; + }); + }; + } + + return setState; + }, [sync]); + + return [state, enhancedSetState] as const; +}; diff --git a/packages/website/src/examples/layout/Table/TableClientSide.demo.tsx b/packages/website/src/examples/layout/Table/TableClientSide.demo.tsx index 0bc33f8a7c..b4e688946a 100644 --- a/packages/website/src/examples/layout/Table/TableClientSide.demo.tsx +++ b/packages/website/src/examples/layout/Table/TableClientSide.demo.tsx @@ -67,6 +67,7 @@ const Demo = () => { pagination: {pageSize: 5}, totalEntries: data.length, }, + syncWithUrl: true, }); return ( store={table} data={data} columns={columns} options={options} getRowId={({id}) => id}> diff --git a/packages/website/src/examples/layout/Table/TablePredicate.demo.tsx b/packages/website/src/examples/layout/Table/TablePredicate.demo.tsx index 9a2c15da20..4b5826e490 100644 --- a/packages/website/src/examples/layout/Table/TablePredicate.demo.tsx +++ b/packages/website/src/examples/layout/Table/TablePredicate.demo.tsx @@ -30,7 +30,7 @@ const columns: Array> = [ const Demo = () => { const data = useMemo(() => makeData(10), []); const table = useTable({ - initialState: {totalEntries: data.length, predicates: {status: '', age: ''}}, + initialState: {totalEntries: data.length, predicates: {status: 'ALL', age: 'ANY'}}, }); // we're filtering the data ourselves here for the example, @@ -51,7 +51,7 @@ const Demo = () => { label="Age group" data={[ { - value: '', + value: 'ANY', label: 'Any', }, {value: 'below20', label: 'Below 20'}, @@ -64,7 +64,7 @@ const Demo = () => { label="Status" data={[ { - value: '', + value: 'ALL', label: 'All', }, {value: 'relationship', label: 'In a relationship'}, @@ -121,5 +121,5 @@ const statusFilter = (row: Person, predicates: Record) => { const status = row['status']; const filterValue = predicates['status']; - return !filterValue || status === filterValue; + return filterValue === 'ALL' || status === filterValue; };