From d175a5705a6b681b2f7a18556d19a534924a4822 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Mon, 12 May 2025 13:59:18 +0200 Subject: [PATCH] feat: Import feature links (#9958) --- .../Import/configure/ImportExplanation.tsx | 2 ++ .../createExportImportService.ts | 11 +++++++ .../export-import-service.ts | 30 +++++++++++++++++++ .../export-import.e2e.test.ts | 17 +++++++++++ .../import-toggles-store-type.ts | 3 +- .../import-toggles-store.ts | 5 ++++ .../frontend-api/frontend-api-controller.ts | 1 - src/lib/openapi/spec/import-toggles-schema.ts | 4 +++ src/lib/services/index.ts | 3 ++ src/lib/types/services.ts | 1 + 10 files changed, 75 insertions(+), 2 deletions(-) diff --git a/frontend/src/component/project/Project/Import/configure/ImportExplanation.tsx b/frontend/src/component/project/Project/Import/configure/ImportExplanation.tsx index 350c291ac2..c114bf81c9 100644 --- a/frontend/src/component/project/Project/Import/configure/ImportExplanation.tsx +++ b/frontend/src/component/project/Project/Import/configure/ImportExplanation.tsx @@ -30,6 +30,8 @@ export const ImportExplanation: FC = () => (
  • variants
  • tags
  • feature flag status
  • +
  • feature dependencies
  • +
  • feature links
  • Exceptions? diff --git a/src/lib/features/export-import-toggles/createExportImportService.ts b/src/lib/features/export-import-toggles/createExportImportService.ts index 128cd1fbe5..00bf9325cb 100644 --- a/src/lib/features/export-import-toggles/createExportImportService.ts +++ b/src/lib/features/export-import-toggles/createExportImportService.ts @@ -51,6 +51,10 @@ import { } from '../context/createContextService'; import { FakeFeatureLinksReadModel } from '../feature-links/fake-feature-links-read-model'; import { FeatureLinksReadModel } from '../feature-links/feature-links-read-model'; +import { + createFakeFeatureLinkService, + createFeatureLinkService, +} from '../feature-links/createFeatureLinkService'; export const createFakeExportImportTogglesService = ( config: IUnleashConfig, @@ -98,6 +102,9 @@ export const createFakeExportImportTogglesService = ( const featureLinksReadModel = new FakeFeatureLinksReadModel(); + const featureLinkService = + createFakeFeatureLinkService(config).featureLinkService; + return new ExportImportService( { importTogglesStore, @@ -118,6 +125,7 @@ export const createFakeExportImportTogglesService = ( strategyService, tagTypeService, dependentFeaturesService, + featureLinkService, }, dependentFeaturesReadModel, segmentReadModel, @@ -191,6 +199,8 @@ export const deferredExportImportTogglesService = ( const featureLinksReadModel = new FeatureLinksReadModel(db, eventBus); + const featureLinkService = createFeatureLinkService(config)(db); + return new ExportImportService( { importTogglesStore, @@ -211,6 +221,7 @@ export const deferredExportImportTogglesService = ( strategyService, tagTypeService, dependentFeaturesService, + featureLinkService, }, dependentFeaturesReadModel, segmentReadModel, 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 59ef481f2f..5a5cfdb956 100644 --- a/src/lib/features/export-import-toggles/export-import-service.ts +++ b/src/lib/features/export-import-toggles/export-import-service.ts @@ -59,6 +59,7 @@ import groupBy from 'lodash.groupby'; import { allSettledWithRejection } from '../../util/allSettledWithRejection'; import type { ISegmentReadModel } from '../segment/segment-read-model-type'; import { readFile } from '../../util/read-file'; +import type FeatureLinkService from '../feature-links/feature-link-service'; export type IImportService = { validate( @@ -131,6 +132,8 @@ export default class ExportImportService private featureLinksReadModel: IFeatureLinksReadModel; + private featureLinkService: FeatureLinkService; + constructor( stores: Pick< IUnleashStores, @@ -155,6 +158,7 @@ export default class ExportImportService tagTypeService, featureTagService, dependentFeaturesService, + featureLinkService, }: Pick< IUnleashServices, | 'featureToggleService' @@ -165,6 +169,7 @@ export default class ExportImportService | 'tagTypeService' | 'featureTagService' | 'dependentFeaturesService' + | 'featureLinkService' >, dependentFeaturesReadModel: IDependentFeaturesReadModel, segmentReadModel: ISegmentReadModel, @@ -186,6 +191,7 @@ export default class ExportImportService this.tagTypeService = tagTypeService; this.featureTagService = featureTagService; this.dependentFeaturesService = dependentFeaturesService; + this.featureLinkService = featureLinkService; this.importPermissionsService = new ImportPermissionsService( this.importTogglesStore, this.accessService, @@ -297,6 +303,9 @@ export default class ExportImportService await this.importTagTypes(dto, auditUser); await this.importTags(dto, auditUser); await this.importContextFields(dto, auditUser); + if (this.flagResolver.isEnabled('featureLinks')) { + await this.importLinks(dto, auditUser); + } } async import( @@ -355,6 +364,27 @@ export default class ExportImportService await this.importDependencies(dto, user, auditUser); } + private async importLinks(dto: ImportTogglesSchema, auditUser: IAuditUser) { + await this.importTogglesStore.deleteLinksForFeatures( + (dto.data.links ?? []).map((featureLink) => featureLink.feature), + ); + + const links = dto.data.links || []; + for (const featureLink of links) { + for (const link of featureLink.links) { + await this.featureLinkService.createLink( + dto.project, + { + featureName: featureLink.feature, + url: link.url, + title: link.title || undefined, + }, + auditUser, + ); + } + } + } + private async importDependencies( dto: ImportTogglesSchema, user: IUser, 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 cf5fd95fa9..69c3894649 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 @@ -10,6 +10,7 @@ import { type IContextFieldStore, type IEnvironmentStore, type IEventStore, + type IFeatureLinkStore, type IFeatureToggleStore, type IProjectStore, type ISegment, @@ -35,6 +36,7 @@ let contextFieldStore: IContextFieldStore; let projectStore: IProjectStore; let toggleStore: IFeatureToggleStore; let tagStore: ITagStore; +let featureLinkStore: IFeatureLinkStore; const defaultStrategy: IStrategyConfig = { name: 'default', @@ -179,6 +181,7 @@ beforeAll(async () => { contextFieldStore = db.stores.contextFieldStore; toggleStore = db.stores.featureToggleStore; tagStore = db.stores.tagStore; + featureLinkStore = db.stores.featureLinkStore; }); beforeEach(async () => { @@ -187,6 +190,7 @@ beforeEach(async () => { await projectStore.deleteAll(); await environmentStore.deleteAll(); await tagStore.deleteAll(); + await featureLinkStore.deleteAll(); await contextFieldStore.deleteAll(); await app.createContextField({ name: 'appName' }); @@ -835,6 +839,15 @@ test('import features to existing project and environment', async () => { ], }, ], + links: [ + { + feature: exportedFeature.name, + links: [ + { url: 'http://example1.com', title: 'link title 1' }, + { url: 'http://example2.com' }, + ], + }, + ], }, }); @@ -857,6 +870,10 @@ test('import features to existing project and environment', async () => { feature: anotherExportedFeature.name, }, ], + links: [ + { title: 'link title 1', url: 'http://example1.com' }, + { title: null, url: 'http://example2.com' }, + ], }); const { body: importedFeatureEnvironment } = diff --git a/src/lib/features/export-import-toggles/import-toggles-store-type.ts b/src/lib/features/export-import-toggles/import-toggles-store-type.ts index 60cd0afaf3..dd460a60d9 100644 --- a/src/lib/features/export-import-toggles/import-toggles-store-type.ts +++ b/src/lib/features/export-import-toggles/import-toggles-store-type.ts @@ -27,7 +27,8 @@ export interface IImportTogglesStore { project: string, ): Promise; - deleteTagsForFeatures(tags: string[]): Promise; + deleteTagsForFeatures(features: string[]): Promise; + deleteLinksForFeatures(features: string[]): Promise; strategiesExistForFeatures( featureNames: string[], diff --git a/src/lib/features/export-import-toggles/import-toggles-store.ts b/src/lib/features/export-import-toggles/import-toggles-store.ts index 31744cac80..10bc8e30f0 100644 --- a/src/lib/features/export-import-toggles/import-toggles-store.ts +++ b/src/lib/features/export-import-toggles/import-toggles-store.ts @@ -9,6 +9,7 @@ const T = { features: 'features', featureTag: 'feature_tag', projectSettings: 'project_settings', + links: 'feature_link', }; export class ImportTogglesStore implements IImportTogglesStore { private db: Db; @@ -124,4 +125,8 @@ export class ImportTogglesStore implements IImportTogglesStore { async deleteTagsForFeatures(features: string[]): Promise { return this.db(T.featureTag).whereIn('feature_name', features).del(); } + + async deleteLinksForFeatures(features: string[]): Promise { + return this.db(T.links).whereIn('feature_name', features).del(); + } } diff --git a/src/lib/features/frontend-api/frontend-api-controller.ts b/src/lib/features/frontend-api/frontend-api-controller.ts index bc91ca083d..e45d7a56ef 100644 --- a/src/lib/features/frontend-api/frontend-api-controller.ts +++ b/src/lib/features/frontend-api/frontend-api-controller.ts @@ -22,7 +22,6 @@ import type { Context } from 'unleash-client'; import { enrichContextWithIp } from './index'; import { corsOriginMiddleware } from '../../middleware'; import NotImplementedError from '../../error/not-implemented-error'; -import NotFoundError from '../../error/notfound-error'; import rateLimit from 'express-rate-limit'; import { minutesToMilliseconds } from 'date-fns'; import metricsHelper from '../../util/metrics-helper'; diff --git a/src/lib/openapi/spec/import-toggles-schema.ts b/src/lib/openapi/spec/import-toggles-schema.ts index b75915393c..a9420274da 100644 --- a/src/lib/openapi/spec/import-toggles-schema.ts +++ b/src/lib/openapi/spec/import-toggles-schema.ts @@ -16,6 +16,8 @@ import { featureEnvironmentSchema } from './feature-environment-schema'; import { strategyVariantSchema } from './strategy-variant-schema'; import { featureDependenciesSchema } from './feature-dependencies-schema'; import { dependentFeatureSchema } from './dependent-feature-schema'; +import { featureLinksSchema } from './feature-links-schema'; +import { featureLinkSchema } from './feature-link-schema'; export const importTogglesSchema = { $id: '#/components/schemas/importTogglesSchema', @@ -60,6 +62,8 @@ export const importTogglesSchema = { tagTypeSchema, featureDependenciesSchema, dependentFeatureSchema, + featureLinksSchema, + featureLinkSchema, }, }, } as const; diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 3f65976751..4568fa395d 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -444,6 +444,8 @@ export const createServices = ( createFakeFeatureLinkService(config).featureLinkService, ); + const featureLinkService = transactionalFeatureLinkService; + return { transactionalAccessService, accessService, @@ -514,6 +516,7 @@ export const createServices = ( uniqueConnectionService, featureLifecycleReadModel, transactionalFeatureLinkService, + featureLinkService, unknownFlagsService, }; }; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 9009f1c2ec..54c6963c55 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -136,5 +136,6 @@ export interface IUnleashServices { uniqueConnectionService: UniqueConnectionService; featureLifecycleReadModel: IFeatureLifecycleReadModel; transactionalFeatureLinkService: WithTransactional; + featureLinkService: FeatureLinkService; unknownFlagsService: UnknownFlagsService; }