+314
apps/client/src/features/organizations/components/CollapsibleOrganizationTable.tsx
+314
apps/client/src/features/organizations/components/CollapsibleOrganizationTable.tsx
···
1
+
import {
2
+
ChevronDownIcon,
3
+
Table,
4
+
TableBody,
5
+
TableCell,
6
+
TableHeader,
7
+
TableHeaderCell,
8
+
TableRow,
9
+
TextInput,
10
+
} from "@cv/ui";
11
+
import { useVirtualizer } from "@tanstack/react-virtual";
12
+
import { useEffect, useRef, useState } from "react";
13
+
import {
14
+
type MeOrganizationsQuery,
15
+
useInfiniteOrganizationMembersQuery,
16
+
} from "@/generated/graphql";
17
+
import { OrganizationMemberRow } from "./OrganizationMemberRow";
18
+
19
+
type Organization = NonNullable<
20
+
NonNullable<MeOrganizationsQuery["me"]>["organizations"]
21
+
>[0];
22
+
23
+
interface CollapsibleOrganizationTableProps {
24
+
organization: Organization;
25
+
defaultExpanded?: boolean;
26
+
}
27
+
28
+
export const CollapsibleOrganizationTable = ({
29
+
organization,
30
+
defaultExpanded = false,
31
+
}: CollapsibleOrganizationTableProps) => {
32
+
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
33
+
const [searchTerm, setSearchTerm] = useState("");
34
+
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
35
+
const parentRef = useRef<HTMLDivElement>(null);
36
+
37
+
// Debounce search term to avoid too many API calls
38
+
useEffect(() => {
39
+
const timer = setTimeout(() => {
40
+
setDebouncedSearchTerm(searchTerm);
41
+
}, 300);
42
+
43
+
return () => clearTimeout(timer);
44
+
}, [searchTerm]);
45
+
46
+
// Only fetch members when expanded using infinite scroll
47
+
const {
48
+
data: membersData,
49
+
isLoading: membersLoading,
50
+
error: membersError,
51
+
fetchNextPage,
52
+
hasNextPage,
53
+
isFetchingNextPage,
54
+
} = useInfiniteOrganizationMembersQuery(
55
+
{
56
+
organizationId: organization.id,
57
+
first: 20,
58
+
searchTerm: debouncedSearchTerm.trim() || undefined,
59
+
},
60
+
{
61
+
enabled: isExpanded,
62
+
initialPageParam: { after: null },
63
+
getNextPageParam: (lastPage) => {
64
+
const pageInfo = lastPage.organization?.memberships?.pageInfo;
65
+
return pageInfo?.hasNextPage
66
+
? { after: pageInfo.endCursor }
67
+
: undefined;
68
+
},
69
+
},
70
+
);
71
+
72
+
// Flatten all pages into a single array of members
73
+
const members =
74
+
membersData?.pages
75
+
?.flatMap(
76
+
(page) =>
77
+
page.organization?.memberships?.edges?.map((edge) => edge.node) || [],
78
+
)
79
+
.filter(
80
+
(member): member is NonNullable<typeof member> => member != null,
81
+
) || [];
82
+
83
+
// Setup virtualizer for performance
84
+
const virtualizer = useVirtualizer({
85
+
count: members.length,
86
+
getScrollElement: () => parentRef.current,
87
+
estimateSize: () => 60,
88
+
overscan: 3,
89
+
});
90
+
91
+
// Auto-load more when scrolling near the end
92
+
const virtualItems = virtualizer.getVirtualItems();
93
+
94
+
useEffect(() => {
95
+
const [lastItem] = [...virtualItems].reverse();
96
+
97
+
if (!(lastItem && hasNextPage) || isFetchingNextPage) {
98
+
return;
99
+
}
100
+
101
+
// Load more when we're within 3 items of the end
102
+
if (lastItem.index >= members.length - 3) {
103
+
fetchNextPage();
104
+
}
105
+
}, [
106
+
hasNextPage,
107
+
fetchNextPage,
108
+
members.length,
109
+
isFetchingNextPage,
110
+
virtualItems,
111
+
]);
112
+
113
+
const toggleExpanded = () => {
114
+
setIsExpanded(!isExpanded);
115
+
};
116
+
117
+
return (
118
+
<div className="w-full bg-ctp-surface0 rounded-lg border border-ctp-surface1">
119
+
{/* Organization Header - Always Visible */}
120
+
<button
121
+
type="button"
122
+
className="w-full p-6 text-left hover:bg-ctp-surface1/50 transition-colors focus:outline-none focus:ring-2 focus:ring-ctp-blue focus:ring-inset"
123
+
onClick={toggleExpanded}
124
+
aria-expanded={isExpanded}
125
+
aria-controls={`collapsible-content-${organization.id}`}
126
+
>
127
+
<div className="flex items-center justify-between">
128
+
<div className="flex-1">
129
+
<h2 className="text-xl font-semibold text-ctp-text">
130
+
{organization.name}
131
+
</h2>
132
+
{organization.description && (
133
+
<p className="mt-1 text-sm text-ctp-subtext0">
134
+
{organization.description}
135
+
</p>
136
+
)}
137
+
<div className="mt-2 text-sm text-ctp-subtext0">
138
+
{isExpanded && membersLoading ? (
139
+
"Loading members..."
140
+
) : isExpanded && membersError ? (
141
+
"Error loading members"
142
+
) : isExpanded ? (
143
+
<div className="flex items-center gap-2">
144
+
<span>
145
+
{debouncedSearchTerm.trim() ? (
146
+
<>
147
+
{members.length} member{members.length !== 1 ? "s" : ""}{" "}
148
+
found
149
+
{members.length > 0 && (
150
+
<span className="text-ctp-subtext0">
151
+
{" "}
152
+
of {organization.memberCount} total
153
+
</span>
154
+
)}
155
+
</>
156
+
) : (
157
+
<>
158
+
{members.length} of {organization.memberCount} member
159
+
{organization.memberCount !== 1 ? "s" : ""}
160
+
</>
161
+
)}
162
+
</span>
163
+
{isFetchingNextPage && (
164
+
<div className="flex items-center gap-1">
165
+
<div className="h-3 w-3 animate-spin rounded-full border-2 border-ctp-blue border-t-transparent"></div>
166
+
<span className="text-xs text-ctp-blue">
167
+
Loading more...
168
+
</span>
169
+
</div>
170
+
)}
171
+
</div>
172
+
) : (
173
+
`${organization.memberCount} member${organization.memberCount !== 1 ? "s" : ""} - Click to load`
174
+
)}
175
+
</div>
176
+
</div>
177
+
178
+
{/* Toggle Icon */}
179
+
<div className="ml-4 flex items-center gap-2 text-sm text-ctp-subtext0">
180
+
<span>{isExpanded ? "Hide Members" : "Show Members"}</span>
181
+
<ChevronDownIcon
182
+
className="h-4 w-4 transition-transform duration-200"
183
+
isOpen={isExpanded}
184
+
/>
185
+
</div>
186
+
</div>
187
+
</button>
188
+
189
+
{/* Collapsible Content */}
190
+
<div
191
+
className={`w-full overflow-hidden transition-all duration-300 ease-in-out ${
192
+
isExpanded ? "max-h-[600px] opacity-100" : "max-h-0 opacity-0"
193
+
}`}
194
+
>
195
+
<div className="w-full border-t border-ctp-surface1">
196
+
{/* Search Input */}
197
+
{isExpanded && (
198
+
<div className="p-4 border-b border-ctp-surface1">
199
+
<TextInput
200
+
placeholder="Search members by name..."
201
+
value={searchTerm}
202
+
onChange={setSearchTerm}
203
+
className="max-w-md"
204
+
/>
205
+
</div>
206
+
)}
207
+
208
+
{membersLoading ? (
209
+
<div className="text-center py-8 px-6">
210
+
<div className="text-ctp-subtext0">Loading members...</div>
211
+
</div>
212
+
) : membersError ? (
213
+
<div className="text-center py-8 px-6">
214
+
<div className="text-ctp-red mb-2">Error loading members</div>
215
+
<div className="text-sm text-ctp-subtext0">
216
+
Please try again later
217
+
</div>
218
+
</div>
219
+
) : members.length === 0 ? (
220
+
<div className="text-center py-8 px-6">
221
+
<div className="text-ctp-subtext0 mb-2">
222
+
{searchTerm.trim() ? "No members found" : "No members found"}
223
+
</div>
224
+
<div className="text-sm text-ctp-subtext1">
225
+
{debouncedSearchTerm.trim() ? (
226
+
<>No members match "{debouncedSearchTerm}"</>
227
+
) : (
228
+
"This organization has no members yet"
229
+
)}
230
+
</div>
231
+
</div>
232
+
) : (
233
+
<div className="w-full pb-4">
234
+
{/* Table with virtualization and proper table rows */}
235
+
<div className="w-full">
236
+
{/* Scrollable wrapper for virtualization */}
237
+
<div
238
+
ref={parentRef}
239
+
className="w-full max-h-[500px] overflow-y-auto"
240
+
>
241
+
<Table fullWidth>
242
+
<TableHeader>
243
+
<TableRow>
244
+
<TableHeaderCell className="w-1/4">
245
+
Member
246
+
</TableHeaderCell>
247
+
<TableHeaderCell className="w-1/6">
248
+
Role
249
+
</TableHeaderCell>
250
+
<TableHeaderCell className="w-1/4">
251
+
Current Position
252
+
</TableHeaderCell>
253
+
<TableHeaderCell className="w-1/6">
254
+
Experience
255
+
</TableHeaderCell>
256
+
<TableHeaderCell className="w-1/6">
257
+
Joined
258
+
</TableHeaderCell>
259
+
</TableRow>
260
+
</TableHeader>
261
+
262
+
<TableBody>
263
+
{/* Top spacer for rows before first visible */}
264
+
{virtualItems.length > 0 &&
265
+
virtualItems[0]?.start > 0 && (
266
+
<TableRow>
267
+
<TableCell
268
+
colSpan={5}
269
+
style={{
270
+
height: `${virtualItems[0].start}px`,
271
+
padding: 0,
272
+
}}
273
+
/>
274
+
</TableRow>
275
+
)}
276
+
277
+
{/* Virtualized rows - render only visible rows as proper tr elements */}
278
+
{virtualItems.map((virtualRow) => {
279
+
const member = members[virtualRow.index];
280
+
if (!member) {
281
+
return null;
282
+
}
283
+
284
+
return (
285
+
<OrganizationMemberRow
286
+
key={virtualRow.key}
287
+
member={member}
288
+
/>
289
+
);
290
+
})}
291
+
292
+
{/* Bottom spacer for rows after last visible */}
293
+
{virtualItems.length > 0 && (
294
+
<TableRow>
295
+
<TableCell
296
+
colSpan={5}
297
+
style={{
298
+
height: `${virtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1]?.end ?? 0)}px`,
299
+
padding: 0,
300
+
}}
301
+
/>
302
+
</TableRow>
303
+
)}
304
+
</TableBody>
305
+
</Table>
306
+
</div>
307
+
</div>
308
+
</div>
309
+
)}
310
+
</div>
311
+
</div>
312
+
</div>
313
+
);
314
+
};
+75
apps/client/src/features/organizations/queries/organization-members.graphql
+75
apps/client/src/features/organizations/queries/organization-members.graphql
···
1
+
query OrganizationMembers(
2
+
$organizationId: String!
3
+
$first: Int
4
+
$after: String
5
+
$sortBy: String
6
+
$sortOrder: String
7
+
$searchTerm: String
8
+
) {
9
+
organization(id: $organizationId) {
10
+
id
11
+
name
12
+
description
13
+
memberships(
14
+
first: $first
15
+
after: $after
16
+
sortBy: $sortBy
17
+
sortOrder: $sortOrder
18
+
searchTerm: $searchTerm
19
+
) {
20
+
edges {
21
+
node {
22
+
id
23
+
joinedAt
24
+
role {
25
+
id
26
+
name
27
+
description
28
+
color
29
+
}
30
+
user {
31
+
id
32
+
name
33
+
email
34
+
createdAt
35
+
experience {
36
+
edges {
37
+
node {
38
+
id
39
+
startDate
40
+
endDate
41
+
description
42
+
company {
43
+
id
44
+
name
45
+
website
46
+
}
47
+
role {
48
+
id
49
+
name
50
+
}
51
+
level {
52
+
id
53
+
name
54
+
}
55
+
skills {
56
+
id
57
+
name
58
+
}
59
+
}
60
+
}
61
+
}
62
+
}
63
+
}
64
+
cursor
65
+
}
66
+
pageInfo {
67
+
hasNextPage
68
+
hasPreviousPage
69
+
startCursor
70
+
endCursor
71
+
}
72
+
totalCount
73
+
}
74
+
}
75
+
}