Skip to main content
Glama
QuestionnaireBuilder.tsx21.2 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { Anchor, Box, Group, NativeSelect, Space, Textarea, TextInput, Title } from '@mantine/core'; import { getElementDefinition, isResource as isResourceType } from '@medplum/core'; import type { Extension, Questionnaire, QuestionnaireItem, QuestionnaireItemAnswerOption, Reference, ResourceType, } from '@medplum/fhirtypes'; import { getQuestionnaireItemReferenceTargetTypes, isChoiceQuestion, QUESTIONNAIRE_ITEM_CONTROL_URL, QuestionnaireItemType, setQuestionnaireItemReferenceTargetTypes, useMedplum, useResource, } from '@medplum/react-hooks'; import { IconArrowDown, IconArrowUp } from '@tabler/icons-react'; import cx from 'clsx'; import type { JSX, MouseEvent, SyntheticEvent } from 'react'; import { useEffect, useRef, useState } from 'react'; import { Form } from '../Form/Form'; import { SubmitButton } from '../Form/SubmitButton'; import { QuestionnaireFormItem } from '../QuestionnaireForm/QuestionnaireFormItem'; import { getValueAndType } from '../ResourcePropertyDisplay/ResourcePropertyDisplay.utils'; import { ResourcePropertyInput } from '../ResourcePropertyInput/ResourcePropertyInput'; import { ResourceTypeInput } from '../ResourceTypeInput/ResourceTypeInput'; import { killEvent } from '../utils/dom'; import classes from './QuestionnaireBuilder.module.css'; export interface QuestionnaireBuilderProps { readonly questionnaire: Partial<Questionnaire> | Reference<Questionnaire>; readonly onSubmit: (result: Questionnaire) => void; readonly autoSave?: boolean; } export function QuestionnaireBuilder(props: QuestionnaireBuilderProps): JSX.Element | null { const medplum = useMedplum(); const defaultValue = useResource(props.questionnaire); const [schemaLoaded, setSchemaLoaded] = useState(false); const [value, setValue] = useState<Questionnaire>(); const [selectedKey, setSelectedKey] = useState<string>(); const [hoverKey, setHoverKey] = useState<string>(); function handleDocumentMouseOver(): void { setHoverKey(undefined); } function handleDocumentClick(): void { setSelectedKey(undefined); } useEffect(() => { medplum .requestSchema('Questionnaire') .then(() => setSchemaLoaded(true)) .catch(console.log); }, [medplum]); useEffect(() => { setValue(ensureQuestionnaireKeys(defaultValue ?? { resourceType: 'Questionnaire', status: 'active' })); document.addEventListener('mouseover', handleDocumentMouseOver); document.addEventListener('click', handleDocumentClick); return () => { document.removeEventListener('mouseover', handleDocumentMouseOver); document.removeEventListener('click', handleDocumentClick); }; }, [defaultValue]); const handleChange = (questionnaire: Questionnaire, disableSubmit?: boolean): void => { setValue(questionnaire); if (props.autoSave && !disableSubmit && props.onSubmit) { props.onSubmit(questionnaire); } }; if (!schemaLoaded || !value) { return null; } return ( <div> <Form testid="questionnaire-form" onSubmit={() => props.onSubmit(value)}> <ItemBuilder item={value} selectedKey={selectedKey} setSelectedKey={setSelectedKey} hoverKey={hoverKey} setHoverKey={setHoverKey} onChange={handleChange} /> <SubmitButton>Save</SubmitButton> </Form> </div> ); } interface ItemBuilderProps<T extends Questionnaire | QuestionnaireItem> { readonly item: T; readonly selectedKey: string | undefined; readonly setSelectedKey: (key: string | undefined) => void; readonly hoverKey: string | undefined; readonly isFirst?: boolean; readonly isLast?: boolean; readonly setHoverKey: (key: string | undefined) => void; readonly onChange: (item: T, disableSubmit?: boolean) => void; readonly onRemove?: () => void; readonly onRepeatable?: (item: QuestionnaireItem) => void; onMoveUp?(): void; onMoveDown?(): void; } function ItemBuilder<T extends Questionnaire | QuestionnaireItem>(props: ItemBuilderProps<T>): JSX.Element { const resource = props.item as Questionnaire; const item = props.item as QuestionnaireItem; const isResource = isResourceType(props.item); const isContainer = isResource || item.type === QuestionnaireItemType.group; const linkId = item.linkId ?? '[untitled]'; const editing = props.selectedKey === props.item.id; const hovering = props.hoverKey === props.item.id; const itemRef = useRef<T>(props.item); itemRef.current = props.item; function onClick(e: SyntheticEvent): void { killEvent(e); props.setSelectedKey(props.item.id); } function onHover(e: SyntheticEvent): void { killEvent(e); props.setHoverKey(props.item.id); } function changeItem(changedItem: QuestionnaireItem): void { const curr = itemRef.current as T; props.onChange({ ...curr, item: curr.item?.map((i) => (i.id === changedItem.id ? changedItem : i)), } as T); } function addItem(addedItem: QuestionnaireItem, disableSubmit?: boolean): void { props.onChange( { ...props.item, item: [...(props.item.item ?? []), addedItem], }, disableSubmit ); } function removeItem(removedItem: QuestionnaireItem): void { props.onChange({ ...props.item, item: props.item.item?.filter((i) => i !== removedItem), }); } function changeProperty(property: string, value: any): void { props.onChange({ ...itemRef.current, [property]: value, } as T); } function updateItem(updatedItem: QuestionnaireItem): void { props.onChange({ ...props.item, ...updatedItem, }); } function toggleRepeatable(item: QuestionnaireItem): void { props.onChange({ ...props.item, item: props.item.item?.map((i) => (i === item ? { ...i, repeats: !i.repeats } : i)), }); } function moveItem(itemIndex: number, delta: number): void { const updatedItems = reorderItems(props.item.item, itemIndex, delta); props.onChange({ ...props.item, item: updatedItems, }); } const className = cx(classes.section, { [classes.editing]: editing, [classes.hovering]: hovering && !editing, }); return ( <div data-testid={item.linkId} className={className} onClick={onClick} onMouseOver={onHover} onFocus={onHover}> <div className={classes.questionBody}> {editing ? ( <> {isResource && ( <TextInput size="xl" defaultValue={resource.title} onBlur={(e) => changeProperty('title', e.currentTarget.value)} /> )} {!isResource && ( <Textarea autosize minRows={2} defaultValue={item.text} onBlur={(e) => changeProperty('text', e.currentTarget.value)} /> )} {item.type === 'reference' && <ReferenceProfiles item={item} onChange={updateItem} />} {isChoiceQuestion(item) && <AnswerBuilder item={item} onChange={(item) => updateItem(item)} />} </> ) : ( <> {resource.title && <Title>{resource.title}</Title>} {item.text && <div className={classes.preserveBreaks}>{item.text}</div>} {!isContainer && ( <QuestionnaireFormItem item={item} index={0} required={false} responseItem={{ linkId: item.linkId }} /> )} </> )} </div> {item.item?.map((item, i) => ( <div key={item.id}> <ItemBuilder item={item} selectedKey={props.selectedKey} setSelectedKey={props.setSelectedKey} hoverKey={props.hoverKey} isFirst={i === 0} isLast={i === (props.item.item ?? []).length - 1} setHoverKey={props.setHoverKey} onChange={changeItem} onRemove={() => removeItem(item)} onRepeatable={toggleRepeatable} onMoveUp={() => moveItem(i, -1)} onMoveDown={() => moveItem(i, 1)} /> </div> ))} {!isContainer && ( <div className={classes.topActions}> {editing ? ( <> <TextInput size="xs" className={classes.linkIdInput} defaultValue={item.linkId} onBlur={(e) => changeProperty('linkId', e.currentTarget.value)} /> {!isContainer && ( <NativeSelect size="xs" className={classes.typeSelect} defaultValue={item.type} onChange={(e) => changeProperty('type', e.currentTarget.value)} data={[ { value: 'display', label: 'Display' }, { value: 'boolean', label: 'Boolean' }, { value: 'decimal', label: 'Decimal' }, { value: 'integer', label: 'Integer' }, { value: 'date', label: 'Date' }, { value: 'dateTime', label: 'Date/Time' }, { value: 'time', label: 'Time' }, { value: 'string', label: 'String' }, { value: 'text', label: 'Text' }, { value: 'url', label: 'URL' }, { value: 'choice', label: 'Choice' }, { value: 'open-choice', label: 'Open Choice' }, { value: 'attachment', label: 'Attachment' }, { value: 'reference', label: 'Reference' }, { value: 'quantity', label: 'Quantity' }, ]} /> )} </> ) : ( <div>{linkId}</div> )} </div> )} {!isResource && ( <Box className={classes.movementActions}> <Box className={classes.columnAlignment}> {!props.isFirst && ( <Anchor href="#" onClick={(e: MouseEvent) => { e.preventDefault(); if (props.onMoveUp) { props.onMoveUp(); } }} > <IconArrowUp data-testid="up-button" size={15} className={classes.movementIcons} /> </Anchor> )} {!props.isLast && ( <Anchor href="#" onClick={(e: MouseEvent) => { e.preventDefault(); if (props.onMoveDown) { props.onMoveDown(); } }} > <IconArrowDown data-testid="down-button" size={15} className={classes.movementIcons} /> </Anchor> )} </Box> </Box> )} <div className={classes.bottomActions}> {isContainer && ( <> <Anchor href="#" onClick={(e: MouseEvent) => { e.preventDefault(); addItem({ id: generateId(), linkId: generateLinkId('q'), type: 'string', text: 'Question', } as QuestionnaireItem); }} > Add item </Anchor> <Anchor href="#" onClick={(e: MouseEvent) => { e.preventDefault(); addItem( { id: generateId(), linkId: generateLinkId('g'), type: 'group', text: 'Group', } as QuestionnaireItem, true ); }} > Add group </Anchor> </> )} {isResource && ( <Anchor href="#" onClick={(e: MouseEvent) => { e.preventDefault(); addItem(createPage(), true); }} > Add Page </Anchor> )} {editing && !isResource && ( <> <Anchor href="#" onClick={(e: MouseEvent) => { e.preventDefault(); if (props.onRepeatable) { props.onRepeatable(item); } }} > {item.repeats ? 'Remove Repeatable' : 'Make Repeatable'} </Anchor> <Anchor href="#" onClick={(e: MouseEvent) => { e.preventDefault(); if (props.onRemove) { props.onRemove(); } }} > Remove </Anchor> </> )} </div> </div> ); } interface AnswerBuilderProps { readonly item: QuestionnaireItem; readonly onChange: (item: QuestionnaireItem) => void; } function AnswerBuilder(props: AnswerBuilderProps): JSX.Element { const property = getElementDefinition('QuestionnaireItemAnswerOption', 'value[x]'); const options = props.item.answerOption ?? []; return ( <div> {props.item.answerValueSet !== undefined ? ( <TextInput placeholder="Enter Value Set" defaultValue={props.item.answerValueSet} onChange={(e) => props.onChange({ ...props.item, answerValueSet: e.target.value })} /> ) : ( <AnswerOptionsInput options={options} property={property} item={props.item} onChange={props.onChange} /> )} <Box display="flex"> <Anchor href="#" onClick={(e: SyntheticEvent) => { killEvent(e); props.onChange({ ...props.item, answerValueSet: undefined, answerOption: [ ...options, { id: generateId(), }, ], }); }} > Add choice </Anchor> <Space w="lg" /> <Anchor href="#" onClick={(e: SyntheticEvent) => { killEvent(e); props.onChange({ ...props.item, answerOption: [], answerValueSet: '', }); }} > Add value set </Anchor> </Box> </div> ); } interface AnswerOptionsInputProps { readonly options: QuestionnaireItemAnswerOption[]; readonly property: any; readonly item: QuestionnaireItem; readonly onChange: (item: QuestionnaireItem) => void; } function AnswerOptionsInput(props: AnswerOptionsInputProps): JSX.Element { return ( <div> {props.options.map((option: QuestionnaireItemAnswerOption) => { const [propertyValue, propertyType] = getValueAndType( { type: 'QuestionnaireItemAnswerOption', value: option }, 'value' ); return ( <div key={option.id} style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', width: '80%', }} > <div> <ResourcePropertyInput key={option.id} name="value[x]" path="Questionnaire.answerOption.value[x]" property={props.property} defaultPropertyType={propertyType} defaultValue={propertyValue} onChange={(newValue: any, propName?: string) => { const newOptions = [...props.options]; const index = newOptions.findIndex((o) => o.id === option.id); newOptions[index] = { id: option.id, [propName as string]: newValue }; props.onChange({ ...props.item, answerOption: newOptions, }); }} outcome={undefined} /> </div> <div> <Anchor href="#" onClick={(e: SyntheticEvent) => { killEvent(e); props.onChange({ ...props.item, answerOption: props.options.filter((o) => o.id !== option.id), }); }} > Remove </Anchor> </div> </div> ); })} </div> ); } interface ReferenceTypeProps { readonly item: QuestionnaireItem; readonly onChange: (updatedItem: QuestionnaireItem) => void; } function ReferenceProfiles(props: ReferenceTypeProps): JSX.Element { const targetTypes = getQuestionnaireItemReferenceTargetTypes(props.item) ?? []; return ( <> {targetTypes.map((targetType: ResourceType, index: number) => { return ( <Group key={`${targetType}-${index}`}> <ResourceTypeInput name="resourceType" placeholder="Resource Type" defaultValue={targetType} onChange={(newValue) => { props.onChange( setQuestionnaireItemReferenceTargetTypes( props.item, targetTypes.map((t) => (t === targetType ? (newValue as ResourceType) : t)) ) ); }} /> <Anchor href="#" onClick={(e: SyntheticEvent) => { killEvent(e); props.onChange( setQuestionnaireItemReferenceTargetTypes( props.item, targetTypes.filter((t) => t !== targetType) ) ); }} > Remove </Anchor> </Group> ); })} <Anchor href="#" onClick={(e: SyntheticEvent) => { killEvent(e); props.onChange(setQuestionnaireItemReferenceTargetTypes(props.item, [...targetTypes, '' as ResourceType])); }} > Add Resource Type </Anchor> </> ); } let nextLinkId = 1; let nextId = 1; /** * Generates a link ID for an item. * Link IDs are required properties on QuestionnaireItem objects. * @param prefix - The link ID prefix string. * @returns A unique link ID. */ function generateLinkId(prefix: string): string { return prefix + nextLinkId++; } /** * Generates a unique ID. * React needs unique IDs for components for rendering performance. * All of the important components in the questionnaire builder have id properties for this: * Questionnaire, QuestionnaireItem, and QuestionnaireItemAnswerOption. * @returns A unique key. */ function generateId(): string { return 'id-' + nextId++; } function ensureQuestionnaireKeys(questionnaire: Questionnaire): Questionnaire { return { ...questionnaire, id: questionnaire.id || generateId(), item: ensureQuestionnaireItemKeys(questionnaire.item), } as Questionnaire; } function ensureQuestionnaireItemKeys(items: QuestionnaireItem[] | undefined): QuestionnaireItem[] | undefined { if (!items) { return undefined; } items.forEach((item) => { if (item.id?.match(/^id-\d+$/)) { nextId = Math.max(nextId, Number.parseInt(item.id.substring(3), 10) + 1); } if (item.linkId?.match(/^q\d+$/)) { nextLinkId = Math.max(nextLinkId, Number.parseInt(item.linkId.substring(1), 10) + 1); } }); return items.map((item) => ({ ...item, id: item.id || generateId(), item: ensureQuestionnaireItemKeys(item.item), answerOption: ensureQuestionnaireOptionKeys(item.answerOption), })); } function ensureQuestionnaireOptionKeys( options: QuestionnaireItemAnswerOption[] | undefined ): QuestionnaireItemAnswerOption[] | undefined { if (!options) { return undefined; } return options.map((option) => ({ ...option, id: option.id || generateId(), })); } function createPage(): QuestionnaireItem { return { id: generateId(), linkId: generateLinkId('s'), type: 'group', text: `New Page`, extension: [ { url: QUESTIONNAIRE_ITEM_CONTROL_URL, valueCodeableConcept: { coding: [ { system: 'http://hl7.org/fhir/questionnaire-item-control', code: 'page', }, ], }, } as Extension, ], } as QuestionnaireItem; } function reorderItems(items: QuestionnaireItem[] | undefined, itemIndex: number, delta: number): QuestionnaireItem[] { const currentItems = items ?? []; const newIndex = itemIndex + delta; if (newIndex < 0 || newIndex >= currentItems.length) { return currentItems; } const updatedItems = [...currentItems]; [updatedItems[itemIndex], updatedItems[newIndex]] = [updatedItems[newIndex], updatedItems[itemIndex]]; return updatedItems; }

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