1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: filter by feature type (#7273)

This commit is contained in:
Mateusz Kwasniewski 2024-06-05 08:17:54 +02:00 committed by GitHub
parent 257cd5513f
commit fef77c1fde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 123 additions and 30 deletions

View File

@ -2,8 +2,10 @@ import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { FeatureOverviewCell as makeFeatureOverviewCell } from './FeatureOverviewCell';
const noOp = () => {};
test('Display full overview information', () => {
const FeatureOverviewCell = makeFeatureOverviewCell(() => {});
const FeatureOverviewCell = makeFeatureOverviewCell(noOp, noOp);
render(
<FeatureOverviewCell
@ -40,7 +42,7 @@ test('Display full overview information', () => {
});
test('Display minimal overview information', () => {
const FeatureOverviewCell = makeFeatureOverviewCell(() => {});
const FeatureOverviewCell = makeFeatureOverviewCell(noOp, noOp);
render(
<FeatureOverviewCell

View File

@ -197,7 +197,8 @@ const PrimaryFeatureInfo: FC<{
searchQuery: string;
type: string;
dependencyType: string;
}> = ({ project, feature, type, searchQuery, dependencyType }) => {
onTypeClick: (type: string) => void;
}> = ({ project, feature, type, searchQuery, dependencyType, onTypeClick }) => {
const { featureTypes } = useFeatureTypes();
const IconComponent = getFeatureTypeIcons(type);
const typeName = featureTypes.find(
@ -207,7 +208,14 @@ const PrimaryFeatureInfo: FC<{
const TypeIcon = () => (
<HtmlTooltip arrow title={title} describeChild>
<IconComponent sx={(theme) => ({ fontSize: theme.spacing(2) })} />
<IconComponent
data-testid='feature-type-icon'
sx={(theme) => ({
cursor: 'pointer',
fontSize: theme.spacing(2),
})}
onClick={() => onTypeClick(type)}
/>
</HtmlTooltip>
);
@ -259,7 +267,10 @@ const SecondaryFeatureInfo: FC<{
};
export const FeatureOverviewCell =
(onClick: (tag: string) => void): FC<IFeatureNameCellProps> =>
(
onTagClick: (tag: string) => void,
onFlagTypeClick: (type: string) => void,
): FC<IFeatureNameCellProps> =>
({ row }) => {
const { searchQuery } = useSearchHighlightContext();
@ -271,12 +282,13 @@ export const FeatureOverviewCell =
searchQuery={searchQuery}
type={row.original.type || ''}
dependencyType={row.original.dependencyType || ''}
onTypeClick={onFlagTypeClick}
/>
<SecondaryFeatureInfo
description={row.original.description || ''}
searchQuery={searchQuery}
/>
<Tags tags={row.original.tags} onClick={onClick} />
<Tags tags={row.original.tags} onClick={onTagClick} />
</Container>
);
};

View File

@ -2,15 +2,19 @@ import { render } from 'utils/testRenderer';
import { Route, Routes } from 'react-router-dom';
import { ProjectFeatureToggles } from './ProjectFeatureToggles';
import { testServerRoute, testServerSetup } from 'utils/testServer';
import { screen } from '@testing-library/react';
import { screen, fireEvent } from '@testing-library/react';
import { BATCH_SELECTED_COUNT } from 'utils/testIds';
const server = testServerSetup();
const setupApi = () => {
const features = [
{ name: 'featureA', tags: [{ type: 'backend', value: 'sdk' }] },
{ name: 'featureB' },
{
name: 'featureA',
tags: [{ type: 'backend', value: 'sdk' }],
type: 'operational',
},
{ name: 'featureB', type: 'release' },
];
testServerRoute(server, '/api/admin/search/features', {
features,
@ -89,3 +93,29 @@ test('filters by tag', async () => {
await screen.findByText('include');
expect(screen.getAllByText('backend:sdk')).toHaveLength(2);
});
test('filters by flag type', async () => {
setupApi();
render(
<Routes>
<Route
path={'/projects/:projectId'}
element={
<ProjectFeatureToggles
environments={['development', 'production']}
/>
}
/>
</Routes>,
{
route: '/projects/default',
},
);
await screen.findByText('featureA');
const [icon] = await screen.getAllByTestId('feature-type-icon');
fireEvent.click(icon);
await screen.findByText('Flag type');
await screen.findByText('Operational');
});

View File

@ -35,7 +35,10 @@ import { useRowActions } from './hooks/useRowActions';
import { useSelectedData } from './hooks/useSelectedData';
import { FeatureOverviewCell } from '../../../common/Table/cells/FeatureOverviewCell/FeatureOverviewCell';
import { useUiFlag } from 'hooks/useUiFlag';
import { useProjectFeatureSearch } from './useProjectFeatureSearch';
import {
useProjectFeatureSearch,
useProjectFeatureSearchActions,
} from './useProjectFeatureSearch';
interface IPaginatedProjectFeatureTogglesProps {
environments: string[];
@ -62,9 +65,15 @@ export const ProjectFeatureToggles = ({
setTableState,
} = useProjectFeatureSearch(projectId);
const { onFlagTypeClick, onTagClick } = useProjectFeatureSearchActions(
tableState,
setTableState,
);
const filterState = {
tag: tableState.tag,
createdAt: tableState.createdAt,
type: tableState.type,
};
const { favorite, unfavorite } = useFavoriteFeaturesApi();
@ -93,24 +102,6 @@ export const ProjectFeatureToggles = ({
const featureLifecycleEnabled = useUiFlag('featureLifecycle');
const onTagClick = (tag: string) => {
if (
tableState.tag &&
tableState.tag.values.length > 0 &&
!tableState.tag.values.includes(tag)
) {
setTableState({
tag: {
operator: tableState.tag.operator,
values: [...tableState.tag.values, tag],
},
});
}
if (!tableState.tag) {
setTableState({ tag: { operator: 'INCLUDE', values: [tag] } });
}
};
const columns = useMemo(
() => [
columnHelper.display({
@ -162,7 +153,7 @@ export const ProjectFeatureToggles = ({
columnHelper.accessor('name', {
id: 'name',
header: 'Name',
cell: FeatureOverviewCell(onTagClick),
cell: FeatureOverviewCell(onTagClick, onFlagTypeClick),
enableHiding: false,
meta: {
width: '50%',

View File

@ -45,6 +45,20 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
filterKey: 'createdAt',
dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'],
},
{
label: 'Flag type',
icon: 'flag',
options: [
{ label: 'Release', value: 'release' },
{ label: 'Experiment', value: 'experiment' },
{ label: 'Operational', value: 'operational' },
{ label: 'Kill switch', value: 'kill-switch' },
{ label: 'Permission', value: 'permission' },
],
filterKey: 'type',
singularOperators: ['IS', 'IS_NOT'],
pluralOperators: ['IS_ANY_OF', 'IS_NONE_OF'],
},
];
setAvailableFilters(availableFilters);

View File

@ -16,6 +16,10 @@ import {
import { usePersistentTableState } from 'hooks/usePersistentTableState';
import mapValues from 'lodash.mapvalues';
type Attribute =
| { key: 'tag'; operator: 'INCLUDE' }
| { key: 'type'; operator: 'IS' };
export const useProjectFeatureSearch = (
projectId: string,
storageKey = 'project-overview-v2',
@ -31,6 +35,7 @@ export const useProjectFeatureSearch = (
columns: ArrayParam,
tag: FilterItemParam,
createdAt: FilterItemParam,
type: FilterItemParam,
};
const [tableState, setTableState] = usePersistentTableState(
`${storageKey}-${projectId}`,
@ -61,3 +66,42 @@ export const useProjectFeatureSearch = (
setTableState,
};
};
export const useProjectFeatureSearchActions = (
tableState: ReturnType<typeof useProjectFeatureSearch>['tableState'],
setTableState: ReturnType<typeof useProjectFeatureSearch>['setTableState'],
) => {
const onAttributeClick = (attribute: Attribute, value: string) => {
const attributeState = tableState[attribute.key];
if (
attributeState &&
attributeState.values.length > 0 &&
!attributeState.values.includes(value)
) {
setTableState({
[attribute.key]: {
operator: attributeState.operator,
values: [...attributeState.values, value],
},
});
} else if (!attributeState) {
setTableState({
[attribute.key]: {
operator: attribute.operator,
values: [value],
},
});
}
};
const onTagClick = (tag: string) =>
onAttributeClick({ key: 'tag', operator: 'INCLUDE' }, tag);
const onFlagTypeClick = (type: string) =>
onAttributeClick({ key: 'type', operator: 'IS' }, type);
return {
onFlagTypeClick,
onTagClick,
};
};

View File

@ -20,7 +20,7 @@ export type SearchFeaturesParams = {
/**
* The list of feature types to filter by
*/
type?: string[];
type?: string;
/**
* The list of feature tags to filter by. Feature tag has to specify a type and a value joined with a colon.
*/