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

feat: export by tags (#3635)

This commit is contained in:
Mateusz Kwasniewski 2023-04-27 10:22:14 +02:00 committed by GitHub
parent 4f12361c94
commit 70a8ab4c47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 177 additions and 56 deletions

View File

@ -110,6 +110,14 @@ class FeatureTagStore implements IFeatureTagStore {
} }
} }
async getAllFeaturesForTag(tagValue: string): Promise<string[]> {
const rows = await this.db
.select('feature_name')
.from<FeatureTagTable>(TABLE)
.where({ tag_value: tagValue });
return rows.map(({ feature_name }) => feature_name);
}
async featureExists(featureName: string): Promise<boolean> { async featureExists(featureName: string): Promise<boolean> {
const result = await this.db.raw( const result = await this.db.raw(
'SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present', 'SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present',

View File

@ -169,7 +169,7 @@ export default class ExportImportService {
const errors = ImportValidationMessages.compileErrors( const errors = ImportValidationMessages.compileErrors(
dto.project, dto.project,
unsupportedStrategies, unsupportedStrategies,
unsupportedContextFields, unsupportedContextFields || [],
[], [],
otherProjectFeatures, otherProjectFeatures,
false, false,
@ -226,7 +226,7 @@ export default class ExportImportService {
private async importToggleStatuses(dto: ImportTogglesSchema, user: User) { private async importToggleStatuses(dto: ImportTogglesSchema, user: User) {
await Promise.all( await Promise.all(
dto.data.featureEnvironments?.map((featureEnvironment) => (dto.data.featureEnvironments || []).map((featureEnvironment) =>
this.featureToggleService.updateEnabled( this.featureToggleService.updateEnabled(
dto.project, dto.project,
featureEnvironment.name, featureEnvironment.name,
@ -281,13 +281,15 @@ export default class ExportImportService {
dto.data.features.map((feature) => feature.name), dto.data.features.map((feature) => feature.name),
); );
return Promise.all( return Promise.all(
dto.data.featureTags?.map((tag) => (dto.data.featureTags || []).map((tag) => {
this.featureTagService.addTag( return tag.tagType
tag.featureName, ? this.featureTagService.addTag(
{ type: tag.tagType, value: tag.tagValue }, tag.featureName,
extractUsernameFromUser(user), { type: tag.tagType, value: tag.tagValue },
), extractUsernameFromUser(user),
), )
: Promise.resolve();
}),
); );
} }
@ -311,12 +313,14 @@ export default class ExportImportService {
private async importTagTypes(dto: ImportTogglesSchema, user: User) { private async importTagTypes(dto: ImportTogglesSchema, user: User) {
const newTagTypes = await this.getNewTagTypes(dto); const newTagTypes = await this.getNewTagTypes(dto);
return Promise.all( return Promise.all(
newTagTypes.map((tagType) => newTagTypes.map((tagType) => {
this.tagTypeService.createTagType( return tagType
tagType, ? this.tagTypeService.createTagType(
extractUsernameFromUser(user), tagType,
), extractUsernameFromUser(user),
), )
: Promise.resolve();
}),
); );
} }
@ -328,15 +332,17 @@ export default class ExportImportService {
featureEnvironment.variants.length > 0, featureEnvironment.variants.length > 0,
) || []; ) || [];
await Promise.all( await Promise.all(
featureEnvsWithVariants.map((featureEnvironment) => featureEnvsWithVariants.map((featureEnvironment) => {
this.featureToggleService.saveVariantsOnEnv( return featureEnvironment.featureName
dto.project, ? this.featureToggleService.saveVariantsOnEnv(
featureEnvironment.featureName, dto.project,
dto.environment, featureEnvironment.featureName,
featureEnvironment.variants as IVariant[], dto.environment,
user, featureEnvironment.variants as IVariant[],
), user,
), )
: Promise.resolve();
}),
); );
} }
@ -395,11 +401,10 @@ export default class ExportImportService {
private async cleanData(dto: ImportTogglesSchema) { private async cleanData(dto: ImportTogglesSchema) {
const removedFeaturesDto = await this.removeArchivedFeatures(dto); const removedFeaturesDto = await this.removeArchivedFeatures(dto);
const remappedDto = this.remapSegments(removedFeaturesDto); return ExportImportService.remapSegments(removedFeaturesDto);
return remappedDto;
} }
private async remapSegments(dto: ImportTogglesSchema) { private static async remapSegments(dto: ImportTogglesSchema) {
return { return {
...dto, ...dto,
data: { data: {
@ -533,14 +538,12 @@ export default class ExportImportService {
const existingTagTypes = (await this.tagTypeService.getAll()).map( const existingTagTypes = (await this.tagTypeService.getAll()).map(
(tagType) => tagType.name, (tagType) => tagType.name,
); );
const newTagTypes = const newTagTypes = (dto.data.tagTypes || []).filter(
dto.data.tagTypes?.filter( (tagType) => !existingTagTypes.includes(tagType.name),
(tagType) => !existingTagTypes.includes(tagType.name), );
) || []; return [
const uniqueTagTypes = [
...new Map(newTagTypes.map((item) => [item.name, item])).values(), ...new Map(newTagTypes.map((item) => [item.name, item])).values(),
]; ];
return uniqueTagTypes;
} }
private async getNewContextFields(dto: ImportTogglesSchema) { private async getNewContextFields(dto: ImportTogglesSchema) {
@ -559,6 +562,10 @@ export default class ExportImportService {
query: ExportQuerySchema, query: ExportQuerySchema,
userName: string, userName: string,
): Promise<ExportResultSchema> { ): Promise<ExportResultSchema> {
const featureNames =
typeof query.tag === 'string'
? await this.featureTagService.listFeatures(query.tag)
: (query.features as string[]) || [];
const [ const [
features, features,
featureEnvironments, featureEnvironments,
@ -569,18 +576,18 @@ export default class ExportImportService {
segments, segments,
tagTypes, tagTypes,
] = await Promise.all([ ] = await Promise.all([
this.toggleStore.getAllByNames(query.features), this.toggleStore.getAllByNames(featureNames),
await this.featureEnvironmentStore.getAllByFeatures( await this.featureEnvironmentStore.getAllByFeatures(
query.features, featureNames,
query.environment, query.environment,
), ),
this.featureStrategiesStore.getAllByFeatures( this.featureStrategiesStore.getAllByFeatures(
query.features, featureNames,
query.environment, query.environment,
), ),
this.segmentStore.getAllFeatureStrategySegments(), this.segmentStore.getAllFeatureStrategySegments(),
this.contextFieldStore.getAll(), this.contextFieldStore.getAll(),
this.featureTagStore.getAllByFeatures(query.features), this.featureTagStore.getAllByFeatures(featureNames),
this.segmentStore.getAll(), this.segmentStore.getAll(),
this.tagTypeStore.getAll(), this.tagTypeStore.getAll(),
]); ]);

View File

@ -295,6 +295,62 @@ test('exports features', async () => {
}); });
}); });
test('exports features by tag', async () => {
await createProjects();
const strategy = {
name: 'default',
parameters: { rollout: '100', stickiness: 'default' },
constraints: [
{
contextName: 'appName',
values: ['test'],
operator: 'IN' as const,
},
],
};
await createToggle(
{
name: 'first_feature',
description: 'the #1 feature',
},
strategy,
['mytag'],
);
await createToggle(
{
name: 'second_feature',
description: 'the #1 feature',
},
strategy,
['anothertag'],
);
const { body } = await app.request
.post('/api/admin/features-batch/export')
.send({
tag: 'mytag',
environment: 'default',
})
.set('Content-Type', 'application/json')
.expect(200);
const { name, ...resultStrategy } = strategy;
expect(body).toMatchObject({
features: [
{
name: 'first_feature',
},
],
featureStrategies: [resultStrategy],
featureEnvironments: [
{
enabled: false,
environment: 'default',
featureName: 'first_feature',
},
],
});
});
test('should export custom context fields from strategies and variants', async () => { test('should export custom context fields from strategies and variants', async () => {
await createProjects(); await createProjects();
const strategyContext = { const strategyContext = {

View File

@ -3,16 +3,9 @@ import { FromSchema } from 'json-schema-to-ts';
export const exportQuerySchema = { export const exportQuerySchema = {
$id: '#/components/schemas/exportQuerySchema', $id: '#/components/schemas/exportQuerySchema',
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: true,
required: ['features', 'environment'], required: ['environment'],
properties: { properties: {
features: {
type: 'array',
items: {
type: 'string',
minLength: 1,
},
},
environment: { environment: {
type: 'string', type: 'string',
}, },
@ -20,6 +13,31 @@ export const exportQuerySchema = {
type: 'boolean', type: 'boolean',
}, },
}, },
oneOf: [
{
required: ['features'],
properties: {
features: {
type: 'array',
items: {
type: 'string',
minLength: 1,
},
description: 'Selects features to export by name.',
},
},
},
{
required: ['tag'],
properties: {
tag: {
type: 'string',
description:
'Selects features to export by tag. Takes precedence over the features field.',
},
},
},
],
components: { components: {
schemas: {}, schemas: {},
}, },

View File

@ -47,6 +47,10 @@ class FeatureTagService {
return this.featureTagStore.getAllTagsForFeature(featureName); return this.featureTagStore.getAllTagsForFeature(featureName);
} }
async listFeatures(tagValue: string): Promise<string[]> {
return this.featureTagStore.getAllFeaturesForTag(tagValue);
}
// TODO: add project Id // TODO: add project Id
async addTag( async addTag(
featureName: string, featureName: string,

View File

@ -13,6 +13,7 @@ export interface IFeatureAndTag {
} }
export interface IFeatureTagStore extends Store<IFeatureTag, IFeatureTag> { export interface IFeatureTagStore extends Store<IFeatureTag, IFeatureTag> {
getAllTagsForFeature(featureName: string): Promise<ITag[]>; getAllTagsForFeature(featureName: string): Promise<ITag[]>;
getAllFeaturesForTag(tagValue: string): Promise<string[]>;
getAllByFeatures(features: string[]): Promise<IFeatureTag[]>; getAllByFeatures(features: string[]): Promise<IFeatureTag[]>;
tagFeature(featureName: string, tag: ITag): Promise<ITag>; tagFeature(featureName: string, tag: ITag): Promise<ITag>;
tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>; tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>;

View File

@ -3,7 +3,7 @@ import { Store } from './store';
export interface ITagType { export interface ITagType {
name: string; name: string;
description?: string; description?: string;
icon?: string; icon?: string | null;
} }
export interface ITagTypeStore extends Store<ITagType, string> { export interface ITagTypeStore extends Store<ITagType, string> {

View File

@ -1851,7 +1851,35 @@ The provider you choose for your addon dictates what properties the \`parameters
"type": "object", "type": "object",
}, },
"exportQuerySchema": { "exportQuerySchema": {
"additionalProperties": false, "additionalProperties": true,
"oneOf": [
{
"properties": {
"features": {
"description": "Selects features to export by name.",
"items": {
"minLength": 1,
"type": "string",
},
"type": "array",
},
},
"required": [
"features",
],
},
{
"properties": {
"tag": {
"description": "Selects features to export by tag. Takes precedence over the features field.",
"type": "string",
},
},
"required": [
"tag",
],
},
],
"properties": { "properties": {
"downloadFile": { "downloadFile": {
"type": "boolean", "type": "boolean",
@ -1859,16 +1887,8 @@ The provider you choose for your addon dictates what properties the \`parameters
"environment": { "environment": {
"type": "string", "type": "string",
}, },
"features": {
"items": {
"minLength": 1,
"type": "string",
},
"type": "array",
},
}, },
"required": [ "required": [
"features",
"environment", "environment",
], ],
"type": "object", "type": "object",

View File

@ -18,6 +18,13 @@ export default class FakeFeatureTagStore implements IFeatureTagStore {
return Promise.resolve(tags); return Promise.resolve(tags);
} }
async getAllFeaturesForTag(tagValue: string): Promise<string[]> {
const tags = this.featureTags
.filter((f) => f.tagValue === tagValue)
.map((f) => f.featureName);
return Promise.resolve(tags);
}
async delete(key: IFeatureTag): Promise<void> { async delete(key: IFeatureTag): Promise<void> {
this.featureTags.splice( this.featureTags.splice(
this.featureTags.findIndex((t) => t === key), this.featureTags.findIndex((t) => t === key),