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

feat: adjust search page columns (#9722)

New columns for search page
- improved "name" with filtering by type and tag
- lifecycle
- created by (avatars) with filtering
This commit is contained in:
Tymoteusz Czech 2025-04-09 09:50:30 +02:00 committed by GitHub
parent e876e6438d
commit 5647fc916e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 281 additions and 119 deletions

View File

@ -90,13 +90,13 @@ const CappedDescription: FC<{ text: string; searchQuery: string }> = ({
placement='bottom-start'
arrow
>
<StyledDescription>
<StyledDescription data-loading>
<Highlighter search={searchQuery}>{text}</Highlighter>
</StyledDescription>
</HtmlTooltip>
}
elseShow={
<StyledDescription>
<StyledDescription data-loading>
<Highlighter search={searchQuery}>{text}</Highlighter>
</StyledDescription>
}

View File

@ -35,9 +35,9 @@ interface IFeatureLifecycleProps {
project: string;
name: string;
};
onComplete: () => void;
onUncomplete: () => void;
onArchive: () => void;
onComplete?: () => void;
onUncomplete?: () => void;
onArchive?: () => void;
}
export const FeatureLifecycleCell: VFC<IFeatureLifecycleProps> = ({

View File

@ -20,7 +20,10 @@ import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironmen
import { ExportDialog } from './ExportDialog';
import { useUiFlag } from 'hooks/useUiFlag';
import { focusable } from 'themes/themeStyles';
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import {
FeatureEnvironmentSeenCell,
FeatureLifecycleCell,
} from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import useToast from 'hooks/useToast';
import { FeatureToggleFilters } from './FeatureToggleFilters/FeatureToggleFilters';
import { withTableState } from 'utils/withTableState';
@ -29,18 +32,36 @@ import { FeatureSegmentCell } from 'component/common/Table/cells/FeatureSegmentC
import { FeatureToggleListActions } from './FeatureToggleListActions/FeatureToggleListActions';
import useLoading from 'hooks/useLoading';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useGlobalFeatureSearch } from './useGlobalFeatureSearch';
import {
useGlobalFeatureSearch,
useTableStateFilter,
} from './useGlobalFeatureSearch';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { LifecycleFilters } from './FeatureToggleFilters/LifecycleFilters';
import { ExportFlags } from './ExportFlags';
import { createFeatureOverviewCell } from 'component/common/Table/cells/FeatureOverviewCell/FeatureOverviewCell';
import { AvatarCell } from 'component/project/Project/PaginatedProjectFeatureToggles/AvatarCell';
export const featuresPlaceholder = Array(15).fill({
name: 'Name of the feature',
description: 'Short description of the feature',
type: '-',
createdAt: new Date(2022, 1, 1),
createdAt: new Date(2022, 1, 1).toISOString(),
project: 'projectID',
});
createdBy: {
id: 0,
name: 'admin',
imageUrl: '',
},
archivedAt: null,
favorite: false,
stale: false,
dependencyType: null,
tags: [],
environments: [],
impressionData: false,
segments: [],
} as FeatureSearchResponseSchema);
const columnHelper = createColumnHelper<FeatureSearchResponseSchema>();
@ -70,6 +91,22 @@ export const FeatureToggleListTable: FC = () => {
setTableState,
filterState,
} = useGlobalFeatureSearch();
const onFlagTypeClick = useTableStateFilter(
['type', 'IS'],
tableState,
setTableState,
);
const onTagClick = useTableStateFilter(
['tag', 'INCLUDE'],
tableState,
setTableState,
);
const onAvatarClick = useTableStateFilter(
['createdBy', 'IS'],
tableState,
setTableState,
);
const { projects } = useProjects();
const bodyLoadingRef = useLoading(loading);
const { favorite, unfavorite } = useFavoriteFeaturesApi();
@ -92,65 +129,147 @@ export const FeatureToggleListTable: FC = () => {
);
const columns = useMemo(
() => [
columnHelper.accessor('favorite', {
header: () => (
<FavoriteIconHeader
isActive={tableState.favoritesFirst}
onClick={() =>
setTableState({
favoritesFirst: !tableState.favoritesFirst,
})
}
/>
),
cell: ({ getValue, row }) => (
<>
<FavoriteIconCell
value={getValue()}
onClick={() => onFavorite(row.original)}
/>
</>
),
enableSorting: false,
meta: {
width: '1%',
},
}),
columnHelper.accessor('lastSeenAt', {
header: 'Seen',
cell: ({ row }) => (
<FeatureEnvironmentSeenCell feature={row.original} />
),
meta: {
align: 'center',
width: '1%',
},
}),
columnHelper.accessor('type', {
header: 'Type',
cell: ({ getValue }) => <FeatureTypeCell value={getValue()} />,
meta: {
align: 'center',
width: '1%',
},
}),
columnHelper.accessor('name', {
header: 'Name',
// cell: (cell) => <FeatureNameCell value={cell.row} />,
cell: ({ row }) => (
<LinkCell
title={row.original.name}
subtitle={row.original.description || undefined}
to={`/projects/${row.original.project}/features/${row.original.name}`}
/>
),
meta: {
width: '50%',
},
}),
...(!flagsReleaseManagementUIEnabled
() =>
flagsReleaseManagementUIEnabled
? [
columnHelper.accessor('favorite', {
header: () => (
<FavoriteIconHeader
isActive={tableState.favoritesFirst}
onClick={() =>
setTableState({
favoritesFirst:
!tableState.favoritesFirst,
})
}
/>
),
cell: ({ getValue, row }) => (
<FavoriteIconCell
value={getValue()}
onClick={() => onFavorite(row.original)}
/>
),
enableSorting: false,
meta: { width: 48 },
}),
columnHelper.accessor('name', {
header: 'Name',
cell: createFeatureOverviewCell(
onTagClick,
onFlagTypeClick,
),
meta: { width: '50%' },
}),
columnHelper.accessor('createdAt', {
header: 'Created',
cell: ({ getValue }) => (
<DateCell value={getValue()} />
),
meta: { width: '1%' },
}),
columnHelper.accessor('createdBy', {
id: 'createdBy',
header: 'By',
cell: AvatarCell(onAvatarClick),
meta: { width: '1%', align: 'center' },
enableSorting: false,
}),
columnHelper.accessor('lifecycle', {
id: 'lifecycle',
header: 'Lifecycle',
cell: ({ row: { original } }) => (
<FeatureLifecycleCell
feature={original}
data-loading
/>
),
enableSorting: false, // FIXME: enable sorting by lifecycle
size: 50,
meta: { align: 'center', width: '1%' },
}),
columnHelper.accessor('project', {
header: 'Project',
cell: ({ getValue }) => {
const projectId = getValue();
const projectName = projects.find(
(project) => project.id === projectId,
)?.name;
return (
<LinkCell
title={projectName || projectId}
to={`/projects/${projectId}`}
/>
);
},
}),
]
: [
columnHelper.accessor('favorite', {
header: () => (
<FavoriteIconHeader
isActive={tableState.favoritesFirst}
onClick={() =>
setTableState({
favoritesFirst:
!tableState.favoritesFirst,
})
}
/>
),
cell: ({ getValue, row }) => (
<>
<FavoriteIconCell
value={getValue()}
onClick={() => onFavorite(row.original)}
/>
</>
),
enableSorting: false,
meta: {
width: '1%',
},
}),
columnHelper.accessor('lastSeenAt', {
header: 'Seen',
cell: ({ row }) => (
<FeatureEnvironmentSeenCell
feature={row.original}
/>
),
meta: {
align: 'center',
width: '1%',
},
}),
columnHelper.accessor('type', {
header: 'Type',
cell: ({ getValue }) => (
<FeatureTypeCell value={getValue()} />
),
meta: {
align: 'center',
width: '1%',
},
}),
columnHelper.accessor('name', {
header: 'Name',
cell: ({ row }) => (
<LinkCell
title={row.original.name}
subtitle={
row.original.description || undefined
}
to={`/projects/${row.original.project}/features/${row.original.name}`}
/>
),
meta: {
width: '50%',
},
}),
columnHelper.accessor(
(row) => row.segments?.join('\n') || '',
{
@ -181,42 +300,30 @@ export const FeatureToggleListTable: FC = () => {
},
},
),
]
: ([] as never[])),
columnHelper.accessor('createdAt', {
header: 'Created',
cell: ({ getValue }) => <DateCell value={getValue()} />,
meta: {
width: '1%',
},
}),
columnHelper.accessor('project', {
header: flagsReleaseManagementUIEnabled
? 'Project'
: 'Project ID',
cell: ({ getValue }) => {
const value = getValue();
const project = projects.find(
(project) => project.id === value,
);
return (
<LinkCell
title={
flagsReleaseManagementUIEnabled
? project?.name || value
: value
}
to={`/projects/${getValue()}`}
/>
);
},
meta: {
width: '1%',
},
}),
...(!flagsReleaseManagementUIEnabled
? [
columnHelper.accessor('createdAt', {
header: 'Created',
cell: ({ getValue }) => (
<DateCell value={getValue()} />
),
meta: {
width: '1%',
},
}),
columnHelper.accessor('project', {
header: 'Project ID',
cell: ({ getValue }) => {
const value = getValue();
return (
<LinkCell
title={value}
to={`/projects/${value}`}
/>
);
},
meta: {
width: '1%',
},
}),
columnHelper.accessor('stale', {
header: 'State',
cell: ({ getValue }) => (
@ -226,12 +333,9 @@ export const FeatureToggleListTable: FC = () => {
width: '1%',
},
}),
]
: ([] as never[])),
],
],
[tableState.favoritesFirst],
);
const data = useMemo(
() =>
features?.length === 0 && loading ? featuresPlaceholder : features,

View File

@ -1,3 +1,4 @@
import { useCallback } from 'react';
import {
encodeQueryParams,
NumberParam,
@ -32,6 +33,7 @@ export const useGlobalFeatureSearch = (pageLimit = DEFAULT_PAGE_LIMIT) => {
createdAt: FilterItemParam,
type: FilterItemParam,
lifecycle: FilterItemParam,
createdBy: FilterItemParam,
};
const [tableState, setTableState] = usePersistentTableState(
`${storageKey}`,
@ -71,3 +73,57 @@ export const useGlobalFeatureSearch = (pageLimit = DEFAULT_PAGE_LIMIT) => {
filterState,
};
};
// TODO: refactor
// This is similar to `useProjectFeatureSearchActions`, but more generic
// Reuse wasn't possible because the prior one is constrained to the project hook
export const useTableStateFilter = <K extends string>(
[key, operator]: [K, string],
state:
| Record<
K,
| {
operator: string;
values: string[];
}
| undefined
| null
>
| undefined
| null,
setState: (state: {
[key: string]: {
operator: string;
values: string[];
};
}) => void,
) =>
useCallback(
(value: string | number) => {
const currentState = state ? state[key] : undefined;
console.log({ key, operator, state: currentState, value });
if (
currentState &&
currentState.values.length > 0 &&
!currentState.values.includes(`${value}`)
) {
setState({
...state,
[key]: {
operator: currentState.operator,
values: [...currentState.values, value],
},
});
} else if (!currentState) {
setState({
...state,
[key]: {
operator: operator,
values: [value],
},
});
}
},
[state, setState, key, operator],
);

View File

@ -19,9 +19,9 @@ export interface LifecycleFeature {
}
export const FeatureLifecycle: FC<{
onArchive: () => void;
onComplete: () => void;
onUncomplete: () => void;
onArchive?: () => void;
onComplete?: () => void;
onUncomplete?: () => void;
feature: LifecycleFeature;
}> = ({ feature, onComplete, onUncomplete, onArchive }) => {
const currentStage = populateCurrentStage(feature);
@ -30,7 +30,7 @@ export const FeatureLifecycle: FC<{
const onUncompleteHandler = async () => {
await markFeatureUncompleted(feature.name, feature.project);
onUncomplete();
onUncomplete?.();
trackEvent('feature-lifecycle', {
props: {
eventType: 'uncomplete',
@ -44,7 +44,7 @@ export const FeatureLifecycle: FC<{
project={feature.project}
onArchive={onArchive}
onComplete={onComplete}
onUncomplete={onUncompleteHandler}
onUncomplete={onUncomplete ? onUncompleteHandler : undefined}
loading={loading}
>
<FeatureLifecycleStageIcon stage={currentStage} />

View File

@ -348,9 +348,9 @@ export const FeatureLifecycleTooltip: FC<{
children: React.ReactElement<any, any>;
stage: LifecycleStage;
project: string;
onArchive: () => void;
onComplete: () => void;
onUncomplete: () => void;
onArchive?: () => void;
onComplete?: () => void;
onUncomplete?: () => void;
loading: boolean;
}> = ({
children,
@ -399,7 +399,7 @@ export const FeatureLifecycleTooltip: FC<{
{stage.name !== 'archived' ? (
<StyledFooter>
<EnvironmentsInfo stage={stage} />
{stage.name === 'live' && (
{stage.name === 'live' && onComplete ? (
<LiveStageAction
onComplete={onComplete}
loading={loading}
@ -409,8 +409,10 @@ export const FeatureLifecycleTooltip: FC<{
environments={stage.environments!}
/>
</LiveStageAction>
)}
{stage.name === 'completed' && (
) : null}
{stage.name === 'completed' &&
onArchive &&
onUncomplete ? (
<CompletedStageDescription
environments={stage.environments!}
onArchive={onArchive}
@ -418,7 +420,7 @@ export const FeatureLifecycleTooltip: FC<{
loading={loading}
project={project}
/>
)}
) : null}
</StyledFooter>
) : null}
</Box>