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 a274cc7b8c..07b466f4a2 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/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 } 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..04223e5e48 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/NumberInput/QuestionNumberInputForm.tsx @@ -66,10 +66,39 @@ export const QuestionNumberForm = (props: QuestionFormProps) => { unit: Yup.string(), }) ), + numberMin: Yup.number() + .typeError('Value must be a number') + .nullable(), + numberMinInclusive: Yup.bool().nullable(), + numberMaxInclusive: Yup.bool().nullable(), + numberMax: Yup.number() + .typeError('Value must be a number') + .nullable() + // We cannot do the same for numberMin because it would create a circular dependency + .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; + } + ), }), })} > - {({ 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); + }} + onBlur={(e: React.FocusEvent) => { + console.log(e.target.value); + e.target.value === '' && + setFieldValue('config.numberMinInclusive', false); + }} + /> + {(values.config as NumberInputConfig).numberMin !== null && ( + + )} + ) => { + 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 && ( + + )} { + 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; + } + ), }), })} > @@ -116,6 +145,76 @@ 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); + }} + onBlur={(e: React.FocusEvent) => { + e.target.value === '' && + formikProps.setFieldValue( + 'config.numberMinInclusive', + false + ); + }} + /> + {(formikProps.values.config as NumberInputConfig).numberMin !== + null && ( + + )} + ) => { + const value = + e.target.value === '' ? null : Number(e.target.value); + formikProps.setFieldValue('config.numberMax', value); + }} + onBlur={(e: React.FocusEvent) => { + e.target.value === '' && + formikProps.setFieldValue( + 'config.numberMaxInclusive', + false + ); + }} + /> + {(formikProps.values.config as NumberInputConfig).numberMax !== + null && ( + + )}