1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-19 17:52:45 +02:00

feat(frontend): quick filters on project overview (#10638)

This commit is contained in:
Tymoteusz Czech 2025-09-11 13:28:59 +02:00 committed by GitHub
parent be4665f3f1
commit 4b42435590
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 178 additions and 87 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,18 @@ const StyledChip = styled(Chip, {
},
}));
interface ILifecycleFiltersProps {
interface ILifecycleFiltersBaseProps {
state: FilterItemParamHolder;
onChange: (value: FilterItemParamHolder) => void;
total?: number;
children?: ReactNode;
countData?: Record<LifecycleStage['name'], number>;
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,34 +58,13 @@ 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 = ({
state,
onChange,
total,
children,
}) => {
const { lifecycleCount } = useLifecycleCount();
countData,
}: ILifecycleFiltersBaseProps) => {
const current = state.lifecycle?.values ?? [];
return (
@ -96,7 +75,7 @@ export const LifecycleFilters: FC<ILifecycleFiltersProps> = ({
value === null
? !state.lifecycle
: current.includes(value);
const count = getStageCount(value, lifecycleCount);
const count = value ? countData?.[value] : total;
const dynamicLabel =
isActive && Number.isInteger(total)
? `${label} (${total === count ? total : `${total} of ${count}`})`

View File

@ -20,7 +20,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { focusable } from 'themes/themeStyles';
import { FeatureLifecycleCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import useToast from 'hooks/useToast';
import { FeatureToggleFilters } from './FeatureToggleFilters/FeatureToggleFilters.tsx';
import { FeaturesOverviewToggleFilters } from './FeaturesOverviewLifecycleFilters/FeaturesOverviewToggleFilters.tsx';
import { withTableState } from 'utils/withTableState';
import useLoading from 'hooks/useLoading';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
@ -29,7 +29,7 @@ import {
useTableStateFilter,
} from './useGlobalFeatureSearch.ts';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { LifecycleFilters } from './FeatureToggleFilters/LifecycleFilters.tsx';
import { FeaturesOverviewLifecycleFilters } from './FeaturesOverviewLifecycleFilters/FeaturesOverviewLifecycleFilters.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
<FeaturesOverviewLifecycleFilters
state={filterState}
onChange={setTableState}
total={loading ? undefined : total}
@ -303,8 +303,8 @@ export const FeatureToggleListTable: FC = () => {
id='globalFeatureFlags'
/>
) : null}
</LifecycleFilters>
<FeatureToggleFilters
</FeaturesOverviewLifecycleFilters>
<FeaturesOverviewToggleFilters
onChange={setTableState}
state={filterState}
/>

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 { FeaturesOverviewLifecycleFilters } from './FeaturesOverviewLifecycleFilters.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()} />,
<FeaturesOverviewLifecycleFilters state={{}} onChange={vi.fn()} />,
);
expect(getByText('All flags')).toBeInTheDocument();
@ -44,10 +44,10 @@ describe('LifecycleFilters', () => {
it('renders all stages with correct counts when no total provided', () => {
const { getByText } = render(
<LifecycleFilters state={{}} onChange={vi.fn()} />,
<FeaturesOverviewLifecycleFilters state={{}} onChange={vi.fn()} />,
);
expect(getByText('All flags (10)')).toBeInTheDocument();
expect(getByText('All flags')).toBeInTheDocument();
expect(getByText('Develop (2)')).toBeInTheDocument();
expect(getByText('Rollout production (3)')).toBeInTheDocument();
expect(getByText('Cleanup (4)')).toBeInTheDocument();
@ -56,7 +56,7 @@ describe('LifecycleFilters', () => {
it('renders dynamic label when total matches count', () => {
const total = 3;
const { getByText } = render(
<LifecycleFilters
<FeaturesOverviewLifecycleFilters
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
<FeaturesOverviewLifecycleFilters
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} />,
<FeaturesOverviewLifecycleFilters state={{}} onChange={onChange} />,
);
await userEvent.click(getByText('Develop (2)'));
@ -98,7 +98,7 @@ describe('LifecycleFilters', () => {
lifecycle: { operator: 'IS', values: ['completed'] },
});
await userEvent.click(getByText('All flags (10)'));
await userEvent.click(getByText('All flags'));
expect(onChange).toHaveBeenCalledWith({ lifecycle: null });
});
});

View File

@ -0,0 +1,38 @@
import type { FC, ReactNode } from 'react';
import { Box } from '@mui/material';
import type { FilterItemParamHolder } from '../../../filter/Filters/Filters.tsx';
import { useLifecycleCount } from 'hooks/api/getters/useLifecycleCount/useLifecycleCount';
import { LifecycleFilters } from 'component/common/LifecycleFilters/LifecycleFilters.tsx';
type FeaturesOverviewLifecycleFiltersProps = {
state: FilterItemParamHolder;
onChange: (value: FilterItemParamHolder) => void;
total?: number;
children?: ReactNode;
};
export const FeaturesOverviewLifecycleFilters: FC<
FeaturesOverviewLifecycleFiltersProps
> = ({ state, onChange, total, children }) => {
const { lifecycleCount } = useLifecycleCount();
const countData = Object.entries(lifecycleCount || {}).reduce(
(acc, [key, value]) => {
acc[key === 'preLive' ? 'pre-live' : key] = value;
return acc;
},
{} as Record<string, number>,
);
return (
<Box sx={(theme) => ({ padding: theme.spacing(1.5, 3, 0, 3) })}>
<LifecycleFilters
state={state}
onChange={onChange}
total={total}
countData={countData}
>
{children}
</LifecycleFilters>
</Box>
);
};

View File

@ -1,7 +1,7 @@
import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { testServerRoute, testServerSetup } from 'utils/testServer';
import { FeatureToggleFilters } from './FeatureToggleFilters.tsx';
import { FeaturesOverviewToggleFilters } from './FeaturesOverviewToggleFilters.tsx';
const server = testServerSetup();
@ -19,7 +19,7 @@ test('should render projects filters when more than one project', async () => {
],
});
render(<FeatureToggleFilters onChange={() => {}} state={{}} />);
render(<FeaturesOverviewToggleFilters onChange={() => {}} state={{}} />);
await screen.findByText('Project');
});
@ -34,7 +34,7 @@ test('should not render projects filters when less than two project', async () =
],
});
render(<FeatureToggleFilters onChange={() => {}} state={{}} />);
render(<FeaturesOverviewToggleFilters onChange={() => {}} state={{}} />);
expect(screen.queryByText('Projects')).not.toBeInTheDocument();
});

