Skip to content

Commit

Permalink
feat: filter persisted in url (#5549)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew authored Dec 5, 2023
1 parent f348acb commit 2dcf4af
Show file tree
Hide file tree
Showing 8 changed files with 144 additions and 73 deletions.
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 35 additions & 43 deletions frontend/src/component/common/FilterItem/FilterItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -158,7 +150,7 @@ export const FilterItem: FC<IFilterItemProps> = ({
checked={
selectedOptions?.some(
(selectedOption) =>
selectedOption.value ===
selectedOption ===
option.value,
) ?? false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 })}
/>
)}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,19 @@ 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';
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';
Expand All @@ -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';

Expand All @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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>
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/hooks/usePersistentTableState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' });

Expand Down
25 changes: 18 additions & 7 deletions frontend/src/hooks/usePersistentTableState.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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);

Expand Down
34 changes: 34 additions & 0 deletions frontend/src/utils/serializeQueryParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Loading

0 comments on commit 2dcf4af

Please sign in to comment.