diff --git a/apps/client/src/features/organizations/components/CollapsibleOrganizationTable.tsx b/apps/client/src/features/organizations/components/CollapsibleOrganizationTable.tsx new file mode 100644 index 0000000..c41d381 --- /dev/null +++ b/apps/client/src/features/organizations/components/CollapsibleOrganizationTable.tsx @@ -0,0 +1,314 @@ +import { + ChevronDownIcon, + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, + TextInput, +} from "@cv/ui"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useEffect, useRef, useState } from "react"; +import { + type MeOrganizationsQuery, + useInfiniteOrganizationMembersQuery, +} from "@/generated/graphql"; +import { OrganizationMemberRow } from "./OrganizationMemberRow"; + +type Organization = NonNullable< + NonNullable["organizations"] +>[0]; + +interface CollapsibleOrganizationTableProps { + organization: Organization; + defaultExpanded?: boolean; +} + +export const CollapsibleOrganizationTable = ({ + organization, + defaultExpanded = false, +}: CollapsibleOrganizationTableProps) => { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const parentRef = useRef(null); + + // Debounce search term to avoid too many API calls + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 300); + + return () => clearTimeout(timer); + }, [searchTerm]); + + // Only fetch members when expanded using infinite scroll + const { + data: membersData, + isLoading: membersLoading, + error: membersError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteOrganizationMembersQuery( + { + organizationId: organization.id, + first: 20, + searchTerm: debouncedSearchTerm.trim() || undefined, + }, + { + enabled: isExpanded, + initialPageParam: { after: null }, + getNextPageParam: (lastPage) => { + const pageInfo = lastPage.organization?.memberships?.pageInfo; + return pageInfo?.hasNextPage + ? { after: pageInfo.endCursor } + : undefined; + }, + }, + ); + + // Flatten all pages into a single array of members + const members = + membersData?.pages + ?.flatMap( + (page) => + page.organization?.memberships?.edges?.map((edge) => edge.node) || [], + ) + .filter( + (member): member is NonNullable => member != null, + ) || []; + + // Setup virtualizer for performance + const virtualizer = useVirtualizer({ + count: members.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 60, + overscan: 3, + }); + + // Auto-load more when scrolling near the end + const virtualItems = virtualizer.getVirtualItems(); + + useEffect(() => { + const [lastItem] = [...virtualItems].reverse(); + + if (!(lastItem && hasNextPage) || isFetchingNextPage) { + return; + } + + // Load more when we're within 3 items of the end + if (lastItem.index >= members.length - 3) { + fetchNextPage(); + } + }, [ + hasNextPage, + fetchNextPage, + members.length, + isFetchingNextPage, + virtualItems, + ]); + + const toggleExpanded = () => { + setIsExpanded(!isExpanded); + }; + + return ( +
+ {/* Organization Header - Always Visible */} + + + {/* Collapsible Content */} +
+
+ {/* Search Input */} + {isExpanded && ( +
+ +
+ )} + + {membersLoading ? ( +
+
Loading members...
+
+ ) : membersError ? ( +
+
Error loading members
+
+ Please try again later +
+
+ ) : members.length === 0 ? ( +
+
+ {searchTerm.trim() ? "No members found" : "No members found"} +
+
+ {debouncedSearchTerm.trim() ? ( + <>No members match "{debouncedSearchTerm}" + ) : ( + "This organization has no members yet" + )} +
+
+ ) : ( +
+ {/* Table with virtualization and proper table rows */} +
+ {/* Scrollable wrapper for virtualization */} +
+ + + + + Member + + + Role + + + Current Position + + + Experience + + + Joined + + + + + + {/* Top spacer for rows before first visible */} + {virtualItems.length > 0 && + virtualItems[0]?.start > 0 && ( + + + + )} + + {/* Virtualized rows - render only visible rows as proper tr elements */} + {virtualItems.map((virtualRow) => { + const member = members[virtualRow.index]; + if (!member) { + return null; + } + + return ( + + ); + })} + + {/* Bottom spacer for rows after last visible */} + {virtualItems.length > 0 && ( + + + + )} + +
+
+
+
+ )} +
+
+
+ ); +}; diff --git a/apps/client/src/features/organizations/queries/organization-members.graphql b/apps/client/src/features/organizations/queries/organization-members.graphql new file mode 100644 index 0000000..14cec61 --- /dev/null +++ b/apps/client/src/features/organizations/queries/organization-members.graphql @@ -0,0 +1,75 @@ +query OrganizationMembers( + $organizationId: String! + $first: Int + $after: String + $sortBy: String + $sortOrder: String + $searchTerm: String +) { + organization(id: $organizationId) { + id + name + description + memberships( + first: $first + after: $after + sortBy: $sortBy + sortOrder: $sortOrder + searchTerm: $searchTerm + ) { + edges { + node { + id + joinedAt + role { + id + name + description + color + } + user { + id + name + email + createdAt + experience { + edges { + node { + id + startDate + endDate + description + company { + id + name + website + } + role { + id + name + } + level { + id + name + } + skills { + id + name + } + } + } + } + } + } + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } +} diff --git a/apps/client/src/features/user/queries/me-organizations.graphql b/apps/client/src/features/user/queries/me-organizations.graphql new file mode 100644 index 0000000..f666b36 --- /dev/null +++ b/apps/client/src/features/user/queries/me-organizations.graphql @@ -0,0 +1,16 @@ +query MeOrganizations { + me { + id + email + name + createdAt + organizations { + id + name + description + createdAt + updatedAt + memberCount + } + } +}