From 70a8ab4c4733c3437a9da903d7ce0896af7c5511 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Thu, 27 Apr 2023 10:22:14 +0200 Subject: [PATCH] feat: export by tags (#3635) --- src/lib/db/feature-tag-store.ts | 8 ++ .../export-import-service.ts | 81 ++++++++++--------- .../export-import.e2e.test.ts | 56 +++++++++++++ src/lib/openapi/spec/export-query-schema.ts | 36 ++++++--- src/lib/services/feature-tag-service.ts | 4 + src/lib/types/stores/feature-tag-store.ts | 1 + src/lib/types/stores/tag-type-store.ts | 2 +- .../__snapshots__/openapi.e2e.test.ts.snap | 38 ++++++--- src/test/fixtures/fake-feature-tag-store.ts | 7 ++ 9 files changed, 177 insertions(+), 56 deletions(-) diff --git a/src/lib/db/feature-tag-store.ts b/src/lib/db/feature-tag-store.ts index 637464b934f..76899e1e0e7 100644 --- a/src/lib/db/feature-tag-store.ts +++ b/src/lib/db/feature-tag-store.ts @@ -110,6 +110,14 @@ class FeatureTagStore implements IFeatureTagStore { } } + async getAllFeaturesForTag(tagValue: string): Promise { + const rows = await this.db + .select('feature_name') + .from(TABLE) + .where({ tag_value: tagValue }); + return rows.map(({ feature_name }) => feature_name); + } + async featureExists(featureName: string): Promise { const result = await this.db.raw( 'SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present', diff --git a/src/lib/features/export-import-toggles/export-import-service.ts b/src/lib/features/export-import-toggles/export-import-service.ts index 4626889d571..ae9ac604ac4 100644 --- a/src/lib/features/export-import-toggles/export-import-service.ts +++ b/src/lib/features/export-import-toggles/export-import-service.ts @@ -169,7 +169,7 @@ export default class ExportImportService { const errors = ImportValidationMessages.compileErrors( dto.project, unsupportedStrategies, - unsupportedContextFields, + unsupportedContextFields || [], [], otherProjectFeatures, false, @@ -226,7 +226,7 @@ export default class ExportImportService { private async importToggleStatuses(dto: ImportTogglesSchema, user: User) { await Promise.all( - dto.data.featureEnvironments?.map((featureEnvironment) => + (dto.data.featureEnvironments || []).map((featureEnvironment) => this.featureToggleService.updateEnabled( dto.project, featureEnvironment.name, @@ -281,13 +281,15 @@ export default class ExportImportService { dto.data.features.map((feature) => feature.name), ); return Promise.all( - dto.data.featureTags?.map((tag) => - this.featureTagService.addTag( - tag.featureName, - { type: tag.tagType, value: tag.tagValue }, - extractUsernameFromUser(user), - ), - ), + (dto.data.featureTags || []).map((tag) => { + return tag.tagType + ? this.featureTagService.addTag( + tag.featureName, + { 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) { const newTagTypes = await this.getNewTagTypes(dto); return Promise.all( - newTagTypes.map((tagType) => - this.tagTypeService.createTagType( - tagType, - extractUsernameFromUser(user), - ), - ), + newTagTypes.map((tagType) => { + return tagType + ? this.tagTypeService.createTagType( + tagType, + extractUsernameFromUser(user), + ) + : Promise.resolve(); + }), ); } @@ -328,15 +332,17 @@ export default class ExportImportService { featureEnvironment.variants.length > 0, ) || []; await Promise.all( - featureEnvsWithVariants.map((featureEnvironment) => - this.featureToggleService.saveVariantsOnEnv( - dto.project, - featureEnvironment.featureName, - dto.environment, - featureEnvironment.variants as IVariant[], - user, - ), - ), + featureEnvsWithVariants.map((featureEnvironment) => { + return featureEnvironment.featureName + ? this.featureToggleService.saveVariantsOnEnv( + dto.project, + featureEnvironment.featureName, + dto.environment, + featureEnvironment.variants as IVariant[], + user, + ) + : Promise.resolve(); + }), ); } @@ -395,11 +401,10 @@ export default class ExportImportService { private async cleanData(dto: ImportTogglesSchema) { const removedFeaturesDto = await this.removeArchivedFeatures(dto); - const remappedDto = this.remapSegments(removedFeaturesDto); - return remappedDto; + return ExportImportService.remapSegments(removedFeaturesDto); } - private async remapSegments(dto: ImportTogglesSchema) { + private static async remapSegments(dto: ImportTogglesSchema) { return { ...dto, data: { @@ -533,14 +538,12 @@ export default class ExportImportService { const existingTagTypes = (await this.tagTypeService.getAll()).map( (tagType) => tagType.name, ); - const newTagTypes = - dto.data.tagTypes?.filter( - (tagType) => !existingTagTypes.includes(tagType.name), - ) || []; - const uniqueTagTypes = [ + const newTagTypes = (dto.data.tagTypes || []).filter( + (tagType) => !existingTagTypes.includes(tagType.name), + ); + return [ ...new Map(newTagTypes.map((item) => [item.name, item])).values(), ]; - return uniqueTagTypes; } private async getNewContextFields(dto: ImportTogglesSchema) { @@ -559,6 +562,10 @@ export default class ExportImportService { query: ExportQuerySchema, userName: string, ): Promise { + const featureNames = + typeof query.tag === 'string' + ? await this.featureTagService.listFeatures(query.tag) + : (query.features as string[]) || []; const [ features, featureEnvironments, @@ -569,18 +576,18 @@ export default class ExportImportService { segments, tagTypes, ] = await Promise.all([ - this.toggleStore.getAllByNames(query.features), + this.toggleStore.getAllByNames(featureNames), await this.featureEnvironmentStore.getAllByFeatures( - query.features, + featureNames, query.environment, ), this.featureStrategiesStore.getAllByFeatures( - query.features, + featureNames, query.environment, ), this.segmentStore.getAllFeatureStrategySegments(), this.contextFieldStore.getAll(), - this.featureTagStore.getAllByFeatures(query.features), + this.featureTagStore.getAllByFeatures(featureNames), this.segmentStore.getAll(), this.tagTypeStore.getAll(), ]); diff --git a/src/lib/features/export-import-toggles/export-import.e2e.test.ts b/src/lib/features/export-import-toggles/export-import.e2e.test.ts index 7d75ed7fb03..9c74daa3c4f 100644 --- a/src/lib/features/export-import-toggles/export-import.e2e.test.ts +++ b/src/lib/features/export-import-toggles/export-import.e2e.test.ts @@ -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 () => { await createProjects(); const strategyContext = { diff --git a/src/lib/openapi/spec/export-query-schema.ts b/src/lib/openapi/spec/export-query-schema.ts index ec5a858dc4a..9af9a2f63eb 100644 --- a/src/lib/openapi/spec/export-query-schema.ts +++ b/src/lib/openapi/spec/export-query-schema.ts @@ -3,16 +3,9 @@ import { FromSchema } from 'json-schema-to-ts'; export const exportQuerySchema = { $id: '#/components/schemas/exportQuerySchema', type: 'object', - additionalProperties: false, - required: ['features', 'environment'], + additionalProperties: true, + required: ['environment'], properties: { - features: { - type: 'array', - items: { - type: 'string', - minLength: 1, - }, - }, environment: { type: 'string', }, @@ -20,6 +13,31 @@ export const exportQuerySchema = { 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: { schemas: {}, }, diff --git a/src/lib/services/feature-tag-service.ts b/src/lib/services/feature-tag-service.ts index cf8124b34d9..da5b2ce5e3c 100644 --- a/src/lib/services/feature-tag-service.ts +++ b/src/lib/services/feature-tag-service.ts @@ -47,6 +47,10 @@ class FeatureTagService { return this.featureTagStore.getAllTagsForFeature(featureName); } + async listFeatures(tagValue: string): Promise { + return this.featureTagStore.getAllFeaturesForTag(tagValue); + } + // TODO: add project Id async addTag( featureName: string, diff --git a/src/lib/types/stores/feature-tag-store.ts b/src/lib/types/stores/feature-tag-store.ts index eaa362cf3b9..b64e9ca01a8 100644 --- a/src/lib/types/stores/feature-tag-store.ts +++ b/src/lib/types/stores/feature-tag-store.ts @@ -13,6 +13,7 @@ export interface IFeatureAndTag { } export interface IFeatureTagStore extends Store { getAllTagsForFeature(featureName: string): Promise; + getAllFeaturesForTag(tagValue: string): Promise; getAllByFeatures(features: string[]): Promise; tagFeature(featureName: string, tag: ITag): Promise; tagFeatures(featureTags: IFeatureTag[]): Promise; diff --git a/src/lib/types/stores/tag-type-store.ts b/src/lib/types/stores/tag-type-store.ts index 56bfd5a9151..8058fc0e036 100644 --- a/src/lib/types/stores/tag-type-store.ts +++ b/src/lib/types/stores/tag-type-store.ts @@ -3,7 +3,7 @@ import { Store } from './store'; export interface ITagType { name: string; description?: string; - icon?: string; + icon?: string | null; } export interface ITagTypeStore extends Store { diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 9782790caf3..cb56b667190 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -1851,7 +1851,35 @@ The provider you choose for your addon dictates what properties the \`parameters "type": "object", }, "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": { "downloadFile": { "type": "boolean", @@ -1859,16 +1887,8 @@ The provider you choose for your addon dictates what properties the \`parameters "environment": { "type": "string", }, - "features": { - "items": { - "minLength": 1, - "type": "string", - }, - "type": "array", - }, }, "required": [ - "features", "environment", ], "type": "object", diff --git a/src/test/fixtures/fake-feature-tag-store.ts b/src/test/fixtures/fake-feature-tag-store.ts index 34a43572271..34e1227c6c6 100644 --- a/src/test/fixtures/fake-feature-tag-store.ts +++ b/src/test/fixtures/fake-feature-tag-store.ts @@ -18,6 +18,13 @@ export default class FakeFeatureTagStore implements IFeatureTagStore { return Promise.resolve(tags); } + async getAllFeaturesForTag(tagValue: string): Promise { + const tags = this.featureTags + .filter((f) => f.tagValue === tagValue) + .map((f) => f.featureName); + return Promise.resolve(tags); + } + async delete(key: IFeatureTag): Promise { this.featureTags.splice( this.featureTags.findIndex((t) => t === key),