From fef77c1fde33f8cd609977ad1d7a7f0d5b65bd92 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Wed, 5 Jun 2024 08:17:54 +0200 Subject: [PATCH] feat: filter by feature type (#7273) --- .../FeatureOverviewCell.test.tsx | 6 ++- .../FeatureOverviewCell.tsx | 20 +++++++-- .../ProjectFeatureToggles.test.tsx | 36 +++++++++++++-- .../ProjectFeatureToggles.tsx | 31 +++++-------- .../ProjectOverviewFilters.tsx | 14 ++++++ .../useProjectFeatureSearch.ts | 44 +++++++++++++++++++ .../openapi/models/searchFeaturesParams.ts | 2 +- 7 files changed, 123 insertions(+), 30 deletions(-) diff --git a/frontend/src/component/common/Table/cells/FeatureOverviewCell/FeatureOverviewCell.test.tsx b/frontend/src/component/common/Table/cells/FeatureOverviewCell/FeatureOverviewCell.test.tsx index cd218865bf..4746b3084a 100644 --- a/frontend/src/component/common/Table/cells/FeatureOverviewCell/FeatureOverviewCell.test.tsx +++ b/frontend/src/component/common/Table/cells/FeatureOverviewCell/FeatureOverviewCell.test.tsx @@ -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( { }); test('Display minimal overview information', () => { - const FeatureOverviewCell = makeFeatureOverviewCell(() => {}); + const FeatureOverviewCell = makeFeatureOverviewCell(noOp, noOp); render( = ({ 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 = () => ( - ({ fontSize: theme.spacing(2) })} /> + ({ + cursor: 'pointer', + fontSize: theme.spacing(2), + })} + onClick={() => onTypeClick(type)} + /> ); @@ -259,7 +267,10 @@ const SecondaryFeatureInfo: FC<{ }; export const FeatureOverviewCell = - (onClick: (tag: string) => void): FC => + ( + onTagClick: (tag: string) => void, + onFlagTypeClick: (type: string) => void, + ): FC => ({ row }) => { const { searchQuery } = useSearchHighlightContext(); @@ -271,12 +282,13 @@ export const FeatureOverviewCell = searchQuery={searchQuery} type={row.original.type || ''} dependencyType={row.original.dependencyType || ''} + onTypeClick={onFlagTypeClick} /> - + ); }; diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx index ce5e71637f..dbad8a44a0 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx @@ -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( + + + } + /> + , + { + 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'); +}); diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx index 2ed8ccedb3..81ddb798e7 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -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%', diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx index 4b741f1fad..bb411aec57 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx @@ -45,6 +45,20 @@ export const ProjectOverviewFilters: VFC = ({ 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); diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/useProjectFeatureSearch.ts b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/useProjectFeatureSearch.ts index 287c719e99..c9ea2b4e40 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/useProjectFeatureSearch.ts +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/useProjectFeatureSearch.ts @@ -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['tableState'], + setTableState: ReturnType['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, + }; +}; diff --git a/frontend/src/openapi/models/searchFeaturesParams.ts b/frontend/src/openapi/models/searchFeaturesParams.ts index 56e51572db..39a71fab4f 100644 --- a/frontend/src/openapi/models/searchFeaturesParams.ts +++ b/frontend/src/openapi/models/searchFeaturesParams.ts @@ -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. */