mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: filters for project overview (#5620)
This commit is contained in:
parent
d632690203
commit
4f207f18e6
@ -10,7 +10,6 @@ import {
|
|||||||
FilterItemParams,
|
FilterItemParams,
|
||||||
} from 'component/common/FilterItem/FilterItem';
|
} from 'component/common/FilterItem/FilterItem';
|
||||||
import useAllTags from 'hooks/api/getters/useAllTags/useAllTags';
|
import useAllTags from 'hooks/api/getters/useAllTags/useAllTags';
|
||||||
import { FILTER_ITEM } from 'utils/testIds';
|
|
||||||
|
|
||||||
const StyledBox = styled(Box)(({ theme }) => ({
|
const StyledBox = styled(Box)(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -19,7 +18,7 @@ const StyledBox = styled(Box)(({ theme }) => ({
|
|||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export type FeatureTogglesListFilters = {
|
type FeatureTogglesListFilters = {
|
||||||
project?: FilterItemParams | null | undefined;
|
project?: FilterItemParams | null | undefined;
|
||||||
tag?: FilterItemParams | null | undefined;
|
tag?: FilterItemParams | null | undefined;
|
||||||
state?: FilterItemParams | null | undefined;
|
state?: FilterItemParams | null | undefined;
|
||||||
@ -32,7 +31,7 @@ interface IFeatureToggleFiltersProps {
|
|||||||
onChange: (value: FeatureTogglesListFilters) => void;
|
onChange: (value: FeatureTogglesListFilters) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFilterItem {
|
interface IFilterItem {
|
||||||
label: string;
|
label: string;
|
||||||
options: {
|
options: {
|
||||||
label: string;
|
label: string;
|
||||||
|
@ -75,7 +75,7 @@ export const FeatureToggleListTable: VFC = () => {
|
|||||||
const { setToastApiError } = useToast();
|
const { setToastApiError } = useToast();
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
const config = {
|
const stateConfig = {
|
||||||
offset: withDefault(NumberParam, 0),
|
offset: withDefault(NumberParam, 0),
|
||||||
limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT),
|
limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT),
|
||||||
query: StringParam,
|
query: StringParam,
|
||||||
@ -90,7 +90,7 @@ export const FeatureToggleListTable: VFC = () => {
|
|||||||
};
|
};
|
||||||
const [tableState, setTableState] = usePersistentTableState(
|
const [tableState, setTableState] = usePersistentTableState(
|
||||||
'features-list-table',
|
'features-list-table',
|
||||||
config,
|
stateConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -100,7 +100,7 @@ export const FeatureToggleListTable: VFC = () => {
|
|||||||
refetch: refetchFeatures,
|
refetch: refetchFeatures,
|
||||||
initialLoad,
|
initialLoad,
|
||||||
} = useFeatureSearch(
|
} = useFeatureSearch(
|
||||||
mapValues(encodeQueryParams(config, tableState), (value) =>
|
mapValues(encodeQueryParams(stateConfig, tableState), (value) =>
|
||||||
value ? `${value}` : undefined,
|
value ? `${value}` : undefined,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -73,12 +73,16 @@ import {
|
|||||||
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
||||||
import mapValues from 'lodash.mapvalues';
|
import mapValues from 'lodash.mapvalues';
|
||||||
import { usePersistentTableState } from 'hooks/usePersistentTableState';
|
import { usePersistentTableState } from 'hooks/usePersistentTableState';
|
||||||
import { BooleansStringParam } from 'utils/serializeQueryParams';
|
import {
|
||||||
|
BooleansStringParam,
|
||||||
|
FilterItemParam,
|
||||||
|
} from 'utils/serializeQueryParams';
|
||||||
import {
|
import {
|
||||||
NumberParam,
|
NumberParam,
|
||||||
StringParam,
|
StringParam,
|
||||||
ArrayParam,
|
ArrayParam,
|
||||||
withDefault,
|
withDefault,
|
||||||
|
encodeQueryParams,
|
||||||
} from 'use-query-params';
|
} from 'use-query-params';
|
||||||
import { ProjectFeatureTogglesHeader } from './ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader';
|
import { ProjectFeatureTogglesHeader } from './ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader';
|
||||||
import { createColumnHelper, useReactTable } from '@tanstack/react-table';
|
import { createColumnHelper, useReactTable } from '@tanstack/react-table';
|
||||||
@ -86,6 +90,7 @@ import { withTableState } from 'utils/withTableState';
|
|||||||
import { type FeatureSearchResponseSchema } from 'openapi';
|
import { type FeatureSearchResponseSchema } from 'openapi';
|
||||||
import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell';
|
import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell';
|
||||||
import { FeatureToggleCell } from './FeatureToggleCell/FeatureToggleCell';
|
import { FeatureToggleCell } from './FeatureToggleCell/FeatureToggleCell';
|
||||||
|
import { ProjectOverviewFilters } from './ProjectOverviewFilters';
|
||||||
|
|
||||||
interface IExperimentalProjectFeatureTogglesProps {
|
interface IExperimentalProjectFeatureTogglesProps {
|
||||||
environments: IProject['environments'];
|
environments: IProject['environments'];
|
||||||
@ -105,22 +110,29 @@ export const ExperimentalProjectFeatureToggles = ({
|
|||||||
storageKey = 'project-feature-toggles',
|
storageKey = 'project-feature-toggles',
|
||||||
}: IExperimentalProjectFeatureTogglesProps) => {
|
}: IExperimentalProjectFeatureTogglesProps) => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
const stateConfig = {
|
||||||
|
offset: withDefault(NumberParam, 0),
|
||||||
|
limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT),
|
||||||
|
query: StringParam,
|
||||||
|
favoritesFirst: withDefault(BooleansStringParam, true),
|
||||||
|
sortBy: withDefault(StringParam, 'createdAt'),
|
||||||
|
sortOrder: withDefault(StringParam, 'desc'),
|
||||||
|
columns: ArrayParam,
|
||||||
|
tag: FilterItemParam,
|
||||||
|
createdAt: FilterItemParam,
|
||||||
|
};
|
||||||
const [tableState, setTableState] = usePersistentTableState(
|
const [tableState, setTableState] = usePersistentTableState(
|
||||||
`${storageKey}-${projectId}`,
|
`${storageKey}-${projectId}`,
|
||||||
{
|
stateConfig,
|
||||||
offset: withDefault(NumberParam, 0),
|
|
||||||
limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT),
|
|
||||||
query: StringParam,
|
|
||||||
favoritesFirst: withDefault(BooleansStringParam, true),
|
|
||||||
sortBy: withDefault(StringParam, 'createdAt'),
|
|
||||||
sortOrder: withDefault(StringParam, 'desc'),
|
|
||||||
columns: ArrayParam,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { features, total, refetch, loading, initialLoad } = useFeatureSearch(
|
const { features, total, refetch, loading, initialLoad } = useFeatureSearch(
|
||||||
mapValues({ ...tableState, projectId }, (value) =>
|
mapValues(
|
||||||
value ? `${value}` : undefined,
|
{
|
||||||
|
...encodeQueryParams(stateConfig, tableState),
|
||||||
|
project: `IS:${projectId}`,
|
||||||
|
},
|
||||||
|
(value) => (value ? `${value}` : undefined),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
refreshInterval,
|
refreshInterval,
|
||||||
@ -366,6 +378,10 @@ export const ExperimentalProjectFeatureToggles = ({
|
|||||||
aria-busy={loading}
|
aria-busy={loading}
|
||||||
aria-live='polite'
|
aria-live='polite'
|
||||||
>
|
>
|
||||||
|
<ProjectOverviewFilters
|
||||||
|
onChange={setTableState}
|
||||||
|
state={tableState}
|
||||||
|
/>
|
||||||
<SearchHighlightProvider value={tableState.query || ''}>
|
<SearchHighlightProvider value={tableState.query || ''}>
|
||||||
<PaginatedTable
|
<PaginatedTable
|
||||||
tableInstance={table}
|
tableInstance={table}
|
||||||
|
@ -0,0 +1,183 @@
|
|||||||
|
import { useEffect, useState, VFC } from 'react';
|
||||||
|
import { Box, styled } from '@mui/material';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { FilterDateItem } from 'component/common/FilterDateItem/FilterDateItem';
|
||||||
|
import {
|
||||||
|
FilterItem,
|
||||||
|
FilterItemParams,
|
||||||
|
} from 'component/common/FilterItem/FilterItem';
|
||||||
|
import useAllTags from 'hooks/api/getters/useAllTags/useAllTags';
|
||||||
|
import AddFilterButton from 'component/feature/FeatureToggleList/FeatureToggleFilters/AddFilterButton/AddFilterButton';
|
||||||
|
|
||||||
|
const StyledBox = styled(Box)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
padding: theme.spacing(2, 3),
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}));
|
||||||
|
|
||||||
|
type FeatureTogglesListFilters = {
|
||||||
|
tag?: FilterItemParams | null | undefined;
|
||||||
|
createdAt?: FilterItemParams | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IFeatureToggleFiltersProps {
|
||||||
|
state: FeatureTogglesListFilters;
|
||||||
|
onChange: (value: FeatureTogglesListFilters) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFilterItem {
|
||||||
|
label: string;
|
||||||
|
options: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
filterKey: keyof FeatureTogglesListFilters;
|
||||||
|
singularOperators: [string, ...string[]];
|
||||||
|
pluralOperators: [string, ...string[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectOverviewFilters: VFC<IFeatureToggleFiltersProps> = ({
|
||||||
|
state,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const { tags } = useAllTags();
|
||||||
|
|
||||||
|
const [availableFilters, setAvailableFilters] = useState<IFilterItem[]>([]);
|
||||||
|
const [unselectedFilters, setUnselectedFilters] = useState<string[]>([]);
|
||||||
|
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const deselectFilter = (label: string) => {
|
||||||
|
const newSelectedFilters = selectedFilters.filter((f) => f !== label);
|
||||||
|
const newUnselectedFilters = [...unselectedFilters, label].sort();
|
||||||
|
|
||||||
|
setSelectedFilters(newSelectedFilters);
|
||||||
|
setUnselectedFilters(newUnselectedFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeArraysKeepingOrder = (
|
||||||
|
firstArray: string[],
|
||||||
|
secondArray: string[],
|
||||||
|
): string[] => {
|
||||||
|
const elementsSet = new Set(firstArray);
|
||||||
|
|
||||||
|
secondArray.forEach((element) => {
|
||||||
|
if (!elementsSet.has(element)) {
|
||||||
|
firstArray.push(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return firstArray;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tagsOptions = (tags || []).map((tag) => ({
|
||||||
|
label: `${tag.type}:${tag.value}`,
|
||||||
|
value: `${tag.type}:${tag.value}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const availableFilters: IFilterItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Tags',
|
||||||
|
options: tagsOptions,
|
||||||
|
filterKey: 'tag',
|
||||||
|
singularOperators: ['INCLUDE', 'DO_NOT_INCLUDE'],
|
||||||
|
pluralOperators: [
|
||||||
|
'INCLUDE_ALL_OF',
|
||||||
|
'INCLUDE_ANY_OF',
|
||||||
|
'EXCLUDE_IF_ANY_OF',
|
||||||
|
'EXCLUDE_ALL',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
setAvailableFilters(availableFilters);
|
||||||
|
}, [JSON.stringify(tags)]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fieldsMapping = [
|
||||||
|
{
|
||||||
|
stateField: 'tag',
|
||||||
|
label: 'Tags',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stateField: 'createdAt',
|
||||||
|
label: 'Created date',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const newSelectedFilters = fieldsMapping
|
||||||
|
.filter((field) =>
|
||||||
|
Boolean(
|
||||||
|
state[field.stateField as keyof FeatureTogglesListFilters],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((field) => field.label);
|
||||||
|
const newUnselectedFilters = fieldsMapping
|
||||||
|
.filter(
|
||||||
|
(field) =>
|
||||||
|
!state[field.stateField as keyof FeatureTogglesListFilters],
|
||||||
|
)
|
||||||
|
.map((field) => field.label)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
setSelectedFilters(
|
||||||
|
mergeArraysKeepingOrder(selectedFilters, newSelectedFilters),
|
||||||
|
);
|
||||||
|
setUnselectedFilters(newUnselectedFilters);
|
||||||
|
}, [JSON.stringify(state)]);
|
||||||
|
|
||||||
|
const hasAvailableFilters = unselectedFilters.length > 0;
|
||||||
|
return (
|
||||||
|
<StyledBox>
|
||||||
|
{selectedFilters.map((selectedFilter) => {
|
||||||
|
if (selectedFilter === 'Created date') {
|
||||||
|
return (
|
||||||
|
<FilterDateItem
|
||||||
|
label={'Created date'}
|
||||||
|
state={state.createdAt}
|
||||||
|
onChange={(value) => onChange({ createdAt: value })}
|
||||||
|
operators={['IS_ON_OR_AFTER', 'IS_BEFORE']}
|
||||||
|
onChipClose={() => deselectFilter('Created date')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = availableFilters.find(
|
||||||
|
(filter) => filter.label === selectedFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filter) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterItem
|
||||||
|
key={filter.label}
|
||||||
|
label={filter.label}
|
||||||
|
state={state[filter.filterKey]}
|
||||||
|
options={filter.options}
|
||||||
|
onChange={(value) =>
|
||||||
|
onChange({ [filter.filterKey]: value })
|
||||||
|
}
|
||||||
|
singularOperators={filter.singularOperators}
|
||||||
|
pluralOperators={filter.pluralOperators}
|
||||||
|
onChipClose={() => deselectFilter(filter.label)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={hasAvailableFilters}
|
||||||
|
show={
|
||||||
|
<AddFilterButton
|
||||||
|
visibleOptions={unselectedFilters}
|
||||||
|
setVisibleOptions={setUnselectedFilters}
|
||||||
|
hiddenOptions={selectedFilters}
|
||||||
|
setHiddenOptions={setSelectedFilters}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledBox>
|
||||||
|
);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user