1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

Refactor flag filters (#10703)

Refactored and simplified code around flag filters, in preparation for
UI improvements. It's split into 2 PRs in order to simplify what needs
to be behind a flag and what doesn't.

- `ExperimentalColumnsMenu` moved to `ColumnsMenu`, old unused
`ColumnsMenu` removed
- Parts of the code moved to `ProjectFeaturesColumnsMenu`
- Moved `FlagCreationButton` to a separate file
- Removed part behind archived flag (`projectOverviewRefactorFeedback`)
This commit is contained in:
Tymoteusz Czech 2025-10-01 09:30:36 +02:00 committed by GitHub
parent e46f8881d1
commit c12aca72db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 213 additions and 407 deletions

View File

@ -4,7 +4,7 @@ import {
UPDATE_PROJECT,
CREATE_PROJECT_API_TOKEN,
} from 'component/providers/AccessProvider/permissions';
import { FlagCreationButton } from '../../project/Project/PaginatedProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx';
import { FlagCreationButton } from '../../project/Project/PaginatedProjectFeatureToggles/ProjectFeatureTogglesHeader/FlagCreationButton/FlagCreationButton.tsx';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { SdkExample } from './SdkExample.tsx';

View File

@ -1,4 +1,4 @@
import { useState, type VFC } from 'react';
import { useState, type FC } from 'react';
import {
IconButton,
ListItemIcon,
@ -17,7 +17,7 @@ import {
StyledDivider,
StyledIconButton,
StyledMenuItem,
} from './ExperimentalColumnsMenu.styles';
} from './ColumnsMenu.styles';
interface IColumnsMenuProps {
columns: {
@ -29,10 +29,7 @@ interface IColumnsMenuProps {
onToggle?: (id: string) => void;
}
export const ExperimentalColumnsMenu: VFC<IColumnsMenuProps> = ({
columns,
onToggle,
}) => {
export const ColumnsMenu: FC<IColumnsMenuProps> = ({ columns, onToggle }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const onIconClick = (event: React.MouseEvent<HTMLButtonElement>) => {

View File

@ -1,41 +0,0 @@
import {
Box,
Checkbox,
Divider,
IconButton,
MenuItem,
styled,
} from '@mui/material';
import { flexRow } from 'themes/themeStyles';
export const StyledBoxContainer = styled(Box)(() => ({
...flexRow,
justifyContent: 'center',
}));
export const StyledIconButton = styled(IconButton)(({ theme }) => ({
margin: theme.spacing(-1, 0),
}));
export const StyledBoxMenuHeader = styled(Box)(({ theme }) => ({
...flexRow,
justifyContent: 'space-between',
padding: theme.spacing(1, 1, 0, 4),
}));
export const StyledMenuItem = styled(MenuItem)(({ theme }) => ({
padding: theme.spacing(0, 2),
margin: theme.spacing(0, 2),
borderRadius: theme.shape.borderRadius,
}));
export const StyledDivider = styled(Divider)(({ theme }) => ({
'&.MuiDivider-root.MuiDivider-fullWidth': {
margin: theme.spacing(0.75, 0),
},
}));
export const StyledCheckbox = styled(Checkbox)(({ theme }) => ({
padding: theme.spacing(0.75, 1),
}));

View File

@ -9,7 +9,6 @@ import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightC
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader';
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
import { ActionsCell } from '../ProjectFeatureToggles/ActionsCell/ActionsCell.tsx';
import { ExperimentalColumnsMenu as ColumnsMenu } from './ExperimentalColumnsMenu/ExperimentalColumnsMenu.tsx';
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import { MemoizedRowSelectCell } from '../ProjectFeatureToggles/RowSelectCell/RowSelectCell.tsx';
import { BatchSelectionActionsBar } from 'component/common/BatchSelectionActionsBar/BatchSelectionActionsBar';
@ -56,14 +55,13 @@ import { UPDATE_FEATURE } from '@server/types/permissions';
import { ImportModal } from '../Import/ImportModal.tsx';
import { IMPORT_BUTTON } from 'utils/testIds';
import { ProjectCleanupReminder } from './ProjectCleanupReminder/ProjectCleanupReminder.tsx';
import { formatEnvironmentColumnId } from './formatEnvironmentColumnId.ts';
import { ProjectFeaturesColumnsMenu } from './ProjectFeaturesColumnsMenu/ProjectFeaturesColumnsMenu.tsx';
type ProjectFeatureTogglesProps = {
environments: string[];
};
const formatEnvironmentColumnId = (environment: string) =>
`environment:${environment}`;
const columnHelper = createColumnHelper<FeatureSearchResponseSchema>();
const getRowId = (row: { name: string }) => row.name;
@ -514,50 +512,9 @@ export const ProjectFeatureToggles = ({
dataToExport={data}
environmentsToExport={environments}
actions={
<ColumnsMenu
columns={[
{
header: 'Name',
id: 'name',
isVisible: columnVisibility.name,
isStatic: true,
},
{
header: 'Created',
id: 'createdAt',
isVisible: columnVisibility.createdAt,
},
{
header: 'By',
id: 'createdBy',
isVisible: columnVisibility.createdBy,
},
{
header: 'Last seen',
id: 'lastSeenAt',
isVisible: columnVisibility.lastSeenAt,
},
{
header: 'Lifecycle',
id: 'lifecycle',
isVisible: columnVisibility.lifecycle,
},
{
id: 'divider',
},
...environments.map((environment) => ({
header: environment,
id: formatEnvironmentColumnId(
environment,
),
isVisible:
columnVisibility[
formatEnvironmentColumnId(
environment,
)
],
})),
]}
<ProjectFeaturesColumnsMenu
columnVisibility={columnVisibility}
environments={environments}
onToggle={onToggleColumnVisibility}
/>
}

View File

@ -0,0 +1,66 @@
import { useState } from 'react';
import Add from '@mui/icons-material/Add';
import { styled } from '@mui/material';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import { useSearchParams } from 'react-router-dom';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { CreateFeatureDialog } from '../CreateFeatureDialog.tsx';
import type { OverridableStringUnion } from '@mui/types';
import type { ButtonPropsVariantOverrides } from '@mui/material/Button/Button';
import { NAVIGATE_TO_CREATE_FEATURE } from 'utils/testIds';
interface IFlagCreationButtonProps {
text?: string;
variant?: OverridableStringUnion<
'text' | 'outlined' | 'contained',
ButtonPropsVariantOverrides
>;
skipNavigationOnComplete?: boolean;
isLoading?: boolean;
onSuccess?: () => void;
}
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
whiteSpace: 'nowrap',
}));
export const FlagCreationButton = ({
variant,
text = 'New feature flag',
skipNavigationOnComplete,
isLoading,
onSuccess,
}: IFlagCreationButtonProps) => {
const { loading } = useUiConfig();
const [searchParams] = useSearchParams();
const projectId = useRequiredPathParam('projectId');
const showCreateDialog = Boolean(searchParams.get('create'));
const [openCreateDialog, setOpenCreateDialog] = useState(showCreateDialog);
return (
<>
<StyledResponsiveButton
onClick={() => setOpenCreateDialog(true)}
maxWidth='960px'
Icon={Add}
projectId={projectId}
disabled={loading || isLoading}
variant={variant}
permission={CREATE_FEATURE}
data-testid={
loading || isLoading ? '' : NAVIGATE_TO_CREATE_FEATURE
}
>
{text}
</StyledResponsiveButton>
<CreateFeatureDialog
open={openCreateDialog}
onClose={() => setOpenCreateDialog(false)}
skipNavigationOnComplete={skipNavigationOnComplete}
onSuccess={onSuccess}
/>
</>
);
};

View File

@ -0,0 +1,35 @@
import { type FC, useState } from 'react';
import { ReactComponent as ImportSvg } from 'assets/icons/import.svg';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_FEATURE } from '@server/types/permissions';
import { ImportModal } from '../../../Import/ImportModal.tsx';
import { IMPORT_BUTTON } from 'utils/testIds';
type ImportButtonProps = {};
export const ImportButton: FC<ImportButtonProps> = () => {
const projectId = useRequiredPathParam('projectId');
const [modalOpen, setModalOpen] = useState(false);
return (
<>
<PermissionIconButton
permission={UPDATE_FEATURE}
projectId={projectId}
onClick={() => setModalOpen(true)}
tooltipProps={{ title: 'Import' }}
data-testid={IMPORT_BUTTON}
data-loading-project
>
<ImportSvg />
</PermissionIconButton>
<ImportModal
open={modalOpen}
setOpen={setModalOpen}
project={projectId}
/>
</>
);
};

View File

@ -1,7 +1,6 @@
import { type ReactNode, type FC, useState } from 'react';
import {
Box,
Button,
IconButton,
Tooltip,
useMediaQuery,
@ -11,24 +10,12 @@ import useLoading from 'hooks/useLoading';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Search } from 'component/common/Search/Search';
import { useUiFlag } from 'hooks/useUiFlag';
import Add from '@mui/icons-material/Add';
import { styled } from '@mui/material';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import { useSearchParams } from 'react-router-dom';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
import type { FeatureSchema } from 'openapi';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import ReviewsOutlined from '@mui/icons-material/ReviewsOutlined';
import { useFeedback } from 'component/feedbackNew/useFeedback';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { CreateFeatureDialog } from './CreateFeatureDialog.tsx';
import IosShare from '@mui/icons-material/IosShare';
import type { OverridableStringUnion } from '@mui/types';
import type { ButtonPropsVariantOverrides } from '@mui/material/Button/Button';
import { NAVIGATE_TO_CREATE_FEATURE } from 'utils/testIds';
import { FlagCreationButton } from './FlagCreationButton/FlagCreationButton.tsx';
interface IProjectFeatureTogglesHeaderProps {
isLoading?: boolean;
@ -40,60 +27,6 @@ interface IProjectFeatureTogglesHeaderProps {
actions?: ReactNode;
}
interface IFlagCreationButtonProps {
text?: string;
variant?: OverridableStringUnion<
'text' | 'outlined' | 'contained',
ButtonPropsVariantOverrides
>;
skipNavigationOnComplete?: boolean;
isLoading?: boolean;
onSuccess?: () => void;
}
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
whiteSpace: 'nowrap',
}));
export const FlagCreationButton = ({
variant,
text = 'New feature flag',
skipNavigationOnComplete,
isLoading,
onSuccess,
}: IFlagCreationButtonProps) => {
const { loading } = useUiConfig();
const [searchParams] = useSearchParams();
const projectId = useRequiredPathParam('projectId');
const showCreateDialog = Boolean(searchParams.get('create'));
const [openCreateDialog, setOpenCreateDialog] = useState(showCreateDialog);
return (
<>
<StyledResponsiveButton
onClick={() => setOpenCreateDialog(true)}
maxWidth='960px'
Icon={Add}
projectId={projectId}
disabled={loading || isLoading}
variant={variant}
permission={CREATE_FEATURE}
data-testid={
loading || isLoading ? '' : NAVIGATE_TO_CREATE_FEATURE
}
>
{text}
</StyledResponsiveButton>
<CreateFeatureDialog
open={openCreateDialog}
onClose={() => setOpenCreateDialog(false)}
skipNavigationOnComplete={skipNavigationOnComplete}
onSuccess={onSuccess}
/>
</>
);
};
export const ProjectFeatureTogglesHeader: FC<
IProjectFeatureTogglesHeaderProps
> = ({
@ -111,10 +44,6 @@ export const ProjectFeatureTogglesHeader: FC<
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [showExportDialog, setShowExportDialog] = useState(false);
const { trackEvent } = usePlausibleTracker();
const projectOverviewRefactorFeedback = useUiFlag(
'projectOverviewRefactorFeedback',
);
const { openFeedback } = useFeedback('newProjectOverview', 'automatic');
const handleSearch = (query: string) => {
onChangeSearchQuery?.(query);
trackEvent('search-bar', {
@ -125,16 +54,6 @@ export const ProjectFeatureTogglesHeader: FC<
});
};
const createFeedbackContext = () => {
openFeedback({
title: 'How easy was it to work with the project overview in Unleash?',
positiveLabel:
'What do you like most about the updated project overview?',
areasForImprovementsLabel:
'What improvements are needed in the project overview?',
});
};
return (
<Box
ref={headerLoadingRef}
@ -196,22 +115,6 @@ export const ProjectFeatureTogglesHeader: FC<
/>
}
/>
<ConditionallyRender
condition={
projectOverviewRefactorFeedback &&
!isSmallScreen
}
show={
<Button
startIcon={<ReviewsOutlined />}
onClick={createFeedbackContext}
variant='outlined'
data-loading
>
Provide feedback
</Button>
}
/>
<FlagCreationButton isLoading={isLoading} />
</>
}

View File

@ -0,0 +1,58 @@
import type { FC } from 'react';
import { ColumnsMenu } from '../ColumnsMenu/ColumnsMenu.tsx';
import { formatEnvironmentColumnId } from '../formatEnvironmentColumnId.ts';
type ProjectFeaturesColumnsMenuProps = {
columnVisibility: Record<string, boolean>;
environments: string[];
onToggle: (id: string) => void;
};
export const ProjectFeaturesColumnsMenu: FC<
ProjectFeaturesColumnsMenuProps
> = ({ columnVisibility, environments, onToggle }) => {
return (
<ColumnsMenu
columns={[
{
header: 'Name',
id: 'name',
isVisible: columnVisibility.name,
isStatic: true,
},
{
header: 'Created',
id: 'createdAt',
isVisible: columnVisibility.createdAt,
},
{
header: 'By',
id: 'createdBy',
isVisible: columnVisibility.createdBy,
},
{
header: 'Last seen',
id: 'lastSeenAt',
isVisible: columnVisibility.lastSeenAt,
},
{
header: 'Lifecycle',
id: 'lifecycle',
isVisible: columnVisibility.lifecycle,
},
{
id: 'divider',
},
...environments.map((environment) => ({
header: environment,
id: formatEnvironmentColumnId(environment),
isVisible:
columnVisibility[
formatEnvironmentColumnId(environment)
],
})),
]}
onToggle={onToggle}
/>
);
};

View File

@ -0,0 +1,42 @@
import type { FC } from 'react';
import { Box } from '@mui/material';
import { Search } from 'component/common/Search/Search';
import useLoading from 'hooks/useLoading';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
interface IProjectFlagsSearchProps {
isLoading?: boolean;
searchQuery?: string;
onChangeSearchQuery?: (query: string) => void;
}
export const ProjectFlagsSearch: FC<IProjectFlagsSearchProps> = ({
isLoading,
searchQuery,
onChangeSearchQuery,
}) => {
const headerLoadingRef = useLoading(isLoading || false);
const { trackEvent } = usePlausibleTracker();
const handleSearch = (query: string) => {
onChangeSearchQuery?.(query);
trackEvent('search-bar', {
props: {
screen: 'project',
length: query.length,
},
});
};
return (
<Box ref={headerLoadingRef} aria-busy={isLoading} aria-live='polite'>
<Search
placeholder='Search'
expandable
initialValue={searchQuery || ''}
onChange={handleSearch}
hasFilters
id='projectFeatureFlags'
/>
</Box>
);
};

View File

@ -0,0 +1,2 @@
export const formatEnvironmentColumnId = (environment: string) =>
`environment:${environment}`;

View File

@ -1,212 +0,0 @@
import { useEffect, useState, type VFC } from 'react';
import {
IconButton,
ListItemIcon,
ListItemText,
MenuList,
Popover,
Tooltip,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import ColumnIcon from '@mui/icons-material/ViewWeek';
import CloseIcon from '@mui/icons-material/Close';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
StyledBoxContainer,
StyledBoxMenuHeader,
StyledCheckbox,
StyledDivider,
StyledIconButton,
StyledMenuItem,
} from './ColumnsMenu.styles';
interface IColumnsMenuProps {
allColumns: {
Header?: string | any;
id: string;
isVisible: boolean;
toggleHidden: (state: boolean) => void;
hideInMenu?: boolean;
}[];
staticColumns?: string[];
dividerBefore?: string[];
dividerAfter?: string[];
isCustomized?: boolean;
setHiddenColumns: (hiddenColumns: string[]) => void;
onCustomize?: () => void;
}
const columnNameMap: Record<string, string> = {
favorite: 'Favorite',
};
export const ColumnsMenu: VFC<IColumnsMenuProps> = ({
allColumns,
staticColumns = [],
dividerBefore = [],
dividerAfter = [],
isCustomized = false,
onCustomize,
setHiddenColumns,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const theme = useTheme();
const isTinyScreen = useMediaQuery(theme.breakpoints.down('sm'));
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
useEffect(() => {
if (isCustomized) {
return;
}
const setVisibleColumns = (
columns: string[],
environmentsToShow: number = 0,
) => {
const visibleEnvColumns = allColumns
.filter(({ id }) => id.startsWith('environment:') !== false)
.map(({ id }) => id)
.slice(0, environmentsToShow);
const hiddenColumns = allColumns
.map(({ id }) => id)
.filter((id) => !columns.includes(id))
.filter((id) => !staticColumns.includes(id))
.filter((id) => !visibleEnvColumns.includes(id));
setHiddenColumns(hiddenColumns);
};
if (isTinyScreen) {
return setVisibleColumns(['createdAt']);
}
if (isSmallScreen) {
return setVisibleColumns(['createdAt'], 1);
}
if (isMediumScreen) {
return setVisibleColumns(['type', 'createdAt'], 1);
}
setVisibleColumns(['lastSeenAt', 'type', 'createdAt'], 3);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTinyScreen, isSmallScreen, isMediumScreen]);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const isOpen = Boolean(anchorEl);
const id = `columns-menu`;
const menuId = `columns-menu-list-${id}`;
return (
<StyledBoxContainer>
<Tooltip title='Select columns' arrow describeChild>
<StyledIconButton
id={id}
aria-controls={isOpen ? menuId : undefined}
aria-haspopup='true'
aria-expanded={isOpen ? 'true' : undefined}
onClick={handleClick}
type='button'
size='large'
data-loading
>
<ColumnIcon />
</StyledIconButton>
</Tooltip>
<Popover
id={menuId}
open={isOpen}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
disableScrollLock={true}
PaperProps={{
sx: (theme) => ({
borderRadius: theme.shape.borderRadius,
paddingBottom: theme.spacing(2),
}),
}}
>
<StyledBoxMenuHeader>
<Typography variant='body2'>
<strong>Columns</strong>
</Typography>
<IconButton onClick={handleClose}>
<CloseIcon />
</IconButton>
</StyledBoxMenuHeader>
<MenuList>
{allColumns
.filter(({ hideInMenu }) => !hideInMenu)
.map((column) => [
<ConditionallyRender
condition={dividerBefore.includes(column.id)}
show={<StyledDivider />}
/>,
<StyledMenuItem
onClick={() => {
column.toggleHidden(column.isVisible);
onCustomize?.();
}}
disabled={staticColumns.includes(column.id)}
>
<ListItemIcon>
<StyledCheckbox
edge='start'
checked={column.isVisible}
disableRipple
inputProps={{
'aria-labelledby': column.id,
}}
size='medium'
/>
</ListItemIcon>
<ListItemText
id={column.id}
primary={
<Typography variant='body2'>
<ConditionallyRender
condition={Boolean(
typeof column.Header ===
'string' &&
column.Header,
)}
show={() => (
<>{column.Header}</>
)}
elseShow={() => (
<>
{columnNameMap[
column.id
] || column.id}
</>
)}
/>
</Typography>
}
/>
</StyledMenuItem>,
<ConditionallyRender
condition={dividerAfter.includes(column.id)}
show={<StyledDivider />}
/>,
])}
</MenuList>
</Popover>
</StyledBoxContainer>
);
};

View File

@ -74,7 +74,6 @@ export type UiFlags = {
outdatedSdksBanner?: boolean;
estimateTrafficDataCost?: boolean;
disableShowContextFieldSelectionValues?: boolean;
projectOverviewRefactorFeedback?: boolean;
featureLifecycle?: boolean;
manyStrategiesPagination?: boolean;
enableLegacyVariants?: boolean;