mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-05 17:53:12 +02:00
feat: bulk revive features (#3321)
This commit is contained in:
parent
75d2930bcd
commit
138ac98094
@ -316,6 +316,14 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return this.rowToFeature(row[0]);
|
return this.rowToFeature(row[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchRevive(names: string[]): Promise<FeatureToggle[]> {
|
||||||
|
const rows = await this.db(TABLE)
|
||||||
|
.whereIn('name', names)
|
||||||
|
.update({ archived_at: null })
|
||||||
|
.returning(FEATURE_COLUMNS);
|
||||||
|
return rows.map((row) => this.rowToFeature(row));
|
||||||
|
}
|
||||||
|
|
||||||
async getVariants(featureName: string): Promise<IVariant[]> {
|
async getVariants(featureName: string): Promise<IVariant[]> {
|
||||||
if (!(await this.exists(featureName))) {
|
if (!(await this.exists(featureName))) {
|
||||||
throw new NotFoundError('No feature toggle found');
|
throw new NotFoundError('No feature toggle found');
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { IUnleashConfig } from '../../../types/option';
|
import { IUnleashConfig } from '../../../types/option';
|
||||||
import { IFlagResolver, IProjectParam, IUnleashServices } from '../../../types';
|
import {
|
||||||
|
IFlagResolver,
|
||||||
|
IProjectParam,
|
||||||
|
IUnleashServices,
|
||||||
|
UPDATE_FEATURE,
|
||||||
|
} from '../../../types';
|
||||||
import { Logger } from '../../../logger';
|
import { Logger } from '../../../logger';
|
||||||
import { extractUsername } from '../../../util/extract-user';
|
import { extractUsername } from '../../../util/extract-user';
|
||||||
import { DELETE_FEATURE } from '../../../types/permissions';
|
import { DELETE_FEATURE } from '../../../types/permissions';
|
||||||
@ -14,6 +19,7 @@ import Controller from '../../controller';
|
|||||||
|
|
||||||
const PATH = '/:projectId/archive';
|
const PATH = '/:projectId/archive';
|
||||||
const PATH_DELETE = `${PATH}/delete`;
|
const PATH_DELETE = `${PATH}/delete`;
|
||||||
|
const PATH_REVIVE = `${PATH}/revive`;
|
||||||
|
|
||||||
export default class ProjectArchiveController extends Controller {
|
export default class ProjectArchiveController extends Controller {
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
@ -52,6 +58,22 @@ export default class ProjectArchiveController extends Controller {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.route({
|
||||||
|
method: 'post',
|
||||||
|
path: PATH_REVIVE,
|
||||||
|
acceptAnyContentType: true,
|
||||||
|
handler: this.reviveFeatures,
|
||||||
|
permission: UPDATE_FEATURE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
tags: ['Archive'],
|
||||||
|
operationId: 'reviveFeatures',
|
||||||
|
requestBody: createRequestSchema('batchFeaturesSchema'),
|
||||||
|
responses: { 200: emptyResponse },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFeatures(
|
async deleteFeatures(
|
||||||
@ -67,6 +89,20 @@ export default class ProjectArchiveController extends Controller {
|
|||||||
await this.featureService.deleteFeatures(features, projectId, user);
|
await this.featureService.deleteFeatures(features, projectId, user);
|
||||||
res.status(200).end();
|
res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reviveFeatures(
|
||||||
|
req: IAuthRequest<IProjectParam, any, BatchFeaturesSchema>,
|
||||||
|
res: Response<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.flagResolver.isEnabled('bulkOperations')) {
|
||||||
|
throw new NotFoundError('Bulk operations are not enabled');
|
||||||
|
}
|
||||||
|
const { projectId } = req.params;
|
||||||
|
const { features } = req.body;
|
||||||
|
const user = extractUsername(req);
|
||||||
|
await this.featureService.reviveFeatures(features, projectId, user);
|
||||||
|
res.status(200).end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = ProjectArchiveController;
|
module.exports = ProjectArchiveController;
|
||||||
|
@ -1374,6 +1374,42 @@ class FeatureToggleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reviveFeatures(
|
||||||
|
featureNames: string[],
|
||||||
|
projectId: string,
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.validateFeaturesContext(featureNames, projectId);
|
||||||
|
|
||||||
|
const features = await this.featureToggleStore.getAllByNames(
|
||||||
|
featureNames,
|
||||||
|
);
|
||||||
|
const eligibleFeatures = features.filter(
|
||||||
|
(toggle) => toggle.archivedAt !== null,
|
||||||
|
);
|
||||||
|
const eligibleFeatureNames = eligibleFeatures.map(
|
||||||
|
(toggle) => toggle.name,
|
||||||
|
);
|
||||||
|
const tags = await this.tagStore.getAllByFeatures(eligibleFeatureNames);
|
||||||
|
await this.featureToggleStore.batchRevive(eligibleFeatureNames);
|
||||||
|
await this.eventStore.batchStore(
|
||||||
|
eligibleFeatures.map(
|
||||||
|
(feature) =>
|
||||||
|
new FeatureRevivedEvent({
|
||||||
|
featureName: feature.name,
|
||||||
|
createdBy,
|
||||||
|
project: feature.project,
|
||||||
|
tags: tags
|
||||||
|
.filter((tag) => tag.featureName === feature.name)
|
||||||
|
.map((tag) => ({
|
||||||
|
value: tag.tagValue,
|
||||||
|
type: tag.tagType,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: add project id.
|
// TODO: add project id.
|
||||||
async reviveToggle(featureName: string, createdBy: string): Promise<void> {
|
async reviveToggle(featureName: string, createdBy: string): Promise<void> {
|
||||||
const toggle = await this.featureToggleStore.revive(featureName);
|
const toggle = await this.featureToggleStore.revive(featureName);
|
||||||
|
@ -21,6 +21,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
|||||||
stale: boolean,
|
stale: boolean,
|
||||||
): Promise<FeatureToggle[]>;
|
): Promise<FeatureToggle[]>;
|
||||||
batchDelete(featureNames: string[]): Promise<void>;
|
batchDelete(featureNames: string[]): Promise<void>;
|
||||||
|
batchRevive(featureNames: string[]): Promise<FeatureToggle[]>;
|
||||||
revive(featureName: string): Promise<FeatureToggle>;
|
revive(featureName: string): Promise<FeatureToggle>;
|
||||||
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
||||||
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
|
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { setupAppWithCustomConfig } from '../../helpers/test-helper';
|
import { setupAppWithCustomConfig } from '../../helpers/test-helper';
|
||||||
import dbInit from '../../helpers/database-init';
|
import dbInit from '../../helpers/database-init';
|
||||||
import getLogger from '../../../fixtures/no-logger';
|
import getLogger from '../../../fixtures/no-logger';
|
||||||
|
import { DEFAULT_PROJECT } from '../../../../lib/types';
|
||||||
|
|
||||||
let app;
|
let app;
|
||||||
let db;
|
let db;
|
||||||
@ -203,8 +204,13 @@ test('can bulk delete features and recreate after', async () => {
|
|||||||
})
|
})
|
||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
.expect(201);
|
.expect(201);
|
||||||
await app.request.delete(`/api/admin/features/${feature}`).expect(200);
|
|
||||||
}
|
}
|
||||||
|
await app.request
|
||||||
|
.post(`/api/admin/projects/${DEFAULT_PROJECT}/archive`)
|
||||||
|
.send({
|
||||||
|
features,
|
||||||
|
})
|
||||||
|
.expect(202);
|
||||||
await app.request
|
await app.request
|
||||||
.post('/api/admin/projects/default/archive/delete')
|
.post('/api/admin/projects/default/archive/delete')
|
||||||
.send({ features })
|
.send({ features })
|
||||||
@ -217,3 +223,33 @@ test('can bulk delete features and recreate after', async () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('can bulk revive features', async () => {
|
||||||
|
const features = ['first.revive.issue', 'second.revive.issue'];
|
||||||
|
for (const feature of features) {
|
||||||
|
await app.request
|
||||||
|
.post('/api/admin/features')
|
||||||
|
.send({
|
||||||
|
name: feature,
|
||||||
|
enabled: false,
|
||||||
|
strategies: [{ name: 'default' }],
|
||||||
|
})
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(201);
|
||||||
|
}
|
||||||
|
await app.request
|
||||||
|
.post(`/api/admin/projects/${DEFAULT_PROJECT}/archive`)
|
||||||
|
.send({
|
||||||
|
features,
|
||||||
|
})
|
||||||
|
.expect(202);
|
||||||
|
await app.request
|
||||||
|
.post('/api/admin/projects/default/archive/revive')
|
||||||
|
.send({ features })
|
||||||
|
.expect(200);
|
||||||
|
for (const feature of features) {
|
||||||
|
await app.request
|
||||||
|
.get(`/api/admin/projects/default/features/${feature}`)
|
||||||
|
.expect(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
@ -6114,6 +6114,40 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/admin/projects/{projectId}/archive/revive": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "reviveFeatures",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "projectId",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/batchFeaturesSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"description": "batchFeaturesSchema",
|
||||||
|
"required": true,
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "This response has no body.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Archive",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/admin/projects/{projectId}/environments": {
|
"/api/admin/projects/{projectId}/environments": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "addEnvironmentToProject",
|
"operationId": "addEnvironmentToProject",
|
||||||
|
10
src/test/fixtures/fake-feature-toggle-store.ts
vendored
10
src/test/fixtures/fake-feature-toggle-store.ts
vendored
@ -54,6 +54,16 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchRevive(featureNames: string[]): Promise<FeatureToggle[]> {
|
||||||
|
const features = this.features.filter((f) =>
|
||||||
|
featureNames.includes(f.name),
|
||||||
|
);
|
||||||
|
for (const feature of features) {
|
||||||
|
feature.archived = false;
|
||||||
|
}
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
async count(query: Partial<IFeatureToggleQuery>): Promise<number> {
|
async count(query: Partial<IFeatureToggleQuery>): Promise<number> {
|
||||||
return this.features.filter(this.getFilterQuery(query)).length;
|
return this.features.filter(this.getFilterQuery(query)).length;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user