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 { Box, Chip, styled } from '@mui/material';
import type { FC, ReactNode } from 'react'; import type { SxProps, Theme } from '@mui/material';
import type { FilterItemParamHolder } from '../../../filter/Filters/Filters.tsx'; import type { ReactNode } from 'react';
import type { LifecycleStage } from '../../FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx'; import type { FilterItemParamHolder } from '../../filter/Filters/Filters.tsx';
import { useLifecycleCount } from 'hooks/api/getters/useLifecycleCount/useLifecycleCount'; import type { LifecycleStage } from '../../feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx';
import type { FeatureLifecycleCountSchema } from 'openapi';
const StyledChip = styled(Chip, { const StyledChip = styled(Chip, {
shouldForwardProp: (prop) => prop !== 'isActive', shouldForwardProp: (prop) => prop !== 'isActive',
@ -26,17 +25,22 @@ const StyledChip = styled(Chip, {
}, },
})); }));
interface ILifecycleFiltersProps { interface ILifecycleFiltersBaseProps<T> {
state: FilterItemParamHolder; state: FilterItemParamHolder;
onChange: (value: FilterItemParamHolder) => void; onChange: (value: FilterItemParamHolder) => void;
total?: number; total?: number;
children?: ReactNode; children?: ReactNode;
countData?: T;
getStageCount: (
lifecycle: LifecycleStage['name'] | null,
data?: T,
) => number | undefined;
sx?: SxProps<Theme>;
} }
const Wrapper = styled(Box)(({ theme }) => ({ const Wrapper = styled(Box)(({ theme }) => ({
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: theme.spacing(1.5, 3, 0, 3),
minHeight: theme.spacing(7), minHeight: theme.spacing(7),
gap: theme.spacing(2), gap: theme.spacing(2),
})); }));
@ -58,45 +62,26 @@ const lifecycleOptions: {
{ label: 'Cleanup', value: 'completed' }, { label: 'Cleanup', value: 'completed' },
]; ];
const getStageCount = ( export const LifecycleFilters = <T,>({
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> = ({
state, state,
onChange, onChange,
total, total,
children, children,
}) => { countData,
const { lifecycleCount } = useLifecycleCount(); getStageCount,
sx,
}: ILifecycleFiltersBaseProps<T>) => {
const current = state.lifecycle?.values ?? []; const current = state.lifecycle?.values ?? [];
return ( return (
<Wrapper> <Wrapper sx={sx}>
<StyledContainer> <StyledContainer>
{lifecycleOptions.map(({ label, value }) => { {lifecycleOptions.map(({ label, value }) => {
const isActive = const isActive =
value === null value === null
? !state.lifecycle ? !state.lifecycle
: current.includes(value); : current.includes(value);
const count = getStageCount(value, lifecycleCount); const count = getStageCount(value, countData);
const dynamicLabel = const dynamicLabel =
isActive && Number.isInteger(total) isActive && Number.isInteger(total)
? `${label} (${total === count ? total : `${total} of ${count}`})` ? `${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 { type MockedFunction, vi } from 'vitest';
import { render } from 'utils/testRenderer'; import { render } from 'utils/testRenderer';
import userEvent from '@testing-library/user-event'; 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'; import { useLifecycleCount } from 'hooks/api/getters/useLifecycleCount/useLifecycleCount';
vi.mock('hooks/api/getters/useLifecycleCount/useLifecycleCount'); vi.mock('hooks/api/getters/useLifecycleCount/useLifecycleCount');
@ -33,7 +33,7 @@ describe('LifecycleFilters', () => {
}); });
const { getByText } = render( const { getByText } = render(
<LifecycleFilters state={{}} onChange={vi.fn()} />, <FeatureLifecycleFilters state={{}} onChange={vi.fn()} />,
); );
expect(getByText('All flags')).toBeInTheDocument(); expect(getByText('All flags')).toBeInTheDocument();
@ -44,7 +44,7 @@ describe('LifecycleFilters', () => {
it('renders all stages with correct counts when no total provided', () => { it('renders all stages with correct counts when no total provided', () => {
const { getByText } = render( const { getByText } = render(
<LifecycleFilters state={{}} onChange={vi.fn()} />, <FeatureLifecycleFilters state={{}} onChange={vi.fn()} />,
); );
expect(getByText('All flags (10)')).toBeInTheDocument(); expect(getByText('All flags (10)')).toBeInTheDocument();
@ -56,7 +56,7 @@ describe('LifecycleFilters', () => {
it('renders dynamic label when total matches count', () => { it('renders dynamic label when total matches count', () => {
const total = 3; const total = 3;
const { getByText } = render( const { getByText } = render(
<LifecycleFilters <FeatureLifecycleFilters
state={{ lifecycle: { operator: 'IS', values: ['live'] } }} state={{ lifecycle: { operator: 'IS', values: ['live'] } }}
onChange={vi.fn()} onChange={vi.fn()}
total={total} total={total}
@ -68,7 +68,7 @@ describe('LifecycleFilters', () => {
it('renders dynamic label when total does not match count', () => { it('renders dynamic label when total does not match count', () => {
const total = 2; const total = 2;
const { getByText } = render( const { getByText } = render(
<LifecycleFilters <FeatureLifecycleFilters
state={{ lifecycle: { operator: 'IS', values: ['live'] } }} state={{ lifecycle: { operator: 'IS', values: ['live'] } }}
onChange={vi.fn()} onChange={vi.fn()}
total={total} total={total}
@ -80,7 +80,7 @@ describe('LifecycleFilters', () => {
it('will apply a correct filter for each stage', async () => { it('will apply a correct filter for each stage', async () => {
const onChange = vi.fn(); const onChange = vi.fn();
const { getByText } = render( const { getByText } = render(
<LifecycleFilters state={{}} onChange={onChange} />, <FeatureLifecycleFilters state={{}} onChange={onChange} />,
); );
await userEvent.click(getByText('Develop (2)')); await userEvent.click(getByText('Develop (2)'));

View File

@ -29,7 +29,7 @@ import {
useTableStateFilter, useTableStateFilter,
} from './useGlobalFeatureSearch.ts'; } from './useGlobalFeatureSearch.ts';
import useProjects from 'hooks/api/getters/useProjects/useProjects'; 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 { ExportFlags } from './ExportFlags.tsx';
import { createFeatureOverviewCell } from 'component/common/Table/cells/FeatureOverviewCell/FeatureOverviewCell'; import { createFeatureOverviewCell } from 'component/common/Table/cells/FeatureOverviewCell/FeatureOverviewCell';
import { AvatarCell } from 'component/project/Project/PaginatedProjectFeatureToggles/AvatarCell'; import { AvatarCell } from 'component/project/Project/PaginatedProjectFeatureToggles/AvatarCell';
@ -290,7 +290,7 @@ export const FeatureToggleListTable: FC = () => {
/> />
} }
> >
<LifecycleFilters <FeatureLifecycleFilters
state={filterState} state={filterState}
onChange={setTableState} onChange={setTableState}
total={loading ? undefined : total} total={loading ? undefined : total}
@ -303,7 +303,7 @@ export const FeatureToggleListTable: FC = () => {
id='globalFeatureFlags' id='globalFeatureFlags'
/> />
) : null} ) : null}
</LifecycleFilters> </FeatureLifecycleFilters>
<FeatureToggleFilters <FeatureToggleFilters
onChange={setTableState} onChange={setTableState}
state={filterState} state={filterState}

View File

@ -1,61 +1,8 @@
import { Box, Chip, styled } from '@mui/material';
import type { FC, ReactNode } from 'react'; import type { FC, ReactNode } from 'react';
import type { FilterItemParamHolder } from '../../../filter/Filters/Filters.tsx'; import type { FilterItemParamHolder } from '../../../filter/Filters/Filters.tsx';
import type { LifecycleStage } from '../../../feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx'; import type { LifecycleStage } from '../../../feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx';
import { useProjectStatus } from 'hooks/api/getters/useProjectStatus/useProjectStatus'; import { useProjectStatus } from 'hooks/api/getters/useProjectStatus/useProjectStatus';
import { LifecycleFilters } from '../../../common/LifecycleFilters/LifecycleFilters.tsx';
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' },
];
const getStageCount = ( const getStageCount = (
lifecycle: LifecycleStage['name'] | null, lifecycle: LifecycleStage['name'] | null,
@ -78,6 +25,14 @@ const getStageCount = (
return lifecycleSummary[key]?.currentFlags; return lifecycleSummary[key]?.currentFlags;
}; };
interface IProjectLifecycleFiltersProps {
projectId: string;
state: FilterItemParamHolder;
onChange: (value: FilterItemParamHolder) => void;
total?: number;
children?: ReactNode;
}
export const ProjectLifecycleFilters: FC<IProjectLifecycleFiltersProps> = ({ export const ProjectLifecycleFilters: FC<IProjectLifecycleFiltersProps> = ({
projectId, projectId,
state, state,
@ -87,47 +42,16 @@ export const ProjectLifecycleFilters: FC<IProjectLifecycleFiltersProps> = ({
}) => { }) => {
const { data } = useProjectStatus(projectId); const { data } = useProjectStatus(projectId);
const lifecycleSummary = data?.lifecycleSummary; const lifecycleSummary = data?.lifecycleSummary;
const current = state.lifecycle?.values ?? [];
return ( return (
<Wrapper> <LifecycleFilters
<StyledContainer> state={state}
{lifecycleOptions.map(({ label, value }) => { onChange={onChange}
const isActive = total={total}
value === null countData={lifecycleSummary}
? !state.lifecycle getStageCount={getStageCount}
: 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>
{children} {children}
</Wrapper> </LifecycleFilters>
); );
}; };