From 6267733587942b55bf23f78ef65b2c8aa980a5f9 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Thu, 26 Sep 2024 13:31:34 +0300 Subject: [PATCH 1/3] feat(firestore): add useNamedQuery hook --- packages/react/src/firestore/index.ts | 2 +- .../src/firestore/useNamedQuery.test.tsx | 164 ++++++++++++++++++ packages/react/src/firestore/useNamedQuery.ts | 35 ++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/firestore/useNamedQuery.test.tsx create mode 100644 packages/react/src/firestore/useNamedQuery.ts diff --git a/packages/react/src/firestore/index.ts b/packages/react/src/firestore/index.ts index a3bb2210..8dc44054 100644 --- a/packages/react/src/firestore/index.ts +++ b/packages/react/src/firestore/index.ts @@ -9,4 +9,4 @@ export { useDocumentQuery } from "./useDocumentQuery"; // useDocumentsQuery <-- Name? useQuery? Bit generic. // useGetCountFromServerQuery // useGetAggregateFromServerQuery -// useNamedQuery +export { useNamedQuery } from "./useNamedQuery"; diff --git a/packages/react/src/firestore/useNamedQuery.test.tsx b/packages/react/src/firestore/useNamedQuery.test.tsx new file mode 100644 index 00000000..0adc0187 --- /dev/null +++ b/packages/react/src/firestore/useNamedQuery.test.tsx @@ -0,0 +1,164 @@ +import React, { type ReactNode } from "react"; +import { describe, expect, test, beforeEach } from "vitest"; +import { useNamedQuery } from "./useNamedQuery"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + collection, + addDoc, + query, + where, + DocumentData, + QuerySnapshot, +} from "firebase/firestore"; + +import { + expectFirestoreError, + firestore, + wipeFirestore, +} from "~/testing-utils"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe("useNamedQuery", () => { + beforeEach(async () => await wipeFirestore()); + + test("returns correct data for empty collection", async () => { + const collectionRef = collection(firestore, "tests"); + + const { result } = renderHook( + () => + useNamedQuery(collectionRef, { + queryKey: ["named", "empty"], + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.empty).toBe(true); + expect(result.current.data?.docs.length).toBe(0); + }); + + test("returns correct data for non-empty collection", async () => { + const collectionRef = collection(firestore, "tests"); + + await addDoc(collectionRef, { value: 10 }); + await addDoc(collectionRef, { value: 20 }); + + const { result } = renderHook( + () => + useNamedQuery(collectionRef, { + queryKey: ["named", "non-empty"], + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.empty).toBe(false); + expect(result.current.data?.docs.length).toBe(2); + }); + + test("handles complex queries", async () => { + const collectionRef = collection(firestore, "tests"); + + await addDoc(collectionRef, { category: "A", value: 10 }); + await addDoc(collectionRef, { category: "B", value: 20 }); + await addDoc(collectionRef, { category: "A", value: 30 }); + + const complexQuery = query(collectionRef, where("category", "==", "A")); + + const { result } = renderHook( + () => + useNamedQuery(complexQuery, { + queryKey: ["named", "complex"], + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.empty).toBe(false); + expect(result.current.data?.docs.length).toBe(2); + }); + + test("handles restricted collections appropriately", async () => { + const restrictedCollectionRef = collection( + firestore, + "restrictedCollection" + ); + + const { result } = renderHook( + () => + useNamedQuery(restrictedCollectionRef, { + queryKey: ["named", "restricted"], + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expectFirestoreError(result.current.error, "permission-denied"); + }); + + test("uses custom transform function", async () => { + const collectionRef = collection(firestore, "tests"); + + await addDoc(collectionRef, { value: 10 }); + await addDoc(collectionRef, { value: 20 }); + + const customTransform = (snapshot: QuerySnapshot) => { + return snapshot.docs?.map((doc) => doc.data().value); + }; + + const { result } = renderHook( + () => + useNamedQuery(collectionRef, { + queryKey: ["named", "transform"], + firestore: { + transform: customTransform, + }, + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result?.current?.data).toEqual([10, 20]); + }); + + test("returns pending state initially", async () => { + const collectionRef = collection(firestore, "tests"); + + await addDoc(collectionRef, { value: 10 }); + + const { result } = renderHook( + () => + useNamedQuery(collectionRef, { + queryKey: ["named", "pending"], + }), + { wrapper } + ); + + // Initially isPending should be true + expect(result.current.isPending).toBe(true); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.empty).toBe(false); + expect(result.current.data?.docs.length).toBe(1); + }); +}); diff --git a/packages/react/src/firestore/useNamedQuery.ts b/packages/react/src/firestore/useNamedQuery.ts new file mode 100644 index 00000000..6cff2ab3 --- /dev/null +++ b/packages/react/src/firestore/useNamedQuery.ts @@ -0,0 +1,35 @@ +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { + type FirestoreError, + getDocs, + type Query, + type QuerySnapshot, + type DocumentData, +} from "firebase/firestore"; + +type FirestoreUseQueryOptions = Omit< + UseQueryOptions, + "queryFn" +> & { + firestore?: { + transform?: (snapshot: QuerySnapshot) => TData; + }; +}; + +export function useNamedQuery>( + query: Query, + options: FirestoreUseQueryOptions +) { + const { firestore, ...queryOptions } = options; + + return useQuery({ + ...queryOptions, + queryFn: async () => { + const snapshot = await getDocs(query); + if (firestore?.transform) { + return firestore.transform(snapshot); + } + return snapshot as TData; + }, + }); +} From e49f96f684704ba3a3acaa3fa94579865dd1bfb6 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Sun, 29 Sep 2024 18:18:42 +0300 Subject: [PATCH 2/3] refactor(firestore): return snapshot for useNamedQuery --- .../src/firestore/useNamedQuery.test.tsx | 137 +----------------- packages/react/src/firestore/useNamedQuery.ts | 34 ++--- 2 files changed, 19 insertions(+), 152 deletions(-) diff --git a/packages/react/src/firestore/useNamedQuery.test.tsx b/packages/react/src/firestore/useNamedQuery.test.tsx index 0adc0187..216c2fcc 100644 --- a/packages/react/src/firestore/useNamedQuery.test.tsx +++ b/packages/react/src/firestore/useNamedQuery.test.tsx @@ -3,20 +3,8 @@ import { describe, expect, test, beforeEach } from "vitest"; import { useNamedQuery } from "./useNamedQuery"; import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - collection, - addDoc, - query, - where, - DocumentData, - QuerySnapshot, -} from "firebase/firestore"; -import { - expectFirestoreError, - firestore, - wipeFirestore, -} from "~/testing-utils"; +import { firestore, wipeFirestore } from "~/testing-utils"; const queryClient = new QueryClient({ defaultOptions: { @@ -33,132 +21,15 @@ const wrapper = ({ children }: { children: ReactNode }) => ( describe("useNamedQuery", () => { beforeEach(async () => await wipeFirestore()); - test("returns correct data for empty collection", async () => { - const collectionRef = collection(firestore, "tests"); - + test("returns correct data for an existing named query", async () => { const { result } = renderHook( () => - useNamedQuery(collectionRef, { + useNamedQuery(firestore, "emptyCollectionQuery", { queryKey: ["named", "empty"], }), { wrapper } ); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data?.empty).toBe(true); - expect(result.current.data?.docs.length).toBe(0); - }); - - test("returns correct data for non-empty collection", async () => { - const collectionRef = collection(firestore, "tests"); - - await addDoc(collectionRef, { value: 10 }); - await addDoc(collectionRef, { value: 20 }); - - const { result } = renderHook( - () => - useNamedQuery(collectionRef, { - queryKey: ["named", "non-empty"], - }), - { wrapper } - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data?.empty).toBe(false); - expect(result.current.data?.docs.length).toBe(2); - }); - - test("handles complex queries", async () => { - const collectionRef = collection(firestore, "tests"); - - await addDoc(collectionRef, { category: "A", value: 10 }); - await addDoc(collectionRef, { category: "B", value: 20 }); - await addDoc(collectionRef, { category: "A", value: 30 }); - - const complexQuery = query(collectionRef, where("category", "==", "A")); - - const { result } = renderHook( - () => - useNamedQuery(complexQuery, { - queryKey: ["named", "complex"], - }), - { wrapper } - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data?.empty).toBe(false); - expect(result.current.data?.docs.length).toBe(2); - }); - - test("handles restricted collections appropriately", async () => { - const restrictedCollectionRef = collection( - firestore, - "restrictedCollection" - ); - - const { result } = renderHook( - () => - useNamedQuery(restrictedCollectionRef, { - queryKey: ["named", "restricted"], - }), - { wrapper } - ); - - await waitFor(() => expect(result.current.isError).toBe(true)); - - expectFirestoreError(result.current.error, "permission-denied"); - }); - - test("uses custom transform function", async () => { - const collectionRef = collection(firestore, "tests"); - - await addDoc(collectionRef, { value: 10 }); - await addDoc(collectionRef, { value: 20 }); - - const customTransform = (snapshot: QuerySnapshot) => { - return snapshot.docs?.map((doc) => doc.data().value); - }; - - const { result } = renderHook( - () => - useNamedQuery(collectionRef, { - queryKey: ["named", "transform"], - firestore: { - transform: customTransform, - }, - }), - { wrapper } - ); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect(result?.current?.data).toEqual([10, 20]); - }); - - test("returns pending state initially", async () => { - const collectionRef = collection(firestore, "tests"); - - await addDoc(collectionRef, { value: 10 }); - - const { result } = renderHook( - () => - useNamedQuery(collectionRef, { - queryKey: ["named", "pending"], - }), - { wrapper } - ); - - // Initially isPending should be true - expect(result.current.isPending).toBe(true); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data?.empty).toBe(false); - expect(result.current.data?.docs.length).toBe(1); + await waitFor(async () => {}); }); }); diff --git a/packages/react/src/firestore/useNamedQuery.ts b/packages/react/src/firestore/useNamedQuery.ts index 6cff2ab3..1cbf0a1d 100644 --- a/packages/react/src/firestore/useNamedQuery.ts +++ b/packages/react/src/firestore/useNamedQuery.ts @@ -1,35 +1,31 @@ import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; import { type FirestoreError, - getDocs, type Query, - type QuerySnapshot, type DocumentData, + namedQuery, + type Firestore, } from "firebase/firestore"; type FirestoreUseQueryOptions = Omit< UseQueryOptions, "queryFn" -> & { - firestore?: { - transform?: (snapshot: QuerySnapshot) => TData; - }; -}; +>; -export function useNamedQuery>( - query: Query, - options: FirestoreUseQueryOptions +export function useNamedQuery< + AppModelType = DocumentData, + DbModelType extends DocumentData = DocumentData +>( + firestore: Firestore, + name: string, + options: FirestoreUseQueryOptions ) { - const { firestore, ...queryOptions } = options; - - return useQuery({ - ...queryOptions, + return useQuery({ + ...options, queryFn: async () => { - const snapshot = await getDocs(query); - if (firestore?.transform) { - return firestore.transform(snapshot); - } - return snapshot as TData; + const snapshot = await namedQuery(firestore, name); + + return snapshot; }, }); } From 085c66f2eddb520bc386e31cef5a53a360fbb937 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Mon, 30 Sep 2024 17:13:44 +0300 Subject: [PATCH 3/3] refactor(firestore): remove unnecessary promises being created in useNamedQuery --- packages/react/src/firestore/useNamedQuery.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/react/src/firestore/useNamedQuery.ts b/packages/react/src/firestore/useNamedQuery.ts index 1cbf0a1d..7579230d 100644 --- a/packages/react/src/firestore/useNamedQuery.ts +++ b/packages/react/src/firestore/useNamedQuery.ts @@ -22,10 +22,6 @@ export function useNamedQuery< ) { return useQuery({ ...options, - queryFn: async () => { - const snapshot = await namedQuery(firestore, name); - - return snapshot; - }, + queryFn: () => namedQuery(firestore, name), }); }