diff --git a/CHANGELOG.md b/CHANGELOG.md index 0570242112..c2fbd8aa46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,18 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v6.80.0](https://github.com/opengovsg/FormSG/compare/v6.79.0...v6.80.0) + +- feat: add index to myinfo child email response [`#6745`](https://github.com/opengovsg/FormSG/pull/6745) +- build: merge v6.79.0 into develop [`#6765`](https://github.com/opengovsg/FormSG/pull/6765) +- feat: add range validation to number field [`#6575`](https://github.com/opengovsg/FormSG/pull/6575) +- fix: missing errors in error mapper [`#6764`](https://github.com/opengovsg/FormSG/pull/6764) +- build: release v6.79.0 [`#6762`](https://github.com/opengovsg/FormSG/pull/6762) + #### [v6.79.0](https://github.com/opengovsg/FormSG/compare/v6.78.2...v6.79.0) +> 3 October 2023 + - feat: rename NRIC field to NRIC/FIN [`#6759`](https://github.com/opengovsg/FormSG/pull/6759) - chore: fix snyk vulnerabilities [`#6728`](https://github.com/opengovsg/FormSG/pull/6728) - chore: update hydrogen alpine [`#6752`](https://github.com/opengovsg/FormSG/pull/6752) @@ -23,6 +33,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: merge v6.78.2 back into develop [`#6742`](https://github.com/opengovsg/FormSG/pull/6742) - fix: hotfix for 500 errors thrown on email bounce notification endpoint [`#6741`](https://github.com/opengovsg/FormSG/pull/6741) - build: merge v6.78.1 into develop [`#6738`](https://github.com/opengovsg/FormSG/pull/6738) +- chore: bump version to v6.79.0 [`5ff86cd`](https://github.com/opengovsg/FormSG/commit/5ff86cdb1d281865229de52aa1085c73a2fee426) #### [v6.78.2](https://github.com/opengovsg/FormSG/compare/v6.78.1...v6.78.2) diff --git a/__tests__/e2e/constants/field.ts b/__tests__/e2e/constants/field.ts index 1f51524df8..613da728d3 100644 --- a/__tests__/e2e/constants/field.ts +++ b/__tests__/e2e/constants/field.ts @@ -17,6 +17,8 @@ import { MyInfoAttribute, NricFieldBase, NumberFieldBase, + NumberSelectedLengthValidation, + NumberSelectedValidation, RadioFieldBase, RatingFieldBase, RatingShape, @@ -246,10 +248,49 @@ export const ALL_FIELDS: E2eFieldMetadata[] = [ fieldType: BasicField.Number, ValidationOptions: { selectedValidation: null, - customVal: null, + LengthValidationOptions: { + selectedLengthValidation: null, + customVal: null, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, }, val: '42', }, + { + title: 'Number field character length validation', + fieldType: BasicField.Number, + ValidationOptions: { + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Exact, + customVal: 5, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, + }, + val: '12345', + }, + { + title: 'Number field range validation', + fieldType: BasicField.Number, + ValidationOptions: { + selectedValidation: NumberSelectedValidation.Range, + LengthValidationOptions: { + selectedLengthValidation: null, + customVal: null, + }, + RangeValidationOptions: { + customMin: 2, + customMax: 4, + }, + }, + val: '3', + }, { title: 'Mother Tongue Language', fieldType: BasicField.Radio, diff --git a/__tests__/e2e/constants/tests.ts b/__tests__/e2e/constants/tests.ts index 86a0e676f3..d98bef2b99 100644 --- a/__tests__/e2e/constants/tests.ts +++ b/__tests__/e2e/constants/tests.ts @@ -93,7 +93,14 @@ const TEST_SUBMISSION_DISABLED_BY_CHAINED_LOGIC_FORMFIELDS: E2eFieldMetadata[] = fieldType: BasicField.Number, ValidationOptions: { selectedValidation: null, - customVal: null, + LengthValidationOptions: { + selectedLengthValidation: null, + customVal: null, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, }, val: '10', }, diff --git a/__tests__/e2e/helpers/createForm.ts b/__tests__/e2e/helpers/createForm.ts index 62fc941040..454056e484 100644 --- a/__tests__/e2e/helpers/createForm.ts +++ b/__tests__/e2e/helpers/createForm.ts @@ -15,6 +15,7 @@ import { LogicConditionState, LogicType, MyInfoAttribute, + NumberSelectedValidation, } from 'shared/types' import { IFormModel, IFormSchema } from 'src/types' @@ -559,7 +560,6 @@ const addBasicField = async ( await page.setInputFiles('input[type="file"]', field.path) break case BasicField.LongText: - case BasicField.Number: case BasicField.ShortText: if (field.ValidationOptions.selectedValidation) { // Select from dropdown @@ -584,6 +584,60 @@ const addBasicField = async ( await page.getByText('Allow international numbers').click() } break + case BasicField.Number: + if (field.ValidationOptions.selectedValidation) { + // We need to transform the backend values to frontend input values + const selectedValidationInput = + field.ValidationOptions.selectedValidation === + NumberSelectedValidation.Length + ? 'Number of characters allowed' + : 'Range of values allowed' + + await fillDropdown( + page, + page.getByRole('combobox', { name: 'Field restriction' }), + selectedValidationInput, + ) + + if ( + field.ValidationOptions.selectedValidation === + NumberSelectedValidation.Length && + field.ValidationOptions.LengthValidationOptions + .selectedLengthValidation + ) { + await fillDropdown( + page, + page.getByPlaceholder('Length restriction'), + field.ValidationOptions.LengthValidationOptions + .selectedLengthValidation, + ) + + if (field.ValidationOptions.LengthValidationOptions.customVal) { + await page + .getByPlaceholder('Number of characters') + .nth(1) + .fill( + field.ValidationOptions.LengthValidationOptions.customVal.toString(), + ) + } + } + + if ( + field.ValidationOptions.selectedValidation === + NumberSelectedValidation.Range + ) { + const customMin = + field.ValidationOptions.RangeValidationOptions.customMin?.toString() ?? + ('' as const) + const customMax = + field.ValidationOptions.RangeValidationOptions.customMax?.toString() ?? + ('' as const) + + await page.getByPlaceholder('Minimum value').nth(1).fill(customMin) + await page.getByPlaceholder('Maximum value').nth(1).fill(customMax) + } + } + break case BasicField.Rating: await fillDropdown( page, diff --git a/__tests__/unit/backend/helpers/generate-form-data.ts b/__tests__/unit/backend/helpers/generate-form-data.ts index e567807695..fb67e18b72 100644 --- a/__tests__/unit/backend/helpers/generate-form-data.ts +++ b/__tests__/unit/backend/helpers/generate-form-data.ts @@ -20,6 +20,7 @@ import { IHomenoFieldSchema, IImageFieldSchema, IMobileFieldSchema, + INumberFieldSchema, IRatingFieldSchema, IShortTextFieldSchema, ITableFieldSchema, @@ -165,6 +166,23 @@ export const generateDefaultField = ( getQuestion: () => defaultParams.title, ...customParams, } as IDateFieldSchema + case BasicField.Number: + return { + ...defaultParams, + ValidationOptions: { + selectedValidation: null, + LengthValidationOptions: { + selectedLengthValidation: null, + customVal: null, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, + }, + getQuestion: () => defaultParams.title, + ...customParams, + } as INumberFieldSchema default: return { ...defaultParams, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0f7eb86971..42089b80ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.79.0", + "version": "6.80.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.79.0", + "version": "6.80.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^1.8.6", diff --git a/frontend/package.json b/frontend/package.json index 9ca4c6b03a..f69770c510 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.79.0", + "version": "6.80.0", "homepage": ".", "private": true, "dependencies": { diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditNumber/EditNumber.stories.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditNumber/EditNumber.stories.tsx index 1327b9374a..6943028b1d 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditNumber/EditNumber.stories.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditNumber/EditNumber.stories.tsx @@ -3,6 +3,7 @@ import { Meta, Story } from '@storybook/react' import { BasicField, NumberFieldBase, + NumberSelectedLengthValidation, NumberSelectedValidation, } from '~shared/types' @@ -14,8 +15,15 @@ const DEFAULT_NUMBER_FIELD: NumberFieldBase = { title: 'Storybook Number', description: 'Some description', ValidationOptions: { - customVal: null, selectedValidation: null, + LengthValidationOptions: { + selectedLengthValidation: null, + customVal: null, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, }, required: true, disabled: false, @@ -60,8 +68,15 @@ WithCustomVal.args = { field: { ...DEFAULT_NUMBER_FIELD, ValidationOptions: { - customVal: 3, - selectedValidation: NumberSelectedValidation.Exact, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Exact, + customVal: 3, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, }, }, } diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditNumber/EditNumber.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditNumber/EditNumber.tsx index 79c6e50ce3..c1b466fa15 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditNumber/EditNumber.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditNumber/EditNumber.tsx @@ -3,7 +3,11 @@ import { Controller, RegisterOptions } from 'react-hook-form' import { FormControl, SimpleGrid } from '@chakra-ui/react' import { extend, isEmpty, pick } from 'lodash' -import { NumberFieldBase, NumberSelectedValidation } from '~shared/types/field' +import { + NumberFieldBase, + NumberSelectedLengthValidation, + NumberSelectedValidation, +} from '~shared/types/field' import { createBaseValidationRules } from '~utils/fieldValidation' import { SingleSelect } from '~components/Dropdown' @@ -25,30 +29,67 @@ type EditNumberProps = EditFieldProps const EDIT_NUMBER_FIELD_KEYS = ['title', 'description', 'required'] as const +// As we want to keep the values in the shared type simple, +// we create a separate enum for frontend options and transform them as needed +enum NumberSelectedValidationInputs { + Length = 'Number of characters allowed', + Range = 'Range of values allowed', +} + type EditNumberInputs = Pick< NumberFieldBase, typeof EDIT_NUMBER_FIELD_KEYS[number] > & { ValidationOptions: { - selectedValidation: NumberSelectedValidation | '' - customVal: number | '' + selectedValidation: NumberSelectedValidationInputs | '' + LengthValidationOptions: { + customVal: number | '' + selectedLengthValidation: NumberSelectedLengthValidation | '' + } + RangeValidationOptions: { + customMin: number | '' + customMax: number | '' + } } } const transformNumberFieldToEditForm = ( field: NumberFieldBase, ): EditNumberInputs => { - const nextValidationOptions = { - selectedValidation: - field.ValidationOptions.selectedValidation || ('' as const), + const { + selectedValidation, + LengthValidationOptions, + RangeValidationOptions, + } = field.ValidationOptions + + const nextSelectedValidation = + selectedValidation === NumberSelectedValidation.Length + ? NumberSelectedValidationInputs.Length + : selectedValidation === NumberSelectedValidation.Range + ? NumberSelectedValidationInputs.Range + : ('' as const) + + const nextLengthValidationOptions = { + selectedLengthValidation: + LengthValidationOptions.selectedLengthValidation || ('' as const), customVal: - (!!field.ValidationOptions.selectedValidation && - field.ValidationOptions.customVal) || + (!!LengthValidationOptions.selectedLengthValidation && + LengthValidationOptions.customVal) || ('' as const), } + + const nextRangeValidationOptions = { + customMin: RangeValidationOptions.customMin || ('' as const), + customMax: RangeValidationOptions.customMax || ('' as const), + } + return { ...pick(field, EDIT_NUMBER_FIELD_KEYS), - ValidationOptions: nextValidationOptions, + ValidationOptions: { + selectedValidation: nextSelectedValidation, + LengthValidationOptions: nextLengthValidationOptions, + RangeValidationOptions: nextRangeValidationOptions, + }, } } @@ -56,13 +97,43 @@ const transformNumberEditFormToField = ( inputs: EditNumberInputs, originalField: NumberFieldBase, ): NumberFieldBase => { - const nextValidationOptions = - inputs.ValidationOptions.selectedValidation === '' - ? { - selectedValidation: null, - customVal: null, + const { + selectedValidation, + LengthValidationOptions, + RangeValidationOptions, + } = inputs.ValidationOptions + + const hasSelectedLengthValidationOption = + selectedValidation === NumberSelectedValidationInputs.Length + + const nextSelectedValidation = + selectedValidation === NumberSelectedValidationInputs.Length + ? NumberSelectedValidation.Length + : selectedValidation === NumberSelectedValidationInputs.Range + ? NumberSelectedValidation.Range + : null + + const nextLengthValidationOptions = hasSelectedLengthValidationOption + ? LengthValidationOptions + : { + selectedLengthValidation: null, + customVal: null, + } + + const nextRangeValidationOptions = + selectedValidation === NumberSelectedValidationInputs.Range + ? RangeValidationOptions + : { + customMin: null, + customMax: null, } - : inputs.ValidationOptions + + const nextValidationOptions = { + selectedValidation: nextSelectedValidation, + LengthValidationOptions: nextLengthValidationOptions, + RangeValidationOptions: nextRangeValidationOptions, + } + return extend({}, originalField, inputs, { ValidationOptions: nextValidationOptions, }) @@ -98,24 +169,40 @@ export const EditNumber = ({ field }: EditNumberProps): JSX.Element => { 'ValidationOptions.selectedValidation', ) - const customValValidationOptions: RegisterOptions< + const watchedSelectedLengthValidation = watch( + 'ValidationOptions.LengthValidationOptions.selectedLengthValidation', + ) + + const selectedLengthValidationOptions: RegisterOptions< EditNumberInputs, - 'ValidationOptions.customVal' + 'ValidationOptions.LengthValidationOptions.selectedLengthValidation' + > = useMemo( + () => ({ + required: { + value: true, + message: 'Please select a validation type', + }, + }), + [], + ) + + const customValLengthValidationOptions: RegisterOptions< + EditNumberInputs, + 'ValidationOptions.LengthValidationOptions.customVal' > = useMemo( () => ({ // customVal is required if there is selected validation. validate: { - hasValidation: (val) => { + hasValidation: (customVal) => { + const selectedLengthValidation = getValues( + 'ValidationOptions.LengthValidationOptions.selectedLengthValidation', + ) return ( - !!val || - !getValues('ValidationOptions.selectedValidation') || + selectedLengthValidation === '' || + customVal !== '' || 'Please enter number of characters' ) }, - validNumber: (val) => { - // Check whether input is a valid number, avoid e - return !isNaN(Number(val)) || 'Please enter a valid number' - }, }, min: { value: 1, @@ -129,11 +216,69 @@ export const EditNumber = ({ field }: EditNumberProps): JSX.Element => { [getValues], ) - // Effect to clear validation option errors when selection limit is toggled off. + // We use the customMin field to perform cross-field validation for + // the number range + const customMinRangeValidationOptions: RegisterOptions< + EditNumberInputs, + 'ValidationOptions.RangeValidationOptions.customMin' + > = useMemo( + () => ({ + validate: { + // Validate that at least one of customMin/customMax is specified + hasRange: (customMin) => { + const customMax = getValues( + 'ValidationOptions.RangeValidationOptions.customMax', + ) + return ( + customMax !== '' || customMin !== '' || 'Please enter range values' + ) + }, + hasValidRange: (customMin) => { + const customMax = getValues( + 'ValidationOptions.RangeValidationOptions.customMax', + ) + + return ( + customMax === '' || + customMin === '' || + customMin < customMax || + 'Minimum must be less than maximum' + ) + }, + }, + min: { + value: 1, + message: 'Minimum cannot be 0', + }, + }), + [getValues], + ) + + const customMaxRangeValidationOptions: RegisterOptions< + EditNumberInputs, + 'ValidationOptions.RangeValidationOptions.customMax' + > = useMemo( + () => ({ + min: { + value: 1, + message: 'Maximum cannot be 0', + }, + }), + [], + ) + useEffect(() => { + // Effect to clear validation errors and inputs + // when the selected validation is cleared. if (!watchedSelectedValidation) { clearErrors('ValidationOptions') - setValue('ValidationOptions.customVal', '') + setValue( + 'ValidationOptions.LengthValidationOptions.selectedLengthValidation', + '', + ) + setValue('ValidationOptions.LengthValidationOptions.customVal', '') + setValue('ValidationOptions.RangeValidationOptions.customMin', '') + setValue('ValidationOptions.RangeValidationOptions.customMax', '') } }, [clearErrors, setValue, watchedSelectedValidation]) @@ -160,42 +305,109 @@ export const EditNumber = ({ field }: EditNumberProps): JSX.Element => { isReadOnly={isLoading} isInvalid={!isEmpty(errors.ValidationOptions)} > - Number of characters allowed - - ( - + Field restriction + + ( + + )} + /> + {watchedSelectedValidation === + NumberSelectedValidationInputs.Length && ( + <> + + ( + + )} + /> + ( + + )} + /> + + + {errors?.ValidationOptions?.LengthValidationOptions + ?.selectedLengthValidation?.message || + errors?.ValidationOptions?.LengthValidationOptions?.customVal + ?.message} + + + )} + {watchedSelectedValidation === NumberSelectedValidationInputs.Range && ( + <> + + ( + + )} /> - )} - /> - ( - ( + + )} /> - )} - /> - - - {errors?.ValidationOptions?.customVal?.message} - + + + {errors?.ValidationOptions?.RangeValidationOptions?.customMin + ?.message || + errors?.ValidationOptions?.RangeValidationOptions?.customMax + ?.message} + + + )} { } } case BasicField.LongText: - case BasicField.Number: { return { fieldType, ...baseMeta, @@ -102,6 +101,22 @@ export const getFieldCreationMeta = (fieldType: BasicField): FieldCreateDto => { customVal: null, }, } + case BasicField.Number: { + return { + fieldType, + ...baseMeta, + ValidationOptions: { + selectedValidation: null, + LengthValidationOptions: { + selectedLengthValidation: null, + customVal: null, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, + }, + } } case BasicField.Dropdown: { return { diff --git a/frontend/src/mocks/msw/handlers/admin-form/form.ts b/frontend/src/mocks/msw/handlers/admin-form/form.ts index 01ad95615c..e67dcf1109 100644 --- a/frontend/src/mocks/msw/handlers/admin-form/form.ts +++ b/frontend/src/mocks/msw/handlers/admin-form/form.ts @@ -120,8 +120,15 @@ export const MOCK_FORM_FIELDS: FormFieldDto[] = [ }, { ValidationOptions: { - customVal: null, selectedValidation: null, + LengthValidationOptions: { + selectedLengthValidation: null, + customVal: null, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, }, title: 'Number', description: '', diff --git a/frontend/src/templates/Field/Number/NumberField.stories.tsx b/frontend/src/templates/Field/Number/NumberField.stories.tsx index f71b59d377..3c12970bdc 100644 --- a/frontend/src/templates/Field/Number/NumberField.stories.tsx +++ b/frontend/src/templates/Field/Number/NumberField.stories.tsx @@ -3,7 +3,11 @@ import { FormProvider, useForm } from 'react-hook-form' import { Text } from '@chakra-ui/react' import { Meta, Story } from '@storybook/react' -import { BasicField, NumberSelectedValidation } from '~shared/types/field' +import { + BasicField, + NumberSelectedLengthValidation, + NumberSelectedValidation, +} from '~shared/types/field' import Button from '~components/Button' @@ -37,8 +41,15 @@ const baseSchema: NumberFieldSchema = { disabled: false, fieldType: BasicField.Number, ValidationOptions: { - customVal: null, selectedValidation: null, + LengthValidationOptions: { + selectedLengthValidation: null, + customVal: null, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, }, _id: '611b94dfbb9e300012f702a7', questionNumber: 1, @@ -102,8 +113,15 @@ ValidationExact3Length.args = { schema: { ...baseSchema, ValidationOptions: { - customVal: 3, - selectedValidation: NumberSelectedValidation.Exact, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Exact, + customVal: 3, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, }, }, defaultValue: '1234', @@ -113,8 +131,15 @@ ValidationMin6Length.args = { schema: { ...baseSchema, ValidationOptions: { - customVal: 6, - selectedValidation: NumberSelectedValidation.Min, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Min, + customVal: 6, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, }, }, defaultValue: '123', @@ -125,8 +150,15 @@ ValidationMax1Length.args = { schema: { ...baseSchema, ValidationOptions: { - customVal: 1, - selectedValidation: NumberSelectedValidation.Max, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Max, + customVal: 1, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, }, }, defaultValue: '67574', diff --git a/frontend/src/templates/Field/Number/NumberField.test.tsx b/frontend/src/templates/Field/Number/NumberField.test.tsx index 2282531ccf..c5f3b494c0 100644 --- a/frontend/src/templates/Field/Number/NumberField.test.tsx +++ b/frontend/src/templates/Field/Number/NumberField.test.tsx @@ -3,7 +3,10 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { merge } from 'lodash' -import { NumberSelectedValidation } from '~shared/types/field' +import { + NumberSelectedLengthValidation, + NumberSelectedValidation, +} from '~shared/types/field' import * as stories from './NumberField.stories' @@ -91,7 +94,7 @@ describe('validation optional', () => { }) }) -describe('text validation', () => { +describe('length validation', () => { describe('NumberSelectedValidation.Min', () => { it('renders error when field input length is < minimum length when submitted', async () => { // Arrange @@ -100,8 +103,11 @@ describe('text validation', () => { // and make validation options explicit. const schema = merge({}, ValidationRequired.args?.schema, { ValidationOptions: { - customVal: 8, - selectedValidation: NumberSelectedValidation.Min, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Min, + customVal: 8, + }, }, }) render() @@ -128,8 +134,11 @@ describe('text validation', () => { const user = userEvent.setup() const schema = merge({}, ValidationRequired.args?.schema, { ValidationOptions: { - customVal: 2, - selectedValidation: NumberSelectedValidation.Min, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Min, + customVal: 2, + }, }, }) render() @@ -152,14 +161,17 @@ describe('text validation', () => { }) }) - describe('TextSelectedValidation.Maximum', () => { + describe('NumberSelectedLengthValidation.Maximum', () => { it('renders error when field input length is > maximum length when submitted', async () => { // Arrange const user = userEvent.setup() const schema = merge({}, ValidationRequired.args?.schema, { ValidationOptions: { - customVal: 2, - selectedValidation: NumberSelectedValidation.Max, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Max, + customVal: 2, + }, }, }) // Using ValidationRequired base story to render the field without any value. @@ -187,8 +199,11 @@ describe('text validation', () => { const user = userEvent.setup() const schema = merge({}, ValidationRequired.args?.schema, { ValidationOptions: { - customVal: 3, - selectedValidation: NumberSelectedValidation.Max, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Max, + customVal: 3, + }, }, }) render() @@ -211,14 +226,17 @@ describe('text validation', () => { }) }) - describe('TextSelectedValidation.Exact', () => { + describe('NumberSelectedLengthValidation.Exact', () => { it('renders error when field input length not exact length when submitted', async () => { // Arrange const user = userEvent.setup() const schema = merge({}, ValidationRequired.args?.schema, { ValidationOptions: { - customVal: 3, - selectedValidation: NumberSelectedValidation.Exact, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Exact, + customVal: 3, + }, }, }) // Using ValidationRequired base story to render the field without any value. @@ -246,8 +264,11 @@ describe('text validation', () => { const user = userEvent.setup() const schema = merge({}, ValidationRequired.args?.schema, { ValidationOptions: { - customVal: 5, - selectedValidation: NumberSelectedValidation.Exact, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Exact, + customVal: 5, + }, }, }) render() @@ -269,4 +290,219 @@ describe('text validation', () => { expect(success).not.toBeNull() }) }) + + describe('range validation', () => { + describe('only customMin specified', () => { + it('renders error when field input is < customMin when submitted', async () => { + // Arrange + const user = userEvent.setup() + // Using ValidationRequired base story to render the field without any value + // and make validation options explicit. + const schema = merge({}, ValidationRequired.args?.schema, { + ValidationOptions: { + selectedValidation: NumberSelectedValidation.Range, + RangeValidationOptions: { + customMin: 4, + customMax: null, + }, + }, + }) + render() + const input = screen.getByLabelText( + `${schema!.questionNumber}. ${schema!.title}`, + ) as HTMLInputElement + const submitButton = screen.getByText('Submit') + + expect(input.value).toBe('') + + // Act + await user.type(input, '3') + await user.click(submitButton) + + // Assert + // Should show error validation message. + const error = screen.getByText( + 'Please enter a number that is at least 4', + ) + expect(error).not.toBeNull() + const success = screen.queryByText('You have submitted') + expect(success).toBeNull() + }) + + it('renders success when field input is >= customMin when submitted', async () => { + const user = userEvent.setup() + const schema = merge({}, ValidationRequired.args?.schema, { + ValidationOptions: { + selectedValidation: NumberSelectedValidation.Range, + RangeValidationOptions: { + customMin: 4, + customMax: null, + }, + }, + }) + render() + const input = screen.getByLabelText( + `${schema!.questionNumber}. ${schema!.title}`, + ) as HTMLInputElement + const submitButton = screen.getByText('Submit') + + expect(input.value).toBe('') + const inputString = '45' + + // Act + await user.type(input, inputString) + await user.click(submitButton) + + // Assert + // Should show success message. + const success = screen.getByText(`You have submitted: ${inputString}`) + expect(success).not.toBeNull() + }) + }) + + describe('only customMax specified', () => { + const schema = merge({}, ValidationRequired.args?.schema, { + ValidationOptions: { + selectedValidation: NumberSelectedValidation.Range, + RangeValidationOptions: { + customMin: null, + customMax: 139, + }, + }, + }) + + it('renders error when field input is > customMax when submitted', async () => { + // Arrange + const user = userEvent.setup() + // Using ValidationRequired base story to render the field without any value + // and make validation options explicit. + render() + + const input = screen.getByLabelText( + `${schema!.questionNumber}. ${schema!.title}`, + ) as HTMLInputElement + const submitButton = screen.getByText('Submit') + + expect(input.value).toBe('') + + // Act + await user.type(input, '140') + await user.click(submitButton) + + // Assert + // Should show error validation message. + const error = screen.getByText( + 'Please enter a number that is at most 139', + ) + expect(error).not.toBeNull() + const success = screen.queryByText('You have submitted') + expect(success).toBeNull() + }) + + it('renders success when field input is <= customMax when submitted', async () => { + const user = userEvent.setup() + render() + const input = screen.getByLabelText( + `${schema!.questionNumber}. ${schema!.title}`, + ) as HTMLInputElement + const submitButton = screen.getByText('Submit') + + expect(input.value).toBe('') + const inputString = '139' + + // Act + await user.type(input, inputString) + await user.click(submitButton) + + // Assert + // Should show success message. + const success = screen.getByText(`You have submitted: ${inputString}`) + expect(success).not.toBeNull() + }) + }) + + describe('both customMin and customMax specified', () => { + const schema = merge({}, ValidationRequired.args?.schema, { + ValidationOptions: { + selectedValidation: NumberSelectedValidation.Range, + RangeValidationOptions: { + customMin: 2015, + customMax: 2019, + }, + }, + }) + const errorMsg = 'Please enter a number between 2015 and 2019' + + it('renders error when field input is < customMin when submitted', async () => { + // Arrange + const user = userEvent.setup() + // Using ValidationRequired base story to render the field without any value + // and make validation options explicit. + render() + const input = screen.getByLabelText( + `${schema!.questionNumber}. ${schema!.title}`, + ) as HTMLInputElement + const submitButton = screen.getByText('Submit') + + expect(input.value).toBe('') + + // Act + await user.type(input, '41') + await user.click(submitButton) + + // Assert + // Should show error validation message. + const error = screen.getByText(errorMsg) + expect(error).not.toBeNull() + const success = screen.queryByText('You have submitted') + expect(success).toBeNull() + }) + + it('renders error when field input is > customMax when submitted', async () => { + // Arrange + const user = userEvent.setup() + // Using ValidationRequired base story to render the field without any value + // and make validation options explicit. + render() + const input = screen.getByLabelText( + `${schema!.questionNumber}. ${schema!.title}`, + ) as HTMLInputElement + const submitButton = screen.getByText('Submit') + + expect(input.value).toBe('') + + // Act + await user.type(input, '3000') + await user.click(submitButton) + + // Assert + // Should show error validation message. + const error = screen.getByText(errorMsg) + expect(error).not.toBeNull() + const success = screen.queryByText('You have submitted') + expect(success).toBeNull() + }) + + it('renders success when field input is within customMin and customMax when submitted', async () => { + const user = userEvent.setup() + render() + const input = screen.getByLabelText( + `${schema!.questionNumber}. ${schema!.title}`, + ) as HTMLInputElement + const submitButton = screen.getByText('Submit') + + expect(input.value).toBe('') + const inputString = '2016' + + // Act + await user.type(input, inputString) + await user.click(submitButton) + + // Assert + // Should show success message. + const success = screen.getByText(`You have submitted: ${inputString}`) + expect(success).not.toBeNull() + }) + }) + }) }) diff --git a/frontend/src/utils/fieldValidation.ts b/frontend/src/utils/fieldValidation.ts index 91c330a57c..784944faf3 100644 --- a/frontend/src/utils/fieldValidation.ts +++ b/frontend/src/utils/fieldValidation.ts @@ -25,6 +25,7 @@ import { MobileFieldBase, NricFieldBase, NumberFieldBase, + NumberSelectedLengthValidation, NumberSelectedValidation, RadioFieldBase, RatingFieldBase, @@ -206,34 +207,68 @@ export const createMobileValidationRules: ValidationRuleFnEmailAndMobile< export const createNumberValidationRules: ValidationRuleFn = ( schema, ): RegisterOptions => { - const { selectedValidation, customVal } = schema.ValidationOptions + const { selectedValidation } = schema.ValidationOptions + const { selectedLengthValidation, customVal } = + schema.ValidationOptions.LengthValidationOptions + const { customMin, customMax } = + schema.ValidationOptions.RangeValidationOptions return { validate: { required: requiredSingleAnswerValidationFn(schema), - validNumber: (val?: string) => { - if (!val || !customVal) return true + validNumberLength: (val: string) => { + if ( + selectedValidation !== NumberSelectedValidation.Length || + !val || + !customVal + ) + return true const currLen = val.trim().length - switch (selectedValidation) { - case NumberSelectedValidation.Exact: + switch (selectedLengthValidation) { + case NumberSelectedLengthValidation.Exact: return ( currLen === customVal || simplur`Please enter ${customVal} digit[|s] (${currLen}/${customVal})` ) - case NumberSelectedValidation.Min: + case NumberSelectedLengthValidation.Min: return ( currLen >= customVal || simplur`Please enter at least ${customVal} digit[|s] (${currLen}/${customVal})` ) - case NumberSelectedValidation.Max: + case NumberSelectedLengthValidation.Max: return ( currLen <= customVal || simplur`Please enter at most ${customVal} digit[|s] (${currLen}/${customVal})` ) } }, + validNumberRange: (val: string) => { + if (selectedValidation !== NumberSelectedValidation.Range || !val) + return true + + const numVal = parseInt(val) + if (Number.isNaN(numVal)) { + return 'Please enter a valid number' + } + + const hasMinimum = customMin !== null + const hasMaximum = customMax !== null + const satisfiesMinimum = !hasMinimum || customMin <= numVal + const satisfiesMaximum = !hasMaximum || numVal <= customMax + const isInRange = satisfiesMinimum && satisfiesMaximum + + if (isInRange) { + return true + } else if (hasMinimum && hasMaximum) { + return `Please enter a number between ${customMin} and ${customMax}` + } else if (hasMinimum) { + return `Please enter a number that is at least ${customMin}` + } else if (hasMaximum) { + return `Please enter a number that is at most ${customMax}` + } + }, }, } } diff --git a/package-lock.json b/package-lock.json index d18d250cb5..ffec772892 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.79.0", + "version": "6.80.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.79.0", + "version": "6.80.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", diff --git a/package.json b/package.json index 13482a0959..d647604f3f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.79.0", + "version": "6.80.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG " diff --git a/scripts/20230817_migrate-number-field-schema/migrate-number-field-schema.js b/scripts/20230817_migrate-number-field-schema/migrate-number-field-schema.js new file mode 100644 index 0000000000..aa29321f72 --- /dev/null +++ b/scripts/20230817_migrate-number-field-schema/migrate-number-field-schema.js @@ -0,0 +1,97 @@ +/* eslint-disable */ + +// This script migrates the existing NumberFieldSchema.ValidationOptions to +// the new NumberFieldSchema.ValidationOptions.LengthValidationOptions + +// BEFORE +// COUNT existing number of forms with the old number field schema +db.forms.countDocuments({ + form_fields: { + $elemMatch: { + fieldType: 'number', + 'ValidationOptions.customVal': { + $exists: true, + }, + }, + }, +}) + +// UPDATE +// modifiedCount should match COUNT in BEFORE +db.forms.updateMany( + { + form_fields: { + $elemMatch: { + fieldType: 'number', + 'ValidationOptions.customVal': { + $exists: true, + }, + }, + }, + }, + [ + { + $set: { + form_fields: { + $map: { + input: '$form_fields', + as: 'field', + in: { + $cond: { + if: { + $eq: ['$$field.fieldType', 'number'], + }, + then: { + $mergeObjects: [ + '$$field', + { + ValidationOptions: { + selectedValidation: { + $cond: { + if: { + $ne: [ + '$$field.ValidationOptions.selectedValidation', + null, + ], + }, + then: 'Length', + else: null, + }, + }, + LengthValidationOptions: { + selectedLengthValidation: + '$$field.ValidationOptions.selectedValidation', + customVal: '$$field.ValidationOptions.customVal', + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, + }, + }, + ], + }, + else: '$$field', + }, + }, + }, + }, + }, + }, + ], +) + +// AFTER +// Count number of forms with old number field schema +// Expect 0 + +db.forms.countDocuments({ + form_fields: { + $elemMatch: { + fieldType: 'number', + 'ValidationOptions.customVal': { + $exists: true, + }, + }, + }, +}) diff --git a/shared/constants/field/myinfo/index.ts b/shared/constants/field/myinfo/index.ts index fe8f80662d..a3f247efa4 100644 --- a/shared/constants/field/myinfo/index.ts +++ b/shared/constants/field/myinfo/index.ts @@ -317,6 +317,16 @@ export const types: MyInfoFieldBlock[] = [ fieldType: BasicField.Children, previewValue: 'Child 1', }, + { + name: MyInfoAttribute.ChildName, + value: "Child's name", + category: 'children', + verified: ['SG', 'PR', 'F'], + source: 'Immigration & Checkpoints Authority / Health Promotion Board', + description: 'Name', + fieldType: BasicField.Children, + previewValue: 'PHUA CHU KING', + }, { name: MyInfoAttribute.ChildBirthCertNo, value: "Child's birth certificte number", diff --git a/shared/types/field/numberField.ts b/shared/types/field/numberField.ts index 70d83f70d7..7af801b193 100644 --- a/shared/types/field/numberField.ts +++ b/shared/types/field/numberField.ts @@ -1,14 +1,30 @@ import { BasicField, MyInfoableFieldBase } from './base' export enum NumberSelectedValidation { + Length = 'Length', + Range = 'Range', +} + +export enum NumberSelectedLengthValidation { Max = 'Maximum', Min = 'Minimum', Exact = 'Exact', } +export type NumberLengthValidationOptions = { + customVal: number | null + selectedLengthValidation: NumberSelectedLengthValidation | null +} + +export type NumberRangeValidationOptions = { + customMin: number | null + customMax: number | null +} + export type NumberValidationOptions = { - customVal: number | '' | null selectedValidation: NumberSelectedValidation | null + LengthValidationOptions: NumberLengthValidationOptions + RangeValidationOptions: NumberRangeValidationOptions } export interface NumberFieldBase extends MyInfoableFieldBase { diff --git a/src/app/models/__tests__/form_fields.schema.spec.ts b/src/app/models/__tests__/form_fields.schema.spec.ts index 6b60407361..ecd0305bd9 100644 --- a/src/app/models/__tests__/form_fields.schema.spec.ts +++ b/src/app/models/__tests__/form_fields.schema.spec.ts @@ -1,7 +1,12 @@ import dbHandler from '__tests__/unit/backend/helpers/jest-db' import { ObjectID } from 'bson' import mongoose from 'mongoose' -import { BasicField, FormResponseMode } from 'shared/types' +import { + BasicField, + FormResponseMode, + NumberSelectedLengthValidation, + NumberSelectedValidation, +} from 'shared/types' import getFormModel from 'src/app/models/form.server.model' import { IFieldSchema } from 'src/types' @@ -173,135 +178,227 @@ describe('Form Field Schema', () => { expect(fieldObj).toHaveProperty('allowedEmailDomains', ['@example.com']) }) }) - }), - describe('Short Text Field', () => { - describe('prefill', () => { - it('should allow creation of short text field with no prefill setting and populate prefill settings with default', async () => { - // Arrange - const field = await createAndReturnFormField({ - fieldType: BasicField.ShortText, - }) + }) - // Assert - const fieldObj = field.toObject() - expect(fieldObj).toHaveProperty('allowPrefill', false) - expect(fieldObj).toHaveProperty('lockPrefill', false) + describe('Short Text Field', () => { + describe('prefill', () => { + it('should allow creation of short text field with no prefill setting and populate prefill settings with default', async () => { + // Arrange + const field = await createAndReturnFormField({ + fieldType: BasicField.ShortText, }) - it('should allow creation of short text field with allowPrefill = false setting and populate lockPrefill settings with default', async () => { - // Arrange - const field = await createAndReturnFormField({ - fieldType: BasicField.ShortText, - allowPrefill: false, - }) + // Assert + const fieldObj = field.toObject() + expect(fieldObj).toHaveProperty('allowPrefill', false) + expect(fieldObj).toHaveProperty('lockPrefill', false) + }) - // Assert - const fieldObj = field.toObject() - expect(fieldObj).toHaveProperty('allowPrefill', false) - expect(fieldObj).toHaveProperty('lockPrefill', false) + it('should allow creation of short text field with allowPrefill = false setting and populate lockPrefill settings with default', async () => { + // Arrange + const field = await createAndReturnFormField({ + fieldType: BasicField.ShortText, + allowPrefill: false, }) - it('should allow creation of short text field with allowPrefill = true setting and populate lockPrefill settings with default', async () => { - // Arrange - const field = await createAndReturnFormField({ - fieldType: BasicField.ShortText, - allowPrefill: true, - }) + // Assert + const fieldObj = field.toObject() + expect(fieldObj).toHaveProperty('allowPrefill', false) + expect(fieldObj).toHaveProperty('lockPrefill', false) + }) - // Assert - const fieldObj = field.toObject() - expect(fieldObj).toHaveProperty('allowPrefill', true) - expect(fieldObj).toHaveProperty('lockPrefill', false) + it('should allow creation of short text field with allowPrefill = true setting and populate lockPrefill settings with default', async () => { + // Arrange + const field = await createAndReturnFormField({ + fieldType: BasicField.ShortText, + allowPrefill: true, }) - it('should allow creation of short text field with allowPrefill = true and lockPrefill = true settings', async () => { - // Arrange + // Assert + const fieldObj = field.toObject() + expect(fieldObj).toHaveProperty('allowPrefill', true) + expect(fieldObj).toHaveProperty('lockPrefill', false) + }) + + it('should allow creation of short text field with allowPrefill = true and lockPrefill = true settings', async () => { + // Arrange + const field = await createAndReturnFormField({ + fieldType: BasicField.ShortText, + allowPrefill: true, + lockPrefill: true, + }) + + // Assert + const fieldObj = field.toObject() + expect(fieldObj).toHaveProperty('allowPrefill', true) + expect(fieldObj).toHaveProperty('lockPrefill', true) + }) + + it('should not allow creation of short text field with allowPrefill = false and lockPrefill = true settings', async () => { + // Arrange + const createField = async () => { const field = await createAndReturnFormField({ fieldType: BasicField.ShortText, - allowPrefill: true, + allowPrefill: false, lockPrefill: true, }) - // Assert - const fieldObj = field.toObject() - expect(fieldObj).toHaveProperty('allowPrefill', true) - expect(fieldObj).toHaveProperty('lockPrefill', true) + return field + } + + // Act + const createFieldPromise = createField() + + // Assert + await expect(createFieldPromise).rejects.toThrow( + 'Cannot lock prefill if prefill is not enabled', + ) + }) + }) + }) + + describe('Number Field', () => { + it('should allow creation of default number field', async () => { + const defaultNumberValidationOptions = { + ValidationOptions: { + selectedValidation: null, + LengthValidationOptions: { + selectedLengthValidation: null, + customVal: null, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, + }, + } + + const createDefaultNumberField = async () => + await createAndReturnFormField({ + fieldType: BasicField.Number, }) - it('should not allow creation of short text field with allowPrefill = false and lockPrefill = true settings', async () => { - // Arrange - const createField = async () => { - const field = await createAndReturnFormField({ - fieldType: BasicField.ShortText, - allowPrefill: false, - lockPrefill: true, - }) + await expect(createDefaultNumberField()).resolves.toMatchObject( + defaultNumberValidationOptions, + ) + }) - return field - } + it('should not allow creation of number field with selectedLengthValidation but no customVal', async () => { + const createInvalidNumberField = async () => + await createAndReturnFormField({ + fieldType: BasicField.Number, + ValidationOptions: { + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Min, + }, + }, + }) - // Act - const createFieldPromise = createField() + await expect(createInvalidNumberField()).rejects.toThrow( + 'Please enter a customVal', + ) + }) - // Assert - await expect(createFieldPromise).rejects.toThrow( - 'Cannot lock prefill if prefill is not enabled', - ) + it('should not allow creation of number field with selectedValidation.Length but no selectedLengthValidation', async () => { + const createInvalidNumberField = async () => + await createAndReturnFormField({ + fieldType: BasicField.Number, + ValidationOptions: { + selectedValidation: NumberSelectedValidation.Length, + }, }) - }) - }), - describe('Methods', () => { - describe('getQuestion', () => { - it('should return field title when field type is not a table field', async () => { - // Arrange - // Get all field types - const fieldTypes = Object.values(BasicField) - for (const fieldType of fieldTypes) { - if (fieldType === BasicField.Table) return - - // Act - const fieldTitle = `test ${fieldType} field title` - const field = await createAndReturnFormField({ - fieldType, - title: fieldTitle, - }) - - // Assert - expect(field.getQuestion()).toEqual(fieldTitle) - } + + await expect(createInvalidNumberField()).rejects.toThrow( + 'Please select the type of length validation', + ) + }) + + it('should not allow creation of number field with selected range validation but invalid range', async () => { + const createInvalidNumberField = async () => + await createAndReturnFormField({ + fieldType: BasicField.Number, + ValidationOptions: { + selectedValidation: NumberSelectedValidation.Range, + RangeValidationOptions: { + customMin: 10, + customMax: 5, + }, + }, + }) + + await expect(createInvalidNumberField()).rejects.toThrow( + 'Please enter a valid range', + ) + }) + + it('should not allow creation of number field with selected range validation but missing range', async () => { + const createInvalidNumberField = async () => + await createAndReturnFormField({ + fieldType: BasicField.Number, + ValidationOptions: { + selectedValidation: NumberSelectedValidation.Range, + }, }) - it('should return table title concatenated with all column titles when field type is a table field', async () => { - // Arrange - const tableFieldParams = { - title: 'testTableTitle', - minimumRows: 1, - columns: [ - { - title: 'Test Column Title 1', - required: true, - columnType: 'textfield', - }, - { - title: 'Test Column Title 2', - required: true, - columnType: 'dropdown', - }, - ], - fieldType: 'table', - } + await expect(createInvalidNumberField()).rejects.toThrow( + 'Please enter a valid range', + ) + }) + }) + + describe('Methods', () => { + describe('getQuestion', () => { + it('should return field title when field type is not a table field', async () => { + // Arrange + // Get all field types + const fieldTypes = Object.values(BasicField) + for (const fieldType of fieldTypes) { + if (fieldType === BasicField.Table) return // Act - const tableField = await createAndReturnFormField(tableFieldParams) + const fieldTitle = `test ${fieldType} field title` + const field = await createAndReturnFormField({ + fieldType, + title: fieldTitle, + }) // Assert - const expectedQuestionString = `${ - tableFieldParams.title - } (${tableFieldParams.columns.map((col) => col.title).join(', ')})` - expect(tableField.getQuestion()).toEqual(expectedQuestionString) - }) + expect(field.getQuestion()).toEqual(fieldTitle) + } + }) + + it('should return table title concatenated with all column titles when field type is a table field', async () => { + // Arrange + const tableFieldParams = { + title: 'testTableTitle', + minimumRows: 1, + columns: [ + { + title: 'Test Column Title 1', + required: true, + columnType: 'textfield', + }, + { + title: 'Test Column Title 2', + required: true, + columnType: 'dropdown', + }, + ], + fieldType: 'table', + } + + // Act + const tableField = await createAndReturnFormField(tableFieldParams) + + // Assert + const expectedQuestionString = `${ + tableFieldParams.title + } (${tableFieldParams.columns.map((col) => col.title).join(', ')})` + expect(tableField.getQuestion()).toEqual(expectedQuestionString) }) }) + }) }) const createAndReturnFormField = async ( diff --git a/src/app/models/field/numberField.ts b/src/app/models/field/numberField.ts index e85e6b86b9..42c1767ac5 100644 --- a/src/app/models/field/numberField.ts +++ b/src/app/models/field/numberField.ts @@ -1,6 +1,7 @@ import { Schema } from 'mongoose' import { + NumberSelectedLengthValidation, NumberSelectedValidation, NumberValidationOptions, } from '../../../../shared/types' @@ -10,24 +11,71 @@ import { MyInfoSchema } from './baseField' const createNumberFieldSchema = () => { const ValidationOptionsSchema = new Schema({ - customVal: { - type: Number, - }, selectedValidation: { type: String, + default: null, enum: [...Object.values(NumberSelectedValidation), null], }, + LengthValidationOptions: { + customVal: { + type: Number, + default: null, + required: [ + function requireSelectedLengthValidation( + this: NumberValidationOptions, + ) { + return ( + this.LengthValidationOptions.selectedLengthValidation !== null + ) + }, + 'Please enter a customVal', + ], + }, + selectedLengthValidation: { + type: String, + default: null, + enum: [...Object.values(NumberSelectedLengthValidation), null], + required: [ + function hasSelectedValidation(this: NumberValidationOptions) { + return this.selectedValidation === NumberSelectedValidation.Length + }, + 'Please select the type of length validation', + ], + }, + }, + RangeValidationOptions: { + customMin: { + type: Number, + default: null, + validate: { + validator: function hasValidRange(this: NumberValidationOptions) { + if (this.selectedValidation !== NumberSelectedValidation.Range) { + return true + } + + const { customMin, customMax } = this.RangeValidationOptions + const hasRange = customMin !== null || customMax !== null + const isValidRange = + customMin === null || customMax === null || customMin < customMax + return hasRange && isValidRange + }, + message: 'Please enter a valid range', + }, + }, + customMax: { + type: Number, + default: null, + }, + }, }) const NumberFieldSchema = new Schema({ myInfo: MyInfoSchema, ValidationOptions: { type: ValidationOptionsSchema, - default: { - // Defaults are defined here because subdocument paths are undefined by default, and Mongoose does not apply subdocument defaults unless you set the subdocument path to a non-nullish value (see https://mongoosejs.com/docs/subdocs.html) - customVal: null, - selectedValidation: null, - }, + // Setting the subdocument path to an empty object ensures the defaults in ValidationOptionsSchema are applied. + // See: https://mongoosejs.com/docs/subdocs.html#subdocument-defaults + default: () => ({}), }, }) diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index 473a852fa7..533d18a26b 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -481,7 +481,6 @@ export const getMyInfoAttr = ( * Helper function to get a MyInfo child's hash key inside an IHashes. * * @param fieldId The ID of the field the Child response belongs to. - * @param childIdx The nth child to look for. * @returns An IHashes-compatible key. */ export const getMyInfoChildHashKey = ( diff --git a/src/app/modules/submission/ParsedResponsesObject.class.ts b/src/app/modules/submission/ParsedResponsesObject.class.ts index 0640a4190c..eb72f26757 100644 --- a/src/app/modules/submission/ParsedResponsesObject.class.ts +++ b/src/app/modules/submission/ParsedResponsesObject.class.ts @@ -130,6 +130,7 @@ export default class ParsedResponsesObject { // Validate each field in the form and inject metadata into the responses. const processedResponses = [] + let childIdx = 0 for (const response of filteredResponses) { const responseId = response._id const formField = fieldMap[responseId] @@ -159,6 +160,15 @@ export default class ParsedResponsesObject { processingResponse as ProcessedChildrenResponse ).childSubFieldsArray = (formField as ChildrenCompoundFieldBase).childrenSubFields ?? [] + ;(processingResponse as ProcessedChildrenResponse).childIdx = childIdx + // 1 MyInfo child field might contain more than 1 child, represented by the length of the answerArray + // This happens when the button "Add another child" is used to add >= 1 child + const noOfChildrenInQn = + (processingResponse as ProcessedChildrenResponse).answerArray + ?.length ?? 0 + // We increment the childIdx by the number of children in the qn, instead of just 1 + // to account for the case where the MyInfo child field contains more than 1 child + childIdx += noOfChildrenInQn } } diff --git a/src/app/modules/submission/email-submission/email-submission.constants.ts b/src/app/modules/submission/email-submission/email-submission.constants.ts index 26120fdae5..88206298ce 100644 --- a/src/app/modules/submission/email-submission/email-submission.constants.ts +++ b/src/app/modules/submission/email-submission/email-submission.constants.ts @@ -3,7 +3,6 @@ export const MYINFO_PREFIX = '[MyInfo] ' export const VERIFIED_PREFIX = '[verified] ' export const TABLE_PREFIX = '[table] ' export const ATTACHMENT_PREFIX = '[attachment] ' -export const CHILD_PREFIX = '[child] ' // Parameters for hashing submissions export const SALT_LENGTH = 32 diff --git a/src/app/modules/submission/email-submission/email-submission.util.ts b/src/app/modules/submission/email-submission/email-submission.util.ts index 386f29f39b..5ac3609369 100644 --- a/src/app/modules/submission/email-submission/email-submission.util.ts +++ b/src/app/modules/submission/email-submission/email-submission.util.ts @@ -1,6 +1,7 @@ import { StatusCodes } from 'http-status-codes' import { compact } from 'lodash' +import { MYINFO_ATTRIBUTE_MAP } from '../../../../../shared/constants/field/myinfo' import { BasicField, FormAuthType, @@ -86,7 +87,6 @@ import { import { ATTACHMENT_PREFIX, - CHILD_PREFIX, MYINFO_PREFIX, TABLE_PREFIX, VERIFIED_PREFIX, @@ -137,8 +137,6 @@ const getFieldTypePrefix = (response: ResponseFormattedForEmail): string => { return TABLE_PREFIX case BasicField.Attachment: return ATTACHMENT_PREFIX - case BasicField.Children: - return CHILD_PREFIX default: return '' } @@ -220,28 +218,37 @@ export const getAnswersForChild = ( response: ProcessedChildrenResponse, ): ResponseFormattedForEmail[] => { const subFields = response.childSubFieldsArray + const qnChildIdx = response.childIdx ?? 0 if (!subFields) { return [] } return response.answerArray.flatMap((arr, childIdx) => { // First array element is always child name const childName = arr[0] - return arr.map((answer, idx) => ({ - _id: getMyInfoChildHashKey( - response._id, - subFields[idx], - childIdx, - childName, - ), - fieldType: response.fieldType, - question: `Child.${subFields[idx]}`, - myInfo: { - attr: subFields[idx] as unknown as MyInfoAttribute, - }, - isVisible: response.isVisible, - isUserVerified: response.isUserVerified, - answer, - })) + return arr.map((answer, idx) => { + const subfield = subFields[idx] + return { + _id: getMyInfoChildHashKey( + response._id, + subFields[idx], + childIdx, + childName, + ), + fieldType: response.fieldType, + // qnChildIdx represents the index of the MyInfo field + // childIdx represents the index of the child in this MyInfo field + // as there might be >1 child for each MyInfo child field if "Add another child" is used + question: `Child ${qnChildIdx + childIdx + 1} ${ + MYINFO_ATTRIBUTE_MAP[subfield].description + }`, + myInfo: { + attr: subFields[idx] as unknown as MyInfoAttribute, + }, + isVisible: response.isVisible, + isUserVerified: response.isUserVerified, + answer, + } + }) }) } diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts index 7c6bfe53a9..9ece480f6d 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts @@ -62,8 +62,10 @@ import { } from '../../spcp/spcp.errors' import { MissingUserError } from '../../user/user.errors' import { + AttachmentTooLargeError, ConflictError, InvalidEncodingError, + InvalidFileExtensionError, ProcessingError, ResponseModeError, SubmissionNotFoundError, @@ -205,6 +207,8 @@ const errorMapper: MapRouteError = ( } case ValidateFieldError: case DatabaseValidationError: + case InvalidFileExtensionError: + case AttachmentTooLargeError: case ProcessingError: return { statusCode: StatusCodes.BAD_REQUEST, diff --git a/src/app/modules/submission/submission.types.ts b/src/app/modules/submission/submission.types.ts index f687cdbc41..de02eab03f 100644 --- a/src/app/modules/submission/submission.types.ts +++ b/src/app/modules/submission/submission.types.ts @@ -59,7 +59,10 @@ export type ProcessedSingleAnswerResponse< export type ProcessedCheckboxResponse = CheckboxResponse & ProcessedResponse export type ProcessedTableResponse = TableResponse & ProcessedResponse export type ProcessedChildrenResponse = ChildBirthRecordsResponse & - ProcessedResponse & { childSubFieldsArray?: MyInfoChildAttributes[] } + ProcessedResponse & { + childSubFieldsArray?: MyInfoChildAttributes[] + childIdx?: number + } /** * Can be either email or storage mode attachment response. * Email mode attachment response in the server will have extra metadata injected diff --git a/src/app/utils/field-validation/validators/__tests__/number-validation.spec.ts b/src/app/utils/field-validation/validators/__tests__/number-validation.spec.ts index 7a207c9ffa..ca2b4d74b1 100644 --- a/src/app/utils/field-validation/validators/__tests__/number-validation.spec.ts +++ b/src/app/utils/field-validation/validators/__tests__/number-validation.spec.ts @@ -8,32 +8,24 @@ import { validateField } from 'src/app/utils/field-validation' import { BasicField, + NumberSelectedLengthValidation, NumberSelectedValidation, } from '../../../../../../shared/types' -describe('Number field validation', () => { - it('should allow number with valid maximum', () => { - const formField = generateDefaultField(BasicField.Number, { - ValidationOptions: { - selectedValidation: NumberSelectedValidation.Max, - customVal: 2, - }, - }) +describe('Base number field validation', () => { + it('should allow number with no custom validation', () => { + const formField = generateDefaultField(BasicField.Number) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '5', + answer: '55', }) - const validateResult = validateField('formId', formField, response) expect(validateResult.isOk()).toBe(true) expect(validateResult._unsafeUnwrap()).toEqual(true) }) - it('should allow number with valid maximum (inclusive)', () => { + it('should allow number with optional answer', () => { const formField = generateDefaultField(BasicField.Number, { - ValidationOptions: { - selectedValidation: NumberSelectedValidation.Max, - customVal: 2, - }, + required: false, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { answer: '55', @@ -43,15 +35,20 @@ describe('Number field validation', () => { expect(validateResult._unsafeUnwrap()).toEqual(true) }) - it('should disallow number with invalid maximum', () => { - const formField = generateDefaultField(BasicField.Number, { - ValidationOptions: { - selectedValidation: NumberSelectedValidation.Max, - customVal: 2, - }, + it('should allow answer to be zero', () => { + const formField = generateDefaultField(BasicField.Number) + const response = generateNewSingleAnswerResponse(BasicField.Number, { + answer: '0', }) + const validateResult = validateField('formId', formField, response) + expect(validateResult.isOk()).toBe(true) + expect(validateResult._unsafeUnwrap()).toEqual(true) + }) + + it('should disallow negative answers', () => { + const formField = generateDefaultField(BasicField.Number) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '555', + answer: '-55', }) const validateResult = validateField('formId', formField, response) expect(validateResult.isErr()).toBe(true) @@ -60,41 +57,58 @@ describe('Number field validation', () => { ) }) - it('should allow number with valid minimum', () => { - const formField = generateDefaultField(BasicField.Number, { - ValidationOptions: { - selectedValidation: NumberSelectedValidation.Min, - customVal: 2, - }, - }) + it('should allow leading zeroes in answer', () => { + const formField = generateDefaultField(BasicField.Number) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '555', + answer: '05', }) const validateResult = validateField('formId', formField, response) expect(validateResult.isOk()).toBe(true) expect(validateResult._unsafeUnwrap()).toEqual(true) }) - it('should allow number with valid minimum (inclusive)', () => { + it('should disallow responses submitted for hidden fields', () => { + const formField = generateDefaultField(BasicField.Number) + const response = generateNewSingleAnswerResponse(BasicField.Number, { + answer: '2', + isVisible: false, + }) + const validateResult = validateField('formId', formField, response) + expect(validateResult.isErr()).toBe(true) + expect(validateResult._unsafeUnwrapErr()).toEqual( + new ValidateFieldError('Attempted to submit response on a hidden field'), + ) + }) +}) + +describe('Number field validation', () => { + it('should allow number with valid maximum length', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: NumberSelectedValidation.Min, - customVal: 2, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Max, + customVal: 2, + }, }, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '55', + answer: '5', }) + const validateResult = validateField('formId', formField, response) expect(validateResult.isOk()).toBe(true) expect(validateResult._unsafeUnwrap()).toEqual(true) }) - it('should allow number with valid exact', () => { + it('should allow number with valid maximum length (inclusive)', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: NumberSelectedValidation.Exact, - customVal: 2, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Max, + customVal: 2, + }, }, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { @@ -105,15 +119,18 @@ describe('Number field validation', () => { expect(validateResult._unsafeUnwrap()).toEqual(true) }) - it('should disallow number with invalid exact', () => { + it('should disallow number with invalid maximum length', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: NumberSelectedValidation.Exact, - customVal: 2, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Max, + customVal: 2, + }, }, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '5', + answer: '555', }) const validateResult = validateField('formId', formField, response) expect(validateResult.isErr()).toBe(true) @@ -122,26 +139,32 @@ describe('Number field validation', () => { ) }) - it('should allow number with maximum left undefined', () => { + it('should allow number with valid minimum length', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: NumberSelectedValidation.Max, - customVal: null, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Min, + customVal: 2, + }, }, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '55', + answer: '555', }) const validateResult = validateField('formId', formField, response) expect(validateResult.isOk()).toBe(true) expect(validateResult._unsafeUnwrap()).toEqual(true) }) - it('should allow number with minimum left undefined', () => { + it('should allow number with valid minimum length (inclusive)', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: NumberSelectedValidation.Min, - customVal: null, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Min, + customVal: 2, + }, }, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { @@ -152,11 +175,14 @@ describe('Number field validation', () => { expect(validateResult._unsafeUnwrap()).toEqual(true) }) - it('should allow number with exact undefined', () => { + it('should allow number with valid exact length', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: NumberSelectedValidation.Exact, - customVal: null, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Exact, + customVal: 2, + }, }, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { @@ -167,98 +193,119 @@ describe('Number field validation', () => { expect(validateResult._unsafeUnwrap()).toEqual(true) }) - it('should allow number with no custom validation', () => { + it('should disallow number with invalid exact length', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: null, - customVal: null, + selectedValidation: NumberSelectedValidation.Length, + LengthValidationOptions: { + selectedLengthValidation: NumberSelectedLengthValidation.Exact, + customVal: 2, + }, }, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '55', + answer: '5', }) const validateResult = validateField('formId', formField, response) - expect(validateResult.isOk()).toBe(true) - expect(validateResult._unsafeUnwrap()).toEqual(true) + expect(validateResult.isErr()).toBe(true) + expect(validateResult._unsafeUnwrapErr()).toEqual( + new ValidateFieldError('Invalid answer submitted'), + ) }) +}) - it('should allow number with optional answer', () => { +describe('Range field validation', () => { + it('should allow number with that is within range (both min and max)', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: null, - customVal: null, + selectedValidation: NumberSelectedValidation.Range, + RangeValidationOptions: { + customMin: 5, + customMax: 10, + }, }, - required: false, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '55', + answer: '7', }) const validateResult = validateField('formId', formField, response) expect(validateResult.isOk()).toBe(true) expect(validateResult._unsafeUnwrap()).toEqual(true) }) - it('should allow answer to be zero', () => { + it('should allow number with that is within maximum (inclusive)', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: null, - customVal: null, + selectedValidation: NumberSelectedValidation.Range, + RangeValidationOptions: { + customMin: null, + customMax: 7, + }, }, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '0', + answer: '7', }) const validateResult = validateField('formId', formField, response) expect(validateResult.isOk()).toBe(true) expect(validateResult._unsafeUnwrap()).toEqual(true) }) - it('should disallow negative answers', () => { + it('should allow number with that is within minimum (inclusive)', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: null, - customVal: null, + selectedValidation: NumberSelectedValidation.Range, + RangeValidationOptions: { + customMin: 9, + customMax: null, + }, }, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '-55', + answer: '9', }) const validateResult = validateField('formId', formField, response) - expect(validateResult.isErr()).toBe(true) - expect(validateResult._unsafeUnwrapErr()).toEqual( - new ValidateFieldError('Invalid answer submitted'), - ) + expect(validateResult.isOk()).toBe(true) + expect(validateResult._unsafeUnwrap()).toEqual(true) }) - it('should allow leading zeroes in answer', () => { + it('should disallow number that is below minimum', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: null, - customVal: null, + selectedValidation: NumberSelectedValidation.Range, + RangeValidationOptions: { + customMin: 100, + customMax: null, + }, }, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '05', + answer: '42', }) const validateResult = validateField('formId', formField, response) - expect(validateResult.isOk()).toBe(true) - expect(validateResult._unsafeUnwrap()).toEqual(true) + expect(validateResult.isErr()).toBe(true) + expect(validateResult._unsafeUnwrapErr()).toEqual( + new ValidateFieldError('Invalid answer submitted'), + ) }) - it('should disallow responses submitted for hidden fields', () => { + + it('should disallow number that is above maximum', () => { const formField = generateDefaultField(BasicField.Number, { ValidationOptions: { - selectedValidation: null, - customVal: null, + selectedValidation: NumberSelectedValidation.Range, + RangeValidationOptions: { + customMin: null, + customMax: 7, + }, }, }) const response = generateNewSingleAnswerResponse(BasicField.Number, { - answer: '2', - isVisible: false, + answer: '42', }) const validateResult = validateField('formId', formField, response) expect(validateResult.isErr()).toBe(true) expect(validateResult._unsafeUnwrapErr()).toEqual( - new ValidateFieldError('Attempted to submit response on a hidden field'), + new ValidateFieldError('Invalid answer submitted'), ) }) }) diff --git a/src/app/utils/field-validation/validators/numberValidator.ts b/src/app/utils/field-validation/validators/numberValidator.ts index cb25382e3d..2cba9d1108 100644 --- a/src/app/utils/field-validation/validators/numberValidator.ts +++ b/src/app/utils/field-validation/validators/numberValidator.ts @@ -1,11 +1,11 @@ import { chain, left, right } from 'fp-ts/lib/Either' import { flow } from 'fp-ts/lib/function' -import { NumberSelectedValidation } from '../../../../../shared/types' import { - INumberFieldSchema, - OmitUnusedValidatorProps, -} from '../../../../types/field' + NumberSelectedLengthValidation, + NumberSelectedValidation, +} from '../../../../../shared/types' +import { INumberFieldSchema, OmitUnusedValidatorProps } from '../../../../types' import { ResponseValidator } from '../../../../types/field/utils/validation' import { ProcessedSingleAnswerResponse } from '../../../modules/submission/submission.types' @@ -33,7 +33,7 @@ const numberFormatValidator: NumberValidator = (response) => { const minLengthValidator: NumberValidatorConstructor = (numberField) => (response) => { const { answer } = response - const { customVal } = numberField.ValidationOptions + const { customVal } = numberField.ValidationOptions.LengthValidationOptions return !customVal || answer.length >= customVal ? right(response) : left(`NumberValidator:\t answer is shorter than custom minimum length`) @@ -46,7 +46,7 @@ const minLengthValidator: NumberValidatorConstructor = const maxLengthValidator: NumberValidatorConstructor = (numberField) => (response) => { const { answer } = response - const { customVal } = numberField.ValidationOptions + const { customVal } = numberField.ValidationOptions.LengthValidationOptions return !customVal || answer.length <= customVal ? right(response) : left(`NumberValidator:\t answer is longer than custom maximum length`) @@ -59,29 +59,67 @@ const maxLengthValidator: NumberValidatorConstructor = const exactLengthValidator: NumberValidatorConstructor = (numberField) => (response) => { const { answer } = response - const { customVal } = numberField.ValidationOptions + const { customVal } = numberField.ValidationOptions.LengthValidationOptions return !customVal || answer.length === customVal ? right(response) : left(`NumberValidator:\t answer does not match custom exact length`) } /** - * Returns the appropriate validation function - * based on the number validation option selected. + * Returns the appropriate number length validation function + * based on the number length validation option selected. */ const getNumberLengthValidator: NumberValidatorConstructor = (numberField) => { - switch (numberField.ValidationOptions.selectedValidation) { - case NumberSelectedValidation.Min: + switch ( + numberField.ValidationOptions.LengthValidationOptions + .selectedLengthValidation + ) { + // Assume that the validation options are valid (customVal exists). + case NumberSelectedLengthValidation.Min: return minLengthValidator(numberField) - case NumberSelectedValidation.Max: + case NumberSelectedLengthValidation.Max: return maxLengthValidator(numberField) - case NumberSelectedValidation.Exact: + case NumberSelectedLengthValidation.Exact: return exactLengthValidator(numberField) default: return right } } +/** + * Returns a validation function to check if number is + * within the number range specified. + */ +const rangeValidator: NumberValidatorConstructor = + (numberField) => (response) => { + // Chained validators ensure that the cast to Number is valid + const val = Number(response.answer) + // Assume that the range passed in validation options is valid + const { customMin, customMax } = + numberField.ValidationOptions.RangeValidationOptions + const isWithinMinimum = customMin === null || customMin <= val + const isWithinMaximum = customMax === null || val <= customMax + + return isWithinMinimum && isWithinMaximum + ? right(response) + : left(`NumberValidator:\t answer does not fall within specified range`) + } + +/** + * Returns the appropriate number validation function + * based on the number validation option selected. + */ +const getNumberValidator: NumberValidatorConstructor = (numberField) => { + switch (numberField.ValidationOptions.selectedValidation) { + case NumberSelectedValidation.Length: + return getNumberLengthValidator(numberField) + case NumberSelectedValidation.Range: + return rangeValidator(numberField) + default: + return right + } +} + /** * Returns a validation function for a number field when called. */ @@ -91,5 +129,5 @@ export const constructNumberValidator: NumberValidatorConstructor = ( flow( notEmptySingleAnswerResponse, chain(numberFormatValidator), - chain(getNumberLengthValidator(numberField)), + chain(getNumberValidator(numberField)), )