Skip to content

Commit b6079ab

Browse files
refactor(#1931): track lookup failures per-coding key instead of per-linkId
Switch answerOptionsLookupFailures from tracking linkIds to individual coding keys (system|code). The dropdown now stays accessible and shows a bracketed code fallback (e.g. [133932002]) for each option that could not be resolved, with a single amber warning below the field for partial failures — keeping successfully-resolved options fully labelled. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7f0800b commit b6079ab

7 files changed

Lines changed: 108 additions & 78 deletions

File tree

packages/smart-forms-renderer/src/components/FormComponents/ChoiceItems/ChoiceSelectAnswerOptionFields.tsx

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,6 @@ import React from 'react';
1919
import Autocomplete from '@mui/material/Autocomplete';
2020
import FormControl from '@mui/material/FormControl';
2121
import FormHelperText from '@mui/material/FormHelperText';
22-
import Typography from '@mui/material/Typography';
23-
// @ts-ignore: Module has no declaration file
24-
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
2522
import type { QuestionnaireItem, QuestionnaireItemAnswerOption } from 'fhir/r4';
2623
import type {
2724
PropsWithIsTabledAttribute,
@@ -35,7 +32,6 @@ import ExpressionUpdateFadingIcon from '../ItemParts/ExpressionUpdateFadingIcon'
3532
import { StandardTextField } from '../Textfield.styles';
3633
import StyledText from '../ItemParts/StyledText';
3734
import AccessibleFeedback from '../ItemParts/AccessibleFeedback';
38-
import { StyledAlert } from '../../Alert.styles';
3935

4036
interface ChoiceSelectAnswerOptionFieldsProps
4137
extends PropsWithIsTabledAttribute,
@@ -69,7 +65,27 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
6965
const readOnlyVisualStyle = useRendererConfigStore.use.readOnlyVisualStyle();
7066
const textFieldWidth = useRendererConfigStore.use.textFieldWidth();
7167
const answerOptionsLookupFailures = useQuestionnaireStore.use.answerOptionsLookupFailures();
72-
const lookupFailed = answerOptionsLookupFailures.has(qItem.linkId);
68+
69+
// Derive per-field failure flag and a label getter that shows [code] for any option
70+
// whose display couldn't be resolved, keeping successfully-resolved options unchanged.
71+
const hasLookupFailure = options.some(
72+
(opt) =>
73+
opt.valueCoding &&
74+
!opt.valueCoding.display &&
75+
answerOptionsLookupFailures.has(`${opt.valueCoding.system}|${opt.valueCoding.code}`)
76+
);
77+
78+
function getLabelWithFallback(option: QuestionnaireItemAnswerOption | string): string {
79+
if (typeof option === 'string') return option;
80+
if (
81+
option.valueCoding &&
82+
!option.valueCoding.display &&
83+
answerOptionsLookupFailures.has(`${option.valueCoding.system}|${option.valueCoding.code}`)
84+
) {
85+
return `[${option.valueCoding.code}]`;
86+
}
87+
return getAnswerOptionLabel(option);
88+
}
7389

7490
const { displayUnit, displayPrompt, entryFormat } = renderingExtensions;
7591

@@ -79,19 +95,6 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
7995
const [currentValueSelect, setCurrentValueSelect] =
8096
React.useState<QuestionnaireItemAnswerOption | null>(valueSelect);
8197

82-
// Terminology server could not resolve displays for this item's answerOptions — show the same
83-
// StyledAlert pattern used by answerValueSet fields to keep the UI consistent.
84-
if (lookupFailed) {
85-
return (
86-
<StyledAlert color="error">
87-
<ErrorOutlineIcon color="error" sx={{ pr: 0.75 }} />
88-
<Typography component="div">
89-
Unable to load option labels — terminology server may be unavailable
90-
</Typography>
91-
</StyledAlert>
92-
);
93-
}
94-
9598
return (
9699
<FormControl
97100
error={!!feedback}
@@ -106,7 +109,7 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
106109
value={valueSelect ?? null}
107110
options={options}
108111
getOptionDisabled={(option) => isOptionDisabled(option, answerOptionsToggleExpressionsMap)}
109-
getOptionLabel={(option) => getAnswerOptionLabel(option)}
112+
getOptionLabel={(option) => getLabelWithFallback(option)}
110113
isOptionEqualToValue={(option, value) => compareAnswerOptionValue(option, value)}
111114
onChange={(_, newValue) => {
112115
onSelectChange(newValue);
@@ -117,7 +120,7 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
117120
if (!inputValue && valueSelect && reason !== 'clear') {
118121
// Convert current input value to be the current value plus additional input
119122
onSelectChange(null);
120-
setInputValue(getAnswerOptionLabel(valueSelect) + newInputValue);
123+
setInputValue(getLabelWithFallback(valueSelect) + newInputValue);
121124
} else {
122125
setInputValue(newInputValue);
123126
}
@@ -134,7 +137,7 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
134137
if (!inputValue && valueSelect) {
135138
// Convert current selection to input value on backspace when input is empty
136139
onSelectChange(null);
137-
setInputValue(getAnswerOptionLabel(valueSelect));
140+
setInputValue(getLabelWithFallback(valueSelect));
138141
}
139142
}
140143
}}
@@ -194,11 +197,11 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
194197
<span>
195198
{option.valueString ? (
196199
<StyledText
197-
textToDisplay={getAnswerOptionLabel(option)}
200+
textToDisplay={getLabelWithFallback(option)}
198201
element={option._valueString}
199202
/>
200203
) : (
201-
getAnswerOptionLabel(option)
204+
getLabelWithFallback(option)
202205
)}
203206
</span>
204207
</li>
@@ -212,17 +215,24 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
212215
<span {...rest} style={{ paddingLeft: '8.5px' }}>
213216
{value.valueString && selectedOption ? (
214217
<StyledText
215-
textToDisplay={getAnswerOptionLabel(value)}
218+
textToDisplay={getLabelWithFallback(value)}
216219
element={selectedOption._valueString}
217220
/>
218221
) : (
219-
getAnswerOptionLabel(value)
222+
getLabelWithFallback(value)
220223
)}
221224
</span>
222225
);
223226
}}
224227
/>
225228

