mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-24 17:51:14 +02:00
feat: implement lifecycle filters for feature toggle list and project lifecycle
This commit is contained in:
parent
86f7806dd0
commit
37ae29ca5a
@ -1,9 +1,8 @@
|
|||||||
import { Box, Chip, styled } from '@mui/material';
|
import { Box, Chip, styled } from '@mui/material';
|
||||||
import type { FC, ReactNode } from 'react';
|
import type { SxProps, Theme } from '@mui/material';
|
||||||
import type { FilterItemParamHolder } from '../../../filter/Filters/Filters.tsx';
|
import type { ReactNode } from 'react';
|
||||||
import type { LifecycleStage } from '../../FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx';
|
import type { FilterItemParamHolder } from '../../filter/Filters/Filters.tsx';
|
||||||
import { useLifecycleCount } from 'hooks/api/getters/useLifecycleCount/useLifecycleCount';
|
import type { LifecycleStage } from '../../feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx';
|
||||||
import type { FeatureLifecycleCountSchema } from 'openapi';
|
|
||||||
|
|
||||||
const StyledChip = styled(Chip, {
|
const StyledChip = styled(Chip, {
|
||||||
shouldForwardProp: (prop) => prop !== 'isActive',
|
shouldForwardProp: (prop) => prop !== 'isActive',
|
||||||
@ -26,17 +25,22 @@ const StyledChip = styled(Chip, {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface ILifecycleFiltersProps {
|
interface ILifecycleFiltersBaseProps<T> {
|
||||||
state: FilterItemParamHolder;
|
state: FilterItemParamHolder;
|
||||||
onChange: (value: FilterItemParamHolder) => void;
|
onChange: (value: FilterItemParamHolder) => void;
|
||||||
total?: number;
|
total?: number;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
countData?: T;
|
||||||
|
getStageCount: (
|
||||||
|
lifecycle: LifecycleStage['name'] | null,
|
||||||
|
data?: T,
|
||||||
|
) => number | undefined;
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Wrapper = styled(Box)(({ theme }) => ({
|
const Wrapper = styled(Box)(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: theme.spacing(1.5, 3, 0, 3),
|
|
||||||
minHeight: theme.spacing(7),
|
minHeight: theme.spacing(7),
|
||||||
gap: theme.spacing(2),
|
gap: theme.spacing(2),
|
||||||
}));
|
}));
|
||||||
@ -58,45 +62,26 @@ const lifecycleOptions: {
|
|||||||
{ label: 'Cleanup', value: 'completed' },
|
{ label: 'Cleanup', value: 'completed' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStageCount = (
|
export const LifecycleFilters = <T,>({
|
||||||
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,
|
state,
|
||||||
onChange,
|
onChange,
|
||||||
total,
|
total,
|
||||||
children,
|
children,
|
||||||
}) => {
|
countData,
|
||||||
const { lifecycleCount } = useLifecycleCount();
|
getStageCount,
|
||||||
|
sx,
|
||||||
|
}: ILifecycleFiltersBaseProps<T>) => {
|
||||||
const current = state.lifecycle?.values ?? [];
|
const current = state.lifecycle?.values ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper sx={sx}>
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
{lifecycleOptions.map(({ label, value }) => {
|
{lifecycleOptions.map(({ label, value }) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
value === null
|
value === null
|
||||||
? !state.lifecycle
|
? !state.lifecycle
|
||||||
: current.includes(value);
|
: current.includes(value);
|
||||||
const count = getStageCount(value, lifecycleCount);
|
const count = getStageCount(value, countData);
|
||||||
const dynamicLabel =
|
const dynamicLabel =
|
||||||
isActive && Number.isInteger(total)
|
isActive && Number.isInteger(total)
|
||||||
? `${label} (${total === count ? total : `${total} of ${count}`})`
|
? `${label} (${total === count ? total : `${total} of ${count}`})`
|
@ -0,0 +1,59 @@
|
|||||||
|
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 { LifecycleFilters } from '../../../common/LifecycleFilters/LifecycleFilters.tsx';
|
||||||
|
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ILifecycleFiltersProps {
|
||||||
|
state: FilterItemParamHolder;
|
||||||
|
onChange: (value: FilterItemParamHolder) => void;
|
||||||
|
total?: number;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeatureLifecycleFilters: FC<ILifecycleFiltersProps> = ({
|
||||||
|
state,
|
||||||
|
onChange,
|
||||||
|
total,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { lifecycleCount } = useLifecycleCount();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LifecycleFilters
|
||||||
|
state={state}
|
||||||
|
onChange={onChange}
|
||||||
|
total={total}
|
||||||
|
countData={lifecycleCount}
|
||||||
|
getStageCount={getStageCount}
|
||||||
|
sx={{
|
||||||
|
padding: (theme) =>
|
||||||
|
`${theme.spacing(1.5)} ${theme.spacing(3)} 0 ${theme.spacing(3)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</LifecycleFilters>
|
||||||
|
);
|
||||||
|
};
|
@ -1,7 +1,7 @@
|
|||||||
import { type MockedFunction, vi } from 'vitest';
|
import { type MockedFunction, vi } from 'vitest';
|
||||||
import { render } from 'utils/testRenderer';
|
import { render } from 'utils/testRenderer';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { LifecycleFilters } from './LifecycleFilters.tsx';
|
import { FeatureLifecycleFilters } from './FeatureLifecycleFilters.tsx';
|
||||||
import { useLifecycleCount } from 'hooks/api/getters/useLifecycleCount/useLifecycleCount';
|
import { useLifecycleCount } from 'hooks/api/getters/useLifecycleCount/useLifecycleCount';
|
||||||
|
|
||||||
vi.mock('hooks/api/getters/useLifecycleCount/useLifecycleCount');
|
vi.mock('hooks/api/getters/useLifecycleCount/useLifecycleCount');
|
||||||
@ -33,7 +33,7 @@ describe('LifecycleFilters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { getByText } = render(
|
const { getByText } = render(
|
||||||
<LifecycleFilters state={{}} onChange={vi.fn()} />,
|
<FeatureLifecycleFilters state={{}} onChange={vi.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getByText('All flags')).toBeInTheDocument();
|
expect(getByText('All flags')).toBeInTheDocument();
|
||||||
@ -44,7 +44,7 @@ describe('LifecycleFilters', () => {
|
|||||||
|
|
||||||
it('renders all stages with correct counts when no total provided', () => {
|
it('renders all stages with correct counts when no total provided', () => {
|
||||||
const { getByText } = render(
|
const { getByText } = render(
|
||||||
<LifecycleFilters state={{}} onChange={vi.fn()} />,
|
<FeatureLifecycleFilters state={{}} onChange={vi.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getByText('All flags (10)')).toBeInTheDocument();
|
expect(getByText('All flags (10)')).toBeInTheDocument();
|
||||||
@ -56,7 +56,7 @@ describe('LifecycleFilters', () => {
|
|||||||
it('renders dynamic label when total matches count', () => {
|
it('renders dynamic label when total matches count', () => {
|
||||||
const total = 3;
|
const total = 3;
|
||||||
const { getByText } = render(
|
const { getByText } = render(
|
||||||
<LifecycleFilters
|
<FeatureLifecycleFilters
|
||||||
state={{ lifecycle: { operator: 'IS', values: ['live'] } }}
|
state={{ lifecycle: { operator: 'IS', values: ['live'] } }}
|
||||||
onChange={vi.fn()}
|
onChange={vi.fn()}
|
||||||
total={total}
|
total={total}
|
||||||
@ -68,7 +68,7 @@ describe('LifecycleFilters', () => {
|
|||||||
it('renders dynamic label when total does not match count', () => {
|
it('renders dynamic label when total does not match count', () => {
|
||||||
const total = 2;
|
const total = 2;
|
||||||
const { getByText } = render(
|
const { getByText } = render(
|
||||||
<LifecycleFilters
|
<FeatureLifecycleFilters
|
||||||
state={{ lifecycle: { operator: 'IS', values: ['live'] } }}
|
state={{ lifecycle: { operator: 'IS', values: ['live'] } }}
|
||||||
onChange={vi.fn()}
|
onChange={vi.fn()}
|
||||||
total={total}
|
total={total}
|
||||||
@ -80,7 +80,7 @@ describe('LifecycleFilters', () => {
|
|||||||
it('will apply a correct filter for each stage', async () => {
|
it('will apply a correct filter for each stage', async () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const { getByText } = render(
|
const { getByText } = render(
|
||||||
<LifecycleFilters state={{}} onChange={onChange} />,
|
<FeatureLifecycleFilters state={{}} onChange={onChange} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.click(getByText('Develop (2)'));
|
await userEvent.click(getByText('Develop (2)'));
|
||||||
|
@ -29,7 +29,7 @@ import {
|
|||||||
useTableStateFilter,
|
useTableStateFilter,
|
||||||
} from './useGlobalFeatureSearch.ts';
|
} from './useGlobalFeatureSearch.ts';
|
||||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||||
import { LifecycleFilters } from './FeatureToggleFilters/LifecycleFilters.tsx';
|
import { FeatureLifecycleFilters } from './FeatureToggleFilters/FeatureLifecycleFilters.tsx';
|
||||||
import { ExportFlags } from './ExportFlags.tsx';
|
import { ExportFlags } from './ExportFlags.tsx';
|
||||||
import { createFeatureOverviewCell } from 'component/common/Table/cells/FeatureOverviewCell/FeatureOverviewCell';
|
import { createFeatureOverviewCell } from 'component/common/Table/cells/FeatureOverviewCell/FeatureOverviewCell';
|
||||||
import { AvatarCell } from 'component/project/Project/PaginatedProjectFeatureToggles/AvatarCell';
|
import { AvatarCell } from 'component/project/Project/PaginatedProjectFeatureToggles/AvatarCell';
|
||||||
@ -290,7 +290,7 @@ export const FeatureToggleListTable: FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LifecycleFilters
|
<FeatureLifecycleFilters
|
||||||
state={filterState}
|
state={filterState}
|
||||||
onChange={setTableState}
|
onChange={setTableState}
|
||||||
total={loading ? undefined : total}
|
total={loading ? undefined : total}
|
||||||
@ -303,7 +303,7 @@ export const FeatureToggleListTable: FC = () => {
|
|||||||
id='globalFeatureFlags'
|
id='globalFeatureFlags'
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</LifecycleFilters>
|
</FeatureLifecycleFilters>
|
||||||
<FeatureToggleFilters
|
<FeatureToggleFilters
|
||||||
onChange={setTableState}
|
onChange={setTableState}
|
||||||
state={filterState}
|
state={filterState}
|
||||||
|
@ -1,61 +1,8 @@
|
|||||||
import { Box, Chip, styled } from '@mui/material';
|
|
||||||
import type { FC, ReactNode } from 'react';
|
import type { FC, ReactNode } from 'react';
|
||||||
import type { FilterItemParamHolder } from '../../../filter/Filters/Filters.tsx';
|
import type { FilterItemParamHolder } from '../../../filter/Filters/Filters.tsx';
|
||||||
import type { LifecycleStage } from '../../../feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx';
|
import type { LifecycleStage } from '../../../feature/FeatureView/FeatureOverview/FeatureLifecycle/LifecycleStage.tsx';
|
||||||
import { useProjectStatus } from 'hooks/api/getters/useProjectStatus/useProjectStatus';
|
import { useProjectStatus } from 'hooks/api/getters/useProjectStatus/useProjectStatus';
|
||||||
|
import { LifecycleFilters } from '../../../common/LifecycleFilters/LifecycleFilters.tsx';
|
||||||
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 = (
|
const getStageCount = (
|
||||||
lifecycle: LifecycleStage['name'] | null,
|
lifecycle: LifecycleStage['name'] | null,
|
||||||
@ -78,6 +25,14 @@ const getStageCount = (
|
|||||||
return lifecycleSummary[key]?.currentFlags;
|
return lifecycleSummary[key]?.currentFlags;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface IProjectLifecycleFiltersProps {
|
||||||
|
projectId: string;
|
||||||
|
state: FilterItemParamHolder;
|
||||||
|
onChange: (value: FilterItemParamHolder) => void;
|
||||||
|
total?: number;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
export const ProjectLifecycleFilters: FC<IProjectLifecycleFiltersProps> = ({
|
export const ProjectLifecycleFilters: FC<IProjectLifecycleFiltersProps> = ({
|
||||||
projectId,
|
projectId,
|
||||||
state,
|
state,
|
||||||
@ -87,47 +42,16 @@ export const ProjectLifecycleFilters: FC<IProjectLifecycleFiltersProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { data } = useProjectStatus(projectId);
|
const { data } = useProjectStatus(projectId);
|
||||||
const lifecycleSummary = data?.lifecycleSummary;
|
const lifecycleSummary = data?.lifecycleSummary;
|
||||||
const current = state.lifecycle?.values ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<LifecycleFilters
|
||||||
<StyledContainer>
|
state={state}
|
||||||
{lifecycleOptions.map(({ label, value }) => {
|
onChange={onChange}
|
||||||
const isActive =
|
total={total}
|
||||||
value === null
|
countData={lifecycleSummary}
|
||||||
? !state.lifecycle
|
getStageCount={getStageCount}
|
||||||
: 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}
|
{children}
|
||||||
</Wrapper>
|
</LifecycleFilters>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user