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

feat(frontend): quick filters on project overview

This commit is contained in:
Tymoteusz Czech 2025-09-09 14:43:53 +02:00
parent 71b0d424b8
commit 86f7806dd0
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
4 changed files with 172 additions and 25 deletions

View File

@ -37,6 +37,30 @@ const setupApi = () => {
{ id: 1, name: 'AuthorA' },
{ id: 2, name: 'AuthorB' },
]);
testServerRoute(server, '/api/admin/projects/default/status', {
activityCountByDate: [],
resources: {
members: 0,
apiTokens: 0,
segments: 0,
},
health: {
current: 0,
},
technicalDebt: {
current: 0,
},
lifecycleSummary: {
initial: { currentFlags: 1, averageDays: null },
preLive: { currentFlags: 2, averageDays: null },
live: { currentFlags: 3, averageDays: null },
completed: { currentFlags: 1, averageDays: null },
archived: { currentFlags: 0, last30Days: 0 },
},
staleFlags: {
total: 0,
},
});
};
test('filters by flag type', async () => {
@ -208,7 +232,7 @@ test('Project is not onboarded', async () => {
await screen.findByText('Welcome to your project');
});
test('renders lifecycle filters', async () => {
test('renders lifecycle quick filters', async () => {
setupApi();
render(
@ -227,14 +251,8 @@ test('renders lifecycle filters', async () => {
},
);
const addFilter = await screen.findByText('Filter');
fireEvent.click(addFilter);
const lifecycleFilter = await screen.findByText('Lifecycle stage');
fireEvent.click(lifecycleFilter);
await screen.findByText('Define');
await screen.findByText('Develop');
await screen.findByText('Rollout production');
await screen.findByText('Cleanup');
await screen.findByText(/All flags/);
await screen.findByText(/Develop/);
await screen.findByText(/Rollout production/);
await screen.findByText(/Cleanup/);
});

View File

@ -31,6 +31,7 @@ import {
PlaceholderFeatureToggleCell,
} from './FeatureToggleCell/FeatureToggleCell.tsx';
import { ProjectOverviewFilters } from './ProjectOverviewFilters.tsx';
import { ProjectLifecycleFilters } from './ProjectLifecycleFilters.tsx';
import { useDefaultColumnVisibility } from './hooks/useDefaultColumnVisibility.ts';
import { TableEmptyState } from './TableEmptyState/TableEmptyState.tsx';
import { useRowActions } from './hooks/useRowActions.tsx';
@ -41,7 +42,7 @@ import {
useProjectFeatureSearchActions,
} from './useProjectFeatureSearch.ts';
import { AvatarCell } from './AvatarCell.tsx';
import { styled } from '@mui/material';
import { Box, styled } from '@mui/material';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { ConnectSdkDialog } from '../../../onboarding/dialog/ConnectSdkDialog.tsx';
import { ProjectOnboarding } from '../../../onboarding/flow/ProjectOnboarding.tsx';
@ -577,6 +578,14 @@ export const ProjectFeatureToggles = ({
onChange={setTableState}
state={filterState}
/>
<Box sx={{ marginRight: 'auto' }} data-test>
<ProjectLifecycleFilters
projectId={projectId}
state={filterState}
onChange={setTableState}
total={loading ? undefined : total}
/>
</Box>
<ButtonGroup>
<PermissionIconButton
permission={UPDATE_FEATURE}

View File

@ -0,0 +1,133 @@
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' },
];
const getStageCount = (
lifecycle: LifecycleStage['name'] | null,
lifecycleSummary?: { [key: string]: { currentFlags: number } },
) => {
if (!lifecycleSummary) {
return undefined;
}
if (lifecycle === null) {
return (
(lifecycleSummary.initial?.currentFlags || 0) +
(lifecycleSummary.preLive?.currentFlags || 0) +
(lifecycleSummary.live?.currentFlags || 0) +
(lifecycleSummary.completed?.currentFlags || 0)
);
}
const key = lifecycle === 'pre-live' ? 'preLive' : lifecycle;
return lifecycleSummary[key]?.currentFlags;
};
export const ProjectLifecycleFilters: FC<IProjectLifecycleFiltersProps> = ({
projectId,
state,
onChange,
total,
children,
}) => {
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>
{children}
</Wrapper>
);
};

View File

@ -118,19 +118,6 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
singularOperators: ['IS'],
pluralOperators: ['IS_ANY_OF'],
},
{
label: 'Lifecycle stage',
icon: 'model_training',
options: [
{ label: 'Define', value: 'initial' },
{ label: 'Develop', value: 'pre-live' },
{ label: 'Rollout production', value: 'live' },
{ label: 'Cleanup', value: 'completed' },
],
filterKey: 'lifecycle',
singularOperators: ['IS', 'IS_NOT'],
pluralOperators: ['IS_ANY_OF', 'IS_NONE_OF'],
},
];
setAvailableFilters(availableFilters);