mirror of
https://github.com/Unleash/unleash.git
synced 2025-03-27 00:19:39 +01:00
feat: keep filter order (#5688)
This commit is contained in:
parent
b933a03e8a
commit
8306073e1f
frontend/src
component
hooks
utils
@ -86,14 +86,15 @@ const FeatureToggleListTableComponent: VFC = () => {
|
||||
'features-list-table',
|
||||
stateConfig,
|
||||
);
|
||||
|
||||
const filterState = {
|
||||
project: tableState.project,
|
||||
tag: tableState.tag,
|
||||
state: tableState.state,
|
||||
segment: tableState.segment,
|
||||
createdAt: tableState.createdAt,
|
||||
};
|
||||
const {
|
||||
offset,
|
||||
limit,
|
||||
query,
|
||||
favoritesFirst,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
...filterState
|
||||
} = tableState;
|
||||
|
||||
const {
|
||||
features = [],
|
||||
@ -130,10 +131,10 @@ const FeatureToggleListTableComponent: VFC = () => {
|
||||
columnHelper.accessor('favorite', {
|
||||
header: () => (
|
||||
<FavoriteIconHeader
|
||||
isActive={tableState.favoritesFirst}
|
||||
isActive={favoritesFirst}
|
||||
onClick={() =>
|
||||
setTableState({
|
||||
favoritesFirst: !tableState.favoritesFirst,
|
||||
favoritesFirst: !favoritesFirst,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@ -211,7 +212,7 @@ const FeatureToggleListTableComponent: VFC = () => {
|
||||
cell: ({ getValue }) => <FeatureStaleCell value={getValue()} />,
|
||||
}),
|
||||
],
|
||||
[tableState.favoritesFirst],
|
||||
[favoritesFirst],
|
||||
);
|
||||
|
||||
const data = useMemo(
|
||||
@ -270,9 +271,7 @@ const FeatureToggleListTableComponent: VFC = () => {
|
||||
<Search
|
||||
placeholder='Search'
|
||||
expandable
|
||||
initialValue={
|
||||
tableState.query || ''
|
||||
}
|
||||
initialValue={query || ''}
|
||||
onChange={setSearchValue}
|
||||
id='globalFeatureToggles'
|
||||
/>
|
||||
@ -298,7 +297,7 @@ const FeatureToggleListTableComponent: VFC = () => {
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={tableState.query || ''}
|
||||
initialValue={query || ''}
|
||||
onChange={setSearchValue}
|
||||
id='globalFeatureToggles'
|
||||
/>
|
||||
@ -311,7 +310,7 @@ const FeatureToggleListTableComponent: VFC = () => {
|
||||
onChange={setTableState}
|
||||
state={filterState}
|
||||
/>
|
||||
<SearchHighlightProvider value={tableState.query || ''}>
|
||||
<SearchHighlightProvider value={query || ''}>
|
||||
<PaginatedTable tableInstance={table} totalItems={total} />
|
||||
</SearchHighlightProvider>
|
||||
<ConditionallyRender
|
||||
@ -319,11 +318,11 @@ const FeatureToggleListTableComponent: VFC = () => {
|
||||
show={
|
||||
<Box sx={(theme) => ({ padding: theme.spacing(0, 2, 2) })}>
|
||||
<ConditionallyRender
|
||||
condition={(tableState.query || '')?.length > 0}
|
||||
condition={(query || '')?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No feature toggles found matching “
|
||||
{tableState.query}
|
||||
{query}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { render } from 'utils/testRenderer';
|
||||
import { FILTER_ITEM } from 'utils/testIds';
|
||||
import { Filters, IFilterItem } from './Filters';
|
||||
import { FilterItemParamHolder, Filters, IFilterItem } from './Filters';
|
||||
|
||||
test('shoulder render all available filters', async () => {
|
||||
const availableFilters: IFilterItem[] = [
|
||||
@ -126,3 +126,51 @@ test('should remove selected item from the add filter list', async () => {
|
||||
addFilterButton.click();
|
||||
expect(screen.getByRole('menu').textContent).toBe('Tags');
|
||||
});
|
||||
|
||||
test('should render filters in the order defined by the initial state', async () => {
|
||||
const initialState: FilterItemParamHolder = {
|
||||
filterB: { operator: '', values: [] },
|
||||
filterA: { operator: '', values: [] },
|
||||
filterC: { operator: '', values: [] },
|
||||
};
|
||||
|
||||
const availableFilters: IFilterItem[] = [
|
||||
{
|
||||
label: 'FilterA',
|
||||
icon: '',
|
||||
options: [],
|
||||
filterKey: 'filterA',
|
||||
singularOperators: ['IRRELEVANT'],
|
||||
pluralOperators: ['IRRELEVANT'],
|
||||
},
|
||||
{
|
||||
label: 'FilterB',
|
||||
icon: '',
|
||||
options: [],
|
||||
filterKey: 'filterB',
|
||||
singularOperators: ['IRRELEVANT'],
|
||||
pluralOperators: ['IRRELEVANT'],
|
||||
},
|
||||
{
|
||||
label: 'FilterC',
|
||||
icon: '',
|
||||
options: [],
|
||||
filterKey: 'filterC',
|
||||
singularOperators: ['IRRELEVANT'],
|
||||
pluralOperators: ['IRRELEVANT'],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<Filters
|
||||
availableFilters={availableFilters}
|
||||
onChange={() => {}}
|
||||
state={initialState}
|
||||
/>,
|
||||
);
|
||||
|
||||
const filterItems = screen.getAllByTestId(FILTER_ITEM);
|
||||
const filterTexts = filterItems.map((item) => item.textContent);
|
||||
|
||||
expect(filterTexts).toEqual(['FilterB', 'FilterA', 'FilterC']);
|
||||
});
|
||||
|
@ -90,9 +90,16 @@ export const Filters: VFC<IFilterProps> = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const newSelectedFilters = availableFilters
|
||||
const newSelectedFilters = Object.keys(state)
|
||||
.map((filterKey) =>
|
||||
availableFilters.find(
|
||||
(filter) => filterKey === filter.filterKey,
|
||||
),
|
||||
)
|
||||
.filter((filter): filter is IFilterItem => Boolean(filter))
|
||||
.filter((field) => Boolean(state[field.filterKey]))
|
||||
.map((field) => field.label);
|
||||
.map((filter) => filter.label);
|
||||
|
||||
const allSelectedFilters = mergeArraysKeepingOrder(
|
||||
selectedFilters,
|
||||
newSelectedFilters,
|
||||
|
@ -27,6 +27,9 @@ function TestComponent({ keyName, queryParamsDefinition }: TestComponentProps) {
|
||||
<span data-testid='state-value'>
|
||||
{tableState.query}
|
||||
</span>
|
||||
<span data-testid='state-keys'>
|
||||
{Object.keys(tableState).join(',')}
|
||||
</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setTableState({ query: 'after' })}
|
||||
@ -229,4 +232,29 @@ describe('usePersistentTableState', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('maintains key order', async () => {
|
||||
createLocalStorage('testKey', {});
|
||||
|
||||
render(
|
||||
<TestComponent
|
||||
keyName='testKey'
|
||||
queryParamsDefinition={{
|
||||
query: StringParam,
|
||||
another: StringParam,
|
||||
ignore: StringParam,
|
||||
}}
|
||||
/>,
|
||||
{ route: '/my-url?another=another&query=initialUrl' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('state-keys').textContent).toBe(
|
||||
'another,query,ignore',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const { value } = createLocalStorage('testKey', {});
|
||||
expect(Object.keys(value)).toStrictEqual(['another', 'query']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||
import { encodeQueryParams, useQueryParams } from 'use-query-params';
|
||||
import { QueryParamConfigMap } from 'serialize-query-params/src/types';
|
||||
import { reorderObject } from '../utils/reorderObject';
|
||||
|
||||
const usePersistentSearchParams = <T extends QueryParamConfigMap>(
|
||||
key: string,
|
||||
@ -43,6 +44,11 @@ export const usePersistentTableState = <T extends QueryParamConfigMap>(
|
||||
queryParamsDefinition,
|
||||
);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const orderedTableState = useMemo(() => {
|
||||
return reorderObject(tableState, [...searchParams.keys()]);
|
||||
}, [searchParams, tableState, reorderObject]);
|
||||
|
||||
type SetTableStateInternalParam = Parameters<
|
||||
typeof setTableStateInternal
|
||||
>[0];
|
||||
@ -76,9 +82,9 @@ export const usePersistentTableState = <T extends QueryParamConfigMap>(
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const { offset, ...rest } = tableState;
|
||||
const { offset, ...rest } = orderedTableState;
|
||||
updateStoredParams(rest);
|
||||
}, [JSON.stringify(tableState)]);
|
||||
}, [JSON.stringify(orderedTableState)]);
|
||||
|
||||
return [tableState, setTableState] as const;
|
||||
return [orderedTableState, setTableState] as const;
|
||||
};
|
||||
|
40
frontend/src/utils/reorderObject.test.ts
Normal file
40
frontend/src/utils/reorderObject.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { reorderObject } from './reorderObject';
|
||||
|
||||
describe('reorderObject', () => {
|
||||
it('correctly reorders the object based on provided keys', () => {
|
||||
const myObj = { a: 1, b: 2, c: 3, d: 4 };
|
||||
const order = ['b', 'a'];
|
||||
const result = reorderObject(myObj, order);
|
||||
const expected = { b: 2, a: 1, c: 3, d: 4 };
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('ignores non-existent keys in the order array', () => {
|
||||
const myObj = { a: 1, b: 2, c: 3 };
|
||||
const order = ['c', 'z', 'a']; // 'z' does not exist in myObj
|
||||
const result = reorderObject(myObj, order);
|
||||
const expected = { c: 3, a: 1, b: 2 };
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('returns the original object when order array is empty', () => {
|
||||
const myObj = { a: 1, b: 2, c: 3 };
|
||||
const order: string[] = [];
|
||||
const result = reorderObject(myObj, order);
|
||||
expect(result).toEqual(myObj);
|
||||
});
|
||||
|
||||
it('returns the object with the same order when order array contains all object keys', () => {
|
||||
const myObj = { a: 1, b: 2, c: 3 };
|
||||
const order = ['a', 'b', 'c'];
|
||||
const result = reorderObject(myObj, order);
|
||||
expect(result).toEqual(myObj);
|
||||
});
|
||||
|
||||
it('does not modify the original object', () => {
|
||||
const myObj = { a: 1, b: 2, c: 3 };
|
||||
const order = ['b', 'a'];
|
||||
const result = reorderObject(myObj, order);
|
||||
expect(myObj).toEqual({ a: 1, b: 2, c: 3 }); // myObj should remain unchanged
|
||||
});
|
||||
});
|
22
frontend/src/utils/reorderObject.ts
Normal file
22
frontend/src/utils/reorderObject.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export const reorderObject = <T extends object>(obj: T, order: string[]): T => {
|
||||
// Create a set for quick lookup of the ordered keys
|
||||
const orderSet = new Set(order);
|
||||
|
||||
const orderedObj: Partial<T> = {};
|
||||
|
||||
// Add explicitly ordered keys to the ordered object
|
||||
order.forEach((key) => {
|
||||
if (key in obj) {
|
||||
orderedObj[key as keyof T] = obj[key as keyof T];
|
||||
}
|
||||
});
|
||||
|
||||
// Add remaining keys that were not explicitly ordered
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (!orderSet.has(key)) {
|
||||
orderedObj[key as keyof T] = obj[key as keyof T];
|
||||
}
|
||||
});
|
||||
|
||||
return orderedObj as T;
|
||||
};
|
Loading…
Reference in New Issue
Block a user