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:
parent
be4665f3f1
commit
4b42435590
@ -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}`})`
|
@ -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}
|
||||
/>
|
||||
|
@ -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 });
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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();
|
||||
});
|
@ -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();
|
@ -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/);
|
||||
});
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user