From b3c008194f27a95e80a983d7dbeaa4d0e0bb50c5 Mon Sep 17 00:00:00 2001 From: Vadim Laletin Date: Sun, 15 Mar 2026 23:22:23 +0100 Subject: [PATCH] Solve issues with enableWhenExpression by re-writing it into calculated + enableWhen --- src/__tests__/QuestionItems.test.tsx | 342 +++++++++++++++++++++ src/index.ts | 3 + src/utils.ts | 180 ++++++++--- tests/utils.test.ts | 440 ++++++++++++++++++++------- 4 files changed, 824 insertions(+), 141 deletions(-) create mode 100644 src/__tests__/QuestionItems.test.tsx diff --git a/src/__tests__/QuestionItems.test.tsx b/src/__tests__/QuestionItems.test.tsx new file mode 100644 index 0000000..3fd83c1 --- /dev/null +++ b/src/__tests__/QuestionItems.test.tsx @@ -0,0 +1,342 @@ +import React, { useState } from 'react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import type { QuestionnaireResponse } from 'fhir/r4b'; +import { success } from '@beda.software/remote-data'; + +import { QuestionItems, QuestionnaireResponseFormProvider } from '../components'; +import { expandEnableWhenExpressions, mapResponseToForm } from '../utils'; +import type { FormItems, GroupItemComponent, ItemContext, QRFContextData, QuestionItemComponent } from '../types'; +import type { FCEQuestionnaire } from '../fce.types'; + +const SpyComponent: QuestionItemComponent = ({ questionItem, parentPath }) => { + return
; +}; + +const TestGroupComponent: GroupItemComponent = ({ questionItem, context, parentPath }) => { + const { linkId, item, repeats } = questionItem; + if (repeats) { + return ( + <> + {context.map((ctx, i) => ( + + ))} + + ); + } + return ( + + ); +}; + +function createInitialContext(questionnaire: FCEQuestionnaire, qr: QuestionnaireResponse): ItemContext { + return { + questionnaire, + resource: qr, + context: qr, + }; +} + +function createProviderProps(initialFormValues: FormItems): QRFContextData { + return { + questionItemComponents: { string: SpyComponent, boolean: SpyComponent }, + groupItemComponent: TestGroupComponent, + formValues: initialFormValues, + setFormValues: vi.fn(), + fhirService: vi.fn(async () => success({})), + }; +} + +function StatefulFormProvider({ + initialFormValues, + children, +}: { + initialFormValues: FormItems; + children: React.ReactNode; +}) { + const [formValues, setFormValuesState] = useState(initialFormValues); + const setFormValues = vi.fn((newValues: FormItems) => { + setFormValuesState(newValues); + }); + return ( + success({}))} + > + {children} + + ); +} + +const ewe = (expression: string) => ({ + language: 'text/fhirpath' as const, + expression, +}); + +afterEach(cleanup); + +describe('QuestionItems enableWhenExpression', () => { + const questionnaire: FCEQuestionnaire = expandEnableWhenExpressions({ + resourceType: 'Questionnaire', + status: 'active', + item: [ + { linkId: 'q-root-1', type: 'string' }, + { + linkId: 'q-root-deps-root-1', + type: 'string', + enableWhenExpression: ewe( + "%resource.repeat(item).where(linkId='q-root-1').answer.valueString.exists()", + ), + }, + { + linkId: 'a', + type: 'group', + item: [ + { linkId: 'q-a-1', type: 'string' }, + { + linkId: 'q-a-deps-a-1', + type: 'string', + enableWhenExpression: ewe( + "%resource.repeat(item).where(linkId='q-a-1').answer.valueString.exists()", + ), + }, + { + linkId: 'q-a-deps-root-1', + type: 'string', + enableWhenExpression: ewe( + "%resource.repeat(item).where(linkId='q-root-1').answer.valueString.exists()", + ), + }, + ], + }, + { + linkId: 'b', + type: 'group', + repeats: true, + variable: [ + { + name: 'QB1', + language: 'text/fhirpath', + expression: "%context.item.where(linkId='q-b-1').answer.valueString", + }, + { + name: 'QB2', + language: 'text/fhirpath', + expression: "%context.item.where(linkId='q-b-2').answer.valueString", + }, + ], + item: [ + { linkId: 'q-b-1', type: 'string' }, + { + linkId: 'q-b-deps-b-1', + type: 'string', + enableWhenExpression: ewe('%QB1.exists()'), + }, + { linkId: 'q-b-2', type: 'string' }, + { + linkId: 'q-b-deps-b-2', + type: 'string', + enableWhenExpression: ewe('%QB2.exists()'), + }, + { + linkId: 'q-b-deps-root-1', + type: 'string', + enableWhenExpression: ewe( + "%resource.repeat(item).where(linkId='q-root-1').answer.valueString.exists()", + ), + }, + { + linkId: 'q-b-deps-a-1', + type: 'string', + enableWhenExpression: ewe( + "%resource.repeat(item).where(linkId='q-a-1').answer.valueString.exists()", + ), + }, + ], + }, + ], + }); + + test('hides dependent questions when conditions are not met', async () => { + const qr: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'in-progress', + item: [ + { linkId: 'q-root-1' }, + { linkId: 'a', item: [{ linkId: 'q-a-1' }] }, + { + linkId: 'b', + item: [{ linkId: 'q-b-1' }, { linkId: 'q-b-2' }], + }, + ], + }; + + const formValues = mapResponseToForm(qr, questionnaire); + const context = createInitialContext(questionnaire, qr); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('q-root-1')).toBeDefined(); + }); + + expect(screen.getByTestId('q-root-1')).toBeDefined(); + expect(screen.getByTestId('a.items.q-a-1')).toBeDefined(); + expect(screen.getByTestId('b.items.0.q-b-1')).toBeDefined(); + expect(screen.getByTestId('b.items.0.q-b-2')).toBeDefined(); + + expect(screen.queryByTestId('q-root-deps-root-1')).toBeNull(); + expect(screen.queryByTestId('a.items.q-a-deps-a-1')).toBeNull(); + expect(screen.queryByTestId('a.items.q-a-deps-root-1')).toBeNull(); + expect(screen.queryByTestId('b.items.0.q-b-deps-b-1')).toBeNull(); + expect(screen.queryByTestId('b.items.0.q-b-deps-b-2')).toBeNull(); + expect(screen.queryByTestId('b.items.0.q-b-deps-root-1')).toBeNull(); + expect(screen.queryByTestId('b.items.0.q-b-deps-a-1')).toBeNull(); + }); + + test('shows dependent questions when conditions are met, with repeatable group isolation', async () => { + const qr: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'in-progress', + item: [ + { linkId: 'q-root-1', answer: [{ valueString: 'filled' }] }, + { linkId: 'q-root-deps-root-1' }, + { + linkId: 'a', + item: [ + { linkId: 'q-a-1', answer: [{ valueString: 'filled' }] }, + { linkId: 'q-a-deps-a-1' }, + { linkId: 'q-a-deps-root-1' }, + ], + }, + { + linkId: 'b', + item: [ + { linkId: 'q-b-1', answer: [{ valueString: 'filled' }] }, + { linkId: 'q-b-deps-b-1' }, + { linkId: 'q-b-2' }, + { linkId: 'q-b-deps-b-2' }, + { linkId: 'q-b-deps-root-1' }, + { linkId: 'q-b-deps-a-1' }, + ], + }, + { + linkId: 'b', + item: [ + { linkId: 'q-b-1' }, + { linkId: 'q-b-deps-b-1' }, + { linkId: 'q-b-2', answer: [{ valueString: 'filled' }] }, + { linkId: 'q-b-deps-b-2' }, + { linkId: 'q-b-deps-root-1' }, + { linkId: 'q-b-deps-a-1' }, + ], + }, + ], + }; + + const formValues = mapResponseToForm(qr, questionnaire); + const context = createInitialContext(questionnaire, qr); + + render( + + + , + ); + + await waitFor( + () => { + expect(screen.getByTestId('q-root-deps-root-1')).toBeDefined(); + }, + { timeout: 2000 }, + ); + + // Root level + expect(screen.getByTestId('q-root-1')).toBeDefined(); + expect(screen.getByTestId('q-root-deps-root-1')).toBeDefined(); + + // Group a + expect(screen.getByTestId('a.items.q-a-1')).toBeDefined(); + expect(screen.getByTestId('a.items.q-a-deps-a-1')).toBeDefined(); + expect(screen.getByTestId('a.items.q-a-deps-root-1')).toBeDefined(); + + // Group b repeat 0: q-b-1 filled, q-b-2 not + expect(screen.getByTestId('b.items.0.q-b-1')).toBeDefined(); + expect(screen.getByTestId('b.items.0.q-b-deps-b-1')).toBeDefined(); + expect(screen.getByTestId('b.items.0.q-b-2')).toBeDefined(); + expect(screen.queryByTestId('b.items.0.q-b-deps-b-2')).toBeNull(); + expect(screen.getByTestId('b.items.0.q-b-deps-root-1')).toBeDefined(); + expect(screen.getByTestId('b.items.0.q-b-deps-a-1')).toBeDefined(); + + // Group b repeat 1: q-b-1 not filled, q-b-2 filled + expect(screen.getByTestId('b.items.1.q-b-1')).toBeDefined(); + expect(screen.queryByTestId('b.items.1.q-b-deps-b-1')).toBeNull(); + expect(screen.getByTestId('b.items.1.q-b-2')).toBeDefined(); + expect(screen.getByTestId('b.items.1.q-b-deps-b-2')).toBeDefined(); + expect(screen.getByTestId('b.items.1.q-b-deps-root-1')).toBeDefined(); + expect(screen.getByTestId('b.items.1.q-b-deps-a-1')).toBeDefined(); + }); +}); + +describe('QuestionItems expandEnableWhenExpressions (issue #47)', () => { + /** + * Use expandEnableWhenExpressions to eliminate enableWhenExpression. When an item has both + * variable and enableWhenExpression referencing it (%MyVar), expand moves variable to the + * helper so the expression can be evaluated. See https://github.com/beda-software/sdc-qrf/issues/47 + */ + test('variable is moved to helper so %MyVar in enableWhenExpression works', async () => { + const questionnaire: FCEQuestionnaire = expandEnableWhenExpressions({ + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'q-root-1', + type: 'string', + variable: [ + { + name: 'MyVar', + language: 'text/fhirpath', + expression: "'value'", + }, + ], + enableWhenExpression: ewe("%MyVar = 'value'"), + }, + ], + }); + + const qr: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'in-progress', + item: [{ linkId: 'q-root-1' }], + }; + + const formValues = mapResponseToForm(qr, questionnaire); + const context = createInitialContext(questionnaire, qr); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('q-root-1-enable-when-expression')).toBeDefined(); + expect(screen.getByTestId('q-root-1')).toBeDefined(); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index f19aea0..2e3d66f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,9 @@ export { populateItemKey, removeItemKey, getItemKey, + expandEnableWhenExpressions, + isEnableWhenExpressionHelperLinkId, + removeEnableWhenExpressionHelperItems, } from './utils'; export { useQuestionnaireResponseFormContext } from './hooks'; export { QuestionItems, QuestionItem, QuestionnaireResponseFormProvider } from './components'; diff --git a/src/utils.ts b/src/utils.ts index 2e03b89..cffaacb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -94,11 +94,13 @@ function buildEmptyQuestionnaireResponseItem(qItem: QuestionnaireItem): Question return { linkId: qItem.linkId, ...(qItem.text ? { text: qItem.text } : {}) }; } +export type BranchItems = { qItem: QuestionnaireItem; qrItems: QuestionnaireResponseItem[] }; + export function getBranchItems( fieldPath: string[], questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse, -): { qItem: QuestionnaireItem; qrItems: QuestionnaireResponseItem[] } { +): BranchItems { // The purpose of this function is to extract qItem and qrItem // from original questionnaire and questionnaire response // that are located for fieldPath in FormItems internal structure @@ -159,10 +161,7 @@ export function getBranchItems( return { qItem, qrItems: qrItem ? [qrItem] : [buildEmptyQuestionnaireResponseItem(qItem as QuestionnaireItem)], - } as { - qItem: QuestionnaireItem; - qrItems: QuestionnaireResponseItem[]; - }; + } as BranchItems; } function isGroup(question: QuestionnaireItem) { @@ -362,6 +361,71 @@ export function mapResponseToForm(resource: QuestionnaireResponse, questionnaire return mapResponseToFormRecursive(resource.item ?? [], questionnaire.item ?? []); } +export const ENABLE_WHEN_EXPRESSION_SUFFIX = '-enable-when-expression'; + +/** True when linkId is a virtual enableWhenExpression helper (from expandEnableWhenExpressions). */ +export function isEnableWhenExpressionHelperLinkId(linkId: string): boolean { + return linkId.endsWith(ENABLE_WHEN_EXPRESSION_SUFFIX); +} + +/** + * Expands each enableWhenExpression into a separate boolean calculated item and classic enableWhen. + * For each item that has enableWhenExpression: + * - Inserts right before it a new item (helper) with linkId `${originalLinkId}${ENABLE_WHEN_EXPRESSION_SUFFIX}`, + * type 'boolean', and calculatedExpression set to the same expression. + * - If the original has `variable`: for a group the helper gets a copy (original keeps variable); + * for a non-group the variable is moved to the helper (removed from original) so the helper + * can evaluate expressions like %MyVar (issue #47). + * - Removes enableWhenExpression from the original and adds enableWhen referencing the helper. + * Recurses into nested items. Returns a new questionnaire; does not mutate the input. + */ +export function expandEnableWhenExpressions(questionnaire: FCEQuestionnaire): FCEQuestionnaire { + return { + ...questionnaire, + item: (questionnaire.item ?? []).flatMap(expandEnableWhenExpressionItem), + }; +} + +function expandEnableWhenExpressionItem(item: FCEQuestionnaireItem): FCEQuestionnaireItem[] { + const withExpandedChildren: FCEQuestionnaireItem = { + ...item, + item: item.item?.flatMap((child) => expandEnableWhenExpressionItem(child as FCEQuestionnaireItem)), + }; + + if (!withExpandedChildren.enableWhenExpression) { + return [withExpandedChildren]; + } + + const linkId = withExpandedChildren.linkId!; + const helperLinkId = `${linkId}${ENABLE_WHEN_EXPRESSION_SUFFIX}`; + const expression = withExpandedChildren.enableWhenExpression; + const isGroup = withExpandedChildren.type === 'group'; + + const helperItem: FCEQuestionnaireItem = { + linkId: helperLinkId, + type: 'boolean', + calculatedExpression: expression, + ...(withExpandedChildren.variable?.length ? { variable: [...withExpandedChildren.variable] } : {}), + }; + + // Omit enableWhenExpression (moved to helper); variable omitted for non-groups (moved to helper) + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- intentionally omitted + const { enableWhenExpression, variable, ...rest } = withExpandedChildren; + const originalWithEnableWhen: FCEQuestionnaireItem = { + ...rest, + ...(isGroup && variable?.length ? { variable } : {}), + enableWhen: [ + { + question: helperLinkId, + operator: '=' as QuestionnaireItemEnableWhen['operator'], + answerBoolean: true, + }, + ], + }; + + return [helperItem, originalWithEnableWhen]; +} + function findAnswersForQuestionsRecursive(linkId: string, values?: FormItems): any | null { // TODO: specify types for returning value // TODO: pass Questionnaire structure to make code robust @@ -538,41 +602,16 @@ interface IsQuestionEnabledArgs { context: ItemContext; } function isQuestionEnabled(args: IsQuestionEnabledArgs) { - const { enableWhen, enableBehavior, enableWhenExpression, linkId } = args.qItem; + const { enableWhen, enableBehavior, enableWhenExpression } = args.qItem; - if (enableWhen && enableWhenExpression) { - console.warn(` - linkId: ${args.qItem.linkId} - Both enableWhen and enableWhenExpression are used in the - same QuestionItem. - enableWhenExpression is used as more prioritized - `); + if (enableWhenExpression) { + throw Error(`enableWhenExpression is not supported, use expandEnableWhenExpressions on FCEQuestionnaire`); } - if (!enableWhen && !enableWhenExpression) { + if (!enableWhen) { return true; } - if (enableWhenExpression && enableWhenExpression.language === 'text/fhirpath') { - try { - const expressionResult = evaluateFHIRPathExpression( - enableWhenExpression, - args.context, - `${linkId}.enableWhenExpression`, - )[0]; - - if (typeof expressionResult !== 'boolean') { - throw Error( - `The result of enableWhenExpression is not a boolean value. Expression result: ${expressionResult}`, - ); - } - - return expressionResult; - } catch (err: unknown) { - throw Error(`FHIRPath expression evaluation failure for ${args.qItem.linkId}.enableWhenExpression: ${err}`); - } - } - const iterFn = enableBehavior === 'any' ? _.some : _.every; return iterFn(enableWhen, ({ question, operator, ...enableWhenItem }) => { @@ -602,13 +641,84 @@ export function removeDisabledAnswers( values: FormItems, context: ItemContext, ): FormItems { - return removeDisabledAnswersRecursive({ + const afterRecursive = removeDisabledAnswersRecursive({ questionnaireItems: questionnaire.item ?? [], parentPath: [], answersItems: values, initialValues: {}, context, }); + return removeEnableWhenExpressionHelperItems(questionnaire, afterRecursive); +} + +/** + * Removes virtual enableWhenExpression helper items (linkId ending with ENABLE_WHEN_EXPRESSION_SUFFIX) + * from form values so they do not appear in the QR. Recurses into nested items. + */ +export function removeEnableWhenExpressionHelperItems( + questionnaire: FCEQuestionnaire, + formItems: FormItems, +): FormItems { + return removeEnableWhenExpressionHelperItemsRecursive(questionnaire.item ?? [], formItems); +} + +function removeEnableWhenExpressionHelperItemsRecursive( + questionnaireItems: FCEQuestionnaireItem[], + formItems: FormItems, +): FormItems { + return Object.fromEntries( + Object.entries(formItems) + .filter(([linkId]) => !isEnableWhenExpressionHelperLinkId(linkId)) + .map(([linkId, value]) => { + if (value == null) { + return [linkId, value]; + } + const questionnaireItem = questionnaireItems.find((i) => i.linkId === linkId); + if (Array.isArray(value)) { + return [ + linkId, + value.map((answer) => + answer?.items && questionnaireItem + ? { + ...answer, + items: removeEnableWhenExpressionHelperItemsRecursive( + questionnaireItem.item ?? [], + answer.items, + ), + } + : answer, + ), + ]; + } + if (!questionnaireItem || !isFormGroupItems(questionnaireItem, value)) { + return [linkId, value]; + } + if (!value.items) { + return [linkId, value]; + } + if (isRepeatableFormGroupItems(questionnaireItem, value)) { + return [ + linkId, + { + ...value, + items: value.items.map((group) => + removeEnableWhenExpressionHelperItemsRecursive(questionnaireItem.item ?? [], group), + ), + }, + ]; + } + return [ + linkId, + { + ...value, + items: removeEnableWhenExpressionHelperItemsRecursive( + questionnaireItem.item ?? [], + value.items, + ), + }, + ]; + }), + ) as FormItems; } interface RemoveDisabledAnswersRecursiveArgs { diff --git a/tests/utils.test.ts b/tests/utils.test.ts index a98e297..f2e645f 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -3,16 +3,19 @@ import { describe, expect, test, vi } from 'vitest'; import { calcInitialContext, compareValue, + ENABLE_WHEN_EXPRESSION_SUFFIX, + expandEnableWhenExpressions, getAnswerValues, getBranchItems, getChoiceTypeValue, - getEnabledQuestions, isAnswerValueEmpty, + isEnableWhenExpressionHelperLinkId, isValueEmpty, isValueEqual, mapFormToResponse, mapResponseToForm, removeDisabledAnswers, + removeEnableWhenExpressionHelperItems, parseFhirQueryExpression, resolveTemplateExpr, toAnswerValue, @@ -1293,123 +1296,348 @@ describe('enableWhen in deep nested', () => { }); }); -test('enableWhenExpression logic', () => { - const questionnaire: FCEQuestionnaire = { - resourceType: 'Questionnaire', - status: 'active', - item: [ - { - linkId: 'root-group', - type: 'group', - text: 'Root group', - item: [ - { - linkId: 'non-repeatable-group', - type: 'group', - text: 'Non Repeatable group', - item: [ - { linkId: 'condition', text: 'Condition', type: 'boolean' }, - { - linkId: 'question-for-yes', - text: 'Question for yes', - type: 'text', - enableWhenExpression: { - language: 'text/fhirpath', - expression: - "%resource.repeat(item).where(linkId = 'condition').answer.valueBoolean = true", - }, - }, - { - linkId: 'question-for-no', - text: 'Question for no', - type: 'text', - enableWhenExpression: { - language: 'text/fhirpath', - expression: - "%resource.repeat(item).where(linkId = 'condition').answer.valueBoolean = false", - }, - }, - ], +describe('expandEnableWhenExpressions', () => { + test('returns copy unchanged when no item has enableWhenExpression', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { linkId: 'q1', type: 'string' }, + { linkId: 'q2', type: 'boolean' }, + ], + }; + const result = expandEnableWhenExpressions(questionnaire); + expect(result).not.toBe(questionnaire); + expect(result.item).toHaveLength(2); + expect(result.item?.[0]?.linkId).toBe('q1'); + expect(result.item?.[1]?.linkId).toBe('q2'); + }); + + test('inserts boolean calculated item before item with enableWhenExpression and replaces with enableWhen', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { linkId: 'q-without-ewe', type: 'string' }, + { + linkId: 'q-with-ewe', + type: 'string', + enableWhenExpression: { + language: 'text/fhirpath', + expression: "%resource.item.where(linkId='q-without-ewe').answer.valueString.exists()", }, - ], + }, + ], + }; + const result = expandEnableWhenExpressions(questionnaire); + expect(result.item).toHaveLength(3); + expect(result.item?.[0]?.linkId).toBe('q-without-ewe'); + expect(result.item?.[1]?.linkId).toBe('q-with-ewe-enable-when-expression'); + expect(result.item?.[1]?.type).toBe('boolean'); + expect((result.item?.[1] as FCEQuestionnaireItem).calculatedExpression).toEqual({ + language: 'text/fhirpath', + expression: "%resource.item.where(linkId='q-without-ewe').answer.valueString.exists()", + }); + expect(result.item?.[2]?.linkId).toBe('q-with-ewe'); + expect((result.item?.[2] as FCEQuestionnaireItem).enableWhenExpression).toBeUndefined(); + expect((result.item?.[2] as FCEQuestionnaireItem).enableWhen).toEqual([ + { + question: 'q-with-ewe-enable-when-expression', + operator: '=', + answerBoolean: true, }, - ], - }; + ]); + }); - const qr: QuestionnaireResponse = { - resourceType: 'QuestionnaireResponse', - status: 'completed', - item: [ - { - linkId: 'root-group', - item: [ - { - linkId: 'non-repeatable-group', - item: [ - { - linkId: 'condition', - answer: [{ valueBoolean: true }], - }, - { - linkId: 'question-for-yes', - answer: [{ valueString: 'yes' }], - }, - { - linkId: 'question-for-no', - answer: [{ valueString: 'no' }], + test('recurses into nested groups and expands enableWhenExpression in children', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'group', + type: 'group', + item: [ + { linkId: 'q-a', type: 'string' }, + { + linkId: 'q-b', + type: 'string', + enableWhenExpression: { + language: 'text/fhirpath', + expression: "'true'", }, - ], + }, + ], + }, + ], + }; + const result = expandEnableWhenExpressions(questionnaire); + const group = result.item?.[0]; + expect(group?.type).toBe('group'); + const children = (group as FCEQuestionnaireItem).item ?? []; + expect(children).toHaveLength(3); + expect(children[0]?.linkId).toBe('q-a'); + expect(children[1]?.linkId).toBe('q-b-enable-when-expression'); + expect(children[1]?.type).toBe('boolean'); + expect((children[1] as FCEQuestionnaireItem).calculatedExpression?.expression).toBe("'true'"); + expect(children[2]?.linkId).toBe('q-b'); + expect((children[2] as FCEQuestionnaireItem).enableWhen).toEqual([ + { question: 'q-b-enable-when-expression', operator: '=', answerBoolean: true }, + ]); + }); + + test('moves variable from non-group item to helper so enableWhenExpression can reference it (issue #47)', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'q-root-1', + type: 'string', + variable: [{ name: 'MyVar', language: 'text/fhirpath', expression: "'value'" }], + enableWhenExpression: { + language: 'text/fhirpath', + expression: '%MyVar', }, - ], - }, - ], - }; - const expectedQR: QuestionnaireResponse = { - resourceType: 'QuestionnaireResponse', - status: 'completed', - item: [ - { - linkId: 'root-group', - item: [ - { - linkId: 'non-repeatable-group', - item: [ - { - linkId: 'condition', - answer: [{ valueBoolean: true }], - }, - { - linkId: 'question-for-yes', - answer: [{ valueString: 'yes' }], - }, - ], + }, + ], + }; + const result = expandEnableWhenExpressions(questionnaire); + expect(result.item).toHaveLength(2); + const helper = result.item?.[0] as FCEQuestionnaireItem; + const original = result.item?.[1] as FCEQuestionnaireItem; + expect(helper.linkId).toBe('q-root-1-enable-when-expression'); + expect(helper.variable).toHaveLength(1); + expect(helper.variable?.[0]?.name).toBe('MyVar'); + expect(original.linkId).toBe('q-root-1'); + expect(original.variable).toBeUndefined(); + }); + + test('copies variable to helper when original is a group (group keeps variable)', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'a', + type: 'group', + variable: [{ name: 'X', language: 'text/fhirpath', expression: '1' }], + enableWhenExpression: { + language: 'text/fhirpath', + expression: '%X = 1', }, + item: [{ linkId: 'q-a-1', type: 'string' }], + }, + ], + }; + const result = expandEnableWhenExpressions(questionnaire); + expect(result.item).toHaveLength(2); + const helper = result.item?.[0] as FCEQuestionnaireItem; + const group = result.item?.[1] as FCEQuestionnaireItem; + expect(helper.linkId).toBe('a-enable-when-expression'); + expect(helper.variable).toHaveLength(1); + expect(helper.variable?.[0]?.name).toBe('X'); + expect(group.type).toBe('group'); + expect(group.variable).toHaveLength(1); + expect(group.variable?.[0]?.name).toBe('X'); + }); +}); + +describe('removeEnableWhenExpressionHelperItems', () => { + const suffix = ENABLE_WHEN_EXPRESSION_SUFFIX; + + test('isEnableWhenExpressionHelperLinkId identifies helper linkIds', () => { + expect(isEnableWhenExpressionHelperLinkId('q1' + suffix)).toBe(true); + expect(isEnableWhenExpressionHelperLinkId('group-enable-when-expression')).toBe(true); + expect(isEnableWhenExpressionHelperLinkId('q1')).toBe(false); + expect(isEnableWhenExpressionHelperLinkId('q1-enable-when')).toBe(false); + }); + + test('strips top-level helper linkId and keeps non-helper entries', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { linkId: 'q1', type: 'string' }, + { linkId: 'q1' + suffix, type: 'boolean' }, + { linkId: 'q2', type: 'boolean' }, + ], + }; + const formItems: FormItems = { + q1: [{ value: { string: 'a' } }], + ['q1' + suffix]: [{ value: { boolean: true } }], + q2: [{ value: { boolean: false } }], + }; + const result = removeEnableWhenExpressionHelperItems(questionnaire, formItems); + expect(Object.keys(result)).toEqual(['q1', 'q2']); + expect(result['q1']).toEqual([{ value: { string: 'a' } }]); + expect(result['q2']).toEqual([{ value: { boolean: false } }]); + expect(result['q1' + suffix]).toBeUndefined(); + }); + + test('recurses into non-repeatable group and strips helper items inside', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'g', + type: 'group', + item: [ + { linkId: 'a', type: 'string' }, + { linkId: 'a' + suffix, type: 'boolean' }, + { linkId: 'b', type: 'boolean' }, + ], + }, + ], + }; + const formItems: FormItems = { + g: { + items: { + a: [{ value: { string: 'x' } }], + ['a' + suffix]: [{ value: { boolean: true } }], + b: [{ value: { boolean: false } }], + }, + }, + }; + const result = removeEnableWhenExpressionHelperItems(questionnaire, formItems); + expect(result.g).toBeDefined(); + const g = result.g as { items: FormItems }; + expect(Object.keys(g.items)).toEqual(['a', 'b']); + expect(g.items['a']).toEqual([{ value: { string: 'x' } }]); + expect(g.items['b']).toEqual([{ value: { boolean: false } }]); + expect(g.items['a' + suffix]).toBeUndefined(); + }); + + test('recurses into repeatable group and strips helper items in each instance', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'g', + type: 'group', + repeats: true, + item: [ + { linkId: 'x', type: 'string' }, + { linkId: 'x' + suffix, type: 'boolean' }, + ], + }, + ], + }; + const helperLinkId = 'x' + suffix; + const formItems: FormItems = { + g: { + items: [ + { x: [{ value: { string: '1' } }], [helperLinkId]: [{ value: { boolean: true } }] }, + { x: [{ value: { string: '2' } }], [helperLinkId]: [{ value: { boolean: false } }] }, ], }, - ], - }; - const formItems = mapResponseToForm(qr, questionnaire); - const enabledQuestionsLinkIds = getEnabledQuestions( - questionnaire.item?.[0]?.item?.[0]?.item ?? [], - ['items', 'root-group', 'items'], - formItems, - { - questionnaire, - resource: qr, - context: qr, - }, - ).map((questionnaireItem) => questionnaireItem.linkId); + }; + const result = removeEnableWhenExpressionHelperItems(questionnaire, formItems); + const g = result.g as { items: FormItems[] }; + expect(g.items).toHaveLength(2); + expect(Object.keys(g.items[0]!)).toEqual(['x']); + expect(g.items[0]!['x']).toEqual([{ value: { string: '1' } }]); + expect(Object.keys(g.items[1]!)).toEqual(['x']); + expect(g.items[1]!['x']).toEqual([{ value: { string: '2' } }]); + }); - expect(enabledQuestionsLinkIds).toStrictEqual(['condition', 'question-for-yes']); + test('recurses into answer array sub-items (repeatable question with nested items) and strips helpers', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'repeat-q', + type: 'group', + repeats: true, + item: [ + { linkId: 'nested', type: 'string' }, + { linkId: 'nested' + suffix, type: 'boolean' }, + ], + }, + ], + }; + const formItems: FormItems = { + 'repeat-q': [ + { + items: { + nested: [{ value: { string: 'v1' } }], + ['nested' + suffix]: [{ value: { boolean: true } }], + }, + }, + ], + }; + const result = removeEnableWhenExpressionHelperItems(questionnaire, formItems); + const arr = result['repeat-q'] as FormAnswerItems[]; + expect(arr).toHaveLength(1); + expect(arr[0]?.items).toBeDefined(); + const nested = (arr[0] as { items: FormItems }).items; + expect(Object.keys(nested)).toEqual(['nested']); + expect(nested['nested']).toEqual([{ value: { string: 'v1' } }]); + expect(nested['nested' + suffix]).toBeUndefined(); + }); - const enabledFormItems = removeDisabledAnswers(questionnaire, formItems, { - questionnaire, - resource: qr, - context: qr, + test('when no questionnaire item found for linkId, keeps value as-is', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [{ linkId: 'known', type: 'string' }], + }; + const formItems: FormItems = { + known: [{ value: { string: 'ok' } }], + unknown: [{ value: { string: 'keep' } }], + }; + const result = removeEnableWhenExpressionHelperItems(questionnaire, formItems); + expect(result.known).toEqual([{ value: { string: 'ok' } }]); + expect(result.unknown).toEqual([{ value: { string: 'keep' } }]); }); - const actualQR = { ...qr, ...mapFormToResponse(enabledFormItems, questionnaire) }; - expect(actualQR).toEqual(expectedQR); + test('empty formItems returns empty object', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [], + }; + const result = removeEnableWhenExpressionHelperItems(questionnaire, {}); + expect(result).toEqual({}); + }); + + test('mixed: helper at root and inside nested group', () => { + const questionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { linkId: 'root', type: 'string' }, + { linkId: 'root' + suffix, type: 'boolean' }, + { + linkId: 'group', + type: 'group', + item: [ + { linkId: 'inner', type: 'string' }, + { linkId: 'inner' + suffix, type: 'boolean' }, + ], + }, + ], + }; + const formItems: FormItems = { + root: [{ value: { string: 'r' } }], + ['root' + suffix]: [{ value: { boolean: true } }], + group: { + items: { + inner: [{ value: { string: 'i' } }], + ['inner' + suffix]: [{ value: { boolean: false } }], + }, + }, + }; + const result = removeEnableWhenExpressionHelperItems(questionnaire, formItems); + expect(Object.keys(result)).toEqual(['root', 'group']); + expect(result['root' + suffix]).toBeUndefined(); + const group = result.group as { items: FormItems }; + expect(Object.keys(group.items)).toEqual(['inner']); + expect(group.items['inner' + suffix]).toBeUndefined(); + }); }); describe('enableWhen exists logic for non-repeatable groups primitives', () => {