1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-23 13:46:45 +02:00

chore: revive archive page (#10001)

Related to:

-
https://linear.app/unleash/issue/2-3366/remove-get-apiadminarchivefeatures-deprecated-in-4100
-
https://linear.app/unleash/issue/2-3367/remove-get-apiadminarchivefeaturesprojectid-deprecated-in-4110

Brings back the overall flag archive page and table using the feature
flag search endpoint.
This commit is contained in:
Nuno Góis 2025-05-15 11:54:52 +01:00 committed by GitHub
parent 0255cf137a
commit 480689e828
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 521 additions and 0 deletions

View File

@ -0,0 +1,123 @@
import { ArchiveTable } from 'component/archive/ArchiveTable/ArchiveTable';
import { render } from 'utils/testRenderer';
import { useState } from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import ToastRenderer from 'component/common/ToastRenderer/ToastRenderer';
import { testServerRoute, testServerSetup } from 'utils/testServer';
import type { FeatureSearchResponseSchema } from 'openapi';
const mockedFeatures = [
{
name: 'someFeature',
description: '',
type: 'release',
project: 'default',
stale: false,
createdAt: '2023-08-10T09:28:58.928Z',
lastSeenAt: null,
impressionData: false,
archivedAt: '2023-08-11T10:18:03.429Z',
},
{
name: 'someOtherFeature',
description: '',
type: 'release',
project: 'default',
stale: false,
createdAt: '2023-08-10T09:28:58.928Z',
lastSeenAt: null,
impressionData: false,
archivedAt: '2023-08-11T10:18:03.429Z',
},
] as FeatureSearchResponseSchema[];
const Component = () => {
const [storedParams, setStoredParams] = useState({});
return (
<ArchiveTable
title='Archived features'
archivedFeatures={mockedFeatures}
refetch={() => Promise.resolve({})}
loading={false}
setStoredParams={setStoredParams as any}
storedParams={storedParams as any}
/>
);
};
const server = testServerSetup();
const setupApi = () => {
testServerRoute(
server,
'/api/admin/projects/default/revive',
{},
'post',
200,
);
testServerRoute(server, '/api/admin/projects/default/overview', {
environment: 'Open Source',
});
testServerRoute(server, '/api/admin/ui-config', {
archivedAt: null,
});
};
test('should load the table', async () => {
render(<Component />, { permissions: [{ permission: UPDATE_FEATURE }] });
expect(screen.getByRole('table')).toBeInTheDocument();
await screen.findByText('someFeature');
});
test('should show confirm dialog when reviving flag', async () => {
setupApi();
render(
<>
<ToastRenderer />
<Component />
</>,
{ permissions: [{ permission: UPDATE_FEATURE }] },
);
await screen.findByText('someFeature');
const reviveButton = screen.getAllByTestId(
'revive-feature-flag-button',
)?.[0];
fireEvent.click(reviveButton);
await screen.findByText('Revive feature flag?');
const reviveFlagsButton = screen.getByRole('button', {
name: /Revive feature flag/i,
});
await waitFor(async () => {
expect(reviveFlagsButton).toBeEnabled();
});
fireEvent.click(reviveFlagsButton);
await screen.findByText('Feature flags revived');
});
test('should show info box when disableAllEnvsOnRevive flag is on', async () => {
setupApi();
render(
<>
<ToastRenderer />
<Component />
</>,
{ permissions: [{ permission: UPDATE_FEATURE }] },
);
await screen.findByText('someFeature');
const reviveButton = screen.getAllByTestId(
'revive-feature-flag-button',
)?.[0];
fireEvent.click(reviveButton);
await screen.findByText('Revive feature flag?');
await screen.findByText(
'Revived feature flags will be automatically disabled in all environments',
);
});

View File

@ -0,0 +1,302 @@
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import {
type SortingRule,
useFlexLayout,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { useMediaQuery } from '@mui/material';
import { sortTypes } from 'utils/sortTypes';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Search } from 'component/common/Search/Search';
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { ArchivedFeatureActionCell } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell';
import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable';
import theme from 'themes/theme';
import type { FeatureSearchResponseSchema } from 'openapi';
import { useSearch } from 'hooks/useSearch';
import { FeatureArchivedCell } from 'component/archive/ArchiveTable/FeatureArchivedCell/FeatureArchivedCell';
import { useSearchParams } from 'react-router-dom';
import { ArchivedFeatureDeleteConfirm } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm';
import type { IFeatureToggle } from 'interfaces/featureToggle';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import { ArchivedFeatureReviveConfirm } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm';
export interface IFeaturesArchiveTableProps {
archivedFeatures: FeatureSearchResponseSchema[];
title: string;
refetch: () => void;
loading: boolean;
storedParams: SortingRule<string>;
setStoredParams: (
newValue:
| SortingRule<string>
| ((prev: SortingRule<string>) => SortingRule<string>),
) => SortingRule<string>;
}
export const ArchiveTable = ({
archivedFeatures = [],
loading,
refetch,
storedParams,
setStoredParams,
title,
}: IFeaturesArchiveTableProps) => {
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deletedFeature, setDeletedFeature] = useState<IFeatureToggle>();
const [reviveModalOpen, setReviveModalOpen] = useState(false);
const [revivedFeature, setRevivedFeature] = useState<IFeatureToggle>();
const [searchParams, setSearchParams] = useSearchParams();
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || '',
);
const columns = useMemo(
() => [
{
Header: 'Seen',
accessor: 'lastSeenAt',
Cell: ({ row: { original: feature } }: any) => {
return <FeatureEnvironmentSeenCell feature={feature} />;
},
align: 'center',
maxWidth: 80,
},
{
Header: 'Type',
accessor: 'type',
width: 85,
canSort: true,
Cell: FeatureTypeCell,
align: 'center',
},
{
Header: 'Name',
accessor: 'name',
searchable: true,
minWidth: 100,
Cell: ({ value, row: { original } }: any) => (
<HighlightCell
value={value}
subtitle={original.description}
/>
),
sortType: 'alphanumeric',
},
{
Header: 'Created',
accessor: 'createdAt',
width: 150,
Cell: DateCell,
},
{
Header: 'Archived',
accessor: 'archivedAt',
width: 150,
Cell: FeatureArchivedCell,
},
{
Header: 'Project ID',
accessor: 'project',
sortType: 'alphanumeric',
filterName: 'project',
searchable: true,
maxWidth: 170,
Cell: ({ value }: any) => (
<LinkCell title={value} to={`/projects/${value}`} />
),
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
maxWidth: 120,
canSort: false,
Cell: ({ row: { original: feature } }: any) => (
<ArchivedFeatureActionCell
project={feature.project}
onRevive={() => {
setRevivedFeature(feature);
setReviveModalOpen(true);
}}
onDelete={() => {
setDeletedFeature(feature);
setDeleteModalOpen(true);
}}
/>
),
},
// Always hidden -- for search
{
accessor: 'description',
header: 'Description',
searchable: true,
},
],
[],
);
const {
data: searchedData,
getSearchText,
getSearchContext,
} = useSearch(columns, searchValue, archivedFeatures);
const data = useMemo(
() => (loading ? featuresPlaceholder : searchedData),
[searchedData, loading],
);
const [initialState] = useState(() => ({
sortBy: [
{
id: searchParams.get('sort') || storedParams.id,
desc: searchParams.has('order')
? searchParams.get('order') === 'desc'
: storedParams.desc,
},
],
hiddenColumns: ['description'],
selectedRowIds: {},
}));
const getRowId = useCallback((row: any) => row.name, []);
const {
headerGroups,
rows,
state: { sortBy },
prepareRow,
setHiddenColumns,
} = 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,
);
useConditionallyHiddenColumns(
[
{
condition: isSmallScreen,
columns: ['type', 'createdAt'],
},
{
condition: isMediumScreen,
columns: ['lastSeenAt', 'stale'],
},
],
setHiddenColumns,
columns,
);
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;
}
setSearchParams(tableState, {
replace: true,
});
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
}, [loading, sortBy, searchValue]);
return (
<>
<PageContent
isLoading={loading}
header={
<PageHeader
titleElement={`${title} (${
rows.length < data.length
? `${rows.length} of ${data.length}`
: data.length
})`}
actions={
<Search
initialValue={searchValue}
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
/>
}
/>
}
>
<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 flags found matching &ldquo;
{searchValue}&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
None of the feature flags were archived yet.
</TablePlaceholder>
}
/>
)}
/>
<ArchivedFeatureDeleteConfirm
deletedFeatures={[deletedFeature?.name!]}
projectId={deletedFeature?.project!}
open={deleteModalOpen}
setOpen={setDeleteModalOpen}
refetch={refetch}
/>
<ArchivedFeatureReviveConfirm
revivedFeatures={[revivedFeature?.name!]}
projectId={revivedFeature?.project!}
open={reviveModalOpen}
setOpen={setReviveModalOpen}
refetch={refetch}
/>
</PageContent>
</>
);
};

