1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: external link templates (#9927)

Adds support for link templates in projects, allowing reusable URL
patterns with placeholders. Includes validation, database changes,
updated API schemas, and tests.
This commit is contained in:
Tymoteusz Czech 2025-05-09 12:40:14 +02:00 committed by GitHub
parent e4ead3bd67
commit f02c883da5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 154 additions and 7 deletions

View File

@ -1480,6 +1480,7 @@ export default class ProjectService {
mode: project.mode,
featureLimit: project.featureLimit,
featureNaming: project.featureNaming,
linkTemplates: project.linkTemplates,
defaultStickiness: project.defaultStickiness,
health: project.health || 0,
favorite: favorite,

View File

@ -7,6 +7,7 @@ import type {
IFeatureNaming,
IProject,
IProjectApplications,
IProjectLinkTemplate,
ProjectMode,
} from '../../types/model';
import type { Store } from '../../types/stores/store';
@ -21,12 +22,14 @@ export interface IProjectInsert {
mode?: ProjectMode;
featureLimit?: number;
featureNaming?: IFeatureNaming;
linkTemplates?: IProjectLinkTemplate[];
}
export interface IProjectEnterpriseSettingsUpdate {
id: string;
mode?: ProjectMode;
featureNaming?: IFeatureNaming;
linkTemplates?: IProjectLinkTemplate[];
}
export interface IProjectSettings {
@ -36,6 +39,7 @@ export interface IProjectSettings {
featureNamingPattern?: string;
featureNamingExample?: string;
featureNamingDescription?: string;
linkTemplates?: IProjectLinkTemplate[];
}
export interface IProjectHealthUpdate {

View File

@ -12,7 +12,11 @@ let environmentStore: IEnvironmentStore;
beforeAll(async () => {
db = await dbInit('project_store_serial', getLogger, {
experimental: { flags: {} },
experimental: {
flags: {
projectLinkTemplates: true,
},
},
});
stores = db.stores;
projectStore = stores.projectStore;
@ -167,3 +171,62 @@ test('should add environment to project', async () => {
expect(envs).toHaveLength(1);
});
test('should update project enterprise settings', async () => {
const project = {
id: 'test-enterprise-settings',
name: 'New project for enterprise settings',
description: 'Blah',
mode: 'open' as const,
};
await projectStore.create(project);
await projectStore.updateProjectEnterpriseSettings({
id: 'test-enterprise-settings',
mode: 'open' as const,
});
let updatedProject = await projectStore.get(project.id);
expect(updatedProject!.mode).toBe('open');
expect(updatedProject!.featureNaming).toEqual({
pattern: null,
example: null,
description: null,
});
expect(updatedProject!.linkTemplates).toEqual([]);
await projectStore.updateProjectEnterpriseSettings({
id: 'test-enterprise-settings',
mode: 'protected' as const,
featureNaming: {
pattern: 'custom-pattern-[A-Z]+',
example: 'custom-pattern-MYFLAG',
description: 'Custom description',
},
linkTemplates: [
{
title: 'My Link',
urlTemplate: 'https://example.com/{{flag}}',
},
],
});
updatedProject = await projectStore.get(project.id);
expect(updatedProject!.mode).toBe('protected');
expect(updatedProject!.featureNaming).toEqual(
expect.objectContaining({
pattern: 'custom-pattern-[A-Z]+',
example: 'custom-pattern-MYFLAG',
description: 'Custom description',
}),
);
expect(updatedProject!.linkTemplates).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'My Link',
urlTemplate: 'https://example.com/{{flag}}',
}),
]),
);
});

View File

@ -45,6 +45,7 @@ const SETTINGS_COLUMNS = [
'feature_naming_pattern',
'feature_naming_example',
'feature_naming_description',
'link_templates',
];
const SETTINGS_TABLE = 'project_settings';
const PROJECT_ENVIRONMENTS = 'project_environments';
@ -135,7 +136,7 @@ class ProjectStore implements IProjectStore {
const rows = await projects;
return rows.map(this.mapRow);
return rows.map(this.mapRow.bind(this));
}
async get(id: string): Promise<IProject> {
@ -150,7 +151,7 @@ class ProjectStore implements IProjectStore {
`${TABLE}.id`,
)
.where({ id })
.then(this.mapRow);
.then(this.mapRow.bind(this));
}
async exists(id: string): Promise<boolean> {
@ -248,6 +249,13 @@ class ProjectStore implements IProjectStore {
data: IProjectEnterpriseSettingsUpdate,
): Promise<void> {
try {
const projectLinkTemplatesEnabled = this.flagResolver.isEnabled(
'projectLinkTemplates',
);
const link_templates = JSON.stringify(
data.linkTemplates ? data.linkTemplates : [],
);
if (await this.hasProjectSettings(data.id)) {
await this.db(SETTINGS_TABLE)
.where({ project: data.id })
@ -257,6 +265,11 @@ class ProjectStore implements IProjectStore {
feature_naming_example: data.featureNaming?.example,
feature_naming_description:
data.featureNaming?.description,
...(projectLinkTemplatesEnabled
? {
link_templates,
}
: {}),
});
} else {
await this.db(SETTINGS_TABLE).insert({
@ -265,6 +278,11 @@ class ProjectStore implements IProjectStore {
feature_naming_pattern: data.featureNaming?.pattern,
feature_naming_example: data.featureNaming?.example,
feature_naming_description: data.featureNaming?.description,
...(projectLinkTemplatesEnabled
? {
link_templates,
}
: {}),
});
}
} catch (err) {
@ -290,7 +308,7 @@ class ProjectStore implements IProjectStore {
await this.addEnvironmentToProject(project.id, env.name);
});
});
return rows.map(this.mapRow);
return rows.map(this.mapRow, this);
}
return [];
}
@ -333,7 +351,7 @@ class ProjectStore implements IProjectStore {
const rows = await this.db('project_environments')
.select(['project_id', 'environment_name'])
.whereIn('environment_name', environments);
return rows.map(this.mapLinkRow);
return rows.map(this.mapLinkRow, this);
}
async deleteEnvironmentForProject(
@ -397,7 +415,7 @@ class ProjectStore implements IProjectStore {
'project_environments.default_strategy',
]);
return rows.map(this.mapProjectEnvironmentRow);
return rows.map(this.mapProjectEnvironmentRow, this);
}
async getMembersCountByProject(projectId: string): Promise<number> {
@ -624,6 +642,10 @@ class ProjectStore implements IProjectStore {
throw new NotFoundError('No project found');
}
const projectLinkTemplatesEnabled = this.flagResolver.isEnabled(
'projectLinkTemplates',
);
return {
id: row.id,
name: row.name,
@ -640,6 +662,9 @@ class ProjectStore implements IProjectStore {
example: row.feature_naming_example,
description: row.feature_naming_description,
},
...(projectLinkTemplatesEnabled
? { linkTemplates: row.link_templates }
: {}),
};
}

