From 2dcf4af7b15c90d1b22b99e259ed5b465949b772 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 5 Dec 2023 17:31:23 +0100 Subject: [PATCH] feat: filter persisted in url (#5549) --- frontend/package.json | 2 +- .../common/FilterItem/FilterItem.tsx | 78 +++++++++---------- .../FeatureToggleFilters.tsx | 5 +- .../FeatureToggleListTable.tsx | 45 ++++++----- .../hooks/usePersistentTableState.test.tsx | 26 +++++++ frontend/src/hooks/usePersistentTableState.ts | 25 ++++-- frontend/src/utils/serializeQueryParams.ts | 34 ++++++++ src/server-dev.ts | 2 +- 8 files changed, 144 insertions(+), 73 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index e714032f72..159ebefb9e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "start:demo": "UNLEASH_BASE_PATH=/demo/ UNLEASH_API=https://app.unleash-hosted.com/ yarn run start", "test": "NODE_OPTIONS=\"${NODE_OPTIONS} --no-experimental-fetch\" vitest run", "test:snapshot": "NODE_OPTIONS=\"${NODE_OPTIONS} --no-experimental-fetch\" yarn test -u", - "test:watch": "NODE_OPTIONS=\"${NODE_OPTIONS} --no-experimental-fetch\" vitest watch usePersistentTable", + "test:watch": "NODE_OPTIONS=\"${NODE_OPTIONS} --no-experimental-fetch\" vitest watch", "lint": "biome lint src --apply", "lint:check": "biome check src", "fmt": "biome format src --write", diff --git a/frontend/src/component/common/FilterItem/FilterItem.tsx b/frontend/src/component/common/FilterItem/FilterItem.tsx index 598d8366dd..637a8b3ba4 100644 --- a/frontend/src/component/common/FilterItem/FilterItem.tsx +++ b/frontend/src/component/common/FilterItem/FilterItem.tsx @@ -13,24 +13,30 @@ import { FilterItemChip } from './FilterItemChip/FilterItemChip'; interface IFilterItemProps { label: string; options: Array<{ label: string; value: string }>; - onChange?: (value: string) => void; + onChange: (value: FilterItem) => void; + state: FilterItem | null | undefined; } const singularOperators = ['IS', 'IS_NOT']; const pluralOperators = ['IS_ANY_OF', 'IS_NOT_ANY_OF']; +export type FilterItem = { + operator: string; + values: string[]; +}; + export const FilterItem: FC = ({ label, options, onChange, + state, }) => { const ref = useRef(null); - const [selectedOptions, setSelectedOptions] = useState([]); const [anchorEl, setAnchorEl] = useState(null); const [searchText, setSearchText] = useState(''); + const currentOperators = - selectedOptions?.length > 1 ? pluralOperators : singularOperators; - const [operator, setOperator] = useState(currentOperators[0]); + state && state.values.length > 1 ? pluralOperators : singularOperators; const onClick = () => { setAnchorEl(ref.current); @@ -40,72 +46,58 @@ export const FilterItem: FC = ({ setAnchorEl(null); }; - const handleOnChange = ( - op: typeof operator, - values: typeof selectedOptions, - ) => { - const value = values.length - ? `${op}:${values?.map((option) => option.value).join(', ')}` - : ''; - onChange?.(value); - }; - - const handleOperatorChange = (value: string) => { - setOperator(value); - handleOnChange(value, selectedOptions); - }; - - const handleOptionsChange = (values: typeof selectedOptions) => { - setSelectedOptions(values); - handleOnChange(operator, values); - }; + const selectedOptions = state ? state.values : []; + const currentOperator = state ? state.operator : currentOperators[0]; const onDelete = () => { - handleOptionsChange([]); + onChange({ operator: 'IS', values: [] }); onClose(); }; const handleToggle = (value: string) => () => { if ( - selectedOptions?.some( - (selectedOption) => selectedOption.value === value, - ) + selectedOptions?.some((selectedOption) => selectedOption === value) ) { const newOptions = selectedOptions?.filter( - (selectedOption) => selectedOption.value !== value, + (selectedOption) => selectedOption !== value, ); - handleOptionsChange(newOptions); + onChange({ operator: currentOperator, values: newOptions }); } else { const newOptions = [ ...(selectedOptions ?? []), - options.find((option) => option.value === value) ?? { - label: '', - value: '', - }, + ( + options.find((option) => option.value === value) ?? { + label: '', + value: '', + } + ).value, ]; - handleOptionsChange(newOptions); + onChange({ operator: currentOperator, values: newOptions }); } }; useEffect(() => { - if (!currentOperators.includes(operator)) { - setOperator(currentOperators[0]); + if (state && !currentOperators.includes(state.operator)) { + onChange({ + operator: currentOperators[0], + values: state.values, + }); } - }, [currentOperators, operator]); + }, [state]); return ( <> option?.label, - )} + selectedOptions={selectedOptions} onDelete={onDelete} onClick={onClick} - operator={operator} + operator={currentOperator} operatorOptions={currentOperators} - onChangeOperator={handleOperatorChange} + onChangeOperator={(operator) => { + onChange({ operator, values: selectedOptions ?? [] }); + }} /> = ({ checked={ selectedOptions?.some( (selectedOption) => - selectedOption.value === + selectedOption === option.value, ) ?? false } diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.tsx index ae3856649c..c1638b9768 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.tsx @@ -5,7 +5,7 @@ import useProjects from 'hooks/api/getters/useProjects/useProjects'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; export type FeatureTogglesListFilters = { - projectId?: string; + project: FilterItem | null | undefined; }; interface IFeatureToggleFiltersProps { @@ -30,8 +30,9 @@ export const FeatureToggleFilters: VFC = ({ show={() => ( onChange({ projectId: value })} + onChange={(value) => onChange({ project: value })} /> )} /> diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx index 86d8fff352..e0d9e46bfc 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -8,13 +8,12 @@ import { useTheme, } from '@mui/material'; import { Link as RouterLink } from 'react-router-dom'; -import { useReactTable, createColumnHelper } from '@tanstack/react-table'; +import { createColumnHelper, useReactTable } from '@tanstack/react-table'; import { PaginatedTable, TablePlaceholder } from 'component/common/Table'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; -import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { PageContent } from 'component/common/PageContent/PageContent'; import { PageHeader } from 'component/common/PageHeader/PageHeader'; @@ -22,7 +21,6 @@ import { FeatureSchema } from 'openapi'; import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton'; import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell'; import { Search } from 'component/common/Search/Search'; -import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell'; import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi'; import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell'; import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader'; @@ -33,17 +31,22 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { focusable } from 'themes/themeStyles'; import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell'; import useToast from 'hooks/useToast'; -import { - FeatureToggleFilters, - FeatureTogglesListFilters, -} from './FeatureToggleFilters/FeatureToggleFilters'; +import { FeatureToggleFilters } from './FeatureToggleFilters/FeatureToggleFilters'; import { DEFAULT_PAGE_LIMIT, useFeatureSearch, } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch'; import mapValues from 'lodash.mapvalues'; -import { NumberParam, StringParam, withDefault } from 'use-query-params'; -import { BooleansStringParam } from 'utils/serializeQueryParams'; +import { + BooleansStringParam, + FilterItemParam, +} from 'utils/serializeQueryParams'; +import { + encodeQueryParams, + NumberParam, + StringParam, + withDefault, +} from 'use-query-params'; import { withTableState } from 'utils/withTableState'; import { usePersistentTableState } from 'hooks/usePersistentTableState'; @@ -70,16 +73,18 @@ export const FeatureToggleListTable: VFC = () => { const { setToastApiError } = useToast(); const { uiConfig } = useUiConfig(); + const config = { + offset: withDefault(NumberParam, 0), + limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT), + query: StringParam, + favoritesFirst: withDefault(BooleansStringParam, true), + sortBy: withDefault(StringParam, 'createdAt'), + sortOrder: withDefault(StringParam, 'desc'), + project: FilterItemParam, + }; const [tableState, setTableState] = usePersistentTableState( 'features-list-table', - { - offset: withDefault(NumberParam, 0), - limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT), - query: StringParam, - favoritesFirst: withDefault(BooleansStringParam, true), - sortBy: withDefault(StringParam, 'createdAt'), - sortOrder: withDefault(StringParam, 'desc'), - }, + config, ); const { @@ -89,7 +94,9 @@ export const FeatureToggleListTable: VFC = () => { refetch: refetchFeatures, initialLoad, } = useFeatureSearch( - mapValues(tableState, (value) => (value ? `${value}` : undefined)), + mapValues(encodeQueryParams(config, tableState), (value) => + value ? `${value}` : undefined, + ), ); const { favorite, unfavorite } = useFavoriteFeaturesApi(); const onFavorite = useCallback( @@ -308,7 +315,7 @@ export const FeatureToggleListTable: VFC = () => { } > - {/* */} + diff --git a/frontend/src/hooks/usePersistentTableState.test.tsx b/frontend/src/hooks/usePersistentTableState.test.tsx index 8590351758..4e18ffc1e9 100644 --- a/frontend/src/hooks/usePersistentTableState.test.tsx +++ b/frontend/src/hooks/usePersistentTableState.test.tsx @@ -5,6 +5,7 @@ import { usePersistentTableState } from './usePersistentTableState'; import { Route, Routes } from 'react-router-dom'; import { createLocalStorage } from '../utils/createLocalStorage'; import { NumberParam, StringParam } from 'use-query-params'; +import { FilterItemParam } from '../utils/serializeQueryParams'; type TestComponentProps = { keyName: string; @@ -80,6 +81,31 @@ describe('usePersistentTableState', () => { expect(window.location.href).toContain('my-url?query=initialStorage'); }); + it('initializes correctly from localStorage with complex decoder', async () => { + createLocalStorage('testKey', {}).setValue({ + query: 'initialStorage', + filterItem: { operator: 'IS', values: ['default'] }, + }); + + render( + , + { route: '/my-url' }, + ); + + expect(screen.getByTestId('state-value').textContent).toBe( + 'initialStorage', + ); + expect(window.location.href).toContain( + 'my-url?query=initialStorage&filterItem=IS%3Adefault', + ); + }); + it('initializes correctly from localStorage and URL', async () => { createLocalStorage('testKey', {}).setValue({ query: 'initialStorage' }); diff --git a/frontend/src/hooks/usePersistentTableState.ts b/frontend/src/hooks/usePersistentTableState.ts index 73925fd8e8..20989d5dc9 100644 --- a/frontend/src/hooks/usePersistentTableState.ts +++ b/frontend/src/hooks/usePersistentTableState.ts @@ -1,9 +1,13 @@ import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { createLocalStorage } from 'utils/createLocalStorage'; -import { useQueryParams } from 'use-query-params'; +import { useQueryParams, encodeQueryParams } from 'use-query-params'; +import { QueryParamConfigMap } from 'serialize-query-params/src/types'; -const usePersistentSearchParams = (key: string) => { +const usePersistentSearchParams = ( + key: string, + queryParamsDefinition: T, +) => { const [searchParams, setSearchParams] = useSearchParams(); const { value, setValue } = createLocalStorage(key, {}); useEffect(() => { @@ -15,19 +19,26 @@ const usePersistentSearchParams = (key: string) => { return; } - setSearchParams(value, { replace: true }); + setSearchParams( + encodeQueryParams(queryParamsDefinition, value) as Record< + string, + string + >, + { replace: true }, + ); }, []); return setValue; }; -export const usePersistentTableState = < - T extends Parameters[0], ->( +export const usePersistentTableState = ( key: string, queryParamsDefinition: T, ) => { - const updateStoredParams = usePersistentSearchParams(key); + const updateStoredParams = usePersistentSearchParams( + key, + queryParamsDefinition, + ); const [tableState, setTableState] = useQueryParams(queryParamsDefinition); diff --git a/frontend/src/utils/serializeQueryParams.ts b/frontend/src/utils/serializeQueryParams.ts index 80a72df65c..7cc2031435 100644 --- a/frontend/src/utils/serializeQueryParams.ts +++ b/frontend/src/utils/serializeQueryParams.ts @@ -28,3 +28,37 @@ export const BooleansStringParam = { encode: encodeBoolean, decode: decodeBoolean, }; + +export type FilterItem = { + operator: string; + values: string[]; +}; + +const encodeFilterItem = ( + filterItem: FilterItem | null | undefined, +): string | undefined => { + return filterItem && filterItem.values.length + ? `${filterItem.operator}:${filterItem.values.join(',')}` + : undefined; +}; + +const decodeFilterItem = ( + input: string | (string | null)[] | null | undefined, +): FilterItem | null | undefined => { + if (typeof input !== 'string' || !input) { + return undefined; + } + + const [operator, values = ''] = input.split(':'); + if (!operator) return undefined; + + const splitValues = values.split(','); + return splitValues.length > 0 + ? { operator, values: splitValues } + : undefined; +}; + +export const FilterItemParam = { + encode: encodeFilterItem, + decode: decodeFilterItem, +}; diff --git a/src/server-dev.ts b/src/server-dev.ts index 1f490c6d2f..52a9cb2456 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -39,7 +39,7 @@ process.nextTick(async () => { responseTimeWithAppNameKillSwitch: false, privateProjects: true, featureSearchAPI: true, - featureSearchFrontend: false, + featureSearchFrontend: true, }, }, authentication: {