mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-24 01:18:01 +02:00
feat: keep filter order (#5688)
This commit is contained in:
parent
b933a03e8a
commit
8306073e1f
@ -86,14 +86,15 @@ const FeatureToggleListTableComponent: VFC = () => {
|
|||||||
'features-list-table',
|
'features-list-table',
|
||||||
stateConfig,
|
stateConfig,
|
||||||
);
|
);
|
||||||
|
const {
|
||||||
const filterState = {
|
offset,
|
||||||
project: tableState.project,
|
limit,
|
||||||
tag: tableState.tag,
|
query,
|
||||||
state: tableState.state,
|
favoritesFirst,
|
||||||
segment: tableState.segment,
|
sortBy,
|
||||||
createdAt: tableState.createdAt,
|
sortOrder,
|
||||||
};
|
...filterState
|
||||||
|
} = tableState;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
features = [],
|
features = [],
|
||||||
@ -130,10 +131,10 @@ const FeatureToggleListTableComponent: VFC = () => {
|
|||||||
columnHelper.accessor('favorite', {
|
columnHelper.accessor('favorite', {
|
||||||
header: () => (
|
header: () => (
|
||||||
<FavoriteIconHeader
|
<FavoriteIconHeader
|
||||||
isActive={tableState.favoritesFirst}
|
isActive={favoritesFirst}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setTableState({
|
setTableState({
|
||||||
favoritesFirst: !tableState.favoritesFirst,
|
favoritesFirst: !favoritesFirst,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -211,7 +212,7 @@ const FeatureToggleListTableComponent: VFC = () => {
|
|||||||
cell: ({ getValue }) => <FeatureStaleCell value={getValue()} />,
|
cell: ({ getValue }) => <FeatureStaleCell value={getValue()} />,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
[tableState.favoritesFirst],
|
[favoritesFirst],
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = useMemo(
|
const data = useMemo(
|
||||||
@ -270,9 +271,7 @@ const FeatureToggleListTableComponent: VFC = () => {
|
|||||||
<Search
|
<Search
|
||||||
placeholder='Search'
|
placeholder='Search'
|
||||||
expandable
|
expandable
|
||||||
initialValue={
|
initialValue={query || ''}
|
||||||
tableState.query || ''
|
|
||||||
}
|
|
||||||
onChange={setSearchValue}
|
onChange={setSearchValue}
|
||||||
id='globalFeatureToggles'
|
id='globalFeatureToggles'
|
||||||
/>
|
/>
|
||||||
@ -298,7 +297,7 @@ const FeatureToggleListTableComponent: VFC = () => {
|
|||||||
condition={isSmallScreen}
|
condition={isSmallScreen}
|
||||||
show={
|
show={
|
||||||
<Search
|
<Search
|
||||||
initialValue={tableState.query || ''}
|
initialValue={query || ''}
|
||||||
onChange={setSearchValue}
|
onChange={setSearchValue}
|
||||||
id='globalFeatureToggles'
|
id='globalFeatureToggles'
|
||||||
/>
|
/>
|
||||||
@ -311,7 +310,7 @@ const FeatureToggleListTableComponent: VFC = () => {
|
|||||||
onChange={setTableState}
|
onChange={setTableState}
|
||||||
state={filterState}
|
state={filterState}
|
||||||
/>
|
/>
|
||||||
<SearchHighlightProvider value={tableState.query || ''}>
|
<SearchHighlightProvider value={query || ''}>
|
||||||
<PaginatedTable tableInstance={table} totalItems={total} />
|
<PaginatedTable tableInstance={table} totalItems={total} />
|
||||||
</SearchHighlightProvider>
|
</SearchHighlightProvider>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
@ -319,11 +318,11 @@ const FeatureToggleListTableComponent: VFC = () => {
|
|||||||
show={
|
show={
|
||||||
<Box sx={(theme) => ({ padding: theme.spacing(0, 2, 2) })}>
|
<Box sx={(theme) => ({ padding: theme.spacing(0, 2, 2) })}>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={(tableState.query || '')?.length > 0}
|
condition={(query || '')?.length > 0}
|
||||||
show={
|
show={
|
||||||
<TablePlaceholder>
|
<TablePlaceholder>
|
||||||
No feature toggles found matching “
|
No feature toggles found matching “
|
||||||
{tableState.query}
|
{query}
|
||||||
”
|
”
|
||||||
</TablePlaceholder>
|
</TablePlaceholder>
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { render } from 'utils/testRenderer';
|
import { render } from 'utils/testRenderer';
|
||||||
import { FILTER_ITEM } from 'utils/testIds';
|
import { FILTER_ITEM } from 'utils/testIds';
|
||||||
import { Filters, IFilterItem } from './Filters';
|
import { FilterItemParamHolder, Filters, IFilterItem } from './Filters';
|
||||||
|
|
||||||
test('shoulder render all available filters', async () => {
|
test('shoulder render all available filters', async () => {
|
||||||
const availableFilters: IFilterItem[] = [
|
const availableFilters: IFilterItem[] = [
|
||||||
@ -126,3 +126,51 @@ test('should remove selected item from the add filter list', async () => {
|
|||||||
addFilterButton.click();
|
addFilterButton.click();
|
||||||
expect(screen.getByRole('menu').textContent).toBe('Tags');
|
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(() => {
|
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]))
|
.filter((field) => Boolean(state[field.filterKey]))
|
||||||
.map((field) => field.label);
|
.map((filter) => filter.label);
|
||||||
|
|
||||||
const allSelectedFilters = mergeArraysKeepingOrder(
|
const allSelectedFilters = mergeArraysKeepingOrder(
|
||||||
selectedFilters,
|
selectedFilters,
|
||||||
newSelectedFilters,
|
newSelectedFilters,
|
||||||
|
@ -27,6 +27,9 @@ function TestComponent({ keyName, queryParamsDefinition }: TestComponentProps) {
|
|||||||
<span data-testid='state-value'>
|
<span data-testid='state-value'>
|
||||||
{tableState.query}
|
{tableState.query}
|
||||||
</span>
|
</span>
|
||||||
|
<span data-testid='state-keys'>
|
||||||
|
{Object.keys(tableState).join(',')}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => setTableState({ query: 'after' })}
|
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 { useSearchParams } from 'react-router-dom';
|
||||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||||
import { encodeQueryParams, useQueryParams } from 'use-query-params';
|
import { encodeQueryParams, useQueryParams } from 'use-query-params';
|
||||||
import { QueryParamConfigMap } from 'serialize-query-params/src/types';
|
import { QueryParamConfigMap } from 'serialize-query-params/src/types';
|
||||||
|
import { reorderObject } from '../utils/reorderObject';
|
||||||
|
|
||||||
const usePersistentSearchParams = <T extends QueryParamConfigMap>(
|
const usePersistentSearchParams = <T extends QueryParamConfigMap>(
|
||||||
key: string,
|
key: string,
|
||||||
@ -43,6 +44,11 @@ export const usePersistentTableState = <T extends QueryParamConfigMap>(
|
|||||||
queryParamsDefinition,
|
queryParamsDefinition,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const orderedTableState = useMemo(() => {
|
||||||
|
return reorderObject(tableState, [...searchParams.keys()]);
|
||||||
|
}, [searchParams, tableState, reorderObject]);
|
||||||
|
|
||||||
type SetTableStateInternalParam = Parameters<
|
type SetTableStateInternalParam = Parameters<
|
||||||
typeof setTableStateInternal
|
typeof setTableStateInternal
|
||||||
>[0];
|
>[0];
|
||||||
@ -76,9 +82,9 @@ export const usePersistentTableState = <T extends QueryParamConfigMap>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { offset, ...rest } = tableState;
|
const { offset, ...rest } = orderedTableState;
|
||||||
updateStoredParams(rest);
|
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