Fix member selection checkboxes showing for all rows regardless of status

Previously checkboxes only appeared for users with status NONE (Not Invited),
hiding them for INVITED/ACTIVE members and making "Select all" confusing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-14 18:38:54 +01:00
parent c321d4711e
commit c634982835
1 changed files with 88 additions and 95 deletions

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useCallback, useEffect, useMemo } from 'react' import { useState, useCallback, useEffect, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useSearchParams, usePathname } from 'next/navigation' import { useSearchParams, usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@ -138,20 +138,20 @@ export function MembersContent() {
const { data: currentUser } = trpc.user.me.useQuery() const { data: currentUser } = trpc.user.me.useQuery()
const currentUserRole = currentUser?.role as RoleValue | undefined const currentUserRole = currentUser?.role as RoleValue | undefined
const { data, isLoading } = trpc.user.list.useQuery({ const { data, isLoading } = trpc.user.list.useQuery({
roles: roles, roles: roles,
search: search || undefined, search: search || undefined,
page, page,
perPage: 20, perPage: 20,
}) })
const invitableIdsQuery = trpc.user.listInvitableIds.useQuery( const invitableIdsQuery = trpc.user.listInvitableIds.useQuery(
{ {
roles: roles, roles: roles,
search: search || undefined, search: search || undefined,
}, },
{ enabled: false } { enabled: false }
) )
const utils = trpc.useUtils() const utils = trpc.useUtils()
@ -171,9 +171,8 @@ export function MembersContent() {
}, },
}) })
// Users on the current page that are selectable (status NONE)
const selectableUsers = useMemo( const selectableUsers = useMemo(
() => (data?.users ?? []).filter((u) => u.status === 'NONE'), () => data?.users ?? [],
[data?.users] [data?.users]
) )
@ -195,7 +194,7 @@ export function MembersContent() {
}) })
}, []) }, [])
const toggleAll = useCallback(() => { const toggleAll = useCallback(() => {
if (allSelectableSelected) { if (allSelectableSelected) {
// Deselect all on this page // Deselect all on this page
setSelectedIds((prev) => { setSelectedIds((prev) => {
@ -215,22 +214,22 @@ export function MembersContent() {
return next return next
}) })
} }
}, [allSelectableSelected, selectableUsers]) }, [allSelectableSelected, selectableUsers])
const selectAllMatching = useCallback(async () => { const selectAllMatching = useCallback(async () => {
const result = await invitableIdsQuery.refetch() const result = await invitableIdsQuery.refetch()
const ids = result.data?.userIds ?? [] const ids = result.data?.userIds ?? []
if (ids.length === 0) { if (ids.length === 0) {
toast.info('No invitable members match the current filter') toast.info('No invitable members match the current filter')
return return
} }
setSelectedIds(new Set(ids)) setSelectedIds(new Set(ids))
toast.success(`Selected ${ids.length} matching members`) toast.success(`Selected ${ids.length} matching members`)
}, [invitableIdsQuery]) }, [invitableIdsQuery])
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
setSelectedIds(new Set()) setSelectedIds(new Set())
}, []) }, [])
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
updateParams({ tab: value === 'all' ? null : value, page: '1' }) updateParams({ tab: value === 'all' ? null : value, page: '1' })
@ -279,42 +278,42 @@ export function MembersContent() {
</Tabs> </Tabs>
{/* Content */} {/* Content */}
{isLoading ? ( {isLoading ? (
<MembersSkeleton /> <MembersSkeleton />
) : data && data.users.length > 0 ? ( ) : data && data.users.length > 0 ? (
<> <>
{/* Bulk selection controls */} {/* Bulk selection controls */}
<Card> <Card>
<CardContent className="py-3 flex flex-wrap items-center justify-between gap-2"> <CardContent className="py-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Selection persists across pages and filters. Selection persists across pages and filters.
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={selectAllMatching} onClick={selectAllMatching}
disabled={invitableIdsQuery.isFetching} disabled={invitableIdsQuery.isFetching}
> >
{invitableIdsQuery.isFetching ? ( {invitableIdsQuery.isFetching ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null} ) : null}
Select all matching Select all matching
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={clearSelection} onClick={clearSelection}
disabled={selectedIds.size === 0} disabled={selectedIds.size === 0}
> >
Clear selection Clear selection
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Desktop table */} {/* Desktop table */}
<Card className="hidden md:block"> <Card className="hidden md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -323,7 +322,7 @@ export function MembersContent() {
<Checkbox <Checkbox
checked={allSelectableSelected ? true : someSelectableSelected ? 'indeterminate' : false} checked={allSelectableSelected ? true : someSelectableSelected ? 'indeterminate' : false}
onCheckedChange={toggleAll} onCheckedChange={toggleAll}
aria-label="Select all uninvited members" aria-label="Select all members"
/> />
)} )}
</TableHead> </TableHead>
@ -340,15 +339,11 @@ export function MembersContent() {
{data.users.map((user) => ( {data.users.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell> <TableCell>
{user.status === 'NONE' ? ( <Checkbox
<Checkbox checked={selectedIds.has(user.id)}
checked={selectedIds.has(user.id)} onCheckedChange={() => toggleUser(user.id)}
onCheckedChange={() => toggleUser(user.id)} aria-label={`Select ${user.name || user.email}`}
aria-label={`Select ${user.name || user.email}`} />
/>
) : (
<span />
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -438,14 +433,12 @@ export function MembersContent() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user.status === 'NONE' && ( <Checkbox
<Checkbox checked={selectedIds.has(user.id)}
checked={selectedIds.has(user.id)} onCheckedChange={() => toggleUser(user.id)}
onCheckedChange={() => toggleUser(user.id)} aria-label={`Select ${user.name || user.email}`}
aria-label={`Select ${user.name || user.email}`} className="mt-1"
className="mt-1" />
/>
)}
<UserAvatar <UserAvatar
user={user} user={user}
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined} avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
@ -573,13 +566,13 @@ export function MembersContent() {
)} )}
Invite Selected Invite Selected
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={clearSelection} onClick={clearSelection}
disabled={bulkInvite.isPending} disabled={bulkInvite.isPending}
className="gap-1.5" className="gap-1.5"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
Clear Clear
</Button> </Button>