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

feat: export feature links (#9954)

This commit is contained in:
Mateusz Kwasniewski 2025-05-12 12:07:00 +02:00 committed by GitHub
parent 1ccd201a25
commit 5708acb5b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 117 additions and 8 deletions

View File

@ -49,6 +49,8 @@ import {
createContextService, createContextService,
createFakeContextService, createFakeContextService,
} from '../context/createContextService'; } 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 = ( export const createFakeExportImportTogglesService = (
config: IUnleashConfig, config: IUnleashConfig,
@ -94,6 +96,8 @@ export const createFakeExportImportTogglesService = (
const dependentFeaturesService = createFakeDependentFeaturesService(config); const dependentFeaturesService = createFakeDependentFeaturesService(config);
const featureLinksReadModel = new FakeFeatureLinksReadModel();
return new ExportImportService( return new ExportImportService(
{ {
importTogglesStore, importTogglesStore,
@ -117,6 +121,7 @@ export const createFakeExportImportTogglesService = (
}, },
dependentFeaturesReadModel, dependentFeaturesReadModel,
segmentReadModel, segmentReadModel,
featureLinksReadModel,
); );
}; };
@ -184,6 +189,8 @@ export const deferredExportImportTogglesService = (
const dependentFeaturesService = const dependentFeaturesService =
createDependentFeaturesService(config)(db); createDependentFeaturesService(config)(db);
const featureLinksReadModel = new FeatureLinksReadModel(db, eventBus);
return new ExportImportService( return new ExportImportService(
{ {
importTogglesStore, importTogglesStore,
@ -207,6 +214,7 @@ export const deferredExportImportTogglesService = (
}, },
dependentFeaturesReadModel, dependentFeaturesReadModel,
segmentReadModel, segmentReadModel,
featureLinksReadModel,
); );
}; };
}; };

View File

@ -21,6 +21,7 @@ import {
type IUnleashStores, type IUnleashStores,
type IVariant, type IVariant,
type WithRequired, type WithRequired,
type IFeatureLinksReadModel,
} from '../../types'; } from '../../types';
import type { import type {
ExportQuerySchema, ExportQuerySchema,
@ -128,6 +129,8 @@ export default class ExportImportService
private dependentFeaturesService: DependentFeaturesService; private dependentFeaturesService: DependentFeaturesService;
private featureLinksReadModel: IFeatureLinksReadModel;
constructor( constructor(
stores: Pick< stores: Pick<
IUnleashStores, IUnleashStores,
@ -165,6 +168,7 @@ export default class ExportImportService
>, >,
dependentFeaturesReadModel: IDependentFeaturesReadModel, dependentFeaturesReadModel: IDependentFeaturesReadModel,
segmentReadModel: ISegmentReadModel, segmentReadModel: ISegmentReadModel,
featureLinksReadModel: IFeatureLinksReadModel,
) { ) {
this.toggleStore = stores.featureToggleStore; this.toggleStore = stores.featureToggleStore;
this.importTogglesStore = stores.importTogglesStore; this.importTogglesStore = stores.importTogglesStore;
@ -190,6 +194,7 @@ export default class ExportImportService
); );
this.dependentFeaturesReadModel = dependentFeaturesReadModel; this.dependentFeaturesReadModel = dependentFeaturesReadModel;
this.segmentReadModel = segmentReadModel; this.segmentReadModel = segmentReadModel;
this.featureLinksReadModel = featureLinksReadModel;
this.logger = getLogger('services/state-service.js'); this.logger = getLogger('services/state-service.js');
} }
@ -872,6 +877,7 @@ export default class ExportImportService
segments, segments,
tagTypes, tagTypes,
featureDependencies, featureDependencies,
featureLinks,
] = await Promise.all([ ] = await Promise.all([
this.toggleStore.getAllByNames(featureNames), this.toggleStore.getAllByNames(featureNames),
await this.featureEnvironmentStore.getAllByFeatures( await this.featureEnvironmentStore.getAllByFeatures(
@ -888,6 +894,9 @@ export default class ExportImportService
this.segmentReadModel.getAll(), this.segmentReadModel.getAll(),
this.tagTypeStore.getAll(), this.tagTypeStore.getAll(),
this.dependentFeaturesReadModel.getDependencies(featureNames), this.dependentFeaturesReadModel.getDependencies(featureNames),
this.flagResolver.isEnabled('featureLinks')
? this.featureLinksReadModel.getLinks(...featureNames)
: Promise.resolve([]),
]); ]);
this.addSegmentsToStrategies(featureStrategies, strategySegments); this.addSegmentsToStrategies(featureStrategies, strategySegments);
const filteredContextFields = contextFields const filteredContextFields = contextFields
@ -929,7 +938,6 @@ export default class ExportImportService
featureDependencies, featureDependencies,
'feature', 'feature',
); );
const mappedFeatureDependencies = Object.entries( const mappedFeatureDependencies = Object.entries(
groupedFeatureDependencies, groupedFeatureDependencies,
).map(([feature, dependencies]) => ({ ).map(([feature, dependencies]) => ({
@ -937,6 +945,18 @@ export default class ExportImportService
dependencies: dependencies.map((d) => d.dependency), 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 = { const result = {
features: features.map((item) => { features: features.map((item) => {
const { createdAt, archivedAt, lastSeenAt, ...rest } = item; const { createdAt, archivedAt, lastSeenAt, ...rest } = item;
@ -978,6 +998,7 @@ export default class ExportImportService
}), }),
tagTypes: filteredTagTypes, tagTypes: filteredTagTypes,
dependencies: mappedFeatureDependencies, dependencies: mappedFeatureDependencies,
links: mappedFeatureLinks,
}; };
await this.eventService.storeEvent( await this.eventService.storeEvent(
new FeaturesExportedEvent({ data: result, auditUser }), new FeaturesExportedEvent({ data: result, auditUser }),

View File

@ -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 ( const createProjects = async (
projects: string[] = [DEFAULT_PROJECT], projects: string[] = [DEFAULT_PROJECT],
featureLimit = 2, featureLimit = 2,
@ -155,7 +166,9 @@ beforeAll(async () => {
db.stores, db.stores,
{ {
experimental: { experimental: {
flags: {}, flags: {
featureLinks: true,
},
}, },
}, },
db.rawDatabase, db.rawDatabase,
@ -290,6 +303,15 @@ test('exports features', async () => {
); );
await app.addDependency(defaultFeatureName, 'second_feature'); 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 const { body } = await app.request
.post('/api/admin/features-batch/export') .post('/api/admin/features-batch/export')
.send({ .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' },
],
},
],
}); });
}); });

View File

@ -8,7 +8,7 @@ export class FakeFeatureLinksReadModel implements IFeatureLinksReadModel {
return []; return [];
} }
async getLinks(feature: string): Promise<IFeatureLink[]> { async getLinks(...features: string[]): Promise<IFeatureLink[]> {
return []; return [];
} }
} }

