diff --git a/src/lib/features/project/project-service.ts b/src/lib/features/project/project-service.ts index 966f1ef9db..4b38ee36fa 100644 --- a/src/lib/features/project/project-service.ts +++ b/src/lib/features/project/project-service.ts @@ -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, diff --git a/src/lib/features/project/project-store-type.ts b/src/lib/features/project/project-store-type.ts index 9f3a190a27..3427fa8e48 100644 --- a/src/lib/features/project/project-store-type.ts +++ b/src/lib/features/project/project-store-type.ts @@ -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 { diff --git a/src/lib/features/project/project-store.e2e.test.ts b/src/lib/features/project/project-store.e2e.test.ts index 9047b35584..fa33be35f1 100644 --- a/src/lib/features/project/project-store.e2e.test.ts +++ b/src/lib/features/project/project-store.e2e.test.ts @@ -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}}', + }), + ]), + ); +}); diff --git a/src/lib/features/project/project-store.ts b/src/lib/features/project/project-store.ts index 5011159825..f87e1d7795 100644 --- a/src/lib/features/project/project-store.ts +++ b/src/lib/features/project/project-store.ts @@ -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 { @@ -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 { @@ -248,6 +249,13 @@ class ProjectStore implements IProjectStore { data: IProjectEnterpriseSettingsUpdate, ): Promise { 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 { @@ -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 } + : {}), }; } diff --git a/src/lib/openapi/spec/create-feature-naming-pattern-schema.ts b/src/lib/openapi/spec/create-feature-naming-pattern-schema.ts index ee75d24cae..91ba89bb62 100644 --- a/src/lib/openapi/spec/create-feature-naming-pattern-schema.ts +++ b/src/lib/openapi/spec/create-feature-naming-pattern-schema.ts @@ -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', diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 578fd1f1c3..2c34e83872 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -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'; diff --git a/src/lib/openapi/spec/project-link-template-schema.ts b/src/lib/openapi/spec/project-link-template-schema.ts new file mode 100644 index 0000000000..ffb4491fe9 --- /dev/null +++ b/src/lib/openapi/spec/project-link-template-schema.ts @@ -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 +>; diff --git a/src/lib/openapi/spec/project-overview-schema.ts b/src/lib/openapi/spec/project-overview-schema.ts index 3c4a7e5a8f..ea89c76871 100644 --- a/src/lib/openapi/spec/project-overview-schema.ts +++ b/src/lib/openapi/spec/project-overview-schema.ts @@ -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; diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index e66fe448b1..b71334650f 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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, diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 585d95e646..457034a757 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -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 { diff --git a/src/server-dev.ts b/src/server-dev.ts index 59d3a4b0c5..dc15d90c21 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -62,6 +62,7 @@ process.nextTick(async () => { strictSchemaValidation: true, registerFrontendClient: true, featureLinks: true, + projectLinkTemplates: true, reportUnknownFlags: true, }, },