From a5624b36c339c459f21b05d9fd134929cd913ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gra=CC=88=C3=9Fl?= Date: Thu, 25 Sep 2025 21:47:59 +0200 Subject: [PATCH] feat: Introduce URL table params feature --- package-lock.json | 23 +++++-- package.json | 1 + .../TableToolsTable/TableToolsTable.js | 28 ++++++++- .../TableToolsTableExperiments.stories.js | 60 +++++++++++++++++++ src/hooks/useFilterConfig/useFilterConfig.js | 2 + .../usePagination/hooks/usePaginationState.js | 6 +- .../hooks/useSearchParamsState.js | 22 +++++++ src/hooks/useTableSearchParams/index.js | 1 + .../useTableSearchParams.js | 30 ++++++++++ src/hooks/useTableSort/useTableSort.js | 16 +++-- src/support/factories/filters.js | 2 +- 11 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 src/hooks/useTableSearchParams/hooks/useSearchParamsState.js create mode 100644 src/hooks/useTableSearchParams/index.js create mode 100644 src/hooks/useTableSearchParams/useTableSearchParams.js diff --git a/package-lock.json b/package-lock.json index 7050eb5..c488bca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "@tanstack/react-pacer": ">= 0.15.0", "@tanstack/react-query": ">= 5.83.0", "p-all": ">= 4.0.0", + "qs": ">= 6.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "use-deep-compare": "^1.3.0" @@ -6959,7 +6960,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -17264,7 +17264,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -18329,6 +18328,22 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -19606,7 +19621,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -19626,7 +19640,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -19643,7 +19656,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -19662,7 +19674,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", diff --git a/package.json b/package.json index a6fb8a5..877f920 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@tanstack/react-pacer": ">= 0.15.0", "@tanstack/react-query": ">= 5.83.0", "p-all": ">= 4.0.0", + "qs": ">= 6.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "use-deep-compare": "^1.3.0" diff --git a/src/components/TableToolsTable/TableToolsTable.js b/src/components/TableToolsTable/TableToolsTable.js index c4df589..1d09f76 100644 --- a/src/components/TableToolsTable/TableToolsTable.js +++ b/src/components/TableToolsTable/TableToolsTable.js @@ -15,6 +15,7 @@ import PrimaryToolbar from '@redhat-cloud-services/frontend-components/PrimaryTo import TableToolbar from '@redhat-cloud-services/frontend-components/TableToolbar'; import useTableTools from '~/hooks/useTableTools'; +import useTableSearchParams from '~/hooks/useTableSearchParams'; import { TableStateProvider, FilterModal, TableViewToggle } from '~/components'; const TableToolsTable = ({ @@ -34,6 +35,7 @@ const TableToolsTable = ({ paginationProps, ...tablePropsRest }) => { + const searchParamsState = useTableSearchParams(options); const { view, loading, @@ -49,10 +51,34 @@ const TableToolsTable = ({ externalTotal, { treeTable, - filters, + filters: { + ...(filters || {}), + activeFilters: { + ...filters?.activeFilters, + ...(searchParamsState?.filters || {}), + }, + }, columns, toolbarProps: toolbarPropsProp, tableProps: tablePropsRest, + ...(searchParamsState + ? { + ...(searchParamsState.page + ? { page: parseInt(searchParamsState.page) } + : {}), + ...(searchParamsState.perPage + ? { perPage: parseInt(searchParamsState.perPage) } + : {}), + ...(searchParamsState.sort + ? { + sortBy: { + ...searchParamsState.sort, + index: parseInt(searchParamsState.sort.index), + }, + } + : {}), + } + : {}), ...options, }, ); diff --git a/src/components/TableToolsTable/TableToolsTableExperiments.stories.js b/src/components/TableToolsTable/TableToolsTableExperiments.stories.js index 9575eec..c9f3eb1 100644 --- a/src/components/TableToolsTable/TableToolsTableExperiments.stories.js +++ b/src/components/TableToolsTable/TableToolsTableExperiments.stories.js @@ -4,6 +4,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Card, CardBody, + Content, + ContentVariants, Spinner, Button, Label, @@ -536,4 +538,62 @@ export const AccessItemsStory = { render: (args) => , }; +const UrlParamsExample = () => { + const exampleUrl = + 'perPage=20&page=1&filters[title][0]=ds&filters[rating-above][0]=4&sort[index]=1&sort[direction]=desc'; + const searchParams = new URLSearchParams(exampleUrl); + const [searchParamsState, setSearchParamsState] = useState(); + const setSearchParams = useCallback((params) => { + setSearchParamsState(params); + }, []); + + const { + loading, + result: { data, meta: { total } = {} } = {}, + error, + } = useExampleDataQuery({ + endpoint: '/api', + useTableState: true, + }); + + return ( + <> + + URL would be:{' '} + {decodeURI(searchParamsState?.toString() || '')} + + + + ); +}; + +export const UrlParamsStory = { + decorators: [ + (Story) => ( + + + + + + ), + ], + render: (args) => , +}; + export default meta; diff --git a/src/hooks/useFilterConfig/useFilterConfig.js b/src/hooks/useFilterConfig/useFilterConfig.js index b323dcc..1255d49 100644 --- a/src/hooks/useFilterConfig/useFilterConfig.js +++ b/src/hooks/useFilterConfig/useFilterConfig.js @@ -81,6 +81,8 @@ const useFilterConfig = (options) => { : {}, ); + // TODO with URL params it can (for some reason) happen that initial values get debounced + // and the first request won't include filters. const debouncedSetState = useDebouncedCallback(setTableState, { wait: 500 }); useEffect(() => { diff --git a/src/hooks/usePagination/hooks/usePaginationState.js b/src/hooks/usePagination/hooks/usePaginationState.js index 9e67c1f..ef5fc7d 100644 --- a/src/hooks/usePagination/hooks/usePaginationState.js +++ b/src/hooks/usePagination/hooks/usePaginationState.js @@ -8,13 +8,13 @@ import useTableState from '~/hooks/useTableState'; import { TABLE_STATE_NAMESPACE } from '../constants'; const usePaginationState = (options) => { - const { perPage = 10, serialisers } = options; + const { perPage = 10, page = 1, serialisers } = options; const defaultState = useMemo(() => { return { perPage, - page: 1, + page, }; - }, [perPage]); + }, [perPage, page]); const resetPage = useCallback( (currentState) => { return { diff --git a/src/hooks/useTableSearchParams/hooks/useSearchParamsState.js b/src/hooks/useTableSearchParams/hooks/useSearchParamsState.js new file mode 100644 index 0000000..a2ea820 --- /dev/null +++ b/src/hooks/useTableSearchParams/hooks/useSearchParamsState.js @@ -0,0 +1,22 @@ +import { useCallback, useRef } from 'react'; +import { parse, stringify } from 'qs'; + +const useSearchParamsState = ({ searchParams, setSearchParams }) => { + const searchParamsState = useRef( + searchParams ? parse(searchParams.toString()) : undefined, + ); + + const setSearchParamsState = useCallback( + (params) => { + setSearchParams(new URLSearchParams(stringify(params))); + }, + [setSearchParams], + ); + + return [ + searchParams && searchParamsState.current, + setSearchParams && setSearchParamsState, + ]; +}; + +export default useSearchParamsState; diff --git a/src/hooks/useTableSearchParams/index.js b/src/hooks/useTableSearchParams/index.js new file mode 100644 index 0000000..6c8735d --- /dev/null +++ b/src/hooks/useTableSearchParams/index.js @@ -0,0 +1 @@ +export { default } from './useTableSearchParams'; diff --git a/src/hooks/useTableSearchParams/useTableSearchParams.js b/src/hooks/useTableSearchParams/useTableSearchParams.js new file mode 100644 index 0000000..b80d24d --- /dev/null +++ b/src/hooks/useTableSearchParams/useTableSearchParams.js @@ -0,0 +1,30 @@ +import { useDeepCompareEffect } from 'use-deep-compare'; +import { useFullTableState } from '~/hooks'; + +import useSearchParamsState from './hooks/useSearchParamsState'; + +const useTableSearchParams = ({ searchParams, setSearchParams }) => { + const fullTableState = useFullTableState(); + const { + tableState: { pagination: { state: pagination } = {}, filters, sort } = {}, + } = fullTableState || {}; + + const [searchParamsState, setSearchParamsState] = useSearchParamsState({ + searchParams, + setSearchParams, + }); + + useDeepCompareEffect(() => { + if (setSearchParamsState) { + setSearchParamsState({ + ...(pagination || {}), + filters, + ...(sort ? { sort } : {}), + }); + } + }, [pagination, filters, sort, setSearchParamsState]); + + return searchParamsState; +}; + +export default useTableSearchParams; diff --git a/src/hooks/useTableSort/useTableSort.js b/src/hooks/useTableSort/useTableSort.js index b9b2c2a..49debb1 100644 --- a/src/hooks/useTableSort/useTableSort.js +++ b/src/hooks/useTableSort/useTableSort.js @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useDeepCompareMemo } from 'use-deep-compare'; import useTableState, { useRawTableState } from '~/hooks/useTableState'; @@ -41,7 +41,14 @@ const useTableSort = (columns, options = {}) => { serialisers: { sort: serialiser } = {}, onSort: onSortOption, } = options; - + const defaultState = useMemo( + () => + initialSortBy || { + index: 0, + direction: 'asc', + }, + [initialSortBy], + ); const { tableView } = useRawTableState() || {}; const offset = columnOffset({ ...options, tableView }); @@ -57,10 +64,7 @@ const useTableSort = (columns, options = {}) => { ); const [sortBy, setSortBy] = useTableState( TABLE_STATE_NAMESPACE, - initialSortBy || { - index: 0, - direction: 'asc', - }, + defaultState, stateOptions, ); diff --git a/src/support/factories/filters.js b/src/support/factories/filters.js index 93666eb..0851538 100644 --- a/src/support/factories/filters.js +++ b/src/support/factories/filters.js @@ -45,7 +45,7 @@ export const rating = { label: 'Rating above', items: [...new Array(5)].map((_, idx) => ({ label: [...new Array(idx + 1)].map(() => '★'), - value: idx + 1, + value: `${idx + 1}`, })), filterSerialiser: (_config, value) => `.rating >= ${value}`, };