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 615051623..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,12 +1,23 @@ import { populateQuestionnaire } from '@aehrc/sdc-populate'; import { fetchResourceCallback } from './PrePopCallbackForPlayground.tsx'; -import { 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'; 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 { + extractWarningMessages, + formatPopulateIssuesForUser, + getWarningFieldNames +} from '../../prepopulate/utils/prepopulateIssues.ts'; interface PrePopulateMenuItemProps { sourceFhirServerUrl: string | null; @@ -32,6 +43,7 @@ function PrePopulateMenuItem(props: PrePopulateMenuItemProps) { } = props; const sourceQuestionnaire = useQuestionnaireStore.use.sourceQuestionnaire(); + const { enqueueSnackbar } = useSnackbar(); const populateEnabled = sourceFhirServerUrl !== null && patient !== null; @@ -62,33 +74,57 @@ 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) { + const warningMessages = extractWarningMessages(issues); + rendererConfigStore + .getState() + .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: + }); + console.warn('Pre-population issues:', issues); + } else { + rendererConfigStore + .getState() + .setRendererConfig({ prepopulationWarningMessages: new Map() }); + 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..bf88a888c 100644 --- a/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx +++ b/apps/smart-forms-app/src/features/prepopulate/hooks/usePopulate.tsx @@ -15,12 +15,18 @@ * limitations under the License. */ -import { useState, useContext } from 'react'; +import { useContext, useState } from 'react'; import CloseSnackbar from '../../../components/Snackbar/CloseSnackbar.tsx'; import { useSnackbar } from 'notistack'; -import { ConfigContext } from '../../configChecker/contexts/ConfigContext.tsx'; +import { + extractWarningMessages, + formatPopulateIssuesForUser, + getWarningFieldNames +} from '../utils/prepopulateIssues.ts'; import { buildForm, + questionnaireStore, + rendererConfigStore, useQuestionnaireResponseStore, useQuestionnaireStore, useTerminologyServerStore @@ -29,6 +35,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; @@ -45,7 +52,9 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void const [isPopulated, setIsPopulated] = useState(false); const { enqueueSnackbar } = useSnackbar(); + const { config } = useContext(ConfigContext); + const { showDeveloperMessages } = config; // Do not run population if spinner purpose is "repopulate" if (status !== 'prepopulate') { @@ -129,18 +138,33 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void onStopSpinner(); if (issues) { - // Only show the snackbar message if developer messages are enabled - if (config.showDeveloperMessages ?? true) { + const warningMessages = extractWarningMessages(issues); + rendererConfigStore + ?.getState() + .setRendererConfig({ prepopulationWarningMessages: warningMessages }); + if (showDeveloperMessages) { enqueueSnackbar( 'Form partially populated, there might be pre-population issues. View console for details.', { action: } ); + } else { + 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: + }); } - // Always log to console - clinicians won't see this, but developers need it console.warn(issues); return; } + rendererConfigStore + ?.getState() + .setRendererConfig({ prepopulationWarningMessages: new Map() }); enqueueSnackbar('Form populated', { preventDuplicate: true, action: 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..de06ec5d1 --- /dev/null +++ b/apps/smart-forms-app/src/features/prepopulate/test/prepopulateIssues.test.ts @@ -0,0 +1,303 @@ +/* + * 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([]); + }); +}); 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..5ed61cc0f --- /dev/null +++ b/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts @@ -0,0 +1,244 @@ +/* + * 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, QuestionnaireItem } from 'fhir/r4'; + +/** + * 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). + * + * 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). + * + * 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, + 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) { + return ( + "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 ( + "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 ( + "Pre-fill incomplete: some fields' pre-fill calculations could not be completed. " + + 'The required health record data may be unavailable.' + + fieldSuffix + ); +} + +/** + * 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 are resource-level and are not tied to a single item. + */ +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) { + messages.set(expr, fieldMessage); + } + } + } + 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 ce55c7224..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,8 @@ import type { RendererSpinner } from '../../types/rendererSpinner.ts'; import useSmartClient from '../../../../hooks/useSmartClient.ts'; import { generateItemsToRepopulate, + questionnaireStore, + rendererConfigStore, useQuestionnaireStore, useTerminologyServerStore } from '@aehrc/smart-forms-renderer'; @@ -38,6 +40,11 @@ import { fetchResourceCallback, fetchTerminologyCallback } from '../../../prepopulate/utils/callback.ts'; +import { + extractWarningMessages, + formatPopulateIssuesForUser, + getWarningFieldNames +} from '../../../prepopulate/utils/prepopulateIssues.ts'; import type Client from 'fhirclient/lib/Client'; import { useState } from 'react'; @@ -130,13 +137,21 @@ 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); + const warningMessages = extractWarningMessages(issues); + rendererConfigStore + .getState() + .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: + }); + console.warn('Re-population issues:', issues); return; } + rendererConfigStore.getState().setRendererConfig({ prepopulationWarningMessages: new Map() }); }, 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/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/stores/rendererConfigStore.ts b/packages/smart-forms-renderer/src/stores/rendererConfigStore.ts index 15bfe808e..12d7d4bf9 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; + + /** + * 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). + */ + prepopulationWarningMessages?: Map; } /** @@ -145,6 +153,7 @@ export interface RendererConfigStoreType { disablePageButtons: boolean; disableTabButtons: boolean; disableHeadingFocusOnTabSwitch: boolean; + prepopulationWarningMessages: Map; setRendererConfig: (params: RendererConfig) => void; } @@ -176,6 +185,7 @@ export const rendererConfigStore = createStore()((set) disablePageButtons: false, disableTabButtons: false, disableHeadingFocusOnTabSwitch: false, + prepopulationWarningMessages: new Map(), 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, + 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 }; +}