From 8366b3cb6cfcdb4c42d4985fa8543dce546d6dbe Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Fri, 31 Jan 2025 05:50:45 +0300 Subject: [PATCH 1/2] feat(react/firestore): add useSetDocumentMutation --- packages/react/src/firestore/index.ts | 1 + .../firestore/useSetDocumentMutation.test.tsx | 169 ++++++++++++++++++ .../src/firestore/useSetDocumentMutation.ts | 26 +++ 3 files changed, 196 insertions(+) create mode 100644 packages/react/src/firestore/useSetDocumentMutation.test.tsx create mode 100644 packages/react/src/firestore/useSetDocumentMutation.ts diff --git a/packages/react/src/firestore/index.ts b/packages/react/src/firestore/index.ts index ec75c7b1..bb4a572d 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 00000000..f9c58ee0 --- /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 00000000..e53d4e28 --- /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), + }); +} From 4ccc96f189ff9e9560c323247f0bcf3c6b599ec8 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Tue, 11 Feb 2025 16:54:57 +0300 Subject: [PATCH 2/2] refactor: move variables to mutation args --- .../firestore/useSetDocumentMutation.test.tsx | 48 +++++++++---------- .../src/firestore/useSetDocumentMutation.ts | 16 ++++--- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/react/src/firestore/useSetDocumentMutation.test.tsx b/packages/react/src/firestore/useSetDocumentMutation.test.tsx index f9c58ee0..0ea5fb64 100644 --- a/packages/react/src/firestore/useSetDocumentMutation.test.tsx +++ b/packages/react/src/firestore/useSetDocumentMutation.test.tsx @@ -20,15 +20,14 @@ describe("useSetDocumentMutation", () => { const docRef = doc(firestore, "tests", "setTest"); const testData = { foo: "bar", num: 42 }; - const { result } = renderHook( - () => useSetDocumentMutation(docRef, testData), - { wrapper } - ); + const { result } = renderHook(() => useSetDocumentMutation(docRef), { + wrapper, + }); expect(result.current.isPending).toBe(false); expect(result.current.isIdle).toBe(true); - await act(() => result.current.mutate()); + await act(() => result.current.mutate(testData)); await waitFor(() => { expect(result.current.isSuccess).toBe(true); @@ -44,12 +43,11 @@ describe("useSetDocumentMutation", () => { const initialData = { foo: "initial", num: 1 }; const newData = { foo: "updated", num: 2 }; - const { result } = renderHook( - () => useSetDocumentMutation(docRef, initialData), - { wrapper } - ); + const { result } = renderHook(() => useSetDocumentMutation(docRef), { + wrapper, + }); - await act(() => result.current.mutate()); + await act(() => result.current.mutate(initialData)); await waitFor(() => { expect(result.current.isSuccess).toBe(true); @@ -59,11 +57,11 @@ describe("useSetDocumentMutation", () => { expect(snapshot.data()).toEqual(initialData); const { result: result2 } = renderHook( - () => useSetDocumentMutation(docRef, newData), + () => useSetDocumentMutation(docRef), { wrapper } ); - await act(() => result2.current.mutate()); + await act(() => result2.current.mutate(newData)); await waitFor(() => { expect(result2.current.isSuccess).toBe(true); @@ -86,12 +84,11 @@ describe("useSetDocumentMutation", () => { ) as DocumentReference; const testData: TestDoc = { foo: "test", num: 123 }; - const { result } = renderHook( - () => useSetDocumentMutation(docRef, testData), - { wrapper } - ); + const { result } = renderHook(() => useSetDocumentMutation(docRef), { + wrapper, + }); - await act(() => result.current.mutate()); + await act(() => result.current.mutate(testData)); await waitFor(() => { expect(result.current.isSuccess).toBe(true); @@ -108,11 +105,11 @@ describe("useSetDocumentMutation", () => { const testData = { foo: "bar" }; const { result } = renderHook( - () => useSetDocumentMutation(restrictedDocRef, testData), + () => useSetDocumentMutation(restrictedDocRef), { wrapper } ); - await act(() => result.current.mutate()); + await act(() => result.current.mutate(testData)); await waitFor(() => { expect(result.current.isError).toBe(true); @@ -128,7 +125,7 @@ describe("useSetDocumentMutation", () => { const { result } = renderHook( () => - useSetDocumentMutation(docRef, testData, { + useSetDocumentMutation(docRef, { onSuccess: () => { callbackCalled = true; }, @@ -136,7 +133,7 @@ describe("useSetDocumentMutation", () => { { wrapper } ); - await act(() => result.current.mutate()); + await act(() => result.current.mutate(testData)); await waitFor(() => { expect(result.current.isSuccess).toBe(true); @@ -151,12 +148,11 @@ describe("useSetDocumentMutation", () => { const docRef = doc(firestore, "tests", "emptyDoc"); const emptyData = {}; - const { result } = renderHook( - () => useSetDocumentMutation(docRef, emptyData), - { wrapper } - ); + const { result } = renderHook(() => useSetDocumentMutation(docRef), { + wrapper, + }); - await act(() => result.current.mutate()); + await act(() => result.current.mutate(emptyData)); await waitFor(() => { expect(result.current.isSuccess).toBe(true); diff --git a/packages/react/src/firestore/useSetDocumentMutation.ts b/packages/react/src/firestore/useSetDocumentMutation.ts index e53d4e28..ebb4a538 100644 --- a/packages/react/src/firestore/useSetDocumentMutation.ts +++ b/packages/react/src/firestore/useSetDocumentMutation.ts @@ -7,20 +7,24 @@ import { setDoc, } from "firebase/firestore"; -type FirestoreUseMutationOptions = Omit< - UseMutationOptions, +type FirestoreUseMutationOptions< + TData = unknown, + TError = Error, + AppModelType extends DocumentData = DocumentData +> = Omit< + UseMutationOptions>, "mutationFn" >; + export function useSetDocumentMutation< AppModelType extends DocumentData = DocumentData, DbModelType extends DocumentData = DocumentData >( documentRef: DocumentReference, - data: WithFieldValue, - options?: FirestoreUseMutationOptions + options?: FirestoreUseMutationOptions ) { - return useMutation({ + return useMutation>({ ...options, - mutationFn: () => setDoc(documentRef, data), + mutationFn: (data) => setDoc(documentRef, data), }); }