1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: count per lifecycle stage (#9845)

Show count per stage, and include count if flags are filtered.
This commit is contained in:
Tymoteusz Czech 2025-04-25 12:52:11 +02:00 committed by GitHub
parent bd78a75177
commit 3ac087e0f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 154 additions and 2 deletions

View File

@ -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(
<LifecycleFilters state={{}} onChange={vi.fn()} />,
);
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(
<LifecycleFilters state={{}} onChange={vi.fn()} />,
);
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(
<LifecycleFilters
state={{ lifecycle: { operator: 'IS', values: ['live'] } }}
onChange={vi.fn()}
total={total}
/>,
);
expect(getByText('Rollout production (3)')).toBeInTheDocument();
});
it('renders dynamic label when total does not match count', () => {
const total = 2;
const { getByText } = render(
<LifecycleFilters
state={{ lifecycle: { operator: 'IS', values: ['live'] } }}
onChange={vi.fn()}
total={total}
/>,
);
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(
<LifecycleFilters state={{}} onChange={onChange} />,
);
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 });
});
});

View File

@ -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<ILifecycleFiltersProps> = ({
state,
onChange,
total,
}) => {
const { lifecycleCount } = useLifecycleCount();
const current = state.lifecycle?.values ?? [];
return (
@ -59,10 +83,11 @@ export const LifecycleFilters: FC<ILifecycleFiltersProps> = ({
{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(

View File

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