diff --git a/frontend/src/component/feature/CopyFeature/CopyFeature.tsx b/frontend/src/component/feature/CopyFeature/CopyFeature.tsx index 995d10297a..7749bb71c7 100644 --- a/frontend/src/component/feature/CopyFeature/CopyFeature.tsx +++ b/frontend/src/component/feature/CopyFeature/CopyFeature.tsx @@ -80,7 +80,7 @@ export const CopyFeatureToggle = () => { const onValidateName = async () => { try { - await validateFeatureToggleName(newToggleName); + await validateFeatureToggleName(newToggleName, projectId); setNameError(undefined); return true; } catch (error) { diff --git a/frontend/src/component/feature/hooks/useFeatureForm.ts b/frontend/src/component/feature/hooks/useFeatureForm.ts index d6271a3c3a..1c35d16749 100644 --- a/frontend/src/component/feature/hooks/useFeatureForm.ts +++ b/frontend/src/component/feature/hooks/useFeatureForm.ts @@ -62,7 +62,7 @@ const useFeatureForm = ( return false; } try { - await validateFeatureToggleName(name); + await validateFeatureToggleName(name, project); return true; } catch (error: unknown) { setErrors(prev => ({ ...prev, name: formatUnknownError(error) })); diff --git a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx index bdc0ec4b6c..3dce656dc9 100644 --- a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx +++ b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx @@ -33,6 +33,10 @@ const CreateProject = () => { projectMode, projectDesc, featureLimit, + featureNamingPattern, + featureNamingExample, + setFeatureNamingExample, + setFeatureNamingPattern, setProjectId, setProjectName, setProjectDesc, @@ -108,6 +112,10 @@ const CreateProject = () => { projectMode={projectMode} projectStickiness={projectStickiness} featureLimit={featureLimit} + featureNamingExample={featureNamingExample} + featureNamingPattern={featureNamingPattern} + setProjectNamingPattern={setFeatureNamingPattern} + setFeatureNamingExample={setFeatureNamingExample} setProjectStickiness={setProjectStickiness} setFeatureLimit={setFeatureLimit} setProjectMode={setProjectMode} diff --git a/frontend/src/component/project/Project/EditProject/EditProject.tsx b/frontend/src/component/project/Project/EditProject/EditProject.tsx index 7d8360833b..9957bd815f 100644 --- a/frontend/src/component/project/Project/EditProject/EditProject.tsx +++ b/frontend/src/component/project/Project/EditProject/EditProject.tsx @@ -41,11 +41,15 @@ const EditProject = () => { projectDesc, projectStickiness, projectMode, + featureNamingPattern, + featureNamingExample, setProjectId, setProjectName, setProjectDesc, setProjectStickiness, setProjectMode, + setFeatureNamingExample, + setFeatureNamingPattern, getProjectPayload, clearErrors, validateProjectId, @@ -56,7 +60,10 @@ const EditProject = () => { project.name, project.description, defaultStickiness, - project.mode + project.mode, + String(project.featureLimit), + project?.featureNaming?.pattern || '', + project?.featureNaming?.example || '' ); const formatApiCode = () => { @@ -122,11 +129,15 @@ const EditProject = () => { setProjectId={setProjectId} projectName={projectName} projectMode={projectMode} + featureNamingPattern={featureNamingPattern} + featureNamingExample={featureNamingExample} setProjectName={setProjectName} projectStickiness={projectStickiness} setProjectStickiness={setProjectStickiness} setProjectMode={setProjectMode} setFeatureLimit={() => {}} + setFeatureNamingExample={setFeatureNamingExample} + setProjectNamingPattern={setFeatureNamingPattern} featureLimit={''} projectDesc={projectDesc} setProjectDesc={setProjectDesc} diff --git a/frontend/src/component/project/Project/ProjectForm/FeatureFlagNamingTooltip.tsx b/frontend/src/component/project/Project/ProjectForm/FeatureFlagNamingTooltip.tsx new file mode 100644 index 0000000000..26de63ea0c --- /dev/null +++ b/frontend/src/component/project/Project/ProjectForm/FeatureFlagNamingTooltip.tsx @@ -0,0 +1,167 @@ +import { Box } from '@mui/material'; +import { FC } from 'react'; +import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; + +export const FeatureFlagNamingTooltip: FC = () => { + const X = 'X'; + const Y = 'Y'; + const nx = 'n{X,}'; + const nxy = 'n{X,Y}'; + return ( + +

Enforce a naming convention for feature flags

+
+

{`eg. ^[A - Za - z0 - 9]{2}[.][a-z]{4,12}$ matches 'a1.project'`}

+
+

Brackets:

+ + + + + + + + + + + + + + + + + + + + + + + +
[abc]Match a single character a, b, or c
[^abc] + Match any character except a, b, or c +
[A-Za-z] + Match any character from uppercase A to + lowercase z +
(ab|cd|ef)Match either ab, cd, or ef
(...)Capture anything enclosed
+

Metacharacters

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
^Start of line
$End of line
.Match any character
\wMatch a word chracter
\WMatch a non-word character
\dMatch a digit
\DMatch any non-digit character
\sMatch a whitespace character
\SMatch a non-whitespace character
\b + Match character at the beginning or end + of a word +
\B + Match a character not at beginning or + end of a word +
\0Match a NUL character
\tMatch a tab character
\xxx + Match a character specified by octal + number xxx +
\xdd + Match a character specified by + hexadecimal number dd +
\uxxxx + Match a Unicode character specified by + hexadecimal number xxxx +
+

Quantifiers

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
n+Match at least one n
n*Match zero or more n's
n?Match zero or one n
n{X}Match sequence of X n's
{nxy}Match sequence of X to Y n's
{nx}Match sequence of X or more n's
+
+ + } + /> + ); +}; diff --git a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx index bb8a7ccd63..77f83897b9 100644 --- a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx +++ b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx @@ -8,6 +8,8 @@ import { Box, styled, TextField } from '@mui/material'; import { CollaborationModeTooltip } from './CollaborationModeTooltip'; import Input from 'component/common/Input/Input'; import { FeatureTogglesLimitTooltip } from './FeatureTogglesLimitTooltip'; +import { FeatureFlagNamingTooltip } from './FeatureFlagNamingTooltip'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; interface IProjectForm { projectId: string; @@ -17,6 +19,10 @@ interface IProjectForm { projectMode?: string; featureLimit: string; featureCount?: number; + featureNamingPattern?: string; + featureNamingExample?: string; + setProjectNamingPattern?: React.Dispatch>; + setFeatureNamingExample?: React.Dispatch>; setProjectStickiness?: React.Dispatch>; setProjectMode?: React.Dispatch>; setProjectId: React.Dispatch>; @@ -95,6 +101,10 @@ const ProjectForm: React.FC = ({ projectMode, featureLimit, featureCount, + featureNamingExample, + featureNamingPattern, + setFeatureNamingExample, + setProjectNamingPattern, setProjectId, setProjectName, setProjectDesc, @@ -106,6 +116,32 @@ const ProjectForm: React.FC = ({ validateProjectId, clearErrors, }) => { + const { uiConfig } = useUiConfig(); + const shouldShowFlagNaming = uiConfig.flags.featureNamingPattern; + const onSetFeatureNamingPattern = (regex: string) => { + try { + new RegExp(regex); + setProjectNamingPattern && setProjectNamingPattern(regex); + clearErrors(); + } catch (e) { + errors.featureNamingPattern = 'Invalid regular expression'; + setProjectNamingPattern && setProjectNamingPattern(regex); + } + }; + + const onSetFeatureNamingExample = (example: string) => { + if (featureNamingPattern) { + const regex = new RegExp(featureNamingPattern); + const matches = regex.test(example); + if (!matches) { + errors.namingExample = 'Example does not match regex'; + } else { + delete errors.namingExample; + } + setFeatureNamingExample && setFeatureNamingExample(trim(example)); + } + }; + return ( @@ -206,7 +242,7 @@ const ProjectForm: React.FC = ({ gap: 1, }} > -

Feature toggles limit?

+

Feature flag limit?

@@ -233,6 +269,61 @@ const ProjectForm: React.FC = ({ /> + + +

Feature flag naming pattern?

+ +
+ + Leave it empty if you don’t want to add a naming + pattern + + + clearErrors()} + onChange={e => + onSetFeatureNamingPattern( + e.target.value + ) + } + /> + + onSetFeatureNamingExample( + e.target.value + ) + } + /> + + + } + />
{children}
diff --git a/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject.tsx b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject.tsx index 88cbd4879e..84f99e0eac 100644 --- a/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject.tsx @@ -36,12 +36,16 @@ const EditProject = () => { projectStickiness, projectMode, featureLimit, + featureNamingPattern, + featureNamingExample, setProjectId, setProjectName, setProjectDesc, setProjectStickiness, setProjectMode, setFeatureLimit, + setFeatureNamingPattern, + setFeatureNamingExample, getProjectPayload, clearErrors, validateProjectId, @@ -53,7 +57,9 @@ const EditProject = () => { project.description, defaultStickiness, project.mode, - project.featureLimit ? String(project.featureLimit) : '' + project.featureLimit ? String(project.featureLimit) : '', + project.featureNaming?.pattern || '', + project.featureNaming?.example || '' ); const formatApiCode = () => { @@ -115,10 +121,14 @@ const EditProject = () => { projectMode={projectMode} featureLimit={featureLimit} featureCount={project.features.length} + featureNamingPattern={featureNamingPattern} + featureNamingExample={featureNamingExample} setProjectName={setProjectName} projectStickiness={projectStickiness} setProjectStickiness={setProjectStickiness} setProjectMode={setProjectMode} + setProjectNamingPattern={setFeatureNamingPattern} + setFeatureNamingExample={setFeatureNamingExample} projectDesc={projectDesc} mode="Edit" setProjectDesc={setProjectDesc} diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.ts b/frontend/src/component/project/Project/hooks/useProjectForm.ts index 94822eb126..1df5e55004 100644 --- a/frontend/src/component/project/Project/hooks/useProjectForm.ts +++ b/frontend/src/component/project/Project/hooks/useProjectForm.ts @@ -10,7 +10,9 @@ const useProjectForm = ( initialProjectDesc = '', initialProjectStickiness = DEFAULT_PROJECT_STICKINESS, initialProjectMode: ProjectMode = 'open', - initialFeatureLimit = '' + initialFeatureLimit = '', + initialFeatureNamingPattern = '', + initialFeatureNamingExample = '' ) => { const [projectId, setProjectId] = useState(initialProjectId); @@ -23,6 +25,12 @@ const useProjectForm = ( useState(initialProjectMode); const [featureLimit, setFeatureLimit] = useState(initialFeatureLimit); + const [featureNamingPattern, setFeatureNamingPattern] = useState( + initialFeatureNamingPattern + ); + const [featureNamingExample, setFeatureNamingExample] = useState( + initialFeatureNamingExample + ); const [errors, setErrors] = useState({}); const { validateId } = useProjectApi(); @@ -47,6 +55,14 @@ const useProjectForm = ( setFeatureLimit(initialFeatureLimit); }, [initialFeatureLimit]); + useEffect(() => { + setFeatureNamingPattern(initialFeatureNamingPattern); + }, [initialFeatureNamingPattern]); + + useEffect(() => { + setFeatureNamingExample(initialFeatureNamingExample); + }, [initialFeatureNamingExample]); + useEffect(() => { setProjectStickiness(initialProjectStickiness); }, [initialProjectStickiness]); @@ -59,6 +75,10 @@ const useProjectForm = ( defaultStickiness: projectStickiness, featureLimit: getFeatureLimitAsNumber(), mode: projectMode, + featureNaming: { + pattern: featureNamingPattern, + example: featureNamingExample, + }, }; }; @@ -103,6 +123,10 @@ const useProjectForm = ( projectStickiness, projectMode, featureLimit, + featureNamingPattern, + featureNamingExample, + setFeatureNamingPattern, + setFeatureNamingExample, setProjectId, setProjectName, setProjectDesc, diff --git a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts index 9d8e6f75c3..8bd77729c2 100644 --- a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts +++ b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts @@ -11,11 +11,14 @@ const useFeatureApi = () => { propagateErrors: true, }); - const validateFeatureToggleName = async (name: string | undefined) => { + const validateFeatureToggleName = async ( + name: string | undefined, + project: string | undefined + ) => { const path = `api/admin/features/validate`; const req = createRequest(path, { method: 'POST', - body: JSON.stringify({ name }), + body: JSON.stringify({ name, projectId: project }), }); try { diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts index a3dd0fd27f..c1bb2c618f 100644 --- a/frontend/src/interfaces/project.ts +++ b/frontend/src/interfaces/project.ts @@ -13,6 +13,11 @@ export interface IProjectCard { favorite?: boolean; } +export type FeatureNamingType = { + pattern: string; + example: string; +}; + export interface IProject { id?: string; members: number; @@ -27,6 +32,7 @@ export interface IProject { mode: 'open' | 'protected'; defaultStickiness: string; featureLimit?: number; + featureNaming?: FeatureNamingType; } export interface IProjectHealthReport extends IProject { diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 1ed61760d6..a6c48096ef 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -56,6 +56,7 @@ export interface IFlags { newApplicationList?: boolean; integrationsRework?: boolean; multipleRoles?: boolean; + featureNamingPattern?: boolean; doraMetrics?: boolean; [key: string]: boolean | Variant | undefined; } diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index e081071d2a..b3a40f5481 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -76,6 +76,7 @@ exports[`should create default config 1`] = ` "disableNotifications": false, "embedProxy": true, "embedProxyFrontend": true, + "featureNamingPattern": false, "featuresExportImport": true, "filterInvalidClientMetrics": false, "googleAuthEnabled": false, @@ -110,6 +111,7 @@ exports[`should create default config 1`] = ` "disableNotifications": false, "embedProxy": true, "embedProxyFrontend": true, + "featureNamingPattern": false, "featuresExportImport": true, "filterInvalidClientMetrics": false, "googleAuthEnabled": false, diff --git a/src/lib/db/project-store.ts b/src/lib/db/project-store.ts index 5e447cf55d..b5446e49b2 100644 --- a/src/lib/db/project-store.ts +++ b/src/lib/db/project-store.ts @@ -37,6 +37,8 @@ const SETTINGS_COLUMNS = [ 'project_mode', 'default_stickiness', 'feature_limit', + 'feature_naming_pattern', + 'feature_naming_example', ]; const SETTINGS_TABLE = 'project_settings'; const PROJECT_ENVIRONMENTS = 'project_environments'; @@ -236,6 +238,8 @@ class ProjectStore implements IProjectStore { project_mode: project.mode, default_stickiness: project.defaultStickiness, feature_limit: project.featureLimit, + feature_naming_pattern: project.featureNamingPattern, + feature_naming_example: project.featureNamingExample, }) .returning('*'); return this.mapRow({ ...row[0], ...settingsRow[0] }); @@ -263,6 +267,8 @@ class ProjectStore implements IProjectStore { project_mode: data.mode, default_stickiness: data.defaultStickiness, feature_limit: data.featureLimit, + feature_naming_pattern: data.featureNaming?.pattern, + feature_naming_example: data.featureNaming?.example, }); } else { await this.db(SETTINGS_TABLE).insert({ @@ -270,6 +276,8 @@ class ProjectStore implements IProjectStore { project_mode: data.mode, default_stickiness: data.defaultStickiness, feature_limit: data.featureLimit, + feature_naming_pattern: data.featureNaming?.pattern, + feature_naming_example: data.featureNaming?.example, }); } } catch (err) { @@ -562,6 +570,10 @@ class ProjectStore implements IProjectStore { mode: row.project_mode || 'open', defaultStickiness: row.default_stickiness || 'default', featureLimit: row.feature_limit, + featureNaming: { + pattern: row.feature_naming_pattern, + example: row.feature_naming_example, + }, }; } diff --git a/src/lib/error/index.ts b/src/lib/error/index.ts index 479c72ad95..1910f85c22 100644 --- a/src/lib/error/index.ts +++ b/src/lib/error/index.ts @@ -14,6 +14,7 @@ import RoleInUseError from './role-in-use-error'; import ProjectWithoutOwnerError from './project-without-owner-error'; import PasswordUndefinedError from './password-undefined'; import PasswordMismatchError from './password-mismatch'; +import PatternError from './pattern-error'; import ForbiddenError from './forbidden-error'; export { @@ -34,5 +35,6 @@ export { RoleInUseError, ProjectWithoutOwnerError, PasswordUndefinedError, + PatternError, PasswordMismatchError, }; diff --git a/src/lib/error/pattern-error.ts b/src/lib/error/pattern-error.ts new file mode 100644 index 0000000000..53da023a16 --- /dev/null +++ b/src/lib/error/pattern-error.ts @@ -0,0 +1,20 @@ +import BadDataError from './bad-data-error'; +import { ApiErrorSchema } from './unleash-error'; + +class PatternError extends BadDataError { + pattern: string; + + constructor(message: string, pattern: string) { + super(message); + this.pattern = pattern; + } + + toJSON(): ApiErrorSchema { + return { + ...super.toJSON(), + pattern: this.pattern, + }; + } +} + +export default PatternError; diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index d3ed7c82f2..d7ccf9e442 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -173,6 +173,7 @@ import { batchStaleSchema } from './spec/batch-stale-schema'; import { createApplicationSchema } from './spec/create-application-schema'; import { contextFieldStrategiesSchema } from './spec/context-field-strategies-schema'; import { advancedPlaygroundEnvironmentFeatureSchema } from './spec/advanced-playground-environment-feature-schema'; +import { createFeatureNamingPatternSchema } from './spec/create-feature-naming-pattern-schema'; // Schemas must have an $id property on the form "#/components/schemas/mySchema". export type SchemaId = typeof schemas[keyof typeof schemas]['$id']; @@ -369,6 +370,7 @@ export const schemas: UnleashSchemas = { createStrategyVariantSchema, clientSegmentSchema, createGroupSchema, + createFeatureNamingPatternSchema, doraFeaturesSchema, projectDoraMetricsSchema, }; diff --git a/src/lib/openapi/spec/create-feature-naming-pattern-schema.ts b/src/lib/openapi/spec/create-feature-naming-pattern-schema.ts new file mode 100644 index 0000000000..827d3647b2 --- /dev/null +++ b/src/lib/openapi/spec/create-feature-naming-pattern-schema.ts @@ -0,0 +1,30 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const createFeatureNamingPatternSchema = { + $id: '#/components/schemas/createFeatureNamingPatternSchema', + type: 'object', + description: 'Create a feature naming pattern', + required: ['pattern'], + properties: { + pattern: { + type: 'string', + nullable: true, + description: + 'A JavaScript regular expression pattern, without the start and end delimiters. Optional flags are not allowed.', + example: '[a-z]{2,5}.team-[a-z]+.[a-z-]+', + pattern: '.*', + }, + example: { + type: 'string', + nullable: true, + description: + 'An example of a feature name that matches the pattern. Must itself match the pattern supplied.', + example: 'new-project.team-red.feature-1', + }, + }, + components: {}, +} as const; + +export type CreateFeatureNamingPatternSchema = FromSchema< + typeof createFeatureNamingPatternSchema +>; diff --git a/src/lib/openapi/spec/health-overview-schema.ts b/src/lib/openapi/spec/health-overview-schema.ts index 4c2a7dc79b..6556b1a95e 100644 --- a/src/lib/openapi/spec/health-overview-schema.ts +++ b/src/lib/openapi/spec/health-overview-schema.ts @@ -12,6 +12,7 @@ import { createFeatureStrategySchema } from './create-feature-strategy-schema'; import { projectEnvironmentSchema } from './project-environment-schema'; import { createStrategyVariantSchema } from './create-strategy-variant-schema'; import { strategyVariantSchema } from './strategy-variant-schema'; +import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema'; export const healthOverviewSchema = { $id: '#/components/schemas/healthOverviewSchema', @@ -117,6 +118,9 @@ export const healthOverviewSchema = { $ref: '#/components/schemas/projectStatsSchema', description: 'Project statistics', }, + featureNaming: { + $ref: '#/components/schemas/createFeatureNamingPatternSchema', + }, }, components: { schemas: { @@ -133,6 +137,7 @@ export const healthOverviewSchema = { strategyVariantSchema, variantSchema, projectStatsSchema, + createFeatureNamingPatternSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/project-overview-schema.ts b/src/lib/openapi/spec/project-overview-schema.ts index 427cd602e5..c7151258dd 100644 --- a/src/lib/openapi/spec/project-overview-schema.ts +++ b/src/lib/openapi/spec/project-overview-schema.ts @@ -12,6 +12,7 @@ import { createFeatureStrategySchema } from './create-feature-strategy-schema'; import { projectEnvironmentSchema } from './project-environment-schema'; import { createStrategyVariantSchema } from './create-strategy-variant-schema'; import { strategyVariantSchema } from './strategy-variant-schema'; +import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema'; export const projectOverviewSchema = { $id: '#/components/schemas/projectOverviewSchema', @@ -62,6 +63,9 @@ export const projectOverviewSchema = { description: 'A limit on the number of features allowed in the project. Null if no limit.', }, + featureNaming: { + $ref: '#/components/schemas/createFeatureNamingPatternSchema', + }, members: { type: 'number', example: 4, @@ -139,6 +143,7 @@ export const projectOverviewSchema = { strategyVariantSchema, variantSchema, projectStatsSchema, + createFeatureNamingPatternSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/validate-feature-schema.ts b/src/lib/openapi/spec/validate-feature-schema.ts index c5cf33ad6c..7ae42edcea 100644 --- a/src/lib/openapi/spec/validate-feature-schema.ts +++ b/src/lib/openapi/spec/validate-feature-schema.ts @@ -11,6 +11,13 @@ export const validateFeatureSchema = { type: 'string', example: 'my-feature-3', }, + projectId: { + description: + 'The id of the project that the feature flag will belong to. If the target project has a feature naming pattern defined, the name will be validated against that pattern.', + nullable: true, + type: 'string', + example: 'project-y', + }, }, components: {}, } as const; diff --git a/src/lib/routes/admin-api/feature.ts b/src/lib/routes/admin-api/feature.ts index 02b624e220..8dc8a6ec80 100644 --- a/src/lib/routes/admin-api/feature.ts +++ b/src/lib/routes/admin-api/feature.ts @@ -307,9 +307,10 @@ class FeatureController extends Controller { req: Request, res: Response, ): Promise { - const { name } = req.body; + const { name, projectId } = req.body; await this.service.validateName(name); + await this.service.validateFeatureFlagPattern(name, projectId); res.status(200).end(); } diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index fe527b7f45..26828e0482 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -45,6 +45,7 @@ import { IStrategyStore, } from '../types'; import { Logger } from '../logger'; +import { PatternError } from '../error'; import BadDataError from '../error/bad-data-error'; import NameExistsError from '../error/name-exists-error'; import InvalidOperationError from '../error/invalid-operation-error'; @@ -1034,6 +1035,8 @@ class FeatureToggleService { ): Promise { this.logger.info(`${createdBy} creates feature toggle ${value.name}`); await this.validateName(value.name); + await this.validateFeatureFlagPattern(value.name, projectId); + const exists = await this.projectStore.hasProject(projectId); if (await this.projectStore.isFeatureLimitReached(projectId)) { @@ -1088,6 +1091,28 @@ class FeatureToggleService { throw new NotFoundError(`Project with id ${projectId} does not exist`); } + async validateFeatureFlagPattern( + featureName: string, + projectId?: string, + ): Promise { + if (this.flagResolver.isEnabled('featureNamingPattern') && projectId) { + const project = await this.projectStore.get(projectId); + const namingPattern = project.featureNaming?.pattern; + const namingExample = project.featureNaming?.example; + + if ( + namingPattern && + !featureName.match(new RegExp(namingPattern)) + ) { + const error = `The feature flag name "${featureName}" does not match the project's naming pattern: "${namingPattern}.`; + const example = namingExample + ? ` Here's an example of a name that does match the pattern: "${namingExample}. Try something like that instead."` + : ''; + throw new PatternError(`${error}${example}`, namingPattern); + } + } + } + async cloneFeatureToggle( featureName: string, projectId: string, diff --git a/src/lib/services/project-schema.ts b/src/lib/services/project-schema.ts index 26db622927..ee0fb15502 100644 --- a/src/lib/services/project-schema.ts +++ b/src/lib/services/project-schema.ts @@ -10,5 +10,9 @@ export const projectSchema = joi mode: joi.string().valid('open', 'protected').default('open'), defaultStickiness: joi.string().default('default'), featureLimit: joi.number().allow(null).optional(), + featureNaming: joi.object().keys({ + pattern: joi.string().allow(null).optional(), + example: joi.string().allow(null).optional(), + }), }) .options({ allowUnknown: false, stripUnknown: true }); diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index d6b175904a..8ac9b55314 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -38,6 +38,7 @@ import { ProjectAccessGroupRolesUpdated, IProjectRoleUsage, ProjectAccessUserRolesDeleted, + IFeatureNaming, } from '../types'; import { IProjectQuery, IProjectStore } from '../types/stores/project-store'; import { @@ -55,7 +56,7 @@ import { FavoritesService } from './favorites-service'; import { calculateAverageTimeToProd } from '../features/feature-toggle/time-to-production/time-to-production'; import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type'; import { uniqueByKey } from '../util/unique'; -import { PermissionError } from '../error'; +import { BadDataError, PermissionError } from '../error'; import { ProjectDoraMetricsSchema } from 'lib/openapi'; const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown'; @@ -167,6 +168,21 @@ export default class ProjectService { return this.store.get(id); } + private validateFlagNaming = (naming?: IFeatureNaming) => { + if (naming) { + const { pattern, example } = naming; + if ( + pattern != null && + example && + !example.match(new RegExp(pattern)) + ) { + throw new BadDataError( + `You've provided a feature flag naming example ("${example}") that doesn't match your feature flag naming pattern ("${pattern}"). Please provide an example that matches your supplied pattern.`, + ); + } + } + }; + async createProject( newProject: Pick< IProject, @@ -177,6 +193,8 @@ export default class ProjectService { const data = await projectSchema.validateAsync(newProject); await this.validateUniqueId(data.id); + this.validateFlagNaming(data.featureNaming); + await this.store.create(data); const enabledEnvironments = await this.environmentStore.getAll({ @@ -207,15 +225,24 @@ export default class ProjectService { async updateProject(updatedProject: IProject, user: User): Promise { const preData = await this.store.get(updatedProject.id); - const project = await projectSchema.validateAsync(updatedProject); - await this.store.update(project); + if (updatedProject.featureNaming) { + this.validateFlagNaming(updatedProject.featureNaming); + } + if ( + updatedProject.featureNaming?.pattern && + !updatedProject.featureNaming?.example + ) { + updatedProject.featureNaming.example = null; + } + + await this.store.update(updatedProject); await this.eventStore.store({ type: PROJECT_UPDATED, - project: project.id, + project: updatedProject.id, createdBy: getCreatedBy(user), - data: project, + data: updatedProject, preData, }); } @@ -981,6 +1008,7 @@ export default class ProjectService { description: project.description, mode: project.mode, featureLimit: project.featureLimit, + featureNaming: project.featureNaming, defaultStickiness: project.defaultStickiness, health: project.health || 0, favorite: favorite, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index e044ad81af..de2d3083c5 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -27,6 +27,7 @@ export type IFlagKey = | 'newApplicationList' | 'integrationsRework' | 'multipleRoles' + | 'featureNamingPattern' | 'doraMetrics'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -123,6 +124,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_NEW_APPLICATION_LIST, false, ), + featureNamingPattern: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_FEATURE_NAMING_PATTERN, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 769c602b09..8b2f1abc20 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -189,6 +189,11 @@ export interface IFeatureOverview { export type ProjectMode = 'open' | 'protected'; +export interface IFeatureNaming { + pattern: string | null; + example: string | null; +} + export interface IProjectOverview { name: string; description: string; @@ -203,6 +208,7 @@ export interface IProjectOverview { stats?: IProjectStats; mode: ProjectMode; featureLimit?: number; + featureNaming?: IFeatureNaming; defaultStickiness: string; } @@ -405,6 +411,7 @@ export interface IProject { mode: ProjectMode; defaultStickiness: string; featureLimit?: number; + featureNaming?: IFeatureNaming; } /** diff --git a/src/lib/types/stores/project-store.ts b/src/lib/types/stores/project-store.ts index fd652b15ba..3b48de6372 100644 --- a/src/lib/types/stores/project-store.ts +++ b/src/lib/types/stores/project-store.ts @@ -19,12 +19,16 @@ export interface IProjectInsert { changeRequestsEnabled?: boolean; mode: ProjectMode; featureLimit?: number; + featureNamingPattern?: string; + featureNamingExample?: string; } export interface IProjectSettings { mode: ProjectMode; defaultStickiness: string; featureLimit?: number; + featureNamingPattern?: string; + featureNamingExample?: string; } export interface IProjectSettingsRow { diff --git a/src/migrations/20230830121352-update-client-applications-usage-table.js b/src/migrations/20230830121352-update-client-applications-usage-table.js new file mode 100644 index 0000000000..42fc2785c2 --- /dev/null +++ b/src/migrations/20230830121352-update-client-applications-usage-table.js @@ -0,0 +1,23 @@ +'use strict'; + +exports.up = function (db, cb) { + db.runSql( + ` + ALTER TABLE project_settings + ADD COLUMN IF NOT EXISTS "feature_naming_pattern" text; + ALTER TABLE project_settings + ADD COLUMN IF NOT EXISTS "feature_naming_example" text; + `, + cb(), + ); +}; + +exports.down = function (db, cb) { + db.runSql( + ` + ALTER TABLE project_settings DROP COLUMN IF EXISTS "feature_naming_pattern"; + ALTER TABLE project_settings DROP COLUMN IF EXISTS "feature_naming_example"; + `, + cb, + ); +}; diff --git a/src/server-dev.ts b/src/server-dev.ts index 0de7336306..42bd13ac8a 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -40,6 +40,7 @@ process.nextTick(async () => { slackAppAddon: true, lastSeenByEnvironment: true, newApplicationList: true, + featureNamingPattern: true, doraMetrics: true, }, }, diff --git a/src/test/e2e/api/client/feature.e2e.test.ts b/src/test/e2e/api/client/feature.e2e.test.ts index 7121597beb..059d1c5881 100644 --- a/src/test/e2e/api/client/feature.e2e.test.ts +++ b/src/test/e2e/api/client/feature.e2e.test.ts @@ -15,6 +15,7 @@ beforeAll(async () => { experimental: { flags: { strictSchemaValidation: true, + featureNamingPattern: true, }, }, });