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()); +};