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 };
+}