diff --git a/CHANGELOG.md b/CHANGELOG.md index e6795a93f8..35e5e1be93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add global variables for pairwise component. `GLOBAL_PARENT1_PRENOM`, `GLOBAL_PARENT1_SEXE`, `GLOBAL_PARENT2_PRENOM`, `GLOBAL_PARENT2_SEXE`, `GLOBAL_CONJOINT_PRENOM` and `GLOBAL_ENFANTS_PRENOMS` are now available if the corresponding source variables of the pairwise component are filled in. +- Radio, Dropdown and CheckboxOne can now have options based on a variable by specifying `optionSource` and filtered through `optionFilter`. ## [3.7.7](https://github.com/InseeFr/Lunatic/releases/tag/3.7.7) - 2026-01-14 diff --git a/lunatic-schema.json b/lunatic-schema.json index f9eb5cbbda..b3fa2cc4ea 100644 --- a/lunatic-schema.json +++ b/lunatic-schema.json @@ -224,7 +224,6 @@ "rules": { "type": "object", "additionalProperties": { - "type": "object", "$ref": "#/$defs/VTLExpression" } } @@ -238,7 +237,6 @@ "rules": { "type": "object", "additionalProperties": { - "type": "object", "$ref": "#/$defs/VTLExpression" } } @@ -952,16 +950,21 @@ "orientation": { "type": "string", "enum": ["horizontal", "vertical"] - }, - "options": { - "$ref": "#/$defs/OptionsWithDetail" } }, - "required": ["componentType", "options"], + "required": ["componentType"], "allOf": [ { "$ref": "#/$defs/ComponentDefinitionBaseWithResponse" } + ], + "oneOf": [ + { + "$ref": "#/$defs/StaticOptions" + }, + { + "$ref": "#/$defs/OptionsFromVariable" + } ] }, "ComponentDropdownDefinition": { @@ -970,16 +973,21 @@ "componentType": { "type": "string", "const": "Dropdown" - }, - "options": { - "$ref": "#/$defs/Options" } }, - "required": ["componentType", "options"], + "required": ["componentType"], "allOf": [ { "$ref": "#/$defs/ComponentDefinitionBaseWithResponse" } + ], + "oneOf": [ + { + "$ref": "#/$defs/StaticOptions" + }, + { + "$ref": "#/$defs/OptionsFromVariable" + } ] }, "ComponentQuestionDefinition": { @@ -1009,16 +1017,21 @@ "componentType": { "type": "string", "const": "CheckboxOne" - }, - "options": { - "$ref": "#/$defs/OptionsWithDetail" } }, - "required": ["componentType", "options"], + "required": ["componentType"], "allOf": [ { "$ref": "#/$defs/ComponentDefinitionBaseWithResponse" } + ], + "oneOf": [ + { + "$ref": "#/$defs/StaticOptions" + }, + { + "$ref": "#/$defs/OptionsFromVariable" + } ] }, "ComponentSuggesterDefinition": { @@ -1391,6 +1404,27 @@ ], "type": "object" }, + "StaticOptions": { + "type": "object", + "properties": { + "options": { + "$ref": "#/$defs/OptionsWithDetail" + } + }, + "required": ["options"] + }, + "OptionsFromVariable": { + "type": "object", + "properties": { + "optionSource": { + "type": "string" + }, + "optionFilter": { + "$ref": "#/$defs/VTLExpression" + } + }, + "required": ["optionSource"] + }, "Options": { "type": "array", "items": { diff --git a/package.json b/package.json index 1796daa68e..3a73c4b24b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inseefr/lunatic", - "version": "3.11.2", + "version": "3.12.0-rc.ucq-options-variable.0", "description": "Library of questionnaire components", "repository": { "type": "git", diff --git a/src/stories/checkbox/checkbox.stories.tsx b/src/stories/checkbox/checkbox.stories.tsx index 25a61d3d6d..205fd7f854 100644 --- a/src/stories/checkbox/checkbox.stories.tsx +++ b/src/stories/checkbox/checkbox.stories.tsx @@ -1,3 +1,4 @@ +import { dataFromObject } from '../../utils/object'; import { type Orchestrator, OrchestratorMeta, @@ -10,6 +11,7 @@ import sourceGroupDetail from './sourceGroupDetail.json'; import sourceGroupLoop from './sourceGroupLoop.json'; import sourceOne from './sourceOne.json'; import sourceOneDetail from './sourceOneDetail.json'; +import sourceOneDynamicOptions from './sourceOneDynamicOptions.json'; import { Meta } from '@storybook/react'; @@ -73,3 +75,14 @@ export const CheckboxOneWithDetail: OrchestratorStory = { source: sourceOneDetail, }, }; + +export const CheckboxOneDynamicOptions: OrchestratorStory = { + args: { + source: sourceOneDynamicOptions, + data: dataFromObject({ + NBHAB: 3, + PRENOM: ['Verso', 'Maëlle', 'Aline'], + AGE: [30, 16, 50], + }), + }, +}; diff --git a/src/stories/checkbox/sourceOneDynamicOptions.json b/src/stories/checkbox/sourceOneDynamicOptions.json new file mode 100644 index 0000000000..6f6ccfc491 --- /dev/null +++ b/src/stories/checkbox/sourceOneDynamicOptions.json @@ -0,0 +1,496 @@ +{ + "id": "mkb7cgp3", + "label": { + "type": "VTL|MD", + "value": "feat: QCU basé sur une variable - QR" + }, + "modele": "FEATQCUBAS", + "maxPage": "7", + "cleaning": {}, + "resizing": { + "NBHAB": { + "size": "NBHAB", + "variables": ["PRENOM", "AGE"] + }, + "PRENOM": { + "size": "count(PRENOM)", + "variables": ["QCUDANSBOU"] + } + }, + "variables": [ + { + "name": "NBHAB", + "values": { + "COLLECTED": null + }, + "dimension": 0, + "variableType": "COLLECTED" + }, + { + "name": "PRENOM", + "values": { + "COLLECTED": [] + }, + "dimension": 1, + "variableType": "COLLECTED", + "iterationReference": "mkb798x7" + }, + { + "name": "AGE", + "values": { + "COLLECTED": [] + }, + "dimension": 1, + "variableType": "COLLECTED", + "iterationReference": "mkb798x7" + }, + { + "name": "QCUHORSBOU", + "values": { + "COLLECTED": null + }, + "dimension": 0, + "variableType": "COLLECTED" + }, + { + "name": "QCUDANSBOU", + "values": { + "COLLECTED": [] + }, + "dimension": 1, + "variableType": "COLLECTED", + "iterationReference": "mkb798x7" + }, + { + "name": "FILTER_RESULT_NBHAB", + "dimension": 0, + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true + }, + { + "name": "FILTER_RESULT_PRENOM", + "dimension": 1, + "shapeFrom": ["PRENOM", "AGE"], + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true, + "iterationReference": "mkb798x7" + }, + { + "name": "FILTER_RESULT_AGE", + "dimension": 1, + "shapeFrom": ["PRENOM", "AGE"], + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true, + "iterationReference": "mkb798x7" + }, + { + "name": "FILTER_RESULT_QCUHORSBOU", + "dimension": 0, + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true + }, + { + "name": "FILTER_RESULT_QCUDANSBOU", + "dimension": 1, + "shapeFrom": ["PRENOM", "AGE"], + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true, + "iterationReference": "mkb798x7" + } + ], + "components": [ + { + "id": "mkb6zd6d", + "page": "1", + "label": { + "type": "VTL", + "value": "\"I - \" || \"S1\"" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "question-mkb7hxy8", + "page": "2", + "label": { + "type": "VTL|MD", + "value": "\"Nombre d'habitants\"" + }, + "components": [ + { + "id": "mkb7hxy8", + "max": 10.0, + "min": 0.0, + "page": "2", + "controls": [ + { + "id": "mkb7hxy8-format-borne-inf-sup", + "type": "SIMPLE", + "control": { + "type": "VTL", + "value": "not(not(isnull(NBHAB)) and (0>NBHAB or 10NBHAB)" + }, + "criticality": "ERROR", + "errorMessage": { + "type": "VTL|MD", + "value": "\"Le nombre doit comporter au maximum 0 chiffre(s) après la virgule\"" + }, + "typeOfControl": "FORMAT" + } + ], + "decimals": 0, + "response": { + "name": "NBHAB" + }, + "description": { + "type": "TXT", + "value": "Format attendu : un nombre entre 0 et 10" + }, + "isMandatory": false, + "componentType": "InputNumber" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "mkb798x7", + "page": "3", + "depth": 1, + "lines": { + "max": { + "type": "VTL", + "value": "NBHAB" + }, + "min": { + "type": "VTL", + "value": "NBHAB" + } + }, + "components": [ + { + "id": "mkb6z1pv", + "page": "3", + "label": { + "type": "VTL", + "value": "\"II - \" || \"THL\"", + "shapeFrom": "PRENOM" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true", + "shapeFrom": "PRENOM" + } + }, + { + "id": "question-mkb7180z", + "page": "3", + "label": { + "type": "VTL|MD", + "value": "\"Prénom\"" + }, + "components": [ + { + "id": "mkb7180z", + "page": "3", + "response": { + "name": "PRENOM" + }, + "maxLength": 249, + "isMandatory": false, + "componentType": "Input" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "question-mkb7gox2", + "page": "3", + "label": { + "type": "VTL|MD", + "value": "\"Âge\"" + }, + "components": [ + { + "id": "mkb7gox2", + "max": 100.0, + "min": 0.0, + "page": "3", + "controls": [ + { + "id": "mkb7gox2-format-borne-inf-sup", + "type": "SIMPLE", + "control": { + "type": "VTL", + "value": "not(not(isnull(AGE)) and (0>AGE or 100AGE)" + }, + "criticality": "ERROR", + "errorMessage": { + "type": "VTL|MD", + "value": "\"Le nombre doit comporter au maximum 0 chiffre(s) après la virgule\"" + }, + "typeOfControl": "FORMAT" + } + ], + "decimals": 0, + "response": { + "name": "AGE" + }, + "description": { + "type": "TXT", + "value": "Format attendu : un nombre entre 0 et 100" + }, + "isMandatory": false, + "componentType": "InputNumber" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + } + ], + "componentType": "Loop", + "paginatedLoop": false, + "conditionFilter": { + "type": "VTL", + "value": "(true)" + }, + "loopDependencies": ["NBHAB"], + "isPaginatedByIterations": false + }, + { + "id": "mkb7ed5t", + "page": "4", + "label": { + "type": "VTL", + "value": "\"III - \" || \"S2\"" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "question-mkb7ktxx", + "page": "5", + "label": { + "type": "VTL|MD", + "value": "\"QCU hors boucle\"" + }, + "components": [ + { + "id": "mkb7ktxx", + "page": "5", + "optionSource": "PRENOM", + "optionFilter": { + "type": "VTL", + "value": "AGE >= 18", + "shapeFrom": "PRENOM" + }, + "response": { + "name": "QCUHORSBOU" + }, + "isMandatory": false, + "orientation": "vertical", + "componentType": "CheckboxOne" + } + ], + "declarations": [ + { + "id": "mke55sef", + "label": { + "type": "VTL|MD", + "value": "\"Les modalités de réponse sont les prénoms des individus ayant 18 ans ou plus\"" + }, + "position": "AFTER_QUESTION_TEXT", + "declarationType": "HELP" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "mkb71vg8", + "page": "6", + "depth": 1, + "maxPage": "2", + "components": [ + { + "id": "mkb76y5x", + "page": "6.1", + "label": { + "type": "VTL", + "value": "\"IV - \" || \"S3\"", + "shapeFrom": "PRENOM" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true", + "shapeFrom": "PRENOM" + } + }, + { + "id": "question-mkb73lyz", + "page": "6.2", + "label": { + "type": "VTL|MD", + "value": "\"QCU dans boucle\"" + }, + "components": [ + { + "id": "mkb73lyz", + "page": "6.2", + "optionSource": "PRENOM", + "optionFilter": { + "type": "VTL", + "value": "AGE >= 18", + "shapeFrom": "PRENOM" + }, + "response": { + "name": "QCUDANSBOU" + }, + "isMandatory": false, + "orientation": "vertical", + "componentType": "CheckboxOne" + } + ], + "declarations": [ + { + "id": "mke5bdkv", + "label": { + "type": "VTL|MD", + "value": "\"Les modalités de réponse sont les prénoms des individus ayant 18 ans ou plus\"" + }, + "position": "AFTER_QUESTION_TEXT", + "declarationType": "HELP" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + } + ], + "iterations": { + "type": "VTL", + "value": "count(PRENOM)" + }, + "componentType": "Loop", + "paginatedLoop": true, + "conditionFilter": { + "type": "VTL", + "value": "(true)" + }, + "loopDependencies": ["PRENOM"] + }, + { + "id": "mkb7iuwp", + "page": "7", + "label": { + "type": "VTL", + "value": "\"V - \" || \"S4 - Fin\"" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + } + ], + "pagination": "question", + "suggesters": [ + { + "name": "L_DIPLOMES-2-1-0", + "fields": [ + { + "min": 3, + "name": "label", + "rules": ["[\\w-]+"], + "stemmer": false, + "language": "French" + } + ], + "version": 1, + "queryParser": { + "type": "tokenized", + "params": { + "min": 3, + "pattern": "[\\w.-]+", + "stemmer": false, + "language": "French" + } + } + } + ], + "componentType": "Questionnaire", + "enoCoreVersion": "3.59.0", + "generatingDate": "14-01-2026 14:51:23", + "lunaticModelVersion": "5.11.0" +} diff --git a/src/stories/dropdown/dropdown.stories.tsx b/src/stories/dropdown/dropdown.stories.tsx index c685db1a4a..57f435820c 100644 --- a/src/stories/dropdown/dropdown.stories.tsx +++ b/src/stories/dropdown/dropdown.stories.tsx @@ -4,6 +4,7 @@ import { type OrchestratorStory, } from '../utils/Orchestrator'; import source from './source.json'; +import sourceDynamicOptions from './sourceDynamicOptions.json'; import { Meta } from '@storybook/react'; @@ -25,3 +26,14 @@ export const Default: OrchestratorStory = { }), }, }; + +export const DynamicOptions: OrchestratorStory = { + args: { + source: sourceDynamicOptions, + data: dataFromObject({ + NBHAB: 3, + PRENOM: ['Verso', 'Maëlle', 'Aline'], + AGE: [30, 16, 50], + }), + }, +}; diff --git a/src/stories/dropdown/sourceDynamicOptions.json b/src/stories/dropdown/sourceDynamicOptions.json new file mode 100644 index 0000000000..8ab1b353e3 --- /dev/null +++ b/src/stories/dropdown/sourceDynamicOptions.json @@ -0,0 +1,496 @@ +{ + "id": "mkb7cgp3", + "label": { + "type": "VTL|MD", + "value": "feat: QCU basé sur une variable - QR" + }, + "modele": "FEATQCUBAS", + "maxPage": "7", + "cleaning": {}, + "resizing": { + "NBHAB": { + "size": "NBHAB", + "variables": ["PRENOM", "AGE"] + }, + "PRENOM": { + "size": "count(PRENOM)", + "variables": ["QCUDANSBOU"] + } + }, + "variables": [ + { + "name": "NBHAB", + "values": { + "COLLECTED": null + }, + "dimension": 0, + "variableType": "COLLECTED" + }, + { + "name": "PRENOM", + "values": { + "COLLECTED": [] + }, + "dimension": 1, + "variableType": "COLLECTED", + "iterationReference": "mkb798x7" + }, + { + "name": "AGE", + "values": { + "COLLECTED": [] + }, + "dimension": 1, + "variableType": "COLLECTED", + "iterationReference": "mkb798x7" + }, + { + "name": "QCUHORSBOU", + "values": { + "COLLECTED": null + }, + "dimension": 0, + "variableType": "COLLECTED" + }, + { + "name": "QCUDANSBOU", + "values": { + "COLLECTED": [] + }, + "dimension": 1, + "variableType": "COLLECTED", + "iterationReference": "mkb798x7" + }, + { + "name": "FILTER_RESULT_NBHAB", + "dimension": 0, + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true + }, + { + "name": "FILTER_RESULT_PRENOM", + "dimension": 1, + "shapeFrom": ["PRENOM", "AGE"], + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true, + "iterationReference": "mkb798x7" + }, + { + "name": "FILTER_RESULT_AGE", + "dimension": 1, + "shapeFrom": ["PRENOM", "AGE"], + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true, + "iterationReference": "mkb798x7" + }, + { + "name": "FILTER_RESULT_QCUHORSBOU", + "dimension": 0, + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true + }, + { + "name": "FILTER_RESULT_QCUDANSBOU", + "dimension": 1, + "shapeFrom": ["PRENOM", "AGE"], + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true, + "iterationReference": "mkb798x7" + } + ], + "components": [ + { + "id": "mkb6zd6d", + "page": "1", + "label": { + "type": "VTL", + "value": "\"I - \" || \"S1\"" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "question-mkb7hxy8", + "page": "2", + "label": { + "type": "VTL|MD", + "value": "\"Nombre d'habitants\"" + }, + "components": [ + { + "id": "mkb7hxy8", + "max": 10.0, + "min": 0.0, + "page": "2", + "controls": [ + { + "id": "mkb7hxy8-format-borne-inf-sup", + "type": "SIMPLE", + "control": { + "type": "VTL", + "value": "not(not(isnull(NBHAB)) and (0>NBHAB or 10NBHAB)" + }, + "criticality": "ERROR", + "errorMessage": { + "type": "VTL|MD", + "value": "\"Le nombre doit comporter au maximum 0 chiffre(s) après la virgule\"" + }, + "typeOfControl": "FORMAT" + } + ], + "decimals": 0, + "response": { + "name": "NBHAB" + }, + "description": { + "type": "TXT", + "value": "Format attendu : un nombre entre 0 et 10" + }, + "isMandatory": false, + "componentType": "InputNumber" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "mkb798x7", + "page": "3", + "depth": 1, + "lines": { + "max": { + "type": "VTL", + "value": "NBHAB" + }, + "min": { + "type": "VTL", + "value": "NBHAB" + } + }, + "components": [ + { + "id": "mkb6z1pv", + "page": "3", + "label": { + "type": "VTL", + "value": "\"II - \" || \"THL\"", + "shapeFrom": "PRENOM" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true", + "shapeFrom": "PRENOM" + } + }, + { + "id": "question-mkb7180z", + "page": "3", + "label": { + "type": "VTL|MD", + "value": "\"Prénom\"" + }, + "components": [ + { + "id": "mkb7180z", + "page": "3", + "response": { + "name": "PRENOM" + }, + "maxLength": 249, + "isMandatory": false, + "componentType": "Input" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "question-mkb7gox2", + "page": "3", + "label": { + "type": "VTL|MD", + "value": "\"Âge\"" + }, + "components": [ + { + "id": "mkb7gox2", + "max": 100.0, + "min": 0.0, + "page": "3", + "controls": [ + { + "id": "mkb7gox2-format-borne-inf-sup", + "type": "SIMPLE", + "control": { + "type": "VTL", + "value": "not(not(isnull(AGE)) and (0>AGE or 100AGE)" + }, + "criticality": "ERROR", + "errorMessage": { + "type": "VTL|MD", + "value": "\"Le nombre doit comporter au maximum 0 chiffre(s) après la virgule\"" + }, + "typeOfControl": "FORMAT" + } + ], + "decimals": 0, + "response": { + "name": "AGE" + }, + "description": { + "type": "TXT", + "value": "Format attendu : un nombre entre 0 et 100" + }, + "isMandatory": false, + "componentType": "InputNumber" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + } + ], + "componentType": "Loop", + "paginatedLoop": false, + "conditionFilter": { + "type": "VTL", + "value": "(true)" + }, + "loopDependencies": ["NBHAB"], + "isPaginatedByIterations": false + }, + { + "id": "mkb7ed5t", + "page": "4", + "label": { + "type": "VTL", + "value": "\"III - \" || \"S2\"" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "question-mkb7ktxx", + "page": "5", + "label": { + "type": "VTL|MD", + "value": "\"QCU hors boucle\"" + }, + "components": [ + { + "id": "mkb7ktxx", + "page": "5", + "optionSource": "PRENOM", + "optionFilter": { + "type": "VTL", + "value": "AGE >= 18", + "shapeFrom": "PRENOM" + }, + "response": { + "name": "QCUHORSBOU" + }, + "isMandatory": false, + "orientation": "vertical", + "componentType": "Dropdown" + } + ], + "declarations": [ + { + "id": "mke55sef", + "label": { + "type": "VTL|MD", + "value": "\"Les modalités de réponse sont les prénoms des individus ayant 18 ans ou plus\"" + }, + "position": "AFTER_QUESTION_TEXT", + "declarationType": "HELP" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "mkb71vg8", + "page": "6", + "depth": 1, + "maxPage": "2", + "components": [ + { + "id": "mkb76y5x", + "page": "6.1", + "label": { + "type": "VTL", + "value": "\"IV - \" || \"S3\"", + "shapeFrom": "PRENOM" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true", + "shapeFrom": "PRENOM" + } + }, + { + "id": "question-mkb73lyz", + "page": "6.2", + "label": { + "type": "VTL|MD", + "value": "\"QCU dans boucle\"" + }, + "components": [ + { + "id": "mkb73lyz", + "page": "6.2", + "optionSource": "PRENOM", + "optionFilter": { + "type": "VTL", + "value": "AGE >= 18", + "shapeFrom": "PRENOM" + }, + "response": { + "name": "QCUDANSBOU" + }, + "isMandatory": false, + "orientation": "vertical", + "componentType": "Dropdown" + } + ], + "declarations": [ + { + "id": "mke5bdkv", + "label": { + "type": "VTL|MD", + "value": "\"Les modalités de réponse sont les prénoms des individus ayant 18 ans ou plus\"" + }, + "position": "AFTER_QUESTION_TEXT", + "declarationType": "HELP" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + } + ], + "iterations": { + "type": "VTL", + "value": "count(PRENOM)" + }, + "componentType": "Loop", + "paginatedLoop": true, + "conditionFilter": { + "type": "VTL", + "value": "(true)" + }, + "loopDependencies": ["PRENOM"] + }, + { + "id": "mkb7iuwp", + "page": "7", + "label": { + "type": "VTL", + "value": "\"V - \" || \"S4 - Fin\"" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + } + ], + "pagination": "question", + "suggesters": [ + { + "name": "L_DIPLOMES-2-1-0", + "fields": [ + { + "min": 3, + "name": "label", + "rules": ["[\\w-]+"], + "stemmer": false, + "language": "French" + } + ], + "version": 1, + "queryParser": { + "type": "tokenized", + "params": { + "min": 3, + "pattern": "[\\w.-]+", + "stemmer": false, + "language": "French" + } + } + } + ], + "componentType": "Questionnaire", + "enoCoreVersion": "3.59.0", + "generatingDate": "14-01-2026 14:51:23", + "lunaticModelVersion": "5.11.0" +} diff --git a/src/stories/radio/radio.stories.tsx b/src/stories/radio/radio.stories.tsx index ea082940ba..1d50a46896 100644 --- a/src/stories/radio/radio.stories.tsx +++ b/src/stories/radio/radio.stories.tsx @@ -7,8 +7,10 @@ import source from './source.json'; import sourceHorizontal from './sourceHorizontal.json'; import sourceDetail from './sourceDetail.json'; import sourceCondition from './sourceCondition.json'; +import sourceDynamicOptions from './sourceDynamicOptions.json'; import { Meta } from '@storybook/react'; +import { dataFromObject } from '../../utils/object'; const meta: Meta = { title: 'Components/Radio', @@ -51,3 +53,14 @@ export const WithDetail: OrchestratorStory = { source: sourceDetail, }, }; + +export const DynamicOptions: OrchestratorStory = { + args: { + source: sourceDynamicOptions, + data: dataFromObject({ + NBHAB: 3, + PRENOM: ['Verso', 'Maëlle', 'Aline'], + AGE: [30, 16, 50], + }), + }, +}; diff --git a/src/stories/radio/sourceDynamicOptions.json b/src/stories/radio/sourceDynamicOptions.json new file mode 100644 index 0000000000..e1b20a4083 --- /dev/null +++ b/src/stories/radio/sourceDynamicOptions.json @@ -0,0 +1,496 @@ +{ + "id": "mkb7cgp3", + "label": { + "type": "VTL|MD", + "value": "feat: QCU basé sur une variable - QR" + }, + "modele": "FEATQCUBAS", + "maxPage": "7", + "cleaning": {}, + "resizing": { + "NBHAB": { + "size": "NBHAB", + "variables": ["PRENOM", "AGE"] + }, + "PRENOM": { + "size": "count(PRENOM)", + "variables": ["QCUDANSBOU"] + } + }, + "variables": [ + { + "name": "NBHAB", + "values": { + "COLLECTED": null + }, + "dimension": 0, + "variableType": "COLLECTED" + }, + { + "name": "PRENOM", + "values": { + "COLLECTED": [] + }, + "dimension": 1, + "variableType": "COLLECTED", + "iterationReference": "mkb798x7" + }, + { + "name": "AGE", + "values": { + "COLLECTED": [] + }, + "dimension": 1, + "variableType": "COLLECTED", + "iterationReference": "mkb798x7" + }, + { + "name": "QCUHORSBOU", + "values": { + "COLLECTED": null + }, + "dimension": 0, + "variableType": "COLLECTED" + }, + { + "name": "QCUDANSBOU", + "values": { + "COLLECTED": [] + }, + "dimension": 1, + "variableType": "COLLECTED", + "iterationReference": "mkb798x7" + }, + { + "name": "FILTER_RESULT_NBHAB", + "dimension": 0, + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true + }, + { + "name": "FILTER_RESULT_PRENOM", + "dimension": 1, + "shapeFrom": ["PRENOM", "AGE"], + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true, + "iterationReference": "mkb798x7" + }, + { + "name": "FILTER_RESULT_AGE", + "dimension": 1, + "shapeFrom": ["PRENOM", "AGE"], + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true, + "iterationReference": "mkb798x7" + }, + { + "name": "FILTER_RESULT_QCUHORSBOU", + "dimension": 0, + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true + }, + { + "name": "FILTER_RESULT_QCUDANSBOU", + "dimension": 1, + "shapeFrom": ["PRENOM", "AGE"], + "expression": { + "type": "VTL", + "value": "true" + }, + "variableType": "CALCULATED", + "isIgnoredByLunatic": true, + "iterationReference": "mkb798x7" + } + ], + "components": [ + { + "id": "mkb6zd6d", + "page": "1", + "label": { + "type": "VTL", + "value": "\"I - \" || \"S1\"" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "question-mkb7hxy8", + "page": "2", + "label": { + "type": "VTL|MD", + "value": "\"Nombre d'habitants\"" + }, + "components": [ + { + "id": "mkb7hxy8", + "max": 10.0, + "min": 0.0, + "page": "2", + "controls": [ + { + "id": "mkb7hxy8-format-borne-inf-sup", + "type": "SIMPLE", + "control": { + "type": "VTL", + "value": "not(not(isnull(NBHAB)) and (0>NBHAB or 10NBHAB)" + }, + "criticality": "ERROR", + "errorMessage": { + "type": "VTL|MD", + "value": "\"Le nombre doit comporter au maximum 0 chiffre(s) après la virgule\"" + }, + "typeOfControl": "FORMAT" + } + ], + "decimals": 0, + "response": { + "name": "NBHAB" + }, + "description": { + "type": "TXT", + "value": "Format attendu : un nombre entre 0 et 10" + }, + "isMandatory": false, + "componentType": "InputNumber" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "mkb798x7", + "page": "3", + "depth": 1, + "lines": { + "max": { + "type": "VTL", + "value": "NBHAB" + }, + "min": { + "type": "VTL", + "value": "NBHAB" + } + }, + "components": [ + { + "id": "mkb6z1pv", + "page": "3", + "label": { + "type": "VTL", + "value": "\"II - \" || \"THL\"", + "shapeFrom": "PRENOM" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true", + "shapeFrom": "PRENOM" + } + }, + { + "id": "question-mkb7180z", + "page": "3", + "label": { + "type": "VTL|MD", + "value": "\"Prénom\"" + }, + "components": [ + { + "id": "mkb7180z", + "page": "3", + "response": { + "name": "PRENOM" + }, + "maxLength": 249, + "isMandatory": false, + "componentType": "Input" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "question-mkb7gox2", + "page": "3", + "label": { + "type": "VTL|MD", + "value": "\"Âge\"" + }, + "components": [ + { + "id": "mkb7gox2", + "max": 100.0, + "min": 0.0, + "page": "3", + "controls": [ + { + "id": "mkb7gox2-format-borne-inf-sup", + "type": "SIMPLE", + "control": { + "type": "VTL", + "value": "not(not(isnull(AGE)) and (0>AGE or 100AGE)" + }, + "criticality": "ERROR", + "errorMessage": { + "type": "VTL|MD", + "value": "\"Le nombre doit comporter au maximum 0 chiffre(s) après la virgule\"" + }, + "typeOfControl": "FORMAT" + } + ], + "decimals": 0, + "response": { + "name": "AGE" + }, + "description": { + "type": "TXT", + "value": "Format attendu : un nombre entre 0 et 100" + }, + "isMandatory": false, + "componentType": "InputNumber" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + } + ], + "componentType": "Loop", + "paginatedLoop": false, + "conditionFilter": { + "type": "VTL", + "value": "(true)" + }, + "loopDependencies": ["NBHAB"], + "isPaginatedByIterations": false + }, + { + "id": "mkb7ed5t", + "page": "4", + "label": { + "type": "VTL", + "value": "\"III - \" || \"S2\"" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "question-mkb7ktxx", + "page": "5", + "label": { + "type": "VTL|MD", + "value": "\"QCU hors boucle\"" + }, + "components": [ + { + "id": "mkb7ktxx", + "page": "5", + "optionSource": "PRENOM", + "optionFilter": { + "type": "VTL", + "value": "AGE >= 18", + "shapeFrom": "PRENOM" + }, + "response": { + "name": "QCUHORSBOU" + }, + "isMandatory": false, + "orientation": "vertical", + "componentType": "Radio" + } + ], + "declarations": [ + { + "id": "mke55sef", + "label": { + "type": "VTL|MD", + "value": "\"Les modalités de réponse sont les prénoms des individus ayant 18 ans ou plus\"" + }, + "position": "AFTER_QUESTION_TEXT", + "declarationType": "HELP" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + }, + { + "id": "mkb71vg8", + "page": "6", + "depth": 1, + "maxPage": "2", + "components": [ + { + "id": "mkb76y5x", + "page": "6.1", + "label": { + "type": "VTL", + "value": "\"IV - \" || \"S3\"", + "shapeFrom": "PRENOM" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true", + "shapeFrom": "PRENOM" + } + }, + { + "id": "question-mkb73lyz", + "page": "6.2", + "label": { + "type": "VTL|MD", + "value": "\"QCU dans boucle\"" + }, + "components": [ + { + "id": "mkb73lyz", + "page": "6.2", + "optionSource": "PRENOM", + "optionFilter": { + "type": "VTL", + "value": "AGE >= 18", + "shapeFrom": "PRENOM" + }, + "response": { + "name": "QCUDANSBOU" + }, + "isMandatory": false, + "orientation": "vertical", + "componentType": "Radio" + } + ], + "declarations": [ + { + "id": "mke5bdkv", + "label": { + "type": "VTL|MD", + "value": "\"Les modalités de réponse sont les prénoms des individus ayant 18 ans ou plus\"" + }, + "position": "AFTER_QUESTION_TEXT", + "declarationType": "HELP" + } + ], + "componentType": "Question", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + } + ], + "iterations": { + "type": "VTL", + "value": "count(PRENOM)" + }, + "componentType": "Loop", + "paginatedLoop": true, + "conditionFilter": { + "type": "VTL", + "value": "(true)" + }, + "loopDependencies": ["PRENOM"] + }, + { + "id": "mkb7iuwp", + "page": "7", + "label": { + "type": "VTL", + "value": "\"V - \" || \"S4 - Fin\"" + }, + "componentType": "Sequence", + "conditionFilter": { + "type": "VTL", + "value": "true" + } + } + ], + "pagination": "question", + "suggesters": [ + { + "name": "L_DIPLOMES-2-1-0", + "fields": [ + { + "min": 3, + "name": "label", + "rules": ["[\\w-]+"], + "stemmer": false, + "language": "French" + } + ], + "version": 1, + "queryParser": { + "type": "tokenized", + "params": { + "min": 3, + "pattern": "[\\w.-]+", + "stemmer": false, + "language": "French" + } + } + } + ], + "componentType": "Questionnaire", + "enoCoreVersion": "3.59.0", + "generatingDate": "14-01-2026 14:51:23", + "lunaticModelVersion": "5.11.0" +} diff --git a/src/type.source.ts b/src/type.source.ts index 49a4cdba3c..e74fbcc915 100644 --- a/src/type.source.ts +++ b/src/type.source.ts @@ -159,8 +159,8 @@ export type ComponentCheckboxBooleanDefinition = export type ComponentRadioDefinition = ComponentDefinitionBaseWithResponse & { componentType: 'Radio'; orientation?: 'horizontal' | 'vertical'; - options: OptionsWithDetail; -}; +} & ComponentRadioDefinition1; +export type ComponentRadioDefinition1 = StaticOptions | OptionsFromVariable; export type OptionsWithDetail = { value: string | boolean; label: VTLExpression; @@ -176,8 +176,8 @@ export type OptionsWithDetail = { export type ComponentDropdownDefinition = ComponentDefinitionBaseWithResponse & { componentType: 'Dropdown'; - options: Options; - }; + } & ComponentDropdownDefinition1; +export type ComponentDropdownDefinition1 = StaticOptions | OptionsFromVariable; export type ComponentQuestionDefinition = ComponentDefinitionBase & { componentType: 'Question'; components: ComponentDefinition[]; @@ -185,8 +185,10 @@ export type ComponentQuestionDefinition = ComponentDefinitionBase & { export type ComponentCheckboxOneDefinition = ComponentDefinitionBaseWithResponse & { componentType: 'CheckboxOne'; - options: OptionsWithDetail; - }; + } & ComponentCheckboxOneDefinition1; +export type ComponentCheckboxOneDefinition1 = + | StaticOptions + | OptionsFromVariable; export type ComponentSuggesterDefinition = ComponentDefinitionBaseWithResponse & { componentType: 'Suggester'; @@ -224,11 +226,12 @@ export type ComponentPairWiseLinksDefinition = ComponentDefinitionBase & { }; }; sourceVariables?: { - /** Name of the variable containing the name of the respondent */ + /** + * Name of the variable containing the name of the respondent + */ name?: string; /** - * Name of the variable containing the gender of the respondent - * (value of variable -> 1:male, 2: female) + * Name of the variable containing the sex/gender of the respondent (value of variable -> 1: man, 2: woman) */ gender?: string; }; @@ -436,6 +439,13 @@ export type ControlDefinition = { export type ResponseDefinition = { name: string; }; +export type StaticOptions = { + options: OptionsWithDetail; +}; +export type OptionsFromVariable = { + optionSource: string; + optionFilter?: VTLExpression; +}; export type ComponentText = { componentType: 'Text'; label: VTLExpression; diff --git a/src/use-lunatic/commons/fill-components/fill-component-expressions.ts b/src/use-lunatic/commons/fill-components/fill-component-expressions.ts index 795cbe52be..c561c98e37 100644 --- a/src/use-lunatic/commons/fill-components/fill-component-expressions.ts +++ b/src/use-lunatic/commons/fill-components/fill-component-expressions.ts @@ -91,7 +91,8 @@ type UntranslatedProperties = | 'controls' | 'conditionFilter' | 'conditionReadOnly' - | 'components'; + | 'components' + | 'optionFilter'; export type DeepTranslateExpression = T extends LunaticExpression ? ReactNode : T extends (infer ElementType)[] diff --git a/src/use-lunatic/commons/fill-components/fill-components.ts b/src/use-lunatic/commons/fill-components/fill-components.ts index 1f98d31a0a..7e76990677 100644 --- a/src/use-lunatic/commons/fill-components/fill-components.ts +++ b/src/use-lunatic/commons/fill-components/fill-components.ts @@ -11,7 +11,7 @@ import type { LunaticComponentProps } from '../../../components/type'; import { getMissingResponseProp } from '../../props/propMissingResponse'; import { getValueProp } from '../../props/propValue'; import { getIterationsProp } from '../../props/propIterations'; -import { getOptionsProp } from '../../props/propOptions'; +import { computeOptionsFromComponent } from '../../props/propOptions'; import { LunaticLogger } from '../../logger/type'; import { VTLScalarExpression } from '../../../type.source'; @@ -80,16 +80,15 @@ export const fillComponent = ( missingResponse: getMissingResponseProp(component, state), management: state.management, iterations: getIterationsProp(component, state), - options: getOptionsProp( - interpretedProps, - state.variables, - state.handleChanges, - state.pager.iteration, + options: computeOptionsFromComponent(interpretedProps, { + variables: state.variables, + handleChanges: state.handleChanges, + pagerIteration: state.pager.iteration, value, - state.logger, - state.disableFilters, - shouldBeFiltered - ), + logger: state.logger, + disableFilters: state.disableFilters, + shouldParentBeFiltered: shouldBeFiltered, + }), ...getComponentTypeProps(interpretedProps, state), // This is too dynamic to be typed correctly, so we allow any here } as any; diff --git a/src/use-lunatic/props/propOptions.spec.ts b/src/use-lunatic/props/propOptions.spec.ts index 446bf95610..75b604f317 100644 --- a/src/use-lunatic/props/propOptions.spec.ts +++ b/src/use-lunatic/props/propOptions.spec.ts @@ -1,46 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { LunaticVariablesStore } from '../commons/variables/lunatic-variables-store'; -import { getOptionsProp } from './propOptions'; +import { computeOptionsFromComponent, InterpretedOption } from './propOptions'; import type { DeepTranslateExpression } from '../commons/fill-components/fill-component-expressions'; import type { LunaticChangesHandler, LunaticComponentDefinition, } from '../type'; -describe('getOptionsProp()', () => { +describe('computeOptionsFromComponent', () => { let variables: LunaticVariablesStore; - const checkboxGroupDefinition = { - id: 'CheckboxGroup', - componentType: 'CheckboxGroup', - responses: [ - { - label: 'Option 1', - response: { name: 'O1' }, - id: 'id1', - }, - { - label: 'Option 2', - response: { name: 'O2' }, - id: 'id2', - }, - ], - } satisfies DeepTranslateExpression; - - const radioDefinition = { - id: 'RadioGroup', - componentType: 'Radio', - response: { name: 'RADIO' }, - options: [ - { - label: 'Option 1', - value: 'id1', - }, - { - label: 'Option 2', - value: 'id2', - }, - ], - } satisfies DeepTranslateExpression; let mockChange: LunaticChangesHandler; const mockLogger = vi.fn(); @@ -50,69 +18,97 @@ describe('getOptionsProp()', () => { variables = new LunaticVariablesStore(); }); - describe('CheckboxGroup', () => { + describe('Options based on a fixed list', () => { + const checkboxGroupDefinition = { + id: 'CheckboxGroup', + componentType: 'CheckboxGroup', + responses: [ + { + label: 'Option 1', + response: { name: 'O1' }, + id: 'id1', + }, + { + label: 'Option 2', + response: { name: 'O2' }, + id: 'id2', + }, + ], + } satisfies DeepTranslateExpression; + + const radioDefinition = { + id: 'RadioGroup', + componentType: 'Radio', + response: { name: 'RADIO' }, + options: [ + { + label: 'Option 1', + value: 'id1', + }, + { + label: 'Option 2', + value: 'id2', + }, + ], + } satisfies DeepTranslateExpression; + it('should check boxes', () => { variables.set('O2', false); - let options = getOptionsProp( - checkboxGroupDefinition, + let options = computeOptionsFromComponent(checkboxGroupDefinition, { variables, - mockChange, - undefined, - undefined, - mockLogger - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }); expect(options[1].checked).toBe(false); variables.set('O2', true); - options = getOptionsProp( - checkboxGroupDefinition, + options = computeOptionsFromComponent(checkboxGroupDefinition, { variables, - mockChange, - undefined, - undefined, - mockLogger - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }); expect(options[1].checked).toBe(true); }); it('should check boxes correctly within iteration', () => { variables.set('O1', []); variables.set('O2', []); - let options = getOptionsProp( - checkboxGroupDefinition, + let options = computeOptionsFromComponent(checkboxGroupDefinition, { variables, - mockChange, - 0, - undefined, - mockLogger - ); + handleChanges: mockChange, + pagerIteration: 0, + value: undefined, + logger: mockLogger, + }); expect( options.filter((o) => o.checked), 'Nothing checked when variable empty' ).toHaveLength(0); variables.set('O1', [true, 0]); - options = getOptionsProp( - checkboxGroupDefinition, + options = computeOptionsFromComponent(checkboxGroupDefinition, { variables, - mockChange, - 0, - undefined, - mockLogger - ); + handleChanges: mockChange, + pagerIteration: 0, + value: undefined, + logger: mockLogger, + }); expect(options[0].checked).toBe(true); expect(options[1].checked).toBe(false); }); it('should create handleChange correctly', () => { variables.set('O1', [true, false]); variables.set('O2', [false, true]); - const options = getOptionsProp( - checkboxGroupDefinition, + const options = computeOptionsFromComponent(checkboxGroupDefinition, { variables, - mockChange, - 1, - undefined, - mockLogger - ); - options[1].onCheck(false); + handleChanges: mockChange, + pagerIteration: 1, + value: undefined, + logger: mockLogger, + }); + options[1].onCheck?.(false); expect(mockChange).toHaveBeenLastCalledWith([ { name: 'O2', value: false }, ]); @@ -145,14 +141,13 @@ describe('getOptionsProp()', () => { variables.set('DETAIL', true); - const options = getOptionsProp( - definition, + const options = computeOptionsFromComponent(definition, { variables, - mockChange, - undefined, - undefined, - mockLogger - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }); expect(options).toHaveLength(2); expect(options[0].detailLabel).toBe('Precize:'); @@ -188,14 +183,13 @@ describe('getOptionsProp()', () => { variables.set('DETAIL', true); - const options = getOptionsProp( - definition, + const options = computeOptionsFromComponent(definition, { variables, - mockChange, - undefined, - undefined, - mockLogger - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }); expect(options).toHaveLength(2); expect(options[0].detailLabel).toBe('Precize:'); @@ -224,14 +218,13 @@ describe('getOptionsProp()', () => { ], } satisfies DeepTranslateExpression; - const options = getOptionsProp( - definition, + const options = computeOptionsFromComponent(definition, { variables, - mockChange, - undefined, - undefined, - mockLogger - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }); // First option should be filtered out since its conditionFilter is evaluated to false expect(options).toHaveLength(1); @@ -254,14 +247,13 @@ describe('getOptionsProp()', () => { ], } as any as DeepTranslateExpression; - const options = getOptionsProp( - definition, + const options = computeOptionsFromComponent(definition, { variables, - mockChange, - undefined, - undefined, - mockLogger - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }); // First option should be filtered out since its conditionFilter is evaluated to false expect(options).toHaveLength(1); @@ -285,14 +277,13 @@ describe('getOptionsProp()', () => { throw new Error('Test error'); }); - const options = getOptionsProp( - definition, + const options = computeOptionsFromComponent(definition, { variables, - mockChange, - undefined, - undefined, - mockLogger - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }); // Ensure the option is not filtered expect(options).toHaveLength(1); @@ -315,14 +306,13 @@ describe('getOptionsProp()', () => { throw new Error('Test error'); }); - const options = getOptionsProp( - definition, + const options = computeOptionsFromComponent(definition, { variables, - mockChange, - undefined, - undefined, - mockLogger - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }); // Ensure the option is not filtered expect(options).toHaveLength(1); @@ -346,15 +336,14 @@ describe('getOptionsProp()', () => { return false; }); - const options = getOptionsProp( - definition, + const options = computeOptionsFromComponent(definition, { variables, - mockChange, - undefined, - undefined, - mockLogger, - true // disableFilters = true - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + disableFilters: true, + }); // Ensure the option is not filtered expect(options).toHaveLength(1); @@ -378,15 +367,14 @@ describe('getOptionsProp()', () => { return false; }); - const options = getOptionsProp( - definition, + const options = computeOptionsFromComponent(definition, { variables, - mockChange, - undefined, - undefined, - mockLogger, - true // disableFilters = true - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + disableFilters: true, + }); // Ensure the option is not filtered expect(options).toHaveLength(1); @@ -406,16 +394,15 @@ describe('getOptionsProp()', () => { ], } satisfies DeepTranslateExpression; - const options = getOptionsProp( - definition, + const options = computeOptionsFromComponent(definition, { variables, - mockChange, - undefined, - undefined, - mockLogger, - true, // disableFilters = true - true // parent component should be filtered - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + disableFilters: true, + shouldParentBeFiltered: true, + }); // Ensure the option is not filtered expect(options).toHaveLength(1); @@ -434,16 +421,15 @@ describe('getOptionsProp()', () => { ], } as any as DeepTranslateExpression; - const options = getOptionsProp( - definition, + const options = computeOptionsFromComponent(definition, { variables, - mockChange, - undefined, - undefined, - mockLogger, - true, // disableFilters = true - true // parent component should be filtered - ); + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + disableFilters: true, + shouldParentBeFiltered: true, + }); // Ensure the option is not filtered expect(options).toHaveLength(1); @@ -451,4 +437,88 @@ describe('getOptionsProp()', () => { expect(options[0].shouldBeFiltered).toBe(true); }); }); + + describe('Options based on a source variable', () => { + const radioOptionSourceDefinition = { + id: 'RadioGroupDynamic', + componentType: 'Radio', + response: { name: 'RADIO' }, + optionSource: 'NAME', + } satisfies DeepTranslateExpression; + + it('should build options when the source variable is an array of strings', () => { + variables.set('NAME', ['Maëlle', 'Verso']); + const options = computeOptionsFromComponent(radioOptionSourceDefinition, { + variables, + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }) as InterpretedOption[]; // force type but it should infer type correctly + + expect(options).toHaveLength(2); + expect(options[0].value).toBe('Maëlle'); + expect(options[0].label).toBe('Maëlle'); + expect(options[1].value).toBe('Verso'); + expect(options[1].label).toBe('Verso'); + }); + + it('should build options when the source variable is an array of numbers', () => { + variables.set('NAME', [10, 20]); + const options = computeOptionsFromComponent(radioOptionSourceDefinition, { + variables, + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }) as InterpretedOption[]; // force type but it should infer type correctly + + expect(options).toHaveLength(2); + expect(options[0].value).toBe(10); + expect(options[0].label).toBe('10'); + expect(options[1].value).toBe(20); + expect(options[1].label).toBe('20'); + }); + + it('should set the response when selecting a dynamic option', () => { + variables.set('NAME', ['Maëlle', 'Verso']); + const options = computeOptionsFromComponent(radioOptionSourceDefinition, { + variables, + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }) as InterpretedOption[]; // force type but it should infer type correctly + + options[0].onCheck?.(); + expect(mockChange).toHaveBeenLastCalledWith([ + { name: 'RADIO', value: 'Maëlle' }, + ]); + + options[1].onCheck?.(); + expect(mockChange).toHaveBeenLastCalledWith([ + { name: 'RADIO', value: 'Verso' }, + ]); + }); + + it('should filter options based on the optionFilter expression', () => { + const definition = { + ...radioOptionSourceDefinition, + optionFilter: { type: 'VTL', value: 'AGE >= 18' }, + } satisfies DeepTranslateExpression; + + variables.set('NAME', ['Maëlle', 'Verso', 'Aline']); + variables.set('AGE', [16, 30, 50]); + + const options = computeOptionsFromComponent(definition, { + variables, + handleChanges: mockChange, + pagerIteration: undefined, + value: undefined, + logger: mockLogger, + }) as InterpretedOption[]; // force type but it should infer type correctly + + expect(options.map((option) => option.value)).toEqual(['Verso', 'Aline']); + }); + }); }); diff --git a/src/use-lunatic/props/propOptions.ts b/src/use-lunatic/props/propOptions.ts index 59e2a7eb07..5f97bd6baf 100644 --- a/src/use-lunatic/props/propOptions.ts +++ b/src/use-lunatic/props/propOptions.ts @@ -28,15 +28,25 @@ export type InterpretedOption = { /** * Compute options for checkboxes / radios / dropdown */ -export function getOptionsProp( +export function computeOptionsFromComponent( definition: DeepTranslateExpression, - variables: LunaticVariablesStore, - handleChanges: LunaticChangesHandler, - pagerIteration: LunaticState['pager']['iteration'], - value: unknown, - logger: LunaticLogger, - disableFilters?: boolean, - shouldParentBeFiltered?: boolean + { + variables, + handleChanges, + pagerIteration, + value, + logger, + disableFilters, + shouldParentBeFiltered, + }: { + variables: LunaticVariablesStore; + handleChanges: LunaticChangesHandler; + pagerIteration: LunaticState['pager']['iteration']; + value: unknown; + logger: LunaticLogger; + disableFilters?: boolean; + shouldParentBeFiltered?: boolean; + } ) { const iteration = isNumber(pagerIteration) ? [pagerIteration] : undefined; @@ -85,10 +95,27 @@ export function getOptionsProp( })); } + // options based on another variable + if ('optionSource' in definition && definition.optionSource) { + return computeOptionsFromSource(definition.optionSource, { + variables, + value, + handleChanges, + responseName: definition.response.name, + logger, + shouldParentBeFiltered, + optionFilter: definition.optionFilter, + }); + } + if (!('options' in definition)) { return []; } + if (!definition.options) { + return []; + } + return definition.options .filter((option) => { if ( @@ -102,7 +129,7 @@ export function getOptionsProp( variables, iteration, logger, - option.conditionFilter + option.conditionFilter as VtlExpression | undefined ); }) .map((option) => ({ @@ -139,11 +166,73 @@ export function getOptionsProp( variables, iteration, logger, - option.conditionFilter + option.conditionFilter as VtlExpression | undefined )), })); } +/** + * Get all options from a source variable, applying filters. + */ +function computeOptionsFromSource( + optionSource: string, + { + variables, + value, + handleChanges, + responseName, + logger, + shouldParentBeFiltered, + optionFilter, + }: { + variables: LunaticVariablesStore; + value: unknown; + handleChanges: LunaticChangesHandler; + responseName: string; + logger: LunaticLogger; + shouldParentBeFiltered?: boolean; + optionFilter?: VtlExpression; + } +): InterpretedOption[] { + // we don't know the type of the optionSource values (string, numbers, boolean) + const optionValues = variables.get(optionSource); + if (!optionValues) { + return []; + } + + const normalizedValues = Array.isArray(optionValues) + ? optionValues + : [optionValues]; + + return normalizedValues + .filter((option, index) => { + // option is an empty value, we remove it from the options list + if (option === null || option === undefined) { + return false; + } + // no filter expression, we keep the option + if (!optionFilter) { + return true; + } + // apply filter expression on option (applied to its iteration) + return !isFilteredOutOption(variables, [index], logger, optionFilter); + }) + .map((option) => { + return { + label: String(option), + value: option, + checked: value === option, + onCheck: () => { + handleChanges([{ name: responseName, value: option }]); + }, + onUncheck: () => { + handleChanges([{ name: responseName, value: null }]); + }, + shouldBeFiltered: shouldParentBeFiltered, + }; + }); +} + /** * Check if an option should be filtered, depending on its conditionFilter. */