diff --git a/src/lib/features/export-import-toggles/createExportImportService.ts b/src/lib/features/export-import-toggles/createExportImportService.ts index b604196d5c..128cd1fbe5 100644 --- a/src/lib/features/export-import-toggles/createExportImportService.ts +++ b/src/lib/features/export-import-toggles/createExportImportService.ts @@ -49,6 +49,8 @@ import { createContextService, createFakeContextService, } from '../context/createContextService'; +import { FakeFeatureLinksReadModel } from '../feature-links/fake-feature-links-read-model'; +import { FeatureLinksReadModel } from '../feature-links/feature-links-read-model'; export const createFakeExportImportTogglesService = ( config: IUnleashConfig, @@ -94,6 +96,8 @@ export const createFakeExportImportTogglesService = ( const dependentFeaturesService = createFakeDependentFeaturesService(config); + const featureLinksReadModel = new FakeFeatureLinksReadModel(); + return new ExportImportService( { importTogglesStore, @@ -117,6 +121,7 @@ export const createFakeExportImportTogglesService = ( }, dependentFeaturesReadModel, segmentReadModel, + featureLinksReadModel, ); }; @@ -184,6 +189,8 @@ export const deferredExportImportTogglesService = ( const dependentFeaturesService = createDependentFeaturesService(config)(db); + const featureLinksReadModel = new FeatureLinksReadModel(db, eventBus); + return new ExportImportService( { importTogglesStore, @@ -207,6 +214,7 @@ export const deferredExportImportTogglesService = ( }, dependentFeaturesReadModel, segmentReadModel, + featureLinksReadModel, ); }; }; 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 60f2ef1f59..59ef481f2f 100644 --- a/src/lib/features/export-import-toggles/export-import-service.ts +++ b/src/lib/features/export-import-toggles/export-import-service.ts @@ -21,6 +21,7 @@ import { type IUnleashStores, type IVariant, type WithRequired, + type IFeatureLinksReadModel, } from '../../types'; import type { ExportQuerySchema, @@ -128,6 +129,8 @@ export default class ExportImportService private dependentFeaturesService: DependentFeaturesService; + private featureLinksReadModel: IFeatureLinksReadModel; + constructor( stores: Pick< IUnleashStores, @@ -165,6 +168,7 @@ export default class ExportImportService >, dependentFeaturesReadModel: IDependentFeaturesReadModel, segmentReadModel: ISegmentReadModel, + featureLinksReadModel: IFeatureLinksReadModel, ) { this.toggleStore = stores.featureToggleStore; this.importTogglesStore = stores.importTogglesStore; @@ -190,6 +194,7 @@ export default class ExportImportService ); this.dependentFeaturesReadModel = dependentFeaturesReadModel; this.segmentReadModel = segmentReadModel; + this.featureLinksReadModel = featureLinksReadModel; this.logger = getLogger('services/state-service.js'); } @@ -872,6 +877,7 @@ export default class ExportImportService segments, tagTypes, featureDependencies, + featureLinks, ] = await Promise.all([ this.toggleStore.getAllByNames(featureNames), await this.featureEnvironmentStore.getAllByFeatures( @@ -888,6 +894,9 @@ export default class ExportImportService this.segmentReadModel.getAll(), this.tagTypeStore.getAll(), this.dependentFeaturesReadModel.getDependencies(featureNames), + this.flagResolver.isEnabled('featureLinks') + ? this.featureLinksReadModel.getLinks(...featureNames) + : Promise.resolve([]), ]); this.addSegmentsToStrategies(featureStrategies, strategySegments); const filteredContextFields = contextFields @@ -929,7 +938,6 @@ export default class ExportImportService featureDependencies, 'feature', ); - const mappedFeatureDependencies = Object.entries( groupedFeatureDependencies, ).map(([feature, dependencies]) => ({ @@ -937,6 +945,18 @@ export default class ExportImportService dependencies: dependencies.map((d) => d.dependency), })); + const groupedFeatureLinks = groupBy(featureLinks, 'feature'); + const mappedFeatureLinks = Object.entries(groupedFeatureLinks).map( + ([feature, links]) => ({ + feature, + links: links.map((link) => ({ + id: link.id, + url: link.url, + title: link.title, + })), + }), + ); + const result = { features: features.map((item) => { const { createdAt, archivedAt, lastSeenAt, ...rest } = item; @@ -978,6 +998,7 @@ export default class ExportImportService }), tagTypes: filteredTagTypes, dependencies: mappedFeatureDependencies, + links: mappedFeatureLinks, }; await this.eventService.storeEvent( new FeaturesExportedEvent({ data: result, auditUser }), 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 fcd6a55939..cf5fd95fa9 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 @@ -112,6 +112,17 @@ const createVariants = async (feature: string, variants: IVariant[]) => { ); }; +const addLink = async ( + feature: string, + link: { url: string; title: string }, +) => { + await app.services.transactionalFeatureLinkService.createLink( + DEFAULT_ENV, + { ...link, featureName: feature }, + TEST_AUDIT_USER, + ); +}; + const createProjects = async ( projects: string[] = [DEFAULT_PROJECT], featureLimit = 2, @@ -155,7 +166,9 @@ beforeAll(async () => { db.stores, { experimental: { - flags: {}, + flags: { + featureLinks: true, + }, }, }, db.rawDatabase, @@ -290,6 +303,15 @@ test('exports features', async () => { ); await app.addDependency(defaultFeatureName, 'second_feature'); + await addLink(defaultFeatureName, { + url: 'http://example1.com', + title: 'link title 1', + }); + await addLink(defaultFeatureName, { + url: 'http://example2.com', + title: 'link title 2', + }); + const { body } = await app.request .post('/api/admin/features-batch/export') .send({ @@ -331,6 +353,15 @@ test('exports features', async () => { ], }, ], + links: [ + { + feature: defaultFeatureName, + links: [ + { url: 'http://example1.com', title: 'link title 1' }, + { url: 'http://example2.com', title: 'link title 2' }, + ], + }, + ], }); }); diff --git a/src/lib/features/feature-links/fake-feature-links-read-model.ts b/src/lib/features/feature-links/fake-feature-links-read-model.ts index f2760a1600..0833af3340 100644 --- a/src/lib/features/feature-links/fake-feature-links-read-model.ts +++ b/src/lib/features/feature-links/fake-feature-links-read-model.ts @@ -8,7 +8,7 @@ export class FakeFeatureLinksReadModel implements IFeatureLinksReadModel { return []; } - async getLinks(feature: string): Promise { + async getLinks(...features: string[]): Promise { return []; } } diff --git a/src/lib/features/feature-links/feature-links-read-model-type.ts b/src/lib/features/feature-links/feature-links-read-model-type.ts index b8abeb1136..8e710d2b59 100644 --- a/src/lib/features/feature-links/feature-links-read-model-type.ts +++ b/src/lib/features/feature-links/feature-links-read-model-type.ts @@ -2,9 +2,10 @@ export interface IFeatureLink { id: string; url: string; title: string | null; + feature: string; } export interface IFeatureLinksReadModel { - getLinks(feature: string): Promise; + getLinks(...features: string[]): Promise; getTopDomains(): Promise<{ domain: string; count: number }[]>; } diff --git a/src/lib/features/feature-links/feature-links-read-model.ts b/src/lib/features/feature-links/feature-links-read-model.ts index d8d32c8299..c2ac93c220 100644 --- a/src/lib/features/feature-links/feature-links-read-model.ts +++ b/src/lib/features/feature-links/feature-links-read-model.ts @@ -52,16 +52,17 @@ export class FeatureLinksReadModel implements IFeatureLinksReadModel { })); } - async getLinks(feature: string): Promise { + async getLinks(...features: string[]): Promise { const links = await this.db .from('feature_link') - .where('feature_name', feature) + .whereIn('feature_name', features) .orderBy('id', 'asc'); return links.map((link) => ({ id: link.id, url: link.url, title: link.title, + feature: link.feature_name, })); } } diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index d5e5a4acbd..82801218d8 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -1109,7 +1109,11 @@ class FeatureToggleService { dependencies, children, lifecycle, - links, + links: links.map((link) => ({ + id: link.id, + url: link.url, + title: link.title, + })), collaborators: { users: collaborators }, }; } else { diff --git a/src/lib/openapi/spec/export-result-schema.ts b/src/lib/openapi/spec/export-result-schema.ts index 710e8989f9..da74cbd5f1 100644 --- a/src/lib/openapi/spec/export-result-schema.ts +++ b/src/lib/openapi/spec/export-result-schema.ts @@ -15,6 +15,8 @@ import { strategyVariantSchema } from './strategy-variant-schema'; import { featureDependenciesSchema } from './feature-dependencies-schema'; import { dependentFeatureSchema } from './dependent-feature-schema'; import { tagSchema } from './tag-schema'; +import { featureLinkSchema } from './feature-link-schema'; +import { featureLinksSchema } from './feature-links-schema'; export const exportResultSchema = { $id: '#/components/schemas/exportResultSchema', @@ -177,6 +179,13 @@ export const exportResultSchema = { $ref: '#/components/schemas/featureDependenciesSchema', }, }, + links: { + type: 'array', + description: 'A list of links for features in `features` list.', + items: { + $ref: '#/components/schemas/featureLinksSchema', + }, + }, }, components: { schemas: { @@ -196,6 +205,8 @@ export const exportResultSchema = { featureDependenciesSchema, dependentFeatureSchema, tagSchema, + featureLinksSchema, + featureLinkSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/feature-links-schema.ts b/src/lib/openapi/spec/feature-links-schema.ts new file mode 100644 index 0000000000..203e795f3a --- /dev/null +++ b/src/lib/openapi/spec/feature-links-schema.ts @@ -0,0 +1,31 @@ +import type { FromSchema } from 'json-schema-to-ts'; +import { featureLinkSchema } from './feature-link-schema'; + +export const featureLinksSchema = { + $id: '#/components/schemas/featureLinksSchema', + type: 'object', + description: 'A list of links for a feature', + required: ['feature', 'links'], + additionalProperties: false, + properties: { + feature: { + type: 'string', + description: 'The name of the child feature.', + example: 'child_feature', + }, + links: { + type: 'array', + description: 'List of feature links', + items: { + $ref: '#/components/schemas/featureLinkSchema', + }, + }, + }, + components: { + schemas: { + featureLinkSchema, + }, + }, +} as const; + +export type FeatureLinksSchema = FromSchema; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 2c34e83872..325c77ba91 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -86,6 +86,7 @@ export * from './feature-lifecycle-completed-schema'; export * from './feature-lifecycle-count-schema'; export * from './feature-lifecycle-schema'; export * from './feature-link-schema'; +export * from './feature-links-schema'; export * from './feature-metrics-schema'; export * from './feature-schema'; export * from './feature-search-environment-schema'; diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 457034a757..0ca6908d27 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -124,7 +124,7 @@ export interface FeatureToggleView extends FeatureToggleWithEnvironment { children: string[]; lifecycle: IFeatureLifecycleStage | undefined; collaborators?: { users: Collaborator[] }; - links: IFeatureLink[]; + links: Omit[]; } export interface IEnvironmentDetail extends IEnvironmentBase {