Skip to content

Commit

Permalink
Merge pull request #12 from vintasoftware/feat/single-context
Browse files Browse the repository at this point in the history
Global ChatContext
  • Loading branch information
fjsj authored Jan 6, 2025
2 parents b01ebdc + 7eb8c9e commit 3bb5d33
Show file tree
Hide file tree
Showing 9 changed files with 862 additions and 686 deletions.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { MockClient, MockSubscriptionManager } from "@medplum/mock";
import { MedplumProvider } from "@medplum/react-hooks";
import { act, renderHook, waitFor } from "@testing-library/react-native";

import { useThreads } from "@/hooks/headless/useThreads";
import { ChatProvider, useChat } from "@/contexts/ChatContext";
import { getQueryString } from "@/utils/url";

const mockPatient: Patient = {
Expand Down Expand Up @@ -86,7 +86,7 @@ async function createCommunicationSubBundle(communication: Communication): Promi
};
}

describe("useThreads", () => {
describe("useChat (threads)", () => {
async function setup(
profile: Patient | Practitioner = mockPatient,
): Promise<{ medplum: MockClient; subManager: MockSubscriptionManager }> {
Expand All @@ -108,6 +108,32 @@ describe("useThreads", () => {
return { medplum, subManager };
}

// Helper function to create wrapper with both providers
function createWrapper(
medplum: MockClient,
props: Partial<{
onError: (error: Error) => void;
onWebSocketClose: () => void;
onWebSocketOpen: () => void;
onSubscriptionConnect: () => void;
}> = {},
) {
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MedplumProvider medplum={medplum}>
<ChatProvider {...props}>{children}</ChatProvider>
</MedplumProvider>
);
return TestWrapper;
}

// Subscription criteria
const criteria = getQueryString({
"part-of:missing": true,
subject: getReferenceString(mockPatient),
_revinclude: "Communication:part-of",
});

// Test cases:
test("Loads and displays threads for patient", async () => {
const { medplum } = await setup();
const searchSpy = jest.spyOn(medplum, "search");
Expand Down Expand Up @@ -137,16 +163,16 @@ describe("useThreads", () => {
],
});

const { result } = renderHook(() => useThreads(), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum),
});

// Initially should be loading
expect(result.current.loading).toBe(true);
expect(result.current.isLoadingThreads).toBe(true);

// Wait for loading to complete
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

// Verify search was called correctly
Expand All @@ -155,7 +181,6 @@ describe("useThreads", () => {
subject: "Patient/test-patient",
_revinclude: "Communication:part-of",
_sort: "-sent",
_count: "100",
});

// Check threads are displayed correctly
Expand Down Expand Up @@ -191,12 +216,12 @@ describe("useThreads", () => {
],
});

const { result } = renderHook(() => useThreads(), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum),
});

await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

expect(result.current.threads).toEqual([]);
Expand All @@ -207,33 +232,32 @@ describe("useThreads", () => {
const { medplum } = await setup(mockPractitioner);
const searchSpy = jest.spyOn(medplum, "search");

const { result } = renderHook(() => useThreads(), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum),
});

await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

expect(searchSpy).toHaveBeenCalledWith("Communication", {
"part-of:missing": true,
subject: undefined,
_revinclude: "Communication:part-of",
_sort: "-sent",
_count: "100",
});
});

test("Creates new thread successfully", async () => {
const { medplum } = await setup();
const createSpy = jest.spyOn(medplum, "createResource");

const { result } = renderHook(() => useThreads(), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum),
});

await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

const newThreadId = await act(async () => {
Expand Down Expand Up @@ -271,12 +295,12 @@ describe("useThreads", () => {
test("Prevents non-patient from creating thread", async () => {
const { medplum } = await setup(mockPractitioner);

const { result } = renderHook(() => useThreads(), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum),
});

await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

await expect(result.current.createThread("New Thread")).rejects.toThrow(
Expand All @@ -288,12 +312,12 @@ describe("useThreads", () => {
const { medplum } = await setup();
const createSpy = jest.spyOn(medplum, "createResource");

const { result } = renderHook(() => useThreads(), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum),
});

await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

const threadId = await result.current.createThread(" ");
Expand Down Expand Up @@ -363,12 +387,12 @@ describe("useThreads", () => {
],
});

const { result } = renderHook(() => useThreads(), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum),
});

await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

// Verify threads are ordered by last activity (message sent time or thread creation time)
Expand Down Expand Up @@ -435,21 +459,16 @@ describe("useThreads", () => {
],
});

const { result } = renderHook(() => useThreads(), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum),
});

// Wait for initial load
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

