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:
parent
45e089f27f
commit
53f90d37c5
@ -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) {
|
||||||
|
@ -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) }));
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
@ -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 { 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 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>
|
</StyledContainer>
|
||||||
<StyledButtonContainer>{children}</StyledButtonContainer>
|
<StyledButtonContainer>{children}</StyledButtonContainer>
|
||||||
</StyledForm>
|
</StyledForm>
|
||||||
|
@ -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}
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
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 { 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,
|
||||||
};
|
};
|
||||||
|
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 { 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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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 });
|
||||||
|
@ -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,
|
||||||
|
@ -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 = {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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 {
|
||||||
|
@ -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,
|
slackAppAddon: true,
|
||||||
lastSeenByEnvironment: true,
|
lastSeenByEnvironment: true,
|
||||||
newApplicationList: true,
|
newApplicationList: true,
|
||||||
|
featureNamingPattern: true,
|
||||||
doraMetrics: true,
|
doraMetrics: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -15,6 +15,7 @@ beforeAll(async () => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
|
featureNamingPattern: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user