1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

chore: remove featureSearchFrontend flag (#6066)

This commit is contained in:
Jaanus Sellin 2024-01-31 09:22:26 +02:00 committed by GitHub
parent b2c127c058
commit c6a2303026
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 24 additions and 1252 deletions

View File

@ -14,8 +14,7 @@ describe('project overview', () => {
const featureToggleName = `${featureTogglePrefix}-${randomId}`;
const projectName = `unleash-e2e-project-overview-${randomId}`;
const baseUrl = Cypress.config().baseUrl;
const selectAll =
'[title="Toggle All Rows Selected"] input[type="checkbox"]';
const selectAll = '[title="Select all rows"] input[type="checkbox"]';
before(() => {
cy.runBefore();

View File

@ -35,12 +35,6 @@ const setupNoFeaturesReturned = () =>
});
const setupApi = (features: APIFeature[], projects: APIProject[]) => {
testServerRoute(server, '/api/admin/ui-config', {
flags: {
featureSearchFrontend: true,
},
});
testServerRoute(server, '/api/admin/projects', {
projects,
});
@ -146,6 +140,6 @@ test('Filter table by project', async () => {
'No feature toggles found matching your criteria. Get started by adding a new feature toggle.',
);
expect(window.location.href).toContain(
'?sort=createdAt&order=desc&offset=0&columns=&project=IS%3Aproject-b',
'?offset=0&columns=&project=IS%3Aproject-b',
);
}, 10000);

View File

@ -50,7 +50,6 @@ import { usePersistentTableState } from 'hooks/usePersistentTableState';
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
import { FeatureSegmentCell } from 'component/common/Table/cells/FeatureSegmentCell/FeatureSegmentCell';
import { useUiFlag } from 'hooks/useUiFlag';
import { FeatureToggleListTable as LegacyFeatureToggleListTable } from './LegacyFeatureToggleListTable';
import { FeatureToggleListActions } from './FeatureToggleListActions/FeatureToggleListActions';
import useLoading from 'hooks/useLoading';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
@ -68,7 +67,7 @@ export const featuresPlaceholder = Array(15).fill({
const columnHelper = createColumnHelper<FeatureSearchResponseSchema>();
const feedbackCategory = 'search';
const FeatureToggleListTableComponent: VFC = () => {
export const FeatureToggleListTable: VFC = () => {
const theme = useTheme();
const { openFeedback } = useFeedback(feedbackCategory, 'automatic');
const { trackEvent } = usePlausibleTracker();
@ -428,11 +427,3 @@ const FeatureToggleListTableComponent: VFC = () => {
</PageContent>
);
};
export const FeatureToggleListTable: VFC = () => {
const featureSearchFrontend = useUiFlag('featureSearchFrontend');
if (featureSearchFrontend) return <FeatureToggleListTableComponent />;
return <LegacyFeatureToggleListTable />;
};

View File

@ -1,413 +0,0 @@
import { useCallback, useEffect, useMemo, useState, VFC } from 'react';
import {
IconButton,
Link,
Tooltip,
useMediaQuery,
useTheme,
} from '@mui/material';
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { createLocalStorage } from 'utils/createLocalStorage';
import { FeatureSchema } from 'openapi';
import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton';
import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell';
import { useSearch } from 'hooks/useSearch';
import { Search } from 'component/common/Search/Search';
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
import { usePinnedFavorites } from 'hooks/usePinnedFavorites';
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader';
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import FileDownload from '@mui/icons-material/FileDownload';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import { ExportDialog } from './ExportDialog';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { focusable } from 'themes/themeStyles';
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import useToast from 'hooks/useToast';
export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
name: 'Name of the feature',
description: 'Short description of the feature',
type: '-',
createdAt: new Date(2022, 1, 1),
project: 'projectID',
});
export type PageQueryType = Partial<
Record<'sort' | 'order' | 'search' | 'favorites', string>
>;
const defaultSort: SortingRule<string> = { id: 'createdAt', desc: true };
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
'FeatureToggleListTable:v1',
defaultSort,
);
/**
* @deprecated remove with flag `featureSearchFrontend`
*/
export const FeatureToggleListTable: VFC = () => {
const theme = useTheme();
const { environments } = useEnvironments();
const enabledEnvironments = environments
.filter((env) => env.enabled)
.map((env) => env.name);
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const [showExportDialog, setShowExportDialog] = useState(false);
const { features = [], loading, refetchFeatures } = useFeatures();
const [searchParams, setSearchParams] = useSearchParams();
const { setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const [initialState] = useState(() => ({
sortBy: [
{
id: searchParams.get('sort') || storedParams.id,
desc: searchParams.has('order')
? searchParams.get('order') === 'desc'
: storedParams.desc,
},
],
hiddenColumns: ['description'],
globalFilter: searchParams.get('search') || '',
}));
const { value: globalStore, setValue: setGlobalStore } =
useGlobalLocalStorage();
const { isFavoritesPinned, sortTypes, onChangeIsFavoritePinned } =
usePinnedFavorites(
searchParams.has('favorites')
? searchParams.get('favorites') === 'true'
: globalStore.favorites,
);
const [searchValue, setSearchValue] = useState(initialState.globalFilter);
const { favorite, unfavorite } = useFavoriteFeaturesApi();
const onFavorite = useCallback(
async (feature: any) => {
try {
if (feature?.favorite) {
await unfavorite(feature.project, feature.name);
} else {
await favorite(feature.project, feature.name);
}
refetchFeatures();
} catch (error) {
setToastApiError(
'Something went wrong, could not update favorite',
);
}
},
[favorite, refetchFeatures, unfavorite, setToastApiError],
);
const columns = useMemo(
() => [
{
Header: (
<FavoriteIconHeader
isActive={isFavoritesPinned}
onClick={onChangeIsFavoritePinned}
/>
),
accessor: 'favorite',
Cell: ({ row: { original: feature } }: any) => (
<FavoriteIconCell
value={feature?.favorite}
onClick={() => onFavorite(feature)}
/>
),
maxWidth: 50,
disableSortBy: true,
},
{
Header: 'Seen',
accessor: 'lastSeenAt',
Cell: ({ value, row: { original: feature } }: any) => {
return <FeatureEnvironmentSeenCell feature={feature} />;
},
align: 'center',
maxWidth: 80,
},
{
Header: 'Type',
accessor: 'type',
Cell: FeatureTypeCell,
align: 'center',
maxWidth: 85,
},
{
Header: 'Name',
accessor: 'name',
minWidth: 150,
Cell: FeatureNameCell,
sortType: 'alphanumeric',
searchable: true,
},
{
id: 'tags',
Header: 'Tags',
accessor: (row: FeatureSchema) =>
row.tags
?.map(({ type, value }) => `${type}:${value}`)
.join('\n') || '',
Cell: FeatureTagCell,
width: 80,
searchable: true,
},
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
maxWidth: 150,
},
{
Header: 'Project ID',
accessor: 'project',
Cell: ({ value }: { value: string }) => (
<LinkCell title={value} to={`/projects/${value}`} />
),
sortType: 'alphanumeric',
maxWidth: 150,
filterName: 'project',
searchable: true,
},
{
Header: 'State',
accessor: 'stale',
Cell: FeatureStaleCell,
sortType: 'boolean',
maxWidth: 120,
filterName: 'state',
filterParsing: (value: any) => (value ? 'stale' : 'active'),
},
// Always hidden -- for search
{
accessor: 'description',
Header: 'Description',
searchable: true,
},
],
[isFavoritesPinned],
);
const {
data: searchedData,
getSearchText,
getSearchContext,
} = useSearch(columns, searchValue, features);
const data = useMemo(
() =>
searchedData?.length === 0 && loading
? featuresPlaceholder
: searchedData,
[searchedData, loading],
);
const {
headerGroups,
rows,
prepareRow,
state: { sortBy },
setHiddenColumns,
} = useTable(
{
columns: columns as any[],
data,
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
},
useSortBy,
useFlexLayout,
);
useConditionallyHiddenColumns(
[
{
condition: !features.some(({ tags }) => tags?.length),
columns: ['tags'],
},
{
condition: isSmallScreen,
columns: ['type', 'createdAt', 'tags'],
},
{
condition: isMediumScreen,
columns: ['lastSeenAt', 'stale'],
},
],
setHiddenColumns,
columns,
);
useEffect(() => {
const tableState: PageQueryType = {};
tableState.sort = sortBy[0].id;
if (sortBy[0].desc) {
tableState.order = 'desc';
}
if (searchValue) {
tableState.search = searchValue;
}
if (isFavoritesPinned) {
tableState.favorites = 'true';
}
setSearchParams(tableState, {
replace: true,
});
setStoredParams({
id: sortBy[0].id,
desc: sortBy[0].desc || false,
});
setGlobalStore((params) => ({
...params,
favorites: Boolean(isFavoritesPinned),
}));
}, [sortBy, searchValue, setSearchParams, isFavoritesPinned]);
if (!(environments.length > 0)) {
return null;
}
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Feature toggles (${
rows.length < data.length
? `${rows.length} of ${data.length}`
: data.length
})`}
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<>
<Search
placeholder='Search and Filter'
expandable
initialValue={searchValue}
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
/>
<PageHeader.Divider />
</>
}
/>
<Link
component={RouterLink}
to='/archive'
underline='always'
sx={{ marginRight: 2, ...focusable(theme) }}
>
View archive
</Link>
<ConditionallyRender
condition={Boolean(
uiConfig?.flags?.featuresExportImport,
)}
show={
<Tooltip
title='Export current selection'
arrow
>
<IconButton
onClick={() =>
setShowExportDialog(true)
}
sx={(theme) => ({
marginRight: theme.spacing(2),
})}
>
<FileDownload />
</IconButton>
</Tooltip>
}
/>
<CreateFeatureButton
loading={false}
filter={{ query: '', project: 'default' }}
/>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
/>
}
/>
</PageHeader>
}
>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No feature toggles found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No feature toggles available. Get started by
adding a new feature toggle.
</TablePlaceholder>
}
/>
}
/>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.featuresExportImport)}
show={
<ExportDialog
showExportDialog={showExportDialog}
data={data}
onClose={() => setShowExportDialog(false)}
environments={enabledEnvironments}
/>
}
/>
</PageContent>
);
};

View File

@ -157,7 +157,6 @@ const Header: VFC = () => {
const [configRef, setConfigRef] = useState<HTMLButtonElement | null>(null);
const disableNotifications = useUiFlag('disableNotifications');
const hasSearch = useUiFlag('featureSearchFrontend');
const { uiConfig, isOss } = useUiConfig();
const smallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [openDrawer, setOpenDrawer] = useState(false);
@ -249,17 +248,7 @@ const Header: VFC = () => {
<StyledNav>
<StyledLinks>
<StyledLink to='/projects'>Projects</StyledLink>
<ConditionallyRender
condition={hasSearch}
show={
<StyledLink to={'/search'}>Search</StyledLink>
}
elseShow={
<StyledLink to={'/features'}>
Feature toggles
</StyledLink>
}
/>
<StyledLink to={'/search'}>Search</StyledLink>
<StyledLink to='/playground'>Playground</StyledLink>
<StyledAdvancedNavButton
onClick={(e) => setConfigRef(e.currentTarget)}

View File

@ -119,17 +119,6 @@ exports[`returns all baseRoutes 1`] = `
"menu": {
"mobile": true,
},
"notFlag": "featureSearchFrontend",
"path": "/features",
"title": "Feature toggles",
"type": "protected",
},
{
"component": [Function],
"flag": "featureSearchFrontend",
"menu": {
"mobile": true,
},
"path": "/search",
"title": "Search",
"type": "protected",

View File

@ -5,6 +5,6 @@ test('returns all baseRoutes', () => {
});
test('getRoute() returns named route', () => {
const featuresRoute = getRoute('/features');
expect(featuresRoute?.path).toEqual('/features');
const featuresRoute = getRoute('/search');
expect(featuresRoute?.path).toEqual('/search');
});

View File

@ -142,21 +142,12 @@ export const routes: IRoute[] = [
type: 'protected',
menu: {},
},
{
path: '/features',
title: 'Feature toggles',
component: FeatureToggleListTable,
type: 'protected',
menu: { mobile: true },
notFlag: 'featureSearchFrontend',
},
{
path: '/search',
title: 'Search',
component: FeatureToggleListTable,
type: 'protected',
menu: { mobile: true },
flag: 'featureSearchFrontend',
},
// Playground

View File

@ -64,7 +64,7 @@ const formatEnvironmentColumnId = (environment: string) =>
const columnHelper = createColumnHelper<FeatureSearchResponseSchema>();
const getRowId = (row: { name: string }) => row.name;
export const PaginatedProjectFeatureToggles = ({
export const ProjectFeatureToggles = ({
environments,
refreshInterval = 15 * 1000,
storageKey = 'project-feature-toggles-v2',

View File

@ -4,8 +4,8 @@ import { flexRow } from 'themes/themeStyles';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import VariantsWarningTooltip from 'component/feature/FeatureView/FeatureVariants/VariantsTooltipWarning';
import { FeatureToggleSwitch } from './FeatureToggleSwitch';
import type { ListItemType } from '../ProjectFeatureToggles.types';
import type { UseFeatureToggleSwitchType } from './FeatureToggleSwitch.types';
import { ListItemType } from '../../PaginatedProjectFeatureToggles/ProjectFeatureToggles.types';
const StyledSwitchContainer = styled('div', {
shouldForwardProp: (prop) => prop !== 'hasWarning',

View File

@ -1,700 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Checkbox,
IconButton,
styled,
Tooltip,
useMediaQuery,
useTheme,
Box,
} from '@mui/material';
import { Add } from '@mui/icons-material';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
SortingRule,
useFlexLayout,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
import type { FeatureSchema } from 'openapi';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { PageContent } from 'component/common/PageContent/PageContent';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import { getCreateTogglePath } from 'utils/routePathHelpers';
import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
import { IProject } from 'interfaces/project';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { createLocalStorage } from 'utils/createLocalStorage';
import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
import { getColumnValues, includesFilter, useSearch } from 'hooks/useSearch';
import { Search } from 'component/common/Search/Search';
import { IFeatureToggleListItem } from 'interfaces/featureToggle';
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader';
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
import {
ProjectEnvironmentType,
useEnvironmentsRef,
} from './hooks/useEnvironmentsRef';
import { ActionsCell } from './ActionsCell/ActionsCell';
import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu';
import { useStyles } from './ProjectFeatureToggles.styles';
import { usePinnedFavorites } from 'hooks/usePinnedFavorites';
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
import FileDownload from '@mui/icons-material/FileDownload';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
import { MemoizedRowSelectCell } from './RowSelectCell/RowSelectCell';
import { BatchSelectionActionsBar } from '../../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
import { ProjectFeaturesBatchActions } from './ProjectFeaturesBatchActions/ProjectFeaturesBatchActions';
import { MemoizedFeatureEnvironmentSeenCell } from '../../../common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { ListItemType } from './ProjectFeatureToggles.types';
import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureToggleCell';
import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch';
import useToast from 'hooks/useToast';
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
whiteSpace: 'nowrap',
}));
interface IProjectFeatureTogglesProps {
features: IProject['features'];
environments: IProject['environments'];
loading: boolean;
onChange: () => void;
total?: number;
style?: React.CSSProperties;
}
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
const defaultSort: SortingRule<string> & {
columns?: string[];
} = { id: 'createdAt', desc: true };
/**
* @deprecated remove when flag `featureSearchFrontend` is removed
*/
export const ProjectFeatureToggles = ({
features,
loading,
environments: newEnvironments = [],
onChange,
total,
style = {},
}: IProjectFeatureTogglesProps) => {
const { classes: styles } = useStyles();
const theme = useTheme();
const { setToastApiError } = useToast();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [featureStaleDialogState, setFeatureStaleDialogState] = useState<{
featureId?: string;
stale?: boolean;
}>({});
const [featureArchiveState, setFeatureArchiveState] = useState<
string | undefined
>();
const projectId = useRequiredPathParam('projectId');
const { onToggle: onFeatureToggle, modals: featureToggleModals } =
useFeatureToggleSwitch(projectId);
const { value: storedParams, setValue: setStoredParams } =
createLocalStorage(
`${projectId}:FeatureToggleListTable:v1`,
defaultSort,
);
const { value: globalStore, setValue: setGlobalStore } =
useGlobalLocalStorage();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const environments = useEnvironmentsRef(
loading
? [{ environment: 'a' }, { environment: 'b' }, { environment: 'c' }]
: newEnvironments,
);
const { isFavoritesPinned, sortTypes, onChangeIsFavoritePinned } =
usePinnedFavorites(
searchParams.has('favorites')
? searchParams.get('favorites') === 'true'
: globalStore.favorites,
);
const { favorite, unfavorite } = useFavoriteFeaturesApi();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const [showExportDialog, setShowExportDialog] = useState(false);
const { uiConfig } = useUiConfig();
const onFavorite = useCallback(
async (feature: IFeatureToggleListItem) => {
try {
if (feature?.favorite) {
await unfavorite(projectId, feature.name);
} else {
await favorite(projectId, feature.name);
}
onChange();
} catch (error) {
setToastApiError(
'Something went wrong, could not update favorite',
);
}
},
[projectId, onChange],
);
const showTagsColumn = useMemo(
() => features.some((feature) => feature?.tags?.length),
[features],
);
const columns = useMemo(
() => [
{
id: 'Select',
Header: ({ getToggleAllRowsSelectedProps }: any) => (
<Checkbox {...getToggleAllRowsSelectedProps()} />
),
Cell: ({ row }: any) => (
<MemoizedRowSelectCell
{...row?.getToggleRowSelectedProps?.()}
/>
),
maxWidth: 50,
disableSortBy: true,
hideInMenu: true,
styles: {
borderRadius: 0,
},
},
{
id: 'favorite',
Header: (
<FavoriteIconHeader
isActive={isFavoritesPinned}
onClick={onChangeIsFavoritePinned}
/>
),
accessor: 'favorite',
Cell: ({ row: { original: feature } }: any) => (
<FavoriteIconCell
value={feature?.favorite}
onClick={() => onFavorite(feature)}
/>
),
maxWidth: 50,
disableSortBy: true,
hideInMenu: true,
},
{
Header: 'Seen',
accessor: 'lastSeenAt',
Cell: ({ value, row: { original: feature } }: any) => {
return (
<MemoizedFeatureEnvironmentSeenCell feature={feature} />
);
},
align: 'center',
maxWidth: 80,
},
{
Header: 'Type',
accessor: 'type',
Cell: FeatureTypeCell,
align: 'center',
filterName: 'type',
maxWidth: 80,
},
{
Header: 'Name',
accessor: 'name',
Cell: ({ value }: { value: string }) => (
<Tooltip title={value} arrow describeChild>
<span>
<LinkCell
title={value}
to={`/projects/${projectId}/features/${value}`}
/>
</span>
</Tooltip>
),
minWidth: 100,
sortType: 'alphanumeric',
searchable: true,
},
...(showTagsColumn
? [
{
id: 'tags',
Header: 'Tags',
accessor: (row: IFeatureToggleListItem) =>
row.tags
?.map(({ type, value }) => `${type}:${value}`)
.join('\n') || '',
Cell: FeatureTagCell,
width: 80,
searchable: true,
filterName: 'tags',
filterBy(
row: IFeatureToggleListItem,
values: string[],
) {
return includesFilter(
getColumnValues(this, row),
values,
);
},
},
]
: []),
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
minWidth: 120,
},
...environments.map((value: ProjectEnvironmentType | string) => {
const name =
typeof value === 'string'
? value
: (value as ProjectEnvironmentType).environment;
const isChangeRequestEnabled = isChangeRequestConfigured(name);
const FeatureToggleCell = createFeatureToggleCell(
projectId,
name,
isChangeRequestEnabled,
onChange,
onFeatureToggle,
);
return {
Header: loading ? () => '' : name,
maxWidth: 90,
id: `environments.${name}`,
accessor: (row: ListItemType) =>
row.environments[name]?.enabled,
align: 'center',
Cell: FeatureToggleCell,
sortType: 'boolean',
filterName: name,
filterParsing: (value: boolean) =>
value ? 'enabled' : 'disabled',
};
}),
{
id: 'Actions',
maxWidth: 56,
width: 56,
Cell: (props: { row: { original: ListItemType } }) => (
<ActionsCell
projectId={projectId}
onOpenArchiveDialog={setFeatureArchiveState}
onOpenStaleDialog={setFeatureStaleDialogState}
{...props}
/>
),
disableSortBy: true,
hideInMenu: true,
styles: {
borderRadius: 0,
},
},
],
[projectId, environments, loading],
);
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || '',
);
const [showTitle, setShowTitle] = useState(true);
const featuresData = useMemo(
() =>
features.map((feature) => ({
...feature,
environments: Object.fromEntries(
environments.map((env) => {
const thisEnv = feature?.environments.find(
(featureEnvironment) =>
featureEnvironment?.name === env,
);
return [
env,
{
name: env,
enabled: thisEnv?.enabled || false,
variantCount: thisEnv?.variantCount || 0,
lastSeenAt: thisEnv?.lastSeenAt,
type: thisEnv?.type,
hasStrategies: thisEnv?.hasStrategies,
hasEnabledStrategies:
thisEnv?.hasEnabledStrategies,
},
];
}),
),
someEnabledEnvironmentHasVariants:
feature.environments?.some(
(featureEnvironment) =>
featureEnvironment.variantCount > 0 &&
featureEnvironment.enabled,
) || false,
})),
[features, environments],
);
const {
data: searchedData,
getSearchText,
getSearchContext,
} = useSearch(columns, searchValue, featuresData);
const data = useMemo(() => {
if (loading) {
return Array(6).fill({
type: '-',
name: 'Feature name',
createdAt: new Date(),
environments: {
production: { name: 'production', enabled: false },
},
}) as FeatureSchema[];
}
return searchedData;
}, [loading, searchedData]);
const initialState = useMemo(
() => {
const allColumnIds = columns
.map(
(column: any) =>
(column?.id as string) ||
(typeof column?.accessor === 'string'
? (column?.accessor as string)
: ''),
)
.filter(Boolean);
let hiddenColumns = environments
.filter((_, index) => index >= 3)
.map((environment) => `environment:${environment}`);
if (searchParams.has('columns')) {
const columnsInParams =
searchParams.get('columns')?.split(',') || [];
const visibleColumns = [...staticColumns, ...columnsInParams];
hiddenColumns = allColumnIds.filter(
(columnId) => !visibleColumns.includes(columnId),
);
} else if (storedParams.columns) {
const visibleColumns = [
...staticColumns,
...storedParams.columns,
];
hiddenColumns = allColumnIds.filter(
(columnId) => !visibleColumns.includes(columnId),
);
}
return {
sortBy: [
{
id:
searchParams.get('sort') ||
storedParams.id ||
'createdAt',
desc: searchParams.has('order')
? searchParams.get('order') === 'desc'
: storedParams.desc,
},
],
hiddenColumns,
selectedRowIds: {},
};
},
[environments], // eslint-disable-line react-hooks/exhaustive-deps
);
const getRowId = useCallback((row: any) => row.name, []);
const {
allColumns,
headerGroups,
rows,
state: { selectedRowIds, sortBy, hiddenColumns },
prepareRow,
setHiddenColumns,
toggleAllRowsSelected,
} = useTable(
{
columns: columns as any[], // TODO: fix after `react-table` v8 update
data,
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetSelectedRows: false,
disableSortRemove: true,
autoResetSortBy: false,
getRowId,
},
useFlexLayout,
useSortBy,
useRowSelect,
);
useEffect(() => {
if (loading) {
return;
}
const tableState: Record<string, string> = {};
tableState.sort = sortBy[0].id;
if (sortBy[0].desc) {
tableState.order = 'desc';
}
if (searchValue) {
tableState.search = searchValue;
}
if (isFavoritesPinned) {
tableState.favorites = 'true';
}
tableState.columns = allColumns
.map(({ id }) => id)
.filter(
(id) =>
!staticColumns.includes(id) && !hiddenColumns?.includes(id),
)
.join(',');
setSearchParams(tableState, {
replace: true,
});
setStoredParams((params) => ({
...params,
id: sortBy[0].id,
desc: sortBy[0].desc || false,
columns: tableState.columns.split(','),
}));
setGlobalStore((params) => ({
...params,
favorites: Boolean(isFavoritesPinned),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
loading,
sortBy,
hiddenColumns,
searchValue,
setSearchParams,
isFavoritesPinned,
]);
return (
<>
<PageContent
isLoading={loading}
disablePadding
className={styles.container}
style={style}
header={
<Box
sx={(theme) => ({
padding: `${theme.spacing(2.5)} ${theme.spacing(
3.125,
)}`,
})}
>
<PageHeader
titleElement={
showTitle
? `Feature toggles (${
total || rows.length
})`
: null
}
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<Search
placeholder='Search and Filter'
expandable
initialValue={searchValue}
onChange={setSearchValue}
onFocus={() =>
setShowTitle(false)
}
onBlur={() =>
setShowTitle(true)
}
hasFilters
getSearchContext={
getSearchContext
}
id='projectFeatureToggles'
/>
}
/>
<ColumnsMenu
allColumns={allColumns}
staticColumns={staticColumns}
dividerAfter={['createdAt']}
dividerBefore={['Actions']}
isCustomized={Boolean(
storedParams.columns,
)}
setHiddenColumns={setHiddenColumns}
/>
<PageHeader.Divider
sx={{ marginLeft: 0 }}
/>
<ConditionallyRender
condition={Boolean(
uiConfig?.flags
?.featuresExportImport,
)}
show={
<Tooltip
title='Export toggles visible in the table below'
arrow
>
<IconButton
onClick={() =>
setShowExportDialog(
true,
)
}
sx={(theme) => ({
marginRight:
theme.spacing(2),
})}
>
<FileDownload />
</IconButton>
</Tooltip>
}
/>
<StyledResponsiveButton
onClick={() =>
navigate(
getCreateTogglePath(projectId),
)
}
maxWidth='960px'
Icon={Add}
projectId={projectId}
permission={CREATE_FEATURE}
data-testid='NAVIGATE_TO_CREATE_FEATURE'
>
New feature toggle
</StyledResponsiveButton>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
id='projectFeatureToggles'
/>
}
/>
</PageHeader>
</Box>
}
>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<Box sx={{ padding: theme.spacing(3) }}>
<TablePlaceholder>
No feature toggles found matching
&ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
</Box>
}
elseShow={
<Box sx={{ padding: theme.spacing(3) }}>
<TablePlaceholder>
No feature toggles found matching your
criteria. Get started by adding a new
feature toggle.
</TablePlaceholder>
</Box>
}
/>
}
/>
<FeatureStaleDialog
isStale={featureStaleDialogState.stale === true}
isOpen={Boolean(featureStaleDialogState.featureId)}
onClose={() => {
setFeatureStaleDialogState({});
onChange();
}}
featureId={featureStaleDialogState.featureId || ''}
projectId={projectId}
/>
<FeatureArchiveDialog
isOpen={Boolean(featureArchiveState)}
onConfirm={onChange}
onClose={() => {
setFeatureArchiveState(undefined);
}}
featureIds={[featureArchiveState || '']}
projectId={projectId}
/>
<ConditionallyRender
condition={
Boolean(uiConfig?.flags?.featuresExportImport) &&
!loading
}
show={
<ExportDialog
showExportDialog={showExportDialog}
data={data}
onClose={() => setShowExportDialog(false)}
environments={environments}
/>
}
/>
{featureToggleModals}
</PageContent>
<BatchSelectionActionsBar
count={Object.keys(selectedRowIds).length}
>
<ProjectFeaturesBatchActions
selectedIds={Object.keys(selectedRowIds)}
data={features}
projectId={projectId}
onResetSelection={() => toggleAllRowsSelected(false)}
onChange={onChange}
/>
</BatchSelectionActionsBar>
</>
);
};

View File

@ -1,18 +1,14 @@
import { FC, useEffect } from 'react';
import useProject, {
useProjectNameOrId,
} from 'hooks/api/getters/useProject/useProject';
import { Box, styled } from '@mui/material';
import { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureToggles';
import ProjectInfo from './ProjectInfo/ProjectInfo';
import { usePageTitle } from 'hooks/usePageTitle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useLastViewedProject } from 'hooks/useLastViewedProject';
import { ProjectStats } from './ProjectStats/ProjectStats';
import { useUiFlag } from 'hooks/useUiFlag';
import { PaginatedProjectFeatureToggles } from './PaginatedProjectFeatureToggles/PaginatedProjectFeatureToggles';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { type FeatureTypeCount } from '../../../interfaces/project';
import { ProjectFeatureToggles } from './PaginatedProjectFeatureToggles/ProjectFeatureToggles';
import useProjectOverview, {
useProjectOverviewNameOrId,
} from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { usePageTitle } from 'hooks/usePageTitle';
import { useLastViewedProject } from 'hooks/useLastViewedProject';
const refreshInterval = 15 * 1000;
@ -37,13 +33,19 @@ const StyledContentContainer = styled(Box)(({ theme }) => ({
minWidth: 0,
}));
const PaginatedProjectOverview: FC<{
const ProjectOverview: FC<{
storageKey?: string;
}> = ({ storageKey = 'project-overview-v2' }) => {
const projectId = useRequiredPathParam('projectId');
const projectName = useProjectOverviewNameOrId(projectId);
const { project } = useProjectOverview(projectId, {
refreshInterval,
});
usePageTitle(`Project overview ${projectName}`);
const { setLastViewed } = useLastViewedProject();
useEffect(() => {
setLastViewed(projectId);
}, [projectId, setLastViewed]);
const {
members,
@ -67,7 +69,7 @@ const PaginatedProjectOverview: FC<{
<StyledContentContainer>
<ProjectStats stats={project.stats} />
<StyledProjectToggles>
<PaginatedProjectFeatureToggles
<ProjectFeatureToggles
environments={environments}
refreshInterval={refreshInterval}
storageKey={storageKey}
@ -78,66 +80,4 @@ const PaginatedProjectOverview: FC<{
);
};
/**
* @deprecated remove when flag `featureSearchFrontend` is removed
*/
const ProjectOverview = () => {
const projectId = useRequiredPathParam('projectId');
const projectName = useProjectNameOrId(projectId);
const { project, loading, refetch } = useProject(projectId, {
refreshInterval,
});
const { members, features, health, description, environments, stats } =
project;
usePageTitle(`Project overview ${projectName}`);
const { setLastViewed } = useLastViewedProject();
const featureSearchFrontend = useUiFlag('featureSearchFrontend');
useEffect(() => {
setLastViewed(projectId);
}, [projectId, setLastViewed]);
if (featureSearchFrontend) return <PaginatedProjectOverview />;
const featureTypeCounts = features.reduce(
(acc: FeatureTypeCount[], feature) => {
const existingEntry = acc.find(
(entry) => entry.type === feature.type,
);
if (existingEntry) {
existingEntry.count += 1;
} else {
acc.push({ type: feature.type, count: 1 });
}
return acc;
},
[],
);
return (
<StyledContainer>
<ProjectInfo
id={projectId}
description={description}
memberCount={members}
health={health}
featureTypeCounts={featureTypeCounts}
stats={stats}
/>
<StyledContentContainer>
<ProjectStats stats={project.stats} />
<StyledProjectToggles>
<ProjectFeatureToggles
key={loading ? 'loading' : 'ready'}
features={features}
environments={environments}
loading={loading}
onChange={refetch}
/>
</StyledProjectToggles>
</StyledContentContainer>
</StyledContainer>
);
};
export default ProjectOverview;

View File

@ -65,7 +65,6 @@ export type UiFlags = {
dependentFeatures?: boolean;
scheduledConfigurationChanges?: boolean;
featureSearchAPI?: boolean;
featureSearchFrontend?: boolean;
newStrategyConfiguration?: boolean;
incomingWebhooks?: boolean;
automatedActions?: boolean;

View File

@ -95,10 +95,9 @@ exports[`should create default config 1`] = `
"executiveDashboard": false,
"extendedUsageMetrics": false,
"extendedUsageMetricsUI": false,
"featureSearchAPI": false,
"featureSearchAPI": true,
"featureSearchFeedback": false,
"featureSearchFeedbackPosting": false,
"featureSearchFrontend": false,
"featuresExportImport": true,
"feedbackComments": {
"enabled": false,

View File

@ -28,7 +28,6 @@ export type IFlagKey =
| 'customRootRolesKillSwitch'
| 'disableMetrics'
| 'featureSearchAPI'
| 'featureSearchFrontend'
| 'scheduledConfigurationChanges'
| 'detectSegmentUsageInChangeRequests'
| 'stripClientHeadersOn304'
@ -133,11 +132,7 @@ const flags: IFlags = {
),
featureSearchAPI: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_FEATURE_SEARCH_API,
false,
),
featureSearchFrontend: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_FEATURE_SEARCH_FRONTEND,
false,
true,
),
scheduledConfigurationChanges: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_SCHEDULED_CONFIGURATION_CHANGES,

View File

@ -41,7 +41,6 @@ process.nextTick(async () => {
anonymiseEventLog: false,
responseTimeWithAppNameKillSwitch: false,
featureSearchAPI: true,
featureSearchFrontend: true,
stripClientHeadersOn304: true,
newStrategyConfiguration: true,
stripHeadersOnAPI: true,