From 1d9b3cb84e8901ea3f4e23d6b2fef95b37adcedc Mon Sep 17 00:00:00 2001 From: David-patrick-chuks <161928481+David-patrick-chuks@users.noreply.github.com> Date: Wed, 24 Jun 2026 05:28:06 +0100 Subject: [PATCH] Refactor data hooks to follow React hook rules. Split factory-style feature hooks into direct use* query hooks with shared query key helpers, and update guild detail and access check screens to call them at render time. Closes #15 --- app/access-check.tsx | 8 +-- app/guilds.tsx | 9 --- app/guilds/[guildId].tsx | 39 +++++----- src/features/access/useAccessCheck.ts | 31 -------- src/features/access/useAccessCheckResult.ts | 11 +++ src/features/guilds/useGuild.ts | 15 ++++ src/features/guilds/useGuildConfig.ts | 11 +++ src/features/guilds/useGuildRoles.ts | 11 +++ src/features/guilds/useGuilds.ts | 52 -------------- src/features/membership/useMembership.ts | 60 ++++------------ src/features/membership/useUserRoles.ts | 15 ++++ src/lib/queryKeys.ts | 22 ++++++ tests/hooks/queryKeys.test.ts | 42 +++++++++++ tests/hooks/useGuild.hook.test.ts | 80 +++++++++++++++++++++ 14 files changed, 240 insertions(+), 166 deletions(-) delete mode 100644 src/features/access/useAccessCheck.ts create mode 100644 src/features/access/useAccessCheckResult.ts create mode 100644 src/features/guilds/useGuild.ts create mode 100644 src/features/guilds/useGuildConfig.ts create mode 100644 src/features/guilds/useGuildRoles.ts delete mode 100644 src/features/guilds/useGuilds.ts create mode 100644 src/features/membership/useUserRoles.ts create mode 100644 src/lib/queryKeys.ts create mode 100644 tests/hooks/queryKeys.test.ts create mode 100644 tests/hooks/useGuild.hook.test.ts diff --git a/app/access-check.tsx b/app/access-check.tsx index ee705c7..54db4d3 100644 --- a/app/access-check.tsx +++ b/app/access-check.tsx @@ -5,7 +5,7 @@ import React, { useState } from "react"; // GuildPass Mobile: Pull in react-native, expo, or external state libraries. import { useWallet } from "../src/features/wallet/useWallet"; // GuildPass Mobile: Import package module dependencies. -import { useAccessCheck } from "../src/features/access/useAccessCheck"; +import { useAccessCheckResult } from "../src/features/access/useAccessCheckResult"; // GuildPass Mobile: Pull in react-native, expo, or external state libraries. import { AppHeader } from "../src/components/AppHeader"; // GuildPass Mobile: Import package module dependencies. @@ -38,13 +38,13 @@ export default function AccessCheck() { } | null>(null); // GuildPass Mobile: Local UI-scoped constant or state representation. - const { checkAccess } = useAccessCheck(); - // GuildPass Mobile: Variable binding and property initialization. const { data: result, isLoading, error, - } = checkAccess(checkParams || { walletAddress: "", guildId: "", resourceId: "" }); + } = useAccessCheckResult( + checkParams ?? { walletAddress: "", guildId: "", resourceId: "" }, + ); // GuildPass Mobile: Local UI-scoped constant or state representation. const handleCheck = () => { diff --git a/app/guilds.tsx b/app/guilds.tsx index 59e2bdd..5813659 100644 --- a/app/guilds.tsx +++ b/app/guilds.tsx @@ -2,11 +2,6 @@ import { View, FlatList } from "react-native"; // GuildPass Mobile: Pull in react-native, expo, or external state libraries. import { useRouter } from "expo-router"; -// GuildPass Mobile: Import package module dependencies. -import { useWallet } from "../src/features/wallet/useWallet"; -// GuildPass Mobile: Pull in react-native, expo, or external state libraries. -import { useMembership } from "../src/features/membership/useMembership"; -// GuildPass Mobile: Import package module dependencies. import { AppHeader } from "../src/components/AppHeader"; // GuildPass Mobile: Pull in react-native, expo, or external state libraries. import { GuildCard } from "../src/components/GuildCard"; @@ -23,10 +18,6 @@ import React from "react"; export default function Guilds() { // GuildPass Mobile: Variable binding and property initialization. const router = useRouter(); - // GuildPass Mobile: Local UI-scoped constant or state representation. - const { walletAddress } = useWallet(); - // GuildPass Mobile: Variable binding and property initialization. - const { getMembership } = useMembership(walletAddress); // In a real app, you would fetch all guilds. // For MVP, we'll show a few example guilds that the user can explore. diff --git a/app/guilds/[guildId].tsx b/app/guilds/[guildId].tsx index 239f293..0c4ef91 100644 --- a/app/guilds/[guildId].tsx +++ b/app/guilds/[guildId].tsx @@ -5,8 +5,10 @@ import { useLocalSearchParams } from "expo-router"; // GuildPass Mobile: Pull in react-native, expo, or external state libraries. import { useWallet } from "../../src/features/wallet/useWallet"; // GuildPass Mobile: Import package module dependencies. -import { useGuilds } from "../../src/features/guilds/useGuilds"; +import { useGuild } from "../../src/features/guilds/useGuild"; // GuildPass Mobile: Pull in react-native, expo, or external state libraries. +import { useGuildRoles } from "../../src/features/guilds/useGuildRoles"; +// GuildPass Mobile: Import package module dependencies. import { useMembership } from "../../src/features/membership/useMembership"; // GuildPass Mobile: Import package module dependencies. import { AppHeader } from "../../src/components/AppHeader"; @@ -23,37 +25,28 @@ import React from "react"; // GuildPass Mobile: Core mobile screen or hook export definition. export default function GuildDetail() { - // GuildPass Mobile: Local UI-scoped constant or state representation. const { guildId } = useLocalSearchParams<{ guildId: string }>(); - // GuildPass Mobile: Variable binding and property initialization. const { walletAddress } = useWallet(); - // GuildPass Mobile: Local UI-scoped constant or state representation. - const { getGuild, getRoles } = useGuilds(); - // GuildPass Mobile: Variable binding and property initialization. - const { getMembership } = useMembership(walletAddress); - // GuildPass Mobile: Local UI-scoped constant or state representation. - const { data: guild, isLoading: guildLoading, error: guildError } = getGuild(guildId); - // GuildPass Mobile: Variable binding and property initialization. - const { data: membership, isLoading: memLoading } = getMembership(guildId); - // GuildPass Mobile: Local UI-scoped constant or state representation. - const { data: roles, isLoading: rolesLoading } = getRoles(guildId); + const { + data: guild, + isLoading: guildLoading, + error: guildError, + } = useGuild(guildId ?? ""); + const { data: membership, isLoading: memLoading } = useMembership( + walletAddress, + guildId ?? "", + ); + const { data: roles, isLoading: rolesLoading } = useGuildRoles(guildId ?? ""); - // GuildPass Mobile: Validate screen variables or params before routing. if (guildLoading || memLoading || rolesLoading) { - // GuildPass Mobile: Return evaluated JSX layout or callback response. return ; - // GuildPass Mobile: Exit functional execution container scope block. } - // GuildPass Mobile: Evaluate branch condition check for UI guards. if (guildError || !guild) { - // GuildPass Mobile: Terminate block execution context and send back value. return ; - // GuildPass Mobile: Exit functional execution container scope block. } - // GuildPass Mobile: Return evaluated JSX layout or callback response. return ( @@ -80,7 +73,10 @@ export default function GuildDetail() { Your Membership - + Status ); - // GuildPass Mobile: Exit functional execution container scope block. } diff --git a/src/features/access/useAccessCheck.ts b/src/features/access/useAccessCheck.ts deleted file mode 100644 index 4f31727..0000000 --- a/src/features/access/useAccessCheck.ts +++ /dev/null @@ -1,31 +0,0 @@ -// GuildPass Mobile: Pull in react-native, expo, or external state libraries. -import { useQuery } from "@tanstack/react-query"; -// GuildPass Mobile: Import package module dependencies. -import { guildPassClient } from "../../lib/guildpassClient"; - -// GuildPass Mobile: Exposed interface structure for local navigation layouts. -export const useAccessCheck = () => { - // GuildPass Mobile: Variable binding and property initialization. - const checkAccess = (params: { - walletAddress: string; - guildId: string; - resourceId: string; - // GuildPass Mobile: Enter functional execution container scope block. - }) => { - // GuildPass Mobile: Terminate block execution context and send back value. - return useQuery({ - queryKey: ["access-check", params], - queryFn: () => guildPassClient.access.checkAccess(params), - enabled: !!params.walletAddress && !!params.guildId && !!params.resourceId, - // GuildPass Mobile: Exit functional execution container scope block. - }); - // GuildPass Mobile: Exit functional execution container scope block. - }; - - // GuildPass Mobile: Return evaluated JSX layout or callback response. - return { - checkAccess, - // GuildPass Mobile: Exit functional execution container scope block. - }; - // GuildPass Mobile: Exit functional execution container scope block. -}; diff --git a/src/features/access/useAccessCheckResult.ts b/src/features/access/useAccessCheckResult.ts new file mode 100644 index 0000000..348fafd --- /dev/null +++ b/src/features/access/useAccessCheckResult.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { guildPassClient } from "../../lib/guildpassClient"; +import { accessCheckKeys, type AccessCheckParams } from "../../lib/queryKeys"; + +export function useAccessCheckResult(params: AccessCheckParams) { + return useQuery({ + queryKey: accessCheckKeys.detail(params), + queryFn: () => guildPassClient.access.checkAccess(params), + enabled: !!params.walletAddress && !!params.guildId && !!params.resourceId, + }); +} diff --git a/src/features/guilds/useGuild.ts b/src/features/guilds/useGuild.ts new file mode 100644 index 0000000..b8aab1a --- /dev/null +++ b/src/features/guilds/useGuild.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { guildPassClient } from "../../lib/guildpassClient"; +import { guildKeys } from "../../lib/queryKeys"; + +export function createGuildQueryOptions(guildId: string) { + return { + queryKey: guildKeys.detail(guildId), + queryFn: () => guildPassClient.guilds.getGuild({ guildId }), + enabled: !!guildId, + } as const; +} + +export function useGuild(guildId: string) { + return useQuery(createGuildQueryOptions(guildId)); +} diff --git a/src/features/guilds/useGuildConfig.ts b/src/features/guilds/useGuildConfig.ts new file mode 100644 index 0000000..30de569 --- /dev/null +++ b/src/features/guilds/useGuildConfig.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { guildPassClient } from "../../lib/guildpassClient"; +import { guildKeys } from "../../lib/queryKeys"; + +export function useGuildConfig(guildId: string) { + return useQuery({ + queryKey: guildKeys.config(guildId), + queryFn: () => guildPassClient.guilds.getGuildConfig({ guildId }), + enabled: !!guildId, + }); +} diff --git a/src/features/guilds/useGuildRoles.ts b/src/features/guilds/useGuildRoles.ts new file mode 100644 index 0000000..f031c23 --- /dev/null +++ b/src/features/guilds/useGuildRoles.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { guildPassClient } from "../../lib/guildpassClient"; +import { guildKeys } from "../../lib/queryKeys"; + +export function useGuildRoles(guildId: string) { + return useQuery({ + queryKey: guildKeys.roles(guildId), + queryFn: () => guildPassClient.roles.getRoles({ guildId }), + enabled: !!guildId, + }); +} diff --git a/src/features/guilds/useGuilds.ts b/src/features/guilds/useGuilds.ts deleted file mode 100644 index 4810248..0000000 --- a/src/features/guilds/useGuilds.ts +++ /dev/null @@ -1,52 +0,0 @@ -// GuildPass Mobile: Pull in react-native, expo, or external state libraries. -import { useQuery } from "@tanstack/react-query"; -// GuildPass Mobile: Import package module dependencies. -import { guildPassClient } from "../../lib/guildpassClient"; - -// GuildPass Mobile: Core mobile screen or hook export definition. -export const useGuilds = () => { - // GuildPass Mobile: Local UI-scoped constant or state representation. - const getGuild = (guildId: string) => { - // GuildPass Mobile: Terminate block execution context and send back value. - return useQuery({ - queryKey: ["guild", guildId], - queryFn: () => guildPassClient.guilds.getGuild({ guildId }), - enabled: !!guildId, - // GuildPass Mobile: Exit functional execution container scope block. - }); - // GuildPass Mobile: Exit functional execution container scope block. - }; - - // GuildPass Mobile: Variable binding and property initialization. - const getGuildConfig = (guildId: string) => { - // GuildPass Mobile: Return evaluated JSX layout or callback response. - return useQuery({ - queryKey: ["guild-config", guildId], - queryFn: () => guildPassClient.guilds.getGuildConfig({ guildId }), - enabled: !!guildId, - // GuildPass Mobile: Exit functional execution container scope block. - }); - // GuildPass Mobile: Exit functional execution container scope block. - }; - - // GuildPass Mobile: Local UI-scoped constant or state representation. - const getRoles = (guildId: string) => { - // GuildPass Mobile: Terminate block execution context and send back value. - return useQuery({ - queryKey: ["guild-roles", guildId], - queryFn: () => guildPassClient.roles.getRoles({ guildId }), - enabled: !!guildId, - // GuildPass Mobile: Exit functional execution container scope block. - }); - // GuildPass Mobile: Exit functional execution container scope block. - }; - - // GuildPass Mobile: Return evaluated JSX layout or callback response. - return { - getGuild, - getGuildConfig, - getRoles, - // GuildPass Mobile: Exit functional execution container scope block. - }; - // GuildPass Mobile: Exit functional execution container scope block. -}; diff --git a/src/features/membership/useMembership.ts b/src/features/membership/useMembership.ts index 9a72171..29f3ed4 100644 --- a/src/features/membership/useMembership.ts +++ b/src/features/membership/useMembership.ts @@ -1,51 +1,15 @@ -// GuildPass Mobile: Pull in react-native, expo, or external state libraries. import { useQuery } from "@tanstack/react-query"; -// GuildPass Mobile: Import package module dependencies. import { guildPassClient } from "../../lib/guildpassClient"; +import { membershipKeys } from "../../lib/queryKeys"; -// GuildPass Mobile: Exported screen, component definition, or state hooks. -export const useMembership = (walletAddress: string | null) => { - // GuildPass Mobile: Variable binding and property initialization. - const getMembership = (guildId: string) => { - // GuildPass Mobile: Terminate block execution context and send back value. - return useQuery({ - queryKey: ["membership", walletAddress, guildId], - queryFn: () => - // GuildPass Mobile: Enter functional execution container scope block. - guildPassClient.membership.getMembership({ - walletAddress: walletAddress!, - guildId, - // GuildPass Mobile: Exit functional execution container scope block. - }), - enabled: !!walletAddress && !!guildId, - // GuildPass Mobile: Exit functional execution container scope block. - }); - // GuildPass Mobile: Exit functional execution container scope block. - }; - - // GuildPass Mobile: Local UI-scoped constant or state representation. - const getUserRoles = (guildId: string) => { - // GuildPass Mobile: Return evaluated JSX layout or callback response. - return useQuery({ - queryKey: ["user-roles", walletAddress, guildId], - queryFn: () => - // GuildPass Mobile: Enter functional execution container scope block. - guildPassClient.roles.getUserRoles({ - walletAddress: walletAddress!, - guildId, - // GuildPass Mobile: Exit functional execution container scope block. - }), - enabled: !!walletAddress && !!guildId, - // GuildPass Mobile: Exit functional execution container scope block. - }); - // GuildPass Mobile: Exit functional execution container scope block. - }; - - // GuildPass Mobile: Terminate block execution context and send back value. - return { - getMembership, - getUserRoles, - // GuildPass Mobile: Exit functional execution container scope block. - }; - // GuildPass Mobile: Exit functional execution container scope block. -}; +export function useMembership(walletAddress: string | null, guildId: string) { + return useQuery({ + queryKey: membershipKeys.detail(walletAddress, guildId), + queryFn: () => + guildPassClient.membership.getMembership({ + walletAddress: walletAddress!, + guildId, + }), + enabled: !!walletAddress && !!guildId, + }); +} diff --git a/src/features/membership/useUserRoles.ts b/src/features/membership/useUserRoles.ts new file mode 100644 index 0000000..757036c --- /dev/null +++ b/src/features/membership/useUserRoles.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { guildPassClient } from "../../lib/guildpassClient"; +import { membershipKeys } from "../../lib/queryKeys"; + +export function useUserRoles(walletAddress: string | null, guildId: string) { + return useQuery({ + queryKey: membershipKeys.userRoles(walletAddress, guildId), + queryFn: () => + guildPassClient.roles.getUserRoles({ + walletAddress: walletAddress!, + guildId, + }), + enabled: !!walletAddress && !!guildId, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts new file mode 100644 index 0000000..b860fc1 --- /dev/null +++ b/src/lib/queryKeys.ts @@ -0,0 +1,22 @@ +export type AccessCheckParams = { + walletAddress: string; + guildId: string; + resourceId: string; +}; + +export const guildKeys = { + detail: (guildId: string) => ["guild", guildId] as const, + config: (guildId: string) => ["guild-config", guildId] as const, + roles: (guildId: string) => ["guild-roles", guildId] as const, +}; + +export const membershipKeys = { + detail: (walletAddress: string | null, guildId: string) => + ["membership", walletAddress, guildId] as const, + userRoles: (walletAddress: string | null, guildId: string) => + ["user-roles", walletAddress, guildId] as const, +}; + +export const accessCheckKeys = { + detail: (params: AccessCheckParams) => ["access-check", params] as const, +}; diff --git a/tests/hooks/queryKeys.test.ts b/tests/hooks/queryKeys.test.ts new file mode 100644 index 0000000..545fa5b --- /dev/null +++ b/tests/hooks/queryKeys.test.ts @@ -0,0 +1,42 @@ +/** + * queryKeys – shared query key helper tests + */ + +import { describe, it, expect } from "vitest"; +import { + accessCheckKeys, + guildKeys, + membershipKeys, +} from "../../src/lib/queryKeys"; +import { TEST_WALLET_ADDRESS } from "../fixtures/membership.fixtures"; + +describe("queryKeys", () => { + it("creates stable guild query keys", () => { + expect(guildKeys.detail("guild_abc")).toStrictEqual(["guild", "guild_abc"]); + expect(guildKeys.config("guild_abc")).toStrictEqual(["guild-config", "guild_abc"]); + expect(guildKeys.roles("guild_abc")).toStrictEqual(["guild-roles", "guild_abc"]); + }); + + it("creates wallet-scoped membership query keys", () => { + expect(membershipKeys.detail(TEST_WALLET_ADDRESS, "guild_abc")).toStrictEqual([ + "membership", + TEST_WALLET_ADDRESS, + "guild_abc", + ]); + expect(membershipKeys.userRoles(TEST_WALLET_ADDRESS, "guild_abc")).toStrictEqual([ + "user-roles", + TEST_WALLET_ADDRESS, + "guild_abc", + ]); + }); + + it("creates access-check query keys from params", () => { + const params = { + walletAddress: TEST_WALLET_ADDRESS, + guildId: "guild_abc", + resourceId: "secret-channel", + }; + + expect(accessCheckKeys.detail(params)).toStrictEqual(["access-check", params]); + }); +}); diff --git a/tests/hooks/useGuild.hook.test.ts b/tests/hooks/useGuild.hook.test.ts new file mode 100644 index 0000000..c4c2e0c --- /dev/null +++ b/tests/hooks/useGuild.hook.test.ts @@ -0,0 +1,80 @@ +/** + * useGuild hook – refactored query hook tests + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { GUILD_DETAIL_FIXTURE } from "../fixtures/guild.fixtures"; +import { guildKeys } from "../../src/lib/queryKeys"; + +const getGuild = vi.fn(); + +vi.mock("../../src/lib/guildpassClient", () => ({ + guildPassClient: { + guilds: { + getGuild: (...args: unknown[]) => getGuild(...args), + }, + }, +})); + +import { createGuildQueryOptions, useGuild } from "../../src/features/guilds/useGuild"; + +describe("useGuild", () => { + beforeEach(() => { + getGuild.mockReset(); + getGuild.mockResolvedValue(GUILD_DETAIL_FIXTURE); + }); + + it("calls useQuery directly from the custom hook module", () => { + const source = readFileSync( + resolve(__dirname, "../../src/features/guilds/useGuild.ts"), + "utf8", + ); + + expect(source).toContain("return useQuery(createGuildQueryOptions(guildId));"); + expect(source).not.toMatch(/return\s*\{\s*getGuild/); + }); + + it("builds stable query options with the shared query key helper", () => { + const options = createGuildQueryOptions("guild_abc"); + + expect(options.queryKey).toStrictEqual(guildKeys.detail("guild_abc")); + expect(options.enabled).toBe(true); + }); + + it("does not enable the query when guildId is empty", () => { + const options = createGuildQueryOptions(""); + + expect(options.enabled).toBe(false); + }); + + it("fetches guild data through the refactored query options", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const result = await queryClient.fetchQuery(createGuildQueryOptions("guild_abc")); + + expect(getGuild).toHaveBeenCalledWith({ guildId: "guild_abc" }); + expect(result).toStrictEqual(GUILD_DETAIL_FIXTURE); + }); + + it("surfaces SDK errors through the refactored query options", async () => { + getGuild.mockRejectedValueOnce(new Error("Network request failed")); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await expect(queryClient.fetchQuery(createGuildQueryOptions("guild_abc"))).rejects.toThrow( + "Network request failed", + ); + }); + + it("exports a use* hook entry point for screens", () => { + expect(typeof useGuild).toBe("function"); + expect(useGuild.name).toBe("useGuild"); + }); +});