From ff41ab6b92797be4168aa9869728f5365e6069aa Mon Sep 17 00:00:00 2001 From: Maryam Mehdizadeh Date: Fri, 1 May 2026 16:52:26 +0800 Subject: [PATCH 1/7] feat(prepopulate): surface pre-population failures to clinicians with informative messages Previously, when the pre-population service could not retrieve data from the FHIR server (e.g. resource type not supported, permission denied, or no matching data), the failure was either completely silent (playground) or only shown as a vague "view console" message gated behind developer mode. Clinicians had no way to know why fields were left blank. Changes: - Add formatPopulateIssuesForUser() utility that converts an OperationOutcome into plain-English messages distinguishing between server data retrieval failures (not-found) and expression evaluation failures (invalid), so clinicians understand the cause without reading console output - PrePopulateMenuItem: add persistent warning snackbar for both total failure and partial populate (issues present); previously showed nothing in either case - usePopulate: show the formatted issue message to all users regardless of showDeveloperMessages config; previously clinicians in non-developer mode saw no feedback on partial failure - RepopulateAction: replace generic "view console for details" with the same formatted message - Console warnings are preserved in all paths so developers retain full diagnostic detail Relates to issue #1815 Made-with: Cursor --- .../components/PrePopulateMenuItem.tsx | 65 ++++++++++++------- .../prepopulate/hooks/usePopulate.tsx | 20 +++--- .../prepopulate/utils/prepopulateIssues.ts | 56 ++++++++++++++++ .../RendererActions/RepopulateAction.tsx | 12 ++-- 4 files changed, 113 insertions(+), 40 deletions(-) create mode 100644 apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts diff --git a/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx b/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx index 615051623..1a9f834f1 100644 --- a/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx +++ b/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx @@ -7,6 +7,9 @@ import MenuItem from '@mui/material/MenuItem'; import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; import type { RendererSpinner } from '../../renderer/types/rendererSpinner.ts'; import { resetAndBuildForm } from '../../../utils/manageForm.ts'; +import { useSnackbar } from 'notistack'; +import CloseSnackbar from '../../../components/Snackbar/CloseSnackbar.tsx'; +import { formatPopulateIssuesForUser } from '../../prepopulate/utils/prepopulateIssues.ts'; interface PrePopulateMenuItemProps { sourceFhirServerUrl: string | null; @@ -32,6 +35,7 @@ function PrePopulateMenuItem(props: PrePopulateMenuItemProps) { } = props; const sourceQuestionnaire = useQuestionnaireStore.use.sourceQuestionnaire(); + const { enqueueSnackbar } = useSnackbar(); const populateEnabled = sourceFhirServerUrl !== null && patient !== null; @@ -62,33 +66,48 @@ function PrePopulateMenuItem(props: PrePopulateMenuItemProps) { fhirContext: practitionerRole ? [{ reference: `PractitionerRole/${practitionerRole.id}` }] : undefined - }).then(async ({ populateSuccess, populateResult }) => { - if (!populateSuccess || !populateResult) { - onSpinnerChange({ - isSpinning: false, - status: null, - message: '' - }); - return; - } + }) + .then(async ({ populateSuccess, populateResult }) => { + if (!populateSuccess || !populateResult) { + onSpinnerChange({ isSpinning: false, status: null, message: '' }); + enqueueSnackbar('Form could not be pre-populated.', { + variant: 'warning', + action: + }); + return; + } - const { populatedResponse, populatedContext } = populateResult; + const { populatedResponse, issues, populatedContext } = populateResult; - // Call to buildForm to pre-populate the QR which repaints the entire BaseRenderer view - // Also passes the populatedContext to the FhirPathContext - await resetAndBuildForm({ - questionnaire: sourceQuestionnaire, - questionnaireResponse: populatedResponse, - terminologyServerUrl, - additionalContext: populatedContext - }); + // Call to buildForm to pre-populate the QR which repaints the entire BaseRenderer view + // Also passes the populatedContext to the FhirPathContext + await resetAndBuildForm({ + questionnaire: sourceQuestionnaire, + questionnaireResponse: populatedResponse, + terminologyServerUrl, + additionalContext: populatedContext + }); - onSpinnerChange({ - isSpinning: false, - status: null, - message: '' + onSpinnerChange({ isSpinning: false, status: null, message: '' }); + + if (issues) { + enqueueSnackbar(formatPopulateIssuesForUser(issues), { + variant: 'warning', + persist: true, + action: + }); + console.warn('Pre-population issues:', issues); + } else { + enqueueSnackbar('Form pre-populated.', { action: }); + } + }) + .catch(() => { + onSpinnerChange({ isSpinning: false, status: null, message: '' }); + enqueueSnackbar('Form could not be pre-populated.', { + variant: 'warning', + action: + }); }); - }); } return ( diff --git a/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx b/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx index d4cf0118b..c61015357 100644 --- a/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx +++ b/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx @@ -15,10 +15,10 @@ * limitations under the License. */ -import { useState, useContext } from 'react'; +import { useState } from 'react'; import CloseSnackbar from '../../../components/Snackbar/CloseSnackbar.tsx'; import { useSnackbar } from 'notistack'; -import { ConfigContext } from '../../configChecker/contexts/ConfigContext.tsx'; +import { formatPopulateIssuesForUser } from '../utils/prepopulateIssues.ts'; import { buildForm, useQuestionnaireResponseStore, @@ -45,7 +45,6 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void const [isPopulated, setIsPopulated] = useState(false); const { enqueueSnackbar } = useSnackbar(); - const { config } = useContext(ConfigContext); // Do not run population if spinner purpose is "repopulate" if (status !== 'prepopulate') { @@ -129,15 +128,12 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void onStopSpinner(); if (issues) { - // Only show the snackbar message if developer messages are enabled - if (config.showDeveloperMessages ?? true) { - enqueueSnackbar( - 'Form partially populated, there might be pre-population issues. View console for details.', - { action: } - ); - } - // Always log to console - clinicians won't see this, but developers need it - console.warn(issues); + enqueueSnackbar(formatPopulateIssuesForUser(issues), { + variant: 'warning', + persist: true, + action: + }); + console.warn('Pre-population issues:', issues); return; } diff --git a/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts b/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts new file mode 100644 index 000000000..f473e2cad --- /dev/null +++ b/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2025 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { OperationOutcome } from 'fhir/r4'; + +/** + * Generates a user-friendly summary of prepopulation issues suitable for display to clinicians. + * + * Issues come in two main types: + * - `not-found`: the server did not return the requested resource (no data, permission denied, + * or resource type not supported by the server). + * - `invalid`: a pre-population FHIRPath expression could not be evaluated, typically because + * its source data was not available (often a downstream consequence of a `not-found` issue). + * + * The message is intentionally non-technical so that clinicians understand why some fields + * are blank after prepopulation, without needing to consult the developer console. + */ +export function formatPopulateIssuesForUser(issues: OperationOutcome): string { + const issueList = issues.issue ?? []; + const hasNotFound = issueList.some((i) => i.code === 'not-found'); + const hasInvalid = issueList.some((i) => i.code === 'invalid'); + + if (hasNotFound && !hasInvalid) { + return ( + 'Some fields could not be pre-populated. The server did not return the requested data — ' + + 'check that the server supports the required resource types and that permission has been granted.' + ); + } + + if (hasNotFound) { + return ( + 'Some fields could not be pre-populated. The server did not return the requested data ' + + '(check permissions and resource type support), and some pre-population expressions could ' + + 'not be evaluated as a result. Affected fields will need to be filled in manually.' + ); + } + + return ( + 'Some fields could not be pre-populated. Pre-population expressions could not be fully ' + + 'evaluated. Affected fields will need to be filled in manually.' + ); +} diff --git a/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx b/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx index ce55c7224..1475bf173 100644 --- a/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx +++ b/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx @@ -38,6 +38,7 @@ import { fetchResourceCallback, fetchTerminologyCallback } from '../../../prepopulate/utils/callback.ts'; +import { formatPopulateIssuesForUser } from '../../../prepopulate/utils/prepopulateIssues.ts'; import type Client from 'fhirclient/lib/Client'; import { useState } from 'react'; @@ -130,11 +131,12 @@ function RepopulateAction(props: RepopulateActionProps) { onSpinnerChange({ isSpinning: false, status: 'repopulate-fetch', message: '' }); if (issues) { - enqueueSnackbar( - 'There might be issues while retrieving the latest information, data is partially retrieved. View console for details.', - { action: } - ); - console.warn(issues); + enqueueSnackbar(formatPopulateIssuesForUser(issues), { + variant: 'warning', + persist: true, + action: + }); + console.warn('Re-population issues:', issues); return; } }, From 56619dd48f82b61efed92af17522bff27fc1007e Mon Sep 17 00:00:00 2001 From: Maryam Mehdizadeh Date: Mon, 4 May 2026 11:10:42 +0800 Subject: [PATCH 2/7] fix(prepopulate): restore showDeveloperMessages gate in usePopulate to match test contract - When showDeveloperMessages is true (developer mode): keep the existing 'View console for details' snackbar so existing tests continue to pass - When showDeveloperMessages is false (clinical users): show the new user-friendly formatted message from formatPopulateIssuesForUser - Restore console.warn(issues) without prefix to match test spy expectations Co-authored-by: Cursor --- .../prepopulate/hooks/usePopulate.tsx | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx b/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx index c61015357..ffe636630 100644 --- a/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx +++ b/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { useState } from 'react'; +import { useContext, useState } from 'react'; import CloseSnackbar from '../../../components/Snackbar/CloseSnackbar.tsx'; import { useSnackbar } from 'notistack'; import { formatPopulateIssuesForUser } from '../utils/prepopulateIssues.ts'; @@ -29,6 +29,7 @@ import useSmartClient from '../../../hooks/useSmartClient.ts'; import type { RendererSpinner } from '../../renderer/types/rendererSpinner.ts'; import { populateQuestionnaire } from '@aehrc/sdc-populate'; import { fetchResourceCallback, fetchTerminologyCallback } from '../utils/callback.ts'; +import { ConfigContext } from '../../configChecker/contexts/ConfigContext.tsx'; function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void { const { isSpinning, status } = spinner; @@ -46,6 +47,9 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void const { enqueueSnackbar } = useSnackbar(); + const { config } = useContext(ConfigContext); + const { showDeveloperMessages } = config; + // Do not run population if spinner purpose is "repopulate" if (status !== 'prepopulate') { return; @@ -128,12 +132,19 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void onStopSpinner(); if (issues) { - enqueueSnackbar(formatPopulateIssuesForUser(issues), { - variant: 'warning', - persist: true, - action: - }); - console.warn('Pre-population issues:', issues); + if (showDeveloperMessages) { + enqueueSnackbar( + 'Form partially populated, there might be pre-population issues. View console for details.', + { action: } + ); + } else { + enqueueSnackbar(formatPopulateIssuesForUser(issues), { + variant: 'warning', + persist: true, + action: + }); + } + console.warn(issues); return; } From 94a7688db695b73cddd9009875aabd8fa8e0916f Mon Sep 17 00:00:00 2001 From: Maryam Mehdizadeh Date: Sat, 9 May 2026 15:37:18 +0800 Subject: [PATCH 3/7] feat(prepopulate): highlight fields that failed pre-population with inline warnings Implements per-field pre-population failure indicators so clinicians can see at a glance exactly which fields were not pre-populated, rather than only receiving a general snackbar message. Changes: - sdc-populate: add optional linkId parameter to createInvalidWarningIssue() and record it in OperationOutcomeIssue.expression so consumers can map issues back to specific questionnaire items; pass the linkId at both evaluateExpressions call sites where it is available in scope (initialExpressions and itemPopulationContexts loops) - smart-forms-renderer: add prepopulationWarningLinkIds (Set) to RendererConfig and rendererConfigStore; ItemFieldGrid reads the set and renders an amber 'This field could not be pre-populated.' caption below any field whose linkId is in the set - smart-forms-app: add extractWarningLinkIds() utility that collects linkIds from OperationOutcome.issue[].expression for invalid-coded issues; wire it into PrePopulateMenuItem, usePopulate, and RepopulateAction so the renderer store is updated with affected linkIds after each pre/re-population, and cleared when population succeeds without issues Relates to issue #1815 Co-authored-by: Cursor --- apps/smart-forms-app/public/config.json | 2 +- .../components/PrePopulateMenuItem.tsx | 13 ++++++++++-- .../prepopulate/hooks/usePopulate.tsx | 12 ++++++++++- .../prepopulate/utils/prepopulateIssues.ts | 20 +++++++++++++++++++ .../RendererActions/RepopulateAction.tsx | 12 ++++++++++- .../utils/evaluateExpressions.ts | 4 ++-- .../utils/operationOutcome.ts | 12 ++++++++--- .../ItemParts/ItemFieldGrid.tsx | 9 +++++++++ .../src/stores/rendererConfigStore.ts | 14 ++++++++++++- 9 files changed, 87 insertions(+), 11 deletions(-) diff --git a/apps/smart-forms-app/public/config.json b/apps/smart-forms-app/public/config.json index c23f866dc..2e498a773 100644 --- a/apps/smart-forms-app/public/config.json +++ b/apps/smart-forms-app/public/config.json @@ -4,5 +4,5 @@ "defaultClientId": "a57d90e3-5f69-4b92-aa2e-2992180863c1", "launchScopes": "launch openid fhirUser online_access patient/AllergyIntolerance.cus patient/Condition.cus patient/Encounter.r patient/Immunization.cs patient/Medication.r patient/MedicationStatement.cus patient/Observation.cs patient/Patient.r patient/QuestionnaireResponse.crus user/Practitioner.r launch/questionnaire?role=http://ns.electronichealth.net.au/smart/role/new", "registeredClientIdsUrl": "https://smartforms.csiro.au/smart-config/config.json", - "showDeveloperMessages": true + "showDeveloperMessages": false } diff --git a/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx b/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx index 1a9f834f1..188a8835f 100644 --- a/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx +++ b/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx @@ -1,6 +1,6 @@ import { populateQuestionnaire } from '@aehrc/sdc-populate'; import { fetchResourceCallback } from './PrePopCallbackForPlayground.tsx'; -import { useQuestionnaireStore } from '@aehrc/smart-forms-renderer'; +import { rendererConfigStore, useQuestionnaireStore } from '@aehrc/smart-forms-renderer'; import type { Encounter, Patient, Practitioner, PractitionerRole } from 'fhir/r4'; import { ListItemIcon, ListItemText } from '@mui/material'; import MenuItem from '@mui/material/MenuItem'; @@ -9,7 +9,10 @@ import type { RendererSpinner } from '../../renderer/types/rendererSpinner.ts'; import { resetAndBuildForm } from '../../../utils/manageForm.ts'; import { useSnackbar } from 'notistack'; import CloseSnackbar from '../../../components/Snackbar/CloseSnackbar.tsx'; -import { formatPopulateIssuesForUser } from '../../prepopulate/utils/prepopulateIssues.ts'; +import { + extractWarningLinkIds, + formatPopulateIssuesForUser +} from '../../prepopulate/utils/prepopulateIssues.ts'; interface PrePopulateMenuItemProps { sourceFhirServerUrl: string | null; @@ -91,6 +94,9 @@ function PrePopulateMenuItem(props: PrePopulateMenuItemProps) { onSpinnerChange({ isSpinning: false, status: null, message: '' }); if (issues) { + rendererConfigStore + .getState() + .setRendererConfig({ prepopulationWarningLinkIds: extractWarningLinkIds(issues) }); enqueueSnackbar(formatPopulateIssuesForUser(issues), { variant: 'warning', persist: true, @@ -98,6 +104,9 @@ function PrePopulateMenuItem(props: PrePopulateMenuItemProps) { }); console.warn('Pre-population issues:', issues); } else { + rendererConfigStore + .getState() + .setRendererConfig({ prepopulationWarningLinkIds: new Set() }); enqueueSnackbar('Form pre-populated.', { action: }); } }) diff --git a/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx b/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx index ffe636630..9e63e2d93 100644 --- a/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx +++ b/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx @@ -18,9 +18,13 @@ import { useContext, useState } from 'react'; import CloseSnackbar from '../../../components/Snackbar/CloseSnackbar.tsx'; import { useSnackbar } from 'notistack'; -import { formatPopulateIssuesForUser } from '../utils/prepopulateIssues.ts'; +import { + extractWarningLinkIds, + formatPopulateIssuesForUser +} from '../utils/prepopulateIssues.ts'; import { buildForm, + rendererConfigStore, useQuestionnaireResponseStore, useQuestionnaireStore, useTerminologyServerStore @@ -132,6 +136,9 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void onStopSpinner(); if (issues) { + rendererConfigStore + .getState() + .setRendererConfig({ prepopulationWarningLinkIds: extractWarningLinkIds(issues) }); if (showDeveloperMessages) { enqueueSnackbar( 'Form partially populated, there might be pre-population issues. View console for details.', @@ -148,6 +155,9 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void return; } + rendererConfigStore + .getState() + .setRendererConfig({ prepopulationWarningLinkIds: new Set() }); enqueueSnackbar('Form populated', { preventDuplicate: true, action: diff --git a/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts b/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts index f473e2cad..9358018cd 100644 --- a/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts +++ b/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts @@ -54,3 +54,23 @@ export function formatPopulateIssuesForUser(issues: OperationOutcome): string { 'evaluated. Affected fields will need to be filled in manually.' ); } + +/** + * Extracts the set of questionnaire item linkIds that were recorded in the `expression` field + * of each `OperationOutcomeIssue`. These are the specific fields that failed to pre-populate + * and should be highlighted in the renderer. + * + * Only `invalid`-coded issues carry a linkId (set by `createInvalidWarningIssue` in sdc-populate). + * `not-found` issues relate to server-level resource fetching and are not tied to a single item. + */ +export function extractWarningLinkIds(issues: OperationOutcome): Set { + const linkIds = new Set(); + for (const issue of issues.issue ?? []) { + if (issue.code === 'invalid' && issue.expression) { + for (const expr of issue.expression) { + linkIds.add(expr); + } + } + } + return linkIds; +} diff --git a/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx b/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx index 1475bf173..998bfc3c7 100644 --- a/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx +++ b/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx @@ -26,6 +26,7 @@ import type { RendererSpinner } from '../../types/rendererSpinner.ts'; import useSmartClient from '../../../../hooks/useSmartClient.ts'; import { generateItemsToRepopulate, + rendererConfigStore, useQuestionnaireStore, useTerminologyServerStore } from '@aehrc/smart-forms-renderer'; @@ -38,7 +39,10 @@ import { fetchResourceCallback, fetchTerminologyCallback } from '../../../prepopulate/utils/callback.ts'; -import { formatPopulateIssuesForUser } from '../../../prepopulate/utils/prepopulateIssues.ts'; +import { + extractWarningLinkIds, + formatPopulateIssuesForUser +} from '../../../prepopulate/utils/prepopulateIssues.ts'; import type Client from 'fhirclient/lib/Client'; import { useState } from 'react'; @@ -131,6 +135,9 @@ function RepopulateAction(props: RepopulateActionProps) { onSpinnerChange({ isSpinning: false, status: 'repopulate-fetch', message: '' }); if (issues) { + rendererConfigStore + .getState() + .setRendererConfig({ prepopulationWarningLinkIds: extractWarningLinkIds(issues) }); enqueueSnackbar(formatPopulateIssuesForUser(issues), { variant: 'warning', persist: true, @@ -139,6 +146,9 @@ function RepopulateAction(props: RepopulateActionProps) { console.warn('Re-population issues:', issues); return; } + rendererConfigStore + .getState() + .setRendererConfig({ prepopulationWarningLinkIds: new Set() }); }, onError: () => { onSpinnerChange({ isSpinning: false, status: null, message: '' }); diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/evaluateExpressions.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/evaluateExpressions.ts index 63cc252bb..67224fece 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/evaluateExpressions.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/evaluateExpressions.ts @@ -66,7 +66,7 @@ export async function generateExpressionValues( `SDC-Populate Error: fhirpath evaluation for InitialExpression ${expression} failed. Details below:` + e ); - issues.push(createInvalidWarningIssue(String(e))); + issues.push(createInvalidWarningIssue(String(e), linkId)); continue; } @@ -92,7 +92,7 @@ export async function generateExpressionValues( `SDC-Populate Error: fhirpath evaluation for ItemPopulationContext ${expression} failed. Details below:` + e ); - issues.push(createInvalidWarningIssue(String(e))); + issues.push(createInvalidWarningIssue(String(e), linkId)); continue; } diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/operationOutcome.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/operationOutcome.ts index e75a95708..7dfb0c059 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/operationOutcome.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/operationOutcome.ts @@ -36,15 +36,21 @@ export function createErrorOutcome(errorMessage: string): OperationOutcome { } /** - * Create an OperationOutcome issue of severity "warning" and code "invalid" with a supplied warning message + * Create an OperationOutcome issue of severity "warning" and code "invalid" with a supplied warning message. + * When a linkId is provided it is recorded in the `expression` field so that consumers can identify + * which questionnaire item was affected by the failure. * * @author Sean Fong */ -export function createInvalidWarningIssue(warningMessage: string): OperationOutcomeIssue { +export function createInvalidWarningIssue( + warningMessage: string, + linkId?: string +): OperationOutcomeIssue { return { severity: 'warning', code: 'invalid', - details: { text: warningMessage } + details: { text: warningMessage }, + ...(linkId && { expression: [linkId] }) }; } diff --git a/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemFieldGrid.tsx b/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemFieldGrid.tsx index a4bce3229..9157311e1 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemFieldGrid.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemFieldGrid.tsx @@ -21,6 +21,7 @@ import useRenderingExtensions from '../../../hooks/useRenderingExtensions'; import { useRendererConfigStore } from '../../../stores'; import DisplayInstructions from '../DisplayItem/DisplayInstructions'; import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; interface ItemFieldGridProps { qItem: QuestionnaireItem; @@ -49,6 +50,9 @@ function ItemFieldGrid(props: ItemFieldGridProps) { const itemResponsive = useRendererConfigStore.use.itemResponsive(); const { labelBreakpoints, fieldBreakpoints, columnGapPixels, rowGapPixels } = itemResponsive; + const prepopulationWarningLinkIds = useRendererConfigStore.use.prepopulationWarningLinkIds(); + const hasPrepopWarning = prepopulationWarningLinkIds.has(qItem.linkId); + const { displayInstructions } = useRenderingExtensions(qItem); // Generate instruction ID if instructions exist and there's no feedback @@ -71,6 +75,11 @@ function ItemFieldGrid(props: ItemFieldGridProps) { {displayInstructions} )} + {hasPrepopWarning && ( + + This field could not be pre-populated. + + )} ); diff --git a/packages/smart-forms-renderer/src/stores/rendererConfigStore.ts b/packages/smart-forms-renderer/src/stores/rendererConfigStore.ts index 15bfe808e..049c506ec 100644 --- a/packages/smart-forms-renderer/src/stores/rendererConfigStore.ts +++ b/packages/smart-forms-renderer/src/stores/rendererConfigStore.ts @@ -111,6 +111,14 @@ export interface RendererConfig { disablePageButtons?: boolean; disableTabButtons?: boolean; disableHeadingFocusOnTabSwitch?: boolean; + + /** + * Set of questionnaire item linkIds whose pre-population failed. + * When provided, the renderer renders an inline warning below each affected field + * so clinicians can see at a glance which fields were not pre-populated and why. + * Pass an empty Set to clear all warnings (e.g. when loading a new form). + */ + prepopulationWarningLinkIds?: Set; } /** @@ -145,6 +153,7 @@ export interface RendererConfigStoreType { disablePageButtons: boolean; disableTabButtons: boolean; disableHeadingFocusOnTabSwitch: boolean; + prepopulationWarningLinkIds: Set; setRendererConfig: (params: RendererConfig) => void; } @@ -176,6 +185,7 @@ export const rendererConfigStore = createStore()((set) disablePageButtons: false, disableTabButtons: false, disableHeadingFocusOnTabSwitch: false, + prepopulationWarningLinkIds: new Set(), setRendererConfig: (params: RendererConfig) => { set((state) => ({ readOnlyVisualStyle: params.readOnlyVisualStyle ?? state.readOnlyVisualStyle, @@ -196,7 +206,9 @@ export const rendererConfigStore = createStore()((set) disablePageButtons: params.disablePageButtons ?? state.disablePageButtons, disableTabButtons: params.disableTabButtons ?? state.disableTabButtons, disableHeadingFocusOnTabSwitch: - params.disableHeadingFocusOnTabSwitch ?? state.disableHeadingFocusOnTabSwitch + params.disableHeadingFocusOnTabSwitch ?? state.disableHeadingFocusOnTabSwitch, + prepopulationWarningLinkIds: + params.prepopulationWarningLinkIds ?? state.prepopulationWarningLinkIds })); } })); From 5dfdd8c3adc19dfa921863aef1309164c98956b5 Mon Sep 17 00:00:00 2001 From: Maryam Mehdizadeh Date: Mon, 11 May 2026 10:29:55 +0800 Subject: [PATCH 4/7] fix(prepopulate): fix prettier formatting and use optional chaining on rendererConfigStore in usePopulate - Import statement for extractWarningLinkIds and formatPopulateIssuesForUser collapsed to single line - rendererConfigStore?.getState() uses optional chaining so tests that mock @aehrc/smart-forms-renderer without rendererConfigStore do not throw before reaching enqueueSnackbar and console.warn - Inline .getState() chain in RepopulateAction to satisfy prettier Co-authored-by: Cursor --- .../src/features/prepopulate/hooks/usePopulate.tsx | 11 +++-------- .../components/RendererActions/RepopulateAction.tsx | 4 +--- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx b/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx index 9e63e2d93..3584f7644 100644 --- a/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx +++ b/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx @@ -18,10 +18,7 @@ import { useContext, useState } from 'react'; import CloseSnackbar from '../../../components/Snackbar/CloseSnackbar.tsx'; import { useSnackbar } from 'notistack'; -import { - extractWarningLinkIds, - formatPopulateIssuesForUser -} from '../utils/prepopulateIssues.ts'; +import { extractWarningLinkIds, formatPopulateIssuesForUser } from '../utils/prepopulateIssues.ts'; import { buildForm, rendererConfigStore, @@ -137,7 +134,7 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void onStopSpinner(); if (issues) { rendererConfigStore - .getState() + ?.getState() .setRendererConfig({ prepopulationWarningLinkIds: extractWarningLinkIds(issues) }); if (showDeveloperMessages) { enqueueSnackbar( @@ -155,9 +152,7 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void return; } - rendererConfigStore - .getState() - .setRendererConfig({ prepopulationWarningLinkIds: new Set() }); + rendererConfigStore?.getState().setRendererConfig({ prepopulationWarningLinkIds: new Set() }); enqueueSnackbar('Form populated', { preventDuplicate: true, action: diff --git a/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx b/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx index 998bfc3c7..f17932bc2 100644 --- a/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx +++ b/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx @@ -146,9 +146,7 @@ function RepopulateAction(props: RepopulateActionProps) { console.warn('Re-population issues:', issues); return; } - rendererConfigStore - .getState() - .setRendererConfig({ prepopulationWarningLinkIds: new Set() }); + rendererConfigStore.getState().setRendererConfig({ prepopulationWarningLinkIds: new Set() }); }, onError: () => { onSpinnerChange({ isSpinning: false, status: null, message: '' }); From 9373c99a0b268ff864f4b7e6a757c590b5ad1873 Mon Sep 17 00:00:00 2001 From: Maryam Mehdizadeh Date: Mon, 25 May 2026 16:12:31 +0800 Subject: [PATCH 5/7] feat(prepopulate): replace per-field inline warnings with group-level warning icons and improve failure messages Previously, pre-population failures displayed a repeated text warning beneath every affected field. This commit replaces that approach with a cleaner group-level indicator and improves the quality of all user-facing messages. Group-level warning icons (renderer package): - Add prepopulationWarnings.ts utility with getGroupPrepopWarning(), which traverses a group's immediate named children (skipping unnamed layout containers like unlabelled grid groups) to collect affected field names and the failure reason. Tooltips show contextual names (e.g. "Body height") rather than repeated generic labels (e.g. "Value, Date performed"). - GroupHeading.tsx: render a WarningAmberIcon + MUI Tooltip on any group whose descendants have pre-population warnings. Tooltip shows the failure reason and lists the affected immediate child names. - GridRow.tsx: same icon + tooltip on grid table row labels when any cell in that row has a warning. - ItemFieldGrid.tsx: remove the per-field inline Typography warning text (replaced by the group-level icon). - SingleItemView.tsx: remove the per-repeated-item inline Typography warning (replaced by the group-level icon). Improved failure messages (smart-forms-app): - prepopulateIssues.ts: - Add hasServerSideError() to detect HTTP 5xx responses and surface a "server returned an error, try again" message to clinicians. - Add hasMissingContextError() to detect fhirpath.js "undefined environment variable" errors, distinguishing a missing launch context (%patient not provided) from a generic expression failure. - extractWarningMessages() now selects the field-level tooltip reason per-issue: data not in health history (not-found cascade), missing patient context (undefined env variable), or standalone expression failure. - getWarningFieldNames() now prefixes each field name with its immediate parent group name (e.g. "Body height > Value") so the snackbar debug list is unambiguous. - All snackbar messages rewritten in plain clinical language with no FHIRPath, HTTP codes, or server internals exposed to the clinician. - usePopulate.tsx: add optional chaining on questionnaireStore access to prevent a throw in test environments where the store is undefined. Co-authored-by: Cursor --- .../components/PrePopulateMenuItem.tsx | 20 +- .../prepopulate/hooks/usePopulate.tsx | 20 +- .../prepopulate/utils/prepopulateIssues.ts | 222 +++++++++++++++--- .../RendererActions/RepopulateAction.tsx | 15 +- .../FormComponents/GridGroup/GridRow.tsx | 38 ++- .../FormComponents/GroupItem/GroupHeading.tsx | 35 +++ .../ItemParts/ItemFieldGrid.tsx | 9 - .../src/stores/rendererConfigStore.ts | 18 +- .../src/utils/prepopulationWarnings.ts | 117 +++++++++ 9 files changed, 431 insertions(+), 63 deletions(-) create mode 100644 packages/smart-forms-renderer/src/utils/prepopulationWarnings.ts diff --git a/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx b/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx index 188a8835f..23ea3a4f1 100644 --- a/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx +++ b/apps/smart-forms-app/src/features/playground/components/PrePopulateMenuItem.tsx @@ -1,6 +1,10 @@ import { populateQuestionnaire } from '@aehrc/sdc-populate'; import { fetchResourceCallback } from './PrePopCallbackForPlayground.tsx'; -import { rendererConfigStore, useQuestionnaireStore } from '@aehrc/smart-forms-renderer'; +import { + questionnaireStore, + rendererConfigStore, + useQuestionnaireStore +} from '@aehrc/smart-forms-renderer'; import type { Encounter, Patient, Practitioner, PractitionerRole } from 'fhir/r4'; import { ListItemIcon, ListItemText } from '@mui/material'; import MenuItem from '@mui/material/MenuItem'; @@ -10,8 +14,9 @@ import { resetAndBuildForm } from '../../../utils/manageForm.ts'; import { useSnackbar } from 'notistack'; import CloseSnackbar from '../../../components/Snackbar/CloseSnackbar.tsx'; import { - extractWarningLinkIds, - formatPopulateIssuesForUser + extractWarningMessages, + formatPopulateIssuesForUser, + getWarningFieldNames } from '../../prepopulate/utils/prepopulateIssues.ts'; interface PrePopulateMenuItemProps { @@ -94,10 +99,13 @@ function PrePopulateMenuItem(props: PrePopulateMenuItemProps) { onSpinnerChange({ isSpinning: false, status: null, message: '' }); if (issues) { + const warningMessages = extractWarningMessages(issues); rendererConfigStore .getState() - .setRendererConfig({ prepopulationWarningLinkIds: extractWarningLinkIds(issues) }); - enqueueSnackbar(formatPopulateIssuesForUser(issues), { + .setRendererConfig({ prepopulationWarningMessages: warningMessages }); + const questionnaire = questionnaireStore.getState().sourceQuestionnaire; + const fieldNames = getWarningFieldNames(questionnaire, new Set(warningMessages.keys())); + enqueueSnackbar(formatPopulateIssuesForUser(issues, warningMessages.size, fieldNames), { variant: 'warning', persist: true, action: @@ -106,7 +114,7 @@ function PrePopulateMenuItem(props: PrePopulateMenuItemProps) { } else { rendererConfigStore .getState() - .setRendererConfig({ prepopulationWarningLinkIds: new Set() }); + .setRendererConfig({ prepopulationWarningMessages: new Map() }); enqueueSnackbar('Form pre-populated.', { action: }); } }) diff --git a/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx b/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx index 3584f7644..bf88a888c 100644 --- a/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx +++ b/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx @@ -18,9 +18,14 @@ import { useContext, useState } from 'react'; import CloseSnackbar from '../../../components/Snackbar/CloseSnackbar.tsx'; import { useSnackbar } from 'notistack'; -import { extractWarningLinkIds, formatPopulateIssuesForUser } from '../utils/prepopulateIssues.ts'; +import { + extractWarningMessages, + formatPopulateIssuesForUser, + getWarningFieldNames +} from '../utils/prepopulateIssues.ts'; import { buildForm, + questionnaireStore, rendererConfigStore, useQuestionnaireResponseStore, useQuestionnaireStore, @@ -133,16 +138,21 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void onStopSpinner(); if (issues) { + const warningMessages = extractWarningMessages(issues); rendererConfigStore ?.getState() - .setRendererConfig({ prepopulationWarningLinkIds: extractWarningLinkIds(issues) }); + .setRendererConfig({ prepopulationWarningMessages: warningMessages }); if (showDeveloperMessages) { enqueueSnackbar( 'Form partially populated, there might be pre-population issues. View console for details.', { action: } ); } else { - enqueueSnackbar(formatPopulateIssuesForUser(issues), { + const questionnaire = questionnaireStore?.getState().sourceQuestionnaire; + const fieldNames = questionnaire + ? getWarningFieldNames(questionnaire, new Set(warningMessages.keys())) + : []; + enqueueSnackbar(formatPopulateIssuesForUser(issues, warningMessages.size, fieldNames), { variant: 'warning', persist: true, action: @@ -152,7 +162,9 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void return; } - rendererConfigStore?.getState().setRendererConfig({ prepopulationWarningLinkIds: new Set() }); + rendererConfigStore + ?.getState() + .setRendererConfig({ prepopulationWarningMessages: new Map() }); enqueueSnackbar('Form populated', { preventDuplicate: true, action: diff --git a/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts b/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts index 9358018cd..5ed61cc0f 100644 --- a/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts +++ b/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts @@ -15,62 +15,230 @@ * limitations under the License. */ -import type { OperationOutcome } from 'fhir/r4'; +import type { OperationOutcome, Questionnaire, QuestionnaireItem } from 'fhir/r4'; /** - * Generates a user-friendly summary of prepopulation issues suitable for display to clinicians. + * Detects a specific HTTP status code inside an issue's details.text string. + * The fetch callback in sdc-populate sets details.text to: + * "HTTP error when performing . Status: " + */ +function detectHttpStatus(issueList: OperationOutcome['issue']): number | null { + for (const issue of issueList ?? []) { + const text = issue.details?.text ?? ''; + const match = text.match(/Status:\s*(\d{3})/); + if (match) { + return parseInt(match[1], 10); + } + } + return null; +} + +/** + * Returns true if any issue indicates a network/CORS failure (fetch threw before + * an HTTP response was received). + */ +function hasNetworkError(issueList: OperationOutcome['issue']): boolean { + return (issueList ?? []).some((i) => { + const text = i.details?.text ?? ''; + return text.includes('Failed to fetch') || text.toLowerCase().includes('networkerror'); + }); +} + +/** + * Returns true if any issue indicates a server-side error (HTTP 5xx). + * These come from the FHIR server itself returning a 500/503/etc. response. + */ +function hasServerSideError(httpStatus: number | null): boolean { + return httpStatus !== null && httpStatus >= 500 && httpStatus < 600; +} + +/** + * Returns true if any issue indicates a missing FHIRPath launch context variable. + * This happens when a questionnaire expression references a context like %patient + * that was never provided in the pre-fill parameters (e.g. running in playground mode + * without a patient launch context). * - * Issues come in two main types: - * - `not-found`: the server did not return the requested resource (no data, permission denied, - * or resource type not supported by the server). - * - `invalid`: a pre-population FHIRPath expression could not be evaluated, typically because - * its source data was not available (often a downstream consequence of a `not-found` issue). + * Detected from fhirpath.js's "Attempting to access an undefined environment variable" error + * stored in issue.details.text by sdc-populate. + */ +function hasMissingContextError(issueList: OperationOutcome['issue']): boolean { + return (issueList ?? []).some((i) => { + const text = i.details?.text ?? ''; + return text.includes('undefined environment variable'); + }); +} + +/** + * Generates a user-friendly summary of pre-fill issues suitable for display to clinicians. + * All messages avoid technical jargon (no FHIRPath, HTTP codes, or server internals). * - * The message is intentionally non-technical so that clinicians understand why some fields - * are blank after prepopulation, without needing to consult the developer console. + * Priority order: + * 1. HTTP 429 — server rate-limited + * 2. HTTP 403 — permission denied + * 3. HTTP 404 — resource not found on server + * 4. HTTP 5xx — server-side error + * 5. Network / CORS — can't reach server at all + * 6. not-found + invalid — server returned no data → cascade expression failure + * 7. not-found only — server returned no data + * 8. Missing launch context — required patient context (%patient etc.) not provided + * 9. Default — standalone FHIRPath expression failure */ -export function formatPopulateIssuesForUser(issues: OperationOutcome): string { +export function formatPopulateIssuesForUser( + issues: OperationOutcome, + affectedFieldCount?: number, + debugFieldNames?: string[] +): string { const issueList = issues.issue ?? []; + const httpStatus = detectHttpStatus(issueList); + + const countPart = + affectedFieldCount !== undefined && affectedFieldCount > 0 + ? ` ${affectedFieldCount} field${affectedFieldCount === 1 ? '' : 's'} affected — please fill ${affectedFieldCount === 1 ? 'it' : 'them'} in manually.` + : ''; + const debugPart = + debugFieldNames && debugFieldNames.length > 0 + ? ` [Debug — affected fields: ${debugFieldNames.join(', ')}]` + : ''; + const fieldSuffix = countPart + debugPart; + + if (httpStatus === 429) { + return ( + 'Pre-fill incomplete: the health record server is busy (too many requests). ' + + 'Please wait a moment and try pre-filling again.' + ); + } + + if (httpStatus === 403) { + return ( + 'Pre-fill incomplete: access to health record data was denied by the server. ' + + 'Contact your administrator to check that the correct permissions have been granted.' + + fieldSuffix + ); + } + + if (httpStatus === 404) { + return ( + 'Pre-fill incomplete: some health record data was not found on the server. ' + + 'Check that the server supports the required resource types.' + + fieldSuffix + ); + } + + if (hasServerSideError(httpStatus)) { + return ( + 'Pre-fill incomplete: the health record server returned an error. ' + + 'This may be a temporary issue — please try again shortly or contact your administrator.' + + fieldSuffix + ); + } + + if (hasNetworkError(issueList)) { + return ( + 'Pre-fill incomplete: could not reach the health record server. ' + + 'Check your network connection and try pre-filling again.' + ); + } + const hasNotFound = issueList.some((i) => i.code === 'not-found'); const hasInvalid = issueList.some((i) => i.code === 'invalid'); - if (hasNotFound && !hasInvalid) { + if (hasNotFound && hasInvalid) { return ( - 'Some fields could not be pre-populated. The server did not return the requested data — ' + - 'check that the server supports the required resource types and that permission has been granted.' + "Pre-fill incomplete: no matching records were found in the patient's health history " + + 'for some fields, so those fields could not be pre-filled.' + + fieldSuffix ); } if (hasNotFound) { return ( - 'Some fields could not be pre-populated. The server did not return the requested data ' + - '(check permissions and resource type support), and some pre-population expressions could ' + - 'not be evaluated as a result. Affected fields will need to be filled in manually.' + "Pre-fill incomplete: no matching records were found in the patient's health history " + + 'for some fields. Check that the server supports the required resource types and that ' + + 'permission has been granted.' + + fieldSuffix + ); + } + + if (hasMissingContextError(issueList)) { + return ( + 'Pre-fill incomplete: some required patient context was not available in this session ' + + '(e.g. the patient record was not provided to the pre-fill service). ' + + 'Affected fields could not be pre-filled.' + + fieldSuffix ); } return ( - 'Some fields could not be pre-populated. Pre-population expressions could not be fully ' + - 'evaluated. Affected fields will need to be filled in manually.' + "Pre-fill incomplete: some fields' pre-fill calculations could not be completed. " + + 'The required health record data may be unavailable.' + + fieldSuffix ); } /** - * Extracts the set of questionnaire item linkIds that were recorded in the `expression` field - * of each `OperationOutcomeIssue`. These are the specific fields that failed to pre-populate - * and should be highlighted in the renderer. + * Builds a Map of questionnaire item linkId → inline warning message for every field + * whose pre-fill failed. The message is chosen per-field based on the root cause: + * + * - `not-found` issues present → downstream cascade; field message says data wasn't in health history. + * - `invalid` issue whose details.text contains "undefined environment variable" → the required + * launch context (%patient etc.) was missing; field message names that specifically. + * - All other `invalid`-only cases → generic expression failure message. * * Only `invalid`-coded issues carry a linkId (set by `createInvalidWarningIssue` in sdc-populate). - * `not-found` issues relate to server-level resource fetching and are not tied to a single item. + * `not-found` issues are resource-level and are not tied to a single item. */ -export function extractWarningLinkIds(issues: OperationOutcome): Set { - const linkIds = new Set(); - for (const issue of issues.issue ?? []) { +export function extractWarningMessages(issues: OperationOutcome): Map { + const issueList = issues.issue ?? []; + const hasNotFound = issueList.some((i) => i.code === 'not-found'); + + const messages = new Map(); + for (const issue of issueList) { if (issue.code === 'invalid' && issue.expression) { + let fieldMessage: string; + + if (hasNotFound) { + // Expression failed because a required resource was not returned by the server + fieldMessage = + "Could not be pre-filled: no matching records were found in the patient's health history for this field."; + } else if ((issue.details?.text ?? '').includes('undefined environment variable')) { + // Expression failed because a launch context variable (%patient etc.) was not provided + fieldMessage = + 'Could not be pre-filled: required patient context was not available in this session.'; + } else { + // Standalone expression evaluation failure + fieldMessage = + "Could not be pre-filled: this field's pre-fill calculation could not be completed."; + } + for (const expr of issue.expression) { - linkIds.add(expr); + messages.set(expr, fieldMessage); } } } - return linkIds; + return messages; +} + +/** + * Traverses the questionnaire tree and returns a qualified label for each linkId that appears + * in `linkIds`. When the item lives inside a group, the immediate parent group name is prepended + * (e.g. "Body height › Value") so that generic names like "Value" or "Date performed" are + * unambiguous in the snackbar debug list. + */ +export function getWarningFieldNames(questionnaire: Questionnaire, linkIds: Set): string[] { + const names: string[] = []; + + function traverse(items: QuestionnaireItem[] | undefined, parentGroupName?: string): void { + for (const item of items ?? []) { + if (linkIds.has(item.linkId)) { + const label = item.text ?? item.linkId; + names.push(parentGroupName ? `${parentGroupName} › ${label}` : label); + } + // Only group items provide meaningful parent context for their children + const nextParent = item.type === 'group' ? (item.text ?? item.linkId) : parentGroupName; + traverse(item.item, nextParent); + } + } + + traverse(questionnaire.item); + return names; } diff --git a/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx b/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx index f17932bc2..7c15143c6 100644 --- a/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx +++ b/apps/smart-forms-app/src/features/renderer/components/RendererActions/RepopulateAction.tsx @@ -26,6 +26,7 @@ import type { RendererSpinner } from '../../types/rendererSpinner.ts'; import useSmartClient from '../../../../hooks/useSmartClient.ts'; import { generateItemsToRepopulate, + questionnaireStore, rendererConfigStore, useQuestionnaireStore, useTerminologyServerStore @@ -40,8 +41,9 @@ import { fetchTerminologyCallback } from '../../../prepopulate/utils/callback.ts'; import { - extractWarningLinkIds, - formatPopulateIssuesForUser + extractWarningMessages, + formatPopulateIssuesForUser, + getWarningFieldNames } from '../../../prepopulate/utils/prepopulateIssues.ts'; import type Client from 'fhirclient/lib/Client'; import { useState } from 'react'; @@ -135,10 +137,13 @@ function RepopulateAction(props: RepopulateActionProps) { onSpinnerChange({ isSpinning: false, status: 'repopulate-fetch', message: '' }); if (issues) { + const warningMessages = extractWarningMessages(issues); rendererConfigStore .getState() - .setRendererConfig({ prepopulationWarningLinkIds: extractWarningLinkIds(issues) }); - enqueueSnackbar(formatPopulateIssuesForUser(issues), { + .setRendererConfig({ prepopulationWarningMessages: warningMessages }); + const questionnaire = questionnaireStore.getState().sourceQuestionnaire; + const fieldNames = getWarningFieldNames(questionnaire, new Set(warningMessages.keys())); + enqueueSnackbar(formatPopulateIssuesForUser(issues, warningMessages.size, fieldNames), { variant: 'warning', persist: true, action: @@ -146,7 +151,7 @@ function RepopulateAction(props: RepopulateActionProps) { console.warn('Re-population issues:', issues); return; } - rendererConfigStore.getState().setRendererConfig({ prepopulationWarningLinkIds: new Set() }); + rendererConfigStore.getState().setRendererConfig({ prepopulationWarningMessages: new Map() }); }, onError: () => { onSpinnerChange({ isSpinning: false, status: null, message: '' }); diff --git a/packages/smart-forms-renderer/src/components/FormComponents/GridGroup/GridRow.tsx b/packages/smart-forms-renderer/src/components/FormComponents/GridGroup/GridRow.tsx index 474e3509e..af6b21b69 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/GridGroup/GridRow.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/GridGroup/GridRow.tsx @@ -28,8 +28,12 @@ import SingleItem from '../SingleItem/SingleItem'; import { getQrItemsIndex, mapQItemsIndex } from '../../../utils/mapItem'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import useHidden from '../../../hooks/useHidden'; import { getItemTextToDisplay } from '../../../utils/itemTextToDisplay'; +import { useRendererConfigStore } from '../../../stores'; +import { getGroupPrepopWarning } from '../../../utils/prepopulationWarnings'; interface GridRowProps extends PropsWithQrItemChangeHandler, @@ -58,6 +62,9 @@ function GridRow(props: GridRowProps) { const qItemsIndexMap = useMemo(() => mapQItemsIndex(qItem), [qItem]); + const prepopulationWarningMessages = useRendererConfigStore.use.prepopulationWarningMessages(); + const rowWarning = getGroupPrepopWarning(qItem, prepopulationWarningMessages); + const itemIsHidden = useHidden(qItem); if (itemIsHidden) { return null; @@ -84,9 +91,34 @@ function GridRow(props: GridRowProps) { return ( <> - - {itemTextToDisplay} - + + + {itemTextToDisplay} + + {rowWarning ? ( + + + {rowWarning.reason} + + + Fields not pre-populated: + + + {rowWarning.fieldNames.map((name) => ( +
  • + {name} +
  • + ))} +
    +
    + } + arrow> + + + ) : null} +
    {columnHeaderLabels.map((label, colIndex) => { // Find the QuestionnaireItem in this row that matches the current column label diff --git a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupHeading.tsx b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupHeading.tsx index c5e0c35c5..d65ae46f5 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupHeading.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupHeading.tsx @@ -18,6 +18,8 @@ import React, { memo } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import type { QuestionnaireItem } from 'fhir/r4'; import { getContextDisplays } from '../../../utils/tabs'; import ContextDisplayItem from '../ItemParts/ContextDisplayItem'; @@ -28,6 +30,7 @@ import ItemTextSwitcher from '../ItemParts/ItemTextSwitcher'; import FlyoverItem from '../ItemParts/FlyoverItem'; import { getHeadingTag } from '../../../utils/headingVariant'; import type { PropsWithParentStylesAttribute } from '../../../interfaces/renderProps.interface'; +import { getGroupPrepopWarning } from '../../../utils/prepopulationWarnings'; interface GroupHeadingProps extends PropsWithParentStylesAttribute { qItem: QuestionnaireItem; @@ -48,6 +51,8 @@ const GroupHeading = memo(function GroupHeading(props: GroupHeadingProps) { } = props; const requiredIndicatorPosition = useRendererConfigStore.use.requiredIndicatorPosition(); + const prepopulationWarningMessages = useRendererConfigStore.use.prepopulationWarningMessages(); + const groupWarning = getGroupPrepopWarning(qItem, prepopulationWarningMessages); const { required, displayFlyover } = useRenderingExtensions(qItem); const contextDisplayItems = getContextDisplays(qItem); @@ -95,6 +100,36 @@ const GroupHeading = memo(function GroupHeading(props: GroupHeadingProps) { ) : null} + + {/* Pre-population warning icon — shown when one or more fields in this group + could not be pre-populated. Tooltip explains why and lists the affected fields. */} + {groupWarning ? ( + + + {groupWarning.reason} + + + Fields not pre-populated: + + + {groupWarning.fieldNames.map((name) => ( +
  • + {name} +
  • + ))} +
    + + } + arrow> + +
    + ) : null} diff --git a/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemFieldGrid.tsx b/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemFieldGrid.tsx index 9157311e1..a4bce3229 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemFieldGrid.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemFieldGrid.tsx @@ -21,7 +21,6 @@ import useRenderingExtensions from '../../../hooks/useRenderingExtensions'; import { useRendererConfigStore } from '../../../stores'; import DisplayInstructions from '../DisplayItem/DisplayInstructions'; import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; interface ItemFieldGridProps { qItem: QuestionnaireItem; @@ -50,9 +49,6 @@ function ItemFieldGrid(props: ItemFieldGridProps) { const itemResponsive = useRendererConfigStore.use.itemResponsive(); const { labelBreakpoints, fieldBreakpoints, columnGapPixels, rowGapPixels } = itemResponsive; - const prepopulationWarningLinkIds = useRendererConfigStore.use.prepopulationWarningLinkIds(); - const hasPrepopWarning = prepopulationWarningLinkIds.has(qItem.linkId); - const { displayInstructions } = useRenderingExtensions(qItem); // Generate instruction ID if instructions exist and there's no feedback @@ -75,11 +71,6 @@ function ItemFieldGrid(props: ItemFieldGridProps) { {displayInstructions} )} - {hasPrepopWarning && ( - - This field could not be pre-populated. - - )} ); diff --git a/packages/smart-forms-renderer/src/stores/rendererConfigStore.ts b/packages/smart-forms-renderer/src/stores/rendererConfigStore.ts index 049c506ec..12d7d4bf9 100644 --- a/packages/smart-forms-renderer/src/stores/rendererConfigStore.ts +++ b/packages/smart-forms-renderer/src/stores/rendererConfigStore.ts @@ -113,12 +113,12 @@ export interface RendererConfig { disableHeadingFocusOnTabSwitch?: boolean; /** - * Set of questionnaire item linkIds whose pre-population failed. - * When provided, the renderer renders an inline warning below each affected field - * so clinicians can see at a glance which fields were not pre-populated and why. - * Pass an empty Set to clear all warnings (e.g. when loading a new form). + * Map of questionnaire item linkId → user-facing warning message for fields whose + * pre-population failed. The renderer renders the message inline below each affected + * field so clinicians can see at a glance which fields were not pre-populated and why. + * Pass an empty Map to clear all warnings (e.g. when loading a new form). */ - prepopulationWarningLinkIds?: Set; + prepopulationWarningMessages?: Map; } /** @@ -153,7 +153,7 @@ export interface RendererConfigStoreType { disablePageButtons: boolean; disableTabButtons: boolean; disableHeadingFocusOnTabSwitch: boolean; - prepopulationWarningLinkIds: Set; + prepopulationWarningMessages: Map; setRendererConfig: (params: RendererConfig) => void; } @@ -185,7 +185,7 @@ export const rendererConfigStore = createStore()((set) disablePageButtons: false, disableTabButtons: false, disableHeadingFocusOnTabSwitch: false, - prepopulationWarningLinkIds: new Set(), + prepopulationWarningMessages: new Map(), setRendererConfig: (params: RendererConfig) => { set((state) => ({ readOnlyVisualStyle: params.readOnlyVisualStyle ?? state.readOnlyVisualStyle, @@ -207,8 +207,8 @@ export const rendererConfigStore = createStore()((set) disableTabButtons: params.disableTabButtons ?? state.disableTabButtons, disableHeadingFocusOnTabSwitch: params.disableHeadingFocusOnTabSwitch ?? state.disableHeadingFocusOnTabSwitch, - prepopulationWarningLinkIds: - params.prepopulationWarningLinkIds ?? state.prepopulationWarningLinkIds + prepopulationWarningMessages: + params.prepopulationWarningMessages ?? state.prepopulationWarningMessages })); } })); diff --git a/packages/smart-forms-renderer/src/utils/prepopulationWarnings.ts b/packages/smart-forms-renderer/src/utils/prepopulationWarnings.ts new file mode 100644 index 000000000..f72ccf337 --- /dev/null +++ b/packages/smart-forms-renderer/src/utils/prepopulationWarnings.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2025 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { QuestionnaireItem } from 'fhir/r4'; + +export interface GroupPrepopWarning { + /** Names of the immediate child items (or child groups) that are affected. */ + fieldNames: string[]; + /** Human-readable reason sourced from the warning messages map. */ + reason: string; +} + +/** + * Returns true when the item itself or any of its descendants has a warning. + */ +function hasAffectedDescendant( + item: QuestionnaireItem, + warningMessages: Map +): boolean { + if (warningMessages.has(item.linkId)) return true; + return (item.item ?? []).some((child) => hasAffectedDescendant(child, warningMessages)); +} + +/** + * Collects all unique warning message strings from the item and all its descendants. + */ +function collectReasons( + item: QuestionnaireItem, + warningMessages: Map, + reasons: Set +): void { + const msg = warningMessages.get(item.linkId); + if (msg) reasons.add(msg); + item.item?.forEach((child) => collectReasons(child, warningMessages, reasons)); +} + +/** + * Builds a {@link GroupPrepopWarning} for a group QuestionnaireItem. + * + * Instead of recursing all the way down to leaf items (which produces unhelpful repeated + * labels like "Value, Date performed, Value, Date performed …"), this function stops at the + * **nearest named ancestor** within the group. The algorithm works as follows at each level: + * + * - If a child has its own text label and is directly affected (or contains affected + * descendants), its label is added to the list. + * - If a child has **no text** (e.g. a layout-only `grid` container) but still has affected + * descendants, the function recurses transparently into that child's children so the first + * level of named items is collected instead. + * + * This handles the common case in Dev-715 where an "Examination" tab contains an unlabelled + * grid group whose ROW items ("Body height", "Body weight" …) are the items we want to name. + * + * The `reason` field is taken directly from the warning messages already stored in the map + * (e.g. "the required clinical data was not returned by the server"), so the tooltip shows + * both *what* failed and *why*. + * + * Returns `null` when no descendant of the group has a warning. + */ +export function getGroupPrepopWarning( + qItem: QuestionnaireItem, + warningMessages: Map +): GroupPrepopWarning | null { + const fieldNames: string[] = []; + const reasons = new Set(); + + function collectNearestNamed(items: QuestionnaireItem[]): void { + for (const child of items) { + const isDirectlyAffected = warningMessages.has(child.linkId); + const hasAffectedChild = !isDirectlyAffected && hasAffectedDescendant(child, warningMessages); + + if (!isDirectlyAffected && !hasAffectedChild) continue; + + if (child.text) { + // Named item — add it and stop descending + fieldNames.push(child.text); + collectReasons(child, warningMessages, reasons); + } else { + // Unnamed layout container (e.g. a grid group with no label) — skip it and + // look at its children for the nearest named items instead + collectNearestNamed(child.item ?? []); + collectReasons(child, warningMessages, reasons); + } + } + } + + collectNearestNamed(qItem.item ?? []); + + if (fieldNames.length === 0) return null; + + // Strip the leading "Could not be pre-filled: " prefix from stored messages to get a + // clean reason clause, then use the first unique reason (they are almost always identical). + const rawReason = reasons.size > 0 ? [...reasons][0] : ''; + const reasonClause = rawReason + .replace(/^Could not be pre-(?:populated|filled):\s*/i, '') + .replace(/\.$/, ''); // trim trailing full stop — we add our own below + + const reason = + reasonClause.length > 0 + ? reasonClause.charAt(0).toUpperCase() + reasonClause.slice(1) + '.' + : 'Pre-population failed.'; + + return { fieldNames, reason }; +} From 6f72ddc642d06f65d517baf070425e2781e58686 Mon Sep 17 00:00:00 2001 From: Maryam Mehdizadeh Date: Mon, 25 May 2026 16:22:12 +0800 Subject: [PATCH 6/7] test(prepopulate): add unit tests for prepopulateIssues utility to restore branch coverage Co-authored-by: Cursor --- .../test/prepopulateIssues.test.ts | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 apps/smart-forms-app/src/features/prepopulate/test/prepopulateIssues.test.ts diff --git a/apps/smart-forms-app/src/features/prepopulate/test/prepopulateIssues.test.ts b/apps/smart-forms-app/src/features/prepopulate/test/prepopulateIssues.test.ts new file mode 100644 index 000000000..923246238 --- /dev/null +++ b/apps/smart-forms-app/src/features/prepopulate/test/prepopulateIssues.test.ts @@ -0,0 +1,300 @@ +/* + * Copyright 2025 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { OperationOutcome, Questionnaire } from 'fhir/r4'; +import { + extractWarningMessages, + formatPopulateIssuesForUser, + getWarningFieldNames +} from '../utils/prepopulateIssues'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeOutcome( + code: string, + detailsText?: string, + expression?: string[] +): OperationOutcome { + return { + resourceType: 'OperationOutcome', + issue: [ + { + severity: 'warning', + code, + ...(detailsText && { details: { text: detailsText } }), + ...(expression && { expression }) + } + ] + }; +} + +function makeHttpOutcome(status: number): OperationOutcome { + return makeOutcome('invalid', `HTTP error when performing /fhir/Patient. Status: ${status}`); +} + +// --------------------------------------------------------------------------- +// formatPopulateIssuesForUser +// --------------------------------------------------------------------------- + +describe('formatPopulateIssuesForUser', () => { + describe('HTTP status code detection', () => { + it('returns rate-limit message for HTTP 429', () => { + const msg = formatPopulateIssuesForUser(makeHttpOutcome(429)); + expect(msg).toContain('busy'); + expect(msg).toContain('try pre-filling again'); + }); + + it('returns permission-denied message for HTTP 403', () => { + const msg = formatPopulateIssuesForUser(makeHttpOutcome(403)); + expect(msg).toContain('denied'); + expect(msg).toContain('administrator'); + }); + + it('returns not-found message for HTTP 404', () => { + const msg = formatPopulateIssuesForUser(makeHttpOutcome(404)); + expect(msg).toContain('not found on the server'); + }); + + it('returns server-error message for HTTP 500', () => { + const msg = formatPopulateIssuesForUser(makeHttpOutcome(500)); + expect(msg).toContain('server returned an error'); + expect(msg).toContain('try again shortly'); + }); + + it('returns server-error message for HTTP 503', () => { + const msg = formatPopulateIssuesForUser(makeHttpOutcome(503)); + expect(msg).toContain('server returned an error'); + }); + }); + + describe('network error detection', () => { + it('returns network-error message when details.text contains "Failed to fetch"', () => { + const msg = formatPopulateIssuesForUser(makeOutcome('invalid', 'Failed to fetch')); + expect(msg).toContain('could not reach the health record server'); + }); + + it('returns network-error message when details.text contains "NetworkError"', () => { + const msg = formatPopulateIssuesForUser(makeOutcome('invalid', 'NetworkError: connection refused')); + expect(msg).toContain('could not reach the health record server'); + }); + }); + + describe('not-found and invalid issue combinations', () => { + it('returns cascade message when both not-found and invalid issues are present', () => { + const outcome: OperationOutcome = { + resourceType: 'OperationOutcome', + issue: [ + { severity: 'warning', code: 'not-found' }, + { severity: 'warning', code: 'invalid', expression: ['link-1'] } + ] + }; + const msg = formatPopulateIssuesForUser(outcome, 1); + expect(msg).toContain("no matching records"); + expect(msg).toContain('1 field affected'); + }); + + it('returns not-found-only message when only not-found issues are present', () => { + const outcome: OperationOutcome = { + resourceType: 'OperationOutcome', + issue: [{ severity: 'warning', code: 'not-found' }] + }; + const msg = formatPopulateIssuesForUser(outcome); + expect(msg).toContain("no matching records"); + expect(msg).toContain('permission'); + }); + }); + + describe('missing launch context detection', () => { + it('returns missing-context message when details.text contains "undefined environment variable"', () => { + const msg = formatPopulateIssuesForUser( + makeOutcome('invalid', 'Error: Attempting to access an undefined environment variable: patient') + ); + expect(msg).toContain('required patient context'); + expect(msg).toContain('not available in this session'); + }); + }); + + describe('default (standalone expression failure)', () => { + it('returns generic expression-failure message as fallback', () => { + const msg = formatPopulateIssuesForUser(makeOutcome('invalid', 'Some fhirpath error')); + expect(msg).toContain("pre-fill calculations could not be completed"); + }); + }); + + describe('field count and debug suffix', () => { + it('appends field count to message when affectedFieldCount is provided', () => { + const msg = formatPopulateIssuesForUser(makeOutcome('not-found'), 3); + expect(msg).toContain('3 fields affected'); + }); + + it('uses singular "field" when affectedFieldCount is 1', () => { + const msg = formatPopulateIssuesForUser(makeOutcome('not-found'), 1); + expect(msg).toContain('1 field affected'); + expect(msg).toContain('fill it in manually'); + }); + + it('appends debug field names when provided', () => { + const msg = formatPopulateIssuesForUser(makeOutcome('not-found'), 2, ['Height', 'Weight']); + expect(msg).toContain('[Debug — affected fields: Height, Weight]'); + }); + + it('omits field count when affectedFieldCount is 0', () => { + const msg = formatPopulateIssuesForUser(makeOutcome('not-found'), 0); + expect(msg).not.toContain('field affected'); + }); + + it('omits debug part when debugFieldNames is empty', () => { + const msg = formatPopulateIssuesForUser(makeOutcome('not-found'), 1, []); + expect(msg).not.toContain('[Debug'); + }); + }); + + describe('empty outcome', () => { + it('returns fallback message for empty issue list', () => { + const msg = formatPopulateIssuesForUser({ resourceType: 'OperationOutcome', issue: [] }); + expect(msg).toContain('Pre-fill incomplete'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// extractWarningMessages +// --------------------------------------------------------------------------- + +describe('extractWarningMessages', () => { + it('maps linkIds from invalid issues to the not-found cascade message when not-found is present', () => { + const outcome: OperationOutcome = { + resourceType: 'OperationOutcome', + issue: [ + { severity: 'warning', code: 'not-found' }, + { severity: 'warning', code: 'invalid', expression: ['link-1', 'link-2'] } + ] + }; + const map = extractWarningMessages(outcome); + expect(map.get('link-1')).toContain("no matching records"); + expect(map.get('link-2')).toContain("no matching records"); + }); + + it('maps linkId to missing-context message when details.text has "undefined environment variable"', () => { + const outcome: OperationOutcome = { + resourceType: 'OperationOutcome', + issue: [ + { + severity: 'warning', + code: 'invalid', + details: { text: 'Error: Attempting to access an undefined environment variable: patient' }, + expression: ['link-3'] + } + ] + }; + const map = extractWarningMessages(outcome); + expect(map.get('link-3')).toContain('required patient context'); + }); + + it('maps linkId to generic calculation message for standalone expression failure', () => { + const outcome = makeOutcome('invalid', 'Some fhirpath error', ['link-4']); + const map = extractWarningMessages(outcome); + expect(map.get('link-4')).toContain("calculation could not be completed"); + }); + + it('ignores invalid issues without an expression array', () => { + const outcome = makeOutcome('invalid', 'Some error'); + const map = extractWarningMessages(outcome); + expect(map.size).toBe(0); + }); + + it('ignores non-invalid issues', () => { + const outcome = makeOutcome('not-found'); + const map = extractWarningMessages(outcome); + expect(map.size).toBe(0); + }); + + it('returns empty map for empty issue list', () => { + const map = extractWarningMessages({ resourceType: 'OperationOutcome', issue: [] }); + expect(map.size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// getWarningFieldNames +// --------------------------------------------------------------------------- + +describe('getWarningFieldNames', () => { + const questionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: 'draft', + item: [ + { + linkId: 'top-level-item', + text: 'Top level field', + type: 'string' + }, + { + linkId: 'group-1', + text: 'Measurements', + type: 'group', + item: [ + { linkId: 'child-1', text: 'Body height', type: 'decimal' }, + { linkId: 'child-2', text: 'Body weight', type: 'decimal' }, + { + linkId: 'sub-group', + text: 'Blood pressure', + type: 'group', + item: [{ linkId: 'grandchild-1', text: 'Systolic', type: 'decimal' }] + } + ] + }, + { + linkId: 'item-no-text', + type: 'string' + } + ] + }; + + it('returns item text for a top-level item with no parent group', () => { + const names = getWarningFieldNames(questionnaire, new Set(['top-level-item'])); + expect(names).toEqual(['Top level field']); + }); + + it('prefixes item text with immediate parent group name', () => { + const names = getWarningFieldNames(questionnaire, new Set(['child-1', 'child-2'])); + expect(names).toContain('Measurements › Body height'); + expect(names).toContain('Measurements › Body weight'); + }); + + it('uses immediate parent group name (not full path) for nested items', () => { + const names = getWarningFieldNames(questionnaire, new Set(['grandchild-1'])); + expect(names).toEqual(['Blood pressure › Systolic']); + }); + + it('falls back to linkId when item has no text', () => { + const names = getWarningFieldNames(questionnaire, new Set(['item-no-text'])); + expect(names).toEqual(['item-no-text']); + }); + + it('returns empty array when no linkIds match', () => { + const names = getWarningFieldNames(questionnaire, new Set(['nonexistent'])); + expect(names).toEqual([]); + }); + + it('returns empty array for empty linkIds set', () => { + const names = getWarningFieldNames(questionnaire, new Set()); + expect(names).toEqual([]); + }); +}); From ca0c903082508a24b0bb189e139571d97bcd9183 Mon Sep 17 00:00:00 2001 From: Maryam Mehdizadeh Date: Mon, 25 May 2026 16:31:13 +0800 Subject: [PATCH 7/7] fix(prepopulate): fix prettier formatting in prepopulateIssues test file Co-authored-by: Cursor --- .../test/prepopulateIssues.test.ts | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/smart-forms-app/src/features/prepopulate/test/prepopulateIssues.test.ts b/apps/smart-forms-app/src/features/prepopulate/test/prepopulateIssues.test.ts index 923246238..de06ec5d1 100644 --- a/apps/smart-forms-app/src/features/prepopulate/test/prepopulateIssues.test.ts +++ b/apps/smart-forms-app/src/features/prepopulate/test/prepopulateIssues.test.ts @@ -26,11 +26,7 @@ import { // Helpers // --------------------------------------------------------------------------- -function makeOutcome( - code: string, - detailsText?: string, - expression?: string[] -): OperationOutcome { +function makeOutcome(code: string, detailsText?: string, expression?: string[]): OperationOutcome { return { resourceType: 'OperationOutcome', issue: [ @@ -90,7 +86,9 @@ describe('formatPopulateIssuesForUser', () => { }); it('returns network-error message when details.text contains "NetworkError"', () => { - const msg = formatPopulateIssuesForUser(makeOutcome('invalid', 'NetworkError: connection refused')); + const msg = formatPopulateIssuesForUser( + makeOutcome('invalid', 'NetworkError: connection refused') + ); expect(msg).toContain('could not reach the health record server'); }); }); @@ -105,7 +103,7 @@ describe('formatPopulateIssuesForUser', () => { ] }; const msg = formatPopulateIssuesForUser(outcome, 1); - expect(msg).toContain("no matching records"); + expect(msg).toContain('no matching records'); expect(msg).toContain('1 field affected'); }); @@ -115,7 +113,7 @@ describe('formatPopulateIssuesForUser', () => { issue: [{ severity: 'warning', code: 'not-found' }] }; const msg = formatPopulateIssuesForUser(outcome); - expect(msg).toContain("no matching records"); + expect(msg).toContain('no matching records'); expect(msg).toContain('permission'); }); }); @@ -123,7 +121,10 @@ describe('formatPopulateIssuesForUser', () => { describe('missing launch context detection', () => { it('returns missing-context message when details.text contains "undefined environment variable"', () => { const msg = formatPopulateIssuesForUser( - makeOutcome('invalid', 'Error: Attempting to access an undefined environment variable: patient') + makeOutcome( + 'invalid', + 'Error: Attempting to access an undefined environment variable: patient' + ) ); expect(msg).toContain('required patient context'); expect(msg).toContain('not available in this session'); @@ -133,7 +134,7 @@ describe('formatPopulateIssuesForUser', () => { describe('default (standalone expression failure)', () => { it('returns generic expression-failure message as fallback', () => { const msg = formatPopulateIssuesForUser(makeOutcome('invalid', 'Some fhirpath error')); - expect(msg).toContain("pre-fill calculations could not be completed"); + expect(msg).toContain('pre-fill calculations could not be completed'); }); }); @@ -187,8 +188,8 @@ describe('extractWarningMessages', () => { ] }; const map = extractWarningMessages(outcome); - expect(map.get('link-1')).toContain("no matching records"); - expect(map.get('link-2')).toContain("no matching records"); + expect(map.get('link-1')).toContain('no matching records'); + expect(map.get('link-2')).toContain('no matching records'); }); it('maps linkId to missing-context message when details.text has "undefined environment variable"', () => { @@ -198,7 +199,9 @@ describe('extractWarningMessages', () => { { severity: 'warning', code: 'invalid', - details: { text: 'Error: Attempting to access an undefined environment variable: patient' }, + details: { + text: 'Error: Attempting to access an undefined environment variable: patient' + }, expression: ['link-3'] } ] @@ -210,7 +213,7 @@ describe('extractWarningMessages', () => { it('maps linkId to generic calculation message for standalone expression failure', () => { const outcome = makeOutcome('invalid', 'Some fhirpath error', ['link-4']); const map = extractWarningMessages(outcome); - expect(map.get('link-4')).toContain("calculation could not be completed"); + expect(map.get('link-4')).toContain('calculation could not be completed'); }); it('ignores invalid issues without an expression array', () => {