• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

medplum / medplum / 25082381350

28 Apr 2026 11:09PM UTC coverage: 91.756% (-0.004%) from 91.76%
25082381350

push

github

web-flow
fix(react): QuestionnaireForm remove disabled item/responses from QuestionnaireResponse (#9065)

* fix(react): QuestionnaireForm remove disabled item/responses from QuestionnaireResponse

Signed-off-by: David Yanez <me@davidyanez.com>

* [autofix.ci] apply automated fixes

---------

Signed-off-by: David Yanez <me@davidyanez.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

18470 of 21056 branches covered (87.72%)

Branch coverage included in aggregate %.

13 of 15 new or added lines in 2 files covered. (86.67%)

1 existing line in 1 file now uncovered.

33421 of 35497 relevant lines covered (94.15%)

13472.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.62
/packages/react-hooks/src/useQuestionnaireForm/useQuestionnaireForm.ts
1
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
2
// SPDX-License-Identifier: Apache-2.0
3
import { getExtension } from '@medplum/core';
4
import type {
5
  Encounter,
6
  Questionnaire,
7
  QuestionnaireItem,
8
  QuestionnaireResponse,
9
  QuestionnaireResponseItem,
10
  QuestionnaireResponseItemAnswer,
11
  Reference,
12
  Signature,
13
} from '@medplum/fhirtypes';
14
import { useReducer, useRef } from 'react';
15
import { useResource } from '../useResource/useResource';
16
import {
17
  buildInitialResponse,
18
  buildInitialResponseItem,
19
  evaluateCalculatedExpressionsInQuestionnaire,
20
  QUESTIONNAIRE_ITEM_CONTROL_URL,
21
  QUESTIONNAIRE_SIGNATURE_RESPONSE_URL,
22
  removeDisabledItems,
23
} from './utils';
24

25
// React Hook for Questionnaire Form
26

27
// Why is this hard?
28
// 1. It needs to handle both initial loading of a questionnaire and updating the response as the user interacts with it.
29
// 2. It needs to support pagination and navigation through the questionnaire.
30
// 3. It needs to handle complex items like groups and repeatable items.
31

32
// Conventions we use:
33
// 1. We use `QuestionnaireResponse` to track the user's answers.
34
// 2. We use `QuestionnaireItem` to define the structure of the questionnaire.
35
// 3. Response items are linked to their corresponding questionnaire items by `linkId`.
36
// 4. Response items will always have a `linkId` that matches the `linkId` of the questionnaire item they correspond to.
37
// 5. Response items will also always have an `id` that is unique within the response, which can be used to track changes to individual items.
38
// 6. Pagination is enabled by default, so current state items will only include items for the current page.
39
// 7. If Pagination is disabled, all items will be included in the current state items.
40

41
export interface UseQuestionnaireFormProps {
42
  readonly questionnaire: Questionnaire | Reference<Questionnaire>;
43
  readonly defaultValue?: QuestionnaireResponse | Reference<QuestionnaireResponse>;
44
  readonly subject?: Reference;
45
  readonly encounter?: Reference<Encounter>;
46
  readonly source?: QuestionnaireResponse['source'];
47
  readonly disablePagination?: boolean;
48
  readonly onChange?: (response: QuestionnaireResponse) => void;
49
}
50

51
export interface QuestionnaireFormPage {
52
  readonly linkId: string;
53
  readonly title: string;
54
  readonly group: QuestionnaireItem & { type: 'group' };
55
}
56

57
export interface QuestionnaireFormLoadingState {
58
  /** Currently loading data such as the Questionnaire or the QuestionnaireResponse default value */
59
  readonly loading: true;
60
}
61

62
export interface QuestionnaireFormLoadedState {
63
  /** Not loading */
64
  readonly loading: false;
65

66
  /** The loaded questionnaire */
67
  questionnaire: Questionnaire;
68

69
  /** The current draft questionnaire response */
70
  questionnaireResponse: QuestionnaireResponse;
71

72
  /** Optional questionnaire subject */
73
  subject?: Reference;
74

75
  /** Optional questionnaire encounter */
76
  encounter?: Reference<Encounter>;
77

78
  /** The top level items for the current page */
79
  items: QuestionnaireItem[];
80

81
  /** The response items for the current page */
82
  responseItems: QuestionnaireResponseItem[];
83

84
  /**
85
   * Adds a new group item to the current context.
86
   * @param context - The current context of the questionnaire response items.
87
   * @param item - The questionnaire item that is being added to the group.
88
   */
89
  onAddGroup: (context: QuestionnaireResponseItem[], item: QuestionnaireItem) => void;
90

91
  /**
92
   * Adds an answer to a repeating item.
93
   * @param context - The current context of the questionnaire response items.
94
   * @param item - The questionnaire item that is being answered.
95
   */
96
  onAddAnswer: (context: QuestionnaireResponseItem[], item: QuestionnaireItem) => void;
97

98
  /**
99
   * Changes an answer value.
100
   * @param context - The current context of the questionnaire response items.
101
   * @param item - The questionnaire item that is being answered.
102
   * @param answer - The answer(s) provided by the user for the questionnaire item.
103
   */
104
  onChangeAnswer: (
105
    context: QuestionnaireResponseItem[],
106
    item: QuestionnaireItem,
107
    answer: QuestionnaireResponseItemAnswer[]
108
  ) => void;
109

110
  /**
111
   * Sets or updates the signature for the questionnaire response.
112
   * @param signature - The signature to set, or undefined to clear the signature.
113
   */
114
  onChangeSignature: (signature: Signature | undefined) => void;
115
}
116

117
export interface QuestionnaireFormSinglePageState extends QuestionnaireFormLoadedState {
118
  readonly pagination: false;
119
}
120

121
export interface QuestionnaireFormPaginationState extends QuestionnaireFormLoadedState {
122
  readonly pagination: true;
123
  pages: QuestionnaireFormPage[];
124
  activePage: number;
125
  onNextPage: () => void;
126
  onPrevPage: () => void;
127
}
128

129
export type QuestionnaireFormState =
130
  | QuestionnaireFormLoadingState
131
  | QuestionnaireFormSinglePageState
132
  | QuestionnaireFormPaginationState;
133

134
export function useQuestionnaireForm(props: UseQuestionnaireFormProps): Readonly<QuestionnaireFormState> {
135
  const questionnaire = useResource(props.questionnaire);
25✔
136
  const defaultResponse = useResource(props.defaultValue);
25✔
137
  const [, forceUpdate] = useReducer((x) => x + 1, 0);
25✔
138

139
  const state = useRef<Partial<QuestionnaireFormPaginationState>>({
25✔
140
    activePage: 0,
141
  });
142

143
  // If the questionnaire is loaded, we will set the current questionnaire and pages.
144
  if (!state.current.questionnaire && questionnaire) {
25✔
145
    state.current.questionnaire = questionnaire;
7✔
146
    state.current.pages = props.disablePagination ? undefined : getPages(questionnaire);
7!
147
  }
148

149
  // If we are expecting a questionnaire response, and it is loaded, then use it.
150
  if (questionnaire && props.defaultValue && defaultResponse && !state.current.questionnaireResponse) {
25✔
151
    state.current.questionnaireResponse = buildInitialResponse(questionnaire, defaultResponse);
1✔
152
    emitChange();
1✔
153
  }
154

155
  // If we are not expecting a questionnaire response, we will create a new one.
156
  if (questionnaire && !props.defaultValue && !state.current.questionnaireResponse) {
25✔
157
    state.current.questionnaireResponse = buildInitialResponse(questionnaire);
6✔
158
    emitChange();
6✔
159
  }
160

161
  if (!state.current.questionnaire || !state.current.questionnaireResponse) {
25!
162
    return { loading: true };
×
163
  }
164

165
  function getResponseItemByContext(
166
    context: QuestionnaireResponseItem[]
167
  ): QuestionnaireResponse | QuestionnaireResponseItem | undefined;
168
  function getResponseItemByContext(
169
    context: QuestionnaireResponseItem[],
170
    item?: QuestionnaireItem
171
  ): QuestionnaireResponseItem | undefined;
172
  function getResponseItemByContext(
173
    context: QuestionnaireResponseItem[],
174
    item?: QuestionnaireItem
175
  ): QuestionnaireResponse | QuestionnaireResponseItem | undefined {
176
    let currentItem: QuestionnaireResponse | QuestionnaireResponseItem | undefined =
177
      state.current.questionnaireResponse;
7✔
178
    for (const contextElement of context) {
7✔
179
      currentItem = currentItem?.item?.find((i) =>
2✔
180
        contextElement.id ? i.id === contextElement.id : i.linkId === contextElement.linkId
3!
181
      );
182
    }
183
    if (item) {
7✔
184
      currentItem = currentItem?.item?.find((i) => i.linkId === item.linkId);
5✔
185
    }
186
    return currentItem;
7✔
187
  }
188

189
  function onNextPage(): void {
190
    state.current.activePage = (state.current.activePage ?? 0) + 1;
1!
191
    forceUpdate();
1✔
192
  }
193

194
  function onPrevPage(): void {
195
    state.current.activePage = (state.current.activePage ?? 0) - 1;
1!
196
    forceUpdate();
1✔
197
  }
198

199
  function onAddGroup(context: QuestionnaireResponseItem[], item: QuestionnaireItem): void {
200
    const responseItem = getResponseItemByContext(context);
2✔
201
    if (responseItem) {
2!
202
      responseItem.item ??= [];
2✔
203
      responseItem.item.push(buildInitialResponseItem(item));
2✔
204
      emitChange();
2✔
205
    }
206
  }
207

208
  function onAddAnswer(context: QuestionnaireResponseItem[], item: QuestionnaireItem): void {
209
    const currentItem = getResponseItemByContext(context, item);
1✔
210
    if (currentItem) {
1!
211
      currentItem.answer ??= [];
1✔
212
      currentItem.answer.push({});
1✔
213
      emitChange();
1✔
214
    }
215
  }
216

217
  function onChangeAnswer(
218
    context: QuestionnaireResponseItem[],
219
    item: QuestionnaireItem,
220
    answer: QuestionnaireResponseItemAnswer[]
221
  ): void {
222
    const currentItem = getResponseItemByContext(context, item);
4✔
223
    if (currentItem) {
4!
224
      currentItem.answer = answer;
4✔
225
      emitChange();
4✔
226
    }
227
  }
228

229
  function onChangeSignature(signature: Signature | undefined): void {
230
    const currentResponse = state.current.questionnaireResponse;
2✔
231
    if (!currentResponse) {
2!
232
      return;
×
233
    }
234
    if (signature) {
2✔
235
      currentResponse.extension = currentResponse.extension ?? [];
1✔
236
      currentResponse.extension = currentResponse.extension.filter(
1✔
237
        (ext) => ext.url !== QUESTIONNAIRE_SIGNATURE_RESPONSE_URL
×
238
      );
239
      currentResponse.extension.push({
1✔
240
        url: QUESTIONNAIRE_SIGNATURE_RESPONSE_URL,
241
        valueSignature: signature,
242
      });
243
    } else {
244
      currentResponse.extension = currentResponse.extension?.filter(
1✔
245
        (ext) => ext.url !== QUESTIONNAIRE_SIGNATURE_RESPONSE_URL
1✔
246
      );
247
    }
248
    emitChange();
2✔
249
  }
250

251
  function updateCalculatedExpressions(): void {
252
    const questionnaire = state.current.questionnaire;
16✔
253
    if (questionnaire?.item) {
16!
254
      const response = state.current.questionnaireResponse as QuestionnaireResponse;
16✔
255
      evaluateCalculatedExpressionsInQuestionnaire(questionnaire.item, response);
16✔
256
    }
257
  }
258

259
  function emitChange(): void {
260
    const currentResponse = state.current.questionnaireResponse;
16✔
261
    const currentQuestionnaire = state.current.questionnaire;
16✔
262
    if (!currentResponse || !currentQuestionnaire) {
16!
UNCOV
263
      return;
×
264
    }
265
    updateCalculatedExpressions();
16✔
266
    forceUpdate();
16✔
267
    props.onChange?.(removeDisabledItems(currentQuestionnaire, currentResponse));
16✔
268
  }
269

270
  return {
25✔
271
    loading: false,
272
    pagination: !!state.current.pages,
273
    questionnaire: state.current.questionnaire,
274
    questionnaireResponse: removeDisabledItems(state.current.questionnaire, state.current.questionnaireResponse),
275
    subject: props.subject,
276
    encounter: props.encounter,
277
    activePage: state.current.activePage,
278
    pages: state.current.pages,
279
    items: getItemsForPage(state.current.questionnaire, state.current.pages, state.current.activePage),
280
    responseItems: getResponseItemsForPage(
281
      state.current.questionnaireResponse,
282
      state.current.pages,
283
      state.current.activePage
284
    ),
285
    onNextPage,
286
    onPrevPage,
287
    onAddGroup,
288
    onAddAnswer,
289
    onChangeAnswer,
290
    onChangeSignature,
291
  } as QuestionnaireFormSinglePageState | QuestionnaireFormPaginationState;
292
}
293

294
function getPages(questionnaire: Questionnaire): QuestionnaireFormPage[] | undefined {
295
  if (!questionnaire?.item) {
7!
296
    return undefined;
×
297
  }
298
  const extension = getExtension(questionnaire?.item?.[0], QUESTIONNAIRE_ITEM_CONTROL_URL);
7✔
299
  if (extension?.valueCodeableConcept?.coding?.[0]?.code !== 'page') {
7✔
300
    return undefined;
6✔
301
  }
302

303
  return questionnaire.item.map((item, index) => {
1✔
304
    return {
2✔
305
      linkId: item.linkId,
306
      title: item.text ?? `Page ${index + 1}`,
2!
307
      group: item as QuestionnaireItem & { type: 'group' },
308
    };
309
  });
310
}
311

312
function getItemsForPage(
313
  questionnaire: Questionnaire,
314
  pages: QuestionnaireFormPage[] | undefined,
315
  activePage = 0
×
316
): QuestionnaireItem[] {
317
  if (pages && questionnaire?.item?.[activePage]) {
25✔
318
    return [questionnaire.item[activePage]];
4✔
319
  }
320
  return questionnaire.item ?? [];
21!
321
}
322

323
function getResponseItemsForPage(
324
  questionnaireResponse: QuestionnaireResponse,
325
  pages: QuestionnaireFormPage[] | undefined,
326
  activePage = 0
×
327
): QuestionnaireResponseItem[] {
328
  if (pages && questionnaireResponse?.item?.[activePage]) {
25✔
329
    return [questionnaireResponse.item[activePage]];
4✔
330
  }
331
  return questionnaireResponse.item ?? [];
21!
332
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc