Skip to content

Commit d8c021a

Browse files
authored
feat(react/firestore): add useUpdateDocumentMutation (#159)
* feat(react/firestore): add useUpdateDocumentMutation * refactor: move variables to mutate args * _
1 parent 513e8a5 commit d8c021a

File tree

3 files changed

+228
-0
lines changed

3 files changed

+228
-0
lines changed

packages/react/src/firestore/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { useDocumentQuery } from "./useDocumentQuery";
88
export { useCollectionQuery } from "./useCollectionQuery";
99
export { useGetAggregateFromServerQuery } from "./useGetAggregateFromServerQuery";
1010
export { useGetCountFromServerQuery } from "./useGetCountFromServerQuery";
11+
export { useUpdateDocumentMutation } from "./useUpdateDocumentMutation";
1112
export { useSetDocumentMutation } from "./useSetDocumentMutation";
1213
export { useNamedQuery } from "./useNamedQuery";
1314
export { useDeleteDocumentMutation } from "./useDeleteDocumentMutation";
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { renderHook, waitFor, act } from "@testing-library/react";
2+
import {
3+
doc,
4+
type DocumentReference,
5+
getDoc,
6+
setDoc,
7+
} from "firebase/firestore";
8+
import { beforeEach, describe, expect, test } from "vitest";
9+
import { useUpdateDocumentMutation } from "./useUpdateDocumentMutation";
10+
11+
import {
12+
expectFirestoreError,
13+
firestore,
14+
wipeFirestore,
15+
} from "~/testing-utils";
16+
import { queryClient, wrapper } from "../../utils";
17+
18+
describe("useUpdateDocumentMutation", () => {
19+
beforeEach(async () => {
20+
await wipeFirestore();
21+
queryClient.clear();
22+
});
23+
24+
test("successfully updates an existing document", async () => {
25+
const docRef = doc(firestore, "tests", "updateTest");
26+
27+
await setDoc(docRef, { foo: "initial", num: 1, unchanged: "same" });
28+
29+
const updateData = { foo: "updated", num: 2 };
30+
31+
const { result } = renderHook(() => useUpdateDocumentMutation(docRef), {
32+
wrapper,
33+
});
34+
35+
expect(result.current.isPending).toBe(false);
36+
37+
await act(() => result.current.mutate(updateData));
38+
39+
await waitFor(() => {
40+
expect(result.current.isSuccess).toBe(true);
41+
});
42+
43+
const snapshot = await getDoc(docRef);
44+
expect(snapshot.exists()).toBe(true);
45+
expect(snapshot.data()).toEqual({
46+
foo: "updated",
47+
num: 2,
48+
unchanged: "same",
49+
});
50+
});
51+
52+
test("handles nested field updates", async () => {
53+
const docRef = doc(firestore, "tests", "nestedTest");
54+
55+
await setDoc(docRef, {
56+
nested: { field1: "old", field2: "keep" },
57+
top: "unchanged",
58+
});
59+
60+
const updateData = {
61+
"nested.field1": "new",
62+
};
63+
64+
const { result } = renderHook(() => useUpdateDocumentMutation(docRef), {
65+
wrapper,
66+
});
67+
68+
await act(() => result.current.mutate(updateData));
69+
70+
await waitFor(() => {
71+
expect(result.current.isSuccess).toBe(true);
72+
});
73+
74+
const snapshot = await getDoc(docRef);
75+
const data = snapshot.data();
76+
expect(data?.nested.field1).toBe("new");
77+
expect(data?.nested.field2).toBe("keep");
78+
expect(data?.top).toBe("unchanged");
79+
});
80+
81+
test("handles type-safe document updates", async () => {
82+
interface TestDoc {
83+
foo: string;
84+
num: number;
85+
optional?: string;
86+
}
87+
88+
const docRef = doc(
89+
firestore,
90+
"tests",
91+
"typedDoc"
92+
) as DocumentReference<TestDoc>;
93+
94+
await setDoc(docRef, { foo: "initial", num: 1 });
95+
96+
const updateData = { num: 42 };
97+
98+
const { result } = renderHook(() => useUpdateDocumentMutation(docRef), {
99+
wrapper,
100+
});
101+
102+
await act(() => result.current.mutate(updateData));
103+
104+
await waitFor(() => {
105+
expect(result.current.isSuccess).toBe(true);
106+
});
107+
108+
const snapshot = await getDoc(docRef);
109+
const data = snapshot.data();
110+
expect(data?.foo).toBe("initial"); // unchanged
111+
expect(data?.num).toBe(42); // updated
112+
});
113+
114+
test("fails when updating non-existent document", async () => {
115+
const nonExistentDocRef = doc(firestore, "tests", "doesNotExist");
116+
const updateData = { foo: "bar" };
117+
118+
const { result } = renderHook(
119+
() => useUpdateDocumentMutation(nonExistentDocRef),
120+
{ wrapper }
121+
);
122+
123+
await act(() => result.current.mutate(updateData));
124+
125+
await waitFor(() => {
126+
expect(result.current.isError).toBe(true);
127+
});
128+
129+
expectFirestoreError(result.current.error, "not-found");
130+
});
131+
132+
test("handles errors when updating restricted collection", async () => {
133+
const restrictedDocRef = doc(firestore, "restrictedCollection", "someDoc");
134+
const updateData = { foo: "bar" };
135+
136+
const { result } = renderHook(
137+
() => useUpdateDocumentMutation(restrictedDocRef),
138+
{ wrapper }
139+
);
140+
141+
await act(() => result.current.mutate(updateData));
142+
143+
await waitFor(() => {
144+
expect(result.current.isError).toBe(true);
145+
});
146+
147+
expectFirestoreError(result.current.error, "permission-denied");
148+
});
149+
150+
test("calls onSuccess callback after update", async () => {
151+
const docRef = doc(firestore, "tests", "callbackTest");
152+
await setDoc(docRef, { foo: "initial" });
153+
154+
let callbackCalled = false;
155+
const updateData = { foo: "updated" };
156+
157+
const { result } = renderHook(
158+
() =>
159+
useUpdateDocumentMutation(docRef, {
160+
onSuccess: () => {
161+
callbackCalled = true;
162+
},
163+
}),
164+
{ wrapper }
165+
);
166+
167+
await act(() => result.current.mutate(updateData));
168+
169+
await waitFor(() => {
170+
expect(result.current.isSuccess).toBe(true);
171+
});
172+
173+
expect(callbackCalled).toBe(true);
174+
const snapshot = await getDoc(docRef);
175+
expect(snapshot.data()?.foo).toBe("updated");
176+
});
177+
178+
test("handles empty update object", async () => {
179+
const docRef = doc(firestore, "tests", "emptyUpdateTest");
180+
await setDoc(docRef, { foo: "initial" });
181+
182+
const emptyUpdate = {};
183+
184+
const { result } = renderHook(() => useUpdateDocumentMutation(docRef), {
185+
wrapper,
186+
});
187+
188+
await act(() => result.current.mutate(emptyUpdate));
189+
190+
await waitFor(() => {
191+
expect(result.current.isSuccess).toBe(true);
192+
});
193+
194+
const snapshot = await getDoc(docRef);
195+
expect(snapshot.data()).toEqual({ foo: "initial" });
196+
});
197+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useMutation, type UseMutationOptions } from "@tanstack/react-query";
2+
import {
3+
type DocumentReference,
4+
type FirestoreError,
5+
type DocumentData,
6+
updateDoc,
7+
type UpdateData,
8+
} from "firebase/firestore";
9+
10+
type FirestoreUseMutationOptions<
11+
TData = unknown,
12+
TError = Error,
13+
DbModelType extends DocumentData = DocumentData
14+
> = Omit<
15+
UseMutationOptions<TData, TError, UpdateData<DbModelType>>,
16+
"mutationFn"
17+
>;
18+
19+
export function useUpdateDocumentMutation<
20+
AppModelType extends DocumentData = DocumentData,
21+
DbModelType extends DocumentData = DocumentData
22+
>(
23+
documentRef: DocumentReference<AppModelType, DbModelType>,
24+
options?: FirestoreUseMutationOptions<void, FirestoreError, DbModelType>
25+
) {
26+
return useMutation<void, FirestoreError, UpdateData<DbModelType>>({
27+
...options,
28+
mutationFn: (data) => updateDoc(documentRef, data),
29+
});
30+
}

0 commit comments

Comments
 (0)