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);