diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a6f73b90..64fdfda13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.1.0](https://github.com/InseeFr/Pogues/releases/tag/3.1.0) - 2026-05-13 +### Added + +- In a unique choice question with modalities computed from a variable, you can now add a VTL expression for filtering modalities. + ### Fixed - Questionnaire loading no longer crash when a question uses a code list (or nomenclature) that does not exist anymore. It could happen when an existing question used a previous version of a nomenclature. diff --git a/legacy/src/constants/dictionary/codes.ts b/legacy/src/constants/dictionary/codes.ts index 61ff920df..576a08f25 100644 --- a/legacy/src/constants/dictionary/codes.ts +++ b/legacy/src/constants/dictionary/codes.ts @@ -46,4 +46,8 @@ export const codesDictionary: Dictionary = { fr: `Modifier le filtre`, en: `Modify filter`, }, + modalityFilter: { + fr: `Filtre de modalités`, + en: `Modality filter`, + }, }; diff --git a/legacy/src/model/formToState/component-new-edit/collected-variable.jsx b/legacy/src/model/formToState/component-new-edit/collected-variable.jsx index 9179bf5af..6b7d426a7 100644 --- a/legacy/src/model/formToState/component-new-edit/collected-variable.jsx +++ b/legacy/src/model/formToState/component-new-edit/collected-variable.jsx @@ -15,6 +15,7 @@ export const defaultState = { codeListReferenceLabel: '', variableReference: '', variableReferenceLabel: '', + optionFilter: '', choiceType: '', isCollected: '1', alternativeLabel: '', @@ -33,6 +34,7 @@ export const defaultForm = { codeListReferenceLabel: '', variableReference: '', variableReferenceLabel: '', + optionFilter: '', choiceType: '', isCollected: '1', alternativeLabel: '', @@ -58,6 +60,7 @@ export function formToState(form, codesListsStore = {}) { codeListReferenceLabel, variableReference, variableReferenceLabel, + optionFilter, choiceType, isCollected, alternativeLabel, @@ -83,6 +86,7 @@ export function formToState(form, codesListsStore = {}) { codeListReferenceLabel, variableReference, variableReferenceLabel: resolvedVariableReferenceLabel, + optionFilter, choiceType, }; } @@ -119,6 +123,7 @@ export function storeToForm(currentStore) { codeListReferenceLabel, variableReference, variableReferenceLabel, + optionFilter, choiceType, } = currentStore[key]; return { @@ -137,6 +142,7 @@ export function storeToForm(currentStore) { codeListReferenceLabel, variableReference, variableReferenceLabel, + optionFilter, choiceType, }; }); diff --git a/legacy/src/model/formToState/component-new-edit/component.jsx b/legacy/src/model/formToState/component-new-edit/component.jsx index 007b01ff5..6b836daf7 100644 --- a/legacy/src/model/formToState/component-new-edit/component.jsx +++ b/legacy/src/model/formToState/component-new-edit/component.jsx @@ -58,6 +58,7 @@ export function formToState(form, transformers) { occurrenceDescription, locked, codeFilters = [], + optionFilter, } = form; let newName; @@ -107,6 +108,7 @@ export function formToState(form, transformers) { occurrenceDescription: occurrenceDescription, locked: locked, codeFilters, + optionFilter, }; if (res.responseFormat.type) { transformers.codesList.formToComponentState( @@ -140,6 +142,7 @@ export function stateToForm(currentState, transformers, activeQuestionnaire) { locked, selectedComponent, codeFilters = [], + optionFilter, } = currentState; let target = ''; @@ -176,6 +179,7 @@ export function stateToForm(currentState, transformers, activeQuestionnaire) { occurrenceDescription: occurrenceDescription || '', locked: locked, codeFilters, + optionFilter, }; if (type === QUESTION) { diff --git a/legacy/src/model/formToState/lists/utils.js b/legacy/src/model/formToState/lists/utils.js index fe3d5aec5..dbb91dea4 100644 --- a/legacy/src/model/formToState/lists/utils.js +++ b/legacy/src/model/formToState/lists/utils.js @@ -9,10 +9,10 @@ export function createListFactory( currentState = {}, ) { if (codesListsStore && currentState.id) { - currentState = merge( - cloneDeep(defaultState), - codesListsStore[currentState.id], - ); + currentState = merge(cloneDeep(defaultState), { + ...codesListsStore[currentState.id], + optionFilter: currentState.optionFilter, + }); } else { currentState = cloneDeep(defaultState); } diff --git a/legacy/src/model/formToState/lists/variables.js b/legacy/src/model/formToState/lists/variables.js index ae4588dc1..9120ed3c5 100644 --- a/legacy/src/model/formToState/lists/variables.js +++ b/legacy/src/model/formToState/lists/variables.js @@ -5,16 +5,18 @@ export const defaultState = { label: '', name: '', scope: '', + optionFilter: '', }; export function formToState(form) { - const { id, label, name, scope } = form; + const { id, label, name, scope, optionFilter } = form; return { id, name, label, scope, + optionFilter, }; } @@ -23,12 +25,14 @@ export function stateComponentToForm({ name = '', label = '', scope = '', + optionFilter = '', }) { return { id, name, label, scope, + optionFilter, }; } diff --git a/legacy/src/model/transformations/component.jsx b/legacy/src/model/transformations/component.jsx index 47aa02622..6ab979d0d 100644 --- a/legacy/src/model/transformations/component.jsx +++ b/legacy/src/model/transformations/component.jsx @@ -352,6 +352,7 @@ function remoteToState(remote, componentGroup, codesListsStore) { Locked: locked, Loop: loop, codeFilters: codeFilters, + OptionFilter: optionFilter, sourceVariableReferences, } = remote; const redirectionClar = @@ -387,6 +388,7 @@ function remoteToState(remote, componentGroup, codesListsStore) { dynamiqueSpecified: flowLogic && flowLogic === FILTER ? Filtres : Redirections, codeFilters, + optionFilter, }; if (genericName) { state.label = label; @@ -422,6 +424,7 @@ function remoteToState(remote, componentGroup, codesListsStore) { scope, mandatory, sourceVariableReferences, + optionFilter, ); state.collectedVariables = CollectedVariable.remoteToComponentState(responseFinal); diff --git a/legacy/src/model/transformations/response-format-single.spec.ts b/legacy/src/model/transformations/response-format-single.spec.ts index 22ca52ba4..267f8b785 100644 --- a/legacy/src/model/transformations/response-format-single.spec.ts +++ b/legacy/src/model/transformations/response-format-single.spec.ts @@ -86,10 +86,11 @@ describe('response format single', () => { choiceType: CHOICE_TYPE.VARIABLE, }, ], + optionFilter: 'nice filter', }; const output = remoteToState(remote); const expected = { - Variable: { id: 'my-variable-id' }, + Variable: { id: 'my-variable-id', optionFilter: 'nice filter' }, id: 'my-response-id', mandatory: true, choiceType: 'VARIABLE', @@ -167,7 +168,7 @@ describe('stateToRemote', () => { mandatory: true, visHint: DATATYPE_VIS_HINT.RADIO, choiceType: CHOICE_TYPE.VARIABLE, - Variable: { id: 'my-variable-id' }, + Variable: { id: 'my-variable-id', optionFilter: 'nice filter' }, }; const collectedVariables = ['my-var-id']; const output = stateToRemote(state, collectedVariables); @@ -187,6 +188,7 @@ describe('stateToRemote', () => { mandatory: true, }, ], + optionFilter: 'nice filter', }; expect(output).toEqual(expected); }); diff --git a/legacy/src/model/transformations/response-format-single.ts b/legacy/src/model/transformations/response-format-single.ts index 052fe8e2d..6c6ca6dd8 100644 --- a/legacy/src/model/transformations/response-format-single.ts +++ b/legacy/src/model/transformations/response-format-single.ts @@ -50,12 +50,13 @@ export type StateResponseFormatSingle = { | DATATYPE_VIS_HINT.CHECKBOX | DATATYPE_VIS_HINT.RADIO | DATATYPE_VIS_HINT.DROPDOWN; - [DEFAULT_VARIABLE_SELECTOR_PATH]: { id: string }; + [DEFAULT_VARIABLE_SELECTOR_PATH]: { id: string; optionFilter?: string }; } ); export function remoteToState(remote: { responses: RemoteResponseFormatSingle; + optionFilter?: string; }): StateResponseFormatSingle { const { responses: [ @@ -68,6 +69,7 @@ export function remoteToState(remote: { id, }, ], + optionFilter, } = remote; const baseState = { @@ -94,7 +96,10 @@ export function remoteToState(remote: { if (choiceType === CHOICE_TYPE.VARIABLE) { return { ...baseState, - [DEFAULT_VARIABLE_SELECTOR_PATH]: { id: VariableReference as string }, + [DEFAULT_VARIABLE_SELECTOR_PATH]: { + id: VariableReference as string, + optionFilter, + }, visHint, }; } @@ -111,12 +116,13 @@ export function remoteToState(remote: { export function stateToRemote( state: StateResponseFormatSingle, collectedVariables: string[], -): { Response: RemoteResponseFormatSingle } { +): { Response: RemoteResponseFormatSingle; optionFilter?: string } { const { allowArbitraryResponse, visHint, mandatory, id, choiceType } = state; let nomenclatureId; let codesListId; let variableReferenceId; + let optionFilter; if ( choiceType === CHOICE_TYPE.SUGGESTER && @@ -128,6 +134,7 @@ export function stateToRemote( DEFAULT_VARIABLE_SELECTOR_PATH in state ) { variableReferenceId = state[DEFAULT_VARIABLE_SELECTOR_PATH]?.id; + optionFilter = state[DEFAULT_VARIABLE_SELECTOR_PATH]?.optionFilter; } else if ( choiceType === CHOICE_TYPE.CODE_LIST && DEFAULT_CODES_LIST_SELECTOR_PATH in state @@ -150,5 +157,6 @@ export function stateToRemote( collectedVariable: collectedVariables[0], }), ], + optionFilter, }; } diff --git a/legacy/src/model/transformations/response-format-table.jsx b/legacy/src/model/transformations/response-format-table.jsx index 8655b08c8..38b6de3e6 100644 --- a/legacy/src/model/transformations/response-format-table.jsx +++ b/legacy/src/model/transformations/response-format-table.jsx @@ -232,7 +232,12 @@ function remoteToStateMeasure(remote) { state.type = SINGLE_CHOICE; state[SINGLE_CHOICE] = ResponseFormatSingle.remoteToState({ responses: [ - { Datatype, VariableReference, choiceType, CollectedVariableReference }, + { + Datatype, + VariableReference, + choiceType, + CollectedVariableReference, + }, ], }); } else { diff --git a/legacy/src/model/transformations/response-format-table.spec.jsx b/legacy/src/model/transformations/response-format-table.spec.jsx index 9f3837cd4..5ea3cb8eb 100644 --- a/legacy/src/model/transformations/response-format-table.spec.jsx +++ b/legacy/src/model/transformations/response-format-table.spec.jsx @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest'; +import { CHOICE_TYPE } from '@/constants/pogues-constants'; + import { remoteToState, stateToRemote } from './response-format-table'; +const { CODE_LIST, SUGGESTER, VARIABLE } = CHOICE_TYPE; + describe('remoteToState', () => { it('should use an offset equal to 1', () => { const remote = { @@ -230,7 +234,7 @@ describe('remoteToState', () => { type: 'TextDatatypeType', MaxLength: 1, }, - choiceType: 'SUGGESTER', + choiceType: SUGGESTER, CollectedVariableReference: 'mbz5jjxv', }, { @@ -242,7 +246,19 @@ describe('remoteToState', () => { type: 'TextDatatypeType', MaxLength: 1, }, - choiceType: 'CODE_LIST', + choiceType: CODE_LIST, + CollectedVariableReference: 'mbz5crwv', + }, + { + id: 'mbh8ke14', + VariableReference: 'kdyg5y7', + Datatype: { + typeName: 'TEXT', + visualizationHint: 'RADIO', + type: 'TextDatatypeType', + MaxLength: 1, + }, + choiceType: VARIABLE, CollectedVariableReference: 'mbz5crwv', }, ], @@ -260,7 +276,11 @@ describe('remoteToState', () => { }, { dimensionType: 'MEASURE', - Label: 'measure-radio', + Label: 'measure-codeList-radio', + }, + { + dimensionType: 'MEASURE', + Label: 'measure-variable-radio', }, ], }; @@ -283,7 +303,7 @@ describe('remoteToState', () => { id: undefined, mandatory: undefined, visHint: 'SUGGESTER', - choiceType: 'SUGGESTER', + choiceType: SUGGESTER, }, label: 'measure-suggester', type: 'SINGLE_CHOICE', @@ -294,9 +314,20 @@ describe('remoteToState', () => { id: undefined, mandatory: undefined, visHint: 'RADIO', - choiceType: 'CODE_LIST', + choiceType: CODE_LIST, + }, + label: 'measure-codeList-radio', + type: 'SINGLE_CHOICE', + }, + { + SINGLE_CHOICE: { + Variable: { id: 'kdyg5y7' }, + id: undefined, + mandatory: undefined, + visHint: 'RADIO', + choiceType: VARIABLE, }, - label: 'measure-radio', + label: 'measure-variable-radio', type: 'SINGLE_CHOICE', }, ], diff --git a/legacy/src/model/transformations/response-format.jsx b/legacy/src/model/transformations/response-format.jsx index 8022819a5..e3201e099 100644 --- a/legacy/src/model/transformations/response-format.jsx +++ b/legacy/src/model/transformations/response-format.jsx @@ -18,13 +18,18 @@ export function remoteToState( mandatory, /** Add source variable references to response format if the response format is pairing. */ sourceVariableReferences, + /** filter for modalities based on a variable. Only for unique choice questions */ + optionFilter = '', ) { let datatypeState = {}; if (type === SIMPLE) { datatypeState = ResponseFormatSimple.remoteToState({ responses }); } else if (type === SINGLE_CHOICE) { - datatypeState = ResponseFormatSingle.remoteToState({ responses }); + datatypeState = ResponseFormatSingle.remoteToState({ + responses, + optionFilter, + }); } else if (type === MULTIPLE_CHOICE) { datatypeState = ResponseFormatMultiple.remoteToState({ responses, @@ -72,6 +77,7 @@ export function stateToRemote( collectedVariables, ); remote.Response = dataTypeRemote.Response; + remote.OptionFilter = dataTypeRemote.optionFilter; } else if (type === MULTIPLE_CHOICE) { dataTypeRemote = ResponseFormatMultiple.stateToRemote( responseFormatState, diff --git a/legacy/src/model/transformations/types.ts b/legacy/src/model/transformations/types.ts index c916c9bc8..02af07677 100644 --- a/legacy/src/model/transformations/types.ts +++ b/legacy/src/model/transformations/types.ts @@ -30,6 +30,7 @@ export type StateResponse = { codesListId?: string; nomenclatureId?: string; variableReferenceId?: string; + optionFilter?: string; allowArbitraryResponse?: unknown; visHint?: DATATYPE_VIS_HINT; choiceType?: diff --git a/legacy/src/utils/variables/collected-variables-single.js b/legacy/src/utils/variables/collected-variables-single.js index 25b45343a..c1852721a 100644 --- a/legacy/src/utils/variables/collected-variables-single.js +++ b/legacy/src/utils/variables/collected-variables-single.js @@ -36,6 +36,7 @@ export function getCollectedVariablesSingle( variableReference: form.Variable.id, // We need to dynamically get the label of the variable reference, as it can be changed by the user variableReferenceLabel: desiredVarLabel, + optionFilter: form.Variable.optionFilter, type: TEXT, choiceType: form.choiceType, [TEXT]: { maxLength: 249 }, diff --git a/legacy/src/utils/variables/collected-variables-single.spec.js b/legacy/src/utils/variables/collected-variables-single.spec.js index 5f81b841e..0387c2bd3 100644 --- a/legacy/src/utils/variables/collected-variables-single.spec.js +++ b/legacy/src/utils/variables/collected-variables-single.spec.js @@ -314,6 +314,7 @@ describe('getCollectedVariablesSingle', () => { label: 'my-variableRef', name: 'my-variableRef-id', scope: 'my-variableScope', + optionFilter: 'nice filter', }, visHint: 'VARIABLE', choiceType: 'VARIABLE', @@ -327,6 +328,7 @@ describe('getCollectedVariablesSingle', () => { label: 'MY_Q label', variableReference: 'my-variableRef-id', variableReferenceLabel: 'my-variableRef', + optionFilter: 'nice filter', choiceType: 'VARIABLE', type: 'TEXT', TEXT: { maxLength: 249 }, diff --git a/legacy/src/widgets/component-new-edit/components/response-format/single/response-format-single-variable.jsx b/legacy/src/widgets/component-new-edit/components/response-format/single/response-format-single-variable.jsx index 52da74067..6bc78858a 100644 --- a/legacy/src/widgets/component-new-edit/components/response-format/single/response-format-single-variable.jsx +++ b/legacy/src/widgets/component-new-edit/components/response-format/single/response-format-single-variable.jsx @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; import { Field, FormSection } from 'redux-form'; import { formValueSelector } from 'redux-form'; +import { InputWithVariableAutoCompletion } from '@/forms/controls/control-with-suggestions'; import ListRadios from '@/forms/controls/list-radios'; import Select from '@/forms/controls/select'; @@ -28,6 +29,7 @@ function ResponseFormatSingleVariable({ selectedScope, componentsStore, externalLoopsStore, + allowFilter = false, }) { const scopes = getQuestionnaireScope(componentsStore, externalLoopsStore); @@ -58,6 +60,18 @@ function ResponseFormatSingleVariable({ scope={selectedScope} /> + {allowFilter && ( + + + + )} + { diff --git a/legacy/src/widgets/component-new-edit/components/response-format/single/response-format-single-variable.spec.jsx b/legacy/src/widgets/component-new-edit/components/response-format/single/response-format-single-variable.spec.jsx index f6266c962..5baeeacb3 100644 --- a/legacy/src/widgets/component-new-edit/components/response-format/single/response-format-single-variable.spec.jsx +++ b/legacy/src/widgets/component-new-edit/components/response-format/single/response-format-single-variable.spec.jsx @@ -6,9 +6,19 @@ import { combineReducers, createStore } from 'redux'; import { reducer as formReducer, reduxForm } from 'redux-form'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import Input from '@/forms/controls/input'; + import Dictionary from '../../../../../utils/dictionary/dictionary'; import ResponseFormatSingleVariable from './response-format-single-variable'; +/* + Mock InputWithVariableAutoCompletion as the classic Input, + since there are errors when importing antlr-editor in tests + */ +vi.mock('@/forms/controls/control-with-suggestions', () => ({ + InputWithVariableAutoCompletion: (props) => , +})); + vi.mock('@/forms/controls/select', () => ({ default: ({ children, label, name, onChange }) => (