1
0
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:
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 () => {
try {
await validateFeatureToggleName(newToggleName);
await validateFeatureToggleName(newToggleName, projectId);
setNameError(undefined);
return true;
} catch (error) {

View File

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

View File

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

View File

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

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 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 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>
<StyledButtonContainer>{children}</StyledButtonContainer>
</StyledForm>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { 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,
};

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = {

View File

@ -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;
}
/**

View File

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

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,
lastSeenByEnvironment: true,
newApplicationList: true,
featureNamingPattern: true,
doraMetrics: true,
},
},

View File

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