mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-31 00:16:47 +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