// Emit that subscription is connected
const criteria = getQueryString({
"part-of:missing": true,
subject: getReferenceString(mockPatient),
_revinclude: "Communication:part-of",
});
act(() => {
subManager.emitEventForCriteria(`Communication?${criteria}`, {
type: "connect",
Expand Down Expand Up @@ -541,13 +560,13 @@ describe("useThreads", () => {
],
});

const { result } = renderHook(() => useThreads(), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum),
});

// Wait for initial load
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

// Verify initial threads are loaded
Expand All @@ -569,7 +588,7 @@ describe("useThreads", () => {

// Wait for loading to complete with new profile
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

// Verify threads are cleared
Expand All @@ -583,21 +602,17 @@ describe("useThreads", () => {
const { medplum, subManager } = await setup();
const searchSpy = jest.spyOn(medplum, "search");

const { result } = renderHook(
() =>
useThreads({
onWebSocketClose: onWebSocketCloseMock,
onWebSocketOpen: onWebSocketOpenMock,
onSubscriptionConnect: onSubscriptionConnectMock,
}),
{
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
},
);
const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum, {
onWebSocketClose: onWebSocketCloseMock,
onWebSocketOpen: onWebSocketOpenMock,
onSubscriptionConnect: onSubscriptionConnectMock,
}),
});

// Wait for initial load
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

// Simulate WebSocket disconnection
Expand Down Expand Up @@ -648,11 +663,6 @@ describe("useThreads", () => {
expect(result.current.threads.find((t) => t.id === "thread-new")).toBeUndefined();

// Emit subscription connected event
const criteria = getQueryString({
"part-of:missing": true,
subject: getReferenceString(mockPatient),
_revinclude: "Communication:part-of",
});
act(() => {
subManager.emitEventForCriteria(`Communication?${criteria}`, {
type: "connect",
Expand All @@ -668,7 +678,7 @@ describe("useThreads", () => {

// Wait for reconnection and thread refresh
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
const newThreadInList = result.current.threads.find((t) => t.id === "thread-new");
expect(newThreadInList).toBeDefined();
expect(newThreadInList?.topic).toBe("Thread created while disconnected");
Expand All @@ -678,21 +688,17 @@ describe("useThreads", () => {
test("Calls onError callback when subscription error occurs", async () => {
const onErrorMock = jest.fn();
const { medplum, subManager } = await setup();
const { result } = renderHook(() => useThreads({ onError: onErrorMock }), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,

const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum, { onError: onErrorMock }),
});

// Wait for initial load
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

// Emit error event on subscription
const criteria = getQueryString({
"part-of:missing": true,
subject: getReferenceString(mockPatient),
_revinclude: "Communication:part-of",
});
act(() => {
subManager.emitEventForCriteria(`Communication?${criteria}`, {
type: "error",
Expand All @@ -714,22 +720,16 @@ describe("useThreads", () => {
const error = new Error("Failed to load threads");
searchSpy.mockRejectedValue(error);

const { result } = renderHook(
() =>
useThreads({
onError: onErrorMock,
}),
{
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
},
);
const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum, { onError: onErrorMock }),
});

// Initially should be loading
expect(result.current.loading).toBe(true);
expect(result.current.isLoadingThreads).toBe(true);

// Wait for loading to complete
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.isLoadingThreads).toBe(false);
});

// Verify the onError callback was called with the error
Expand Down
7 changes: 6 additions & 1 deletion app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Redirect, Slot } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";

import { Spinner } from "@/components/ui/spinner";
import { ChatProvider } from "@/contexts/ChatContext";

export default function AppLayout() {
const medplum = useMedplum();
Expand All @@ -21,5 +22,9 @@ export default function AppLayout() {
return <Redirect href="/sign-in" />;
}

return <Slot />;
return (
<ChatProvider>
<Slot />
</ChatProvider>
);
}
6 changes: 3 additions & 3 deletions app/(app)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import { CreateThreadModal } from "@/components/CreateThreadModal";
import { ThreadList } from "@/components/ThreadList";
import { ThreadListHeader } from "@/components/ThreadListHeader";
import { Spinner } from "@/components/ui/spinner";
import { useThreads } from "@/hooks/headless/useThreads";
import { useChat } from "@/contexts/ChatContext";

export default function Index() {
const { threads, loading, createThread } = useThreads();
const { threads, isLoadingThreads, createThread } = useChat();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const medplum = useMedplum();
const router = useRouter();
Expand All @@ -20,7 +20,7 @@ export default function Index() {
router.replace("/sign-in");
}, [medplum, router]);

if (loading) {
if (isLoadingThreads) {
return (
<SafeAreaView
style={{
Expand Down
Loading

0 comments on commit 3bb5d33

Please sign in to comment.