1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-24 17:51:14 +02:00

feat: implement lifecycle filters for feature toggle list and project lifecycle

This commit is contained in:
Tymoteusz Czech 2025-09-11 09:34:23 +02:00
parent 86f7806dd0
commit 37ae29ca5a
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
5 changed files with 103 additions and 135 deletions

View File

@ -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<T> {
state: FilterItemParamHolder;
onChange: (value: FilterItemParamHolder) => void;
total?: number;
children?: ReactNode;
countData?: T;
getStageCount: (
lifecycle: LifecycleStage['name'] | null,
data?: T,
) => number | undefined;
sx?: SxProps<Theme>;
}
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<ILifecycleFiltersProps> = ({
export const LifecycleFilters = <T,>({
state,
onChange,
total,
children,
}) => {
const { lifecycleCount } = useLifecycleCount();
countData,
getStageCount,
sx,
}: ILifecycleFiltersBaseProps<T>) => {
const current = state.lifecycle?.values ?? [];
return (
<Wrapper>
<Wrapper sx={sx}>
<StyledContainer>
{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}`})`

View File

@ -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<ILifecycleFiltersProps> = ({
state,
onChange,
total,
children,
}) => {
const { lifecycleCount } = useLifecycleCount();
return (
<LifecycleFilters
state={state}
onChange={onChange}
total={total}
countData={lifecycleCount}
getStageCount={getStageCount}
sx={{
padding: (theme) =>
`${theme.spacing(1.5)} ${theme.spacing(3)} 0 ${theme.spacing(3)}`,
}}
>
{children}
</LifecycleFilters>
);
};

View File

@ -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(
<LifecycleFilters state={{}} onChange={vi.fn()} />,
<FeatureLifecycleFilters state={{}} onChange={vi.fn()} />,
);
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(
<LifecycleFilters state={{}} onChange={vi.fn()} />,
<FeatureLifecycleFilters state={{}} onChange={vi.fn()} />,
);
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(
<LifecycleFilters
<FeatureLifecycleFilters
state={{ lifecycle: { operator: 'IS', values: ['live'] } }}
onChange={vi.fn()}
total={total}
@ -68,7 +68,7 @@ describe('LifecycleFilters', () => {
it('renders dynamic label when total does not match count', () => {
const total = 2;
const { getByText } = render(
<LifecycleFilters
<FeatureLifecycleFilters
state={{ lifecycle: { operator: 'IS', values: ['live'] } }}
onChange={vi.fn()}
total={total}
@ -80,7 +80,7 @@ describe('LifecycleFilters', () => {
it('will apply a correct filter for each stage', async () => {
const onChange = vi.fn();
const { getByText } = render(
<LifecycleFilters state={{}} onChange={onChange} />,
<FeatureLifecycleFilters state={{}} onChange={onChange} />,
);
await userEvent.click(getByText('Develop (2)'));

View File

@ -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 = () => {
/>
}
>
<LifecycleFilters
<FeatureLifecycleFilters
state={filterState}
onChange={setTableState}
total={loading ? undefined : total}
@ -303,7 +303,7 @@ export const FeatureToggleListTable: FC = () => {
id='globalFeatureFlags'
/>
) : null}
</LifecycleFilters>
</FeatureLifecycleFilters>
<FeatureToggleFilters
onChange={setTableState}
state={filterState}

View File

@ -1,61 +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 '../../../feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx';
import { useProjectStatus } from 'hooks/api/getters/useProjectStatus/useProjectStatus';
const StyledChip = styled(Chip, {
shouldForwardProp: (prop) => 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<IProjectLifecycleFiltersProps> = ({
projectId,
state,
@ -87,47 +42,16 @@ export const ProjectLifecycleFilters: FC<IProjectLifecycleFiltersProps> = ({
}) => {
const { data } = useProjectStatus(projectId);
const lifecycleSummary = data?.lifecycleSummary;
const current = state.lifecycle?.values ?? [];
return (
<Wrapper>
<StyledContainer>
{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 (
<StyledChip
data-loading
key={label}
label={dynamicLabel}
variant='outlined'
isActive={isActive}
onClick={handleClick}
/>
);
})}
</StyledContainer>
<LifecycleFilters
state={state}
onChange={onChange}
total={total}
countData={lifecycleSummary}
getStageCount={getStageCount}
>
{children}
</Wrapper>
</LifecycleFilters>
);
};