From 37ae29ca5aa9adbb8f4ab7229f9433824457707a Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Thu, 11 Sep 2025 09:34:23 +0200 Subject: [PATCH] feat: implement lifecycle filters for feature toggle list and project lifecycle --- .../LifecycleFilters}/LifecycleFilters.tsx | 51 +++----- .../FeatureLifecycleFilters.tsx | 59 ++++++++++ .../LifecycleFilters.test.tsx | 12 +- .../FeatureToggleListTable.tsx | 6 +- .../ProjectLifecycleFilters.tsx | 110 +++--------------- 5 files changed, 103 insertions(+), 135 deletions(-) rename frontend/src/component/{feature/FeatureToggleList/FeatureToggleFilters => common/LifecycleFilters}/LifecycleFilters.tsx (71%) create mode 100644 frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureLifecycleFilters.tsx diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.tsx b/frontend/src/component/common/LifecycleFilters/LifecycleFilters.tsx similarity index 71% rename from frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.tsx rename to frontend/src/component/common/LifecycleFilters/LifecycleFilters.tsx index 896ba8fe8f..6f242b212a 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,22 @@ const StyledChip = styled(Chip, { }, })); -interface ILifecycleFiltersProps { +interface ILifecycleFiltersBaseProps { state: FilterItemParamHolder; onChange: (value: FilterItemParamHolder) => void; total?: number; children?: ReactNode; + countData?: T; + getStageCount: ( + lifecycle: LifecycleStage['name'] | null, + data?: T, + ) => number | undefined; + 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,45 +62,26 @@ 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, + getStageCount, + sx, +}: ILifecycleFiltersBaseProps) => { const current = state.lifecycle?.values ?? []; return ( - + {lifecycleOptions.map(({ label, value }) => { const isActive = value === null ? !state.lifecycle : current.includes(value); - const count = getStageCount(value, lifecycleCount); + const count = getStageCount(value, countData); const dynamicLabel = isActive && Number.isInteger(total) ? `${label} (${total === count ? total : `${total} of ${count}`})` diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureLifecycleFilters.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureLifecycleFilters.tsx new file mode 100644 index 0000000000..e65d15f30e --- /dev/null +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureLifecycleFilters.tsx @@ -0,0 +1,59 @@ +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 { LifecycleFilters } from '../../../common/LifecycleFilters/LifecycleFilters.tsx'; + +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]; +}; + +interface ILifecycleFiltersProps { + state: FilterItemParamHolder; + onChange: (value: FilterItemParamHolder) => void; + total?: number; + children?: ReactNode; +} + +export const FeatureLifecycleFilters: FC = ({ + state, + onChange, + total, + children, +}) => { + const { lifecycleCount } = useLifecycleCount(); + + return ( + + `${theme.spacing(1.5)} ${theme.spacing(3)} 0 ${theme.spacing(3)}`, + }} + > + {children} + + ); +}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.test.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.test.tsx index 0d15018f61..2dded0acfa 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.test.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/LifecycleFilters.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 { FeatureLifecycleFilters } from './FeatureLifecycleFilters.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,7 +44,7 @@ describe('LifecycleFilters', () => { it('renders all stages with correct counts when no total provided', () => { const { getByText } = render( - , + , ); expect(getByText('All flags (10)')).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)')); diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx index d9b8024004..2efd67ccad 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -29,7 +29,7 @@ import { useTableStateFilter, } from './useGlobalFeatureSearch.ts'; import useProjects from 'hooks/api/getters/useProjects/useProjects'; -import { LifecycleFilters } from './FeatureToggleFilters/LifecycleFilters.tsx'; +import { FeatureLifecycleFilters } from './FeatureToggleFilters/FeatureLifecycleFilters.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} - + 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' }, -]; +import { LifecycleFilters } from '../../../common/LifecycleFilters/LifecycleFilters.tsx'; const getStageCount = ( lifecycle: LifecycleStage['name'] | null, @@ -78,6 +25,14 @@ const getStageCount = ( return lifecycleSummary[key]?.currentFlags; }; +interface IProjectLifecycleFiltersProps { + projectId: string; + state: FilterItemParamHolder; + onChange: (value: FilterItemParamHolder) => void; + total?: number; + children?: ReactNode; +} + export const ProjectLifecycleFilters: FC = ({ projectId, state, @@ -87,47 +42,16 @@ export const ProjectLifecycleFilters: FC = ({ }) => { 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} - + ); };