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

medplum / medplum / 30566672722

30 Jul 2026 05:35PM UTC coverage: 92.022% (+0.03%) from 91.994%
30566672722

push

github

web-flow
Provider: Add Message Settings to Messages (#9621)

* feat(react): rework New Message dialog with MultiResourceInput

Replace the QuestionnaireForm-based practitioner picker with
MultiResourceInput, defaulting the signed-in provider. Make the patient
required and disable Next until a patient and at least one practitioner are
selected. Apply the shared Send-Fax-style modal spacing/font.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Kevin Shaw <kevin@medplum.com>

* feat(react): add Message Settings dialog for threads

Add EditTopicDialog, opened from a new info-icon button in the thread header,
to edit a thread's topic and practitioner participants after creation. References
are resolved in parallel before render so all fields appear together, with a
failed patient read handled independently. Threads with no practitioner recipient
fall back to showing the sender practitioner. Non-practitioner recipients are
preserved on save.

Add useThreadInbox.updateThreadParent so an edited thread updates in place rather
than refetching, keeping a draft thread (no reply yet) in the list. Order the
inbox by latest message time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Kevin Shaw <kevin@medplum.com>

* feat(react): auto-select first thread in inbox

When no thread is selected and the list has items, navigate to the first
thread, matching the Tasks/Faxes boards. Add an optional replace flag to the
Medplum navigate function (forwarded by the app router wrappers) so the implicit
selection replaces history instead of adding a back-button entry that would
re-trigger the selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Kevin Shaw <kevin@medplum.com>

* fix(react): type resolved practitioners via discriminant narrowing

The full tsc build (not run by vitest/esbuild) rejected the explicit
`PromiseFulfilledResult<Practitioner>` type predicate because readReference
resolves to WithId<Prac... (continued)

22738 of 25819 branches covered (88.07%)

Branch coverage included in aggregate %.

103 of 113 new or added lines in 10 files covered. (91.15%)

1 existing line in 1 file now uncovered.

40309 of 42694 relevant lines covered (94.41%)

12266.03 hits per line

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

80.95
/packages/react/src/chat/ThreadInbox/ThreadInbox.tsx
1
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
2
// SPDX-License-Identifier: Apache-2.0
3

4
import { ActionIcon, Box, Center, Flex, Skeleton, Stack, Text, ThemeIcon, Tooltip } from '@mantine/core';
5
import { useDisclosure } from '@mantine/hooks';
6
import { showNotification } from '@mantine/notifications';
7
import type { SearchRequest } from '@medplum/core';
8
import { normalizeErrorString, Operator, parseSearchRequest } from '@medplum/core';
9
import type { Communication, DocumentReference, Patient, Practitioner, Reference } from '@medplum/fhirtypes';
10
import { useThreadInbox } from '@medplum/react-hooks';
11
import { IconMessageCircle, IconPlus } from '@tabler/icons-react';
12
import type { JSX } from 'react';
13
import { useCallback, useEffect, useMemo } from 'react';
14
import type { ListWithDetailPaneTab } from '../../ListWithDetailPane/ListWithDetailPane';
15
import { ListWithDetailPane } from '../../ListWithDetailPane/ListWithDetailPane';
16
import type { PatientSummarySectionConfig } from '../../PatientSummary/PatientSummary.types';
17
import { EditThreadDialog } from './EditThreadDialog';
18
import { NewTopicDialog } from './NewTopicDialog';
19
import { ParticipantFilter } from './ParticipantFilter';
20
import { ThreadDetail } from './ThreadDetail';
21
import classes from './ThreadInbox.module.css';
22
import { ThreadListItem } from './ThreadListItem';
23

24
/**
25
 * ThreadInbox is a component that displays a list of threads and allows the user to select a thread to view.
26
 * @param query - The query to fetch all communications.
27
 * @param threadId - The id of the thread to select.
28
 * @param subject - The default subject when creating a new thread.
29
 * @param showPatientSummary - Whether to show the patient summary.
30
 * @param sections - Optional sections configuration for the patient summary.
31
 * @param onNew - A function to handle a new thread.
32
 * @param onSelectFirst - Fired with the first thread when the list loads with nothing selected; use it to navigate to that thread (with replace) so the inbox auto-selects it.
33
 * @param getThreadUri - A function to build thread URIs.
34
 * @param onChange - A function to handle search changes.
35
 * @param inProgressUri - The URI for in-progress threads.
36
 * @param completedUri - The URI for completed threads.
37
 * @param newTopicOpened - Controlled open state for the new topic dialog. When provided, use `onNewTopicOpen` and `onNewTopicClose` to update it.
38
 * @param onNewTopicOpen - Called when the user clicks the new message button. Required when `newTopicOpened` is provided.
39
 * @param onNewTopicClose - Called when the new topic dialog is closed. Required when `newTopicOpened` is provided.
40
 */
41

42
export interface ThreadInboxProps {
43
  readonly query: string;
44
  readonly threadId: string | undefined;
45
  readonly subject?: Reference<Patient> | Patient;
46
  readonly showPatientSummary?: boolean;
47
  readonly sections?: PatientSummarySectionConfig[];
48
  readonly onNew: (message: Communication) => void;
49
  readonly onSelectFirst?: (thread: Communication) => void;
50
  readonly getThreadUri: (topic: Communication) => string;
51
  readonly onChange: (search: SearchRequest) => void;
52
  readonly inProgressUri: string;
53
  readonly completedUri: string;
54
  readonly uploadEnabled?: boolean;
55
  readonly onViewInDocuments?: (reference: Reference<DocumentReference>) => void;
56
  readonly allowPatientSelection?: boolean;
57
  readonly newTopicOpened?: boolean;
58
  readonly onNewTopicOpen?: () => void;
59
  readonly onNewTopicClose?: () => void;
60
}
61

62
export function ThreadInbox(props: ThreadInboxProps): JSX.Element {
63
  const {
64
    query,
65
    threadId,
66
    subject,
67
    showPatientSummary = false,
76✔
68
    sections,
69
    onNew,
70
    onSelectFirst,
71
    getThreadUri,
72
    uploadEnabled,
73
    onViewInDocuments,
74
    onChange,
75
    inProgressUri,
76
    completedUri,
77
    allowPatientSelection = false,
76✔
78
    onNewTopicOpen,
79
    onNewTopicClose,
80
  } = props;
76✔
81

82
  const [internalModalOpened, { open: openInternalModal, close: closeInternalModal }] = useDisclosure(false);
76✔
83
  const modalOpened = props.newTopicOpened ?? internalModalOpened;
76✔
84
  const openModal = onNewTopicOpen ?? openInternalModal;
76✔
85
  const closeModal = onNewTopicClose ?? closeInternalModal;
76✔
86
  const [editModalOpened, { open: openEditModal, close: closeEditModal }] = useDisclosure(false);
76✔
87

88
  const currentSearch = useMemo(() => parseSearchRequest(`Communication?${query}`), [query]);
76✔
89

90
  const searchParams = useMemo(() => new URLSearchParams(query), [query]);
76✔
91
  const itemsPerPage = Number.parseInt(searchParams.get('_count') || '20', 10);
76✔
92
  const currentOffset = Number.parseInt(searchParams.get('_offset') || '0', 10);
76✔
93
  const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
76✔
94
  const status = (searchParams.get('status') as Communication['status']) || 'in-progress';
76✔
95

96
  // Extract participants from parsed search request filters (comma-separated)
97
  const selectedParticipants = useMemo((): Reference<Patient | Practitioner>[] => {
76✔
98
    const recipientFilters = currentSearch.filters?.filter((f) => f.code === 'recipient') ?? [];
25✔
99
    // Split comma-separated values and flatten
100
    return recipientFilters.flatMap((f) =>
25✔
101
      f.value
×
102
        .split(',')
103
        .filter(Boolean)
104
        .map((ref) => ({ reference: ref }))
×
105
    );
106
  }, [currentSearch]);
107

108
  const {
109
    loading,
110
    error,
111
    threadMessages,
112
    selectedThread,
113
    total,
114
    handleThreadStatusChange,
115
    addThreadMessage,
116
    refreshThreadMessages,
117
  } = useThreadInbox({
76✔
118
    query,
119
    threadId,
120
  });
121

122
  const handleParticipantsChange = useCallback(
76✔
123
    (participants: Reference<Patient | Practitioner>[]) => {
124
      // Remove existing recipient filters
125
      const otherFilters = currentSearch.filters?.filter((f) => f.code !== 'recipient') ?? [];
×
126

127
      // Add recipient filter with comma-separated values (OR logic in FHIR)
128
      const participantRefs = participants.map((p) => p.reference).filter(Boolean) as string[];
×
129
      const newFilters =
130
        participantRefs.length > 0
×
131
          ? [...otherFilters, { code: 'recipient', operator: Operator.EQUALS, value: participantRefs.join(',') }]
132
          : otherFilters;
133

134
      onChange({
×
135
        ...currentSearch,
136
        filters: newFilters,
137
        offset: 0, // Reset to first page when filter changes
138
      });
139
    },
140
    [currentSearch, onChange]
141
  );
142

143
  useEffect(() => {
76✔
144
    if (error) {
25!
NEW
145
      showNotification({ color: 'red', message: normalizeErrorString(error) });
×
146
    }
147
  }, [error]);
148

149
  const handleTopicStatusChangeWithErrorHandling = async (newStatus: Communication['status']): Promise<void> => {
76✔
150
    handleThreadStatusChange(newStatus);
×
151
    try {
×
152
      await refreshThreadMessages();
×
153
    } catch (error) {
NEW
154
      showNotification({ color: 'red', message: normalizeErrorString(error) });
×
155
    }
156
  };
157

158
  const handleNewTopicCompletion = (message: Communication): void => {
76✔
159
    addThreadMessage(message);
1✔
160
    onNew(message);
1✔
161
  };
162

163
  // The list renders the parent thread (topic) of each tuple; the last message is
164
  // looked up by thread id when rendering each row.
165
  const items = useMemo(() => threadMessages.map(([topic]) => topic), [threadMessages]);
76✔
166
  const lastMessageByThreadId = useMemo(() => {
76✔
167
    const map = new Map<string, Communication | undefined>();
52✔
168
    for (const [topic, last] of threadMessages) {
52✔
169
      if (topic.id) {
5!
170
        map.set(topic.id, last);
5✔
171
      }
172
    }
173
    return map;
52✔
174
  }, [threadMessages]);
175

176
  const isDraft = useCallback(
76✔
177
    (thread: Communication): boolean =>
178
      !!thread.id && lastMessageByThreadId.has(thread.id) && !lastMessageByThreadId.get(thread.id),
15✔
179
    [lastMessageByThreadId]
180
  );
181

182
  const handleMessageSent = useCallback(() => {
76✔
183
    refreshThreadMessages().catch((err) => showNotification({ color: 'red', message: normalizeErrorString(err) }));
1✔
184
  }, [refreshThreadMessages]);
185

186
  const tabs = useMemo<ListWithDetailPaneTab[]>(
76✔
187
    () => [
25✔
188
      { value: 'in-progress', label: 'In Progress', uri: inProgressUri },
189
      { value: 'completed', label: 'Completed', uri: completedUri },
190
    ],
191
    [inProgressUri, completedUri]
192
  );
193

194
  const pageCount = total !== undefined ? Math.ceil(total / itemsPerPage) : 0;
76✔
195

196
  const headerActions = (
197
    <>
76✔
198
      <ParticipantFilter selectedParticipants={selectedParticipants} onFilterChange={handleParticipantsChange} />
199
      <Tooltip label="New Message" position="bottom" openDelay={500}>
200
        <ActionIcon radius="xl" variant="filled" color="blue" size={32} onClick={openModal}>
201
          <IconPlus size={16} />
202
        </ActionIcon>
203
      </Tooltip>
204
    </>
205
  );
206

207
  return (
76✔
208
    <>
209
      <div className={classes.container}>
210
        <ListWithDetailPane<Communication>
211
          items={items}
212
          loading={loading}
213
          selectedKey={selectedThread?.id ?? threadId}
135✔
214
          selected={selectedThread}
215
          // Suppress auto-select while the new-topic dialog is open (e.g. a URL-driven
216
          // /new route with no selection) — selecting would navigate away and close it.
217
          onSelectFirst={modalOpened ? undefined : onSelectFirst}
76✔
218
          listWidth={380}
219
          tabs={tabs}
220
          activeTab={status}
221
          headerActions={headerActions}
222
          skeleton={<ThreadListSkeleton />}
223
          emptyList={<EmptyMessagesState />}
224
          emptyDetail={<NoMessages />}
225
          refresh={refreshThreadMessages}
226
          page={currentPage}
227
          pageCount={pageCount}
228
          onPageChange={(page) => onChange({ ...currentSearch, offset: (page - 1) * itemsPerPage })}
×
229
          renderItem={(item) => (
230
            <ThreadListItem
7✔
231
              topic={item}
232
              lastCommunication={item.id ? lastMessageByThreadId.get(item.id) : undefined}
7!
233
              getThreadUri={getThreadUri}
234
            />
235
          )}
236
          renderDetail={(thread) => (
237
            <ThreadDetail
15✔
238
              thread={thread}
239
              showPatientSummary={showPatientSummary}
240
              sections={sections}
241
              uploadEnabled={uploadEnabled}
242
              onViewInDocuments={onViewInDocuments}
243
              onStatusChange={handleTopicStatusChangeWithErrorHandling}
244
              onOpenSettings={isDraft(thread) ? undefined : openEditModal}
15✔
245
              onMessageSent={handleMessageSent}
246
            />
247
          )}
248
        />
249
      </div>
250
      {/* Both dialogs are mounted only while open so every open is a fresh instance — any
251
          entries dismissed without saving are abandoned, with no leftover form state. */}
252
      {modalOpened && (
89✔
253
        <NewTopicDialog
254
          subject={subject}
255
          opened={modalOpened}
256
          onClose={closeModal}
257
          onSubmit={handleNewTopicCompletion}
258
          allowPatientSelection={allowPatientSelection}
259
        />
260
      )}
261
      {selectedThread && editModalOpened && (
95✔
262
        <EditThreadDialog
263
          thread={selectedThread}
264
          opened={editModalOpened}
265
          onClose={closeEditModal}
266
          onSaved={() => {
267
            refreshThreadMessages().catch((err) =>
1✔
NEW
268
              showNotification({ color: 'red', message: normalizeErrorString(err) })
×
269
            );
270
          }}
271
        />
272
      )}
273
    </>
274
  );
275
}
276

277
function NoMessages(): JSX.Element {
278
  return (
44✔
279
    <Center h="100%" w="100%">
280
      <Stack align="center" gap="md">
281
        <ThemeIcon size={64} variant="light" color="gray">
282
          <IconMessageCircle size={32} />
283
        </ThemeIcon>
284
        <Stack align="center" gap="xs">
285
          <Text size="sm" c="dimmed" ta="center">
286
            Select a message from the list to view details
287
          </Text>
288
        </Stack>
289
      </Stack>
290
    </Center>
291
  );
292
}
293

294
function EmptyMessagesState(): JSX.Element {
295
  return (
28✔
296
    <Flex direction="column" h="100%" justify="center" align="center">
297
      <Stack align="center" gap="md" pt="xl">
298
        <IconMessageCircle size={64} color="var(--mantine-color-gray-4)" />
299
        <Text size="lg" c="dimmed" fw={500}>
300
          No messages found
301
        </Text>
302
      </Stack>
303
    </Flex>
304
  );
305
}
306

307
function ThreadListSkeleton(): JSX.Element {
308
  const titleWidths = [80, 72, 68, 64];
25✔
309
  const subtitleWidths = [85, 78, 70, 60];
25✔
310
  return (
25✔
311
    <Stack gap="md" p="md">
312
      {Array.from({ length: 10 }).map((_, index) => {
313
        const titleWidth = titleWidths[index % titleWidths.length];
250✔
314
        const subtitleWidth = subtitleWidths[index % subtitleWidths.length];
250✔
315
        return (
250✔
316
          <Flex key={index} gap="sm" align="flex-start">
317
            <Skeleton height={40} width={40} radius="50%" />
318
            <Box style={{ flex: 1 }}>
319
              <Flex direction="column" gap="xs">
320
                <Skeleton height={16} width={`${titleWidth}%`} />
321
                <Skeleton height={14} width={`${subtitleWidth}%`} />
322
              </Flex>
323
            </Box>
324
          </Flex>
325
        );
326
      })}
327
    </Stack>
328
  );
329
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc