mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-31 01:16:01 +02:00
refactor: remove deprecated GET archive features endpoint (#9924)
https://linear.app/unleash/issue/2-3366/remove-get-apiadminarchivefeatures-deprecated-in-4100 Removes GET `/api/admin/archive/features` which was deprecated in v4.10. Also cleans up related code. May include some slight scouting. **P.S.** Should we merge this into main, or is there a `v7` branch we should be targeting instead?
This commit is contained in:
parent
410142cb42
commit
42b6fc810e
@ -17,7 +17,6 @@ import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { Search } from 'component/common/Search/Search';
|
||||
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
|
||||
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
|
||||
import { ArchivedFeatureActionCell } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell';
|
||||
import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable';
|
||||
import theme from 'themes/theme';
|
||||
@ -32,7 +31,6 @@ import { RowSelectCell } from '../../project/Project/ProjectFeatureToggles/RowSe
|
||||
import { BatchSelectionActionsBar } from '../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
|
||||
import { ArchiveBatchActions } from './ArchiveBatchActions';
|
||||
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { ArchivedFeatureReviveConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm';
|
||||
|
||||
export interface IFeaturesArchiveTableProps {
|
||||
@ -46,7 +44,7 @@ export interface IFeaturesArchiveTableProps {
|
||||
| SortingRule<string>
|
||||
| ((prev: SortingRule<string>) => SortingRule<string>),
|
||||
) => SortingRule<string>;
|
||||
projectId?: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const ArchiveTable = ({
|
||||
@ -72,35 +70,27 @@ export const ArchiveTable = ({
|
||||
searchParams.get('search') || '',
|
||||
);
|
||||
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
...(projectId
|
||||
? [
|
||||
{
|
||||
id: 'Select',
|
||||
Header: ({ getToggleAllRowsSelectedProps }: any) => (
|
||||
<Checkbox
|
||||
data-testid='select_all_rows'
|
||||
{...getToggleAllRowsSelectedProps()}
|
||||
/>
|
||||
),
|
||||
Cell: ({ row }: any) => (
|
||||
<RowSelectCell
|
||||
{...row?.getToggleRowSelectedProps?.()}
|
||||
/>
|
||||
),
|
||||
maxWidth: 50,
|
||||
disableSortBy: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
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: ({ value, row: { original: feature } }: any) => {
|
||||
Cell: ({ row: { original: feature } }: any) => {
|
||||
return <FeatureEnvironmentSeenCell feature={feature} />;
|
||||
},
|
||||
align: 'center',
|
||||
@ -139,24 +129,6 @@ export const ArchiveTable = ({
|
||||
width: 150,
|
||||
Cell: FeatureArchivedCell,
|
||||
},
|
||||
...(!projectId
|
||||
? [
|
||||
{
|
||||
Header: 'Project ID',
|
||||
accessor: 'project',
|
||||
sortType: 'alphanumeric',
|
||||
filterName: 'project',
|
||||
searchable: true,
|
||||
maxWidth: 170,
|
||||
Cell: ({ value }: any) => (
|
||||
<LinkCell
|
||||
title={value}
|
||||
to={`/projects/${value}`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
Header: 'Actions',
|
||||
id: 'Actions',
|
||||
@ -184,8 +156,7 @@ export const ArchiveTable = ({
|
||||
searchable: true,
|
||||
},
|
||||
],
|
||||
//eslint-disable-next-line
|
||||
[projectId],
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
@ -270,7 +241,7 @@ export const ArchiveTable = ({
|
||||
replace: true,
|
||||
});
|
||||
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
|
||||
}, [loading, sortBy, searchValue]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [loading, sortBy, searchValue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -322,33 +293,28 @@ export const ArchiveTable = ({
|
||||
/>
|
||||
<ArchivedFeatureDeleteConfirm
|
||||
deletedFeatures={[deletedFeature?.name!]}
|
||||
projectId={projectId ?? deletedFeature?.project!}
|
||||
projectId={projectId}
|
||||
open={deleteModalOpen}
|
||||
setOpen={setDeleteModalOpen}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<ArchivedFeatureReviveConfirm
|
||||
revivedFeatures={[revivedFeature?.name!]}
|
||||
projectId={projectId ?? revivedFeature?.project!}
|
||||
projectId={projectId}
|
||||
open={reviveModalOpen}
|
||||
setOpen={setReviveModalOpen}
|
||||
refetch={refetch}
|
||||
/>
|
||||
</PageContent>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(projectId)}
|
||||
show={
|
||||
<BatchSelectionActionsBar
|
||||
count={Object.keys(selectedRowIds).length}
|
||||
>
|
||||
<ArchiveBatchActions
|
||||
selectedIds={Object.keys(selectedRowIds)}
|
||||
projectId={projectId!}
|
||||
onConfirm={() => toggleAllRowsSelected(false)}
|
||||
/>
|
||||
</BatchSelectionActionsBar>
|
||||
}
|
||||
/>
|
||||
<BatchSelectionActionsBar
|
||||
count={Object.keys(selectedRowIds).length}
|
||||
>
|
||||
<ArchiveBatchActions
|
||||
selectedIds={Object.keys(selectedRowIds)}
|
||||
projectId={projectId}
|
||||
onConfirm={() => toggleAllRowsSelected(false)}
|
||||
/>
|
||||
</BatchSelectionActionsBar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,32 +0,0 @@
|
||||
import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
|
||||
import { ArchiveTable } from './ArchiveTable/ArchiveTable';
|
||||
import type { SortingRule } from 'react-table';
|
||||
import { usePageTitle } from 'hooks/usePageTitle';
|
||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||
|
||||
const defaultSort: SortingRule<string> = { id: 'createdAt' };
|
||||
const { value, setValue } = createLocalStorage(
|
||||
'FeaturesArchiveTable:v1',
|
||||
defaultSort,
|
||||
);
|
||||
|
||||
export const FeaturesArchiveTable = () => {
|
||||
usePageTitle('Archive');
|
||||
|
||||
const {
|
||||
archivedFeatures = [],
|
||||
loading,
|
||||
refetchArchived,
|
||||
} = useFeaturesArchive();
|
||||
|
||||
return (
|
||||
<ArchiveTable
|
||||
title='Archive'
|
||||
archivedFeatures={archivedFeatures}
|
||||
loading={loading}
|
||||
storedParams={value}
|
||||
setStoredParams={setValue}
|
||||
refetch={refetchArchived}
|
||||
/>
|
||||
);
|
||||
};
|
@ -11,7 +11,7 @@ const StyledFeatureId = styled('strong')({
|
||||
export const FeatureNotFound = () => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const featureId = useRequiredPathParam('featureId');
|
||||
const { archivedFeatures } = useFeaturesArchive();
|
||||
const { archivedFeatures } = useFeaturesArchive(projectId);
|
||||
|
||||
const createFeatureTogglePath = getCreateTogglePath(projectId, {
|
||||
name: featureId,
|
||||
|
@ -440,13 +440,6 @@ exports[`returns all baseRoutes 1`] = `
|
||||
"title": "Login history",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"menu": {},
|
||||
"path": "/archive",
|
||||
"title": "Archived flags",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": {
|
||||
"$$typeof": Symbol(react.lazy),
|
||||
|
@ -31,7 +31,6 @@ import { EditSegment } from 'component/segments/EditSegment/EditSegment';
|
||||
import type { INavigationMenuItem, IRoute } from 'interfaces/route';
|
||||
import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable';
|
||||
import { SegmentTable } from '../segments/SegmentTable/SegmentTable';
|
||||
import { FeaturesArchiveTable } from '../archive/FeaturesArchiveTable';
|
||||
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
|
||||
import { Profile } from 'component/user/Profile/Profile';
|
||||
import { LazyFeatureView } from 'component/feature/FeatureView/LazyFeatureView';
|
||||
@ -456,15 +455,6 @@ export const routes: IRoute[] = [
|
||||
menu: { adminSettings: true },
|
||||
},
|
||||
|
||||
// Archive
|
||||
{
|
||||
path: '/archive',
|
||||
title: 'Archived flags',
|
||||
component: FeaturesArchiveTable,
|
||||
type: 'protected',
|
||||
menu: {},
|
||||
},
|
||||
|
||||
// Admin
|
||||
{
|
||||
path: '/admin/*',
|
||||
|
@ -9,13 +9,9 @@ const fetcher = (path: string) => {
|
||||
.then((res) => res.json());
|
||||
};
|
||||
|
||||
export const useFeaturesArchive = (projectId?: string) => {
|
||||
export const useFeaturesArchive = (projectId: string) => {
|
||||
const { data, error, mutate, isLoading } = useSWR<ArchivedFeaturesSchema>(
|
||||
formatApiPath(
|
||||
projectId
|
||||
? `/api/admin/archive/features/${projectId}`
|
||||
: 'api/admin/archive/features',
|
||||
),
|
||||
formatApiPath(`/api/admin/archive/features/${projectId}`),
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: 15 * 1000, // ms
|
||||
|
@ -2,10 +2,7 @@ import type { Request, Response } from 'express';
|
||||
import type { IUnleashConfig } from '../../types/option';
|
||||
import type { IUnleashServices } from '../../types';
|
||||
import Controller from '../../routes/controller';
|
||||
import {
|
||||
extractUserIdFromUser,
|
||||
extractUsername,
|
||||
} from '../../util/extract-user';
|
||||
import { extractUsername } from '../../util/extract-user';
|
||||
import { DELETE_FEATURE, NONE, UPDATE_FEATURE } from '../../types/permissions';
|
||||
import type FeatureToggleService from './feature-toggle-service';
|
||||
import type { IAuthRequest } from '../../routes/unleash-types';
|
||||
@ -46,28 +43,6 @@ export default class ArchiveController extends Controller {
|
||||
this.transactionalFeatureToggleService =
|
||||
transactionalFeatureToggleService;
|
||||
|
||||
this.route({
|
||||
method: 'get',
|
||||
path: '/features',
|
||||
handler: this.getArchivedFeatures,
|
||||
permission: NONE,
|
||||
middleware: [
|
||||
openApiService.validPath({
|
||||
tags: ['Archive'],
|
||||
summary: 'Get archived features',
|
||||
description:
|
||||
'Retrieve a list of all [archived feature flags](https://docs.getunleash.io/reference/feature-toggles#archive-a-feature-flag).',
|
||||
operationId: 'getArchivedFeatures',
|
||||
responses: {
|
||||
200: createResponseSchema('archivedFeaturesSchema'),
|
||||
...getStandardResponses(401, 403),
|
||||
},
|
||||
|
||||
deprecated: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
this.route({
|
||||
method: 'get',
|
||||
path: '/features/:projectId',
|
||||
@ -133,45 +108,13 @@ export default class ArchiveController extends Controller {
|
||||
});
|
||||
}
|
||||
|
||||
async getArchivedFeatures(
|
||||
req: IAuthRequest,
|
||||
res: Response<ArchivedFeaturesSchema>,
|
||||
): Promise<void> {
|
||||
const { user } = req;
|
||||
const features = await this.featureService.getAllArchivedFeatures(
|
||||
true,
|
||||
extractUserIdFromUser(user),
|
||||
);
|
||||
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
archivedFeaturesSchema.$id,
|
||||
{
|
||||
version: 2,
|
||||
features: serializeDates(
|
||||
features.map((feature) => {
|
||||
return {
|
||||
...feature,
|
||||
stale: feature.stale || false,
|
||||
archivedAt: feature.archivedAt!,
|
||||
};
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getArchivedFeaturesByProjectId(
|
||||
req: Request<{ projectId: string }, any, any, any>,
|
||||
res: Response<ArchivedFeaturesSchema>,
|
||||
): Promise<void> {
|
||||
const { projectId } = req.params;
|
||||
const features =
|
||||
await this.featureService.getArchivedFeaturesByProjectId(
|
||||
true,
|
||||
projectId,
|
||||
);
|
||||
await this.featureService.getArchivedFeaturesByProjectId(projectId);
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
|
@ -2162,25 +2162,7 @@ class FeatureToggleService {
|
||||
);
|
||||
}
|
||||
|
||||
async getAllArchivedFeatures(
|
||||
archived: boolean,
|
||||
userId: number,
|
||||
): Promise<FeatureToggle[]> {
|
||||
const features = await this.featureToggleStore.getArchivedFeatures();
|
||||
|
||||
const projectAccess =
|
||||
await this.privateProjectChecker.getUserAccessibleProjects(userId);
|
||||
if (projectAccess.mode === 'all') {
|
||||
return features;
|
||||
} else {
|
||||
return features.filter((f) =>
|
||||
projectAccess.projects.includes(f.project),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getArchivedFeaturesByProjectId(
|
||||
archived: boolean,
|
||||
project: string,
|
||||
): Promise<FeatureToggle[]> {
|
||||
return this.featureToggleStore.getArchivedFeatures(project);
|
||||
|
@ -258,7 +258,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
return rows.map(this.rowToFeature);
|
||||
}
|
||||
|
||||
async getArchivedFeatures(project?: string): Promise<FeatureToggle[]> {
|
||||
async getArchivedFeatures(project: string): Promise<FeatureToggle[]> {
|
||||
const builder = new FeatureToggleListBuilder(this.db, [
|
||||
...commonSelectColumns,
|
||||
'features.archived_at as archived_at',
|
||||
@ -274,18 +274,10 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
'last_seen_at_metrics.environment as last_seen_at_env',
|
||||
);
|
||||
|
||||
let rows: any[];
|
||||
|
||||
if (project) {
|
||||
rows = await builder.internalQuery
|
||||
.select(builder.getSelectColumns())
|
||||
.where({ project })
|
||||
.whereNotNull('archived_at');
|
||||
} else {
|
||||
rows = await builder.internalQuery
|
||||
.select(builder.getSelectColumns())
|
||||
.whereNotNull('archived_at');
|
||||
}
|
||||
const rows = await builder.internalQuery
|
||||
.select(builder.getSelectColumns())
|
||||
.where({ project })
|
||||
.whereNotNull('archived_at');
|
||||
|
||||
return this.featureToggleRowConverter.buildArchivedFeatureToggleListFromRows(
|
||||
rows,
|
||||
|
@ -32,16 +32,6 @@ afterAll(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('Should get empty features via admin', async () => {
|
||||
await app.request
|
||||
.get('/api/admin/archive/features')
|
||||
.expect(200)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect((res) => {
|
||||
expect(res.body.features).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Should be allowed to reuse deleted toggle name', async () => {
|
||||
await app.request
|
||||
.post('/api/admin/projects/default/features')
|
||||
@ -61,30 +51,6 @@ test('Should be allowed to reuse deleted toggle name', async () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
test('Should get archived toggles via admin', async () => {
|
||||
await app.request
|
||||
.post('/api/admin/projects/default/features')
|
||||
.send({
|
||||
name: 'archived.test.1',
|
||||
archived: true,
|
||||
})
|
||||
.expect(201);
|
||||
await app.request
|
||||
.post('/api/admin/projects/default/features')
|
||||
.send({
|
||||
name: 'archived.test.2',
|
||||
archived: true,
|
||||
})
|
||||
.expect(201);
|
||||
await app.request
|
||||
.get('/api/admin/archive/features')
|
||||
.expect(200)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect((res) => {
|
||||
expect(res.body.features).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('Should get archived toggles via project', async () => {
|
||||
await db.stores.featureToggleStore.deleteAll();
|
||||
|
||||
@ -132,14 +98,6 @@ test('Should get archived toggles via project', async () => {
|
||||
.expect((res) => {
|
||||
expect(res.body.features).toHaveLength(2);
|
||||
});
|
||||
|
||||
await app.request
|
||||
.get('/api/admin/archive/features')
|
||||
.expect(200)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect((res) => {
|
||||
expect(res.body.features).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
test('Should be able to revive toggle', async () => {
|
||||
|
@ -80,26 +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', async () => {
|
||||
const featureName = 'multiple-environment-last-seen-at-archived';
|
||||
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`);
|
||||
|
||||
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 for multiple environments in /api/admin/archive/features/:projectId', async () => {
|
||||
const featureName = 'multiple-environment-last-seen-at-archived-project';
|
||||
await setupLastSeenAtTest(featureName);
|
||||
|
@ -1036,7 +1036,8 @@ test('Patching feature flags to active (turning stale to false) should trigger F
|
||||
});
|
||||
|
||||
test('Should archive feature flag', async () => {
|
||||
const url = '/api/admin/projects/default/features';
|
||||
const projectId = 'default';
|
||||
const url = `/api/admin/projects/${projectId}/features`;
|
||||
const name = 'new.flag.archive';
|
||||
await app.request
|
||||
.post(url)
|
||||
@ -1046,7 +1047,7 @@ test('Should archive feature flag', async () => {
|
||||
|
||||
await app.request.get(`${url}/${name}`).expect(404);
|
||||
const { body } = await app.request
|
||||
.get(`/api/admin/archive/features`)
|
||||
.get(`/api/admin/archive/features/${projectId}`)
|
||||
.expect(200);
|
||||
|
||||
const flag = body.features.find((f) => f.name === name);
|
||||
|
@ -54,7 +54,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
||||
archived?: boolean,
|
||||
): Promise<FeatureToggle[]>;
|
||||
|
||||
getArchivedFeatures(project?: string): Promise<FeatureToggle[]>;
|
||||
getArchivedFeatures(project: string): Promise<FeatureToggle[]>;
|
||||
|
||||
getPlaygroundFeatures(
|
||||
featureQuery?: IFeatureToggleQuery,
|
||||
|
@ -68,7 +68,7 @@ afterAll(async () => {
|
||||
test('returns three archived flags', async () => {
|
||||
expect.assertions(1);
|
||||
return app.request
|
||||
.get('/api/admin/archive/features')
|
||||
.get(`/api/admin/archive/features/${DEFAULT_PROJECT}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
@ -79,7 +79,7 @@ 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')
|
||||
.get(`/api/admin/archive/features/${DEFAULT_PROJECT}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
|
@ -4,31 +4,6 @@ title: /api/admin/archive
|
||||
|
||||
> In order to access the admin API endpoints you need to identify yourself. Unless you're using the `none` authentication method, you'll need to [create an ADMIN token](/how-to/how-to-create-api-tokens) and add an Authorization header using the token.
|
||||
|
||||
### Fetch archived flags {#fetch-archived-toggles}
|
||||
|
||||
`GET http://unleash.host.com/api/admin/archive/features`
|
||||
|
||||
Used to fetch list of archived feature flags
|
||||
|
||||
**Example response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"features": [
|
||||
{
|
||||
"name": "Feature.A",
|
||||
"description": "lorem ipsum",
|
||||
"type": "release",
|
||||
"stale": false,
|
||||
"variants": [],
|
||||
"tags": [],
|
||||
"strategy": "default",
|
||||
"parameters": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Revive feature flag {#revive-feature-toggle}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user