mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-24 17:51:14 +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,
|
mode: project.mode,
|
||||||
featureLimit: project.featureLimit,
|
featureLimit: project.featureLimit,
|
||||||
featureNaming: project.featureNaming,
|
featureNaming: project.featureNaming,
|
||||||
|
linkTemplates: project.linkTemplates,
|
||||||
defaultStickiness: project.defaultStickiness,
|
defaultStickiness: project.defaultStickiness,
|
||||||
health: project.health || 0,
|
health: project.health || 0,
|
||||||
favorite: favorite,
|
favorite: favorite,
|
||||||
|
@ -7,6 +7,7 @@ import type {
|
|||||||
IFeatureNaming,
|
IFeatureNaming,
|
||||||
IProject,
|
IProject,
|
||||||
IProjectApplications,
|
IProjectApplications,
|
||||||
|
IProjectLinkTemplate,
|
||||||
ProjectMode,
|
ProjectMode,
|
||||||
} from '../../types/model';
|
} from '../../types/model';
|
||||||
import type { Store } from '../../types/stores/store';
|
import type { Store } from '../../types/stores/store';
|
||||||
@ -21,12 +22,14 @@ export interface IProjectInsert {
|
|||||||
mode?: ProjectMode;
|
mode?: ProjectMode;
|
||||||
featureLimit?: number;
|
featureLimit?: number;
|
||||||
featureNaming?: IFeatureNaming;
|
featureNaming?: IFeatureNaming;
|
||||||
|
linkTemplates?: IProjectLinkTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProjectEnterpriseSettingsUpdate {
|
export interface IProjectEnterpriseSettingsUpdate {
|
||||||
id: string;
|
id: string;
|
||||||
mode?: ProjectMode;
|
mode?: ProjectMode;
|
||||||
featureNaming?: IFeatureNaming;
|
featureNaming?: IFeatureNaming;
|
||||||
|
linkTemplates?: IProjectLinkTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProjectSettings {
|
export interface IProjectSettings {
|
||||||
@ -36,6 +39,7 @@ export interface IProjectSettings {
|
|||||||
featureNamingPattern?: string;
|
featureNamingPattern?: string;
|
||||||
featureNamingExample?: string;
|
featureNamingExample?: string;
|
||||||
featureNamingDescription?: string;
|
featureNamingDescription?: string;
|
||||||
|
linkTemplates?: IProjectLinkTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProjectHealthUpdate {
|
export interface IProjectHealthUpdate {
|
||||||
|
@ -12,7 +12,11 @@ let environmentStore: IEnvironmentStore;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('project_store_serial', getLogger, {
|
db = await dbInit('project_store_serial', getLogger, {
|
||||||
experimental: { flags: {} },
|
experimental: {
|
||||||
|
flags: {
|
||||||
|
projectLinkTemplates: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
projectStore = stores.projectStore;
|
projectStore = stores.projectStore;
|
||||||
@ -167,3 +171,62 @@ test('should add environment to project', async () => {
|
|||||||
|
|
||||||
expect(envs).toHaveLength(1);
|
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_pattern',
|
||||||
'feature_naming_example',
|
'feature_naming_example',
|
||||||
'feature_naming_description',
|
'feature_naming_description',
|
||||||
|
'link_templates',
|
||||||
];
|
];
|
||||||
const SETTINGS_TABLE = 'project_settings';
|
const SETTINGS_TABLE = 'project_settings';
|
||||||
const PROJECT_ENVIRONMENTS = 'project_environments';
|
const PROJECT_ENVIRONMENTS = 'project_environments';
|
||||||
@ -135,7 +136,7 @@ class ProjectStore implements IProjectStore {
|
|||||||
|
|
||||||
const rows = await projects;
|
const rows = await projects;
|
||||||
|
|
||||||
return rows.map(this.mapRow);
|
return rows.map(this.mapRow.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string): Promise<IProject> {
|
async get(id: string): Promise<IProject> {
|
||||||
@ -150,7 +151,7 @@ class ProjectStore implements IProjectStore {
|
|||||||
`${TABLE}.id`,
|
`${TABLE}.id`,
|
||||||
)
|
)
|
||||||
.where({ id })
|
.where({ id })
|
||||||
.then(this.mapRow);
|
.then(this.mapRow.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists(id: string): Promise<boolean> {
|
async exists(id: string): Promise<boolean> {
|
||||||
@ -248,6 +249,13 @@ class ProjectStore implements IProjectStore {
|
|||||||
data: IProjectEnterpriseSettingsUpdate,
|
data: IProjectEnterpriseSettingsUpdate,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const projectLinkTemplatesEnabled = this.flagResolver.isEnabled(
|
||||||
|
'projectLinkTemplates',
|
||||||
|
);
|
||||||
|
const link_templates = JSON.stringify(
|
||||||
|
data.linkTemplates ? data.linkTemplates : [],
|
||||||
|
);
|
||||||
|
|
||||||
if (await this.hasProjectSettings(data.id)) {
|
if (await this.hasProjectSettings(data.id)) {
|
||||||
await this.db(SETTINGS_TABLE)
|
await this.db(SETTINGS_TABLE)
|
||||||
.where({ project: data.id })
|
.where({ project: data.id })
|
||||||
@ -257,6 +265,11 @@ class ProjectStore implements IProjectStore {
|
|||||||
feature_naming_example: data.featureNaming?.example,
|
feature_naming_example: data.featureNaming?.example,
|
||||||
feature_naming_description:
|
feature_naming_description:
|
||||||
data.featureNaming?.description,
|
data.featureNaming?.description,
|
||||||
|
...(projectLinkTemplatesEnabled
|
||||||
|
? {
|
||||||
|
link_templates,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.db(SETTINGS_TABLE).insert({
|
await this.db(SETTINGS_TABLE).insert({
|
||||||
@ -265,6 +278,11 @@ class ProjectStore implements IProjectStore {
|
|||||||
feature_naming_pattern: data.featureNaming?.pattern,
|
feature_naming_pattern: data.featureNaming?.pattern,
|
||||||
feature_naming_example: data.featureNaming?.example,
|
feature_naming_example: data.featureNaming?.example,
|
||||||
feature_naming_description: data.featureNaming?.description,
|
feature_naming_description: data.featureNaming?.description,
|
||||||
|
...(projectLinkTemplatesEnabled
|
||||||
|
? {
|
||||||
|
link_templates,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -290,7 +308,7 @@ class ProjectStore implements IProjectStore {
|
|||||||
await this.addEnvironmentToProject(project.id, env.name);
|
await this.addEnvironmentToProject(project.id, env.name);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return rows.map(this.mapRow);
|
return rows.map(this.mapRow, this);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -333,7 +351,7 @@ class ProjectStore implements IProjectStore {
|
|||||||
const rows = await this.db('project_environments')
|
const rows = await this.db('project_environments')
|
||||||
.select(['project_id', 'environment_name'])
|
.select(['project_id', 'environment_name'])
|
||||||
.whereIn('environment_name', environments);
|
.whereIn('environment_name', environments);
|
||||||
return rows.map(this.mapLinkRow);
|
return rows.map(this.mapLinkRow, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteEnvironmentForProject(
|
async deleteEnvironmentForProject(
|
||||||
@ -397,7 +415,7 @@ class ProjectStore implements IProjectStore {
|
|||||||
'project_environments.default_strategy',
|
'project_environments.default_strategy',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return rows.map(this.mapProjectEnvironmentRow);
|
return rows.map(this.mapProjectEnvironmentRow, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMembersCountByProject(projectId: string): Promise<number> {
|
async getMembersCountByProject(projectId: string): Promise<number> {
|
||||||
@ -624,6 +642,10 @@ class ProjectStore implements IProjectStore {
|
|||||||
throw new NotFoundError('No project found');
|
throw new NotFoundError('No project found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectLinkTemplatesEnabled = this.flagResolver.isEnabled(
|
||||||
|
'projectLinkTemplates',
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@ -640,6 +662,9 @@ class ProjectStore implements IProjectStore {
|
|||||||
example: row.feature_naming_example,
|
example: row.feature_naming_example,
|
||||||
description: row.feature_naming_description,
|
description: row.feature_naming_description,
|
||||||
},
|
},
|
||||||
|
...(projectLinkTemplatesEnabled
|
||||||
|
? { linkTemplates: row.link_templates }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ export const createFeatureNamingPatternSchema = {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
description:
|
description:
|
||||||
'An example of a feature name that matches the pattern. Must itself match the pattern supplied.',
|
'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: {
|
description: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
@ -157,6 +157,7 @@ export * from './project-feature-schema';
|
|||||||
export * from './project-features-schema';
|
export * from './project-features-schema';
|
||||||
export * from './project-flag-creators-schema';
|
export * from './project-flag-creators-schema';
|
||||||
export * from './project-insights-schema';
|
export * from './project-insights-schema';
|
||||||
|
export * from './project-link-template-schema';
|
||||||
export * from './project-overview-schema';
|
export * from './project-overview-schema';
|
||||||
export * from './project-schema';
|
export * from './project-schema';
|
||||||
export * from './project-stats-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 { strategyVariantSchema } from './strategy-variant-schema';
|
||||||
import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema';
|
import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema';
|
||||||
import { featureTypeCountSchema } from './feature-type-count-schema';
|
import { featureTypeCountSchema } from './feature-type-count-schema';
|
||||||
|
import { projectLinkTemplateSchema } from './project-link-template-schema';
|
||||||
|
|
||||||
export const projectOverviewSchema = {
|
export const projectOverviewSchema = {
|
||||||
$id: '#/components/schemas/projectOverviewSchema',
|
$id: '#/components/schemas/projectOverviewSchema',
|
||||||
@ -67,6 +68,14 @@ export const projectOverviewSchema = {
|
|||||||
featureNaming: {
|
featureNaming: {
|
||||||
$ref: '#/components/schemas/createFeatureNamingPatternSchema',
|
$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: {
|
members: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
example: 4,
|
example: 4,
|
||||||
@ -188,6 +197,7 @@ export const projectOverviewSchema = {
|
|||||||
projectStatsSchema,
|
projectStatsSchema,
|
||||||
createFeatureNamingPatternSchema,
|
createFeatureNamingPatternSchema,
|
||||||
featureTypeCountSchema,
|
featureTypeCountSchema,
|
||||||
|
projectLinkTemplateSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -69,6 +69,7 @@ export type IFlagKey =
|
|||||||
| 'removeInactiveApplications'
|
| 'removeInactiveApplications'
|
||||||
| 'registerFrontendClient'
|
| 'registerFrontendClient'
|
||||||
| 'featureLinks'
|
| 'featureLinks'
|
||||||
|
| 'projectLinkTemplates'
|
||||||
| 'reportUnknownFlags';
|
| 'reportUnknownFlags';
|
||||||
|
|
||||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
@ -326,6 +327,10 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_FEATURE_LINKS,
|
process.env.UNLEASH_EXPERIMENTAL_FEATURE_LINKS,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
projectLinkTemplates: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_PROJECT_LINK_TEMPLATES,
|
||||||
|
false,
|
||||||
|
),
|
||||||
reportUnknownFlags: parseEnvVarBoolean(
|
reportUnknownFlags: parseEnvVarBoolean(
|
||||||
process.env.UNLEASH_EXPERIMENTAL_REPORT_UNKNOWN_FLAGS,
|
process.env.UNLEASH_EXPERIMENTAL_REPORT_UNKNOWN_FLAGS,
|
||||||
false,
|
false,
|
||||||
|
@ -322,6 +322,7 @@ export interface IProjectOverview {
|
|||||||
featureNaming?: IFeatureNaming;
|
featureNaming?: IFeatureNaming;
|
||||||
defaultStickiness: string;
|
defaultStickiness: string;
|
||||||
onboardingStatus: ProjectOverviewSchema['onboardingStatus'];
|
onboardingStatus: ProjectOverviewSchema['onboardingStatus'];
|
||||||
|
linkTemplates?: IProjectLinkTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProjectHealthReport extends IProjectHealth {
|
export interface IProjectHealthReport extends IProjectHealth {
|
||||||
@ -334,6 +335,11 @@ export interface IProjectParam {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IProjectLinkTemplate {
|
||||||
|
title?: string;
|
||||||
|
urlTemplate: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IArchivedQuery {
|
export interface IArchivedQuery {
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
}
|
}
|
||||||
@ -561,6 +567,7 @@ export interface IProject {
|
|||||||
defaultStickiness: string;
|
defaultStickiness: string;
|
||||||
featureLimit?: number;
|
featureLimit?: number;
|
||||||
featureNaming?: IFeatureNaming;
|
featureNaming?: IFeatureNaming;
|
||||||
|
linkTemplates?: IProjectLinkTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProjectApplications {
|
export interface IProjectApplications {
|
||||||
|
@ -62,6 +62,7 @@ process.nextTick(async () => {
|
|||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
registerFrontendClient: true,
|
registerFrontendClient: true,
|
||||||
featureLinks: true,
|
featureLinks: true,
|
||||||
|
projectLinkTemplates: true,
|
||||||
reportUnknownFlags: true,
|
reportUnknownFlags: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user