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

feat: bulk revive features (#3321)

This commit is contained in:
Jaanus Sellin 2023-03-16 09:51:18 +02:00 committed by GitHub
parent 75d2930bcd
commit 138ac98094
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 163 additions and 2 deletions

View File

@ -316,6 +316,14 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
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[]> {
if (!(await this.exists(featureName))) {
throw new NotFoundError('No feature toggle found');

View File

@ -1,6 +1,11 @@
import { Response } from 'express';
import { IUnleashConfig } from '../../../types/option';
import { IFlagResolver, IProjectParam, IUnleashServices } from '../../../types';
import {
IFlagResolver,
IProjectParam,
IUnleashServices,
UPDATE_FEATURE,
} from '../../../types';
import { Logger } from '../../../logger';
import { extractUsername } from '../../../util/extract-user';
import { DELETE_FEATURE } from '../../../types/permissions';
@ -14,6 +19,7 @@ import Controller from '../../controller';
const PATH = '/:projectId/archive';
const PATH_DELETE = `${PATH}/delete`;
const PATH_REVIVE = `${PATH}/revive`;
export default class ProjectArchiveController extends Controller {
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(
@ -67,6 +89,20 @@ export default class ProjectArchiveController extends Controller {
await this.featureService.deleteFeatures(features, projectId, user);
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;

View File

@ -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.
async reviveToggle(featureName: string, createdBy: string): Promise<void> {
const toggle = await this.featureToggleStore.revive(featureName);

View File

@ -21,6 +21,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
stale: boolean,
): Promise<FeatureToggle[]>;
batchDelete(featureNames: string[]): Promise<void>;
batchRevive(featureNames: string[]): Promise<FeatureToggle[]>;
revive(featureName: string): Promise<FeatureToggle>;
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
getAllByNames(names: string[]): Promise<FeatureToggle[]>;

View File

@ -1,6 +1,7 @@
import { setupAppWithCustomConfig } from '../../helpers/test-helper';
import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { DEFAULT_PROJECT } from '../../../../lib/types';
let app;
let db;
@ -203,8 +204,13 @@ test('can bulk delete features and recreate after', async () => {
})
.set('Content-Type', 'application/json')
.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
.post('/api/admin/projects/default/archive/delete')
.send({ features })
@ -217,3 +223,33 @@ test('can bulk delete features and recreate after', async () => {
.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);
}
});

View File

@ -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": {
"post": {
"operationId": "addEnvironmentToProject",

View File

@ -54,6 +54,16 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
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> {
return this.features.filter(this.getFilterQuery(query)).length;
}