From c387b788c34dd402775dde32a53466d4a1ea6eb3 Mon Sep 17 00:00:00 2001 From: brian_pos Date: Wed, 15 Jul 2026 16:32:44 +1000 Subject: [PATCH 1/2] Preserve FHIR types for Liquid variables Keep internal FHIRPath resource nodes across template evaluations so unprefixed Liquid loop variables retain polymorphic property typing. Resolve nodes only at boolean and rendering boundaries, with regression coverage for Extension.value. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- helpers/fhirliquid-engine.ts | 24 +++++++++++++----- test/fhirliquid-engine.test.ts | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/helpers/fhirliquid-engine.ts b/helpers/fhirliquid-engine.ts index c63e849..b23af50 100644 --- a/helpers/fhirliquid-engine.ts +++ b/helpers/fhirliquid-engine.ts @@ -571,7 +571,9 @@ export class FhirLiquidEngine { expression: ExpressionContext, state: EvaluationState, ): boolean { - const values = this.evaluateExpression(expression, state); + const values = this.evaluateExpression(expression, state) + .map(value => fhirpath.resolveInternalTypes(value)) + .filter(value => value !== null && value !== undefined); if (values.length === 0) return false; if (values.length !== 1 || typeof values[0] !== "boolean") { throw new Error( @@ -599,7 +601,7 @@ export class FhirLiquidEngine { expressionText, variables, this.model, - { async: false }, + { async: false, resolveInternalTypes: false }, ); } @@ -616,13 +618,14 @@ export class FhirLiquidEngine { ): string { const context: FhirLiquidRenderContext = { resource: state.resource, - variables: variablesFromState(state), + variables: resolvedVariablesFromState(state), }; const rendered: string[] = []; for (const value of values) { - if (value === null || value === undefined) continue; - const custom = this.renderValue?.(value, context); - rendered.push(custom ?? defaultValueToString(value)); + const resolvedValue = fhirpath.resolveInternalTypes(value); + if (resolvedValue === null || resolvedValue === undefined) continue; + const custom = this.renderValue?.(resolvedValue, context); + rendered.push(custom ?? defaultValueToString(resolvedValue)); } return rendered.join(", "); } @@ -644,6 +647,15 @@ function variablesFromState(state: EvaluationState): Record { }; } +function resolvedVariablesFromState( + state: EvaluationState, +): Record { + return Object.fromEntries( + Object.entries(variablesFromState(state)) + .map(([name, value]) => [name, fhirpath.resolveInternalTypes(value)]), + ); +} + function itemsBetween( items: TItem[], startExclusive: number, diff --git a/test/fhirliquid-engine.test.ts b/test/fhirliquid-engine.test.ts index 9b70550..46adff3 100644 --- a/test/fhirliquid-engine.test.ts +++ b/test/fhirliquid-engine.test.ts @@ -16,6 +16,22 @@ const patient = { ], }; +const patientWithExtensions = { + ...patient, + meta: { + extension: [ + { + url: "u1", + valueString: "sample extension string", + }, + { + url: "u12", + valueMarkdown: "### a simple header", + }, + ], + }, +}; + describe("FhirLiquidEngine", () => { it("renders FHIRPath output and Liquid filters", () => { const engine = new FhirLiquidEngine(); @@ -59,6 +75,18 @@ describe("FhirLiquidEngine", () => { expect(evaluateFhirLiquid(template, patient)).toBe("1=Chalmers,3=Jones;"); }); + it("retains FHIR type information for loop variables", () => { + const template = "{% for i in Patient.meta.extension %}" + + "[{{ i.value }}|{{ %i.value }}|" + + "{{ i.valueString }}|{{ i.valueMarkdown }}]" + + "{% endfor %}"; + + expect(evaluateFhirLiquid(template, patientWithExtensions)).toBe( + "[sample extension string|sample extension string|sample extension string|]" + + "[### a simple header|### a simple header||### a simple header]", + ); + }); + it("applies loop modifiers and renders the empty branch", () => { expect(evaluateFhirLiquid( "{% for name in Patient.name reversed offset: 1 limit: 2 %}" @@ -92,6 +120,24 @@ describe("FhirLiquidEngine", () => { )).toContain("Important"); }); + it("passes resolved values to custom renderers", () => { + const renderValue = jest.fn(() => undefined); + const engine = new FhirLiquidEngine({ renderValue }); + + expect(engine.evaluate( + "{% for name in Patient.name limit: 1 %}{{ name }}{% endfor %}", + patient, + )) + .toContain("\"family\":\"Chalmers\""); + expect(renderValue).toHaveBeenCalledWith( + patient.name[0], + expect.objectContaining({ + resource: patient, + variables: expect.objectContaining({ name: patient.name[0] }), + }), + ); + }); + it("rejects invalid templates and multi-value assignments", () => { const engine = new FhirLiquidEngine(); From 024f5d31943fb21200d0870f2c7cbca29684b9d4 Mon Sep 17 00:00:00 2001 From: brian_pos Date: Wed, 15 Jul 2026 16:34:46 +1000 Subject: [PATCH 2/2] Keep active Liquid output tab visible Exclude hidden tabs from the active split-pane set and avoid switching away from an output tab that is already visible after template evaluation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- vue3-src/app/components/TwinPaneTab.vue | 4 ++-- vue3-src/app/pages/fhir-liquid.vue | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/vue3-src/app/components/TwinPaneTab.vue b/vue3-src/app/components/TwinPaneTab.vue index a2faecd..2640571 100644 --- a/vue3-src/app/components/TwinPaneTab.vue +++ b/vue3-src/app/components/TwinPaneTab.vue @@ -343,8 +343,8 @@ const getActiveTabs = (): TabData[] => { } else { const lockedTabData = props.tabs[lockedTab.value] const selectableTabData = props.tabs[selectableTab.value] - if (lockedTabData) result.push(lockedTabData) - if (selectableTabData) result.push(selectableTabData) + if (lockedTabData && lockedTabData.show) result.push(lockedTabData) + if (selectableTabData && selectableTabData.show) result.push(selectableTabData) } return result } diff --git a/vue3-src/app/pages/fhir-liquid.vue b/vue3-src/app/pages/fhir-liquid.vue index 6301e95..ee50fb3 100644 --- a/vue3-src/app/pages/fhir-liquid.vue +++ b/vue3-src/app/pages/fhir-liquid.vue @@ -102,6 +102,7 @@ useHead({ interface TwinPaneControl { selectTab(tabIndex: number): void; + getActiveTabs(): TabData[]; } interface ResourceEditorControl { @@ -119,6 +120,7 @@ const tabSpaces = 2; const TEMPLATE_TAB = 0; const RESOURCE_TAB = 1; const OUTPUT_TAB = 2; +const OUTPUT_HTML_TAB = 3; const ERRORS_TAB = 4; const twinTabControl = ref(); const templateEditor = ref(); @@ -235,8 +237,12 @@ function evaluateTemplate(): void { } errorOutcome.value = undefined; - twinTabControl.value?.selectTab(OUTPUT_TAB); - showSuccessMessage("Template evaluated successfully."); + var activeTabs = twinTabControl.value?.getActiveTabs() ?? []; + // only switch tabs if either of the output tabs are not currently active + if (!activeTabs.some(tab => tab.tabName === "Output" || tab.tabName === "Output HTML")) { + twinTabControl.value?.selectTab(hasHtmlOutput.value ? OUTPUT_HTML_TAB : OUTPUT_TAB); + } + // twinTabControl.value?.selectTab(OUTPUT_TAB); } function ctrlEnterHandler(event: KeyboardEvent): void {