229+
{hasLookupFailure ? (
230+
<FormHelperText sx={{ color: 'warning.main' }}>
231+
<AccessibleFeedback>
232+
Some option labels could not be loaded — terminology server may be unavailable
233+
</AccessibleFeedback>
234+
</FormHelperText>
235+
) : null}
226236
{feedback ? (
227237
<FormHelperText>
228238
<AccessibleFeedback>{feedback}</AccessibleFeedback>

packages/smart-forms-renderer/src/components/FormComponents/OpenChoiceItems/OpenChoiceSelectAnswerOptionField.tsx

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,6 @@ import type { AutocompleteChangeReason } from '@mui/material/Autocomplete';
2222
import Autocomplete from '@mui/material/Autocomplete';
2323
import FormControl from '@mui/material/FormControl';
2424
import FormHelperText from '@mui/material/FormHelperText';
25-
import Typography from '@mui/material/Typography';
26-
// @ts-ignore: Module has no declaration file
27-
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
2825
import type { QuestionnaireItem, QuestionnaireItemAnswerOption } from 'fhir/r4';
2926
import type {
3027
PropsWithIsTabledAttribute,
@@ -36,7 +33,6 @@ import DisplayUnitText from '../ItemParts/DisplayUnitText';
3633
import ExpressionUpdateFadingIcon from '../ItemParts/ExpressionUpdateFadingIcon';
3734
import StyledText from '../ItemParts/StyledText';
3835
import AccessibleFeedback from '../ItemParts/AccessibleFeedback';
39-
import { StyledAlert } from '../../Alert.styles';
4036

4137
interface OpenChoiceSelectAnswerOptionFieldProps
4238
extends PropsWithIsTabledAttribute,
@@ -72,23 +68,30 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi
7268
const readOnlyVisualStyle = useRendererConfigStore.use.readOnlyVisualStyle();
7369
const textFieldWidth = useRendererConfigStore.use.textFieldWidth();
7470
const answerOptionsLookupFailures = useQuestionnaireStore.use.answerOptionsLookupFailures();
75-
const lookupFailed = answerOptionsLookupFailures.has(qItem.linkId);
71+
72+
const hasLookupFailure = options.some(
73+
(opt) =>
74+
opt.valueCoding &&
75+
!opt.valueCoding.display &&
76+
answerOptionsLookupFailures.has(`${opt.valueCoding.system}|${opt.valueCoding.code}`)
77+
);
78+
79+
function getLabelWithFallback(option: QuestionnaireItemAnswerOption | string): string {
80+
if (typeof option === 'string') return option;
81+
if (
82+
option.valueCoding &&
83+
!option.valueCoding.display &&
84+
answerOptionsLookupFailures.has(`${option.valueCoding.system}|${option.valueCoding.code}`)
85+
) {
86+
return `[${option.valueCoding.code}]`;
87+
}
88+
return getAnswerOptionLabel(option);
89+
}
7690

7791
const { displayUnit, displayPrompt, entryFormat } = renderingExtensions;
7892

7993
const [inputValue, setInputValue] = React.useState('');
8094

81-
if (lookupFailed) {
82-
return (
83-
<StyledAlert color="error">
84-
<ErrorOutlineIcon color="error" sx={{ pr: 0.75 }} />
85-
<Typography component="div">
86-
Unable to load option labels — terminology server may be unavailable
87-
</Typography>
88-
</StyledAlert>
89-
);
90-
}
91-
9295
return (
9396
<FormControl
9497
error={!!feedback}
@@ -102,14 +105,14 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi
102105
id={qItem.type + '-' + qItem.linkId}
103106
value={valueSelect ?? null}
104107
options={options}
105-
getOptionLabel={(option) => getAnswerOptionLabel(option)}
108+
getOptionLabel={(option) => getLabelWithFallback(option)}
106109
onChange={(_, newValue, reason) => onValueChange(newValue, reason)}
107110
inputValue={inputValue}
108111
onInputChange={(_, newInputValue, reason) => {
109112
if (!inputValue && valueSelect && reason !== 'clear') {
110113
// Convert current input value to be the current value plus additional input
111114
onValueChange(null, 'clear');
112-
setInputValue(getAnswerOptionLabel(valueSelect) + newInputValue);
115+
setInputValue(getLabelWithFallback(valueSelect) + newInputValue);
113116
} else {
114117
setInputValue(newInputValue);
115118
}
@@ -126,7 +129,7 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi
126129
if (!inputValue && valueSelect) {
127130
// Convert current selection to input value on backspace when input is empty
128131
onValueChange(null, 'clear');
129-
setInputValue(getAnswerOptionLabel(valueSelect));
132+
setInputValue(getLabelWithFallback(valueSelect));
130133
}
131134
}
132135
}}
@@ -179,11 +182,11 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi
179182
<span>
180183
{option.valueString ? (
181184
<StyledText
182-
textToDisplay={getAnswerOptionLabel(option)}
185+
textToDisplay={getLabelWithFallback(option)}
183186
element={option._valueString}
184187
/>
185188
) : (
186-
getAnswerOptionLabel(option)
189+
getLabelWithFallback(option)
187190
)}
188191
</span>
189192
</li>
@@ -202,17 +205,24 @@ function OpenChoiceSelectAnswerOptionField(props: OpenChoiceSelectAnswerOptionFi
202205
<span {...rest}>
203206
{typeof value !== 'string' && value.valueString && selectedOption ? (
204207
<StyledText
205-
textToDisplay={getAnswerOptionLabel(value)}
208+
textToDisplay={getLabelWithFallback(value)}
206209
element={selectedOption._valueString}
207210
/>
208211
) : (
209-
getAnswerOptionLabel(value)
212+
getLabelWithFallback(value)
210213
)}
211214
</span>
212215
);
213216
}}
214217
/>
215218

219+
{hasLookupFailure ? (
220+
<FormHelperText sx={{ color: 'warning.main' }}>
221+
<AccessibleFeedback>
222+
Some option labels could not be loaded — terminology server may be unavailable
223+
</AccessibleFeedback>
224+
</FormHelperText>
225+
) : null}
216226
{feedback ? (
217227
<FormHelperText>
218228
<AccessibleFeedback>{feedback}</AccessibleFeedback>

packages/smart-forms-renderer/src/interfaces/questionnaireStore.interface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export interface QuestionnaireModel {
4242
initialExpressions: Record<string, InitialExpression>;
4343
answerExpressions: Record<string, AnswerExpression>;
4444
answerOptions: Record<string, QuestionnaireItemAnswerOption[]>;
45-
/** linkIds whose answerOption codings had no display and whose $lookup failed */
45+
/** Per-coding keys (`${system}|${code}`) where $lookup failed — individual options can be labelled with their code as fallback */
4646
answerOptionsLookupFailures: Set<string>;
4747
answerOptionsToggleExpressions: Record<string, AnswerOptionsToggleExpression[]>;
4848
processedValueSets: Record<string, ProcessedValueSet>;

packages/smart-forms-renderer/src/stores/questionnaireStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export interface QuestionnaireStoreType {
129129
enableWhenIsActivated: boolean;
130130
enableWhenExpressions: EnableWhenExpressions;
131131
answerOptionsToggleExpressions: Record<string, AnswerOptionsToggleExpression[]>;
132-
/** linkIds whose answerOption codings had no display and whose $lookup call failed */
132+
/** Per-coding keys (`${system}|${code}`) where $lookup failed — options can show a fallback label */
133133
answerOptionsLookupFailures: Set<string>;
134134
processedValueSets: Record<string, ProcessedValueSet>;
135135
cachedValueSetCodings: Record<string, Coding[]>;

packages/smart-forms-renderer/src/test/addDisplayToCodings.test.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,13 @@ describe('addDisplayToCodings - Phase 5', () => {
180180
]
181181
};
182182

183-
const { answerOptions: result, lookupFailedLinkIds } = await addDisplayToAnswerOptions(
183+
const { answerOptions: result, lookupFailedCodingKeys } = await addDisplayToAnswerOptions(
184184
answerOptions,
185185
'http://terminology.hl7.org/fhir'
186186
);
187187

188188
expect(result).toEqual(answerOptions);
189-
expect(lookupFailedLinkIds.size).toBe(0);
189+
expect(lookupFailedCodingKeys.size).toBe(0);
190190
expect(mockClient).not.toHaveBeenCalled();
191191
});
192192

@@ -213,7 +213,7 @@ describe('addDisplayToCodings - Phase 5', () => {
213213
const mockRequest = jest.fn().mockResolvedValue(mockLookupResponse);
214214
mockClient.mockReturnValue({ request: mockRequest } as any);
215215

216-
const { answerOptions: result, lookupFailedLinkIds } = await addDisplayToAnswerOptions(
216+
const { answerOptions: result, lookupFailedCodingKeys } = await addDisplayToAnswerOptions(
217217
answerOptions,
218218
'http://terminology.hl7.org/fhir'
219219
);
@@ -225,14 +225,15 @@ describe('addDisplayToCodings - Phase 5', () => {
225225
expect(result.item1[0].valueCoding?.display).toBe('Fever');
226226
expect(result.item1[1].valueCoding?.display).toBe('Existing');
227227
expect(result.item1[2]).toEqual({ valueString: 'text option' });
228-
expect(lookupFailedLinkIds.size).toBe(0);
228+
expect(lookupFailedCodingKeys.size).toBe(0);
229229
});
230230

231-
it('should report lookupFailedLinkIds when terminology server is unavailable', async () => {
231+
it('should report lookupFailedCodingKeys when terminology server is unavailable', async () => {
232232
// This is the bug reported in issue #1931:
233233
// When the terminology server is down, codings without displays still have no display after
234234
// lookup. Previously the UI would silently fall back to showing the raw code. Now
235-
// lookupFailedLinkIds records which items had this failure so the UI can show an error.
235+
// lookupFailedCodingKeys records which individual codings had this failure so the UI can
236+
// show a bracketed fallback label per option (e.g. "[133932002]") and an amber warning.
236237
const answerOptions = {
237238
'carer-type': [
238239
{ valueCoding: { system: 'http://snomed.info/sct', code: '133932002' } }, // no display
@@ -246,7 +247,7 @@ describe('addDisplayToCodings - Phase 5', () => {
246247
const mockRequest = jest.fn().mockRejectedValue(new Error('Network Error'));
247248
mockClient.mockReturnValue({ request: mockRequest } as any);
248249

249-
const { answerOptions: result, lookupFailedLinkIds } = await addDisplayToAnswerOptions(
250+
const { answerOptions: result, lookupFailedCodingKeys } = await addDisplayToAnswerOptions(
250251
answerOptions,
251252
'http://terminology.hl7.org/fhir'
252253
);
@@ -257,22 +258,23 @@ describe('addDisplayToCodings - Phase 5', () => {
257258
// Item with existing display is unaffected
258259
expect(result['relationship-type'][0].valueCoding?.display).toBe('Mother');
259260

260-
// The linkId whose lookup failed is tracked so the UI can warn the user
261-
expect(lookupFailedLinkIds.has('carer-type')).toBe(true);
262-
// Items that already had displays and didn't need a lookup are NOT flagged
263-
expect(lookupFailedLinkIds.has('relationship-type')).toBe(false);
261+
// Each failing coding is tracked individually by its system|code key
262+
expect(lookupFailedCodingKeys.has('http://snomed.info/sct|133932002')).toBe(true);
263+
expect(lookupFailedCodingKeys.has('http://snomed.info/sct|394738000')).toBe(true);
264+
// Codings that already had displays and didn't need a lookup are NOT flagged
265+
expect(lookupFailedCodingKeys.has('http://snomed.info/sct|72705000')).toBe(false);
264266
});
265267

266268
it('should handle empty answer options', async () => {
267269
const answerOptions = {};
268270

269-
const { answerOptions: result, lookupFailedLinkIds } = await addDisplayToAnswerOptions(
271+
const { answerOptions: result, lookupFailedCodingKeys } = await addDisplayToAnswerOptions(
270272
answerOptions,
271273
'http://terminology.hl7.org/fhir'
272274
);
273275

274276
expect(result).toEqual({});
275-
expect(lookupFailedLinkIds.size).toBe(0);
277+
expect(lookupFailedCodingKeys.size).toBe(0);
276278
expect(mockClient).not.toHaveBeenCalled();
277279
});
278280

@@ -281,13 +283,13 @@ describe('addDisplayToCodings - Phase 5', () => {
281283
item1: []
282284
};
283285

284-
const { answerOptions: result, lookupFailedLinkIds } = await addDisplayToAnswerOptions(
286+
const { answerOptions: result, lookupFailedCodingKeys } = await addDisplayToAnswerOptions(
285287
answerOptions,
286288
'http://terminology.hl7.org/fhir'
287289
);
288290

289291
expect(result).toEqual(answerOptions);
290-
expect(lookupFailedLinkIds.size).toBe(0);
292+
expect(lookupFailedCodingKeys.size).toBe(0);
291293
expect(mockClient).not.toHaveBeenCalled();
292294
});
293295
});

0 commit comments

Comments
 (0)