mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-23 01:16:27 +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:
parent
42b6fc810e
commit
0429c1985a
@ -7,7 +7,6 @@ import {
|
|||||||
UPDATE_FEATURE,
|
UPDATE_FEATURE,
|
||||||
} from 'component/providers/AccessProvider/permissions';
|
} from 'component/providers/AccessProvider/permissions';
|
||||||
import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC';
|
import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC';
|
||||||
import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
|
|
||||||
import { ArchivedFeatureDeleteConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm';
|
import { ArchivedFeatureDeleteConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm';
|
||||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||||
import { ArchivedFeatureReviveConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm';
|
import { ArchivedFeatureReviveConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm';
|
||||||
@ -23,7 +22,6 @@ export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
|
|||||||
projectId,
|
projectId,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}) => {
|
}) => {
|
||||||
const { refetchArchived } = useFeaturesArchive(projectId);
|
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
const [reviveModalOpen, setReviveModalOpen] = useState(false);
|
const [reviveModalOpen, setReviveModalOpen] = useState(false);
|
||||||
const { trackEvent } = usePlausibleTracker();
|
const { trackEvent } = usePlausibleTracker();
|
||||||
@ -70,7 +68,6 @@ export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
|
|||||||
open={deleteModalOpen}
|
open={deleteModalOpen}
|
||||||
setOpen={setDeleteModalOpen}
|
setOpen={setDeleteModalOpen}
|
||||||
refetch={() => {
|
refetch={() => {
|
||||||
refetchArchived();
|
|
||||||
onConfirm?.();
|
onConfirm?.();
|
||||||
trackEvent('batch_operations', {
|
trackEvent('batch_operations', {
|
||||||
props: {
|
props: {
|
||||||
@ -85,7 +82,6 @@ export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
|
|||||||
open={reviveModalOpen}
|
open={reviveModalOpen}
|
||||||
setOpen={setReviveModalOpen}
|
setOpen={setReviveModalOpen}
|
||||||
refetch={() => {
|
refetch={() => {
|
||||||
refetchArchived();
|
|
||||||
onConfirm?.();
|
onConfirm?.();
|
||||||
trackEvent('batch_operations', {
|
trackEvent('batch_operations', {
|
||||||
props: {
|
props: {
|
||||||
|
@ -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',
|
|
||||||
);
|
|
||||||
});
|
|
@ -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 “
|
|
||||||
{searchValue}”
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,7 +0,0 @@
|
|||||||
import { Navigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
const RedirectArchive = () => {
|
|
||||||
return <Navigate to='/archive' replace />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RedirectArchive;
|
|
@ -1,8 +1,8 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { getCreateTogglePath } from 'utils/routePathHelpers';
|
import { getCreateTogglePath } from 'utils/routePathHelpers';
|
||||||
import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
|
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
|
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
||||||
|
|
||||||
const StyledFeatureId = styled('strong')({
|
const StyledFeatureId = styled('strong')({
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
@ -11,7 +11,10 @@ const StyledFeatureId = styled('strong')({
|
|||||||
export const FeatureNotFound = () => {
|
export const FeatureNotFound = () => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
const featureId = useRequiredPathParam('featureId');
|
const featureId = useRequiredPathParam('featureId');
|
||||||
const { archivedFeatures } = useFeaturesArchive(projectId);
|
const { features: archivedFeatures } = useFeatureSearch({
|
||||||
|
project: `IS:${projectId}`,
|
||||||
|
archived: 'IS:true',
|
||||||
|
});
|
||||||
|
|
||||||
const createFeatureTogglePath = getCreateTogglePath(projectId, {
|
const createFeatureTogglePath = getCreateTogglePath(projectId, {
|
||||||
name: featureId,
|
name: featureId,
|
||||||
|
@ -35,14 +35,6 @@ exports[`returns all baseRoutes 1`] = `
|
|||||||
"title": "Create",
|
"title": "Create",
|
||||||
"type": "protected",
|
"type": "protected",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"component": [Function],
|
|
||||||
"menu": {},
|
|
||||||
"parent": "/archive",
|
|
||||||
"path": "/projects/:projectId/archived",
|
|
||||||
"title": ":projectId",
|
|
||||||
"type": "protected",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"component": [Function],
|
"component": [Function],
|
||||||
"menu": {},
|
"menu": {},
|
||||||
|
@ -10,7 +10,6 @@ import ResetPassword from 'component/user/ResetPassword/ResetPassword';
|
|||||||
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
|
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
|
||||||
import { ProjectList } from 'component/project/ProjectList/ProjectList';
|
import { ProjectList } from 'component/project/ProjectList/ProjectList';
|
||||||
import { ArchiveProjectList } from 'component/project/ProjectList/ArchiveProjectList';
|
import { ArchiveProjectList } from 'component/project/ProjectList/ArchiveProjectList';
|
||||||
import RedirectArchive from 'component/archive/RedirectArchive';
|
|
||||||
import CreateEnvironment from 'component/environments/CreateEnvironment/CreateEnvironment';
|
import CreateEnvironment from 'component/environments/CreateEnvironment/CreateEnvironment';
|
||||||
import EditEnvironment from 'component/environments/EditEnvironment/EditEnvironment';
|
import EditEnvironment from 'component/environments/EditEnvironment/EditEnvironment';
|
||||||
import { EditContext } from 'component/context/EditContext/EditContext';
|
import { EditContext } from 'component/context/EditContext/EditContext';
|
||||||
@ -80,14 +79,6 @@ export const routes: IRoute[] = [
|
|||||||
enterprise: true,
|
enterprise: true,
|
||||||
menu: {},
|
menu: {},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/projects/:projectId/archived',
|
|
||||||
title: ':projectId',
|
|
||||||
parent: '/archive',
|
|
||||||
component: RedirectArchive,
|
|
||||||
type: 'protected',
|
|
||||||
menu: {},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/projects/:projectId/features/:featureId/copy',
|
path: '/projects/:projectId/features/:featureId/copy',
|
||||||
parent: '/projects/:projectId/features/:featureId/',
|
parent: '/projects/:projectId/features/:featureId/',
|
||||||
|
@ -27,7 +27,6 @@ import useToast from 'hooks/useToast';
|
|||||||
import useQueryParams from 'hooks/useQueryParams';
|
import useQueryParams from 'hooks/useQueryParams';
|
||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import ProjectEnvironment from '../ProjectEnvironment/ProjectEnvironment';
|
import ProjectEnvironment from '../ProjectEnvironment/ProjectEnvironment';
|
||||||
import { ProjectFeaturesArchive } from './ProjectFeaturesArchive/ProjectFeaturesArchive';
|
|
||||||
import ProjectFlags from './ProjectFlags';
|
import ProjectFlags from './ProjectFlags';
|
||||||
import ProjectHealth from './ProjectHealth/ProjectHealth';
|
import ProjectHealth from './ProjectHealth/ProjectHealth';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
@ -387,7 +386,6 @@ export const Project = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path='environments' element={<ProjectEnvironment />} />
|
<Route path='environments' element={<ProjectEnvironment />} />
|
||||||
<Route path='archive' element={<ProjectFeaturesArchive />} />
|
|
||||||
<Route path='insights' element={<ProjectInsights />} />
|
<Route path='insights' element={<ProjectInsights />} />
|
||||||
<Route path='logs' element={<ProjectLog />} />
|
<Route path='logs' element={<ProjectLog />} />
|
||||||
<Route
|
<Route
|
||||||
|
@ -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} />;
|
|
||||||
};
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
};
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
};
|
|
@ -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;
|
|
||||||
};
|
|
@ -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;
|
|
||||||
};
|
|
@ -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;
|
|
||||||
};
|
|
@ -124,9 +124,6 @@ export * from './archiveProject401';
|
|||||||
export * from './archiveProject403';
|
export * from './archiveProject403';
|
||||||
export * from './archiveReleasePlanTemplate401';
|
export * from './archiveReleasePlanTemplate401';
|
||||||
export * from './archiveReleasePlanTemplate403';
|
export * from './archiveReleasePlanTemplate403';
|
||||||
export * from './archivedFeatureSchema';
|
|
||||||
export * from './archivedFeatureSchemaEnvironmentsItem';
|
|
||||||
export * from './archivedFeaturesSchema';
|
|
||||||
export * from './bannerSchema';
|
export * from './bannerSchema';
|
||||||
export * from './bannersSchema';
|
export * from './bannersSchema';
|
||||||
export * from './batchFeaturesSchema';
|
export * from './batchFeaturesSchema';
|
||||||
@ -710,10 +707,6 @@ export * from './getApplication404';
|
|||||||
export * from './getApplicationEnvironmentInstances404';
|
export * from './getApplicationEnvironmentInstances404';
|
||||||
export * from './getApplicationOverview404';
|
export * from './getApplicationOverview404';
|
||||||
export * from './getApplicationsParams';
|
export * from './getApplicationsParams';
|
||||||
export * from './getArchivedFeatures401';
|
|
||||||
export * from './getArchivedFeatures403';
|
|
||||||
export * from './getArchivedFeaturesByProjectId401';
|
|
||||||
export * from './getArchivedFeaturesByProjectId403';
|
|
||||||
export * from './getBanners401';
|
export * from './getBanners401';
|
||||||
export * from './getBaseUsersAndGroups401';
|
export * from './getBaseUsersAndGroups401';
|
||||||
export * from './getChangeRequest404';
|
export * from './getChangeRequest404';
|
||||||
|
@ -223,7 +223,9 @@ const searchFeaturesWithoutQueryParams = async (expectedCode = 200) => {
|
|||||||
};
|
};
|
||||||
const getProjectArchive = async (projectId = 'default', expectedCode = 200) => {
|
const getProjectArchive = async (projectId = 'default', expectedCode = 200) => {
|
||||||
return app.request
|
return app.request
|
||||||
.get(`/api/admin/archive/features/${projectId}`)
|
.get(
|
||||||
|
`/api/admin/search/features?project=IS%3A${projectId}&archived=IS%3Atrue`,
|
||||||
|
)
|
||||||
.expect(expectedCode);
|
.expect(expectedCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,23 +1,17 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import type { IUnleashConfig } from '../../types/option';
|
import type { IUnleashConfig } from '../../types/option';
|
||||||
import type { IUnleashServices } from '../../types';
|
import type { IUnleashServices } from '../../types';
|
||||||
import Controller from '../../routes/controller';
|
import Controller from '../../routes/controller';
|
||||||
import { extractUsername } from '../../util/extract-user';
|
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 FeatureToggleService from './feature-toggle-service';
|
||||||
import type { IAuthRequest } from '../../routes/unleash-types';
|
import type { IAuthRequest } from '../../routes/unleash-types';
|
||||||
import { serializeDates } from '../../types/serialize-dates';
|
|
||||||
import type { OpenApiService } from '../../services/openapi-service';
|
import type { OpenApiService } from '../../services/openapi-service';
|
||||||
import { createResponseSchema } from '../../openapi/util/create-response-schema';
|
|
||||||
import {
|
import {
|
||||||
emptyResponse,
|
emptyResponse,
|
||||||
getStandardResponses,
|
getStandardResponses,
|
||||||
} from '../../openapi/util/standard-responses';
|
} from '../../openapi/util/standard-responses';
|
||||||
import type { WithTransactional } from '../../db/transaction';
|
import type { WithTransactional } from '../../db/transaction';
|
||||||
import {
|
|
||||||
archivedFeaturesSchema,
|
|
||||||
type ArchivedFeaturesSchema,
|
|
||||||
} from '../../openapi';
|
|
||||||
|
|
||||||
export default class ArchiveController extends Controller {
|
export default class ArchiveController extends Controller {
|
||||||
private featureService: FeatureToggleService;
|
private featureService: FeatureToggleService;
|
||||||
@ -43,28 +37,6 @@ export default class ArchiveController extends Controller {
|
|||||||
this.transactionalFeatureToggleService =
|
this.transactionalFeatureToggleService =
|
||||||
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({
|
this.route({
|
||||||
method: 'delete',
|
method: 'delete',
|
||||||
path: '/:featureName',
|
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(
|
async deleteFeature(
|
||||||
req: IAuthRequest<{ featureName: string }>,
|
req: IAuthRequest<{ featureName: string }>,
|
||||||
res: Response<void>,
|
res: Response<void>,
|
||||||
|
@ -220,30 +220,4 @@ export class FeatureToggleRowConverter {
|
|||||||
|
|
||||||
return this.formatToggles(result);
|
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);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -178,10 +178,6 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return this.features.filter((feature) => feature.archived !== archived);
|
return this.features.filter((feature) => feature.archived !== archived);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getArchivedFeatures(project: string): Promise<FeatureToggle[]> {
|
|
||||||
return this.features.filter((feature) => feature.archived === true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPlaygroundFeatures(
|
async getPlaygroundFeatures(
|
||||||
query?: IFeatureToggleQuery,
|
query?: IFeatureToggleQuery,
|
||||||
): Promise<FeatureConfigurationClient[]> {
|
): Promise<FeatureConfigurationClient[]> {
|
||||||
|
@ -2162,12 +2162,6 @@ class FeatureToggleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getArchivedFeaturesByProjectId(
|
|
||||||
project: string,
|
|
||||||
): Promise<FeatureToggle[]> {
|
|
||||||
return this.featureToggleStore.getArchivedFeatures(project);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProjectId(name: string): Promise<string | undefined> {
|
async getProjectId(name: string): Promise<string | undefined> {
|
||||||
return this.featureToggleStore.getProjectId(name);
|
return this.featureToggleStore.getProjectId(name);
|
||||||
}
|
}
|
||||||
|
@ -258,32 +258,6 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return rows.map(this.rowToFeature);
|
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({
|
async getFeatureTypeCounts({
|
||||||
projectId,
|
projectId,
|
||||||
archived,
|
archived,
|
||||||
|
@ -54,37 +54,42 @@ test('Should be allowed to reuse deleted toggle name', async () => {
|
|||||||
test('Should get archived toggles via project', async () => {
|
test('Should get archived toggles via project', async () => {
|
||||||
await db.stores.featureToggleStore.deleteAll();
|
await db.stores.featureToggleStore.deleteAll();
|
||||||
|
|
||||||
|
const proj1 = 'proj-1';
|
||||||
|
const proj2 = 'proj-2';
|
||||||
|
|
||||||
await db.stores.projectStore.create({
|
await db.stores.projectStore.create({
|
||||||
id: 'proj-1',
|
id: proj1,
|
||||||
name: 'proj-1',
|
name: proj1,
|
||||||
description: '',
|
description: '',
|
||||||
mode: 'open' as const,
|
mode: 'open' as const,
|
||||||
});
|
});
|
||||||
await db.stores.projectStore.create({
|
await db.stores.projectStore.create({
|
||||||
id: 'proj-2',
|
id: proj2,
|
||||||
name: 'proj-2',
|
name: proj2,
|
||||||
description: '',
|
description: '',
|
||||||
mode: 'open' as const,
|
mode: 'open' as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.stores.featureToggleStore.create('proj-1', {
|
await db.stores.featureToggleStore.create(proj1, {
|
||||||
name: 'feat-proj-1',
|
name: 'feat-proj-1',
|
||||||
archived: true,
|
archived: true,
|
||||||
createdByUserId: 9999,
|
createdByUserId: 9999,
|
||||||
});
|
});
|
||||||
await db.stores.featureToggleStore.create('proj-2', {
|
await db.stores.featureToggleStore.create(proj2, {
|
||||||
name: 'feat-proj-2',
|
name: 'feat-proj-2',
|
||||||
archived: true,
|
archived: true,
|
||||||
createdByUserId: 9999,
|
createdByUserId: 9999,
|
||||||
});
|
});
|
||||||
await db.stores.featureToggleStore.create('proj-2', {
|
await db.stores.featureToggleStore.create(proj2, {
|
||||||
name: 'feat-proj-2-2',
|
name: 'feat-proj-2-2',
|
||||||
archived: true,
|
archived: true,
|
||||||
createdByUserId: 9999,
|
createdByUserId: 9999,
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.request
|
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(200)
|
||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect((res) => {
|
.expect((res) => {
|
||||||
@ -92,7 +97,9 @@ test('Should get archived toggles via project', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.request
|
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(200)
|
||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect((res) => {
|
.expect((res) => {
|
||||||
|
@ -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');
|
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 () => {
|
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';
|
const featureName = 'multiple-environment-last-seen-at-single-toggle';
|
||||||
await app.createFeature(featureName);
|
await app.createFeature(featureName);
|
||||||
|
@ -1047,7 +1047,9 @@ test('Should archive feature flag', async () => {
|
|||||||
|
|
||||||
await app.request.get(`${url}/${name}`).expect(404);
|
await app.request.get(`${url}/${name}`).expect(404);
|
||||||
const { body } = await app.request
|
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);
|
.expect(200);
|
||||||
|
|
||||||
const flag = body.features.find((f) => f.name === name);
|
const flag = body.features.find((f) => f.name === name);
|
||||||
|
@ -54,8 +54,6 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
|||||||
archived?: boolean,
|
archived?: boolean,
|
||||||
): Promise<FeatureToggle[]>;
|
): Promise<FeatureToggle[]>;
|
||||||
|
|
||||||
getArchivedFeatures(project: string): Promise<FeatureToggle[]>;
|
|
||||||
|
|
||||||
getPlaygroundFeatures(
|
getPlaygroundFeatures(
|
||||||
featureQuery?: IFeatureToggleQuery,
|
featureQuery?: IFeatureToggleQuery,
|
||||||
): Promise<FeatureConfigurationClient[]>;
|
): Promise<FeatureConfigurationClient[]>;
|
||||||
|
@ -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>;
|
|
@ -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>;
|
|
@ -24,8 +24,6 @@ export * from './application-overview-schema';
|
|||||||
export * from './application-schema';
|
export * from './application-schema';
|
||||||
export * from './application-usage-schema';
|
export * from './application-usage-schema';
|
||||||
export * from './applications-schema';
|
export * from './applications-schema';
|
||||||
export * from './archived-feature-schema';
|
|
||||||
export * from './archived-features-schema';
|
|
||||||
export * from './batch-features-schema';
|
export * from './batch-features-schema';
|
||||||
export * from './batch-stale-schema';
|
export * from './batch-stale-schema';
|
||||||
export * from './bulk-metrics-schema';
|
export * from './bulk-metrics-schema';
|
||||||
|
@ -68,7 +68,9 @@ afterAll(async () => {
|
|||||||
test('returns three archived flags', async () => {
|
test('returns three archived flags', async () => {
|
||||||
expect.assertions(1);
|
expect.assertions(1);
|
||||||
return app.request
|
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('Content-Type', /json/)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.expect((res) => {
|
.expect((res) => {
|
||||||
@ -79,7 +81,9 @@ test('returns three archived flags', async () => {
|
|||||||
test('returns three archived flags with archivedAt', async () => {
|
test('returns three archived flags with archivedAt', async () => {
|
||||||
expect.assertions(2);
|
expect.assertions(2);
|
||||||
return app.request
|
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('Content-Type', /json/)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.expect((res) => {
|
.expect((res) => {
|
||||||
@ -238,7 +242,9 @@ test('Should be able to bulk archive features', async () => {
|
|||||||
.expect(202);
|
.expect(202);
|
||||||
|
|
||||||
const { body } = await app.request
|
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);
|
.expect(200);
|
||||||
|
|
||||||
const archivedFeatures = body.features.filter(
|
const archivedFeatures = body.features.filter(
|
||||||
|
Loading…
Reference in New Issue
Block a user