From 1f882daadf0602067e303215fa6664c6316983a2 Mon Sep 17 00:00:00 2001 From: Lin Huiqing <37061143+LinHuiqing@users.noreply.github.com> Date: Wed, 4 Oct 2023 02:00:04 +0800 Subject: [PATCH 1/4] fix: missing errors in error mapper (#6764) --- .../submission/encrypt-submission/encrypt-submission.utils.ts | 4 ++++ 1 file changed, 4 insertions(+) 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, From fd6b55878a07d2675ca6fe9302873f6a6fdd26bc Mon Sep 17 00:00:00 2001 From: LeonardYam Date: Wed, 4 Oct 2023 11:25:28 +0800 Subject: [PATCH 2/4] feat: add range validation to number field (#6575) * feat: implement frontend and backend for range validation * feat: improve error message clarity for range validation * fix: add validation for invalid ranges Implement additional validation in EditNumber.tsx to ensure invalid ranges cannot be filled. * refactor: apply suggestions from code review 1. For selectedValidation, create new enum in the frontend and simplify shared enum values. 2. Rename rangeMinimum and rangeMaximum to customMin and customMax respectively. 3. Rewrite typeof checks to truthiness checks in backend validators. 4. Remove unnecessary '' from selectedLengthValidation * feat: add mongodb script for schema migration * fix: apply changes from code review * fix: apply changes from code review * fix: apply changes from code review * feat: update backend tests to work on new NumberField schema * chore: update frontend stories to work on new NumberField schema * chore: update frontend tests to work on new NumberField schema * chore: update backend schema, tests and validators * chore: update and write new e2e tests * feat: update frontend validation rules and add placeholder text * chore: write new frontend tests * test: expand heap size for frontend tests --------- Co-authored-by: Justyn Oh --- __tests__/e2e/constants/field.ts | 43 ++- __tests__/e2e/constants/tests.ts | 9 +- __tests__/e2e/helpers/createForm.ts | 56 ++- .../backend/helpers/generate-form-data.ts | 18 + .../EditNumber/EditNumber.stories.tsx | 21 +- .../edit-fieldtype/EditNumber/EditNumber.tsx | 332 ++++++++++++++---- .../builder-and-design/utils/fieldCreation.ts | 17 +- .../src/mocks/msw/handlers/admin-form/form.ts | 9 +- .../Field/Number/NumberField.stories.tsx | 48 ++- .../Field/Number/NumberField.test.tsx | 268 +++++++++++++- frontend/src/utils/fieldValidation.ts | 49 ++- .../migrate-number-field-schema.js | 97 +++++ shared/types/field/numberField.ts | 18 +- .../__tests__/form_fields.schema.spec.ts | 301 ++++++++++------ src/app/models/field/numberField.ts | 64 +++- .../__tests__/number-validation.spec.ts | 219 +++++++----- .../validators/numberValidator.ts | 66 +++- 17 files changed, 1325 insertions(+), 310 deletions(-) create mode 100644 scripts/20230817_migrate-number-field-schema/migrate-number-field-schema.js 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/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/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/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/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)), ) From 2cc97886dbc81b2dbad87dd6910858099e65fa76 Mon Sep 17 00:00:00 2001 From: wanlingt <56983748+wanlingt@users.noreply.github.com> Date: Wed, 4 Oct 2023 14:39:20 +0800 Subject: [PATCH 3/4] feat: add index to myinfo child email response (#6745) * feat: add index of child to myinfo email response * feat: remove child index from getMyInfoChildHashKey * fix: remove console.log statements * fix: remove [Child] from myinfo child question name * fix: rename child field question names * fix: remove comment * feat: account for case of >1 child per field * fix: re-add childIdx to MyInfoChildHashKey for case of >1 child per field --- shared/constants/field/myinfo/index.ts | 10 +++++ src/app/modules/myinfo/myinfo.util.ts | 1 - .../submission/ParsedResponsesObject.class.ts | 10 +++++ .../email-submission.constants.ts | 1 - .../email-submission/email-submission.util.ts | 45 +++++++++++-------- .../modules/submission/submission.types.ts | 5 ++- 6 files changed, 50 insertions(+), 22 deletions(-) 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/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/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 From 7777b7bd0c83ef11175da712666b12c30efd1a95 Mon Sep 17 00:00:00 2001 From: wanlingt Date: Wed, 4 Oct 2023 14:40:39 +0800 Subject: [PATCH 4/4] chore: bump version to v6.80.0 --- CHANGELOG.md | 11 +++++++++++ frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 17 insertions(+), 6 deletions(-) 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/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/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 "