Skip to main content
Glama
useQuestionnaireForm.ts11.5 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { getExtension } from '@medplum/core'; import type { Encounter, Questionnaire, QuestionnaireItem, QuestionnaireResponse, QuestionnaireResponseItem, QuestionnaireResponseItemAnswer, Reference, Signature, } from '@medplum/fhirtypes'; import { useReducer, useRef } from 'react'; import { useResource } from '../useResource/useResource'; import { buildInitialResponse, buildInitialResponseItem, evaluateCalculatedExpressionsInQuestionnaire, QUESTIONNAIRE_ITEM_CONTROL_URL, QUESTIONNAIRE_SIGNATURE_RESPONSE_URL, } from './utils'; // React Hook for Questionnaire Form // Why is this hard? // 1. It needs to handle both initial loading of a questionnaire and updating the response as the user interacts with it. // 2. It needs to support pagination and navigation through the questionnaire. // 3. It needs to handle complex items like groups and repeatable items. // Conventions we use: // 1. We use `QuestionnaireResponse` to track the user's answers. // 2. We use `QuestionnaireItem` to define the structure of the questionnaire. // 3. Response items are linked to their corresponding questionnaire items by `linkId`. // 4. Response items will always have a `linkId` that matches the `linkId` of the questionnaire item they correspond to. // 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. // 6. Pagination is enabled by default, so current state items will only include items for the current page. // 7. If Pagination is disabled, all items will be included in the current state items. export interface UseQuestionnaireFormProps { readonly questionnaire: Questionnaire | Reference<Questionnaire>; readonly defaultValue?: QuestionnaireResponse | Reference<QuestionnaireResponse>; readonly subject?: Reference; readonly encounter?: Reference<Encounter>; readonly source?: QuestionnaireResponse['source']; readonly disablePagination?: boolean; readonly onChange?: (response: QuestionnaireResponse) => void; } export interface QuestionnaireFormPage { readonly linkId: string; readonly title: string; readonly group: QuestionnaireItem & { type: 'group' }; } export interface QuestionnaireFormLoadingState { /** Currently loading data such as the Questionnaire or the QuestionnaireResponse default value */ readonly loading: true; } export interface QuestionnaireFormLoadedState { /** Not loading */ readonly loading: false; /** The loaded questionnaire */ questionnaire: Questionnaire; /** The current draft questionnaire response */ questionnaireResponse: QuestionnaireResponse; /** Optional questionnaire subject */ subject?: Reference; /** Optional questionnaire encounter */ encounter?: Reference<Encounter>; /** The top level items for the current page */ items: QuestionnaireItem[]; /** The response items for the current page */ responseItems: QuestionnaireResponseItem[]; /** * Adds a new group item to the current context. * @param context - The current context of the questionnaire response items. * @param item - The questionnaire item that is being added to the group. */ onAddGroup: (context: QuestionnaireResponseItem[], item: QuestionnaireItem) => void; /** * Adds an answer to a repeating item. * @param context - The current context of the questionnaire response items. * @param item - The questionnaire item that is being answered. */ onAddAnswer: (context: QuestionnaireResponseItem[], item: QuestionnaireItem) => void; /** * Changes an answer value. * @param context - The current context of the questionnaire response items. * @param item - The questionnaire item that is being answered. * @param answer - The answer(s) provided by the user for the questionnaire item. */ onChangeAnswer: ( context: QuestionnaireResponseItem[], item: QuestionnaireItem, answer: QuestionnaireResponseItemAnswer[] ) => void; /** * Sets or updates the signature for the questionnaire response. * @param signature - The signature to set, or undefined to clear the signature. */ onChangeSignature: (signature: Signature | undefined) => void; } export interface QuestionnaireFormSinglePageState extends QuestionnaireFormLoadedState { readonly pagination: false; } export interface QuestionnaireFormPaginationState extends QuestionnaireFormLoadedState { readonly pagination: true; pages: QuestionnaireFormPage[]; activePage: number; onNextPage: () => void; onPrevPage: () => void; } export type QuestionnaireFormState = | QuestionnaireFormLoadingState | QuestionnaireFormSinglePageState | QuestionnaireFormPaginationState; export function useQuestionnaireForm(props: UseQuestionnaireFormProps): Readonly<QuestionnaireFormState> { const questionnaire = useResource(props.questionnaire); const defaultResponse = useResource(props.defaultValue); const [, forceUpdate] = useReducer((x) => x + 1, 0); const state = useRef<Partial<QuestionnaireFormPaginationState>>({ activePage: 0, }); // If the questionnaire is loaded, we will set the current questionnaire and pages. if (!state.current.questionnaire && questionnaire) { state.current.questionnaire = questionnaire; state.current.pages = props.disablePagination ? undefined : getPages(questionnaire); } // If we are expecting a questionnaire response, and it is loaded, then use it. if (questionnaire && props.defaultValue && defaultResponse && !state.current.questionnaireResponse) { state.current.questionnaireResponse = buildInitialResponse(questionnaire, defaultResponse); emitChange(); } // If we are not expecting a questionnaire response, we will create a new one. if (questionnaire && !props.defaultValue && !state.current.questionnaireResponse) { state.current.questionnaireResponse = buildInitialResponse(questionnaire); emitChange(); } if (!state.current.questionnaire || !state.current.questionnaireResponse) { return { loading: true }; } function getResponseItemByContext( context: QuestionnaireResponseItem[] ): QuestionnaireResponse | QuestionnaireResponseItem | undefined; function getResponseItemByContext( context: QuestionnaireResponseItem[], item?: QuestionnaireItem ): QuestionnaireResponseItem | undefined; function getResponseItemByContext( context: QuestionnaireResponseItem[], item?: QuestionnaireItem ): QuestionnaireResponse | QuestionnaireResponseItem | undefined { let currentItem: QuestionnaireResponse | QuestionnaireResponseItem | undefined = state.current.questionnaireResponse; for (const contextElement of context) { currentItem = currentItem?.item?.find((i) => contextElement.id ? i.id === contextElement.id : i.linkId === contextElement.linkId ); } if (item) { currentItem = currentItem?.item?.find((i) => i.linkId === item.linkId); } return currentItem; } function onNextPage(): void { state.current.activePage = (state.current.activePage ?? 0) + 1; forceUpdate(); } function onPrevPage(): void { state.current.activePage = (state.current.activePage ?? 0) - 1; forceUpdate(); } function onAddGroup(context: QuestionnaireResponseItem[], item: QuestionnaireItem): void { const responseItem = getResponseItemByContext(context); if (responseItem) { responseItem.item ??= []; responseItem.item.push(buildInitialResponseItem(item)); emitChange(); } } function onAddAnswer(context: QuestionnaireResponseItem[], item: QuestionnaireItem): void { const currentItem = getResponseItemByContext(context, item); if (currentItem) { currentItem.answer ??= []; currentItem.answer.push({}); emitChange(); } } function onChangeAnswer( context: QuestionnaireResponseItem[], item: QuestionnaireItem, answer: QuestionnaireResponseItemAnswer[] ): void { const currentItem = getResponseItemByContext(context, item); if (currentItem) { currentItem.answer = answer; emitChange(); } } function onChangeSignature(signature: Signature | undefined): void { const currentResponse = state.current.questionnaireResponse; if (!currentResponse) { return; } if (signature) { currentResponse.extension = currentResponse.extension ?? []; currentResponse.extension = currentResponse.extension.filter( (ext) => ext.url !== QUESTIONNAIRE_SIGNATURE_RESPONSE_URL ); currentResponse.extension.push({ url: QUESTIONNAIRE_SIGNATURE_RESPONSE_URL, valueSignature: signature, }); } else { currentResponse.extension = currentResponse.extension?.filter( (ext) => ext.url !== QUESTIONNAIRE_SIGNATURE_RESPONSE_URL ); } emitChange(); } function updateCalculatedExpressions(): void { const questionnaire = state.current.questionnaire; if (questionnaire?.item) { const response = state.current.questionnaireResponse as QuestionnaireResponse; evaluateCalculatedExpressionsInQuestionnaire(questionnaire.item, response); } } function emitChange(): void { const currentResponse = state.current.questionnaireResponse; if (!currentResponse) { return; } updateCalculatedExpressions(); forceUpdate(); props.onChange?.(currentResponse); } return { loading: false, pagination: !!state.current.pages, questionnaire: state.current.questionnaire, questionnaireResponse: state.current.questionnaireResponse, subject: props.subject, encounter: props.encounter, activePage: state.current.activePage, pages: state.current.pages, items: getItemsForPage(state.current.questionnaire, state.current.pages, state.current.activePage), responseItems: getResponseItemsForPage( state.current.questionnaireResponse, state.current.pages, state.current.activePage ), onNextPage, onPrevPage, onAddGroup, onAddAnswer, onChangeAnswer, onChangeSignature, } as QuestionnaireFormSinglePageState | QuestionnaireFormPaginationState; } function getPages(questionnaire: Questionnaire): QuestionnaireFormPage[] | undefined { if (!questionnaire?.item) { return undefined; } const extension = getExtension(questionnaire?.item?.[0], QUESTIONNAIRE_ITEM_CONTROL_URL); if (extension?.valueCodeableConcept?.coding?.[0]?.code !== 'page') { return undefined; } return questionnaire.item.map((item, index) => { return { linkId: item.linkId, title: item.text ?? `Page ${index + 1}`, group: item as QuestionnaireItem & { type: 'group' }, }; }); } function getItemsForPage( questionnaire: Questionnaire, pages: QuestionnaireFormPage[] | undefined, activePage = 0 ): QuestionnaireItem[] { if (pages && questionnaire?.item?.[activePage]) { return [questionnaire.item[activePage]]; } return questionnaire.item ?? []; } function getResponseItemsForPage( questionnaireResponse: QuestionnaireResponse, pages: QuestionnaireFormPage[] | undefined, activePage = 0 ): QuestionnaireResponseItem[] { if (pages && questionnaireResponse?.item?.[activePage]) { return [questionnaireResponse.item[activePage]]; } return questionnaireResponse.item ?? []; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server