Skip to main content
Glama
ResourceTimeline.tsx14.1 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { ActionIcon, Button, Center, Group, Loader, ScrollArea, TextInput } from '@mantine/core'; import { showNotification, updateNotification } from '@mantine/notifications'; import type { MedplumClient, ProfileResource } from '@medplum/core'; import { createReference, normalizeErrorString } from '@medplum/core'; import type { Attachment, AuditEvent, Bundle, Communication, DiagnosticReport, Media, OperationOutcome, Reference, Resource, ResourceType, } from '@medplum/fhirtypes'; import { useMedplum, useResource } from '@medplum/react-hooks'; import { IconCheck, IconCloudUpload, IconFileAlert, IconMessage } from '@tabler/icons-react'; import type { JSX, ReactNode } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { AttachmentButton } from '../AttachmentButton/AttachmentButton'; import { AttachmentDisplay } from '../AttachmentDisplay/AttachmentDisplay'; import { DiagnosticReportDisplay } from '../DiagnosticReportDisplay/DiagnosticReportDisplay'; import { Form } from '../Form/Form'; import { Panel } from '../Panel/Panel'; import { ResourceAvatar } from '../ResourceAvatar/ResourceAvatar'; import { ResourceDiffTable } from '../ResourceDiffTable/ResourceDiffTable'; import { ResourceTable } from '../ResourceTable/ResourceTable'; import type { TimelineItemProps } from '../Timeline/Timeline'; import { Timeline, TimelineItem } from '../Timeline/Timeline'; import { sortByDateAndPriority } from '../utils/date'; import classes from './ResourceTimeline.module.css'; export interface ResourceTimelineMenuItemContext { readonly primaryResource: Resource; readonly currentResource: Resource; readonly reloadTimeline: () => void; } export interface ResourceTimelineProps<T extends Resource> { readonly value: T | Reference<T>; readonly loadTimelineResources: ( medplum: MedplumClient, resourceType: ResourceType, id: string ) => Promise<PromiseSettledResult<Bundle>[]>; readonly createCommunication?: (resource: T, sender: ProfileResource, text: string) => Communication; readonly createMedia?: (resource: T, operator: ProfileResource, attachment: Attachment) => Media; readonly getMenu?: (context: ResourceTimelineMenuItemContext) => ReactNode; } export function ResourceTimeline<T extends Resource>(props: ResourceTimelineProps<T>): JSX.Element { const medplum = useMedplum(); const sender = medplum.getProfile() as ProfileResource; const inputRef = useRef<HTMLInputElement>(null); const resource = useResource(props.value); const [history, setHistory] = useState<Bundle>(); const [items, setItems] = useState<Resource[]>([]); const [countToShow, setCountToShow] = useState(10); const loadTimelineResources = props.loadTimelineResources; const itemsRef = useRef<Resource[]>(items); itemsRef.current = items; /** * Sorts and sets the items. * * Sorting is primarily a function of meta.lastUpdated, but there are special cases. * When displaying connected resources, for example a Communication in the context of an Encounter, * the Communication.sent time is used rather than Communication.meta.lastUpdated. * * Other examples of special cases: * - DiagnosticReport.issued * - Media.issued * - Observation.issued * - DocumentReference.date * * See "sortByDateAndPriority()" for more details. */ const sortAndSetItems = useCallback( (newItems: Resource[]): void => { sortByDateAndPriority(newItems, resource); newItems.reverse(); setItems(newItems); }, [resource] ); /** * Handles a batch request response. * @param batchResponse - The batch response. */ const handleBatchResponse = useCallback( (batchResponse: PromiseSettledResult<Bundle>[]): void => { const newItems = []; for (const settledResult of batchResponse) { if (settledResult.status !== 'fulfilled') { // User may not have access to all resource types continue; } const bundle = settledResult.value; if (bundle.type === 'history') { setHistory(bundle); } if (bundle.entry) { for (const entry of bundle.entry) { newItems.push(entry.resource as Resource); } } } sortAndSetItems(newItems); }, [sortAndSetItems] ); /** * Adds an array of resources to the timeline. * @param resource - Resource to add. */ const addResource = useCallback( (resource: Resource): void => sortAndSetItems([...itemsRef.current, resource]), [sortAndSetItems] ); /** * Loads the timeline. */ const loadTimeline = useCallback(() => { let resourceType: ResourceType; let id: string; if ('resourceType' in props.value) { resourceType = props.value.resourceType; id = props.value.id as string; } else { [resourceType, id] = props.value.reference?.split('/') as [ResourceType, string]; } loadTimelineResources(medplum, resourceType, id).then(handleBatchResponse).catch(console.error); }, [medplum, props.value, loadTimelineResources, handleBatchResponse]); useEffect(() => loadTimeline(), [loadTimeline]); /** * Adds a Communication resource to the timeline. * @param contentString - The comment content. */ function createComment(contentString: string): void { if (!resource || !props.createCommunication) { // Encounter not loaded yet return; } medplum .createResource(props.createCommunication(resource, sender, contentString)) .then((result) => addResource(result)) .catch(console.error); } /** * Adds a Media resource to the timeline. * @param attachment - The media attachment. */ function createMedia(attachment: Attachment): void { if (!resource || !props.createMedia) { // Encounter not loaded yet return; } medplum .createResource(props.createMedia(resource, sender, attachment)) .then((result) => addResource(result)) .then(() => updateNotification({ id: 'upload-notification', color: 'teal', title: 'Upload complete', message: '', icon: <IconCheck size={16} />, autoClose: 2000, }) ) .catch((reason) => updateNotification({ id: 'upload-notification', color: 'red', title: 'Upload error', message: normalizeErrorString(reason), icon: <IconFileAlert size={16} />, autoClose: 2000, }) ); } function onUploadStart(): void { showNotification({ id: 'upload-notification', loading: true, title: 'Initializing upload...', message: 'Please wait...', autoClose: false, withCloseButton: false, }); } function onUploadProgress(e: ProgressEvent): void { updateNotification({ id: 'upload-notification', loading: true, title: 'Uploading...', message: getProgressMessage(e), autoClose: false, withCloseButton: false, }); } function onUploadError(outcome: OperationOutcome): void { updateNotification({ id: 'upload-notification', color: 'red', title: 'Upload error', message: normalizeErrorString(outcome), icon: <IconFileAlert size={16} />, autoClose: 2000, }); } if (!resource) { return ( <Center style={{ width: '100%', height: '100%' }}> <Loader /> </Center> ); } // TODO: Handle null history items for deleted versions. const itemsToShow = items.filter((item) => item).slice(0, countToShow); return ( <Timeline> {props.createCommunication && ( <Panel> <Form testid="timeline-form" onSubmit={(formData: Record<string, string>) => { createComment(formData.text); const input = inputRef.current; if (input) { input.value = ''; input.focus(); } }} > <Group gap="xs" wrap="nowrap" style={{ width: '100%' }}> <ResourceAvatar value={sender} /> <TextInput name="text" ref={inputRef} placeholder="Add comment" style={{ width: '100%', maxWidth: 300 }} /> <ActionIcon type="submit" radius="xl" color="blue" variant="filled"> <IconMessage size={16} /> </ActionIcon> <AttachmentButton securityContext={createReference(resource)} onUpload={createMedia} onUploadStart={onUploadStart} onUploadProgress={onUploadProgress} onUploadError={onUploadError} > {(props) => ( <ActionIcon {...props} radius="xl" color="blue" variant="filled"> <IconCloudUpload size={16} /> </ActionIcon> )} </AttachmentButton> </Group> </Form> </Panel> )} {itemsToShow.map((item) => { const key = `${item.resourceType}/${item.id}/${item.meta?.versionId}`; const menu = props.getMenu ? props.getMenu({ primaryResource: resource, currentResource: item, reloadTimeline: loadTimeline, }) : undefined; if (item.resourceType === resource.resourceType && item.id === resource.id) { return <HistoryTimelineItem key={key} history={history as Bundle} resource={item} popupMenuItems={menu} />; } switch (item.resourceType) { case 'AuditEvent': return <AuditEventTimelineItem key={key} resource={item} popupMenuItems={menu} />; case 'Communication': return <CommunicationTimelineItem key={key} resource={item} popupMenuItems={menu} />; case 'DiagnosticReport': return <DiagnosticReportTimelineItem key={key} resource={item} popupMenuItems={menu} />; case 'Media': return <MediaTimelineItem key={key} resource={item} popupMenuItems={menu} />; default: return ( <TimelineItem key={key} resource={item} padding={true}> <ResourceTable value={item} ignoreMissingValues={true} /> </TimelineItem> ); } })} {countToShow < items.length && ( <Group justify="center" pb="lg"> <Button onClick={() => setCountToShow(countToShow + 10)}>Show More</Button> </Group> )} </Timeline> ); } interface HistoryTimelineItemProps extends TimelineItemProps { readonly history: Bundle; } function HistoryTimelineItem(props: HistoryTimelineItemProps): JSX.Element { const { history, resource, ...rest } = props; const previous = getPrevious(history, resource); if (previous) { return ( <TimelineItem resource={resource} padding={true} {...rest}> <ResourceDiffTable original={previous} revised={props.resource} /> </TimelineItem> ); } else { return ( <TimelineItem resource={resource} padding={true} {...rest}> <h3>Created</h3> <ResourceTable value={resource} ignoreMissingValues forceUseInput /> </TimelineItem> ); } } function getPrevious(history: Bundle, version: Resource): Resource | undefined { const entries = history.entry ?? []; const index = entries.findIndex((entry) => entry.resource?.meta?.versionId === version.meta?.versionId); // If not found index is -1, -1 === 0 - 1 so this returns undefined if (index >= entries.length - 1) { return undefined; } return entries[index + 1].resource; } function CommunicationTimelineItem(props: TimelineItemProps<Communication>): JSX.Element { const routine = !props.resource.priority || props.resource.priority === 'routine'; const className = routine ? undefined : classes.pinnedComment; return ( <TimelineItem resource={props.resource} profile={props.resource.sender} dateTime={props.resource.sent} padding={true} className={className} popupMenuItems={props.popupMenuItems} > <p>{props.resource.payload?.[0]?.contentString}</p> </TimelineItem> ); } function MediaTimelineItem(props: TimelineItemProps<Media>): JSX.Element { const contentType = props.resource.content?.contentType; const padding = contentType && !contentType.startsWith('image/') && !contentType.startsWith('video/') && contentType !== 'application/pdf'; return ( <TimelineItem resource={props.resource} padding={!!padding} popupMenuItems={props.popupMenuItems}> <AttachmentDisplay value={props.resource.content} /> </TimelineItem> ); } function AuditEventTimelineItem(props: TimelineItemProps<AuditEvent>): JSX.Element { return ( <TimelineItem resource={props.resource} padding={true} popupMenuItems={props.popupMenuItems}> <ScrollArea> <pre>{props.resource.outcomeDesc}</pre> </ScrollArea> </TimelineItem> ); } function DiagnosticReportTimelineItem(props: TimelineItemProps<DiagnosticReport>): JSX.Element { return ( <TimelineItem resource={props.resource} padding={true} popupMenuItems={props.popupMenuItems}> <DiagnosticReportDisplay value={props.resource} /> </TimelineItem> ); } function getProgressMessage(e: ProgressEvent): string { if (e.lengthComputable) { const percent = (100 * e.loaded) / e.total; return `Uploaded: ${formatFileSize(e.loaded)} / ${formatFileSize(e.total)} ${percent.toFixed(2)}%`; } return `Uploaded: ${formatFileSize(e.loaded)}`; } function formatFileSize(bytes: number): string { if (bytes === 0) { return '0.00 B'; } const e = Math.floor(Math.log(bytes) / Math.log(1024)); return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B'; }

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