diff --git a/frontend/cypress/integration/feature/feature.spec.ts b/frontend/cypress/integration/feature/feature.spec.ts index 2ce8628fa9..f2651b5f5c 100644 --- a/frontend/cypress/integration/feature/feature.spec.ts +++ b/frontend/cypress/integration/feature/feature.spec.ts @@ -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); diff --git a/frontend/src/component/common/InputCaption/InputCaption.tsx b/frontend/src/component/common/InputCaption/InputCaption.tsx new file mode 100644 index 0000000000..ecf6fcb0c6 --- /dev/null +++ b/frontend/src/component/common/InputCaption/InputCaption.tsx @@ -0,0 +1,23 @@ +import { Box } from '@mui/material'; + +export interface IInputCaptionProps { + text?: string; +} + +export const InputCaption = ({ text }: IInputCaptionProps) => { + if (!text) { + return null; + } + + return ( + ({ + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallerBody, + marginTop: theme.spacing(1), + })} + > + {text} + + ); +}; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx index 15340a0859..811e259c3a 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx @@ -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>({}); const [segments, setSegments] = useState([]); 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} /> ); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx index f684c6eb7c..338db01bf3 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx @@ -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} /> ); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx index dabb3cfceb..a96b024a7d 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx @@ -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>; + 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 ( -
+
Save strategy @@ -147,7 +199,6 @@ export const FeatureStrategyForm = ({ > Cancel - setShowProdGuard(false)} diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons.tsx index 89aadafacf..63e011a374 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons.tsx @@ -16,7 +16,7 @@ export const FeatureStrategyIcons = ({ return ( {strategies.map(strategy => ( - + ))} diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyType/FeatureStrategyType.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyType/FeatureStrategyType.tsx index 6ab2beaf9c..3ea0fc8c94 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyType/FeatureStrategyType.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyType/FeatureStrategyType.tsx @@ -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; + strategyDefinition: IStrategy; setStrategy: React.Dispatch< React.SetStateAction> >; + 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 ; @@ -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} /> ); } diff --git a/frontend/src/component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy.styles.ts b/frontend/src/component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy.styles.ts deleted file mode 100644 index 75b187d585..0000000000 --- a/frontend/src/component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy.styles.ts +++ /dev/null @@ -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), - }, -})); diff --git a/frontend/src/component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy.tsx b/frontend/src/component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy.tsx index 75b782b170..417c0f97dd 100644 --- a/frontend/src/component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy.tsx +++ b/frontend/src/component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy.tsx @@ -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 - ) => { - 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 ( -
- {strategyDefinition.parameters.map( - ({ name, type, description, required }) => { - if (type === 'percentage') { - const value = parseParameterNumber(parameters[name]); - return ( -
- - {description && ( -

- {description} -

- )} -
- ); - } else if (type === 'list') { - const values = parseParameterStrings(parameters[name]); - return ( -
- - {description && ( -

- {description} -

- )} -
- ); - } else if (type === 'number') { - const regex = new RegExp('^\\d+$'); - const value = parseParameterString(parameters[name]); - const error = - value.length > 0 ? !regex.test(value) : false; - - return ( -
- - {description && ( -

- {description} -

- )} -
- ); - } else if (type === 'boolean') { - const value = parseParameterString(parameters[name]); - return ( -
- - - } - /> - -
- ); - } else { - const value = parseParameterString(parameters[name]); - return ( -
- - {description && ( -

- {description} -

- )} -
- ); - } - } - )} -
+ + {strategyDefinition.parameters.map((definition, index) => ( +
+ +
+ ))} +
); }; diff --git a/frontend/src/component/feature/StrategyTypes/RolloutSlider/RolloutSlider.tsx b/frontend/src/component/feature/StrategyTypes/RolloutSlider/RolloutSlider.tsx index 42c10b8249..695ac5054e 100644 --- a/frontend/src/component/feature/StrategyTypes/RolloutSlider/RolloutSlider.tsx +++ b/frontend/src/component/feature/StrategyTypes/RolloutSlider/RolloutSlider.tsx @@ -87,7 +87,6 @@ const RolloutSlider = ({ > {name} -
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={ 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) => { + 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 ( +
+ + +
+ ); + } + + if (type === 'list') { + return ( +
+ + +
+ ); + } + + if (type === 'number') { + return ( +
+ + +
+ ); + } + + if (type === 'boolean') { + const value = parseParameterString(parameters[name]); + const checked = value === 'true'; + return ( +
+ + } + /> + +
+ ); + } + + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/component/feature/StrategyTypes/UserWithIdStrategy/UserWithId.tsx b/frontend/src/component/feature/StrategyTypes/UserWithIdStrategy/UserWithId.tsx index d0e75fc199..aab8651a20 100644 --- a/frontend/src/component/feature/StrategyTypes/UserWithIdStrategy/UserWithId.tsx +++ b/frontend/src/component/feature/StrategyTypes/UserWithIdStrategy/UserWithId.tsx @@ -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 (
@@ -20,6 +23,7 @@ const UserWithIdStrategy = ({ list={parseParameterStrings(parameters.userIds)} disabled={!editable} setConfig={updateParameter} + errors={errors} />
); diff --git a/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.styles.ts b/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.styles.ts index b09cfcb296..0098f28200 100644 --- a/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.styles.ts +++ b/frontend/src/component/strategies/StrategyForm/StrategyParameters/StrategyParameter/StrategyParameter.styles.ts @@ -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', diff --git a/frontend/src/hooks/useFormErrors.ts b/frontend/src/hooks/useFormErrors.ts new file mode 100644 index 0000000000..0ed404746d --- /dev/null +++ b/frontend/src/hooks/useFormErrors.ts @@ -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>({}); + + 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, + }; +}; diff --git a/frontend/src/utils/createFeatureStrategy.test.ts b/frontend/src/utils/createFeatureStrategy.test.ts new file mode 100644 index 0000000000..bac5ba7f09 --- /dev/null +++ b/frontend/src/utils/createFeatureStrategy.test.ts @@ -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", + }, + } + `); +}); diff --git a/frontend/src/utils/createFeatureStrategy.ts b/frontend/src/utils/createFeatureStrategy.ts new file mode 100644 index 0000000000..26366f0fa7 --- /dev/null +++ b/frontend/src/utils/createFeatureStrategy.ts @@ -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 => { + 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 ''; +}; diff --git a/frontend/src/utils/getStrategyObject.ts b/frontend/src/utils/getStrategyObject.ts deleted file mode 100644 index 5709fa6640..0000000000 --- a/frontend/src/utils/getStrategyObject.ts +++ /dev/null @@ -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: [] }; -}; diff --git a/frontend/src/utils/resolveDefaultParamValue.ts b/frontend/src/utils/resolveDefaultParamValue.ts deleted file mode 100644 index 21de93815d..0000000000 --- a/frontend/src/utils/resolveDefaultParamValue.ts +++ /dev/null @@ -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 ''; - } -}; diff --git a/frontend/src/utils/validateParameterValue.test.ts b/frontend/src/utils/validateParameterValue.test.ts new file mode 100644 index 0000000000..81909bf18e --- /dev/null +++ b/frontend/src/utils/validateParameterValue.test.ts @@ -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(); +}); diff --git a/frontend/src/utils/validateParameterValue.ts b/frontend/src/utils/validateParameterValue.ts new file mode 100644 index 0000000000..2bb6295d40 --- /dev/null +++ b/frontend/src/utils/validateParameterValue.ts @@ -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)); +};