1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

feat: filter persisted in url (#5549)

This commit is contained in:
Mateusz Kwasniewski 2023-12-05 17:31:23 +01:00 committed by GitHub
parent f348acb3b9
commit 2dcf4af7b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 144 additions and 73 deletions

View File

@ -17,7 +17,7 @@
"start:demo": "UNLEASH_BASE_PATH=/demo/ UNLEASH_API=https://app.unleash-hosted.com/ yarn run start", "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": "NODE_OPTIONS=\"${NODE_OPTIONS} --no-experimental-fetch\" vitest run",
"test:snapshot": "NODE_OPTIONS=\"${NODE_OPTIONS} --no-experimental-fetch\" yarn test -u", "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": "biome lint src --apply",
"lint:check": "biome check src", "lint:check": "biome check src",
"fmt": "biome format src --write", "fmt": "biome format src --write",

View File

@ -13,24 +13,30 @@ import { FilterItemChip } from './FilterItemChip/FilterItemChip';
interface IFilterItemProps { interface IFilterItemProps {
label: string; label: string;
options: Array<{ label: string; value: 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 singularOperators = ['IS', 'IS_NOT'];
const pluralOperators = ['IS_ANY_OF', 'IS_NOT_ANY_OF']; const pluralOperators = ['IS_ANY_OF', 'IS_NOT_ANY_OF'];
export type FilterItem = {
operator: string;
values: string[];
};
export const FilterItem: FC<IFilterItemProps> = ({ export const FilterItem: FC<IFilterItemProps> = ({
label, label,
options, options,
onChange, onChange,
state,
}) => { }) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [selectedOptions, setSelectedOptions] = useState<typeof options>([]);
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(null);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const currentOperators = const currentOperators =
selectedOptions?.length > 1 ? pluralOperators : singularOperators; state && state.values.length > 1 ? pluralOperators : singularOperators;
const [operator, setOperator] = useState(currentOperators[0]);
const onClick = () => { const onClick = () => {
setAnchorEl(ref.current); setAnchorEl(ref.current);
@ -40,72 +46,58 @@ export const FilterItem: FC<IFilterItemProps> = ({
setAnchorEl(null); setAnchorEl(null);
}; };
const handleOnChange = ( const selectedOptions = state ? state.values : [];
op: typeof operator, const currentOperator = state ? state.operator : currentOperators[0];
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 onDelete = () => { const onDelete = () => {
handleOptionsChange([]); onChange({ operator: 'IS', values: [] });
onClose(); onClose();
}; };
const handleToggle = (value: string) => () => { const handleToggle = (value: string) => () => {
if ( if (
selectedOptions?.some( selectedOptions?.some((selectedOption) => selectedOption === value)
(selectedOption) => selectedOption.value === value,
)
) { ) {
const newOptions = selectedOptions?.filter( const newOptions = selectedOptions?.filter(
(selectedOption) => selectedOption.value !== value, (selectedOption) => selectedOption !== value,
); );
handleOptionsChange(newOptions); onChange({ operator: currentOperator, values: newOptions });
} else { } else {
const newOptions = [ const newOptions = [
...(selectedOptions ?? []), ...(selectedOptions ?? []),
options.find((option) => option.value === value) ?? { (
label: '', options.find((option) => option.value === value) ?? {
value: '', label: '',
}, value: '',
}
).value,
]; ];
handleOptionsChange(newOptions); onChange({ operator: currentOperator, values: newOptions });
} }
}; };
useEffect(() => { useEffect(() => {
if (!currentOperators.includes(operator)) { if (state && !currentOperators.includes(state.operator)) {
setOperator(currentOperators[0]); onChange({
operator: currentOperators[0],
values: state.values,
});
} }
}, [currentOperators, operator]); }, [state]);
return ( return (
<> <>
<Box ref={ref}> <Box ref={ref}>
<FilterItemChip <FilterItemChip
label={label} label={label}
selectedOptions={selectedOptions?.map( selectedOptions={selectedOptions}
(option) => option?.label,
)}
onDelete={onDelete} onDelete={onDelete}
onClick={onClick} onClick={onClick}
operator={operator} operator={currentOperator}
operatorOptions={currentOperators} operatorOptions={currentOperators}
onChangeOperator={handleOperatorChange} onChangeOperator={(operator) => {
onChange({ operator, values: selectedOptions ?? [] });
}}
/> />
</Box> </Box>
<StyledPopover <StyledPopover
@ -158,7 +150,7 @@ export const FilterItem: FC<IFilterItemProps> = ({
checked={ checked={
selectedOptions?.some( selectedOptions?.some(
(selectedOption) => (selectedOption) =>
selectedOption.value === selectedOption ===
option.value, option.value,
) ?? false ) ?? false
} }

View File

@ -5,7 +5,7 @@ import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
export type FeatureTogglesListFilters = { export type FeatureTogglesListFilters = {
projectId?: string; project: FilterItem | null | undefined;
}; };
interface IFeatureToggleFiltersProps { interface IFeatureToggleFiltersProps {
@ -30,8 +30,9 @@ export const FeatureToggleFilters: VFC<IFeatureToggleFiltersProps> = ({
show={() => ( show={() => (
<FilterItem <FilterItem
label='Project' label='Project'
state={state.project}
options={projectsOptions} options={projectsOptions}
onChange={(value) => onChange({ projectId: value })} onChange={(value) => onChange({ project: value })}
/> />
)} )}
/> />

View File

@ -8,13 +8,12 @@ import {
useTheme, useTheme,
} from '@mui/material'; } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom'; 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 { PaginatedTable, TablePlaceholder } from 'component/common/Table';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; 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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
@ -22,7 +21,6 @@ import { FeatureSchema } from 'openapi';
import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton'; import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton';
import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell'; import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell';
import { Search } from 'component/common/Search/Search'; 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 { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell'; import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader'; 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 { focusable } from 'themes/themeStyles';
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell'; import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { import { FeatureToggleFilters } from './FeatureToggleFilters/FeatureToggleFilters';
FeatureToggleFilters,
FeatureTogglesListFilters,
} from './FeatureToggleFilters/FeatureToggleFilters';
import { import {
DEFAULT_PAGE_LIMIT, DEFAULT_PAGE_LIMIT,
useFeatureSearch, useFeatureSearch,
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch'; } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
import mapValues from 'lodash.mapvalues'; import mapValues from 'lodash.mapvalues';
import { NumberParam, StringParam, withDefault } from 'use-query-params'; import {
import { BooleansStringParam } from 'utils/serializeQueryParams'; BooleansStringParam,
FilterItemParam,
} from 'utils/serializeQueryParams';
import {
encodeQueryParams,
NumberParam,
StringParam,
withDefault,
} from 'use-query-params';
import { withTableState } from 'utils/withTableState'; import { withTableState } from 'utils/withTableState';
import { usePersistentTableState } from 'hooks/usePersistentTableState'; import { usePersistentTableState } from 'hooks/usePersistentTableState';
@ -70,16 +73,18 @@ export const FeatureToggleListTable: VFC = () => {
const { setToastApiError } = useToast(); const { setToastApiError } = useToast();
const { uiConfig } = useUiConfig(); 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( const [tableState, setTableState] = usePersistentTableState(
'features-list-table', 'features-list-table',
{ 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'),
},
); );
const { const {
@ -89,7 +94,9 @@ export const FeatureToggleListTable: VFC = () => {
refetch: refetchFeatures, refetch: refetchFeatures,
initialLoad, initialLoad,
} = useFeatureSearch( } = useFeatureSearch(
mapValues(tableState, (value) => (value ? `${value}` : undefined)), mapValues(encodeQueryParams(config, tableState), (value) =>
value ? `${value}` : undefined,
),
); );
const { favorite, unfavorite } = useFavoriteFeaturesApi(); const { favorite, unfavorite } = useFavoriteFeaturesApi();
const onFavorite = useCallback( const onFavorite = useCallback(
@ -308,7 +315,7 @@ export const FeatureToggleListTable: VFC = () => {
</PageHeader> </PageHeader>
} }
> >
{/* <FeatureToggleFilters state={tableState} onChange={setTableState} /> */} <FeatureToggleFilters onChange={setTableState} state={tableState} />
<SearchHighlightProvider value={tableState.query || ''}> <SearchHighlightProvider value={tableState.query || ''}>
<PaginatedTable tableInstance={table} totalItems={total} /> <PaginatedTable tableInstance={table} totalItems={total} />
</SearchHighlightProvider> </SearchHighlightProvider>

View File

@ -5,6 +5,7 @@ import { usePersistentTableState } from './usePersistentTableState';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { createLocalStorage } from '../utils/createLocalStorage'; import { createLocalStorage } from '../utils/createLocalStorage';
import { NumberParam, StringParam } from 'use-query-params'; import { NumberParam, StringParam } from 'use-query-params';
import { FilterItemParam } from '../utils/serializeQueryParams';
type TestComponentProps = { type TestComponentProps = {
keyName: string; keyName: string;
@ -80,6 +81,31 @@ describe('usePersistentTableState', () => {
expect(window.location.href).toContain('my-url?query=initialStorage'); 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 () => { it('initializes correctly from localStorage and URL', async () => {
createLocalStorage('testKey', {}).setValue({ query: 'initialStorage' }); createLocalStorage('testKey', {}).setValue({ query: 'initialStorage' });

View File

@ -1,9 +1,13 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { createLocalStorage } from 'utils/createLocalStorage'; 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 [searchParams, setSearchParams] = useSearchParams();
const { value, setValue } = createLocalStorage(key, {}); const { value, setValue } = createLocalStorage(key, {});
useEffect(() => { useEffect(() => {
@ -15,19 +19,26 @@ const usePersistentSearchParams = (key: string) => {
return; return;
} }
setSearchParams(value, { replace: true }); setSearchParams(
encodeQueryParams(queryParamsDefinition, value) as Record<
string,
string
>,
{ replace: true },
);
}, []); }, []);
return setValue; return setValue;
}; };
export const usePersistentTableState = < export const usePersistentTableState = <T extends QueryParamConfigMap>(
T extends Parameters<typeof useQueryParams>[0],
>(
key: string, key: string,
queryParamsDefinition: T, queryParamsDefinition: T,
) => { ) => {
const updateStoredParams = usePersistentSearchParams(key); const updateStoredParams = usePersistentSearchParams(
key,
queryParamsDefinition,
);
const [tableState, setTableState] = useQueryParams(queryParamsDefinition); const [tableState, setTableState] = useQueryParams(queryParamsDefinition);

View File

@ -28,3 +28,37 @@ export const BooleansStringParam = {
encode: encodeBoolean, encode: encodeBoolean,
decode: decodeBoolean, 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,
};

View File

@ -39,7 +39,7 @@ process.nextTick(async () => {
responseTimeWithAppNameKillSwitch: false, responseTimeWithAppNameKillSwitch: false,
privateProjects: true, privateProjects: true,
featureSearchAPI: true, featureSearchAPI: true,
featureSearchFrontend: false, featureSearchFrontend: true,
}, },
}, },
authentication: { authentication: {