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..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,12 +1,18 @@ 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'; 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 { + extractWarningLinkIds, + formatPopulateIssuesForUser +} from '../../prepopulate/utils/prepopulateIssues.ts'; interface PrePopulateMenuItemProps { sourceFhirServerUrl: string | null; @@ -32,6 +38,7 @@ function PrePopulateMenuItem(props: PrePopulateMenuItemProps) { } = props; const sourceQuestionnaire = useQuestionnaireStore.use.sourceQuestionnaire(); + const { enqueueSnackbar } = useSnackbar(); const populateEnabled = sourceFhirServerUrl !== null && patient !== null; @@ -62,33 +69,54 @@ 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) { + rendererConfigStore + .getState() + .setRendererConfig({ prepopulationWarningLinkIds: extractWarningLinkIds(issues) }); + enqueueSnackbar(formatPopulateIssuesForUser(issues), { + variant: 'warning', + persist: true, + action: + }); + console.warn('Pre-population issues:', issues); + } else { + rendererConfigStore + .getState() + .setRendererConfig({ prepopulationWarningLinkIds: new Set() }); + 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..3584f7644 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,13 @@ * 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 { extractWarningLinkIds, formatPopulateIssuesForUser } from '../utils/prepopulateIssues.ts'; import { buildForm, + rendererConfigStore, useQuestionnaireResponseStore, useQuestionnaireStore, useTerminologyServerStore @@ -29,6 +30,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 +47,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 +133,26 @@ function usePopulate(spinner: RendererSpinner, onStopSpinner: () => void): void onStopSpinner(); if (issues) { - // Only show the snackbar message if developer messages are enabled - if (config.showDeveloperMessages ?? true) { + rendererConfigStore + ?.getState() + .setRendererConfig({ prepopulationWarningLinkIds: extractWarningLinkIds(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: + }); } - // Always log to console - clinicians won't see this, but developers need it console.warn(issues); 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 new file mode 100644 index 000000000..9358018cd --- /dev/null +++ b/apps/smart-forms-app/src/features/prepopulate/utils/prepopulateIssues.ts @@ -0,0 +1,76 @@ +/* + * 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.' + ); +} + +/** + * 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 ce55c7224..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 @@ -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,6 +39,10 @@ import { fetchResourceCallback, fetchTerminologyCallback } from '../../../prepopulate/utils/callback.ts'; +import { + extractWarningLinkIds, + formatPopulateIssuesForUser +} from '../../../prepopulate/utils/prepopulateIssues.ts'; import type Client from 'fhirclient/lib/Client'; import { useState } from 'react'; @@ -130,13 +135,18 @@ 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); + rendererConfigStore + .getState() + .setRendererConfig({ prepopulationWarningLinkIds: extractWarningLinkIds(issues) }); + enqueueSnackbar(formatPopulateIssuesForUser(issues), { + variant: 'warning', + persist: true, + action: + }); + 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 })); } }));