From 4b424355908b45b71ce0b5c76650ca64be3aa997 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:28:59 +0200 Subject: [PATCH] feat(frontend): quick filters on project overview (#10638) --- .../LifecycleFilters}/LifecycleFilters.tsx | 43 ++++--------- .../FeatureToggleListTable.tsx | 10 +-- ...FeaturesOverviewLifecycleFilters.test.tsx} | 16 ++--- .../FeaturesOverviewLifecycleFilters.tsx | 38 +++++++++++ .../FeaturesOverviewToggleFilters.test.tsx} | 6 +- .../FeaturesOverviewToggleFilters.tsx} | 13 ++-- .../ProjectFeatureToggles.test.tsx | 40 ++++++++---- .../ProjectFeatureToggles.tsx | 14 ++-- .../ProjectLifecycleFilters.tsx | 64 +++++++++++++++++++ .../ProjectOverviewFilters.tsx | 21 ++---- 10 files changed, 178 insertions(+), 87 deletions(-) rename frontend/src/component/{feature/FeatureToggleList/FeatureToggleFilters => common/LifecycleFilters}/LifecycleFilters.tsx (72%) rename frontend/src/component/feature/FeatureToggleList/{FeatureToggleFilters/LifecycleFilters.test.tsx => FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.test.tsx} (85%) create mode 100644 frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.tsx rename frontend/src/component/feature/FeatureToggleList/{FeatureToggleFilters/FeatureToggleFilters.test.tsx => FeaturesOverviewLifecycleFilters/FeaturesOverviewToggleFilters.test.tsx} (79%) rename frontend/src/component/feature/FeatureToggleList/{FeatureToggleFilters/FeatureToggleFilters.tsx => FeaturesOverviewLifecycleFilters/FeaturesOverviewToggleFilters.tsx} (95%) create mode 100644 frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectLifecycleFilters.tsx diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.tsx b/frontend/src/component/common/LifecycleFilters/LifecycleFilters.tsx similarity index 72% rename from frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.tsx rename to frontend/src/component/common/LifecycleFilters/LifecycleFilters.tsx index 896ba8fe8f..ec9c911407 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.tsx +++ b/frontend/src/component/common/LifecycleFilters/LifecycleFilters.tsx @@ -1,9 +1,8 @@ import { Box, Chip, styled } from '@mui/material'; -import type { FC, ReactNode } from 'react'; -import type { FilterItemParamHolder } from '../../../filter/Filters/Filters.tsx'; -import type { LifecycleStage } from '../../FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx'; -import { useLifecycleCount } from 'hooks/api/getters/useLifecycleCount/useLifecycleCount'; -import type { FeatureLifecycleCountSchema } from 'openapi'; +import type { SxProps, Theme } from '@mui/material'; +import type { ReactNode } from 'react'; +import type { FilterItemParamHolder } from '../../filter/Filters/Filters.tsx'; +import type { LifecycleStage } from '../../feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx'; const StyledChip = styled(Chip, { shouldForwardProp: (prop) => prop !== 'isActive', @@ -26,17 +25,18 @@ const StyledChip = styled(Chip, { }, })); -interface ILifecycleFiltersProps { +interface ILifecycleFiltersBaseProps { state: FilterItemParamHolder; onChange: (value: FilterItemParamHolder) => void; total?: number; children?: ReactNode; + countData?: Record; + sx?: SxProps; } const Wrapper = styled(Box)(({ theme }) => ({ display: 'flex', justifyContent: 'space-between', - padding: theme.spacing(1.5, 3, 0, 3), minHeight: theme.spacing(7), gap: theme.spacing(2), })); @@ -58,34 +58,13 @@ const lifecycleOptions: { { label: 'Cleanup', value: 'completed' }, ]; -const getStageCount = ( - lifecycle: LifecycleStage['name'] | null, - lifecycleCount?: FeatureLifecycleCountSchema, -) => { - if (!lifecycleCount) { - return undefined; - } - - if (lifecycle === null) { - return ( - (lifecycleCount.initial || 0) + - (lifecycleCount.preLive || 0) + - (lifecycleCount.live || 0) + - (lifecycleCount.completed || 0) - ); - } - - const key = lifecycle === 'pre-live' ? 'preLive' : lifecycle; - return lifecycleCount[key]; -}; - -export const LifecycleFilters: FC = ({ +export const LifecycleFilters = ({ state, onChange, total, children, -}) => { - const { lifecycleCount } = useLifecycleCount(); + countData, +}: ILifecycleFiltersBaseProps) => { const current = state.lifecycle?.values ?? []; return ( @@ -96,7 +75,7 @@ export const LifecycleFilters: FC = ({ value === null ? !state.lifecycle : current.includes(value); - const count = getStageCount(value, lifecycleCount); + const count = value ? countData?.[value] : total; const dynamicLabel = isActive && Number.isInteger(total) ? `${label} (${total === count ? total : `${total} of ${count}`})` diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx index d9b8024004..892f749686 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -20,7 +20,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { focusable } from 'themes/themeStyles'; import { FeatureLifecycleCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell'; import useToast from 'hooks/useToast'; -import { FeatureToggleFilters } from './FeatureToggleFilters/FeatureToggleFilters.tsx'; +import { FeaturesOverviewToggleFilters } from './FeaturesOverviewLifecycleFilters/FeaturesOverviewToggleFilters.tsx'; import { withTableState } from 'utils/withTableState'; import useLoading from 'hooks/useLoading'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; @@ -29,7 +29,7 @@ import { useTableStateFilter, } from './useGlobalFeatureSearch.ts'; import useProjects from 'hooks/api/getters/useProjects/useProjects'; -import { LifecycleFilters } from './FeatureToggleFilters/LifecycleFilters.tsx'; +import { FeaturesOverviewLifecycleFilters } from './FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.tsx'; import { ExportFlags } from './ExportFlags.tsx'; import { createFeatureOverviewCell } from 'component/common/Table/cells/FeatureOverviewCell/FeatureOverviewCell'; import { AvatarCell } from 'component/project/Project/PaginatedProjectFeatureToggles/AvatarCell'; @@ -290,7 +290,7 @@ export const FeatureToggleListTable: FC = () => { /> } > - { id='globalFeatureFlags' /> ) : null} - - + diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.test.tsx b/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.test.tsx similarity index 85% rename from frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.test.tsx rename to frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.test.tsx index 0d15018f61..ee46539720 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.test.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.test.tsx @@ -1,7 +1,7 @@ import { type MockedFunction, vi } from 'vitest'; import { render } from 'utils/testRenderer'; import userEvent from '@testing-library/user-event'; -import { LifecycleFilters } from './LifecycleFilters.tsx'; +import { FeaturesOverviewLifecycleFilters } from './FeaturesOverviewLifecycleFilters.tsx'; import { useLifecycleCount } from 'hooks/api/getters/useLifecycleCount/useLifecycleCount'; vi.mock('hooks/api/getters/useLifecycleCount/useLifecycleCount'); @@ -33,7 +33,7 @@ describe('LifecycleFilters', () => { }); const { getByText } = render( - , + , ); expect(getByText('All flags')).toBeInTheDocument(); @@ -44,10 +44,10 @@ describe('LifecycleFilters', () => { it('renders all stages with correct counts when no total provided', () => { const { getByText } = render( - , + , ); - expect(getByText('All flags (10)')).toBeInTheDocument(); + expect(getByText('All flags')).toBeInTheDocument(); expect(getByText('Develop (2)')).toBeInTheDocument(); expect(getByText('Rollout production (3)')).toBeInTheDocument(); expect(getByText('Cleanup (4)')).toBeInTheDocument(); @@ -56,7 +56,7 @@ describe('LifecycleFilters', () => { it('renders dynamic label when total matches count', () => { const total = 3; const { getByText } = render( - { it('renders dynamic label when total does not match count', () => { const total = 2; const { getByText } = render( - { it('will apply a correct filter for each stage', async () => { const onChange = vi.fn(); const { getByText } = render( - , + , ); await userEvent.click(getByText('Develop (2)')); @@ -98,7 +98,7 @@ describe('LifecycleFilters', () => { lifecycle: { operator: 'IS', values: ['completed'] }, }); - await userEvent.click(getByText('All flags (10)')); + await userEvent.click(getByText('All flags')); expect(onChange).toHaveBeenCalledWith({ lifecycle: null }); }); }); diff --git a/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.tsx b/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.tsx new file mode 100644 index 0000000000..b0cef11275 --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.tsx @@ -0,0 +1,38 @@ +import type { FC, ReactNode } from 'react'; +import { Box } from '@mui/material'; +import type { FilterItemParamHolder } from '../../../filter/Filters/Filters.tsx'; +import { useLifecycleCount } from 'hooks/api/getters/useLifecycleCount/useLifecycleCount'; +import { LifecycleFilters } from 'component/common/LifecycleFilters/LifecycleFilters.tsx'; + +type FeaturesOverviewLifecycleFiltersProps = { + state: FilterItemParamHolder; + onChange: (value: FilterItemParamHolder) => void; + total?: number; + children?: ReactNode; +}; + +export const FeaturesOverviewLifecycleFilters: FC< + FeaturesOverviewLifecycleFiltersProps +> = ({ state, onChange, total, children }) => { + const { lifecycleCount } = useLifecycleCount(); + const countData = Object.entries(lifecycleCount || {}).reduce( + (acc, [key, value]) => { + acc[key === 'preLive' ? 'pre-live' : key] = value; + return acc; + }, + {} as Record, + ); + + return ( + ({ padding: theme.spacing(1.5, 3, 0, 3) })}> + + {children} + + + ); +}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.test.tsx b/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewToggleFilters.test.tsx similarity index 79% rename from frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.test.tsx rename to frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewToggleFilters.test.tsx index a846c11320..6253bb6680 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.test.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewToggleFilters.test.tsx @@ -1,7 +1,7 @@ import { screen } from '@testing-library/react'; import { render } from 'utils/testRenderer'; import { testServerRoute, testServerSetup } from 'utils/testServer'; -import { FeatureToggleFilters } from './FeatureToggleFilters.tsx'; +import { FeaturesOverviewToggleFilters } from './FeaturesOverviewToggleFilters.tsx'; const server = testServerSetup(); @@ -19,7 +19,7 @@ test('should render projects filters when more than one project', async () => { ], }); - render( {}} state={{}} />); + render( {}} state={{}} />); await screen.findByText('Project'); }); @@ -34,7 +34,7 @@ test('should not render projects filters when less than two project', async () = ], }); - render( {}} state={{}} />); + render( {}} state={{}} />); expect(screen.queryByText('Projects')).not.toBeInTheDocument(); }); diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.tsx b/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewToggleFilters.tsx similarity index 95% rename from frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.tsx rename to frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewToggleFilters.tsx index f605182b83..df25d41f97 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeaturesOverviewLifecycleFilters/FeaturesOverviewToggleFilters.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type VFC } from 'react'; +import { type FC, useEffect, useState } from 'react'; import useProjects from 'hooks/api/getters/useProjects/useProjects'; import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; import useAllTags from 'hooks/api/getters/useAllTags/useAllTags'; @@ -9,15 +9,14 @@ import { } from 'component/filter/Filters/Filters'; import { formatTag } from 'utils/format-tag'; -interface IFeatureToggleFiltersProps { +type FeaturesOverviewToggleFiltersProps = { state: FilterItemParamHolder; onChange: (value: FilterItemParamHolder) => void; -} +}; -export const FeatureToggleFilters: VFC = ({ - state, - onChange, -}) => { +export const FeaturesOverviewToggleFilters: FC< + FeaturesOverviewToggleFiltersProps +> = ({ state, onChange }) => { const { projects } = useProjects(); const { segments } = useSegments(); const { tags } = useAllTags(); diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx index bcd455c3e8..a6f05c8ef0 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx @@ -37,6 +37,30 @@ const setupApi = () => { { id: 1, name: 'AuthorA' }, { id: 2, name: 'AuthorB' }, ]); + testServerRoute(server, '/api/admin/projects/default/status', { + activityCountByDate: [], + resources: { + members: 0, + apiTokens: 0, + segments: 0, + }, + health: { + current: 0, + }, + technicalDebt: { + current: 0, + }, + lifecycleSummary: { + initial: { currentFlags: 1, averageDays: null }, + preLive: { currentFlags: 2, averageDays: null }, + live: { currentFlags: 3, averageDays: null }, + completed: { currentFlags: 1, averageDays: null }, + archived: { currentFlags: 0, last30Days: 0 }, + }, + staleFlags: { + total: 0, + }, + }); }; test('filters by flag type', async () => { @@ -208,7 +232,7 @@ test('Project is not onboarded', async () => { await screen.findByText('Welcome to your project'); }); -test('renders lifecycle filters', async () => { +test('renders lifecycle quick filters', async () => { setupApi(); render( @@ -227,14 +251,8 @@ test('renders lifecycle filters', async () => { }, ); - const addFilter = await screen.findByText('Filter'); - fireEvent.click(addFilter); - - const lifecycleFilter = await screen.findByText('Lifecycle stage'); - fireEvent.click(lifecycleFilter); - - await screen.findByText('Define'); - await screen.findByText('Develop'); - await screen.findByText('Rollout production'); - await screen.findByText('Cleanup'); + await screen.findByText(/All flags/); + await screen.findByText(/Develop/); + await screen.findByText(/Rollout production/); + await screen.findByText(/Cleanup/); }); diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx index d069728d58..fb69b74d38 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -31,6 +31,7 @@ import { PlaceholderFeatureToggleCell, } from './FeatureToggleCell/FeatureToggleCell.tsx'; import { ProjectOverviewFilters } from './ProjectOverviewFilters.tsx'; +import { ProjectLifecycleFilters } from './ProjectLifecycleFilters.tsx'; import { useDefaultColumnVisibility } from './hooks/useDefaultColumnVisibility.ts'; import { TableEmptyState } from './TableEmptyState/TableEmptyState.tsx'; import { useRowActions } from './hooks/useRowActions.tsx'; @@ -56,9 +57,9 @@ import { ImportModal } from '../Import/ImportModal.tsx'; import { IMPORT_BUTTON } from 'utils/testIds'; import { ProjectCleanupReminder } from './ProjectCleanupReminder/ProjectCleanupReminder.tsx'; -interface IPaginatedProjectFeatureTogglesProps { +type ProjectFeatureTogglesProps = { environments: string[]; -} +}; const formatEnvironmentColumnId = (environment: string) => `environment:${environment}`; @@ -75,7 +76,6 @@ const Container = styled('div')(({ theme }) => ({ const FilterRow = styled('div')(({ theme }) => ({ display: 'flex', flexFlow: 'row wrap', - gap: theme.spacing(2), justifyContent: 'space-between', })); @@ -87,7 +87,7 @@ const ButtonGroup = styled('div')(({ theme }) => ({ export const ProjectFeatureToggles = ({ environments, -}: IPaginatedProjectFeatureTogglesProps) => { +}: ProjectFeatureTogglesProps) => { const { trackEvent } = usePlausibleTracker(); const projectId = useRequiredPathParam('projectId'); const { project } = useProjectOverview(projectId); @@ -577,6 +577,12 @@ export const ProjectFeatureToggles = ({ onChange={setTableState} state={filterState} /> + void; + total?: number; + children?: ReactNode; +}; + +export const ProjectLifecycleFilters: FC = ({ + projectId, + state, + onChange, + total, + children, +}) => { + const { data } = useProjectStatus(projectId); + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + const lifecycleSummary = Object.entries( + data?.lifecycleSummary || {}, + ).reduce( + (acc, [key, value]) => { + acc[key === 'preLive' ? 'pre-live' : key] = value.currentFlags || 0; + return acc; + }, + {} as Record, + ); + + const isArchivedFilterActive = state.archived?.values?.includes('true'); + useEffect(() => { + if (isArchivedFilterActive && state.lifecycle) { + onChange({ ...state, lifecycle: null }); + } + }, [isArchivedFilterActive, state, onChange]); + + if (isArchivedFilterActive) { + return null; + } + + return ( + + + {children} + + + ); +}; diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx index 1e2dd4b40f..0a616e22c4 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type VFC } from 'react'; +import { useEffect, useState, type FC } from 'react'; import useAllTags from 'hooks/api/getters/useAllTags/useAllTags'; import { type FilterItemParamHolder, @@ -8,13 +8,13 @@ import { import { useProjectFlagCreators } from 'hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators'; import { formatTag } from 'utils/format-tag'; -interface IProjectOverviewFilters { +type ProjectOverviewFiltersProps = { state: FilterItemParamHolder; onChange: (value: FilterItemParamHolder) => void; project: string; -} +}; -export const ProjectOverviewFilters: VFC = ({ +export const ProjectOverviewFilters: FC = ({ state, onChange, project, @@ -118,19 +118,6 @@ export const ProjectOverviewFilters: VFC = ({ singularOperators: ['IS'], pluralOperators: ['IS_ANY_OF'], }, - { - label: 'Lifecycle stage', - icon: 'model_training', - options: [ - { label: 'Define', value: 'initial' }, - { label: 'Develop', value: 'pre-live' }, - { label: 'Rollout production', value: 'live' }, - { label: 'Cleanup', value: 'completed' }, - ], - filterKey: 'lifecycle', - singularOperators: ['IS', 'IS_NOT'], - pluralOperators: ['IS_ANY_OF', 'IS_NONE_OF'], - }, ]; setAvailableFilters(availableFilters);