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

feat: improve flag filters on project page (#10705)

This commit is contained in:
Tymoteusz Czech 2025-10-01 10:11:02 +02:00 committed by GitHub
parent c12aca72db
commit 9d996f14d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 372 additions and 192 deletions

View File

@ -37,7 +37,6 @@ interface ILifecycleFiltersBaseProps {
const Wrapper = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
minHeight: theme.spacing(7),
gap: theme.spacing(2),
}));

View File

@ -20,7 +20,7 @@ import {
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useFeatureToggleSwitch } from '../ProjectFeatureToggles/FeatureToggleSwitch/useFeatureToggleSwitch.tsx';
import useLoading from 'hooks/useLoading';
import { ProjectFeatureTogglesHeader } from './ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx';
import { ProjectFeatureTogglesHeader as LegacyProjectFeatureTogglesHeader } from './ProjectFeatureTogglesHeader/LegacyProjectFeatureTogglesHeader.tsx';
import { createColumnHelper, useReactTable } from '@tanstack/react-table';
import { withTableState } from 'utils/withTableState';
import type { FeatureSearchResponseSchema } from 'openapi';
@ -41,7 +41,7 @@ import {
useProjectFeatureSearchActions,
} from './useProjectFeatureSearch.ts';
import { AvatarCell } from './AvatarCell.tsx';
import { styled } from '@mui/material';
import { styled, useMediaQuery, useTheme } from '@mui/material';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { ConnectSdkDialog } from '../../../onboarding/dialog/ConnectSdkDialog.tsx';
import { ProjectOnboarding } from '../../../onboarding/flow/ProjectOnboarding.tsx';
@ -57,6 +57,9 @@ import { IMPORT_BUTTON } from 'utils/testIds';
import { ProjectCleanupReminder } from './ProjectCleanupReminder/ProjectCleanupReminder.tsx';
import { formatEnvironmentColumnId } from './formatEnvironmentColumnId.ts';
import { ProjectFeaturesColumnsMenu } from './ProjectFeaturesColumnsMenu/ProjectFeaturesColumnsMenu.tsx';
import { useUiFlag } from 'hooks/useUiFlag.ts';
import { ProjectFeatureTogglesHeader } from './ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx';
import { ProjectFlagsSearch } from './ProjectFlagsSearch/ProjectFlagsSearch.tsx';
type ProjectFeatureTogglesProps = {
environments: string[];
@ -71,12 +74,27 @@ const Container = styled('div')(({ theme }) => ({
gap: theme.spacing(2),
}));
const FilterRow = styled('div')(({ theme }) => ({
const LegacyFilterRow = styled('div')(({ theme }) => ({
display: 'flex',
flexFlow: 'row wrap',
justifyContent: 'space-between',
}));
const FiltersContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
padding: theme.spacing(2, 3, 2),
[theme.breakpoints.down('md')]: {
padding: theme.spacing(2, 2),
},
}));
const FilterRow = styled('div')({
display: 'flex',
alignItems: 'center',
});
const ButtonGroup = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
@ -91,6 +109,9 @@ export const ProjectFeatureToggles = ({
const { project } = useProjectOverview(projectId);
const [connectSdkOpen, setConnectSdkOpen] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const flagsUiFilterRefactorEnabled = useUiFlag('flagsUiFilterRefactor');
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const {
features,
@ -500,25 +521,32 @@ export const ProjectFeatureToggles = ({
) : null}
<PageContent
disableLoading
disablePadding
header={
<ProjectFeatureTogglesHeader
isLoading={initialLoad}
totalItems={total}
searchQuery={tableState.query || ''}
onChangeSearchQuery={(query) => {
setTableState({ query });
}}
dataToExport={data}
environmentsToExport={environments}
actions={
<ProjectFeaturesColumnsMenu
columnVisibility={columnVisibility}
environments={environments}
onToggle={onToggleColumnVisibility}
/>
}
/>
flagsUiFilterRefactorEnabled ? (
<ProjectFeatureTogglesHeader
isLoading={initialLoad}
totalItems={total}
environmentsToExport={environments}
/>
) : (
<LegacyProjectFeatureTogglesHeader
isLoading={initialLoad}
totalItems={total}
searchQuery={tableState.query || ''}
onChangeSearchQuery={(query) => {
setTableState({ query });
}}
dataToExport={data}
environmentsToExport={environments}
actions={
<ProjectFeaturesColumnsMenu
columnVisibility={columnVisibility}
environments={environments}
onToggle={onToggleColumnVisibility}
/>
}
/>
)
}
bodyClass='noop'
style={{ cursor: 'inherit' }}
@ -528,31 +556,74 @@ export const ProjectFeatureToggles = ({
aria-busy={isPlaceholder}
aria-live='polite'
>
<FilterRow>
<ProjectOverviewFilters
project={projectId}
onChange={setTableState}
state={filterState}
/>
<ProjectLifecycleFilters
projectId={projectId}
state={filterState}
onChange={setTableState}
total={loading ? undefined : total}
/>
<ButtonGroup>
<PermissionIconButton
permission={UPDATE_FEATURE}
{flagsUiFilterRefactorEnabled ? (
<FiltersContainer>
<FilterRow>
<ProjectLifecycleFilters
projectId={projectId}
state={filterState}
onChange={setTableState}
total={loading ? undefined : total}
/>
{isSmallScreen ? null : (
<ProjectFlagsSearch
searchQuery={tableState.query || ''}
onChangeSearchQuery={(query) => {
setTableState({ query });
}}
isLoading={loading}
/>
)}
<ProjectFeaturesColumnsMenu
columnVisibility={columnVisibility}
environments={environments}
onToggle={onToggleColumnVisibility}
/>
</FilterRow>
<FilterRow>
<ProjectOverviewFilters
project={projectId}
onChange={setTableState}
state={filterState}
/>
</FilterRow>
{isSmallScreen ? (
<ProjectFlagsSearch
searchQuery={tableState.query || ''}
onChangeSearchQuery={(query) => {
setTableState({ query });
}}
isLoading={loading}
/>
) : null}
</FiltersContainer>
) : (
<LegacyFilterRow>
<ProjectOverviewFilters
project={projectId}
onChange={setTableState}
state={filterState}
/>
<ProjectLifecycleFilters
projectId={projectId}
onClick={() => setModalOpen(true)}
tooltipProps={{ title: 'Import' }}
data-testid={IMPORT_BUTTON}
data-loading-project
>
<ImportSvg />
</PermissionIconButton>
</ButtonGroup>
</FilterRow>
state={filterState}
onChange={setTableState}
total={loading ? undefined : total}
/>
<ButtonGroup>
<PermissionIconButton
permission={UPDATE_FEATURE}
projectId={projectId}
onClick={() => setModalOpen(true)}
tooltipProps={{ title: 'Import' }}
data-testid={IMPORT_BUTTON}
data-loading-project
>
<ImportSvg />
</PermissionIconButton>
</ButtonGroup>
</LegacyFilterRow>
)}
<SearchHighlightProvider value={tableState.query || ''}>
<PaginatedTable
tableInstance={table}

View File

@ -0,0 +1,165 @@
import { type ReactNode, type FC, useState } from 'react';
import {
Box,
Button,
IconButton,
Tooltip,
useMediaQuery,
useTheme,
} from '@mui/material';
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 { useRequiredPathParam } from 'hooks/useRequiredPathParam';
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 IosShare from '@mui/icons-material/IosShare';
import { FlagCreationButton } from './FlagCreationButton/FlagCreationButton.tsx';
interface IProjectFeatureTogglesHeaderProps {
isLoading?: boolean;
totalItems?: number;
searchQuery?: string;
onChangeSearchQuery?: (query: string) => void;
dataToExport?: Pick<FeatureSchema, 'name'>[];
environmentsToExport?: string[];
actions?: ReactNode;
}
/**
* @deprecated remove with `flagsUiFilterRefactor` flag
*/
export const ProjectFeatureTogglesHeader: FC<
IProjectFeatureTogglesHeaderProps
> = ({
isLoading,
totalItems,
searchQuery,
onChangeSearchQuery,
environmentsToExport,
actions,
}) => {
const projectId = useRequiredPathParam('projectId');
const headerLoadingRef = useLoading(isLoading || false);
const [showTitle, setShowTitle] = useState(true);
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [showExportDialog, setShowExportDialog] = useState(false);
const { trackEvent } = usePlausibleTracker();
const projectOverviewRefactorFeedback = false;
const { openFeedback } = useFeedback('newProjectOverview', 'automatic');
const handleSearch = (query: string) => {
onChangeSearchQuery?.(query);
trackEvent('search-bar', {
props: {
screen: 'project',
length: query.length,
},
});
};
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} aria-busy={isLoading} aria-live='polite'>
<PageHeader
titleElement={
showTitle
? `Feature flags ${
totalItems !== undefined ? `(${totalItems})` : ''
}`
: null
}
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<Search
data-loading
placeholder='Search and Filter'
expandable
initialValue={searchQuery || ''}
onChange={handleSearch}
onFocus={() => setShowTitle(false)}
onBlur={() => setShowTitle(true)}
hasFilters
id='projectFeatureFlags'
/>
}
/>
{actions}
<PageHeader.Divider sx={{ marginLeft: 0 }} />
<Tooltip title='Export all project flags' arrow>
<IconButton
data-loading
onClick={() => setShowExportDialog(true)}
sx={(theme) => ({
marginRight: theme.spacing(2),
})}
>
<IosShare />
</IconButton>
</Tooltip>
<ConditionallyRender
condition={!isLoading}
show={
<ExportDialog
showExportDialog={showExportDialog}
project={projectId}
data={[]}
onClose={() => setShowExportDialog(false)}
environments={environmentsToExport || []}
/>
}
/>
{/* FIXME: remove */}
<ConditionallyRender
condition={
projectOverviewRefactorFeedback &&
!isSmallScreen
}
show={
<Button
startIcon={<ReviewsOutlined />}
onClick={createFeedbackContext}
variant='outlined'
data-loading
>
Provide feedback
</Button>
}
/>
<FlagCreationButton isLoading={isLoading} />
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchQuery || ''}
onChange={handleSearch}
hasFilters
id='projectFeatureFlags'
/>
}
/>
</PageHeader>
</Box>
);
};

View File

@ -1,136 +1,59 @@
import { type ReactNode, type FC, useState } from 'react';
import {
Box,
IconButton,
Tooltip,
useMediaQuery,
useTheme,
} from '@mui/material';
import { type FC, useState } from 'react';
import { Box, IconButton, Tooltip } from '@mui/material';
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 { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
import type { FeatureSchema } from 'openapi';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import IosShare from '@mui/icons-material/IosShare';
import { FlagCreationButton } from './FlagCreationButton/FlagCreationButton.tsx';
import { ImportButton } from './ImportButton/ImportButton.tsx';
interface IProjectFeatureTogglesHeaderProps {
type ProjectFeatureTogglesHeaderProps = {
isLoading?: boolean;
totalItems?: number;
searchQuery?: string;
onChangeSearchQuery?: (query: string) => void;
dataToExport?: Pick<FeatureSchema, 'name'>[];
environmentsToExport?: string[];
actions?: ReactNode;
}
};
export const ProjectFeatureTogglesHeader: FC<
IProjectFeatureTogglesHeaderProps
> = ({
isLoading,
totalItems,
searchQuery,
onChangeSearchQuery,
environmentsToExport,
actions,
}) => {
ProjectFeatureTogglesHeaderProps
> = ({ isLoading, totalItems, environmentsToExport }) => {
const projectId = useRequiredPathParam('projectId');
const headerLoadingRef = useLoading(isLoading || false);
const [showTitle, setShowTitle] = useState(true);
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [showExportDialog, setShowExportDialog] = useState(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'
sx={(theme) => ({
padding: `${theme.spacing(2.5)} ${theme.spacing(3.125)}`,
})}
>
<Box ref={headerLoadingRef} aria-busy={isLoading} aria-live='polite'>
<PageHeader
titleElement={
showTitle
? `Feature flags ${
totalItems !== undefined ? `(${totalItems})` : ''
}`
: null
}
titleElement={`Feature flags ${
totalItems !== undefined ? `(${totalItems})` : ''
}`}
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<Search
data-loading
placeholder='Search and Filter'
expandable
initialValue={searchQuery || ''}
onChange={handleSearch}
onFocus={() => setShowTitle(false)}
onBlur={() => setShowTitle(true)}
hasFilters
id='projectFeatureFlags'
/>
}
/>
{actions}
<PageHeader.Divider sx={{ marginLeft: 0 }} />
<Tooltip title='Export all project flags' arrow>
<IconButton
data-loading
onClick={() => setShowExportDialog(true)}
sx={(theme) => ({
marginRight: theme.spacing(2),
})}
>
<IosShare />
</IconButton>
</Tooltip>
<ImportButton />
<ConditionallyRender
condition={!isLoading}
show={
<ExportDialog
showExportDialog={showExportDialog}
project={projectId}
data={[]}
onClose={() => setShowExportDialog(false)}
environments={environmentsToExport || []}
/>
}
/>
<FlagCreationButton isLoading={isLoading} />
{!isLoading ? (
<ExportDialog
showExportDialog={showExportDialog}
project={projectId}
data={[]}
onClose={() => setShowExportDialog(false)}
environments={environmentsToExport || []}
/>
) : null}
<Box>
<FlagCreationButton isLoading={isLoading} />
</Box>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchQuery || ''}
onChange={handleSearch}
hasFilters
id='projectFeatureFlags'
/>
}
/>
</PageHeader>
/>
</Box>
);
};

