From 01aca00499b84fe9dc30ac0aa4f55b386b22d155 Mon Sep 17 00:00:00 2001 From: nkechiogbuji Date: Tue, 28 Jul 2026 14:46:46 +0100 Subject: [PATCH] Add search and filter controls to the admin member list --- app/[communitySlug]/admin/members/page.tsx | 233 ++------------- components/admin/member-list.tsx | 266 ++++++++++++++++++ lib/api/mappers.ts | 4 + lib/api/mock.ts | 36 ++- lib/api/types.ts | 29 +- lib/hooks/useDebouncedValue.ts | 17 ++ lib/members/filter-members.ts | 38 +++ test/e2e/admin-members-search-filters.spec.ts | 68 +++++ test/fixtures/openapi.json | 3 + test/member-filters.test.ts | 167 +++++++++++ test/mock-members-variety.test.ts | 45 +++ test/tsconfig.json | 7 +- 12 files changed, 667 insertions(+), 246 deletions(-) create mode 100644 components/admin/member-list.tsx create mode 100644 lib/hooks/useDebouncedValue.ts create mode 100644 lib/members/filter-members.ts create mode 100644 test/e2e/admin-members-search-filters.spec.ts create mode 100644 test/member-filters.test.ts create mode 100644 test/mock-members-variety.test.ts diff --git a/app/[communitySlug]/admin/members/page.tsx b/app/[communitySlug]/admin/members/page.tsx index 9b82645..5cf6bda 100644 --- a/app/[communitySlug]/admin/members/page.tsx +++ b/app/[communitySlug]/admin/members/page.tsx @@ -2,7 +2,7 @@ import { useAccount } from "wagmi"; import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getApi, type MemberRow, type Role, type MembershipTier } from "@/lib/api"; +import { getApi, type MemberRow, type Role } from "@/lib/api"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -22,7 +22,6 @@ import { DeniedState, safeErrorMessage, } from "@/components/ui/api-states"; -import { usePagination } from "@/lib/hooks/usePagination"; import { applyOptimisticRole, applyOptimisticRemoveRole, @@ -30,9 +29,9 @@ import { import { roleRemovalConfirmationMessage } from "@/lib/api/role-removal"; import { AddressText } from "@/components/wallet/address-text"; import { isWalletAddress, normalizeAddress } from "@/lib/wallet/address"; -import { BulkActionToolbar, type BulkResult } from "@/components/ui/bulk-action-toolbar"; +import type { BulkResult } from "@/components/ui/bulk-action-toolbar"; +import { MemberList } from "@/components/admin/member-list"; import { Users } from "lucide-react"; -import Link from "next/link"; import { features } from "@/lib/features"; type AssignRoleInput = { @@ -173,21 +172,6 @@ export default function MembersPage() { const params = useParams(); const communitySlug = (params?.communitySlug as string) || 'guildpass-demo'; - // Filter state - const [searchQuery, setSearchQuery] = useState('') - const [roleFilter, setRoleFilter] = useState('all') - const [tierFilter, setTierFilter] = useState('all') - const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all') - const [pageSize, setPageSize] = useState(25) - - const resetFilters = () => { - setSearchQuery('') - setRoleFilter('all') - setTierFilter('all') - setStatusFilter('all') - setPageSize(25) - } - const { data, isLoading, @@ -198,14 +182,16 @@ export default function MembersPage() { isFetchingNextPage, refetch, } = useInfiniteQuery({ - queryKey: [...queryKeys.members.all(communitySlug), { searchQuery }], + queryKey: queryKeys.members.all(communitySlug), queryFn: async ({ pageParam }) => { const api = getApi(address, authSession?.token, communitySlug); const limit = 100; + // Search/role/tier filtering happens entirely client-side (see + // components/admin/member-list.tsx) — this fetch is purely + // cursor-driven and never re-runs in response to filter changes. const res = await api.listMembers({ cursor: pageParam, limit, - filter: searchQuery || undefined, }); if (Array.isArray(res)) { @@ -254,42 +240,7 @@ export default function MembersPage() { return data?.pages.flatMap((page) => page.members) ?? []; }, [data]); - const isFallbackMode = data?.pages[0]?.isFallback ?? false; - - const filteredMembers = useMemo(() => { - return allFetchedMembers.filter((m) => { - const matchesSearch = - !isFallbackMode || - !searchQuery || - m.address.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesRole = roleFilter === 'all' || m.roles.includes(roleFilter); - const matchesTier = tierFilter === 'all' || m.tier === tierFilter; - const matchesStatus = - statusFilter === 'all' || - (statusFilter === 'active' && m.active) || - (statusFilter === 'inactive' && !m.active); - - return matchesSearch && matchesRole && matchesTier && matchesStatus; - }); - }, [allFetchedMembers, isFallbackMode, searchQuery, roleFilter, tierFilter, statusFilter]); - - const { - paginatedItems, - currentPage, - totalPages, - nextPage, - prevPage, - setPage, - setCurrentPage, - } = usePagination(filteredMembers, pageSize); - - useEffect(() => { - setCurrentPage(1); - }, [searchQuery, roleFilter, tierFilter, statusFilter, pageSize, setCurrentPage]); - - const isFiltered = searchQuery || roleFilter !== 'all' || tierFilter !== 'all' || statusFilter !== 'all' const hasAnyMembers = allFetchedMembers.length > 0 - const hasVisibleMembers = filteredMembers.length > 0 const { mutate, @@ -353,7 +304,6 @@ export default function MembersPage() { } setPendingAssignment(null); addToast({ - tone: "default", tone: "warning", title: "Approval Required", description: `Assignment of ${input.role} to ${input.address.slice(0, 6)}…${input.address.slice(-4)} has been proposed for approval.`, @@ -513,7 +463,6 @@ export default function MembersPage() { } setPendingAssignment(null); addToast({ - tone: "default", tone: "warning", title: "Approval Required", description: `Removal of ${input.role} from ${input.address.slice(0, 6)}…${input.address.slice(-4)} has been proposed for approval.`, @@ -637,28 +586,6 @@ export default function MembersPage() { }); }; - const toggleSelectAll = () => { - const pageAddresses = paginatedItems.map((m) => m.address); - const allSelected = pageAddresses.every((a) => - selectedAddresses.has(a), - ); - if (allSelected) { - // Deselect all on this page - setSelectedAddresses((prev) => { - const next = new Set(prev); - pageAddresses.forEach((a) => next.delete(a)); - return next; - }); - } else { - // Select all on this page - setSelectedAddresses((prev) => { - const next = new Set(prev); - pageAddresses.forEach((a) => next.add(a)); - return next; - }); - } - }; - const clearSelection = () => { setSelectedAddresses(new Set()); setBulkResults(null); @@ -733,11 +660,6 @@ export default function MembersPage() { } }; - const pageAddresses = paginatedItems.map((m) => m.address); - const allPageSelected = - pageAddresses.length > 0 && - pageAddresses.every((a) => selectedAddresses.has(a)); - const handleScrollToBottom = () => { if (hasNextPage && !isFetchingNextPage) { fetchNextPage(); @@ -872,7 +794,6 @@ export default function MembersPage() { {log.timestamp.toLocaleTimeString()} - {log.status === 'pending' && Pending} {log.status === 'pending' && Pending} {log.status === 'success' && Success} {log.status === 'error' && ( @@ -888,11 +809,6 @@ export default function MembersPage() { - {hasAnyMembers && hasVisibleMembers && ( - - Member List - - )} {isLoading ? ( ) : isError ? ( @@ -915,119 +831,30 @@ export default function MembersPage() { } /> - ) : !hasVisibleMembers ? ( -