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: {},