mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +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" | ||||
|         <StyledContainer> | ||||
|             {strategyDefinition.parameters.map((definition, index) => ( | ||||
|                 <div key={index}> | ||||
|                     <StrategyParameter | ||||
|                         definition={definition} | ||||
|                         parameters={parameters} | ||||
|                         updateParameter={updateParameter} | ||||
|                         editable={editable} | ||||
|                         errors={errors} | ||||
|                     /> | ||||
|                                 {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> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -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