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', () => {