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:
parent
0b93776db6
commit
59c8822cf2
@ -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);
|
||||
|
23
frontend/src/component/common/InputCaption/InputCaption.tsx
Normal file
23
frontend/src/component/common/InputCaption/InputCaption.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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)}
|
||||
|
@ -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>
|
||||
))}
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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),
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -87,7 +87,6 @@ const RolloutSlider = ({
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
<br />
|
||||
<StyledSlider
|
||||
min={0}
|
||||
max={100}
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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',
|
||||
|
59
frontend/src/hooks/useFormErrors.ts
Normal file
59
frontend/src/hooks/useFormErrors.ts
Normal 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,
|
||||
};
|
||||
};
|
76
frontend/src/utils/createFeatureStrategy.test.ts
Normal file
76
frontend/src/utils/createFeatureStrategy.test.ts
Normal 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",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
55
frontend/src/utils/createFeatureStrategy.ts
Normal file
55
frontend/src/utils/createFeatureStrategy.ts
Normal 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 '';
|
||||
};
|
@ -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: [] };
|
||||
};
|
@ -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 '';
|
||||
}
|
||||
};
|
55
frontend/src/utils/validateParameterValue.test.ts
Normal file
55
frontend/src/utils/validateParameterValue.test.ts
Normal 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();
|
||||
});
|
23
frontend/src/utils/validateParameterValue.ts
Normal file
23
frontend/src/utils/validateParameterValue.ts
Normal 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));
|
||||
};
|
Loading…
Reference in New Issue
Block a user