From 86f7806dd067b4c52c9d2e798c040d4d6b3163bd Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Tue, 9 Sep 2025 14:43:53 +0200 Subject: [PATCH] feat(frontend): quick filters on project overview --- .../ProjectFeatureToggles.test.tsx | 40 ++++-- .../ProjectFeatureToggles.tsx | 11 +- .../ProjectLifecycleFilters.tsx | 133 ++++++++++++++++++ .../ProjectOverviewFilters.tsx | 13 -- 4 files changed, 172 insertions(+), 25 deletions(-) create mode 100644 frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectLifecycleFilters.tsx 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..78b33ad0ec 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'; @@ -41,7 +42,7 @@ import { useProjectFeatureSearchActions, } from './useProjectFeatureSearch.ts'; import { AvatarCell } from './AvatarCell.tsx'; -import { styled } from '@mui/material'; +import { Box, styled } from '@mui/material'; import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview'; import { ConnectSdkDialog } from '../../../onboarding/dialog/ConnectSdkDialog.tsx'; import { ProjectOnboarding } from '../../../onboarding/flow/ProjectOnboarding.tsx'; @@ -577,6 +578,14 @@ export const ProjectFeatureToggles = ({ onChange={setTableState} state={filterState} /> + + + prop !== 'isActive', +})<{ + isActive?: boolean; +}>(({ theme, isActive = false }) => ({ + borderRadius: `${theme.shape.borderRadius}px`, + padding: theme.spacing(0.5), + fontSize: theme.typography.body2.fontSize, + height: 'auto', + ...(isActive && { + backgroundColor: theme.palette.secondary.light, + fontWeight: 'bold', + borderColor: theme.palette.primary.main, + color: theme.palette.primary.main, + }), + ':focus-visible': { + outline: `1px solid ${theme.palette.primary.main}`, + borderColor: theme.palette.primary.main, + }, +})); + +interface IProjectLifecycleFiltersProps { + projectId: string; + state: FilterItemParamHolder; + onChange: (value: FilterItemParamHolder) => void; + total?: number; + children?: ReactNode; +} + +const Wrapper = styled(Box)(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + minHeight: theme.spacing(7), + gap: theme.spacing(2), +})); + +const StyledContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: theme.spacing(1), +})); + +const lifecycleOptions: { + label: string; + value: LifecycleStage['name'] | null; +}[] = [ + { label: 'All flags', value: null }, + { label: 'Develop', value: 'pre-live' }, + { label: 'Rollout production', value: 'live' }, + { label: 'Cleanup', value: 'completed' }, +]; + +const getStageCount = ( + lifecycle: LifecycleStage['name'] | null, + lifecycleSummary?: { [key: string]: { currentFlags: number } }, +) => { + if (!lifecycleSummary) { + return undefined; + } + + if (lifecycle === null) { + return ( + (lifecycleSummary.initial?.currentFlags || 0) + + (lifecycleSummary.preLive?.currentFlags || 0) + + (lifecycleSummary.live?.currentFlags || 0) + + (lifecycleSummary.completed?.currentFlags || 0) + ); + } + + const key = lifecycle === 'pre-live' ? 'preLive' : lifecycle; + return lifecycleSummary[key]?.currentFlags; +}; + +export const ProjectLifecycleFilters: FC = ({ + projectId, + state, + onChange, + total, + children, +}) => { + const { data } = useProjectStatus(projectId); + const lifecycleSummary = data?.lifecycleSummary; + const current = state.lifecycle?.values ?? []; + + return ( + + + {lifecycleOptions.map(({ label, value }) => { + const isActive = + value === null + ? !state.lifecycle + : current.includes(value); + const count = getStageCount(value, lifecycleSummary); + const dynamicLabel = + isActive && Number.isInteger(total) + ? `${label} (${total === count ? total : `${total} of ${count}`})` + : `${label}${count !== undefined ? ` (${count})` : ''}`; + + const handleClick = () => + onChange( + value === null + ? { lifecycle: null } + : { + lifecycle: { + operator: 'IS', + values: [value], + }, + }, + ); + + return ( + + ); + })} + + {children} + + ); +}; diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx index 1e2dd4b40f..a1c853d36a 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx @@ -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);