mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: filter persisted in url (#5549)
This commit is contained in:
		
							parent
							
								
									f348acb3b9
								
							
						
					
					
						commit
						2dcf4af7b1
					
				| @ -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", | ||||
|  | ||||
| @ -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<IFilterItemProps> = ({ | ||||
|     label, | ||||
|     options, | ||||
|     onChange, | ||||
|     state, | ||||
| }) => { | ||||
|     const ref = useRef<HTMLDivElement>(null); | ||||
|     const [selectedOptions, setSelectedOptions] = useState<typeof options>([]); | ||||
|     const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(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<IFilterItemProps> = ({ | ||||
|         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 ( | ||||
|         <> | ||||
|             <Box ref={ref}> | ||||
|                 <FilterItemChip | ||||
|                     label={label} | ||||
|                     selectedOptions={selectedOptions?.map( | ||||
|                         (option) => option?.label, | ||||
|                     )} | ||||
|                     selectedOptions={selectedOptions} | ||||
|                     onDelete={onDelete} | ||||
|                     onClick={onClick} | ||||
|                     operator={operator} | ||||
|                     operator={currentOperator} | ||||
|                     operatorOptions={currentOperators} | ||||
|                     onChangeOperator={handleOperatorChange} | ||||
|                     onChangeOperator={(operator) => { | ||||
|                         onChange({ operator, values: selectedOptions ?? [] }); | ||||
|                     }} | ||||
|                 /> | ||||
|             </Box> | ||||
|             <StyledPopover | ||||
| @ -158,7 +150,7 @@ export const FilterItem: FC<IFilterItemProps> = ({ | ||||
|                                             checked={ | ||||
|                                                 selectedOptions?.some( | ||||
|                                                     (selectedOption) => | ||||
|                                                         selectedOption.value === | ||||
|                                                         selectedOption === | ||||
|                                                         option.value, | ||||
|                                                 ) ?? false | ||||
|                                             } | ||||
|  | ||||
| @ -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<IFeatureToggleFiltersProps> = ({ | ||||
|                 show={() => ( | ||||
|                     <FilterItem | ||||
|                         label='Project' | ||||
|                         state={state.project} | ||||
|                         options={projectsOptions} | ||||
|                         onChange={(value) => onChange({ projectId: value })} | ||||
|                         onChange={(value) => onChange({ project: value })} | ||||
|                     /> | ||||
|                 )} | ||||
|             /> | ||||
|  | ||||
| @ -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 = () => { | ||||
|                 </PageHeader> | ||||
|             } | ||||
|         > | ||||
|             {/* <FeatureToggleFilters state={tableState} onChange={setTableState} /> */} | ||||
|             <FeatureToggleFilters onChange={setTableState} state={tableState} /> | ||||
|             <SearchHighlightProvider value={tableState.query || ''}> | ||||
|                 <PaginatedTable tableInstance={table} totalItems={total} /> | ||||
|             </SearchHighlightProvider> | ||||
|  | ||||
| @ -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( | ||||
|             <TestComponent | ||||
|                 keyName='testKey' | ||||
|                 queryParamsDefinition={{ | ||||
|                     query: StringParam, | ||||
|                     filterItem: FilterItemParam, | ||||
|                 }} | ||||
|             />, | ||||
|             { 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' }); | ||||
| 
 | ||||
|  | ||||
| @ -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 = <T extends QueryParamConfigMap>( | ||||
|     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<typeof useQueryParams>[0], | ||||
| >( | ||||
| export const usePersistentTableState = <T extends QueryParamConfigMap>( | ||||
|     key: string, | ||||
|     queryParamsDefinition: T, | ||||
| ) => { | ||||
|     const updateStoredParams = usePersistentSearchParams(key); | ||||
|     const updateStoredParams = usePersistentSearchParams( | ||||
|         key, | ||||
|         queryParamsDefinition, | ||||
|     ); | ||||
| 
 | ||||
|     const [tableState, setTableState] = useQueryParams(queryParamsDefinition); | ||||
| 
 | ||||
|  | ||||
| @ -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, | ||||
| }; | ||||
|  | ||||
| @ -39,7 +39,7 @@ process.nextTick(async () => { | ||||
|                         responseTimeWithAppNameKillSwitch: false, | ||||
|                         privateProjects: true, | ||||
|                         featureSearchAPI: true, | ||||
|                         featureSearchFrontend: false, | ||||
|                         featureSearchFrontend: true, | ||||
|                     }, | ||||
|                 }, | ||||
|                 authentication: { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user