1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-18 01:18:23 +02:00

refactor: remove deprecated get archive featured by project endpoint (#9938)

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

Removes GET `/api/admin/archive/features/{projectId}` which was
deprecated in v4.11.
Also cleans up related code.

I leveraged our search features endpoint where needed, since that's what
we're using now in our UI (to see archived features you filter by
archived=true).

Builds on top of https://github.com/Unleash/unleash/pull/9924 — Since
they're both related.
This commit is contained in:
Nuno Góis 2025-05-13 12:09:52 +01:00 committed by GitHub
parent 42b6fc810e
commit 0429c1985a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 38 additions and 1048 deletions

View File

@ -7,7 +7,6 @@ import {
UPDATE_FEATURE,
} from 'component/providers/AccessProvider/permissions';
import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC';
import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
import { ArchivedFeatureDeleteConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { ArchivedFeatureReviveConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm';
@ -23,7 +22,6 @@ export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
projectId,
onConfirm,
}) => {
const { refetchArchived } = useFeaturesArchive(projectId);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [reviveModalOpen, setReviveModalOpen] = useState(false);
const { trackEvent } = usePlausibleTracker();
@ -70,7 +68,6 @@ export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
open={deleteModalOpen}
setOpen={setDeleteModalOpen}
refetch={() => {
refetchArchived();
onConfirm?.();
trackEvent('batch_operations', {
props: {
@ -85,7 +82,6 @@ export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
open={reviveModalOpen}
setOpen={setReviveModalOpen}
refetch={() => {
refetchArchived();
onConfirm?.();
trackEvent('batch_operations', {
props: {

View File

@ -1,160 +0,0 @@
import { ArchiveTable } from './ArchiveTable';
import { render } from 'utils/testRenderer';
import { useState } from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
DELETE_FEATURE,
UPDATE_FEATURE,
} from 'component/providers/AccessProvider/permissions';
import ToastRenderer from 'component/common/ToastRenderer/ToastRenderer';
import { testServerRoute, testServerSetup } from 'utils/testServer';
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',
archived: true,
},
{
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',
archived: true,
},
];
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}
projectId='default'
/>
);
};
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 confirm dialog when batch reviving flag', async () => {
setupApi();
render(
<>
<ToastRenderer />
<Component />
</>,
{
permissions: [
{ permission: UPDATE_FEATURE, project: 'default' },
{ permission: DELETE_FEATURE, project: 'default' },
],
},
);
await screen.findByText('someFeature');
const selectAll = await screen.findByTestId('select_all_rows');
fireEvent.click(selectAll.firstChild!);
const batchReviveButton = await screen.findByText(/Revive/i);
await userEvent.click(batchReviveButton!);
await screen.findByText('Revive feature flags?');
const reviveTogglesButton = screen.getByRole('button', {
name: /Revive feature flags/i,
});
fireEvent.click(reviveTogglesButton);
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

@ -1,320 +0,0 @@
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 { Checkbox, 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 { ArchivedFeatureActionCell } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell';
import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable';
import theme from 'themes/theme';
import type { ArchivedFeatureSchema } from 'openapi';
import { useSearch } from 'hooks/useSearch';
import { FeatureArchivedCell } from './FeatureArchivedCell/FeatureArchivedCell';
import { useSearchParams } from 'react-router-dom';
import { ArchivedFeatureDeleteConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm';
import type { IFeatureToggle } from 'interfaces/featureToggle';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { RowSelectCell } from '../../project/Project/ProjectFeatureToggles/RowSelectCell/RowSelectCell';
import { BatchSelectionActionsBar } from '../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
import { ArchiveBatchActions } from './ArchiveBatchActions';
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import { ArchivedFeatureReviveConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm';
export interface IFeaturesArchiveTableProps {
archivedFeatures: ArchivedFeatureSchema[];
title: string;
refetch: () => void;
loading: boolean;
storedParams: SortingRule<string>;
setStoredParams: (
newValue:
| SortingRule<string>
| ((prev: SortingRule<string>) => SortingRule<string>),
) => SortingRule<string>;
projectId: string;
}
export const ArchiveTable = ({
archivedFeatures = [],
loading,
refetch,
storedParams,
setStoredParams,
title,
projectId,
}: 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(
() => [
{
id: 'Select',
Header: ({ getToggleAllRowsSelectedProps }: any) => (
<Checkbox
data-testid='select_all_rows'
{...getToggleAllRowsSelectedProps()}
/>
),
Cell: ({ row }: any) => (
<RowSelectCell {...row?.getToggleRowSelectedProps?.()} />
),
maxWidth: 50,
disableSortBy: true,
hideInMenu: true,
},
{
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: '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, selectedRowIds },
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,
);
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={projectId}
open={deleteModalOpen}
setOpen={setDeleteModalOpen}
refetch={refetch}
/>
<ArchivedFeatureReviveConfirm
revivedFeatures={[revivedFeature?.name!]}
projectId={projectId}
open={reviveModalOpen}
setOpen={setReviveModalOpen}
refetch={refetch}
/>
</PageContent>
<BatchSelectionActionsBar
count={Object.keys(selectedRowIds).length}
>
<ArchiveBatchActions
selectedIds={Object.keys(selectedRowIds)}
projectId={projectId}
onConfirm={() => toggleAllRowsSelected(false)}
/>
</BatchSelectionActionsBar>
</>
);
};

View File

@ -1,45 +0,0 @@
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

@ -1,35 +0,0 @@
import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
import type { FC } from 'react';
import type { SortingRule } from 'react-table';
import { createLocalStorage } from 'utils/createLocalStorage';
import { ArchiveTable } from './ArchiveTable/ArchiveTable';
const defaultSort: SortingRule<string> = { id: 'archivedAt' };
interface IProjectFeaturesTable {
projectId: string;
}
export const ProjectFeaturesArchiveTable: FC<IProjectFeaturesTable> = ({
projectId,
}) => {
const { archivedFeatures, loading, refetchArchived } =
useFeaturesArchive(projectId);
const { value, setValue } = createLocalStorage(
`${projectId}:ProjectFeaturesArchiveTable`,
defaultSort,
);
return (
<ArchiveTable
title='Archived flags'
archivedFeatures={archivedFeatures || []}
loading={loading}
storedParams={value}
setStoredParams={setValue}
refetch={refetchArchived}
projectId={projectId}
/>
);
};

View File

@ -1,7 +0,0 @@
import { Navigate } from 'react-router-dom';
const RedirectArchive = () => {
return <Navigate to='/archive' replace />;
};
export default RedirectArchive;

View File

@ -1,8 +1,8 @@
import { Link } from 'react-router-dom';
import { getCreateTogglePath } from 'utils/routePathHelpers';
import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { styled } from '@mui/material';
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
const StyledFeatureId = styled('strong')({
wordBreak: 'break-all',
@ -11,7 +11,10 @@ const StyledFeatureId = styled('strong')({
export const FeatureNotFound = () => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { archivedFeatures } = useFeaturesArchive(projectId);
const { features: archivedFeatures } = useFeatureSearch({
project: `IS:${projectId}`,
archived: 'IS:true',
});
const createFeatureTogglePath = getCreateTogglePath(projectId, {
name: featureId,

View File

@ -35,14 +35,6 @@ exports[`returns all baseRoutes 1`] = `
"title": "Create",
"type": "protected",
},
{
"component": [Function],
"menu": {},
"parent": "/archive",
"path": "/projects/:projectId/archived",
"title": ":projectId",
"type": "protected",
},
{
"component": [Function],
"menu": {},

View File

@ -10,7 +10,6 @@ import ResetPassword from 'component/user/ResetPassword/ResetPassword';
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
import { ProjectList } from 'component/project/ProjectList/ProjectList';
import { ArchiveProjectList } from 'component/project/ProjectList/ArchiveProjectList';
import RedirectArchive from 'component/archive/RedirectArchive';
import CreateEnvironment from 'component/environments/CreateEnvironment/CreateEnvironment';
import EditEnvironment from 'component/environments/EditEnvironment/EditEnvironment';
import { EditContext } from 'component/context/EditContext/EditContext';
@ -80,14 +79,6 @@ export const routes: IRoute[] = [
enterprise: true,
menu: {},
},
{
path: '/projects/:projectId/archived',
title: ':projectId',
parent: '/archive',
component: RedirectArchive,
type: 'protected',
menu: {},
},
{
path: '/projects/:projectId/features/:featureId/copy',
parent: '/projects/:projectId/features/:featureId/',

View File

@ -27,7 +27,6 @@ import useToast from 'hooks/useToast';
import useQueryParams from 'hooks/useQueryParams';
import { useEffect, useState, type ReactNode } from 'react';
import ProjectEnvironment from '../ProjectEnvironment/ProjectEnvironment';
import { ProjectFeaturesArchive } from './ProjectFeaturesArchive/ProjectFeaturesArchive';
import ProjectFlags from './ProjectFlags';
import ProjectHealth from './ProjectHealth/ProjectHealth';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
@ -387,7 +386,6 @@ export const Project = () => {
}
/>
<Route path='environments' element={<ProjectEnvironment />} />
<Route path='archive' element={<ProjectFeaturesArchive />} />
<Route path='insights' element={<ProjectInsights />} />
<Route path='logs' element={<ProjectLog />} />
<Route

View File

@ -1,12 +0,0 @@
import { ProjectFeaturesArchiveTable } from 'component/archive/ProjectFeaturesArchiveTable';
import { usePageTitle } from 'hooks/usePageTitle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useProjectOverviewNameOrId } from 'hooks/api/getters/useProjectOverview/useProjectOverview';
export const ProjectFeaturesArchive = () => {
const projectId = useRequiredPathParam('projectId');
const projectName = useProjectOverviewNameOrId(projectId);
usePageTitle(`Project archived flags ${projectName}`);
return <ProjectFeaturesArchiveTable projectId={projectId} />;
};

View File

@ -1,27 +0,0 @@
import useSWR from 'swr';
import type { ArchivedFeaturesSchema } from 'openapi';
import handleErrorResponses from '../httpErrorResponseHandler';
import { formatApiPath } from 'utils/formatPath';
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Feature flag archive'))
.then((res) => res.json());
};
export const useFeaturesArchive = (projectId: string) => {
const { data, error, mutate, isLoading } = useSWR<ArchivedFeaturesSchema>(
formatApiPath(`/api/admin/archive/features/${projectId}`),
fetcher,
{
refreshInterval: 15 * 1000, // ms
},
);
return {
archivedFeatures: data?.features,
refetchArchived: mutate,
loading: isLoading,
error,
};
};

View File

@ -1,42 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
import type { ArchivedFeatureSchemaEnvironmentsItem } from './archivedFeatureSchemaEnvironmentsItem';
/**
* An archived project feature flag definition
*/
export interface ArchivedFeatureSchema {
/** The date the feature was archived */
archivedAt?: string;
/** The date the feature was created */
createdAt?: string;
/**
* Detailed description of the feature
* @nullable
*/
description?: string | null;
/**
* The list of environments where the feature can be used
* @deprecated
*/
environments?: ArchivedFeatureSchemaEnvironmentsItem[];
/** `true` if the impression data collection is enabled for the feature, otherwise `false`. */
impressionData?: boolean;
/**
* The date when metrics where last collected for the feature. This field was deprecated in v5, use the one in featureEnvironmentSchema
* @deprecated
* @nullable
*/
lastSeenAt?: string | null;
/** Unique feature name */
name: string;
/** Name of the project the feature belongs to */
project: string;
/** `true` if the feature is stale based on the age and feature type, otherwise `false`. */
stale?: boolean;
/** Type of the flag e.g. experiment, kill-switch, release, operational, permission */
type?: string;
}

View File

@ -1,17 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type ArchivedFeatureSchemaEnvironmentsItem = {
/** `true` if the feature is enabled for the environment, otherwise `false`. */
enabled?: boolean;
/**
* The date when metrics where last collected for the feature environment
* @nullable
*/
lastSeenAt?: string | null;
/** The name of the environment */
name?: string;
};

View File

@ -1,16 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
import type { ArchivedFeatureSchema } from './archivedFeatureSchema';
/**
* A list of archived features
*/
export interface ArchivedFeaturesSchema {
/** A list of features */
features: ArchivedFeatureSchema[];
/** The version of the feature's schema */
version: number;
}

View File

@ -1,14 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type GetArchivedFeatures401 = {
/** The ID of the error instance */
id?: string;
/** A description of what went wrong. */
message?: string;
/** The name of the error kind */
name?: string;
};

View File

@ -1,14 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type GetArchivedFeatures403 = {
/** The ID of the error instance */
id?: string;
/** A description of what went wrong. */
message?: string;
/** The name of the error kind */
name?: string;
};

View File

@ -1,14 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type GetArchivedFeaturesByProjectId401 = {
/** The ID of the error instance */
id?: string;
/** A description of what went wrong. */
message?: string;
/** The name of the error kind */
name?: string;
};

View File

@ -1,14 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type GetArchivedFeaturesByProjectId403 = {
/** The ID of the error instance */
id?: string;
/** A description of what went wrong. */
message?: string;
/** The name of the error kind */
name?: string;
};

View File

@ -124,9 +124,6 @@ export * from './archiveProject401';
export * from './archiveProject403';
export * from './archiveReleasePlanTemplate401';
export * from './archiveReleasePlanTemplate403';
export * from './archivedFeatureSchema';
export * from './archivedFeatureSchemaEnvironmentsItem';
export * from './archivedFeaturesSchema';
export * from './bannerSchema';
export * from './bannersSchema';
export * from './batchFeaturesSchema';
@ -710,10 +707,6 @@ export * from './getApplication404';
export * from './getApplicationEnvironmentInstances404';
export * from './getApplicationOverview404';
export * from './getApplicationsParams';
export * from './getArchivedFeatures401';
export * from './getArchivedFeatures403';
export * from './getArchivedFeaturesByProjectId401';
export * from './getArchivedFeaturesByProjectId403';
export * from './getBanners401';
export * from './getBaseUsersAndGroups401';
export * from './getChangeRequest404';

View File

@ -223,7 +223,9 @@ const searchFeaturesWithoutQueryParams = async (expectedCode = 200) => {
};
const getProjectArchive = async (projectId = 'default', expectedCode = 200) => {
return app.request
.get(`/api/admin/archive/features/${projectId}`)
.get(
`/api/admin/search/features?project=IS%3A${projectId}&archived=IS%3Atrue`,
)
.expect(expectedCode);
};

View File

@ -1,23 +1,17 @@
import type { Request, Response } from 'express';
import type { Response } from 'express';
import type { IUnleashConfig } from '../../types/option';
import type { IUnleashServices } from '../../types';
import Controller from '../../routes/controller';
import { extractUsername } from '../../util/extract-user';
import { DELETE_FEATURE, NONE, UPDATE_FEATURE } from '../../types/permissions';
import { DELETE_FEATURE, UPDATE_FEATURE } from '../../types/permissions';
import type FeatureToggleService from './feature-toggle-service';
import type { IAuthRequest } from '../../routes/unleash-types';
import { serializeDates } from '../../types/serialize-dates';
import type { OpenApiService } from '../../services/openapi-service';
import { createResponseSchema } from '../../openapi/util/create-response-schema';
import {
emptyResponse,
getStandardResponses,
} from '../../openapi/util/standard-responses';
import type { WithTransactional } from '../../db/transaction';
import {
archivedFeaturesSchema,
type ArchivedFeaturesSchema,
} from '../../openapi';
export default class ArchiveController extends Controller {
private featureService: FeatureToggleService;
@ -43,28 +37,6 @@ export default class ArchiveController extends Controller {
this.transactionalFeatureToggleService =
transactionalFeatureToggleService;
this.route({
method: 'get',
path: '/features/:projectId',
handler: this.getArchivedFeaturesByProjectId,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Archive'],
operationId: 'getArchivedFeaturesByProjectId',
summary: 'Get archived features in project',
description:
'Retrieves a list of archived features that belong to the provided project.',
responses: {
200: createResponseSchema('archivedFeaturesSchema'),
...getStandardResponses(401, 403),
},
deprecated: true,
}),
],
});
this.route({
method: 'delete',
path: '/:featureName',
@ -108,21 +80,6 @@ export default class ArchiveController extends Controller {
});
}
async getArchivedFeaturesByProjectId(
req: Request<{ projectId: string }, any, any, any>,
res: Response<ArchivedFeaturesSchema>,
): Promise<void> {
const { projectId } = req.params;
const features =
await this.featureService.getArchivedFeaturesByProjectId(projectId);
this.openApiService.respondWithValidation(
200,
res,
archivedFeaturesSchema.$id,
{ version: 2, features: serializeDates(features) },
);
}
async deleteFeature(
req: IAuthRequest<{ featureName: string }>,
res: Response<void>,

View File

@ -220,30 +220,4 @@ export class FeatureToggleRowConverter {
return this.formatToggles(result);
};
buildArchivedFeatureToggleListFromRows = (
rows: any[],
): IFeatureToggleListItem[] => {
const result = rows.reduce((acc, row) => {
const feature: PartialDeep<IFeatureToggleListItem> =
acc[row.name] ?? {};
feature.name = row.name;
feature.description = row.description;
feature.type = row.type;
feature.project = row.project;
feature.stale = row.stale;
feature.createdAt = row.created_at;
feature.impressionData = row.impression_data;
feature.lastSeenAt = row.last_seen_at;
feature.archivedAt = row.archived_at;
this.addLastSeenByEnvironment(feature, row);
acc[row.name] = feature;
return acc;
}, {});
return Object.values(result);
};
}

View File

@ -178,10 +178,6 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return this.features.filter((feature) => feature.archived !== archived);
}
async getArchivedFeatures(project: string): Promise<FeatureToggle[]> {
return this.features.filter((feature) => feature.archived === true);
}
async getPlaygroundFeatures(
query?: IFeatureToggleQuery,
): Promise<FeatureConfigurationClient[]> {

View File

@ -2162,12 +2162,6 @@ class FeatureToggleService {
);
}
async getArchivedFeaturesByProjectId(
project: string,
): Promise<FeatureToggle[]> {
return this.featureToggleStore.getArchivedFeatures(project);
}
async getProjectId(name: string): Promise<string | undefined> {
return this.featureToggleStore.getProjectId(name);
}

View File

@ -258,32 +258,6 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
return rows.map(this.rowToFeature);
}
async getArchivedFeatures(project: string): Promise<FeatureToggle[]> {
const builder = new FeatureToggleListBuilder(this.db, [
...commonSelectColumns,
'features.archived_at as archived_at',
]);
const archived = true;
builder.query('features').withLastSeenByEnvironment(archived);
builder.addSelectColumn(
'last_seen_at_metrics.last_seen_at as env_last_seen_at',
);
builder.addSelectColumn(
'last_seen_at_metrics.environment as last_seen_at_env',
);
const rows = await builder.internalQuery
.select(builder.getSelectColumns())
.where({ project })
.whereNotNull('archived_at');
return this.featureToggleRowConverter.buildArchivedFeatureToggleListFromRows(
rows,
);
}
async getFeatureTypeCounts({
projectId,
archived,

View File

@ -54,37 +54,42 @@ test('Should be allowed to reuse deleted toggle name', async () => {
test('Should get archived toggles via project', async () => {
await db.stores.featureToggleStore.deleteAll();
const proj1 = 'proj-1';
const proj2 = 'proj-2';
await db.stores.projectStore.create({
id: 'proj-1',
name: 'proj-1',
id: proj1,
name: proj1,
description: '',
mode: 'open' as const,
});
await db.stores.projectStore.create({
id: 'proj-2',
name: 'proj-2',
id: proj2,
name: proj2,
description: '',
mode: 'open' as const,
});
await db.stores.featureToggleStore.create('proj-1', {
await db.stores.featureToggleStore.create(proj1, {
name: 'feat-proj-1',
archived: true,
createdByUserId: 9999,
});
await db.stores.featureToggleStore.create('proj-2', {
await db.stores.featureToggleStore.create(proj2, {
name: 'feat-proj-2',
archived: true,
createdByUserId: 9999,
});
await db.stores.featureToggleStore.create('proj-2', {
await db.stores.featureToggleStore.create(proj2, {
name: 'feat-proj-2-2',
archived: true,
createdByUserId: 9999,
});
await app.request
.get('/api/admin/archive/features/proj-1')
.get(
`/api/admin/search/features?project=IS%3A${proj1}&archived=IS%3Atrue`,
)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
@ -92,7 +97,9 @@ test('Should get archived toggles via project', async () => {
});
await app.request
.get('/api/admin/archive/features/proj-2')
.get(
`/api/admin/search/features?project=IS%3A${proj2}&archived=IS%3Atrue`,
)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {

View File

@ -80,28 +80,6 @@ test('response should include last seen at per environment for multiple environm
expect(production.lastSeenAt).toEqual('2023-10-01T12:34:56.000Z');
});
test('response should include last seen at per environment for multiple environments in /api/admin/archive/features/:projectId', async () => {
const featureName = 'multiple-environment-last-seen-at-archived-project';
await setupLastSeenAtTest(featureName);
await app.request
.delete(`/api/admin/projects/default/features/${featureName}`)
.expect(202);
const { body } = await app.request.get(
`/api/admin/archive/features/default`,
);
const featureEnvironments = body.features[0].environments;
const [development, production] = featureEnvironments;
expect(development.name).toBe('development');
expect(development.lastSeenAt).toEqual('2023-10-01T12:34:56.000Z');
expect(production.name).toBe('production');
expect(production.lastSeenAt).toEqual('2023-10-01T12:34:56.000Z');
});
test('response should include last seen at per environment correctly for a single toggle /api/admin/project/:projectId/features/:featureName', async () => {
const featureName = 'multiple-environment-last-seen-at-single-toggle';
await app.createFeature(featureName);

View File

@ -1047,7 +1047,9 @@ test('Should archive feature flag', async () => {
await app.request.get(`${url}/${name}`).expect(404);
const { body } = await app.request
.get(`/api/admin/archive/features/${projectId}`)
.get(
`/api/admin/search/features?project=IS%3A${projectId}&archived=IS%3Atrue`,
)
.expect(200);
const flag = body.features.find((f) => f.name === name);

View File

@ -54,8 +54,6 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
archived?: boolean,
): Promise<FeatureToggle[]>;
getArchivedFeatures(project: string): Promise<FeatureToggle[]>;
getPlaygroundFeatures(
featureQuery?: IFeatureToggleQuery,
): Promise<FeatureConfigurationClient[]>;

View File

@ -1,102 +0,0 @@
import type { FromSchema } from 'json-schema-to-ts';
export const archivedFeatureSchema = {
$id: '#/components/schemas/archivedFeatureSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'project'],
description: 'An archived project feature flag definition',
properties: {
name: {
type: 'string',
example: 'disable-comments',
description: 'Unique feature name',
},
type: {
type: 'string',
example: 'kill-switch',
description:
'Type of the flag e.g. experiment, kill-switch, release, operational, permission',
},
description: {
type: 'string',
nullable: true,
example:
'Controls disabling of the comments section in case of an incident',
description: 'Detailed description of the feature',
},
project: {
type: 'string',
example: 'dx-squad',
description: 'Name of the project the feature belongs to',
},
stale: {
type: 'boolean',
example: false,
description:
'`true` if the feature is stale based on the age and feature type, otherwise `false`.',
},
impressionData: {
type: 'boolean',
example: false,
description:
'`true` if the impression data collection is enabled for the feature, otherwise `false`.',
},
createdAt: {
type: 'string',
format: 'date-time',
example: '2023-01-28T15:21:39.975Z',
description: 'The date the feature was created',
},
archivedAt: {
type: 'string',
format: 'date-time',
example: '2023-01-29T15:21:39.975Z',
description: 'The date the feature was archived',
},
lastSeenAt: {
type: 'string',
format: 'date-time',
nullable: true,
deprecated: true,
example: '2023-01-28T16:21:39.975Z',
description:
'The date when metrics where last collected for the feature. This field was deprecated in v5, use the one in featureEnvironmentSchema',
},
environments: {
type: 'array',
deprecated: true,
description:
'The list of environments where the feature can be used',
items: {
type: 'object',
properties: {
name: {
type: 'string',
example: 'my-dev-env',
description: 'The name of the environment',
},
lastSeenAt: {
type: 'string',
format: 'date-time',
nullable: true,
example: '2023-01-28T16:21:39.975Z',
description:
'The date when metrics where last collected for the feature environment',
},
enabled: {
type: 'boolean',
example: true,
description:
'`true` if the feature is enabled for the environment, otherwise `false`.',
},
},
},
},
},
components: {
schemas: {},
},
} as const;
export type ArchivedFeatureSchema = FromSchema<typeof archivedFeatureSchema>;

View File

@ -1,30 +0,0 @@
import type { FromSchema } from 'json-schema-to-ts';
import { archivedFeatureSchema } from './archived-feature-schema';
export const archivedFeaturesSchema = {
$id: '#/components/schemas/archivedFeaturesSchema',
type: 'object',
additionalProperties: false,
required: ['version', 'features'],
description: 'A list of archived features',
properties: {
version: {
type: 'integer',
description: "The version of the feature's schema",
},
features: {
type: 'array',
items: {
$ref: '#/components/schemas/archivedFeatureSchema',
},
description: 'A list of features',
},
},
components: {
schemas: {
archivedFeatureSchema,
},
},
} as const;
export type ArchivedFeaturesSchema = FromSchema<typeof archivedFeaturesSchema>;

View File

@ -24,8 +24,6 @@ export * from './application-overview-schema';
export * from './application-schema';
export * from './application-usage-schema';
export * from './applications-schema';
export * from './archived-feature-schema';
export * from './archived-features-schema';
export * from './batch-features-schema';
export * from './batch-stale-schema';
export * from './bulk-metrics-schema';

View File

@ -68,7 +68,9 @@ afterAll(async () => {
test('returns three archived flags', async () => {
expect.assertions(1);
return app.request
.get(`/api/admin/archive/features/${DEFAULT_PROJECT}`)
.get(
`/api/admin/search/features?project=IS%3A${DEFAULT_PROJECT}&archived=IS%3Atrue`,
)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
@ -79,7 +81,9 @@ test('returns three archived flags', async () => {
test('returns three archived flags with archivedAt', async () => {
expect.assertions(2);
return app.request
.get(`/api/admin/archive/features/${DEFAULT_PROJECT}`)
.get(
`/api/admin/search/features?project=IS%3A${DEFAULT_PROJECT}&archived=IS%3Atrue`,
)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
@ -238,7 +242,9 @@ test('Should be able to bulk archive features', async () => {
.expect(202);
const { body } = await app.request
.get(`/api/admin/archive/features/${DEFAULT_PROJECT}`)
.get(
`/api/admin/search/features?project=IS%3A${DEFAULT_PROJECT}&archived=IS%3Atrue`,
)
.expect(200);
const archivedFeatures = body.features.filter(