1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-11 00:08:30 +01:00

fix: validate feature strategy parameters (#1192)

* refactor: extract InputCaption component

* refactor: split up GeneralStrategy component

* refactor: fill inn more default feature strategy parameter values

* fix: validate feature strategy parameters

* refactor: fix duplicate keys in strategy icon list

* refactor: expand variable names

* refactor: remove unnecessary useMemo

* refactor: use captions instead of tooltips for boolean parameter descriptions

* refactor: improve strategy definition form spacing
This commit is contained in:
olav 2022-08-04 13:34:30 +02:00 committed by GitHub
parent 0b93776db6
commit 59c8822cf2
21 changed files with 569 additions and 260 deletions

View File

@ -110,7 +110,7 @@ describe('feature', () => {
expect(req.body.name).to.equal('flexibleRollout');
expect(req.body.parameters.groupId).to.equal(featureToggleName);
expect(req.body.parameters.stickiness).to.equal('default');
expect(req.body.parameters.rollout).to.equal('100');
expect(req.body.parameters.rollout).to.equal('50');
if (ENTERPRISE) {
expect(req.body.constraints.length).to.equal(1);
@ -151,7 +151,7 @@ describe('feature', () => {
req => {
expect(req.body.parameters.groupId).to.equal('new-group-id');
expect(req.body.parameters.stickiness).to.equal('sessionId');
expect(req.body.parameters.rollout).to.equal('100');
expect(req.body.parameters.rollout).to.equal('50');
if (ENTERPRISE) {
expect(req.body.constraints.length).to.equal(1);

View File

@ -0,0 +1,23 @@
import { Box } from '@mui/material';
export interface IInputCaptionProps {
text?: string;
}
export const InputCaption = ({ text }: IInputCaptionProps) => {
if (!text) {
return null;
}
return (
<Box
sx={theme => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallerBody,
marginTop: theme.spacing(1),
})}
>
{text}
</Box>
);
};

View File

@ -16,13 +16,14 @@ import {
createStrategyPayload,
featureStrategyDocsLinkLabel,
} from '../FeatureStrategyEdit/FeatureStrategyEdit';
import { getStrategyObject } from 'utils/getStrategyObject';
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment';
import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi';
import { formatStrategyName } from 'utils/strategyNames';
import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable';
import { useFormErrors } from 'hooks/useFormErrors';
import { createFeatureStrategy } from 'utils/createFeatureStrategy';
export const FeatureStrategyCreate = () => {
const projectId = useRequiredPathParam('projectId');
@ -32,6 +33,7 @@ export const FeatureStrategyCreate = () => {
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
const [segments, setSegments] = useState<ISegment[]>([]);
const { strategies } = useStrategies();
const errors = useFormErrors();
const { addStrategyToFeature, loading } = useFeatureStrategyApi();
const { setStrategySegments } = useSegmentsApi();
@ -45,10 +47,15 @@ export const FeatureStrategyCreate = () => {
featureId
);
const strategyDefinition = strategies.find(strategy => {
return strategy.name === strategyName;
});
useEffect(() => {
// Fill in the default values once the strategies have been fetched.
setStrategy(getStrategyObject(strategies, strategyName, featureId));
}, [strategies, strategyName, featureId]);
if (strategyDefinition) {
setStrategy(createFeatureStrategy(featureId, strategyDefinition));
}
}, [featureId, strategyDefinition]);
const onSubmit = async () => {
try {
@ -105,6 +112,7 @@ export const FeatureStrategyCreate = () => {
onSubmit={onSubmit}
loading={loading}
permission={CREATE_FEATURE_STRATEGY}
errors={errors}
/>
</FormTemplate>
);

View File

@ -15,6 +15,7 @@ import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi'
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { formatStrategyName } from 'utils/strategyNames';
import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable';
import { useFormErrors } from 'hooks/useFormErrors';
export const FeatureStrategyEdit = () => {
const projectId = useRequiredPathParam('projectId');
@ -27,6 +28,7 @@ export const FeatureStrategyEdit = () => {
const { updateStrategyOnFeature, loading } = useFeatureStrategyApi();
const { setStrategySegments } = useSegmentsApi();
const { setToastData, setToastApiError } = useToast();
const errors = useFormErrors();
const { uiConfig } = useUiConfig();
const { unleashUrl } = uiConfig;
const navigate = useNavigate();
@ -115,6 +117,7 @@ export const FeatureStrategyEdit = () => {
onSubmit={onSubmit}
loading={loading}
permission={UPDATE_FEATURE_STRATEGY}
errors={errors}
/>
</FormTemplate>
);

View File

@ -1,5 +1,9 @@
import React, { useState, useContext } from 'react';
import { IFeatureStrategy } from 'interfaces/strategy';
import {
IFeatureStrategy,
IFeatureStrategyParameters,
IStrategyParameter,
} from 'interfaces/strategy';
import { FeatureStrategyType } from '../FeatureStrategyType/FeatureStrategyType';
import { FeatureStrategyEnabled } from '../FeatureStrategyEnabled/FeatureStrategyEnabled';
import { FeatureStrategyConstraints } from '../FeatureStrategyConstraints/FeatureStrategyConstraints';
@ -20,6 +24,9 @@ import AccessContext from 'contexts/AccessContext';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { FeatureStrategySegment } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment';
import { ISegment } from 'interfaces/segment';
import { IFormErrors } from 'hooks/useFormErrors';
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import { validateParameterValue } from 'utils/validateParameterValue';
interface IFeatureStrategyFormProps {
feature: IFeatureToggle;
@ -33,6 +40,7 @@ interface IFeatureStrategyFormProps {
>;
segments: ISegment[];
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
errors: IFormErrors;
}
export const FeatureStrategyForm = ({
@ -45,44 +53,81 @@ export const FeatureStrategyForm = ({
setStrategy,
segments,
setSegments,
errors,
}: IFeatureStrategyFormProps) => {
const { classes: styles } = useStyles();
const [showProdGuard, setShowProdGuard] = useState(false);
const hasValidConstraints = useConstraintsValidation(strategy.constraints);
const enableProdGuard = useFeatureStrategyProdGuard(feature, environmentId);
const { hasAccess } = useContext(AccessContext);
const { strategies } = useStrategies();
const navigate = useNavigate();
const strategyDefinition = strategies.find(definition => {
return definition.name === strategy.name;
});
const {
uiConfig,
error: uiConfigError,
loading: uiConfigLoading,
} = useUiConfig();
if (uiConfigError) {
throw uiConfigError;
}
if (uiConfigLoading || !strategyDefinition) {
return null;
}
const findParameterDefinition = (name: string): IStrategyParameter => {
return strategyDefinition.parameters.find(parameterDefinition => {
return parameterDefinition.name === name;
})!;
};
const validateParameter = (
name: string,
value: IFeatureStrategyParameters[string]
): boolean => {
const parameterValueError = validateParameterValue(
findParameterDefinition(name),
value
);
if (parameterValueError) {
errors.setFormError(name, parameterValueError);
return false;
} else {
errors.removeFormError(name);
return true;
}
};
const validateAllParameters = (): boolean => {
return strategyDefinition.parameters
.map(parameter => parameter.name)
.map(name => validateParameter(name, strategy.parameters?.[name]))
.every(Boolean);
};
const onCancel = () => {
navigate(formatFeaturePath(feature.project, feature.name));
};
const onSubmitOrProdGuard = async (event: React.FormEvent) => {
const onSubmitWithValidation = async (event: React.FormEvent) => {
event.preventDefault();
if (enableProdGuard) {
if (!validateAllParameters()) {
return;
} else if (enableProdGuard) {
setShowProdGuard(true);
} else {
onSubmit();
}
};
if (uiConfigError) {
throw uiConfigError;
}
// Wait for uiConfig to load to get the correct flags.
if (uiConfigLoading) {
return null;
}
return (
<form className={styles.form} onSubmit={onSubmitOrProdGuard}>
<form className={styles.form} onSubmit={onSubmitWithValidation}>
<div>
<FeatureStrategyEnabled
feature={feature}
@ -118,7 +163,10 @@ export const FeatureStrategyForm = ({
/>
<FeatureStrategyType
strategy={strategy}
strategyDefinition={strategyDefinition}
setStrategy={setStrategy}
validateParameter={validateParameter}
errors={errors}
hasAccess={hasAccess(
permission,
feature.project,
@ -134,7 +182,11 @@ export const FeatureStrategyForm = ({
variant="contained"
color="primary"
type="submit"
disabled={loading || !hasValidConstraints}
disabled={
loading ||
!hasValidConstraints ||
errors.hasFormErrors()
}
data-testid={STRATEGY_FORM_SUBMIT_ID}
>
Save strategy
@ -147,7 +199,6 @@ export const FeatureStrategyForm = ({
>
Cancel
</Button>
<FeatureStrategyProdGuard
open={showProdGuard}
onClose={() => setShowProdGuard(false)}

View File

@ -16,7 +16,7 @@ export const FeatureStrategyIcons = ({
return (
<StyledList aria-label="Feature strategies">
{strategies.map(strategy => (
<StyledListItem key={strategy.name}>
<StyledListItem key={strategy.id}>
<FeatureStrategyIcon strategyName={strategy.name} />
</StyledListItem>
))}

View File

@ -1,46 +1,44 @@
import { IFeatureStrategy } from 'interfaces/strategy';
import { IFeatureStrategy, IStrategy } from 'interfaces/strategy';
import DefaultStrategy from 'component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy';
import FlexibleStrategy from 'component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy';
import UserWithIdStrategy from 'component/feature/StrategyTypes/UserWithIdStrategy/UserWithId';
import GeneralStrategy from 'component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy';
import { useStrategies } from 'hooks/api/getters/useStrategies/useStrategies';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import produce from 'immer';
import React from 'react';
import { IFormErrors } from 'hooks/useFormErrors';
interface IFeatureStrategyTypeProps {
hasAccess: boolean;
strategy: Partial<IFeatureStrategy>;
strategyDefinition: IStrategy;
setStrategy: React.Dispatch<
React.SetStateAction<Partial<IFeatureStrategy>>
>;
validateParameter: (name: string, value: string) => boolean;
errors: IFormErrors;
}
export const FeatureStrategyType = ({
hasAccess,
strategy,
strategyDefinition,
setStrategy,
validateParameter,
errors,
}: IFeatureStrategyTypeProps) => {
const { strategies } = useStrategies();
const { context } = useUnleashContext();
const strategyDefinition = strategies.find(definition => {
return definition.name === strategy.name;
});
const updateParameter = (field: string, value: string) => {
const updateParameter = (name: string, value: string) => {
setStrategy(
produce(draft => {
draft.parameters = draft.parameters ?? {};
draft.parameters[field] = value;
draft.parameters[name] = value;
})
);
validateParameter(name, value);
};
if (!strategyDefinition) {
return null;
}
switch (strategy.name) {
case 'default':
return <DefaultStrategy strategyDefinition={strategyDefinition} />;
@ -59,6 +57,7 @@ export const FeatureStrategyType = ({
parameters={strategy.parameters ?? {}}
updateParameter={updateParameter}
editable={hasAccess}
errors={errors}
/>
);
default:
@ -68,6 +67,7 @@ export const FeatureStrategyType = ({
parameters={strategy.parameters ?? {}}
updateParameter={updateParameter}
editable={hasAccess}
errors={errors}
/>
);
}

View File

@ -1,15 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
container: {
display: 'grid',
gap: theme.spacing(4),
},
helpText: {
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallerBody,
lineHeight: '14px',
margin: 0,
marginTop: theme.spacing(1),
},
}));

View File

@ -1,190 +1,47 @@
import React from 'react';
import { FormControlLabel, Switch, TextField, Tooltip } from '@mui/material';
import StrategyInputList from '../StrategyInputList/StrategyInputList';
import RolloutSlider from '../RolloutSlider/RolloutSlider';
import { IStrategy, IFeatureStrategyParameters } from 'interfaces/strategy';
import { useStyles } from './GeneralStrategy.styles';
import {
parseParameterNumber,
parseParameterStrings,
parseParameterString,
} from 'utils/parseParameter';
import { styled } from '@mui/system';
import { StrategyParameter } from 'component/feature/StrategyTypes/StrategyParameter/StrategyParameter';
import { IFormErrors } from 'hooks/useFormErrors';
interface IGeneralStrategyProps {
parameters: IFeatureStrategyParameters;
strategyDefinition: IStrategy;
updateParameter: (field: string, value: string) => void;
editable: boolean;
errors: IFormErrors;
}
const StyledContainer = styled('div')(({ theme }) => ({
display: 'grid',
gap: theme.spacing(4),
}));
const GeneralStrategy = ({
parameters,
strategyDefinition,
updateParameter,
editable,
errors,
}: IGeneralStrategyProps) => {
const { classes: styles } = useStyles();
const onChangeTextField = (
field: string,
evt: React.ChangeEvent<HTMLInputElement>
) => {
const { value } = evt.currentTarget;
evt.preventDefault();
updateParameter(field, value);
};
const onChangePercentage = (
field: string,
evt: Event,
newValue: number | number[]
) => {
evt.preventDefault();
updateParameter(field, newValue.toString());
};
const handleSwitchChange = (field: string, currentValue: any) => {
const value = currentValue === 'true' ? 'false' : 'true';
updateParameter(field, value);
};
if (!strategyDefinition || strategyDefinition.parameters.length === 0) {
return null;
}
return (
<div className={styles.container}>
{strategyDefinition.parameters.map(
({ name, type, description, required }) => {
if (type === 'percentage') {
const value = parseParameterNumber(parameters[name]);
return (
<div key={name}>
<RolloutSlider
name={name}
onChange={onChangePercentage.bind(
this,
name
)}
disabled={!editable}
value={value}
minLabel="off"
maxLabel="on"
/>
{description && (
<p className={styles.helpText}>
{description}
</p>
)}
</div>
);
} else if (type === 'list') {
const values = parseParameterStrings(parameters[name]);
return (
<div key={name}>
<StrategyInputList
name={name}
list={values}
disabled={!editable}
setConfig={updateParameter}
/>
{description && (
<p className={styles.helpText}>
{description}
</p>
)}
</div>
);
} else if (type === 'number') {
const regex = new RegExp('^\\d+$');
const value = parseParameterString(parameters[name]);
const error =
value.length > 0 ? !regex.test(value) : false;
return (
<div key={name}>
<TextField
error={error}
helperText={
error && `${name} is not a number!`
}
variant="outlined"
size="small"
required={required}
style={{ width: '100%' }}
disabled={!editable}
name={name}
label={name}
onChange={onChangeTextField.bind(
this,
name
)}
value={value}
/>
{description && (
<p className={styles.helpText}>
{description}
</p>
)}
</div>
);
} else if (type === 'boolean') {
const value = parseParameterString(parameters[name]);
return (
<div key={name}>
<Tooltip
title={description}
placement="right-end"
arrow
>
<FormControlLabel
label={name}
control={
<Switch
name={name}
onChange={handleSwitchChange.bind(
this,
name,
value
)}
checked={value === 'true'}
/>
}
/>
</Tooltip>
</div>
);
} else {
const value = parseParameterString(parameters[name]);
return (
<div key={name}>
<TextField
rows={1}
placeholder=""
variant="outlined"
size="small"
style={{ width: '100%' }}
required={required}
disabled={!editable}
name={name}
label={name}
onChange={onChangeTextField.bind(
this,
name
)}
value={value}
/>
{description && (
<p className={styles.helpText}>
{description}
</p>
)}
</div>
);
}
}
)}
</div>
<StyledContainer>
{strategyDefinition.parameters.map((definition, index) => (
<div key={index}>
<StrategyParameter
definition={definition}
parameters={parameters}
updateParameter={updateParameter}
editable={editable}
errors={errors}
/>
</div>
))}
</StyledContainer>
);
};

View File

@ -87,7 +87,6 @@ const RolloutSlider = ({
>
{name}
</Typography>
<br />
<StyledSlider
min={0}
max={100}

View File

@ -11,12 +11,14 @@ import { Add } from '@mui/icons-material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ADD_TO_STRATEGY_INPUT_LIST, STRATEGY_INPUT_LIST } from 'utils/testIds';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { IFormErrors } from 'hooks/useFormErrors';
interface IStrategyInputList {
name: string;
list: string[];
setConfig: (field: string, value: string) => void;
disabled: boolean;
errors: IFormErrors;
}
const Container = styled('div')(({ theme }) => ({
@ -32,6 +34,7 @@ const ChipsList = styled('div')(({ theme }) => ({
const InputContainer = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'start',
}));
const StrategyInputList = ({
@ -39,6 +42,7 @@ const StrategyInputList = ({
list,
setConfig,
disabled,
errors,
}: IStrategyInputList) => {
const [input, setInput] = useState('');
const ENTERKEY = 'Enter';
@ -120,6 +124,8 @@ const StrategyInputList = ({
show={
<InputContainer>
<TextField
error={Boolean(errors.getFormError(name))}
helperText={errors.getFormError(name)}
name={`input_field`}
variant="outlined"
label="Add items"

View File

@ -0,0 +1,140 @@
import React from 'react';
import { FormControlLabel, Switch, TextField } from '@mui/material';
import StrategyInputList from '../StrategyInputList/StrategyInputList';
import RolloutSlider from '../RolloutSlider/RolloutSlider';
import {
IFeatureStrategyParameters,
IStrategyParameter,
} from 'interfaces/strategy';
import {
parseParameterNumber,
parseParameterStrings,
parseParameterString,
} from 'utils/parseParameter';
import { InputCaption } from 'component/common/InputCaption/InputCaption';
import { IFormErrors } from 'hooks/useFormErrors';
interface IStrategyParameterProps {
definition: IStrategyParameter;
parameters: IFeatureStrategyParameters;
updateParameter: (field: string, value: string) => void;
editable: boolean;
errors: IFormErrors;
}
export const StrategyParameter = ({
definition,
parameters,
updateParameter,
editable,
errors,
}: IStrategyParameterProps) => {
const { type, name, description, required } = definition;
const value = parameters[name];
const error = errors.getFormError(name);
const label = required ? `${name} * ` : name;
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
updateParameter(name, event.target.value);
};
const onChangePercentage = (event: Event, next: number | number[]) => {
updateParameter(name, next.toString());
};
const onChangeBoolean = (event: React.ChangeEvent, checked: boolean) => {
updateParameter(name, String(checked));
};
if (type === 'percentage') {
return (
<div>
<RolloutSlider
name={name}
onChange={onChangePercentage}
disabled={!editable}
value={parseParameterNumber(parameters[name])}
minLabel="off"
maxLabel="on"
/>
<InputCaption text={description} />
</div>
);
}
if (type === 'list') {
return (
<div>
<StrategyInputList
name={name}
list={parseParameterStrings(parameters[name])}
disabled={!editable}
setConfig={updateParameter}
errors={errors}
/>
<InputCaption text={description} />
</div>
);
}
if (type === 'number') {
return (
<div>
<TextField
error={Boolean(error)}
helperText={error}
variant="outlined"
size="small"
aria-required={required}
style={{ width: '100%' }}
disabled={!editable}
label={label}
onChange={onChange}
value={value}
/>
<InputCaption text={description} />
</div>
);
}
if (type === 'boolean') {
const value = parseParameterString(parameters[name]);
const checked = value === 'true';
return (
<div>
<FormControlLabel
label={name}
control={
<Switch
name={name}
onChange={onChangeBoolean}
checked={checked}
/>
}
/>
<InputCaption text={description} />
</div>
);
}
return (
<div>
<TextField
rows={1}
placeholder=""
variant="outlined"
size="small"
style={{ width: '100%' }}
aria-required={required}
disabled={!editable}
error={Boolean(error)}
helperText={error}
name={name}
label={label}
onChange={onChange}
value={parseParameterString(parameters[name])}
/>
<InputCaption text={description} />
</div>
);
};

View File

@ -1,17 +1,20 @@
import { IFeatureStrategyParameters } from 'interfaces/strategy';
import StrategyInputList from '../StrategyInputList/StrategyInputList';
import { parseParameterStrings } from 'utils/parseParameter';
import { IFormErrors } from 'hooks/useFormErrors';
interface IUserWithIdStrategyProps {
parameters: IFeatureStrategyParameters;
updateParameter: (field: string, value: string) => void;
editable: boolean;
errors: IFormErrors;
}
const UserWithIdStrategy = ({
editable,
parameters,
updateParameter,
errors,
}: IUserWithIdStrategyProps) => {
return (
<div>
@ -20,6 +23,7 @@ const UserWithIdStrategy = ({
list={parseParameterStrings(parameters.userIds)}
disabled={!editable}
setConfig={updateParameter}
errors={errors}
/>
</div>
);

View File

@ -3,10 +3,11 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
paramsContainer: {
maxWidth: '400px',
margin: '1rem 0',
},
divider: {
borderStyle: 'dashed',
marginBottom: '1rem !important',
margin: '1rem 0 1.5rem 0',
borderColor: theme.palette.grey[500],
},
nameContainer: {
@ -18,13 +19,17 @@ export const useStyles = makeStyles()(theme => ({
minWidth: '365px',
width: '100%',
},
input: { minWidth: '365px', width: '100%', marginBottom: '1rem' },
input: {
minWidth: '365px',
width: '100%',
marginBottom: '1rem',
},
description: {
minWidth: '365px',
marginBottom: '1rem',
},
checkboxLabel: {
marginBottom: '1rem',
marginTop: '-0.5rem',
},
inputDescription: {
marginBottom: '0.5rem',

View File

@ -0,0 +1,59 @@
import { useState, useCallback } from 'react';
import produce from 'immer';
export interface IFormErrors {
// Get the error message for a field name, if any.
getFormError(field: string): string | undefined;
// Set an error message for a field name.
setFormError(field: string, message: string): void;
// Remove an existing error for a field name.
removeFormError(field: string): void;
// Check if there are any errors.
hasFormErrors(): boolean;
}
export const useFormErrors = (): IFormErrors => {
const [errors, setErrors] = useState<Record<string, string>>({});
const getFormError = useCallback(
(field: string): string | undefined => errors[field],
[errors]
);
const setFormError = useCallback(
(field: string, message: string): void => {
setErrors(
produce(draft => {
draft[field] = message;
})
);
},
[setErrors]
);
const removeFormError = useCallback(
(field: string): void => {
setErrors(
produce(draft => {
delete draft[field];
})
);
},
[setErrors]
);
const hasFormErrors = useCallback(
(): boolean => Object.values(errors).some(Boolean),
[errors]
);
return {
getFormError,
setFormError,
removeFormError,
hasFormErrors,
};
};

View File

@ -0,0 +1,76 @@
import { createFeatureStrategy } from 'utils/createFeatureStrategy';
test('createFeatureStrategy', () => {
expect(
createFeatureStrategy('a', {
name: 'b',
displayName: 'c',
editable: true,
deprecated: false,
description: 'd',
parameters: [],
})
).toMatchInlineSnapshot(`
{
"constraints": [],
"name": "b",
"parameters": {},
}
`);
});
test('createFeatureStrategy with parameters', () => {
expect(
createFeatureStrategy('a', {
name: 'b',
displayName: 'c',
editable: true,
deprecated: false,
description: 'd',
parameters: [
{
name: 'groupId',
type: 'string',
description: 'a',
required: true,
},
{
name: 'stickiness',
type: 'string',
description: 'a',
required: true,
},
{
name: 'rollout',
type: 'percentage',
description: 'a',
required: true,
},
{
name: 's',
type: 'string',
description: 's',
required: true,
},
{
name: 'b',
type: 'boolean',
description: 'b',
required: true,
},
],
})
).toMatchInlineSnapshot(`
{
"constraints": [],
"name": "b",
"parameters": {
"b": "false",
"groupId": "a",
"rollout": "50",
"s": "",
"stickiness": "default",
},
}
`);
});

View File

@ -0,0 +1,55 @@
import {
IStrategy,
IFeatureStrategy,
IFeatureStrategyParameters,
IStrategyParameter,
} from 'interfaces/strategy';
// Create a new feature strategy with default values from a strategy definition.
export const createFeatureStrategy = (
featureId: string,
strategyDefinition: IStrategy
): Omit<IFeatureStrategy, 'id'> => {
const parameters: IFeatureStrategyParameters = {};
strategyDefinition.parameters.forEach((parameter: IStrategyParameter) => {
parameters[parameter.name] = createFeatureStrategyParameterValue(
featureId,
parameter
);
});
return {
name: strategyDefinition.name,
constraints: [],
parameters,
};
};
// Create default feature strategy parameter values from a strategy definition.
const createFeatureStrategyParameterValue = (
featureId: string,
parameter: IStrategyParameter
): string => {
if (
parameter.name === 'rollout' ||
parameter.name === 'percentage' ||
parameter.type === 'percentage'
) {
return '50';
}
if (parameter.name === 'stickiness') {
return 'default';
}
if (parameter.name === 'groupId') {
return featureId;
}
if (parameter.type === 'boolean') {
return 'false';
}
return '';
};

View File

@ -1,24 +0,0 @@
import {
IStrategy,
IStrategyParameter,
IFeatureStrategyParameters,
} from 'interfaces/strategy';
import { resolveDefaultParamValue } from 'utils/resolveDefaultParamValue';
export const getStrategyObject = (
selectableStrategies: IStrategy[],
name: string,
featureId: string
) => {
const selectedStrategy = selectableStrategies.find(
strategy => strategy.name === name
);
const parameters: IFeatureStrategyParameters = {};
selectedStrategy?.parameters.forEach(({ name }: IStrategyParameter) => {
parameters[name] = resolveDefaultParamValue(name, featureId);
});
return { name, parameters, constraints: [] };
};

View File

@ -1,16 +0,0 @@
export const resolveDefaultParamValue = (
name: string,
featureToggleName: string
): string => {
switch (name) {
case 'percentage':
case 'rollout':
return '100';
case 'stickiness':
return 'default';
case 'groupId':
return featureToggleName;
default:
return '';
}
};

View File

@ -0,0 +1,55 @@
import { validateParameterValue } from 'utils/validateParameterValue';
test('validateParameterValue string', () => {
expect(
validateParameterValue(
{ type: 'string', name: 'a', description: 'b', required: false },
''
)
).toBeUndefined();
expect(
validateParameterValue(
{ type: 'string', name: 'a', description: 'b', required: false },
'a'
)
).toBeUndefined();
expect(
validateParameterValue(
{ type: 'string', name: 'a', description: 'b', required: true },
''
)
).not.toBeUndefined();
expect(
validateParameterValue(
{ type: 'string', name: 'a', description: 'b', required: true },
'b'
)
).toBeUndefined();
});
test('validateParameterValue number', () => {
expect(
validateParameterValue(
{ type: 'number', name: 'a', description: 'b', required: false },
''
)
).toBeUndefined();
expect(
validateParameterValue(
{ type: 'number', name: 'a', description: 'b', required: false },
'a'
)
).not.toBeUndefined();
expect(
validateParameterValue(
{ type: 'number', name: 'a', description: 'b', required: true },
''
)
).not.toBeUndefined();
expect(
validateParameterValue(
{ type: 'number', name: 'a', description: 'b', required: true },
'1'
)
).toBeUndefined();
});

View File

@ -0,0 +1,23 @@
import {
IStrategyParameter,
IFeatureStrategyParameters,
} from 'interfaces/strategy';
export const validateParameterValue = (
definition: IStrategyParameter,
value: IFeatureStrategyParameters[string]
): string | undefined => {
const { type, required } = definition;
if (required && value === '') {
return 'Field is required';
}
if (type === 'number' && !isValidNumberOrEmpty(value)) {
return 'Not a valid number.';
}
};
const isValidNumberOrEmpty = (value: string | number | undefined): boolean => {
return value === '' || /^\d+$/.test(String(value));
};