From c87ffe5334d221a0e9a4cfe77e55b431249390a6 Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Wed, 15 Apr 2026 14:17:59 +0100 Subject: [PATCH 1/5] feat: add max and min options to number input --- .../src/models/questionTypes/NumberInput.ts | 4 ++ .../src/resolvers/types/FieldConfig.ts | 13 ++++ apps/e2e/cypress/e2e/templatesBasic.cy.ts | 50 ++++++++++++- apps/e2e/cypress/support/template.ts | 20 ++++++ apps/e2e/cypress/types/template.d.ts | 4 ++ .../NumberInput/QuestionNumberInputForm.tsx | 71 ++++++++++++++++++- ...uestionTemplateRelationNumberInputForm.tsx | 71 +++++++++++++++++++ .../template/fragment.fieldConfig.graphql | 4 ++ validation/src/Questionary/numberInput.ts | 25 +++++++ 9 files changed, 259 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/models/questionTypes/NumberInput.ts b/apps/backend/src/models/questionTypes/NumberInput.ts index cb8f21c776..78c52702e8 100644 --- a/apps/backend/src/models/questionTypes/NumberInput.ts +++ b/apps/backend/src/models/questionTypes/NumberInput.ts @@ -40,6 +40,10 @@ export const numberInputDefinition: Question = { config.required = false; config.tooltip = ''; config.units = []; + config.numberMin = null; + config.numberMinInclusive = false; + config.numberMax = null; + config.numberMaxInclusive = false; config.readPermissions = []; return config; diff --git a/apps/backend/src/resolvers/types/FieldConfig.ts b/apps/backend/src/resolvers/types/FieldConfig.ts index b268367282..044b609d38 100644 --- a/apps/backend/src/resolvers/types/FieldConfig.ts +++ b/apps/backend/src/resolvers/types/FieldConfig.ts @@ -3,6 +3,7 @@ import { Ctx, Field, FieldResolver, + Float, Int, ObjectType, Resolver, @@ -309,6 +310,18 @@ export class NumberInputConfig extends ConfigBase { @Field(() => NumberValueConstraint, { nullable: true }) numberValueConstraint: NumberValueConstraint | null; + + @Field(() => Float, { nullable: true }) + numberMin: number | null; + + @Field(() => Boolean, { nullable: true }) + numberMinInclusive: boolean | null; + + @Field(() => Float, { nullable: true }) + numberMax: number | null; + + @Field(() => Boolean, { nullable: true }) + numberMaxInclusive: boolean | null; } @ObjectType() diff --git a/apps/e2e/cypress/e2e/templatesBasic.cy.ts b/apps/e2e/cypress/e2e/templatesBasic.cy.ts index a6aba08eae..a9941f2aa9 100644 --- a/apps/e2e/cypress/e2e/templatesBasic.cy.ts +++ b/apps/e2e/cypress/e2e/templatesBasic.cy.ts @@ -697,7 +697,7 @@ context('Template Basic tests', () => { ); }); - it('should render the Number field accepting only positive, negative numbers if set', () => { + it('should render the Number field accepting only numbers according to the config', () => { const generateId = () => `${faker.lorem.word()}_${faker.lorem.word()}_${faker.lorem.word()}`; @@ -738,6 +738,48 @@ context('Template Basic tests', () => { goodInput: '1', failureMessage: 'Value must be positive whole number', }, + { + id: generateId(), + title: faker.lorem.words(3), + fieldName: 'numberField2', + numberMin: 0, + numberMax: 10, + badInput: '10', + goodInput: '5', + failureMessage: 'Value must be less than 10', + }, + { + id: generateId(), + title: faker.lorem.words(3), + fieldName: 'numberField3', + numberMin: 0, + numberMax: 10, + badInput: '-1', + goodInput: '5', + failureMessage: 'Value must be greater than 0', + }, + { + id: generateId(), + title: faker.lorem.words(3), + fieldName: 'numberField4', + numberMin: 0, + numberMax: 10, + numberMaxInclusive: true, + badInput: '10.1', + goodInput: '10', + failureMessage: 'Value must be less than or equal to 10', + }, + { + id: generateId(), + title: faker.lorem.words(3), + fieldName: 'numberField5', + numberMin: 0, + numberMax: 10, + numberMinInclusive: true, + badInput: '-0.1', + goodInput: '0', + failureMessage: 'Value must be greater than or equal to 0', + }, ]; cy.login('officer'); @@ -751,6 +793,10 @@ context('Template Basic tests', () => { units: ['kelvin'], valueConstraint: question.valueConstraint, firstTopic: true, + numberMin: question.numberMin, + numberMax: question.numberMax, + numberMinInclusive: question.numberMinInclusive, + numberMaxInclusive: question.numberMaxInclusive, }); } @@ -2095,7 +2141,7 @@ context('Template Basic tests', () => { ); }); - it.only('User officer cannot delete referenced email template', () => { + it('User officer cannot delete referenced email template', () => { const statusActionConfig = { recipientsWithEmailTemplate: [ { diff --git a/apps/e2e/cypress/support/template.ts b/apps/e2e/cypress/support/template.ts index 4edc2921ba..b36fe60fc0 100644 --- a/apps/e2e/cypress/support/template.ts +++ b/apps/e2e/cypress/support/template.ts @@ -507,6 +507,10 @@ function createNumberInputQuestion( units?: string[]; valueConstraint?: string; firstTopic?: boolean; + numberMax?: number; + numberMaxInclusive?: boolean; + numberMin?: number; + numberMinInclusive?: boolean; } ) { openQuestionsMenu({ @@ -533,6 +537,22 @@ function createNumberInputQuestion( cy.contains(options?.valueConstraint).click(); } + if (options?.numberMin !== undefined) { + cy.get('[data-cy="numberMin"]').clear().type(options.numberMin.toString()); + + if (options.numberMinInclusive) { + cy.get('[data-cy="numberMinInclusive"]').click(); + } + } + + if (options?.numberMax !== undefined) { + cy.get('[data-cy="numberMax"]').clear().type(options.numberMax.toString()); + + if (options.numberMaxInclusive) { + cy.get('[data-cy="numberMaxInclusive"]').click(); + } + } + cy.contains('Save').click(); cy.contains(question) diff --git a/apps/e2e/cypress/types/template.d.ts b/apps/e2e/cypress/types/template.d.ts index b06a42f2ae..5e16ceb86a 100644 --- a/apps/e2e/cypress/types/template.d.ts +++ b/apps/e2e/cypress/types/template.d.ts @@ -221,6 +221,10 @@ declare global { units?: string[]; valueConstraint?: string; firstTopic?: boolean; + numberMax?: number; + numberMaxInclusive?: boolean; + numberMin?: number; + numberMinInclusive?: boolean; } ) => void; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx index 5573cba464..0b3140ae35 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx @@ -66,10 +66,25 @@ export const QuestionNumberForm = (props: QuestionFormProps) => { unit: Yup.string(), }) ), + numberMin: Yup.number() + .typeError('Minimum score must be a number') + .nullable(), + numberMax: Yup.number() + .typeError('Maximum score must be a number') + .nullable() + // We cannot do the same for numberMin because it would create a circular dependency + .when('numberMin', (numberMin, schema) => { + return numberMin !== undefined + ? schema.moreThan( + numberMin, + 'Maximum must be strictly greater than Minimum' + ) + : schema; + }), }), })} > - {({ setFieldValue }) => ( + {({ setFieldValue, setFieldTouched, values }) => ( <> { }, ]} /> + ) => { + const value = + e.target.value === '' ? null : Number(e.target.value); + setFieldValue('config.numberMin', value); + //Trigger validation for numberMax when numberMin changes + setFieldTouched('config.numberMax', true, true); + }} + /> + {(values.config as NumberInputConfig).numberMin !== null && ( + + )} + ) => { + const value = + e.target.value === '' ? null : Number(e.target.value); + setFieldValue('config.numberMax', value); + }} + /> + {(values.config as NumberInputConfig).numberMax !== null && ( + + )} { + return numberMin !== null + ? schema.moreThan( + numberMin, + 'Maximum must be strictly greater than Minimum' + ) + : schema; + }), }), })} > @@ -116,6 +131,62 @@ export const QuestionTemplateRelationNumberForm = ( }, ]} /> + ) => { + const value = + e.target.value === '' ? null : Number(e.target.value); + formikProps.setFieldValue('config.numberMin', value); + //Trigger validation for numberMax when numberMin changes + formikProps.setFieldTouched('config.numberMax', true, true); + }} + /> + {(formikProps.values.config as NumberInputConfig).numberMin !== + null && ( + + )} + ) => { + const value = + e.target.value === '' ? null : Number(e.target.value); + formikProps.setFieldValue('config.numberMax', value); + }} + /> + {(formikProps.values.config as NumberInputConfig).numberMax !== + null && ( + + )} Date: Wed, 15 Apr 2026 15:02:13 +0100 Subject: [PATCH 2/5] update export test --- .../e2e/cypress/fixtures/template_export.json | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/e2e/cypress/fixtures/template_export.json b/apps/e2e/cypress/fixtures/template_export.json index 6d941a3cd5..51f43d8e65 100644 --- a/apps/e2e/cypress/fixtures/template_export.json +++ b/apps/e2e/cypress/fixtures/template_export.json @@ -1,7 +1,7 @@ { "metadata": { "version": "1.2.0", - "exportDate": "2025-10-17T11:23:20.021Z" + "exportDate": "2026-04-15T14:00:17.520Z" }, "data": { "template": { @@ -119,6 +119,10 @@ "siConversionFormula": "x / 100" } ], + "numberMin": null, + "numberMinInclusive": false, + "numberMax": null, + "numberMaxInclusive": false, "readPermissions": [], "numberValueConstraint": null } @@ -145,6 +149,10 @@ "siConversionFormula": "x / 100" } ], + "numberMin": null, + "numberMinInclusive": false, + "numberMax": null, + "numberMaxInclusive": false, "readPermissions": [], "numberValueConstraint": null }, @@ -601,6 +609,10 @@ "siConversionFormula": "x / 100" } ], + "numberMin": null, + "numberMinInclusive": false, + "numberMax": null, + "numberMaxInclusive": false, "readPermissions": [], "numberValueConstraint": null } @@ -1015,6 +1027,10 @@ "siConversionFormula": "x / 100" } ], + "numberMin": null, + "numberMinInclusive": false, + "numberMax": null, + "numberMaxInclusive": false, "readPermissions": [], "numberValueConstraint": null } @@ -1041,6 +1057,10 @@ "siConversionFormula": "x / 100" } ], + "numberMin": null, + "numberMinInclusive": false, + "numberMax": null, + "numberMaxInclusive": false, "readPermissions": [], "numberValueConstraint": null }, @@ -1303,6 +1323,10 @@ "siConversionFormula": "x / 100" } ], + "numberMin": null, + "numberMinInclusive": false, + "numberMax": null, + "numberMaxInclusive": false, "readPermissions": [], "numberValueConstraint": null } From ad030179c0d708cb5dc7682cf84e296ba4374dda Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Mon, 18 May 2026 16:56:23 +0100 Subject: [PATCH 3/5] Fix validation --- .../NumberInput/QuestionNumberInputForm.tsx | 34 +++++++++++++------ ...uestionTemplateRelationNumberInputForm.tsx | 32 ++++++++++++----- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx index 0b3140ae35..7a7c3853cd 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx @@ -67,20 +67,34 @@ export const QuestionNumberForm = (props: QuestionFormProps) => { }) ), numberMin: Yup.number() - .typeError('Minimum score must be a number') + .typeError('Value must be a number') .nullable(), + numberMinInclusive: Yup.bool().nullable(), + numberMaxInclusive: Yup.bool().nullable(), numberMax: Yup.number() - .typeError('Maximum score must be a number') + .typeError('Value must be a number') .nullable() // We cannot do the same for numberMin because it would create a circular dependency - .when('numberMin', (numberMin, schema) => { - return numberMin !== undefined - ? schema.moreThan( - numberMin, - 'Maximum must be strictly greater than Minimum' - ) - : schema; - }), + .when( + ['numberMin', 'numberMaxInclusive', 'numberMinInclusive'], + ([numberMin, numberMaxInclusive, numberMinInclusive], schema) => { + if (numberMin !== undefined) { + if (numberMinInclusive && numberMaxInclusive) { + return schema.min( + numberMin, + 'Maximum must be greater than or equal to Minimum' + ); + } else { + return schema.moreThan( + numberMin, + 'Maximum must be strictly greater than Minimum' + ); + } + } + + return schema; + } + ), }), })} > diff --git a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionTemplateRelationNumberInputForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionTemplateRelationNumberInputForm.tsx index 4706b4e8fa..3ec20280d0 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionTemplateRelationNumberInputForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionTemplateRelationNumberInputForm.tsx @@ -41,18 +41,32 @@ export const QuestionTemplateRelationNumberForm = ( numberMin: Yup.number() .typeError('Minimum score must be a number') .nullable(), + numberMinInclusive: Yup.bool().nullable(), + numberMaxInclusive: Yup.bool().nullable(), numberMax: Yup.number() - .typeError('Maximum score must be a number') + .typeError('Value must be a number') .nullable() // We cannot do the same for numberMin because it would create a circular dependency - .when('numberMin', (numberMin, schema) => { - return numberMin !== null - ? schema.moreThan( - numberMin, - 'Maximum must be strictly greater than Minimum' - ) - : schema; - }), + .when( + ['numberMin', 'numberMaxInclusive', 'numberMinInclusive'], + ([numberMin, numberMaxInclusive, numberMinInclusive], schema) => { + if (numberMin !== undefined) { + if (numberMinInclusive && numberMaxInclusive) { + return schema.min( + numberMin, + 'Maximum must be greater than or equal to Minimum' + ); + } else { + return schema.moreThan( + numberMin, + 'Maximum must be strictly greater than Minimum' + ); + } + } + + return schema; + } + ), }), })} > From 627286f68c4d99567b07a8be3307dfd4dbdad55a Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Tue, 26 May 2026 15:59:16 +0100 Subject: [PATCH 4/5] update inclusive var when max and min arnt set --- .../NumberInput/QuestionNumberInputForm.tsx | 14 +++++++---- ...uestionTemplateRelationNumberInputForm.tsx | 24 +++++++++++++++---- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx index 7a7c3853cd..9dbbf861a3 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx @@ -224,9 +224,11 @@ export const QuestionNumberForm = (props: QuestionFormProps) => { fullWidth inputProps={{ 'data-cy': 'numberMin' }} onChange={(e: React.ChangeEvent) => { - const value = - e.target.value === '' ? null : Number(e.target.value); + const minIsNull = e.target.value === ''; + const value = minIsNull ? null : Number(e.target.value); setFieldValue('config.numberMin', value); + + minIsNull && setFieldValue('config.numberMinInclusive', false); //Trigger validation for numberMax when numberMin changes setFieldTouched('config.numberMax', true, true); }} @@ -252,8 +254,12 @@ export const QuestionNumberForm = (props: QuestionFormProps) => { fullWidth inputProps={{ 'data-cy': 'numberMax' }} onChange={(e: React.ChangeEvent) => { - const value = - e.target.value === '' ? null : Number(e.target.value); + const maxIsNull = e.target.value === ''; + const value = maxIsNull ? null : Number(e.target.value); + setFieldValue('config.numberMax', value); + + maxIsNull && setFieldValue('config.numberMaxInclusive', false); + setFieldValue('config.numberMax', value); }} /> diff --git a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionTemplateRelationNumberInputForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionTemplateRelationNumberInputForm.tsx index 3ec20280d0..9d63d59a81 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionTemplateRelationNumberInputForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionTemplateRelationNumberInputForm.tsx @@ -39,7 +39,7 @@ export const QuestionTemplateRelationNumberForm = ( }) ), numberMin: Yup.number() - .typeError('Minimum score must be a number') + .typeError('Value must be a number') .nullable(), numberMinInclusive: Yup.bool().nullable(), numberMaxInclusive: Yup.bool().nullable(), @@ -154,9 +154,15 @@ export const QuestionTemplateRelationNumberForm = ( fullWidth inputProps={{ 'data-cy': 'numberMin' }} onChange={(e: React.ChangeEvent) => { - const value = - e.target.value === '' ? null : Number(e.target.value); + const minIsNull = e.target.value === ''; + const value = minIsNull ? null : Number(e.target.value); formikProps.setFieldValue('config.numberMin', value); + + minIsNull && + formikProps.setFieldValue( + 'config.numberMinInclusive', + false + ); //Trigger validation for numberMax when numberMin changes formikProps.setFieldTouched('config.numberMax', true, true); }} @@ -183,8 +189,16 @@ export const QuestionTemplateRelationNumberForm = ( fullWidth inputProps={{ 'data-cy': 'numberMax' }} onChange={(e: React.ChangeEvent) => { - const value = - e.target.value === '' ? null : Number(e.target.value); + const maxIsNull = e.target.value === ''; + const value = maxIsNull ? null : Number(e.target.value); + formikProps.setFieldValue('config.numberMax', value); + + maxIsNull && + formikProps.setFieldValue( + 'config.numberMaxInclusive', + false + ); + formikProps.setFieldValue('config.numberMax', value); }} /> From 252f8c3e4073e55be47b64b9bd707fd8a51d4d68 Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Tue, 26 May 2026 16:26:24 +0100 Subject: [PATCH 5/5] use on blur on not change --- .../NumberInput/QuestionNumberInputForm.tsx | 23 ++++++++++-------- ...uestionTemplateRelationNumberInputForm.tsx | 24 +++++++++---------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx index 9dbbf861a3..04223e5e48 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx @@ -224,14 +224,17 @@ export const QuestionNumberForm = (props: QuestionFormProps) => { fullWidth inputProps={{ 'data-cy': 'numberMin' }} onChange={(e: React.ChangeEvent) => { - const minIsNull = e.target.value === ''; - const value = minIsNull ? null : Number(e.target.value); + const value = + e.target.value === '' ? null : Number(e.target.value); setFieldValue('config.numberMin', value); - - minIsNull && setFieldValue('config.numberMinInclusive', false); //Trigger validation for numberMax when numberMin changes setFieldTouched('config.numberMax', true, true); }} + onBlur={(e: React.FocusEvent) => { + console.log(e.target.value); + e.target.value === '' && + setFieldValue('config.numberMinInclusive', false); + }} /> {(values.config as NumberInputConfig).numberMin !== null && ( { fullWidth inputProps={{ 'data-cy': 'numberMax' }} onChange={(e: React.ChangeEvent) => { - const maxIsNull = e.target.value === ''; - const value = maxIsNull ? null : Number(e.target.value); - setFieldValue('config.numberMax', value); - - maxIsNull && setFieldValue('config.numberMaxInclusive', false); - + const value = + e.target.value === '' ? null : Number(e.target.value); setFieldValue('config.numberMax', value); }} + onBlur={(e: React.FocusEvent) => { + e.target.value === '' && + setFieldValue('config.numberMaxInclusive', false); + }} /> {(values.config as NumberInputConfig).numberMax !== null && ( ) => { - const minIsNull = e.target.value === ''; - const value = minIsNull ? null : Number(e.target.value); + const value = + e.target.value === '' ? null : Number(e.target.value); formikProps.setFieldValue('config.numberMin', value); - - minIsNull && + //Trigger validation for numberMax when numberMin changes + formikProps.setFieldTouched('config.numberMax', true, true); + }} + onBlur={(e: React.FocusEvent) => { + e.target.value === '' && formikProps.setFieldValue( 'config.numberMinInclusive', false ); - //Trigger validation for numberMax when numberMin changes - formikProps.setFieldTouched('config.numberMax', true, true); }} /> {(formikProps.values.config as NumberInputConfig).numberMin !== @@ -189,17 +190,16 @@ export const QuestionTemplateRelationNumberForm = ( fullWidth inputProps={{ 'data-cy': 'numberMax' }} onChange={(e: React.ChangeEvent) => { - const maxIsNull = e.target.value === ''; - const value = maxIsNull ? null : Number(e.target.value); + const value = + e.target.value === '' ? null : Number(e.target.value); formikProps.setFieldValue('config.numberMax', value); - - maxIsNull && + }} + onBlur={(e: React.FocusEvent) => { + e.target.value === '' && formikProps.setFieldValue( 'config.numberMaxInclusive', false ); - - formikProps.setFieldValue('config.numberMax', value); }} /> {(formikProps.values.config as NumberInputConfig).numberMax !==