diff --git a/frontend/common/utils/__tests__/featureValueValidation.test.ts b/frontend/common/utils/__tests__/featureValueValidation.test.ts new file mode 100644 index 000000000000..be764531af99 --- /dev/null +++ b/frontend/common/utils/__tests__/featureValueValidation.test.ts @@ -0,0 +1,33 @@ +import { + getFeatureValueLengthError, + hasInvalidFeatureValueLength, +} from 'common/utils/featureValueValidation' + +describe('getFeatureValueLengthError', () => { + it('accepts values up to the configured maximum', () => { + expect(getFeatureValueLengthError('x'.repeat(20_000))).toBeUndefined() + }) + + it('reports the actual and maximum character counts when exceeded', () => { + expect(getFeatureValueLengthError('x'.repeat(20_001))).toBe( + 'Flag value is 20,001 characters. Maximum length is 20,000 characters.', + ) + }) + + it('counts Unicode code points as characters', () => { + expect(getFeatureValueLengthError('😀'.repeat(20_001))).toContain( + '20,001 characters', + ) + }) + + it('does not apply the string limit to non-string values', () => { + expect(getFeatureValueLengthError(20_001)).toBeUndefined() + }) + + it('detects any over-limit value in a save', () => { + expect(hasInvalidFeatureValueLength(['valid', 'x'.repeat(20_001)])).toBe( + true, + ) + expect(hasInvalidFeatureValueLength(['valid', 'also valid'])).toBe(false) + }) +}) diff --git a/frontend/common/utils/featureValueValidation.ts b/frontend/common/utils/featureValueValidation.ts new file mode 100644 index 000000000000..cba21eae81dd --- /dev/null +++ b/frontend/common/utils/featureValueValidation.ts @@ -0,0 +1,31 @@ +export const MAX_FEATURE_VALUE_LENGTH = 20_000 + +export function getFeatureValueLengthError( + value: unknown, + label = 'Flag value', + maxLength = MAX_FEATURE_VALUE_LENGTH, +): string | undefined { + if (typeof value !== 'string') return undefined + + const characterCount = Array.from(value).length + if (characterCount <= maxLength) return undefined + + return `${label} is ${characterCount.toLocaleString( + 'en-GB', + )} characters. Maximum length is ${maxLength.toLocaleString( + 'en-GB', + )} characters.` +} + +export function getFeatureValueLengthErrors( + values: { label: string; value: unknown }[], +): string[] { + return values.flatMap(({ label, value }) => { + const error = getFeatureValueLengthError(value, label) + return error ? [error] : [] + }) +} + +export function hasInvalidFeatureValueLength(values: unknown[]): boolean { + return values.some((value) => !!getFeatureValueLengthError(value)) +} diff --git a/frontend/web/components/SegmentOverrides.js b/frontend/web/components/SegmentOverrides.js index 30c29d73d34a..3351f65a3738 100644 --- a/frontend/web/components/SegmentOverrides.js +++ b/frontend/web/components/SegmentOverrides.js @@ -278,6 +278,7 @@ const SegmentOverrideInner = class Override extends React.Component {
} value={v.value} disabled={readOnly} diff --git a/frontend/web/components/ValueEditor/ValueEditor.tsx b/frontend/web/components/ValueEditor/ValueEditor.tsx index 847760764f96..1da2f1e79f79 100644 --- a/frontend/web/components/ValueEditor/ValueEditor.tsx +++ b/frontend/web/components/ValueEditor/ValueEditor.tsx @@ -9,9 +9,14 @@ import React, { } from 'react' import cx from 'classnames' +import { FlagsmithValue } from 'common/types/responses' +import { + getFeatureValueLengthError, + MAX_FEATURE_VALUE_LENGTH, +} from 'common/utils/featureValueValidation' +import ErrorMessage from 'components/ErrorMessage' import FieldLabel from 'components/base/forms/FieldLabel' import Highlight from 'components/Highlight' -import { FlagsmithValue } from 'common/types/responses' import CopyValueButton from './components/CopyValueButton' import LanguageSelector from './components/LanguageSelector' @@ -36,6 +41,7 @@ export interface ValueEditorProps { // caller's job (Utils.getTypedValue, Utils.valueToFeatureState). onChange?: (value: string) => void value?: FlagsmithValue + validateLength?: boolean } const ValueEditor: FC = ({ @@ -47,6 +53,7 @@ const ValueEditor: FC = ({ language: languageProp, onBlur, onChange, + validateLength = false, value, }) => { const [picked, setPicked] = useState() @@ -56,6 +63,13 @@ const ValueEditor: FC = ({ const language = languageProp ?? picked ?? detected ?? 'txt' const labelId = useId() const text = value === undefined || value === null ? '' : `${value}` + const lengthError = validateLength + ? getFeatureValueLengthError( + text, + typeof label === 'string' ? label : 'Value', + MAX_FEATURE_VALUE_LENGTH, + ) + : undefined // Detection waits for a value rather than running on mount: values load // after mount, and a mount-only check left JSON rendering as plaintext. @@ -112,12 +126,18 @@ const ValueEditor: FC = ({
)} + {!!lengthError && ( +
+ +
+ )}
{showControls && } = (props) => { return { featureError, featureWarning } } + const getValueLengthErrors = () => + getFeatureValueLengthErrors([ + { + label: identity ? 'Identity override' : 'Flag value', + value: environmentFlag.feature_state_value, + }, + ...(projectFlag.multivariate_options || []).map( + (option: any, index: number) => ({ + label: `Variant ${option.key || index + 1} value`, + value: option.string_value, + }), + ), + ...(segmentOverrides || []) + .filter((override: any) => !override.toRemove) + .map((override: any, index: number) => ({ + label: `Segment override ${override.segment_name || index + 1} value`, + value: override.value, + })), + ]) + + const showValueLengthErrors = (errors = getValueLengthErrors()) => { + errors.forEach((error) => toast(error, 'danger')) + return errors.length > 0 + } + const save = (func: (...args: any[]) => void, isSaving: boolean) => { + if (showValueLengthErrors()) return + const hasMultivariate = props.environmentFlag?.multivariate_feature_state_values?.length @@ -476,6 +504,11 @@ const CreateFeatureModal: FC = (props) => { !!projectFlag.multivariate_options && projectFlag.multivariate_options.length && controlValue < 0 + const validateValueLengths = + (cb: (schedule?: boolean) => void) => (schedule?: boolean) => { + if (showValueLengthErrors()) return + cb(schedule) + } const isVersionedChangeRequest = existingChangeRequest && isVersioned const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab() const isDependenciesTabEnabled = !existingChangeRequest @@ -577,7 +610,16 @@ const CreateFeatureModal: FC = (props) => { const hasVariationChanges = variationChanges.values || variationChanges.added - const onSaveVariationValues = () => + const onSaveVariationValues = () => { + const valueLengthErrors = getFeatureValueLengthErrors( + (projectFlag.multivariate_options || []).map( + (option: any, index: number) => ({ + label: `Variant ${option.key || index + 1} value`, + value: option.string_value, + }), + ), + ) + if (showValueLengthErrors(valueLengthErrors)) return openConfirm({ body: 'Variation values belong to the feature, so this applies to every environment straight away and is not part of a change request.', noText: 'Cancel', @@ -624,9 +666,11 @@ const CreateFeatureModal: FC = (props) => { title: 'Save variation values', yesText: 'Save for all environments', }) + } const saveFeatureValue = saveFeatureWithValidation( (schedule?: boolean) => { + if (showValueLengthErrors()) return if ((is4Eyes || schedule) && !identity) { const approvable = dependenciesChanged || @@ -831,7 +875,9 @@ const CreateFeatureModal: FC = (props) => { hasVariationChanges={hasVariationChanges} onSaveVariationValues={onSaveVariationValues} originalMultivariateOptions={originalMultivariateOptions} - onSaveFeatureValue={saveFeatureValue} + onSaveFeatureValue={validateValueLengths( + saveFeatureValue, + )} onEnvironmentFlagChange={(changes: any) => { setEnvironmentFlag((prev: any) => ({ ...prev, diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx index 172cefada982..39fcdc64405b 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx @@ -404,6 +404,7 @@ const FeatureValueTab: FC = ({
diff --git a/frontend/web/components/mv/VariationValueInput/VariationValueInput.tsx b/frontend/web/components/mv/VariationValueInput/VariationValueInput.tsx index 3e633ddb5212..dd74a311eef0 100644 --- a/frontend/web/components/mv/VariationValueInput/VariationValueInput.tsx +++ b/frontend/web/components/mv/VariationValueInput/VariationValueInput.tsx @@ -72,6 +72,7 @@ export const VariationValueInput: React.FC = ({ : Constants.projectPermissions(ProjectPermission.CREATE_FEATURE),