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:
parent
e4ead3bd67
commit
f02c883da5
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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}}',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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';
|
||||
|
30
src/lib/openapi/spec/project-link-template-schema.ts
Normal file
30
src/lib/openapi/spec/project-link-template-schema.ts
Normal 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
|
||||
>;
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -62,6 +62,7 @@ process.nextTick(async () => {
|
||||
strictSchemaValidation: true,
|
||||
registerFrontendClient: true,
|
||||
featureLinks: true,
|
||||
projectLinkTemplates: true,
|
||||
reportUnknownFlags: true,
|
||||
},
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user