From 18107efb91e056146643cba012cceb1d76aeb930 Mon Sep 17 00:00:00 2001 From: silentgeckoaudit3801 Date: Thu, 23 Jul 2026 16:46:10 -0600 Subject: [PATCH] Add URL-synced member filters --- apps/dashboard/app/members/page.tsx | 135 ++++++++++++++++++++++++---- apps/dashboard/test/members.test.ts | 42 +++++++++ 2 files changed, 162 insertions(+), 15 deletions(-) diff --git a/apps/dashboard/app/members/page.tsx b/apps/dashboard/app/members/page.tsx index 42fb06c..8ca8bdc 100644 --- a/apps/dashboard/app/members/page.tsx +++ b/apps/dashboard/app/members/page.tsx @@ -16,7 +16,7 @@ import { toMembersCsv } from "@/lib/members-csv"; import type { Member as MockMember } from "@/lib/mock-data"; import { canManageMembers } from "@/lib/permissions"; import type { PaginatedResult } from "@/lib/repositories/types"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useGuild } from "@/lib/guild/GuildProvider"; import { guildFetch } from "@/lib/guild/api"; import { getMembersForGuild } from "@/lib/data/guild-scoped"; @@ -25,7 +25,7 @@ import { invalidateAfterMutation, queryKeys, } from "@/lib/cache/query-cache"; -import { useQueryInvalidation } from "@/lib/cache/use-query-invalidation"; +import { useQueryInvalidation } from "@/lib/cache/use-query-invalidation";`nimport { usePathname, useRouter, useSearchParams } from "next/navigation"; type ListState = "loading" | "loaded" | "unsupported" | "error"; @@ -44,11 +44,30 @@ const emptyPage: PaginatedResult = { hasPreviousPage: false, }; +function readStatusFilter(value: string | null): MemberStatusFilter { + return value === "active" || value === "inactive" || value === "pending" ? value : "all"; +} + +function readRoleFilter(value: string | null): MemberRoleFilter { + return value && MEMBER_ROLES.includes(value as (typeof MEMBER_ROLES)[number]) + ? (value as MemberRoleFilter) + : "all"; +} + +function readPageFilter(value: string | null): number { + if (!value) return 1; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1; +} + export default function MembersPage() { const session = useSession(); const canWrite = canManageMembers(session, session.activeGuildId); const apiMode = getClientApiMode(); - const { guildId, guild } = useGuild(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { guildId, guild, guilds, setGuildId } = useGuild(); const seedMembers = getMembersForGuild(guildId); const [members, setMembers] = useState(seedMembers.slice(0, PAGE_SIZE)); @@ -60,10 +79,10 @@ export default function MembersPage() { }); const [pendingIds, setPendingIds] = useState>(new Set()); const [listState, setListState] = useState("loading"); - const [search, setSearch] = useState(""); - const [status, setStatus] = useState("all"); - const [role, setRole] = useState("all"); - const [page, setPage] = useState(1); + const [search, setSearch] = useState(() => searchParams.get("search") ?? ""); + const [status, setStatus] = useState(() => readStatusFilter(searchParams.get("status"))); + const [role, setRole] = useState(() => readRoleFilter(searchParams.get("role"))); + const [page, setPage] = useState(() => readPageFilter(searchParams.get("page"))); const debouncedSearch = useDebouncedValue(search, 250); const previousMembersRef = useRef(members); const membersQueryKey = useMemo( @@ -82,6 +101,78 @@ export default function MembersPage() { const [inviteLoading, setInviteLoading] = useState(false); const [form, setForm] = useState({ name: "", wallet: "" }); + const updateFilterQuery = useCallback( + (updates: { search?: string; status?: MemberStatusFilter; role?: MemberRoleFilter; guild?: string; page?: number | null }) => { + const next = new URLSearchParams(searchParams.toString()); + + if (updates.search !== undefined) { + const value = updates.search.trim(); + value ? next.set("search", value) : next.delete("search"); + } + if (updates.status !== undefined) { + updates.status === "all" ? next.delete("status") : next.set("status", updates.status); + } + if (updates.role !== undefined) { + updates.role === "all" ? next.delete("role") : next.set("role", updates.role); + } + if (updates.guild !== undefined) { + updates.guild ? next.set("guild", updates.guild) : next.delete("guild"); + } + if (updates.page !== undefined) { + updates.page && updates.page > 1 ? next.set("page", String(updates.page)) : next.delete("page"); + } + + const query = next.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, + [pathname, router, searchParams] + ); + + const handleSearchChange = (value: string) => { + setSearch(value); + setPage(1); + updateFilterQuery({ search: value, page: null }); + }; + + const handleStatusChange = (value: MemberStatusFilter) => { + setStatus(value); + setPage(1); + updateFilterQuery({ status: value, page: null }); + }; + + const handleRoleChange = (value: MemberRoleFilter) => { + setRole(value); + setPage(1); + updateFilterQuery({ role: value, page: null }); + }; + + const handleGuildFilterChange = (value: string) => { + setGuildId(value); + setPage(1); + updateFilterQuery({ guild: value, page: null }); + }; + + const handlePageChange = (value: number) => { + const nextPage = Math.max(1, value); + setPage(nextPage); + updateFilterQuery({ page: nextPage === 1 ? null : nextPage }); + }; + + useEffect(() => { + const nextSearch = searchParams.get("search") ?? ""; + const nextStatus = readStatusFilter(searchParams.get("status")); + const nextRole = readRoleFilter(searchParams.get("role")); + const nextPage = readPageFilter(searchParams.get("page")); + const nextGuildId = searchParams.get("guild"); + + if (nextSearch !== search) setSearch(nextSearch); + if (nextStatus !== status) setStatus(nextStatus); + if (nextRole !== role) setRole(nextRole); + if (nextPage !== page) setPage(nextPage); + if (nextGuildId && nextGuildId !== guildId && guilds.some((candidate) => candidate.id === nextGuildId)) { + setGuildId(nextGuildId); + } + }, [guildId, guilds, page, role, search, searchParams, setGuildId, status]); useEffect(() => { setPage(1); }, [debouncedSearch, role, status, guildId]); @@ -285,22 +376,36 @@ export default function MembersPage() { {listState !== "unsupported" && (
{/* Search + Status row */} -
+
+