View File

@ -1,4 +1,5 @@
import type { FC } from 'react';
import { Box } from '@mui/material';
import { ColumnsMenu } from '../ColumnsMenu/ColumnsMenu.tsx';
import { formatEnvironmentColumnId } from '../formatEnvironmentColumnId.ts';
@ -12,47 +13,49 @@ 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}
/>
<Box sx={(theme) => ({ marginLeft: theme.spacing(1) })}>
<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}
/>
</Box>
);
};

View File

@ -4,6 +4,7 @@ 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';
import { useUiFlag } from 'hooks/useUiFlag.ts';
type ProjectLifecycleFiltersProps = {
projectId: string;
@ -23,6 +24,7 @@ export const ProjectLifecycleFilters: FC<ProjectLifecycleFiltersProps> = ({
const { data } = useProjectStatus(projectId);
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const flagsUiFilterRefactorEnabled = useUiFlag('flagsUiFilterRefactor');
const lifecycleSummary = Object.entries(
data?.lifecycleSummary || {},
).reduce(
@ -48,7 +50,13 @@ export const ProjectLifecycleFilters: FC<ProjectLifecycleFiltersProps> = ({
<Box
sx={{
marginRight: 'auto',
margin: isSmallScreen ? theme.spacing(0, 2) : '0 auto 0 0',
...(!flagsUiFilterRefactorEnabled
? {
margin: isSmallScreen
? theme.spacing(0, 3)
: `${theme.spacing(1.5)} auto 0 0`,
}
: {}),
}}
>
<LifecycleFilters

View File

@ -7,6 +7,8 @@ import {
} from 'component/filter/Filters/Filters';
import { useProjectFlagCreators } from 'hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators';
import { formatTag } from 'utils/format-tag';
import { styled } from '@mui/material';
import { useUiFlag } from 'hooks/useUiFlag';
type ProjectOverviewFiltersProps = {
state: FilterItemParamHolder;
@ -14,6 +16,10 @@ type ProjectOverviewFiltersProps = {
project: string;
};
const StyledFilters = styled(Filters)({
padding: 0,
});
export const ProjectOverviewFilters: FC<ProjectOverviewFiltersProps> = ({
state,
onChange,
@ -22,6 +28,10 @@ export const ProjectOverviewFilters: FC<ProjectOverviewFiltersProps> = ({
const { tags } = useAllTags();
const { flagCreators } = useProjectFlagCreators(project);
const [availableFilters, setAvailableFilters] = useState<IFilterItem[]>([]);
const flagsUiFilterRefactorEnabled = useUiFlag('flagsUiFilterRefactor');
const FilterComponent = flagsUiFilterRefactorEnabled
? StyledFilters
: Filters;
useEffect(() => {
const tagsOptions = (tags || []).map((tag) => {
@ -124,7 +134,7 @@ export const ProjectOverviewFilters: FC<ProjectOverviewFiltersProps> = ({
}, [JSON.stringify(tags), JSON.stringify(flagCreators)]);
return (
<Filters
<FilterComponent
availableFilters={availableFilters}
state={state}
onChange={onChange}

View File

@ -88,6 +88,7 @@ export type UiFlags = {
lifecycleGraphs?: boolean;
newStrategyModal?: boolean;
globalChangeRequestList?: boolean;
flagsUiFilterRefactor?: boolean;
};
export interface IVersionInfo {