View File

@ -2,9 +2,10 @@ export interface IFeatureLink {
id: string; id: string;
url: string; url: string;
title: string | null; title: string | null;
feature: string;
} }
export interface IFeatureLinksReadModel { export interface IFeatureLinksReadModel {
getLinks(feature: string): Promise<IFeatureLink[]>; getLinks(...features: string[]): Promise<IFeatureLink[]>;
getTopDomains(): Promise<{ domain: string; count: number }[]>; getTopDomains(): Promise<{ domain: string; count: number }[]>;
} }

View File

@ -52,16 +52,17 @@ export class FeatureLinksReadModel implements IFeatureLinksReadModel {
})); }));
} }
async getLinks(feature: string): Promise<IFeatureLink[]> { async getLinks(...features: string[]): Promise<IFeatureLink[]> {
const links = await this.db const links = await this.db
.from('feature_link') .from('feature_link')
.where('feature_name', feature) .whereIn('feature_name', features)
.orderBy('id', 'asc'); .orderBy('id', 'asc');
return links.map((link) => ({ return links.map((link) => ({
id: link.id, id: link.id,
url: link.url, url: link.url,
title: link.title, title: link.title,
feature: link.feature_name,
})); }));
} }
} }

View File

@ -1109,7 +1109,11 @@ class FeatureToggleService {
dependencies, dependencies,
children, children,
lifecycle, lifecycle,
links, links: links.map((link) => ({
id: link.id,
url: link.url,
title: link.title,
})),
collaborators: { users: collaborators }, collaborators: { users: collaborators },
}; };
} else { } else {

View File

@ -15,6 +15,8 @@ import { strategyVariantSchema } from './strategy-variant-schema';
import { featureDependenciesSchema } from './feature-dependencies-schema'; import { featureDependenciesSchema } from './feature-dependencies-schema';
import { dependentFeatureSchema } from './dependent-feature-schema'; import { dependentFeatureSchema } from './dependent-feature-schema';
import { tagSchema } from './tag-schema'; import { tagSchema } from './tag-schema';
import { featureLinkSchema } from './feature-link-schema';
import { featureLinksSchema } from './feature-links-schema';
export const exportResultSchema = { export const exportResultSchema = {
$id: '#/components/schemas/exportResultSchema', $id: '#/components/schemas/exportResultSchema',
@ -177,6 +179,13 @@ export const exportResultSchema = {
$ref: '#/components/schemas/featureDependenciesSchema', $ref: '#/components/schemas/featureDependenciesSchema',
}, },
}, },
links: {
type: 'array',
description: 'A list of links for features in `features` list.',
items: {
$ref: '#/components/schemas/featureLinksSchema',
},
},
}, },
components: { components: {
schemas: { schemas: {
@ -196,6 +205,8 @@ export const exportResultSchema = {
featureDependenciesSchema, featureDependenciesSchema,
dependentFeatureSchema, dependentFeatureSchema,
tagSchema, tagSchema,
featureLinksSchema,
featureLinkSchema,
}, },
}, },
} as const; } as const;

View File

@ -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<typeof featureLinksSchema>;

View File

@ -86,6 +86,7 @@ export * from './feature-lifecycle-completed-schema';
export * from './feature-lifecycle-count-schema'; export * from './feature-lifecycle-count-schema';
export * from './feature-lifecycle-schema'; export * from './feature-lifecycle-schema';
export * from './feature-link-schema'; export * from './feature-link-schema';
export * from './feature-links-schema';
export * from './feature-metrics-schema'; export * from './feature-metrics-schema';
export * from './feature-schema'; export * from './feature-schema';
export * from './feature-search-environment-schema'; export * from './feature-search-environment-schema';

View File

@ -124,7 +124,7 @@ export interface FeatureToggleView extends FeatureToggleWithEnvironment {
children: string[]; children: string[];
lifecycle: IFeatureLifecycleStage | undefined; lifecycle: IFeatureLifecycleStage | undefined;
collaborators?: { users: Collaborator[] }; collaborators?: { users: Collaborator[] };
links: IFeatureLink[]; links: Omit<IFeatureLink, 'feature'>[];
} }
export interface IEnvironmentDetail extends IEnvironmentBase { export interface IEnvironmentDetail extends IEnvironmentBase {