mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-28 17:55:15 +02:00
feat(frontend): quick filters on project overview
This commit is contained in:
parent
71b0d424b8
commit
86f7806dd0
@ -37,6 +37,30 @@ const setupApi = () => {
|
|||||||
{ id: 1, name: 'AuthorA' },
|
{ id: 1, name: 'AuthorA' },
|
||||||
{ id: 2, name: 'AuthorB' },
|
{ 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 () => {
|
test('filters by flag type', async () => {
|
||||||
@ -208,7 +232,7 @@ test('Project is not onboarded', async () => {
|
|||||||
await screen.findByText('Welcome to your project');
|
await screen.findByText('Welcome to your project');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders lifecycle filters', async () => {
|
test('renders lifecycle quick filters', async () => {
|
||||||
setupApi();
|
setupApi();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@ -227,14 +251,8 @@ test('renders lifecycle filters', async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const addFilter = await screen.findByText('Filter');
|
await screen.findByText(/All flags/);
|
||||||
fireEvent.click(addFilter);
|
await screen.findByText(/Develop/);
|
||||||
|
await screen.findByText(/Rollout production/);
|
||||||
const lifecycleFilter = await screen.findByText('Lifecycle stage');
|
await screen.findByText(/Cleanup/);
|
||||||
fireEvent.click(lifecycleFilter);
|
|
||||||
|
|
||||||
await screen.findByText('Define');
|
|
||||||
await screen.findByText('Develop');
|
|
||||||
await screen.findByText('Rollout production');
|
|
||||||
await screen.findByText('Cleanup');
|
|
||||||
});
|
});
|
||||||
|
@ -31,6 +31,7 @@ import {
|
|||||||
PlaceholderFeatureToggleCell,
|
PlaceholderFeatureToggleCell,
|
||||||
} from './FeatureToggleCell/FeatureToggleCell.tsx';
|
} from './FeatureToggleCell/FeatureToggleCell.tsx';
|
||||||
import { ProjectOverviewFilters } from './ProjectOverviewFilters.tsx';
|
import { ProjectOverviewFilters } from './ProjectOverviewFilters.tsx';
|
||||||
|
import { ProjectLifecycleFilters } from './ProjectLifecycleFilters.tsx';
|
||||||
import { useDefaultColumnVisibility } from './hooks/useDefaultColumnVisibility.ts';
|
import { useDefaultColumnVisibility } from './hooks/useDefaultColumnVisibility.ts';
|
||||||
import { TableEmptyState } from './TableEmptyState/TableEmptyState.tsx';
|
import { TableEmptyState } from './TableEmptyState/TableEmptyState.tsx';
|
||||||
import { useRowActions } from './hooks/useRowActions.tsx';
|
import { useRowActions } from './hooks/useRowActions.tsx';
|
||||||
@ -41,7 +42,7 @@ import {
|
|||||||
useProjectFeatureSearchActions,
|
useProjectFeatureSearchActions,
|
||||||
} from './useProjectFeatureSearch.ts';
|
} from './useProjectFeatureSearch.ts';
|
||||||
import { AvatarCell } from './AvatarCell.tsx';
|
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 useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
|
||||||
import { ConnectSdkDialog } from '../../../onboarding/dialog/ConnectSdkDialog.tsx';
|
import { ConnectSdkDialog } from '../../../onboarding/dialog/ConnectSdkDialog.tsx';
|
||||||
import { ProjectOnboarding } from '../../../onboarding/flow/ProjectOnboarding.tsx';
|
import { ProjectOnboarding } from '../../../onboarding/flow/ProjectOnboarding.tsx';
|
||||||
@ -577,6 +578,14 @@ export const ProjectFeatureToggles = ({
|
|||||||
onChange={setTableState}
|
onChange={setTableState}
|
||||||
state={filterState}
|
state={filterState}
|
||||||
/>
|
/>
|
||||||
|
<Box sx={{ marginRight: 'auto' }} data-test>
|
||||||
|
<ProjectLifecycleFilters
|
||||||
|
projectId={projectId}
|
||||||
|
state={filterState}
|
||||||
|
onChange={setTableState}
|
||||||
|
total={loading ? undefined : total}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<PermissionIconButton
|
<PermissionIconButton
|
||||||
permission={UPDATE_FEATURE}
|
permission={UPDATE_FEATURE}
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -118,19 +118,6 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
|
|||||||
singularOperators: ['IS'],
|
singularOperators: ['IS'],
|
||||||
pluralOperators: ['IS_ANY_OF'],
|
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);
|
setAvailableFilters(availableFilters);
|
||||||
|
Loading…
Reference in New Issue
Block a user