View File

@ -0,0 +1,45 @@
import type { FC } from 'react';
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
import { Tooltip, Typography, useTheme } from '@mui/material';
import { formatDateYMD } from 'utils/formatDate';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { useLocationSettings } from 'hooks/useLocationSettings';
interface IFeatureArchivedCellProps {
value?: string | Date | null;
}
export const FeatureArchivedCell: FC<IFeatureArchivedCellProps> = ({
value: archivedAt,
}) => {
const { locationSettings } = useLocationSettings();
const theme = useTheme();
if (!archivedAt)
return (
<TextCell>
<Typography
variant='body2'
color={theme.palette.text.secondary}
>
not available
</Typography>
</TextCell>
);
return (
<TextCell>
<Tooltip
title={`Archived on: ${formatDateYMD(
archivedAt,
locationSettings.locale,
)}`}
arrow
>
<Typography noWrap variant='body2' data-loading>
<TimeAgo date={new Date(archivedAt)} refresh={false} />
</Typography>
</Tooltip>
</TextCell>
);
};

View File

@ -0,0 +1,34 @@
import { ArchiveTable } from 'component/archive/ArchiveTable/ArchiveTable';
import type { SortingRule } from 'react-table';
import { usePageTitle } from 'hooks/usePageTitle';
import { createLocalStorage } from 'utils/createLocalStorage';
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
const defaultSort: SortingRule<string> = { id: 'createdAt' };
const { value, setValue } = createLocalStorage(
'FeaturesArchiveTable:v1',
defaultSort,
);
export const FeaturesArchiveTable = () => {
usePageTitle('Archive');
const {
features: archivedFeatures,
loading,
refetch,
} = useFeatureSearch({
archived: 'IS:true',
});
return (
<ArchiveTable
title='Archive'
archivedFeatures={archivedFeatures}
loading={loading}
storedParams={value}
setStoredParams={setValue}
refetch={refetch}
/>
);
};

View File

@ -432,6 +432,13 @@ exports[`returns all baseRoutes 1`] = `
"title": "Login history",
"type": "protected",
},
{
"component": [Function],
"menu": {},
"path": "/archive",
"title": "Archived flags",
"type": "protected",
},
{
"component": {
"$$typeof": Symbol(react.lazy),

View File

@ -30,6 +30,7 @@ import { EditSegment } from 'component/segments/EditSegment/EditSegment';
import type { INavigationMenuItem, IRoute } from 'interfaces/route';
import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable';
import { SegmentTable } from '../segments/SegmentTable/SegmentTable.jsx';
import { FeaturesArchiveTable } from 'component/archive/FeaturesArchiveTable';
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
import { Profile } from 'component/user/Profile/Profile';
import { LazyFeatureView } from 'component/feature/FeatureView/LazyFeatureView';
@ -446,6 +447,15 @@ export const routes: IRoute[] = [
menu: { adminSettings: true },
},
// Archive
{
path: '/archive',
title: 'Archived flags',
component: FeaturesArchiveTable,
type: 'protected',
menu: {},
},
// Admin
{
path: '/admin/*',