View File

@ -1,4 +1,4 @@
import { useEffect, useState, type VFC } from 'react';
import { type FC, useEffect, useState } from 'react';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import useAllTags from 'hooks/api/getters/useAllTags/useAllTags';
@ -9,15 +9,14 @@ import {
} from 'component/filter/Filters/Filters';
import { formatTag } from 'utils/format-tag';
interface IFeatureToggleFiltersProps {
type FeaturesOverviewToggleFiltersProps = {
state: FilterItemParamHolder;
onChange: (value: FilterItemParamHolder) => void;
}
};
export const FeatureToggleFilters: VFC<IFeatureToggleFiltersProps> = ({
state,
onChange,
}) => {
export const FeaturesOverviewToggleFilters: FC<
FeaturesOverviewToggleFiltersProps
> = ({ state, onChange }) => {
const { projects } = useProjects();
const { segments } = useSegments();
const { tags } = useAllTags();

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';
@ -56,9 +57,9 @@ import { ImportModal } from '../Import/ImportModal.tsx';
import { IMPORT_BUTTON } from 'utils/testIds';
import { ProjectCleanupReminder } from './ProjectCleanupReminder/ProjectCleanupReminder.tsx';
interface IPaginatedProjectFeatureTogglesProps {
type ProjectFeatureTogglesProps = {
environments: string[];
}
};
const formatEnvironmentColumnId = (environment: string) =>
`environment:${environment}`;
@ -75,7 +76,6 @@ const Container = styled('div')(({ theme }) => ({
const FilterRow = styled('div')(({ theme }) => ({
display: 'flex',
flexFlow: 'row wrap',
gap: theme.spacing(2),
justifyContent: 'space-between',
}));
@ -87,7 +87,7 @@ const ButtonGroup = styled('div')(({ theme }) => ({
export const ProjectFeatureToggles = ({
environments,
}: IPaginatedProjectFeatureTogglesProps) => {
}: ProjectFeatureTogglesProps) => {
const { trackEvent } = usePlausibleTracker();
const projectId = useRequiredPathParam('projectId');
const { project } = useProjectOverview(projectId);
@ -577,6 +577,12 @@ export const ProjectFeatureToggles = ({
onChange={setTableState}
state={filterState}
/>
<ProjectLifecycleFilters
projectId={projectId}
state={filterState}
onChange={setTableState}
total={loading ? undefined : total}
/>
<ButtonGroup>
<PermissionIconButton
permission={UPDATE_FEATURE}

View File

@ -0,0 +1,64 @@
import type { FC, ReactNode } from 'react';
import { useEffect } from 'react';
import type { FilterItemParamHolder } from '../../../filter/Filters/Filters.tsx';
import { useProjectStatus } from 'hooks/api/getters/useProjectStatus/useProjectStatus';
import { LifecycleFilters } from 'component/common/LifecycleFilters/LifecycleFilters.tsx';
import { Box, useMediaQuery, useTheme } from '@mui/material';
type ProjectLifecycleFiltersProps = {
projectId: string;
state: FilterItemParamHolder;
onChange: (value: FilterItemParamHolder) => void;
total?: number;
children?: ReactNode;
};
export const ProjectLifecycleFilters: FC<ProjectLifecycleFiltersProps> = ({
projectId,
state,
onChange,
total,
children,
}) => {
const { data } = useProjectStatus(projectId);
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const lifecycleSummary = Object.entries(
data?.lifecycleSummary || {},
).reduce(
(acc, [key, value]) => {
acc[key === 'preLive' ? 'pre-live' : key] = value.currentFlags || 0;
return acc;
},
{} as Record<string, number>,
);
const isArchivedFilterActive = state.archived?.values?.includes('true');
useEffect(() => {
if (isArchivedFilterActive && state.lifecycle) {
onChange({ ...state, lifecycle: null });
}
}, [isArchivedFilterActive, state, onChange]);
if (isArchivedFilterActive) {
return null;
}
return (
<Box
sx={{
marginRight: 'auto',
margin: isSmallScreen ? theme.spacing(0, 2) : '0 auto 0 0',
}}
>
<LifecycleFilters
state={state}
onChange={onChange}
total={total}
countData={lifecycleSummary}
>
{children}
</LifecycleFilters>
</Box>
);
};

View File

@ -1,4 +1,4 @@
import { useEffect, useState, type VFC } from 'react';
import { useEffect, useState, type FC } from 'react';
import useAllTags from 'hooks/api/getters/useAllTags/useAllTags';
import {
type FilterItemParamHolder,
@ -8,13 +8,13 @@ import {
import { useProjectFlagCreators } from 'hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators';
import { formatTag } from 'utils/format-tag';
interface IProjectOverviewFilters {
type ProjectOverviewFiltersProps = {
state: FilterItemParamHolder;
onChange: (value: FilterItemParamHolder) => void;
project: string;
}
};
export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
export const ProjectOverviewFilters: FC<ProjectOverviewFiltersProps> = ({
state,
onChange,
project,
@ -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);