From 3ac087e0f6acca1beace2062cbc2f53d9afa9864 Mon Sep 17 00:00:00 2001
From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com>
Date: Fri, 25 Apr 2025 12:52:11 +0200
Subject: [PATCH] feat: count per lifecycle stage (#9845)
Show count per stage, and include count if flags are filtered.
---
.../LifecycleFilters.test.tsx | 104 ++++++++++++++++++
.../FeatureToggleFilters/LifecycleFilters.tsx | 29 ++++-
.../useLifecycleCount/useLifecycleCount.ts | 23 ++++
3 files changed, 154 insertions(+), 2 deletions(-)
create mode 100644 frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.test.tsx
create mode 100644 frontend/src/hooks/api/getters/useLifecycleCount/useLifecycleCount.ts
diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.test.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.test.tsx
new file mode 100644
index 0000000000..d7f0bf9994
--- /dev/null
+++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.test.tsx
@@ -0,0 +1,104 @@
+import { type MockedFunction, vi } from 'vitest';
+import { render } from 'utils/testRenderer';
+import userEvent from '@testing-library/user-event';
+import { LifecycleFilters } from './LifecycleFilters';
+import { useLifecycleCount } from 'hooks/api/getters/useLifecycleCount/useLifecycleCount';
+
+vi.mock('hooks/api/getters/useLifecycleCount/useLifecycleCount');
+
+const mockUseLifecycleCount = useLifecycleCount as MockedFunction<
+ typeof useLifecycleCount
+>;
+
+describe('LifecycleFilters', () => {
+ beforeEach(() => {
+ mockUseLifecycleCount.mockReturnValue({
+ lifecycleCount: {
+ initial: 1,
+ preLive: 2,
+ live: 3,
+ completed: 4,
+ archived: 0,
+ },
+ error: undefined,
+ loading: false,
+ });
+ });
+
+ it('renders labels without count if lifecycle count is not available', async () => {
+ mockUseLifecycleCount.mockReturnValue({
+ lifecycleCount: undefined,
+ error: undefined,
+ loading: true,
+ });
+
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText('All flags')).toBeInTheDocument();
+ expect(getByText('Develop')).toBeInTheDocument();
+ expect(getByText('Rollout production')).toBeInTheDocument();
+ expect(getByText('Cleanup')).toBeInTheDocument();
+ });
+
+ it('renders all stages with correct counts when no total provided', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText('All flags (10)')).toBeInTheDocument();
+ expect(getByText('Develop (2)')).toBeInTheDocument();
+ expect(getByText('Rollout production (3)')).toBeInTheDocument();
+ expect(getByText('Cleanup (4)')).toBeInTheDocument();
+ });
+
+ it('renders dynamic label when total matches count', () => {
+ const total = 3;
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('Rollout production (3)')).toBeInTheDocument();
+ });
+
+ it('renders dynamic label when total does not match count', () => {
+ const total = 2;
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('Rollout production (2 of 3)')).toBeInTheDocument();
+ });
+
+ it('will apply a correct filter for each stage', async () => {
+ const onChange = vi.fn();
+ const { getByText } = render(
+ ,
+ );
+
+ await userEvent.click(getByText('Develop (2)'));
+ expect(onChange).toHaveBeenCalledWith({
+ lifecycle: { operator: 'IS', values: ['pre-live'] },
+ });
+
+ await userEvent.click(getByText('Rollout production (3)'));
+ expect(onChange).toHaveBeenCalledWith({
+ lifecycle: { operator: 'IS', values: ['live'] },
+ });
+
+ await userEvent.click(getByText('Cleanup (4)'));
+ expect(onChange).toHaveBeenCalledWith({
+ lifecycle: { operator: 'IS', values: ['completed'] },
+ });
+
+ await userEvent.click(getByText('All flags (10)'));
+ expect(onChange).toHaveBeenCalledWith({ lifecycle: null });
+ });
+});
diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.tsx
index ddc5517009..59fea2416e 100644
--- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.tsx
+++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.tsx
@@ -2,6 +2,8 @@ import { Box, Chip, styled } from '@mui/material';
import type { FC } from 'react';
import type { FilterItemParamHolder } from '../../../filter/Filters/Filters';
import type { LifecycleStage } from '../../FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage';
+import { useLifecycleCount } from 'hooks/api/getters/useLifecycleCount/useLifecycleCount';
+import type { FeatureLifecycleCountSchema } from 'openapi';
const StyledChip = styled(Chip, {
shouldForwardProp: (prop) => prop !== 'isActive',
@@ -47,11 +49,33 @@ 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 = ({
state,
onChange,
total,
}) => {
+ const { lifecycleCount } = useLifecycleCount();
const current = state.lifecycle?.values ?? [];
return (
@@ -59,10 +83,11 @@ export const LifecycleFilters: FC = ({
{lifecycleOptions.map(({ label, value }) => {
const isActive =
value === null ? !state.lifecycle : current.includes(value);
+ const count = getStageCount(value, lifecycleCount);
const dynamicLabel =
isActive && Number.isInteger(total)
- ? `${label} (${total})`
- : label;
+ ? `${label} (${total === count ? total : `${total} of ${count}`})`
+ : `${label}${count !== undefined ? ` (${count})` : ''}`;
const handleClick = () =>
onChange(
diff --git a/frontend/src/hooks/api/getters/useLifecycleCount/useLifecycleCount.ts b/frontend/src/hooks/api/getters/useLifecycleCount/useLifecycleCount.ts
new file mode 100644
index 0000000000..7cc47224f5
--- /dev/null
+++ b/frontend/src/hooks/api/getters/useLifecycleCount/useLifecycleCount.ts
@@ -0,0 +1,23 @@
+import { formatApiPath } from 'utils/formatPath';
+import useSWR from 'swr';
+import type { FeatureLifecycleCountSchema } from 'openapi';
+import handleErrorResponses from '../httpErrorResponseHandler';
+
+export const useLifecycleCount = () => {
+ const { data, error } = useSWR(
+ formatApiPath('api/admin/lifecycle/count'),
+ fetcher,
+ );
+
+ return {
+ lifecycleCount: data,
+ error,
+ loading: !error && !data,
+ };
+};
+
+const fetcher = (path: string) => {
+ return fetch(path)
+ .then(handleErrorResponses('Lifecycle count'))
+ .then((res) => res.json());
+};