View File

@ -18,7 +18,7 @@ export const createFeatureNamingPatternSchema = {
nullable: true,
description:
'An example of a feature name that matches the pattern. Must itself match the pattern supplied.',
example: 'dx.feature1.1-135',
example: 'dx.feature.1-135',
},
description: {
type: 'string',

View File

@ -157,6 +157,7 @@ export * from './project-feature-schema';
export * from './project-features-schema';
export * from './project-flag-creators-schema';
export * from './project-insights-schema';
export * from './project-link-template-schema';
export * from './project-overview-schema';
export * from './project-schema';
export * from './project-stats-schema';

View File

@ -0,0 +1,30 @@
import type { FromSchema } from 'json-schema-to-ts';
export const projectLinkTemplateSchema = {
$id: '#/components/schemas/projectLinkTemplateSchema',
type: 'object',
description:
'A template for a link that can be automatically added to new feature flags.',
required: ['urlTemplate'],
properties: {
title: {
type: 'string',
description: 'The title of the link.',
example: 'Code search',
nullable: true,
},
urlTemplate: {
type: 'string',
description:
'The URL to use as a template. Can contain {{project}} or {{feature}} as placeholders.',
example:
'https://github.com/search?type=code&q=repo%3AUnleash%2F{{project}}+{{feature}}',
},
},
additionalProperties: false,
components: {},
} as const;
export type ProjectLinkTemplateSchema = FromSchema<
typeof projectLinkTemplateSchema
>;

View File

@ -14,6 +14,7 @@ import { createStrategyVariantSchema } from './create-strategy-variant-schema';
import { strategyVariantSchema } from './strategy-variant-schema';
import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema';
import { featureTypeCountSchema } from './feature-type-count-schema';
import { projectLinkTemplateSchema } from './project-link-template-schema';
export const projectOverviewSchema = {
$id: '#/components/schemas/projectOverviewSchema',
@ -67,6 +68,14 @@ export const projectOverviewSchema = {
featureNaming: {
$ref: '#/components/schemas/createFeatureNamingPatternSchema',
},
linkTemplates: {
type: 'array',
items: {
$ref: '#/components/schemas/projectLinkTemplateSchema',
},
description:
'A list of templates for links that will be automatically added to new feature flags.',
},
members: {
type: 'number',
example: 4,
@ -188,6 +197,7 @@ export const projectOverviewSchema = {
projectStatsSchema,
createFeatureNamingPatternSchema,
featureTypeCountSchema,
projectLinkTemplateSchema,
},
},
} as const;

View File

@ -69,6 +69,7 @@ export type IFlagKey =
| 'removeInactiveApplications'
| 'registerFrontendClient'
| 'featureLinks'
| 'projectLinkTemplates'
| 'reportUnknownFlags';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -326,6 +327,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_FEATURE_LINKS,
false,
),
projectLinkTemplates: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_PROJECT_LINK_TEMPLATES,
false,
),
reportUnknownFlags: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_REPORT_UNKNOWN_FLAGS,
false,

View File

@ -322,6 +322,7 @@ export interface IProjectOverview {
featureNaming?: IFeatureNaming;
defaultStickiness: string;
onboardingStatus: ProjectOverviewSchema['onboardingStatus'];
linkTemplates?: IProjectLinkTemplate[];
}
export interface IProjectHealthReport extends IProjectHealth {
@ -334,6 +335,11 @@ export interface IProjectParam {
projectId: string;
}
export interface IProjectLinkTemplate {
title?: string;
urlTemplate: string;
}
export interface IArchivedQuery {
archived: boolean;
}
@ -561,6 +567,7 @@ export interface IProject {
defaultStickiness: string;
featureLimit?: number;
featureNaming?: IFeatureNaming;
linkTemplates?: IProjectLinkTemplate[];
}
export interface IProjectApplications {

View File

@ -62,6 +62,7 @@ process.nextTick(async () => {
strictSchemaValidation: true,
registerFrontendClient: true,
featureLinks: true,
projectLinkTemplates: true,
reportUnknownFlags: true,
},
},