Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand All @@ -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);
Expand All @@ -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);
}
Expand All @@ -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));
}
}
}}
Expand Down Expand Up @@ -175,11 +197,11 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
<span>
{option.valueString ? (
<StyledText
textToDisplay={getAnswerOptionLabel(option)}
textToDisplay={getLabelWithFallback(option)}
element={option._valueString}
/>
) : (
getAnswerOptionLabel(option)
getLabelWithFallback(option)
)}
</span>
</li>
Expand All @@ -193,17 +215,24 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
<span {...rest} style={{ paddingLeft: '8.5px' }}>
{value.valueString && selectedOption ? (
<StyledText
textToDisplay={getAnswerOptionLabel(value)}
textToDisplay={getLabelWithFallback(value)}
element={selectedOption._valueString}
/>
) : (
getAnswerOptionLabel(value)
getLabelWithFallback(value)
)}
</span>
);
}}
/>

{hasLookupFailure ? (
<FormHelperText sx={{ color: 'warning.main' }}>
<AccessibleFeedback>
Some option labels could not be loaded — terminology server may be unavailable
</AccessibleFeedback>
</FormHelperText>
) : null}
{feedback ? (
<FormHelperText>
<AccessibleFeedback>{feedback}</AccessibleFeedback>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand All @@ -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);
}
Expand All @@ -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));
}
}
}}
Expand Down Expand Up @@ -162,11 +182,11 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi
<span>
{option.valueString ? (
<StyledText
textToDisplay={getAnswerOptionLabel(option)}
textToDisplay={getLabelWithFallback(option)}
element={option._valueString}
/>
) : (
getAnswerOptionLabel(option)
getLabelWithFallback(option)
)}
</span>
</li>
Expand All @@ -185,17 +205,24 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi
<span {...rest}>
{typeof value !== 'string' && value.valueString && selectedOption ? (
<StyledText
textToDisplay={getAnswerOptionLabel(value)}
textToDisplay={getLabelWithFallback(value)}
element={selectedOption._valueString}
/>
) : (
getAnswerOptionLabel(value)
getLabelWithFallback(value)
)}
</span>
);
}}
/>

{hasLookupFailure ? (
<FormHelperText sx={{ color: 'warning.main' }}>
<AccessibleFeedback>
Some option labels could not be loaded — terminology server may be unavailable
</AccessibleFeedback>
</FormHelperText>
) : null}
{feedback ? (
<FormHelperText>
<AccessibleFeedback>{feedback}</AccessibleFeedback>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface QuestionnaireModel {
initialExpressions: Record<string, InitialExpression>;
answerExpressions: Record<string, AnswerExpression>;
answerOptions: Record<string, QuestionnaireItemAnswerOption[]>;
/** Per-coding keys (`${system}|${code}`) where $lookup failed — individual options can be labelled with their code as fallback */
answerOptionsLookupFailures: Set<string>;
answerOptionsToggleExpressions: Record<string, AnswerOptionsToggleExpression[]>;
processedValueSets: Record<string, ProcessedValueSet>;
cachedValueSetCodings: Record<string, Coding[]>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export interface QuestionnaireStoreType {
enableWhenIsActivated: boolean;
enableWhenExpressions: EnableWhenExpressions;
answerOptionsToggleExpressions: Record<string, AnswerOptionsToggleExpression[]>;
/** Per-coding keys (`${system}|${code}`) where $lookup failed — options can show a fallback label */
answerOptionsLookupFailures: Set<string>;
processedValueSets: Record<string, ProcessedValueSet>;
cachedValueSetCodings: Record<string, Coding[]>;
fhirPathContext: Record<string, any>;
Expand Down Expand Up @@ -195,6 +197,7 @@ export const questionnaireStore = createStore<QuestionnaireStoreType>()((set, ge
targetConstraints: {},
targetConstraintLinkIds: {},
answerOptionsToggleExpressions: {},
answerOptionsLookupFailures: new Set<string>(),
calculatedExpressions: {},
initialExpressions: {},
enableWhenExpressions: { singleExpressions: {}, repeatExpressions: {} },
Expand Down Expand Up @@ -279,6 +282,7 @@ export const questionnaireStore = createStore<QuestionnaireStoreType>()((set, ge
targetConstraints: initialTargetConstraints,
targetConstraintLinkIds: targetConstraintLinkIds,
answerOptionsToggleExpressions: initialAnswerOptionsToggleExpressions,
answerOptionsLookupFailures: questionnaireModel.answerOptionsLookupFailures,
enableWhenItems: initialEnableWhenItems,
enableWhenLinkedQuestions: initialEnableWhenLinkedQuestions,
enableWhenExpressions: initialEnableWhenExpressions,
Expand Down Expand Up @@ -309,6 +313,7 @@ export const questionnaireStore = createStore<QuestionnaireStoreType>()((set, ge
targetConstraints: {},
targetConstraintLinkIds: {},
answerOptionsToggleExpressions: {},
answerOptionsLookupFailures: new Set<string>(),
enableWhenItems: { singleItems: {}, repeatItems: {} },
enableWhenLinkedQuestions: {},
enableWhenExpressions: { singleExpressions: {}, repeatExpressions: {} },
Expand Down
49 changes: 45 additions & 4 deletions packages/smart-forms-renderer/src/test/addDisplayToCodings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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'
);
Expand All @@ -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();
});

Expand All @@ -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();
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ describe('questionnaireStore', () => {
cachedValueSetCodings: {},
fhirPathContext: {},
fhirPathTerminologyCache: {},
answerOptions: {}
answerOptions: {},
answerOptionsLookupFailures: new Set<string>()
};

const mockInitialiseFormResult = {
Expand Down
Loading
Loading