diff --git a/packages/react/src/firestore/index.ts b/packages/react/src/firestore/index.ts index ec75c7b..bb4a572 100644 --- a/packages/react/src/firestore/index.ts +++ b/packages/react/src/firestore/index.ts @@ -10,3 +10,4 @@ export { useCollectionQuery } from "./useCollectionQuery"; export { useGetAggregateFromServerQuery } from "./useGetAggregateFromServerQuery"; export { useGetCountFromServerQuery } from "./useGetCountFromServerQuery"; // useNamedQuery +export { useSetDocumentMutation } from "./useSetDocumentMutation"; diff --git a/packages/react/src/firestore/useSetDocumentMutation.test.tsx b/packages/react/src/firestore/useSetDocumentMutation.test.tsx new file mode 100644 index 0000000..f9c58ee --- /dev/null +++ b/packages/react/src/firestore/useSetDocumentMutation.test.tsx @@ -0,0 +1,169 @@ +import { renderHook, waitFor, act } from "@testing-library/react"; +import { doc, type DocumentReference, getDoc } from "firebase/firestore"; +import { beforeEach, describe, expect, test } from "vitest"; +import { useSetDocumentMutation } from "./useSetDocumentMutation"; + +import { + expectFirestoreError, + firestore, + wipeFirestore, +} from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; + +describe("useSetDocumentMutation", () => { + beforeEach(async () => { + await wipeFirestore(); + queryClient.clear(); + }); + + test("successfully sets a new document", async () => { + const docRef = doc(firestore, "tests", "setTest"); + const testData = { foo: "bar", num: 42 }; + + const { result } = renderHook( + () => useSetDocumentMutation(docRef, testData), + { wrapper } + ); + + expect(result.current.isPending).toBe(false); + expect(result.current.isIdle).toBe(true); + + await act(() => result.current.mutate()); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const snapshot = await getDoc(docRef); + expect(snapshot.exists()).toBe(true); + expect(snapshot.data()).toEqual(testData); + }); + + test("successfully overwrites existing document", async () => { + const docRef = doc(firestore, "tests", "overwriteTest"); + const initialData = { foo: "initial", num: 1 }; + const newData = { foo: "updated", num: 2 }; + + const { result } = renderHook( + () => useSetDocumentMutation(docRef, initialData), + { wrapper } + ); + + await act(() => result.current.mutate()); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + let snapshot = await getDoc(docRef); + expect(snapshot.data()).toEqual(initialData); + + const { result: result2 } = renderHook( + () => useSetDocumentMutation(docRef, newData), + { wrapper } + ); + + await act(() => result2.current.mutate()); + + await waitFor(() => { + expect(result2.current.isSuccess).toBe(true); + }); + + snapshot = await getDoc(docRef); + expect(snapshot.data()).toEqual(newData); + }); + + test("handles type-safe document data", async () => { + interface TestDoc { + foo: string; + num: number; + } + + const docRef = doc( + firestore, + "tests", + "typedDoc" + ) as DocumentReference; + const testData: TestDoc = { foo: "test", num: 123 }; + + const { result } = renderHook( + () => useSetDocumentMutation(docRef, testData), + { wrapper } + ); + + await act(() => result.current.mutate()); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const snapshot = await getDoc(docRef); + const data = snapshot.data(); + expect(data?.foo).toBe("test"); + expect(data?.num).toBe(123); + }); + + test("handles errors when setting to restricted collection", async () => { + const restrictedDocRef = doc(firestore, "restrictedCollection", "someDoc"); + const testData = { foo: "bar" }; + + const { result } = renderHook( + () => useSetDocumentMutation(restrictedDocRef, testData), + { wrapper } + ); + + await act(() => result.current.mutate()); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expectFirestoreError(result.current.error, "permission-denied"); + }); + + test("calls onSuccess callback after setting document", async () => { + const docRef = doc(firestore, "tests", "callbackTest"); + const testData = { foo: "callback" }; + let callbackCalled = false; + + const { result } = renderHook( + () => + useSetDocumentMutation(docRef, testData, { + onSuccess: () => { + callbackCalled = true; + }, + }), + { wrapper } + ); + + await act(() => result.current.mutate()); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(callbackCalled).toBe(true); + const snapshot = await getDoc(docRef); + expect(snapshot.data()?.foo).toBe("callback"); + }); + + test("handles empty data object", async () => { + const docRef = doc(firestore, "tests", "emptyDoc"); + const emptyData = {}; + + const { result } = renderHook( + () => useSetDocumentMutation(docRef, emptyData), + { wrapper } + ); + + await act(() => result.current.mutate()); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const snapshot = await getDoc(docRef); + expect(snapshot.exists()).toBe(true); + expect(snapshot.data()).toEqual({}); + }); +}); diff --git a/packages/react/src/firestore/useSetDocumentMutation.ts b/packages/react/src/firestore/useSetDocumentMutation.ts new file mode 100644 index 0000000..e53d4e2 --- /dev/null +++ b/packages/react/src/firestore/useSetDocumentMutation.ts @@ -0,0 +1,26 @@ +import { useMutation, type UseMutationOptions } from "@tanstack/react-query"; +import { + type DocumentReference, + type FirestoreError, + type WithFieldValue, + type DocumentData, + setDoc, +} from "firebase/firestore"; + +type FirestoreUseMutationOptions = Omit< + UseMutationOptions, + "mutationFn" +>; +export function useSetDocumentMutation< + AppModelType extends DocumentData = DocumentData, + DbModelType extends DocumentData = DocumentData +>( + documentRef: DocumentReference, + data: WithFieldValue, + options?: FirestoreUseMutationOptions +) { + return useMutation({ + ...options, + mutationFn: () => setDoc(documentRef, data), + }); +}