1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-23 01:16:27 +02:00

feat: feature naming patterns (#4591)

Adds a first iteration of feature flag naming patterns. Currently behind a flag.

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
Co-authored-by: Thomas Heartman <thomas@getunleash.io>
Co-authored-by: andreas-unleash <andreas@getunleash.ai>
Co-authored-by: Thomas Heartman <thomas@getunleash.ai>
This commit is contained in:
Jaanus Sellin 2023-09-04 14:53:33 +03:00 committed by GitHub
parent 45e089f27f
commit 53f90d37c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 519 additions and 14 deletions

View File

@ -80,7 +80,7 @@ export const CopyFeatureToggle = () => {
const onValidateName = async () => { const onValidateName = async () => {
try { try {
await validateFeatureToggleName(newToggleName); await validateFeatureToggleName(newToggleName, projectId);
setNameError(undefined); setNameError(undefined);
return true; return true;
} catch (error) { } catch (error) {

View File

@ -62,7 +62,7 @@ const useFeatureForm = (
return false; return false;
} }
try { try {
await validateFeatureToggleName(name); await validateFeatureToggleName(name, project);
return true; return true;
} catch (error: unknown) { } catch (error: unknown) {
setErrors(prev => ({ ...prev, name: formatUnknownError(error) })); setErrors(prev => ({ ...prev, name: formatUnknownError(error) }));

View File

@ -33,6 +33,10 @@ const CreateProject = () => {
projectMode, projectMode,
projectDesc, projectDesc,
featureLimit, featureLimit,
featureNamingPattern,
featureNamingExample,
setFeatureNamingExample,
setFeatureNamingPattern,
setProjectId, setProjectId,
setProjectName, setProjectName,
setProjectDesc, setProjectDesc,
@ -108,6 +112,10 @@ const CreateProject = () => {
projectMode={projectMode} projectMode={projectMode}
projectStickiness={projectStickiness} projectStickiness={projectStickiness}
featureLimit={featureLimit} featureLimit={featureLimit}
featureNamingExample={featureNamingExample}
featureNamingPattern={featureNamingPattern}
setProjectNamingPattern={setFeatureNamingPattern}
setFeatureNamingExample={setFeatureNamingExample}
setProjectStickiness={setProjectStickiness} setProjectStickiness={setProjectStickiness}
setFeatureLimit={setFeatureLimit} setFeatureLimit={setFeatureLimit}
setProjectMode={setProjectMode} setProjectMode={setProjectMode}

View File

@ -41,11 +41,15 @@ const EditProject = () => {
projectDesc, projectDesc,
projectStickiness, projectStickiness,
projectMode, projectMode,
featureNamingPattern,
featureNamingExample,
setProjectId, setProjectId,
setProjectName, setProjectName,
setProjectDesc, setProjectDesc,
setProjectStickiness, setProjectStickiness,
setProjectMode, setProjectMode,
setFeatureNamingExample,
setFeatureNamingPattern,
getProjectPayload, getProjectPayload,
clearErrors, clearErrors,
validateProjectId, validateProjectId,
@ -56,7 +60,10 @@ const EditProject = () => {
project.name, project.name,
project.description, project.description,
defaultStickiness, defaultStickiness,
project.mode project.mode,
String(project.featureLimit),
project?.featureNaming?.pattern || '',
project?.featureNaming?.example || ''
); );
const formatApiCode = () => { const formatApiCode = () => {
@ -122,11 +129,15 @@ const EditProject = () => {
setProjectId={setProjectId} setProjectId={setProjectId}
projectName={projectName} projectName={projectName}
projectMode={projectMode} projectMode={projectMode}
featureNamingPattern={featureNamingPattern}
featureNamingExample={featureNamingExample}
setProjectName={setProjectName} setProjectName={setProjectName}
projectStickiness={projectStickiness} projectStickiness={projectStickiness}
setProjectStickiness={setProjectStickiness} setProjectStickiness={setProjectStickiness}
setProjectMode={setProjectMode} setProjectMode={setProjectMode}
setFeatureLimit={() => {}} setFeatureLimit={() => {}}
setFeatureNamingExample={setFeatureNamingExample}
setProjectNamingPattern={setFeatureNamingPattern}
featureLimit={''} featureLimit={''}
projectDesc={projectDesc} projectDesc={projectDesc}
setProjectDesc={setProjectDesc} setProjectDesc={setProjectDesc}

View File

@ -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 (
<HelpIcon
htmlTooltip
tooltip={
<Box>
<h3>Enforce a naming convention for feature flags</h3>
<hr />
<p>{`eg. ^[A - Za - z0 - 9]{2}[.][a-z]{4,12}$ matches 'a1.project'`}</p>
<div className="scrollable">
<h3>Brackets:</h3>
<table>
<tbody>
<tr>
<td>[abc]</td>
<td>Match a single character a, b, or c</td>
</tr>
<tr>
<td>[^abc]</td>
<td>
Match any character except a, b, or c
</td>
</tr>
<tr>
<td>[A-Za-z]</td>
<td>
Match any character from uppercase A to
lowercase z
</td>
</tr>
<tr>
<td>(ab|cd|ef)</td>
<td>Match either ab, cd, or ef</td>
</tr>
<tr>
<td>(...)</td>
<td>Capture anything enclosed</td>
</tr>
</tbody>
</table>
<h3>Metacharacters</h3>
<table>
<tbody>
<tr>
<td>^</td>
<td>Start of line</td>
</tr>
<tr>
<td>$</td>
<td>End of line</td>
</tr>
<tr>
<td>.</td>
<td>Match any character</td>
</tr>
<tr>
<td>\w</td>
<td>Match a word chracter</td>
</tr>
<tr>
<td>\W</td>
<td>Match a non-word character</td>
</tr>
<tr>
<td>\d</td>
<td>Match a digit</td>
</tr>
<tr>
<td>\D</td>
<td>Match any non-digit character</td>
</tr>
<tr>
<td>\s</td>
<td>Match a whitespace character</td>
</tr>
<tr>
<td>\S</td>
<td>Match a non-whitespace character</td>
</tr>
<tr>
<td>\b</td>
<td>
Match character at the beginning or end
of a word
</td>
</tr>
<tr>
<td>\B</td>
<td>
Match a character not at beginning or
end of a word
</td>
</tr>
<tr>
<td>\0</td>
<td>Match a NUL character</td>
</tr>
<tr>
<td>\t</td>
<td>Match a tab character</td>
</tr>
<tr>
<td>\xxx</td>
<td>
Match a character specified by octal
number xxx
</td>
</tr>
<tr>
<td>\xdd</td>
<td>
Match a character specified by
hexadecimal number dd
</td>
</tr>
<tr>
<td>\uxxxx</td>
<td>
Match a Unicode character specified by
hexadecimal number xxxx
</td>
</tr>
</tbody>
</table>
<h3>Quantifiers</h3>
<table>
<tbody>
<tr>
<td>n+</td>
<td>Match at least one n</td>
</tr>
<tr>
<td>n*</td>
<td>Match zero or more n's</td>
</tr>
<tr>
<td>n?</td>
<td>Match zero or one n</td>
</tr>
<tr>
<td>n{X}</td>
<td>Match sequence of X n's</td>
</tr>
<tr>
<td>{nxy}</td>
<td>Match sequence of X to Y n's</td>
</tr>
<tr>
<td>{nx}</td>
<td>Match sequence of X or more n's</td>
</tr>
</tbody>
</table>
</div>
</Box>
}
/>
);
};

View File

@ -8,6 +8,8 @@ import { Box, styled, TextField } from '@mui/material';
import { CollaborationModeTooltip } from './CollaborationModeTooltip'; import { CollaborationModeTooltip } from './CollaborationModeTooltip';
import Input from 'component/common/Input/Input'; import Input from 'component/common/Input/Input';
import { FeatureTogglesLimitTooltip } from './FeatureTogglesLimitTooltip'; import { FeatureTogglesLimitTooltip } from './FeatureTogglesLimitTooltip';
import { FeatureFlagNamingTooltip } from './FeatureFlagNamingTooltip';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
interface IProjectForm { interface IProjectForm {
projectId: string; projectId: string;
@ -17,6 +19,10 @@ interface IProjectForm {
projectMode?: string; projectMode?: string;
featureLimit: string; featureLimit: string;
featureCount?: number; featureCount?: number;
featureNamingPattern?: string;
featureNamingExample?: string;
setProjectNamingPattern?: React.Dispatch<React.SetStateAction<string>>;
setFeatureNamingExample?: React.Dispatch<React.SetStateAction<string>>;
setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>; setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>;
setProjectMode?: React.Dispatch<React.SetStateAction<ProjectMode>>; setProjectMode?: React.Dispatch<React.SetStateAction<ProjectMode>>;
setProjectId: React.Dispatch<React.SetStateAction<string>>; setProjectId: React.Dispatch<React.SetStateAction<string>>;
@ -95,6 +101,10 @@ const ProjectForm: React.FC<IProjectForm> = ({
projectMode, projectMode,
featureLimit, featureLimit,
featureCount, featureCount,
featureNamingExample,
featureNamingPattern,
setFeatureNamingExample,
setProjectNamingPattern,
setProjectId, setProjectId,
setProjectName, setProjectName,
setProjectDesc, setProjectDesc,
@ -106,6 +116,32 @@ const ProjectForm: React.FC<IProjectForm> = ({
validateProjectId, validateProjectId,
clearErrors, 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 ( return (
<StyledForm onSubmit={handleSubmit}> <StyledForm onSubmit={handleSubmit}>
<StyledContainer> <StyledContainer>
@ -206,7 +242,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
gap: 1, gap: 1,
}} }}
> >
<p>Feature toggles limit?</p> <p>Feature flag limit?</p>
<FeatureTogglesLimitTooltip /> <FeatureTogglesLimitTooltip />
</Box> </Box>
<StyledSubtitle> <StyledSubtitle>
@ -233,6 +269,61 @@ const ProjectForm: React.FC<IProjectForm> = ({
/> />
</StyledInputContainer> </StyledInputContainer>
</> </>
<ConditionallyRender
condition={
Boolean(shouldShowFlagNaming) &&
setProjectNamingPattern != null &&
setFeatureNamingExample != null
}
show={
<>
<Box
sx={{
display: 'flex',
alignItems: 'center',
marginBottom: 1,
gap: 1,
}}
>
<p>Feature flag naming pattern?</p>
<FeatureFlagNamingTooltip />
</Box>
<StyledSubtitle>
Leave it empty if you dont want to add a naming
pattern
</StyledSubtitle>
<StyledInputContainer>
<StyledInput
label={'Naming Pattern'}
name="pattern"
type={'text'}
value={featureNamingPattern || ''}
error={Boolean(errors.featureNamingPattern)}
errorText={errors.featureNamingPattern}
onFocus={() => clearErrors()}
onChange={e =>
onSetFeatureNamingPattern(
e.target.value
)
}
/>
<StyledInput
label={'Naming Example'}
name="example"
type={'text'}
value={featureNamingExample || ''}
error={Boolean(errors.namingExample)}
errorText={errors.namingExample}
onChange={e =>
onSetFeatureNamingExample(
e.target.value
)
}
/>
</StyledInputContainer>
</>
}
/>
</StyledContainer> </StyledContainer>
<StyledButtonContainer>{children}</StyledButtonContainer> <StyledButtonContainer>{children}</StyledButtonContainer>
</StyledForm> </StyledForm>

View File

@ -36,12 +36,16 @@ const EditProject = () => {
projectStickiness, projectStickiness,
projectMode, projectMode,
featureLimit, featureLimit,
featureNamingPattern,
featureNamingExample,
setProjectId, setProjectId,
setProjectName, setProjectName,
setProjectDesc, setProjectDesc,
setProjectStickiness, setProjectStickiness,
setProjectMode, setProjectMode,
setFeatureLimit, setFeatureLimit,
setFeatureNamingPattern,
setFeatureNamingExample,
getProjectPayload, getProjectPayload,
clearErrors, clearErrors,
validateProjectId, validateProjectId,
@ -53,7 +57,9 @@ const EditProject = () => {
project.description, project.description,
defaultStickiness, defaultStickiness,
project.mode, project.mode,
project.featureLimit ? String(project.featureLimit) : '' project.featureLimit ? String(project.featureLimit) : '',
project.featureNaming?.pattern || '',
project.featureNaming?.example || ''
); );
const formatApiCode = () => { const formatApiCode = () => {
@ -115,10 +121,14 @@ const EditProject = () => {
projectMode={projectMode} projectMode={projectMode}
featureLimit={featureLimit} featureLimit={featureLimit}
featureCount={project.features.length} featureCount={project.features.length}
featureNamingPattern={featureNamingPattern}
featureNamingExample={featureNamingExample}
setProjectName={setProjectName} setProjectName={setProjectName}
projectStickiness={projectStickiness} projectStickiness={projectStickiness}
setProjectStickiness={setProjectStickiness} setProjectStickiness={setProjectStickiness}
setProjectMode={setProjectMode} setProjectMode={setProjectMode}
setProjectNamingPattern={setFeatureNamingPattern}
setFeatureNamingExample={setFeatureNamingExample}
projectDesc={projectDesc} projectDesc={projectDesc}
mode="Edit" mode="Edit"
setProjectDesc={setProjectDesc} setProjectDesc={setProjectDesc}

View File

@ -10,7 +10,9 @@ const useProjectForm = (
initialProjectDesc = '', initialProjectDesc = '',
initialProjectStickiness = DEFAULT_PROJECT_STICKINESS, initialProjectStickiness = DEFAULT_PROJECT_STICKINESS,
initialProjectMode: ProjectMode = 'open', initialProjectMode: ProjectMode = 'open',
initialFeatureLimit = '' initialFeatureLimit = '',
initialFeatureNamingPattern = '',
initialFeatureNamingExample = ''
) => { ) => {
const [projectId, setProjectId] = useState(initialProjectId); const [projectId, setProjectId] = useState(initialProjectId);
@ -23,6 +25,12 @@ const useProjectForm = (
useState<ProjectMode>(initialProjectMode); useState<ProjectMode>(initialProjectMode);
const [featureLimit, setFeatureLimit] = const [featureLimit, setFeatureLimit] =
useState<string>(initialFeatureLimit); useState<string>(initialFeatureLimit);
const [featureNamingPattern, setFeatureNamingPattern] = useState(
initialFeatureNamingPattern
);
const [featureNamingExample, setFeatureNamingExample] = useState(
initialFeatureNamingExample
);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const { validateId } = useProjectApi(); const { validateId } = useProjectApi();
@ -47,6 +55,14 @@ const useProjectForm = (
setFeatureLimit(initialFeatureLimit); setFeatureLimit(initialFeatureLimit);
}, [initialFeatureLimit]); }, [initialFeatureLimit]);
useEffect(() => {
setFeatureNamingPattern(initialFeatureNamingPattern);
}, [initialFeatureNamingPattern]);
useEffect(() => {
setFeatureNamingExample(initialFeatureNamingExample);
}, [initialFeatureNamingExample]);
useEffect(() => { useEffect(() => {
setProjectStickiness(initialProjectStickiness); setProjectStickiness(initialProjectStickiness);
}, [initialProjectStickiness]); }, [initialProjectStickiness]);
@ -59,6 +75,10 @@ const useProjectForm = (
defaultStickiness: projectStickiness, defaultStickiness: projectStickiness,
featureLimit: getFeatureLimitAsNumber(), featureLimit: getFeatureLimitAsNumber(),
mode: projectMode, mode: projectMode,
featureNaming: {
pattern: featureNamingPattern,
example: featureNamingExample,
},
}; };
}; };
@ -103,6 +123,10 @@ const useProjectForm = (
projectStickiness, projectStickiness,
projectMode, projectMode,
featureLimit, featureLimit,
featureNamingPattern,
featureNamingExample,
setFeatureNamingPattern,
setFeatureNamingExample,
setProjectId, setProjectId,
setProjectName, setProjectName,
setProjectDesc, setProjectDesc,

View File

@ -11,11 +11,14 @@ const useFeatureApi = () => {
propagateErrors: true, propagateErrors: true,
}); });
const validateFeatureToggleName = async (name: string | undefined) => { const validateFeatureToggleName = async (
name: string | undefined,
project: string | undefined
) => {
const path = `api/admin/features/validate`; const path = `api/admin/features/validate`;
const req = createRequest(path, { const req = createRequest(path, {
method: 'POST', method: 'POST',
body: JSON.stringify({ name }), body: JSON.stringify({ name, projectId: project }),
}); });
try { try {

View File

@ -13,6 +13,11 @@ export interface IProjectCard {
favorite?: boolean; favorite?: boolean;
} }
export type FeatureNamingType = {
pattern: string;
example: string;
};
export interface IProject { export interface IProject {
id?: string; id?: string;
members: number; members: number;
@ -27,6 +32,7 @@ export interface IProject {
mode: 'open' | 'protected'; mode: 'open' | 'protected';
defaultStickiness: string; defaultStickiness: string;
featureLimit?: number; featureLimit?: number;
featureNaming?: FeatureNamingType;
} }
export interface IProjectHealthReport extends IProject { export interface IProjectHealthReport extends IProject {

View File

@ -56,6 +56,7 @@ export interface IFlags {
newApplicationList?: boolean; newApplicationList?: boolean;
integrationsRework?: boolean; integrationsRework?: boolean;
multipleRoles?: boolean; multipleRoles?: boolean;
featureNamingPattern?: boolean;
doraMetrics?: boolean; doraMetrics?: boolean;
[key: string]: boolean | Variant | undefined; [key: string]: boolean | Variant | undefined;
} }

View File

@ -76,6 +76,7 @@ exports[`should create default config 1`] = `
"disableNotifications": false, "disableNotifications": false,
"embedProxy": true, "embedProxy": true,
"embedProxyFrontend": true, "embedProxyFrontend": true,
"featureNamingPattern": false,
"featuresExportImport": true, "featuresExportImport": true,
"filterInvalidClientMetrics": false, "filterInvalidClientMetrics": false,
"googleAuthEnabled": false, "googleAuthEnabled": false,
@ -110,6 +111,7 @@ exports[`should create default config 1`] = `
"disableNotifications": false, "disableNotifications": false,
"embedProxy": true, "embedProxy": true,
"embedProxyFrontend": true, "embedProxyFrontend": true,
"featureNamingPattern": false,
"featuresExportImport": true, "featuresExportImport": true,
"filterInvalidClientMetrics": false, "filterInvalidClientMetrics": false,
"googleAuthEnabled": false, "googleAuthEnabled": false,

View File

@ -37,6 +37,8 @@ const SETTINGS_COLUMNS = [
'project_mode', 'project_mode',
'default_stickiness', 'default_stickiness',
'feature_limit', 'feature_limit',
'feature_naming_pattern',
'feature_naming_example',
]; ];
const SETTINGS_TABLE = 'project_settings'; const SETTINGS_TABLE = 'project_settings';
const PROJECT_ENVIRONMENTS = 'project_environments'; const PROJECT_ENVIRONMENTS = 'project_environments';
@ -236,6 +238,8 @@ class ProjectStore implements IProjectStore {
project_mode: project.mode, project_mode: project.mode,
default_stickiness: project.defaultStickiness, default_stickiness: project.defaultStickiness,
feature_limit: project.featureLimit, feature_limit: project.featureLimit,
feature_naming_pattern: project.featureNamingPattern,
feature_naming_example: project.featureNamingExample,
}) })
.returning('*'); .returning('*');
return this.mapRow({ ...row[0], ...settingsRow[0] }); return this.mapRow({ ...row[0], ...settingsRow[0] });
@ -263,6 +267,8 @@ class ProjectStore implements IProjectStore {
project_mode: data.mode, project_mode: data.mode,
default_stickiness: data.defaultStickiness, default_stickiness: data.defaultStickiness,
feature_limit: data.featureLimit, feature_limit: data.featureLimit,
feature_naming_pattern: data.featureNaming?.pattern,
feature_naming_example: data.featureNaming?.example,
}); });
} else { } else {
await this.db(SETTINGS_TABLE).insert({ await this.db(SETTINGS_TABLE).insert({
@ -270,6 +276,8 @@ class ProjectStore implements IProjectStore {
project_mode: data.mode, project_mode: data.mode,
default_stickiness: data.defaultStickiness, default_stickiness: data.defaultStickiness,
feature_limit: data.featureLimit, feature_limit: data.featureLimit,
feature_naming_pattern: data.featureNaming?.pattern,
feature_naming_example: data.featureNaming?.example,
}); });
} }
} catch (err) { } catch (err) {
@ -562,6 +570,10 @@ class ProjectStore implements IProjectStore {
mode: row.project_mode || 'open', mode: row.project_mode || 'open',
defaultStickiness: row.default_stickiness || 'default', defaultStickiness: row.default_stickiness || 'default',
featureLimit: row.feature_limit, featureLimit: row.feature_limit,
featureNaming: {
pattern: row.feature_naming_pattern,
example: row.feature_naming_example,
},
}; };
} }

View File

@ -14,6 +14,7 @@ import RoleInUseError from './role-in-use-error';
import ProjectWithoutOwnerError from './project-without-owner-error'; import ProjectWithoutOwnerError from './project-without-owner-error';
import PasswordUndefinedError from './password-undefined'; import PasswordUndefinedError from './password-undefined';
import PasswordMismatchError from './password-mismatch'; import PasswordMismatchError from './password-mismatch';
import PatternError from './pattern-error';
import ForbiddenError from './forbidden-error'; import ForbiddenError from './forbidden-error';
export { export {
@ -34,5 +35,6 @@ export {
RoleInUseError, RoleInUseError,
ProjectWithoutOwnerError, ProjectWithoutOwnerError,
PasswordUndefinedError, PasswordUndefinedError,
PatternError,
PasswordMismatchError, PasswordMismatchError,
}; };

View File

@ -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;

View File

@ -173,6 +173,7 @@ import { batchStaleSchema } from './spec/batch-stale-schema';
import { createApplicationSchema } from './spec/create-application-schema'; import { createApplicationSchema } from './spec/create-application-schema';
import { contextFieldStrategiesSchema } from './spec/context-field-strategies-schema'; import { contextFieldStrategiesSchema } from './spec/context-field-strategies-schema';
import { advancedPlaygroundEnvironmentFeatureSchema } from './spec/advanced-playground-environment-feature-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". // Schemas must have an $id property on the form "#/components/schemas/mySchema".
export type SchemaId = typeof schemas[keyof typeof schemas]['$id']; export type SchemaId = typeof schemas[keyof typeof schemas]['$id'];
@ -369,6 +370,7 @@ export const schemas: UnleashSchemas = {
createStrategyVariantSchema, createStrategyVariantSchema,
clientSegmentSchema, clientSegmentSchema,
createGroupSchema, createGroupSchema,
createFeatureNamingPatternSchema,
doraFeaturesSchema, doraFeaturesSchema,
projectDoraMetricsSchema, projectDoraMetricsSchema,
}; };

View File

@ -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
>;

View File

@ -12,6 +12,7 @@ import { createFeatureStrategySchema } from './create-feature-strategy-schema';
import { projectEnvironmentSchema } from './project-environment-schema'; import { projectEnvironmentSchema } from './project-environment-schema';
import { createStrategyVariantSchema } from './create-strategy-variant-schema'; 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';
export const healthOverviewSchema = { export const healthOverviewSchema = {
$id: '#/components/schemas/healthOverviewSchema', $id: '#/components/schemas/healthOverviewSchema',
@ -117,6 +118,9 @@ export const healthOverviewSchema = {
$ref: '#/components/schemas/projectStatsSchema', $ref: '#/components/schemas/projectStatsSchema',
description: 'Project statistics', description: 'Project statistics',
}, },
featureNaming: {
$ref: '#/components/schemas/createFeatureNamingPatternSchema',
},
}, },
components: { components: {
schemas: { schemas: {
@ -133,6 +137,7 @@ export const healthOverviewSchema = {
strategyVariantSchema, strategyVariantSchema,
variantSchema, variantSchema,
projectStatsSchema, projectStatsSchema,
createFeatureNamingPatternSchema,
}, },
}, },
} as const; } as const;

View File

@ -12,6 +12,7 @@ import { createFeatureStrategySchema } from './create-feature-strategy-schema';
import { projectEnvironmentSchema } from './project-environment-schema'; import { projectEnvironmentSchema } from './project-environment-schema';
import { createStrategyVariantSchema } from './create-strategy-variant-schema'; 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';
export const projectOverviewSchema = { export const projectOverviewSchema = {
$id: '#/components/schemas/projectOverviewSchema', $id: '#/components/schemas/projectOverviewSchema',
@ -62,6 +63,9 @@ export const projectOverviewSchema = {
description: description:
'A limit on the number of features allowed in the project. Null if no limit.', 'A limit on the number of features allowed in the project. Null if no limit.',
}, },
featureNaming: {
$ref: '#/components/schemas/createFeatureNamingPatternSchema',
},
members: { members: {
type: 'number', type: 'number',
example: 4, example: 4,
@ -139,6 +143,7 @@ export const projectOverviewSchema = {
strategyVariantSchema, strategyVariantSchema,
variantSchema, variantSchema,
projectStatsSchema, projectStatsSchema,
createFeatureNamingPatternSchema,
}, },
}, },
} as const; } as const;

View File

@ -11,6 +11,13 @@ export const validateFeatureSchema = {
type: 'string', type: 'string',
example: 'my-feature-3', 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: {}, components: {},
} as const; } as const;

View File

@ -307,9 +307,10 @@ class FeatureController extends Controller {
req: Request<any, any, ValidateFeatureSchema, any>, req: Request<any, any, ValidateFeatureSchema, any>,
res: Response<void>, res: Response<void>,
): Promise<void> { ): Promise<void> {
const { name } = req.body; const { name, projectId } = req.body;
await this.service.validateName(name); await this.service.validateName(name);
await this.service.validateFeatureFlagPattern(name, projectId);
res.status(200).end(); res.status(200).end();
} }

View File

@ -45,6 +45,7 @@ import {
IStrategyStore, IStrategyStore,
} from '../types'; } from '../types';
import { Logger } from '../logger'; import { Logger } from '../logger';
import { PatternError } from '../error';
import BadDataError from '../error/bad-data-error'; import BadDataError from '../error/bad-data-error';
import NameExistsError from '../error/name-exists-error'; import NameExistsError from '../error/name-exists-error';
import InvalidOperationError from '../error/invalid-operation-error'; import InvalidOperationError from '../error/invalid-operation-error';
@ -1034,6 +1035,8 @@ class FeatureToggleService {
): Promise<FeatureToggle> { ): Promise<FeatureToggle> {
this.logger.info(`${createdBy} creates feature toggle ${value.name}`); this.logger.info(`${createdBy} creates feature toggle ${value.name}`);
await this.validateName(value.name); await this.validateName(value.name);
await this.validateFeatureFlagPattern(value.name, projectId);
const exists = await this.projectStore.hasProject(projectId); const exists = await this.projectStore.hasProject(projectId);
if (await this.projectStore.isFeatureLimitReached(projectId)) { if (await this.projectStore.isFeatureLimitReached(projectId)) {
@ -1088,6 +1091,28 @@ class FeatureToggleService {
throw new NotFoundError(`Project with id ${projectId} does not exist`); throw new NotFoundError(`Project with id ${projectId} does not exist`);
} }
async validateFeatureFlagPattern(
featureName: string,
projectId?: string,
): Promise<void> {
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( async cloneFeatureToggle(
featureName: string, featureName: string,
projectId: string, projectId: string,

View File

@ -10,5 +10,9 @@ export const projectSchema = joi
mode: joi.string().valid('open', 'protected').default('open'), mode: joi.string().valid('open', 'protected').default('open'),
defaultStickiness: joi.string().default('default'), defaultStickiness: joi.string().default('default'),
featureLimit: joi.number().allow(null).optional(), 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 }); .options({ allowUnknown: false, stripUnknown: true });

View File

@ -38,6 +38,7 @@ import {
ProjectAccessGroupRolesUpdated, ProjectAccessGroupRolesUpdated,
IProjectRoleUsage, IProjectRoleUsage,
ProjectAccessUserRolesDeleted, ProjectAccessUserRolesDeleted,
IFeatureNaming,
} from '../types'; } from '../types';
import { IProjectQuery, IProjectStore } from '../types/stores/project-store'; import { IProjectQuery, IProjectStore } from '../types/stores/project-store';
import { import {
@ -55,7 +56,7 @@ import { FavoritesService } from './favorites-service';
import { calculateAverageTimeToProd } from '../features/feature-toggle/time-to-production/time-to-production'; import { calculateAverageTimeToProd } from '../features/feature-toggle/time-to-production/time-to-production';
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type'; import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
import { uniqueByKey } from '../util/unique'; import { uniqueByKey } from '../util/unique';
import { PermissionError } from '../error'; import { BadDataError, PermissionError } from '../error';
import { ProjectDoraMetricsSchema } from 'lib/openapi'; import { ProjectDoraMetricsSchema } from 'lib/openapi';
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown'; const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
@ -167,6 +168,21 @@ export default class ProjectService {
return this.store.get(id); 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( async createProject(
newProject: Pick< newProject: Pick<
IProject, IProject,
@ -177,6 +193,8 @@ export default class ProjectService {
const data = await projectSchema.validateAsync(newProject); const data = await projectSchema.validateAsync(newProject);
await this.validateUniqueId(data.id); await this.validateUniqueId(data.id);
this.validateFlagNaming(data.featureNaming);
await this.store.create(data); await this.store.create(data);
const enabledEnvironments = await this.environmentStore.getAll({ const enabledEnvironments = await this.environmentStore.getAll({
@ -207,15 +225,24 @@ export default class ProjectService {
async updateProject(updatedProject: IProject, user: User): Promise<void> { async updateProject(updatedProject: IProject, user: User): Promise<void> {
const preData = await this.store.get(updatedProject.id); 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({ await this.eventStore.store({
type: PROJECT_UPDATED, type: PROJECT_UPDATED,
project: project.id, project: updatedProject.id,
createdBy: getCreatedBy(user), createdBy: getCreatedBy(user),
data: project, data: updatedProject,
preData, preData,
}); });
} }
@ -981,6 +1008,7 @@ export default class ProjectService {
description: project.description, description: project.description,
mode: project.mode, mode: project.mode,
featureLimit: project.featureLimit, featureLimit: project.featureLimit,
featureNaming: project.featureNaming,
defaultStickiness: project.defaultStickiness, defaultStickiness: project.defaultStickiness,
health: project.health || 0, health: project.health || 0,
favorite: favorite, favorite: favorite,

View File

@ -27,6 +27,7 @@ export type IFlagKey =
| 'newApplicationList' | 'newApplicationList'
| 'integrationsRework' | 'integrationsRework'
| 'multipleRoles' | 'multipleRoles'
| 'featureNamingPattern'
| 'doraMetrics'; | 'doraMetrics';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -123,6 +124,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_NEW_APPLICATION_LIST, process.env.UNLEASH_EXPERIMENTAL_NEW_APPLICATION_LIST,
false, false,
), ),
featureNamingPattern: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_FEATURE_NAMING_PATTERN,
false,
),
}; };
export const defaultExperimentalOptions: IExperimentalOptions = { export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -189,6 +189,11 @@ export interface IFeatureOverview {
export type ProjectMode = 'open' | 'protected'; export type ProjectMode = 'open' | 'protected';
export interface IFeatureNaming {
pattern: string | null;
example: string | null;
}
export interface IProjectOverview { export interface IProjectOverview {
name: string; name: string;
description: string; description: string;
@ -203,6 +208,7 @@ export interface IProjectOverview {
stats?: IProjectStats; stats?: IProjectStats;
mode: ProjectMode; mode: ProjectMode;
featureLimit?: number; featureLimit?: number;
featureNaming?: IFeatureNaming;
defaultStickiness: string; defaultStickiness: string;
} }
@ -405,6 +411,7 @@ export interface IProject {
mode: ProjectMode; mode: ProjectMode;
defaultStickiness: string; defaultStickiness: string;
featureLimit?: number; featureLimit?: number;
featureNaming?: IFeatureNaming;
} }
/** /**

View File

@ -19,12 +19,16 @@ export interface IProjectInsert {
changeRequestsEnabled?: boolean; changeRequestsEnabled?: boolean;
mode: ProjectMode; mode: ProjectMode;
featureLimit?: number; featureLimit?: number;
featureNamingPattern?: string;
featureNamingExample?: string;
} }
export interface IProjectSettings { export interface IProjectSettings {
mode: ProjectMode; mode: ProjectMode;
defaultStickiness: string; defaultStickiness: string;
featureLimit?: number; featureLimit?: number;
featureNamingPattern?: string;
featureNamingExample?: string;
} }
export interface IProjectSettingsRow { export interface IProjectSettingsRow {

View File

@ -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,
);
};

View File

@ -40,6 +40,7 @@ process.nextTick(async () => {
slackAppAddon: true, slackAppAddon: true,
lastSeenByEnvironment: true, lastSeenByEnvironment: true,
newApplicationList: true, newApplicationList: true,
featureNamingPattern: true,
doraMetrics: true, doraMetrics: true,
}, },
}, },

View File

@ -15,6 +15,7 @@ beforeAll(async () => {
experimental: { experimental: {
flags: { flags: {
strictSchemaValidation: true, strictSchemaValidation: true,
featureNamingPattern: true,
}, },
}, },
}); });