diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.test.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.test.tsx new file mode 100644 index 0000000000..e80254cb26 --- /dev/null +++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { render } from 'utils/testRenderer'; +import { testServerRoute, testServerSetup } from 'utils/testServer'; +import { FreeTextInput } from './FreeTextInput'; + +const server = testServerSetup(); + +const LIMIT = 3; + +const setupApi = () => { + testServerRoute(server, '/api/admin/ui-config', { + flags: { + resourceLimits: true, + }, + resourceLimits: { + constraintValues: LIMIT, + }, + }); +}; + +test('should set error when new constraint values exceed the limit', async () => { + setupApi(); + const values: string[] = []; + const errors: string[] = []; + render( + { + values.push(...newValues); + }} + setError={(newError: string) => { + errors.push(newError); + }} + removeValue={() => {}} + />, + ); + + await waitFor(async () => { + const button = await screen.findByText('Add values'); + expect(button).not.toBeDisabled(); + }); + + const input = await screen.findByLabelText('Values'); + fireEvent.change(input, { + target: { value: '1, 2, 3, 4' }, + }); + const button = await screen.findByText('Add values'); + fireEvent.click(button); + + expect(errors).toEqual(['constraints cannot have more than 3 values']); + expect(values).toEqual([]); +}); + +test('should set error when old and new constraint values exceed the limit', async () => { + setupApi(); + const values: string[] = []; + const errors: string[] = []; + render( + { + values.push(...newValues); + }} + setError={(newError: string) => { + errors.push(newError); + }} + removeValue={() => {}} + />, + ); + + await waitFor(async () => { + const button = await screen.findByText('Add values'); + expect(button).not.toBeDisabled(); + }); + + const input = await screen.findByLabelText('Values'); + fireEvent.change(input, { + target: { value: '3, 4' }, + }); + const button = await screen.findByText('Add values'); + fireEvent.click(button); + + expect(errors).toEqual(['constraints cannot have more than 3 values']); + expect(values).toEqual([]); +}); + +test('should set values', async () => { + setupApi(); + const values: string[] = []; + const errors: string[] = []; + render( + { + values.push(...newValues); + }} + setError={(newError: string) => { + errors.push(newError); + }} + removeValue={() => {}} + />, + ); + + await waitFor(async () => { + const button = await screen.findByText('Add values'); + expect(button).not.toBeDisabled(); + }); + + const input = await screen.findByLabelText('Values'); + fireEvent.change(input, { + target: { value: '2, 3' }, + }); + const button = await screen.findByText('Add values'); + fireEvent.click(button); + + expect(errors).toEqual(['']); + expect(values).toEqual(['1', '2', '3']); +}); diff --git a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx index 19613ecd6a..62d56d25f5 100644 --- a/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx +++ b/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx @@ -6,6 +6,8 @@ import type React from 'react'; import { useState } from 'react'; import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader'; import { parseParameterStrings } from 'utils/parseParameter'; +import { useUiFlag } from 'hooks/useUiFlag'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; interface IFreeTextInputProps { values: string[]; @@ -13,7 +15,7 @@ interface IFreeTextInputProps { setValues: (values: string[]) => void; beforeValues?: JSX.Element; error: string; - setError: React.Dispatch>; + setError: (error: string) => void; } const useStyles = makeStyles()((theme) => ({ @@ -62,6 +64,8 @@ export const FreeTextInput = ({ }: IFreeTextInputProps) => { const [inputValues, setInputValues] = useState(''); const { classes: styles } = useStyles(); + const resourceLimitsEnabled = useUiFlag('resourceLimits'); + const { uiConfig, loading } = useUiConfig(); const onKeyPress = (event: React.KeyboardEvent) => { if (event.key === ENTER) { @@ -75,8 +79,16 @@ export const FreeTextInput = ({ ...values, ...parseParameterStrings(inputValues), ]); + const limitReached = Boolean( + resourceLimitsEnabled && + newValues.length > uiConfig.resourceLimits.constraintValues, + ); - if (newValues.length === 0) { + if (limitReached) { + setError( + `constraints cannot have more than ${uiConfig.resourceLimits.constraintValues} values`, + ); + } else if (newValues.length === 0) { setError('values cannot be empty'); } else if (newValues.some((v) => v.length > 100)) { setError('values cannot be longer than 100 characters'); @@ -114,8 +126,9 @@ export const FreeTextInput = ({ className={styles.button} variant='outlined' color='primary' - onClick={() => addValues()} + onClick={addValues} data-testid='CONSTRAINT_VALUES_ADD_BUTTON' + disabled={loading} > Add values diff --git a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx index 22216af732..0de32d7b05 100644 --- a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx +++ b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx @@ -38,5 +38,6 @@ export const defaultValue: IUiConfig = { actionSetFilterValues: 25, signalTokensPerEndpoint: 5, featureEnvironmentStrategies: 30, + constraintValues: 250, }, }; diff --git a/frontend/src/openapi/models/resourceLimitsSchema.ts b/frontend/src/openapi/models/resourceLimitsSchema.ts index d709a472dd..66acb44e77 100644 --- a/frontend/src/openapi/models/resourceLimitsSchema.ts +++ b/frontend/src/openapi/models/resourceLimitsSchema.ts @@ -26,4 +26,6 @@ export interface ResourceLimitsSchema { strategySegments: number; /** The maximum number of feature environment strategies allowed. */ featureEnvironmentStrategies: number; + /** The maximum number of values for a single constraint. */ + constraintValues: number; } diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index eb563bb1a5..20dded0605 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -109,6 +109,7 @@ import { allSettledWithRejection } from '../../util/allSettledWithRejection'; import type EventEmitter from 'node:events'; import type { IFeatureLifecycleReadModel } from '../feature-lifecycle/feature-lifecycle-read-model-type'; import type { ResourceLimitsSchema } from '../../openapi'; +import { ExceedsLimitError } from '../../error/exceeds-limit-error'; interface IFeatureContext { featureName: string; @@ -382,11 +383,11 @@ class FeatureToggleService { ) ).length; if (existingCount >= limit) { - throw new BadDataError(`Strategy limit of ${limit} exceeded}.`); + throw new ExceedsLimitError('strategy', limit); } } - validateContraintValuesLimit(updatedConstrains: IConstraint[]) { + validateConstraintValuesLimit(updatedConstrains: IConstraint[]) { if (!this.flagResolver.isEnabled('resourceLimits')) return; const limit = this.resourceLimits.constraintValues; @@ -396,8 +397,9 @@ class FeatureToggleService { constraint.values?.length > limit, ); if (constraintOverLimit) { - throw new BadDataError( - `Constraint values limit of ${limit} is exceeded for ${constraintOverLimit.contextName}.`, + throw new ExceedsLimitError( + `content values for ${constraintOverLimit.contextName}`, + limit, ); } } @@ -647,7 +649,7 @@ class FeatureToggleService { strategyConfig.constraints && strategyConfig.constraints.length > 0 ) { - this.validateContraintValuesLimit(strategyConfig.constraints); + this.validateConstraintValuesLimit(strategyConfig.constraints); strategyConfig.constraints = await this.validateConstraints( strategyConfig.constraints, ); @@ -806,7 +808,7 @@ class FeatureToggleService { if (existingStrategy.id === id) { if (updates.constraints && updates.constraints.length > 0) { - this.validateContraintValuesLimit(updates.constraints); + this.validateConstraintValuesLimit(updates.constraints); updates.constraints = await this.validateConstraints( updates.constraints, ); diff --git a/src/lib/features/feature-toggle/tests/feature-toggle-service.limit.test.ts b/src/lib/features/feature-toggle/tests/feature-toggle-service.limit.test.ts index 4a29d0cae3..8dcb78ad27 100644 --- a/src/lib/features/feature-toggle/tests/feature-toggle-service.limit.test.ts +++ b/src/lib/features/feature-toggle/tests/feature-toggle-service.limit.test.ts @@ -41,7 +41,7 @@ test('Should not allow to exceed strategy limit', async () => { } await expect(addStrategy()).rejects.toThrow( - `Strategy limit of ${LIMIT} exceeded`, + "Failed to create strategy. You can't create more than the established limit of 3", ); }); @@ -79,6 +79,6 @@ test('Should not allow to exceed constraint values limit', async () => { }, ]), ).rejects.toThrow( - `Constraint values limit of ${LIMIT} is exceeded for userId`, + "Failed to create content values for userId. You can't create more than the established limit of 3", ); });