• 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

92.37
/packages/react-hooks/src/useThreadInbox/useThreadInbox.ts
1
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
2
// SPDX-License-Identifier: Apache-2.0
3
import { getReferenceString } from '@medplum/core';
4
import type { Communication } from '@medplum/fhirtypes';
5
import { useCallback, useEffect, useRef, useState } from 'react';
6
import { useMedplum } from '../MedplumProvider/MedplumProvider.context';
7

8
export interface UseThreadInboxOptions {
9
  query: string;
10
  threadId: string | undefined;
11
}
12

13
export interface UseThreadInboxReturn {
14
  loading: boolean;
15
  error: Error | null;
16
  threadMessages: [Communication, Communication | undefined][];
17
  selectedThread: Communication | undefined;
18
  total: number | undefined;
19
  addThreadMessage: (message: Communication) => void;
20
  handleThreadStatusChange: (newStatus: Communication['status']) => void;
21
  refreshThreadMessages: () => Promise<void>;
22
}
23

24
/*
25
useThreadInbox is a hook that fetches all communications and returns the thread messages and selected thread.
26
All comunications returned do not have a partOf field.
27
It also provides a function to update the status of the selected thread.
28

29
@param query - The query to fetch all communications.
30
@param threadId - The id of the thread to select.
31
@returns The thread messages and selected thread.
32
@returns A function to update the status of the selected thread.
33
*/
34
export function useThreadInbox({ query, threadId }: UseThreadInboxOptions): UseThreadInboxReturn {
35
  const medplum = useMedplum();
123✔
36
  const [loading, setLoading] = useState(true);
123✔
37
  const [memoizedQuery, setMemoizedQuery] = useState(query);
123✔
38
  const [threadMessages, setThreadMessages] = useState<[Communication, Communication | undefined][]>([]);
123✔
39
  const [selectedThread, setSelectedThread] = useState<Communication | undefined>(undefined);
123✔
40
  const [error, setError] = useState<Error | null>(null);
123✔
41
  const [total, setTotal] = useState<number | undefined>(undefined);
123✔
42
  const loadRequestIdRef = useRef(0);
123✔
43

44
  // Adjust state during render (useResourceBoard pattern) so a new query reports loading
45
  // on that same render — consumers never act on the previous query's threadMessages
46
  // (e.g. auto-selecting the old tab's first thread).
47
  if (query !== memoizedQuery) {
123✔
48
    setMemoizedQuery(query);
1✔
49
    setLoading(true);
1✔
50
  }
51

52
  const fetchAllCommunications = useCallback(async (): Promise<void> => {
123✔
53
    const requestId = ++loadRequestIdRef.current;
43✔
54
    try {
43✔
55
      const searchParams = new URLSearchParams(memoizedQuery);
43✔
56
      searchParams.append('identifier:not', 'http://medplum.com/ai-message|');
43✔
57
      searchParams.append('part-of:missing', 'true');
43✔
58
      searchParams.append('_has:Communication:part-of:_id:not', 'null');
43✔
59

60
      const bundle = await medplum.search('Communication', searchParams.toString(), { cache: 'no-cache' });
43✔
61
      if (requestId !== loadRequestIdRef.current) {
41!
NEW
62
        return;
×
63
      }
64
      const parents =
65
        bundle.entry
41✔
66
          ?.map((entry) => entry.resource as Communication)
20✔
67
          .filter((r): r is Communication => r !== undefined) || [];
20✔
68

69
      if (bundle.total !== undefined) {
43✔
70
        setTotal(bundle.total);
30✔
71
      }
72

73
      if (parents.length === 0) {
41✔
74
        setThreadMessages([]);
23✔
75
        return;
23✔
76
      }
77

78
      const queryParts = parents.map((parent) => {
18✔
79
        const safeId = parent.id?.replaceAll('-', '') || '';
20!
80
        const alias = `thread_${safeId}`;
20✔
81
        const ref = getReferenceString(parent);
20✔
82

83
        return `
20✔
84
          ${alias}: CommunicationList(
85
            part_of: "${ref}"
86
            _sort: "-sent"
87
            _count: 1
88
          ) {
89
            id
90
            meta {
91
              lastUpdated
92
            }
93
            partOf {
94
              reference
95
            }
96
            sender {
97
              display
98
              reference
99
            }
100
            payload {
101
              contentString
102
            }
103
            sent
104
            status
105
          }
106
        `;
107
      });
108

109
      const fullQuery = `
18✔
110
        query {
111
          ${queryParts.join('\n')}
112
        }
113
      `;
114

115
      const response = await medplum.graphql(fullQuery);
18✔
116
      if (requestId !== loadRequestIdRef.current) {
18!
NEW
117
        return;
×
118
      }
119

120
      const threadsWithReplies = parents
18✔
121
        .map((parent) => {
122
          const safeId = parent.id?.replaceAll('-', '') || '';
20!
123
          const alias = `thread_${safeId}`;
20✔
124
          const childList = response.data[alias] as Communication[] | undefined;
20✔
125
          const lastMessage = childList && childList.length > 0 ? childList[0] : undefined;
20✔
126
          return [parent, lastMessage];
20✔
127
        })
128
        .filter((thread): thread is [Communication, Communication] => thread[1] !== undefined);
20✔
129

130
      setThreadMessages(threadsWithReplies);
18✔
131
    } finally {
132
      if (requestId === loadRequestIdRef.current) {
42!
133
        setLoading(false);
42✔
134
      }
135
    }
136
  }, [medplum, memoizedQuery]);
137

138
  useEffect(() => {
123✔
139
    fetchAllCommunications().catch((err: Error) => {
39✔
140
      setError(err);
1✔
141
    });
142
  }, [fetchAllCommunications]);
143

144
  useEffect(() => {
123✔
145
    const fetchThread = async (): Promise<void> => {
81✔
146
      if (!threadId) {
81✔
147
        setSelectedThread(undefined);
45✔
148
        return;
45✔
149
      }
150

151
      const thread = threadMessages.find((t) => t[0].id === threadId);
36✔
152
      if (thread) {
36✔
153
        setSelectedThread(thread[0]);
4✔
154
        return;
4✔
155
      }
156

157
      const communication: Communication = await medplum.readResource('Communication', threadId);
32✔
158
      if (communication.partOf === undefined) {
31✔
159
        setSelectedThread(communication);
29✔
160
      } else {
161
        const parentRef = communication.partOf[0].reference;
2✔
162
        if (parentRef) {
2!
163
          const parent = await medplum.readReference({ reference: parentRef });
2✔
164
          setSelectedThread(parent as Communication);
2✔
165
        }
166
      }
167
    };
168

169
    fetchThread().catch((err: Error) => {
81✔
170
      setError(err);
1✔
171
    });
172
  }, [threadId, threadMessages, medplum]);
173

174
  const handleThreadStatusChange = (newStatus: Communication['status']): void => {
123✔
175
    if (!selectedThread) {
3✔
176
      return;
1✔
177
    }
178
    const doUpdate = async (): Promise<void> => {
2✔
179
      const updatedThread = await medplum.updateResource({ ...selectedThread, status: newStatus });
2✔
180
      setSelectedThread(updatedThread);
1✔
181
      setThreadMessages((prev) =>
1✔
182
        prev.map(([parent, lastMsg]) => (parent.id === updatedThread.id ? [updatedThread, lastMsg] : [parent, lastMsg]))
1!
183
      );
184
    };
185
    doUpdate().catch((err: Error) => setError(err));
2✔
186
  };
187

188
  const addThreadMessage = (message: Communication): void => {
123✔
189
    const doAdd = async (): Promise<void> => {
2✔
190
      await fetchAllCommunications();
2✔
191
      setThreadMessages((prev) => [[message, undefined], ...prev]);
2✔
192
    };
193
    doAdd().catch((err: Error) => setError(err));
2✔
194
  };
195

196
  return {
123✔
197
    loading,
198
    error,
199
    threadMessages,
200
    selectedThread,
201
    total,
202
    addThreadMessage,
203
    handleThreadStatusChange,
204
    refreshThreadMessages: fetchAllCommunications,
205
  };
206
}
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