mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-09 00:18:00 +01: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:
parent
45e089f27f
commit
53f90d37c5
@ -80,7 +80,7 @@ export const CopyFeatureToggle = () => {
|
||||
|
||||
const onValidateName = async () => {
|
||||
try {
|
||||
await validateFeatureToggleName(newToggleName);
|
||||
await validateFeatureToggleName(newToggleName, projectId);
|
||||
setNameError(undefined);
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
@ -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) }));
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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<React.SetStateAction<string>>;
|
||||
setFeatureNamingExample?: React.Dispatch<React.SetStateAction<string>>;
|
||||
setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>;
|
||||
setProjectMode?: React.Dispatch<React.SetStateAction<ProjectMode>>;
|
||||
setProjectId: React.Dispatch<React.SetStateAction<string>>;
|
||||
@ -95,6 +101,10 @@ const ProjectForm: React.FC<IProjectForm> = ({
|
||||
projectMode,
|
||||
featureLimit,
|
||||
featureCount,
|
||||
featureNamingExample,
|
||||
featureNamingPattern,
|
||||
setFeatureNamingExample,
|
||||
setProjectNamingPattern,
|
||||
setProjectId,
|
||||
setProjectName,
|
||||
setProjectDesc,
|
||||
@ -106,6 +116,32 @@ const ProjectForm: React.FC<IProjectForm> = ({
|
||||
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 (
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
<StyledContainer>
|
||||
@ -206,7 +242,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<p>Feature toggles limit?</p>
|
||||
<p>Feature flag limit?</p>
|
||||
<FeatureTogglesLimitTooltip />
|
||||
</Box>
|
||||
<StyledSubtitle>
|
||||
@ -233,6 +269,61 @@ const ProjectForm: React.FC<IProjectForm> = ({
|
||||
/>
|
||||
</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 don’t 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>
|
||||
<StyledButtonContainer>{children}</StyledButtonContainer>
|
||||
</StyledForm>
|
||||
|
@ -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}
|
||||
|
@ -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<ProjectMode>(initialProjectMode);
|
||||
const [featureLimit, setFeatureLimit] =
|
||||
useState<string>(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,
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -56,6 +56,7 @@ export interface IFlags {
|
||||
newApplicationList?: boolean;
|
||||
integrationsRework?: boolean;
|
||||
multipleRoles?: boolean;
|
||||
featureNamingPattern?: boolean;
|
||||
doraMetrics?: boolean;
|
||||
[key: string]: boolean | Variant | undefined;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
20
src/lib/error/pattern-error.ts
Normal file
20
src/lib/error/pattern-error.ts
Normal 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;
|
@ -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,
|
||||
};
|
||||
|
30
src/lib/openapi/spec/create-feature-naming-pattern-schema.ts
Normal file
30
src/lib/openapi/spec/create-feature-naming-pattern-schema.ts
Normal 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
|
||||
>;
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -307,9 +307,10 @@ class FeatureController extends Controller {
|
||||
req: Request<any, any, ValidateFeatureSchema, any>,
|
||||
res: Response<void>,
|
||||
): Promise<void> {
|
||||
const { name } = req.body;
|
||||
const { name, projectId } = req.body;
|
||||
|
||||
await this.service.validateName(name);
|
||||
await this.service.validateFeatureFlagPattern(name, projectId);
|
||||
res.status(200).end();
|
||||
}
|
||||
|
||||
|
@ -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<FeatureToggle> {
|
||||
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<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(
|
||||
featureName: string,
|
||||
projectId: string,
|
||||
|
@ -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 });
|
||||
|
@ -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<void> {
|
||||
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,
|
||||
|
@ -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 = {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
);
|
||||
};
|
@ -40,6 +40,7 @@ process.nextTick(async () => {
|
||||
slackAppAddon: true,
|
||||
lastSeenByEnvironment: true,
|
||||
newApplicationList: true,
|
||||
featureNamingPattern: true,
|
||||
doraMetrics: true,
|
||||
},
|
||||
},
|
||||
|
@ -15,6 +15,7 @@ beforeAll(async () => {
|
||||
experimental: {
|
||||
flags: {
|
||||
strictSchemaValidation: true,
|
||||
featureNamingPattern: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user