1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-27 01:19:00 +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,
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,
);
};
};

View File

@ -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 }),

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 (
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' },
],
},
],
});
});

View File

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

View File

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

View File

@ -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 {

View File

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

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-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';

View File

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