mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-09 01:17:06 +02:00
feat: filter by feature type (#7273)
This commit is contained in:
parent
257cd5513f
commit
fef77c1fde
@ -2,8 +2,10 @@ import { screen } from '@testing-library/react';
|
|||||||
import { render } from 'utils/testRenderer';
|
import { render } from 'utils/testRenderer';
|
||||||
import { FeatureOverviewCell as makeFeatureOverviewCell } from './FeatureOverviewCell';
|
import { FeatureOverviewCell as makeFeatureOverviewCell } from './FeatureOverviewCell';
|
||||||
|
|
||||||
|
const noOp = () => {};
|
||||||
|
|
||||||
test('Display full overview information', () => {
|
test('Display full overview information', () => {
|
||||||
const FeatureOverviewCell = makeFeatureOverviewCell(() => {});
|
const FeatureOverviewCell = makeFeatureOverviewCell(noOp, noOp);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<FeatureOverviewCell
|
<FeatureOverviewCell
|
||||||
@ -40,7 +42,7 @@ test('Display full overview information', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Display minimal overview information', () => {
|
test('Display minimal overview information', () => {
|
||||||
const FeatureOverviewCell = makeFeatureOverviewCell(() => {});
|
const FeatureOverviewCell = makeFeatureOverviewCell(noOp, noOp);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<FeatureOverviewCell
|
<FeatureOverviewCell
|
||||||
|
@ -197,7 +197,8 @@ const PrimaryFeatureInfo: FC<{
|
|||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
type: string;
|
type: string;
|
||||||
dependencyType: string;
|
dependencyType: string;
|
||||||
}> = ({ project, feature, type, searchQuery, dependencyType }) => {
|
onTypeClick: (type: string) => void;
|
||||||
|
}> = ({ project, feature, type, searchQuery, dependencyType, onTypeClick }) => {
|
||||||
const { featureTypes } = useFeatureTypes();
|
const { featureTypes } = useFeatureTypes();
|
||||||
const IconComponent = getFeatureTypeIcons(type);
|
const IconComponent = getFeatureTypeIcons(type);
|
||||||
const typeName = featureTypes.find(
|
const typeName = featureTypes.find(
|
||||||
@ -207,7 +208,14 @@ const PrimaryFeatureInfo: FC<{
|
|||||||
|
|
||||||
const TypeIcon = () => (
|
const TypeIcon = () => (
|
||||||
<HtmlTooltip arrow title={title} describeChild>
|
<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>
|
</HtmlTooltip>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -259,7 +267,10 @@ const SecondaryFeatureInfo: FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const FeatureOverviewCell =
|
export const FeatureOverviewCell =
|
||||||
(onClick: (tag: string) => void): FC<IFeatureNameCellProps> =>
|
(
|
||||||
|
onTagClick: (tag: string) => void,
|
||||||
|
onFlagTypeClick: (type: string) => void,
|
||||||
|
): FC<IFeatureNameCellProps> =>
|
||||||
({ row }) => {
|
({ row }) => {
|
||||||
const { searchQuery } = useSearchHighlightContext();
|
const { searchQuery } = useSearchHighlightContext();
|
||||||
|
|
||||||
@ -271,12 +282,13 @@ export const FeatureOverviewCell =
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
type={row.original.type || ''}
|
type={row.original.type || ''}
|
||||||
dependencyType={row.original.dependencyType || ''}
|
dependencyType={row.original.dependencyType || ''}
|
||||||
|
onTypeClick={onFlagTypeClick}
|
||||||
/>
|
/>
|
||||||
<SecondaryFeatureInfo
|
<SecondaryFeatureInfo
|
||||||
description={row.original.description || ''}
|
description={row.original.description || ''}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
/>
|
/>
|
||||||
<Tags tags={row.original.tags} onClick={onClick} />
|
<Tags tags={row.original.tags} onClick={onTagClick} />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -2,15 +2,19 @@ import { render } from 'utils/testRenderer';
|
|||||||
import { Route, Routes } from 'react-router-dom';
|
import { Route, Routes } from 'react-router-dom';
|
||||||
import { ProjectFeatureToggles } from './ProjectFeatureToggles';
|
import { ProjectFeatureToggles } from './ProjectFeatureToggles';
|
||||||
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
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';
|
import { BATCH_SELECTED_COUNT } from 'utils/testIds';
|
||||||
|
|
||||||
const server = testServerSetup();
|
const server = testServerSetup();
|
||||||
|
|
||||||
const setupApi = () => {
|
const setupApi = () => {
|
||||||
const features = [
|
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', {
|
testServerRoute(server, '/api/admin/search/features', {
|
||||||
features,
|
features,
|
||||||
@ -89,3 +93,29 @@ test('filters by tag', async () => {
|
|||||||
await screen.findByText('include');
|
await screen.findByText('include');
|
||||||
expect(screen.getAllByText('backend:sdk')).toHaveLength(2);
|
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');
|
||||||
|
});
|
||||||
|
@ -35,7 +35,10 @@ import { useRowActions } from './hooks/useRowActions';
|
|||||||
import { useSelectedData } from './hooks/useSelectedData';
|
import { useSelectedData } from './hooks/useSelectedData';
|
||||||
import { FeatureOverviewCell } from '../../../common/Table/cells/FeatureOverviewCell/FeatureOverviewCell';
|
import { FeatureOverviewCell } from '../../../common/Table/cells/FeatureOverviewCell/FeatureOverviewCell';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
import { useProjectFeatureSearch } from './useProjectFeatureSearch';
|
import {
|
||||||
|
useProjectFeatureSearch,
|
||||||
|
useProjectFeatureSearchActions,
|
||||||
|
} from './useProjectFeatureSearch';
|
||||||
|
|
||||||
interface IPaginatedProjectFeatureTogglesProps {
|
interface IPaginatedProjectFeatureTogglesProps {
|
||||||
environments: string[];
|
environments: string[];
|
||||||
@ -62,9 +65,15 @@ export const ProjectFeatureToggles = ({
|
|||||||
setTableState,
|
setTableState,
|
||||||
} = useProjectFeatureSearch(projectId);
|
} = useProjectFeatureSearch(projectId);
|
||||||
|
|
||||||
|
const { onFlagTypeClick, onTagClick } = useProjectFeatureSearchActions(
|
||||||
|
tableState,
|
||||||
|
setTableState,
|
||||||
|
);
|
||||||
|
|
||||||
const filterState = {
|
const filterState = {
|
||||||
tag: tableState.tag,
|
tag: tableState.tag,
|
||||||
createdAt: tableState.createdAt,
|
createdAt: tableState.createdAt,
|
||||||
|
type: tableState.type,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { favorite, unfavorite } = useFavoriteFeaturesApi();
|
const { favorite, unfavorite } = useFavoriteFeaturesApi();
|
||||||
@ -93,24 +102,6 @@ export const ProjectFeatureToggles = ({
|
|||||||
|
|
||||||
const featureLifecycleEnabled = useUiFlag('featureLifecycle');
|
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(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
@ -162,7 +153,7 @@ export const ProjectFeatureToggles = ({
|
|||||||
columnHelper.accessor('name', {
|
columnHelper.accessor('name', {
|
||||||
id: 'name',
|
id: 'name',
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
cell: FeatureOverviewCell(onTagClick),
|
cell: FeatureOverviewCell(onTagClick, onFlagTypeClick),
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
meta: {
|
meta: {
|
||||||
width: '50%',
|
width: '50%',
|
||||||
|
@ -45,6 +45,20 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
|
|||||||
filterKey: 'createdAt',
|
filterKey: 'createdAt',
|
||||||
dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'],
|
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);
|
setAvailableFilters(availableFilters);
|
||||||
|
@ -16,6 +16,10 @@ import {
|
|||||||
import { usePersistentTableState } from 'hooks/usePersistentTableState';
|
import { usePersistentTableState } from 'hooks/usePersistentTableState';
|
||||||
import mapValues from 'lodash.mapvalues';
|
import mapValues from 'lodash.mapvalues';
|
||||||
|
|
||||||
|
type Attribute =
|
||||||
|
| { key: 'tag'; operator: 'INCLUDE' }
|
||||||
|
| { key: 'type'; operator: 'IS' };
|
||||||
|
|
||||||
export const useProjectFeatureSearch = (
|
export const useProjectFeatureSearch = (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
storageKey = 'project-overview-v2',
|
storageKey = 'project-overview-v2',
|
||||||
@ -31,6 +35,7 @@ export const useProjectFeatureSearch = (
|
|||||||
columns: ArrayParam,
|
columns: ArrayParam,
|
||||||
tag: FilterItemParam,
|
tag: FilterItemParam,
|
||||||
createdAt: FilterItemParam,
|
createdAt: FilterItemParam,
|
||||||
|
type: FilterItemParam,
|
||||||
};
|
};
|
||||||
const [tableState, setTableState] = usePersistentTableState(
|
const [tableState, setTableState] = usePersistentTableState(
|
||||||
`${storageKey}-${projectId}`,
|
`${storageKey}-${projectId}`,
|
||||||
@ -61,3 +66,42 @@ export const useProjectFeatureSearch = (
|
|||||||
setTableState,
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@ -20,7 +20,7 @@ export type SearchFeaturesParams = {
|
|||||||
/**
|
/**
|
||||||
* The list of feature types to filter by
|
* 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.
|
* The list of feature tags to filter by. Feature tag has to specify a type and a value joined with a colon.
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user