diff --git a/packages/react/src/firestore/index.ts b/packages/react/src/firestore/index.ts index bd3827bf..437a3ab9 100644 --- a/packages/react/src/firestore/index.ts +++ b/packages/react/src/firestore/index.ts @@ -8,6 +8,7 @@ export { useDocumentQuery } from "./useDocumentQuery"; export { useCollectionQuery } from "./useCollectionQuery"; export { useGetAggregateFromServerQuery } from "./useGetAggregateFromServerQuery"; export { useGetCountFromServerQuery } from "./useGetCountFromServerQuery"; +export { useAddDocumentMutation } from "./useAddDocumentMutation"; export { useUpdateDocumentMutation } from "./useUpdateDocumentMutation"; export { useSetDocumentMutation } from "./useSetDocumentMutation"; export { useNamedQuery } from "./useNamedQuery"; diff --git a/packages/react/src/firestore/useAddDocumentMutation.test.tsx b/packages/react/src/firestore/useAddDocumentMutation.test.tsx new file mode 100644 index 00000000..667a5234 --- /dev/null +++ b/packages/react/src/firestore/useAddDocumentMutation.test.tsx @@ -0,0 +1,142 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { + collection, + type CollectionReference, + type DocumentReference, + getDoc, +} from "firebase/firestore"; +import { beforeEach, describe, expect, test } from "vitest"; +import { useAddDocumentMutation } from "./useAddDocumentMutation"; + +import { + expectFirestoreError, + firestore, + wipeFirestore, +} from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { act } from "react"; + +describe("useAddDocumentMutation", () => { + beforeEach(async () => { + await wipeFirestore(); + queryClient.clear(); + }); + + test("successfully adds a document", async () => { + const collectionRef = collection(firestore, "tests"); + const testData = { foo: "bar", num: 42 }; + + const { result } = renderHook(() => useAddDocumentMutation(collectionRef), { + wrapper, + }); + + expect(result.current.isIdle).toBe(true); + + await act(() => result.current.mutate(testData)); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toBeDefined(); + const docRef = result.current.data!; + + const snapshot = await getDoc(docRef); + expect(snapshot.exists()).toBe(true); + expect(snapshot.data()).toEqual(testData); + }); + + test("handles type-safe data", async () => { + interface TestDoc { + foo: string; + num: number; + } + + const collectionRef = collection( + firestore, + "tests" + ) as CollectionReference; + const testData: TestDoc = { foo: "test", num: 123 }; + + const { result } = renderHook(() => useAddDocumentMutation(collectionRef), { + wrapper, + }); + + await act(() => result.current.mutate(testData)); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const snapshot = await getDoc(result.current.data!); + const data = snapshot.data(); + expect(data?.foo).toBe("test"); + expect(data?.num).toBe(123); + }); + + test("handles errors when adding to restricted collection", async () => { + const restrictedCollectionRef = collection( + firestore, + "restrictedCollection" + ); + const testData = { foo: "bar" }; + + const { result } = renderHook( + () => useAddDocumentMutation(restrictedCollectionRef), + { wrapper } + ); + + await act(() => result.current.mutate(testData)); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expectFirestoreError(result.current.error, "permission-denied"); + }); + + test("calls onSuccess callback with document reference", async () => { + const collectionRef = collection(firestore, "tests"); + const testData = { foo: "success" }; + let callbackDocRef: DocumentReference | null = null; + + const { result } = renderHook( + () => + useAddDocumentMutation(collectionRef, { + onSuccess: (docRef) => { + callbackDocRef = docRef; + }, + }), + { wrapper } + ); + + await act(() => result.current.mutate(testData)); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(callbackDocRef).toBeDefined(); + const snapshot = await getDoc(callbackDocRef!); + expect(snapshot.data()?.foo).toBe("success"); + }); + + test("handles empty data object", async () => { + const collectionRef = collection(firestore, "tests"); + const emptyData = {}; + + const { result } = renderHook(() => useAddDocumentMutation(collectionRef), { + wrapper, + }); + + await act(() => result.current.mutate(emptyData)); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const snapshot = await getDoc(result.current.data!); + expect(snapshot.exists()).toBe(true); + expect(snapshot.data()).toEqual({}); + }); +}); diff --git a/packages/react/src/firestore/useAddDocumentMutation.ts b/packages/react/src/firestore/useAddDocumentMutation.ts new file mode 100644 index 00000000..e0dac39e --- /dev/null +++ b/packages/react/src/firestore/useAddDocumentMutation.ts @@ -0,0 +1,39 @@ +import { useMutation, type UseMutationOptions } from "@tanstack/react-query"; +import { + type DocumentReference, + type FirestoreError, + type WithFieldValue, + type CollectionReference, + type DocumentData, + addDoc, +} from "firebase/firestore"; + +type FirestoreUseMutationOptions< + TData = unknown, + TError = Error, + AppModelType extends DocumentData = DocumentData +> = Omit< + UseMutationOptions>, + "mutationFn" +>; + +export function useAddDocumentMutation< + AppModelType extends DocumentData = DocumentData, + DbModelType extends DocumentData = DocumentData +>( + collectionRef: CollectionReference, + options?: FirestoreUseMutationOptions< + DocumentReference, + FirestoreError, + AppModelType + > +) { + return useMutation< + DocumentReference, + FirestoreError, + WithFieldValue + >({ + ...options, + mutationFn: (data) => addDoc(collectionRef, data), + }); +}