diff --git a/apps/smart-forms-app/src/test/__screenshots__/aboriginalFormPopulation.test.tsx/Population-workflow-for-Allergies-1.png b/apps/smart-forms-app/src/test/__screenshots__/aboriginalFormPopulation.test.tsx/Population-workflow-for-Allergies-1.png new file mode 100644 index 000000000..1921ef6f7 Binary files /dev/null and b/apps/smart-forms-app/src/test/__screenshots__/aboriginalFormPopulation.test.tsx/Population-workflow-for-Allergies-1.png differ diff --git a/packages/smart-forms-renderer/src/components/FormComponents/ChoiceItems/ChoiceSelectAnswerOptionFields.tsx b/packages/smart-forms-renderer/src/components/FormComponents/ChoiceItems/ChoiceSelectAnswerOptionFields.tsx index a35b01df8..33b5732f5 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/ChoiceItems/ChoiceSelectAnswerOptionFields.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/ChoiceItems/ChoiceSelectAnswerOptionFields.tsx @@ -24,7 +24,7 @@ import type { PropsWithIsTabledAttribute, PropsWithRenderingExtensionsAttribute } from '../../../interfaces/renderProps.interface'; -import { useRendererConfigStore } from '../../../stores'; +import { useQuestionnaireStore, useRendererConfigStore } from '../../../stores'; import { compareAnswerOptionValue, isOptionDisabled } from '../../../utils/choice'; import { getAnswerOptionLabel } from '../../../utils/openChoice'; import DisplayUnitText from '../ItemParts/DisplayUnitText'; @@ -64,6 +64,28 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro const readOnlyVisualStyle = useRendererConfigStore.use.readOnlyVisualStyle(); const textFieldWidth = useRendererConfigStore.use.textFieldWidth(); + const answerOptionsLookupFailures = useQuestionnaireStore.use.answerOptionsLookupFailures(); + + // Derive per-field failure flag and a label getter that shows [code] for any option + // whose display couldn't be resolved, keeping successfully-resolved options unchanged. + const hasLookupFailure = options.some( + (opt) => + opt.valueCoding && + !opt.valueCoding.display && + answerOptionsLookupFailures.has(`${opt.valueCoding.system}|${opt.valueCoding.code}`) + ); + + function getLabelWithFallback(option: QuestionnaireItemAnswerOption | string): string { + if (typeof option === 'string') return option; + if ( + option.valueCoding && + !option.valueCoding.display && + answerOptionsLookupFailures.has(`${option.valueCoding.system}|${option.valueCoding.code}`) + ) { + return `[${option.valueCoding.code}]`; + } + return getAnswerOptionLabel(option); + } const { displayUnit, displayPrompt, entryFormat } = renderingExtensions; @@ -87,7 +109,7 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro value={valueSelect ?? null} options={options} getOptionDisabled={(option) => isOptionDisabled(option, answerOptionsToggleExpressionsMap)} - getOptionLabel={(option) => getAnswerOptionLabel(option)} + getOptionLabel={(option) => getLabelWithFallback(option)} isOptionEqualToValue={(option, value) => compareAnswerOptionValue(option, value)} onChange={(_, newValue) => { onSelectChange(newValue); @@ -98,7 +120,7 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro if (!inputValue && valueSelect && reason !== 'clear') { // Convert current input value to be the current value plus additional input onSelectChange(null); - setInputValue(getAnswerOptionLabel(valueSelect) + newInputValue); + setInputValue(getLabelWithFallback(valueSelect) + newInputValue); } else { setInputValue(newInputValue); } @@ -115,7 +137,7 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro if (!inputValue && valueSelect) { // Convert current selection to input value on backspace when input is empty onSelectChange(null); - setInputValue(getAnswerOptionLabel(valueSelect)); + setInputValue(getLabelWithFallback(valueSelect)); } } }} @@ -175,11 +197,11 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro {option.valueString ? ( ) : ( - getAnswerOptionLabel(option) + getLabelWithFallback(option) )} @@ -193,17 +215,24 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro {value.valueString && selectedOption ? ( ) : ( - getAnswerOptionLabel(value) + getLabelWithFallback(value) )} ); }} /> + {hasLookupFailure ? ( + + + Some option labels could not be loaded — terminology server may be unavailable + + + ) : null} {feedback ? ( {feedback} diff --git a/packages/smart-forms-renderer/src/components/FormComponents/OpenChoiceItems/OpenChoiceSelectAnswerOptionField.tsx b/packages/smart-forms-renderer/src/components/FormComponents/OpenChoiceItems/OpenChoiceSelectAnswerOptionField.tsx index 3c7c2ee3b..aa3b64bc3 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/OpenChoiceItems/OpenChoiceSelectAnswerOptionField.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/OpenChoiceItems/OpenChoiceSelectAnswerOptionField.tsx @@ -28,7 +28,7 @@ import type { PropsWithParentIsReadOnlyAttribute, PropsWithRenderingExtensionsAttribute } from '../../../interfaces/renderProps.interface'; -import { useRendererConfigStore } from '../../../stores'; +import { useQuestionnaireStore, useRendererConfigStore } from '../../../stores'; import DisplayUnitText from '../ItemParts/DisplayUnitText'; import ExpressionUpdateFadingIcon from '../ItemParts/ExpressionUpdateFadingIcon'; import StyledText from '../ItemParts/StyledText'; @@ -67,6 +67,26 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi const readOnlyVisualStyle = useRendererConfigStore.use.readOnlyVisualStyle(); const textFieldWidth = useRendererConfigStore.use.textFieldWidth(); + const answerOptionsLookupFailures = useQuestionnaireStore.use.answerOptionsLookupFailures(); + + const hasLookupFailure = options.some( + (opt) => + opt.valueCoding && + !opt.valueCoding.display && + answerOptionsLookupFailures.has(`${opt.valueCoding.system}|${opt.valueCoding.code}`) + ); + + function getLabelWithFallback(option: QuestionnaireItemAnswerOption | string): string { + if (typeof option === 'string') return option; + if ( + option.valueCoding && + !option.valueCoding.display && + answerOptionsLookupFailures.has(`${option.valueCoding.system}|${option.valueCoding.code}`) + ) { + return `[${option.valueCoding.code}]`; + } + return getAnswerOptionLabel(option); + } const { displayUnit, displayPrompt, entryFormat } = renderingExtensions; @@ -85,14 +105,14 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi id={qItem.type + '-' + qItem.linkId} value={valueSelect ?? null} options={options} - getOptionLabel={(option) => getAnswerOptionLabel(option)} + getOptionLabel={(option) => getLabelWithFallback(option)} onChange={(_, newValue, reason) => onValueChange(newValue, reason)} inputValue={inputValue} onInputChange={(_, newInputValue, reason) => { if (!inputValue && valueSelect && reason !== 'clear') { // Convert current input value to be the current value plus additional input onValueChange(null, 'clear'); - setInputValue(getAnswerOptionLabel(valueSelect) + newInputValue); + setInputValue(getLabelWithFallback(valueSelect) + newInputValue); } else { setInputValue(newInputValue); } @@ -109,7 +129,7 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi if (!inputValue && valueSelect) { // Convert current selection to input value on backspace when input is empty onValueChange(null, 'clear'); - setInputValue(getAnswerOptionLabel(valueSelect)); + setInputValue(getLabelWithFallback(valueSelect)); } } }} @@ -162,11 +182,11 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi {option.valueString ? ( ) : ( - getAnswerOptionLabel(option) + getLabelWithFallback(option) )} @@ -185,17 +205,24 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi {typeof value !== 'string' && value.valueString && selectedOption ? ( ) : ( - getAnswerOptionLabel(value) + getLabelWithFallback(value) )} ); }} /> + {hasLookupFailure ? ( + + + Some option labels could not be loaded — terminology server may be unavailable + + + ) : null} {feedback ? ( {feedback} diff --git a/packages/smart-forms-renderer/src/interfaces/questionnaireStore.interface.ts b/packages/smart-forms-renderer/src/interfaces/questionnaireStore.interface.ts index a2b796373..9f7d81ea7 100644 --- a/packages/smart-forms-renderer/src/interfaces/questionnaireStore.interface.ts +++ b/packages/smart-forms-renderer/src/interfaces/questionnaireStore.interface.ts @@ -42,6 +42,8 @@ export interface QuestionnaireModel { initialExpressions: Record; answerExpressions: Record; answerOptions: Record; + /** Per-coding keys (`${system}|${code}`) where $lookup failed — individual options can be labelled with their code as fallback */ + answerOptionsLookupFailures: Set; answerOptionsToggleExpressions: Record; processedValueSets: Record; cachedValueSetCodings: Record; diff --git a/packages/smart-forms-renderer/src/stores/questionnaireStore.ts b/packages/smart-forms-renderer/src/stores/questionnaireStore.ts index 02261e932..c22ac42d3 100644 --- a/packages/smart-forms-renderer/src/stores/questionnaireStore.ts +++ b/packages/smart-forms-renderer/src/stores/questionnaireStore.ts @@ -129,6 +129,8 @@ export interface QuestionnaireStoreType { enableWhenIsActivated: boolean; enableWhenExpressions: EnableWhenExpressions; answerOptionsToggleExpressions: Record; + /** Per-coding keys (`${system}|${code}`) where $lookup failed — options can show a fallback label */ + answerOptionsLookupFailures: Set; processedValueSets: Record; cachedValueSetCodings: Record; fhirPathContext: Record; @@ -195,6 +197,7 @@ export const questionnaireStore = createStore()((set, ge targetConstraints: {}, targetConstraintLinkIds: {}, answerOptionsToggleExpressions: {}, + answerOptionsLookupFailures: new Set(), calculatedExpressions: {}, initialExpressions: {}, enableWhenExpressions: { singleExpressions: {}, repeatExpressions: {} }, @@ -279,6 +282,7 @@ export const questionnaireStore = createStore()((set, ge targetConstraints: initialTargetConstraints, targetConstraintLinkIds: targetConstraintLinkIds, answerOptionsToggleExpressions: initialAnswerOptionsToggleExpressions, + answerOptionsLookupFailures: questionnaireModel.answerOptionsLookupFailures, enableWhenItems: initialEnableWhenItems, enableWhenLinkedQuestions: initialEnableWhenLinkedQuestions, enableWhenExpressions: initialEnableWhenExpressions, @@ -309,6 +313,7 @@ export const questionnaireStore = createStore()((set, ge targetConstraints: {}, targetConstraintLinkIds: {}, answerOptionsToggleExpressions: {}, + answerOptionsLookupFailures: new Set(), enableWhenItems: { singleItems: {}, repeatItems: {} }, enableWhenLinkedQuestions: {}, enableWhenExpressions: { singleExpressions: {}, repeatExpressions: {} }, diff --git a/packages/smart-forms-renderer/src/test/addDisplayToCodings.test.ts b/packages/smart-forms-renderer/src/test/addDisplayToCodings.test.ts index 059f179d3..8f138d3e8 100644 --- a/packages/smart-forms-renderer/src/test/addDisplayToCodings.test.ts +++ b/packages/smart-forms-renderer/src/test/addDisplayToCodings.test.ts @@ -180,12 +180,13 @@ describe('addDisplayToCodings - Phase 5', () => { ] }; - const result = await addDisplayToAnswerOptions( + const { answerOptions: result, lookupFailedCodingKeys } = await addDisplayToAnswerOptions( answerOptions, 'http://terminology.hl7.org/fhir' ); expect(result).toEqual(answerOptions); + expect(lookupFailedCodingKeys.size).toBe(0); expect(mockClient).not.toHaveBeenCalled(); }); @@ -212,7 +213,7 @@ describe('addDisplayToCodings - Phase 5', () => { const mockRequest = jest.fn().mockResolvedValue(mockLookupResponse); mockClient.mockReturnValue({ request: mockRequest } as any); - const result = await addDisplayToAnswerOptions( + const { answerOptions: result, lookupFailedCodingKeys } = await addDisplayToAnswerOptions( answerOptions, 'http://terminology.hl7.org/fhir' ); @@ -224,17 +225,56 @@ describe('addDisplayToCodings - Phase 5', () => { expect(result.item1[0].valueCoding?.display).toBe('Fever'); expect(result.item1[1].valueCoding?.display).toBe('Existing'); expect(result.item1[2]).toEqual({ valueString: 'text option' }); + expect(lookupFailedCodingKeys.size).toBe(0); + }); + + it('should report lookupFailedCodingKeys when terminology server is unavailable', async () => { + // This is the bug reported in issue #1931: + // When the terminology server is down, codings without displays still have no display after + // lookup. Previously the UI would silently fall back to showing the raw code. Now + // lookupFailedCodingKeys records which individual codings had this failure so the UI can + // show a bracketed fallback label per option (e.g. "[133932002]") and an amber warning. + const answerOptions = { + 'carer-type': [ + { valueCoding: { system: 'http://snomed.info/sct', code: '133932002' } }, // no display + { valueCoding: { system: 'http://snomed.info/sct', code: '394738000' } } // no display + ], + 'relationship-type': [ + { valueCoding: { system: 'http://snomed.info/sct', code: '72705000', display: 'Mother' } } // already has display + ] + }; + + const mockRequest = jest.fn().mockRejectedValue(new Error('Network Error')); + mockClient.mockReturnValue({ request: mockRequest } as any); + + const { answerOptions: result, lookupFailedCodingKeys } = await addDisplayToAnswerOptions( + answerOptions, + 'http://terminology.hl7.org/fhir' + ); + + // Displays remain undefined because lookups failed + expect(result['carer-type'][0].valueCoding?.display).toBeUndefined(); + expect(result['carer-type'][1].valueCoding?.display).toBeUndefined(); + // Item with existing display is unaffected + expect(result['relationship-type'][0].valueCoding?.display).toBe('Mother'); + + // Each failing coding is tracked individually by its system|code key + expect(lookupFailedCodingKeys.has('http://snomed.info/sct|133932002')).toBe(true); + expect(lookupFailedCodingKeys.has('http://snomed.info/sct|394738000')).toBe(true); + // Codings that already had displays and didn't need a lookup are NOT flagged + expect(lookupFailedCodingKeys.has('http://snomed.info/sct|72705000')).toBe(false); }); it('should handle empty answer options', async () => { const answerOptions = {}; - const result = await addDisplayToAnswerOptions( + const { answerOptions: result, lookupFailedCodingKeys } = await addDisplayToAnswerOptions( answerOptions, 'http://terminology.hl7.org/fhir' ); expect(result).toEqual({}); + expect(lookupFailedCodingKeys.size).toBe(0); expect(mockClient).not.toHaveBeenCalled(); }); @@ -243,12 +283,13 @@ describe('addDisplayToCodings - Phase 5', () => { item1: [] }; - const result = await addDisplayToAnswerOptions( + const { answerOptions: result, lookupFailedCodingKeys } = await addDisplayToAnswerOptions( answerOptions, 'http://terminology.hl7.org/fhir' ); expect(result).toEqual(answerOptions); + expect(lookupFailedCodingKeys.size).toBe(0); expect(mockClient).not.toHaveBeenCalled(); }); }); diff --git a/packages/smart-forms-renderer/src/test/stores/questionnaireStore.test.ts b/packages/smart-forms-renderer/src/test/stores/questionnaireStore.test.ts index 4ab705911..ad3cbc977 100644 --- a/packages/smart-forms-renderer/src/test/stores/questionnaireStore.test.ts +++ b/packages/smart-forms-renderer/src/test/stores/questionnaireStore.test.ts @@ -123,7 +123,8 @@ describe('questionnaireStore', () => { cachedValueSetCodings: {}, fhirPathContext: {}, fhirPathTerminologyCache: {}, - answerOptions: {} + answerOptions: {}, + answerOptionsLookupFailures: new Set() }; const mockInitialiseFormResult = { diff --git a/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/addDisplayToCodings.ts b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/addDisplayToCodings.ts index 9a698b46d..2847833e3 100644 --- a/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/addDisplayToCodings.ts +++ b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/addDisplayToCodings.ts @@ -55,13 +55,26 @@ export async function addDisplayToCacheCodings( return cachedValueSetCodings; } +export interface AnswerOptionsLookupResult { + answerOptions: Record; + /** + * Per-coding keys (`${system}|${code}`) where the $lookup call failed or returned no display. + * Granular enough to show a fallback label per individual option that couldn't be resolved, + * while leaving successfully-resolved options in the same dropdown unaffected. + */ + lookupFailedCodingKeys: Set; +} + // Use this for a Record export async function addDisplayToAnswerOptions( answerOptions: Record, terminologyServerUrl: string -): Promise> { - // Store code system lookup promises for codings without displays +): Promise { + // Store code system lookup promises for codings without displays. + // Track each coding key (system|code) that needed a lookup so we can identify failures afterward. const codeSystemLookupPromises: Record = {}; + const codingKeysNeedingLookup = new Set(); + for (const key in answerOptions) { const options = answerOptions[key]; for (const option of options) { @@ -71,27 +84,36 @@ export async function addDisplayToAnswerOptions( promise: getCodeSystemLookupPromise(query, terminologyServerUrl), oldCoding: option.valueCoding }; + codingKeysNeedingLookup.add(`${option.valueCoding.system}|${option.valueCoding.code}`); } } } // Resolves lookup promises in one go and assign newCodings to processedCodings const resolvedCodeSystemLookupPromises = await resolveLookupPromises(codeSystemLookupPromises); + const lookupFailedCodingKeys = new Set(); + for (const key in answerOptions) { const options = answerOptions[key]; for (const option of options) { if (option.valueCoding) { const lookUpKey = `system=${option.valueCoding.system}&code=${option.valueCoding.code}`; + const codingKey = `${option.valueCoding.system}|${option.valueCoding.code}`; const resolvedLookup = resolvedCodeSystemLookupPromises[lookUpKey]; if (resolvedLookup?.newCoding?.display) { option.valueCoding.display = resolvedLookup.newCoding.display; + } else if (codingKeysNeedingLookup.has(codingKey) && !option.valueCoding.display) { + // This coding needed a lookup but still has no display — the $lookup request either + // failed or the server returned no display value. Track it so individual options can + // show a fallback label (e.g. "[133932002]") instead of the raw code silently. + lookupFailedCodingKeys.add(codingKey); } } } } - return answerOptions; + return { answerOptions, lookupFailedCodingKeys }; } // Use this for an array of codings diff --git a/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/createQuestionnaireModel.ts b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/createQuestionnaireModel.ts index 9abd06280..8bc175767 100644 --- a/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/createQuestionnaireModel.ts +++ b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/createQuestionnaireModel.ts @@ -104,11 +104,12 @@ export async function createQuestionnaireModel( terminologyServerUrl ); - // In answerOptions, add display values to codings lacking them - const completeAnswerOptions = await addDisplayToAnswerOptions( - answerOptions, - terminologyServerUrl - ); + // In answerOptions, add display values to codings lacking them. + // lookupFailedLinkIds contains linkIds where the $lookup call failed so the UI can warn the user. + const { + answerOptions: completeAnswerOptions, + lookupFailedCodingKeys: answerOptionsLookupFailures + } = await addDisplayToAnswerOptions(answerOptions, terminologyServerUrl); return { itemMap, @@ -124,6 +125,7 @@ export async function createQuestionnaireModel( initialExpressions, answerExpressions, answerOptions: completeAnswerOptions, + answerOptionsLookupFailures, answerOptionsToggleExpressions: answerOptionsToggleExpressions, processedValueSets, cachedValueSetCodings, @@ -146,6 +148,7 @@ function createEmptyModel(): QuestionnaireModel { enableWhenExpressions: { singleExpressions: {}, repeatExpressions: {} }, answerExpressions: {}, answerOptions: {}, + answerOptionsLookupFailures: new Set(), answerOptionsToggleExpressions: {}, enableWhenItems: { singleItems: {}, repeatItems: {} }, processedValueSets: {},