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