From a9bd86b3711d2ca88c8b60be3e7d7b5677cebcfb Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 11 Sep 2026 01:37:43 +0000 Subject: [PATCH 1/2] feat(ui): search, sort and role filter for the team member table Rebuild the shared member table on DataTable so admins can search members by name, email or user id, sort by name, email, role, budget and spend, and filter by role. /team/info now returns each member's user_alias so the table can show a human-readable name Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 5 + .../management_endpoints/team_endpoints.py | 60 ++-- .../test_team_endpoints.py | 110 +++--- .../common_components/MemberTable.test.tsx | 215 ++++++++++++ .../common_components/MemberTable.tsx | 329 +++++++++++++----- .../src/components/networking.tsx | 1 + .../organization/organization_view.tsx | 19 +- .../shared/DataTable/DataTable.test.tsx | 22 ++ .../components/shared/DataTable/DataTable.tsx | 10 + .../src/components/team/TeamMemberTab.tsx | 18 +- 10 files changed, 595 insertions(+), 194 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 2cbff128635..22591eab651 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4358,7 +4358,12 @@ class TeamAccessGroupModelGrant(LiteLLMPydanticObjectBase): agent_ids: tuple[str, ...] = () +class TeamInfoMember(Member): + user_alias: str | None = None + + class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable): + members_with_roles: tuple[TeamInfoMember, ...] = () team_member_budget_table: LiteLLM_BudgetTableFull | None = None # Resources inherited from access groups (separate from direct assignments) access_group_models: list[str] | None = None diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index c050368b3fe..e9e37540dd8 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -61,6 +61,7 @@ from litellm.proxy._types import ( SpecialProxyStrings, TeamAccessGroupModelGrant, TeamAddMemberResponse, + TeamInfoMember, TeamInfoResponseObject, TeamInfoResponseObjectTeamTable, TeamListResponseObject, @@ -4292,37 +4293,35 @@ async def _add_team_member_budget_table( return team_info_response_object -async def _hydrate_member_emails( +async def _hydrate_member_user_details( prisma_client: PrismaClient, members: Sequence[Member], -) -> tuple[Member, ...]: - """Fill in ``user_email`` for roster entries that were stored without one. - - ``members_with_roles`` is a denormalized snapshot written at add-time, so an entry - stored with ``user_email=None`` keeps that null even once the user row has an email. - Look the missing ones up in ``LiteLLM_UserTable`` (one indexed query) and fill them - in. A stored email is never overwritten - the snapshot stays the source of truth - wherever it has a value. - """ - missing_user_ids: Final = frozenset(m.user_id for m in members if not m.user_email and m.user_id is not None) - if not missing_user_ids: - return tuple(members) - - user_rows: Final[Sequence[prisma_models.LiteLLM_UserTable]] = await _user_db(prisma_client).find_many( - where={ # mutable-ok: Prisma query filters are dict-shaped - "user_id": { # mutable-ok: Prisma query filters are dict-shaped - "in": sorted(missing_user_ids) +) -> tuple[TeamInfoMember, ...]: + """Attach ``user_alias`` and fill in a missing ``user_email`` from ``LiteLLM_UserTable`` in one query.""" + user_ids: Final = frozenset(m.user_id for m in members if m.user_id is not None) + user_rows: Final[Sequence[prisma_models.LiteLLM_UserTable]] = ( + await _user_db(prisma_client).find_many( + where={ # mutable-ok: Prisma query filters are dict-shaped + "user_id": { # mutable-ok: Prisma query filters are dict-shaped + "in": sorted(user_ids) + } } - } + ) + if user_ids + else () ) - email_by_user_id: Final = MappingProxyType({u.user_id: u.user_email for u in user_rows if u.user_email}) + user_by_id: Final = MappingProxyType({u.user_id: u for u in user_rows}) - return tuple( - m.model_copy(update={"user_email": email_by_user_id[m.user_id]}) # mutable-ok: pydantic update payload - if not m.user_email and m.user_id is not None and m.user_id in email_by_user_id - else m - for m in members - ) + def hydrate(m: Member) -> TeamInfoMember: + user_row: Final = user_by_id.get(m.user_id) if m.user_id is not None else None + return TeamInfoMember( + role=m.role, + user_id=m.user_id, + user_email=m.user_email or (user_row.user_email if user_row is not None else None), + user_alias=user_row.user_alias if user_row is not None else None, + ) + + return tuple(hydrate(m) for m in members) async def _resolve_team_access_group_resources( @@ -4462,17 +4461,12 @@ async def team_info( # Resolve resources inherited from access groups resolved_team_info: Final = await _resolve_team_access_group_resources(_team_info) - # Fill in emails the add-time roster snapshot never captured - hydrated_members: Final = await _hydrate_member_emails( + hydrated_members: Final = await _hydrate_member_user_details( prisma_client=prisma_client, members=resolved_team_info.members_with_roles, ) hydrated_team_info: Final = resolved_team_info.model_copy( - update={ # mutable-ok: pydantic update payload - # list(), not the tuple: model_copy skips validation, so the field has - # to be handed the list[Member] the response model declares. - "members_with_roles": list(hydrated_members) # mutable-ok: declared list[Member] - } + update={"members_with_roles": hydrated_members} # mutable-ok: pydantic update payload ) response_object: Final = TeamInfoResponseObject( diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 2f6561046b1..2fb496d6231 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -11220,13 +11220,14 @@ async def test_team_info_returns_model_aliases(): @pytest.mark.asyncio -async def test_team_info_hydrates_member_emails_from_the_user_table(): - """/team/info must fill in emails missing from the members_with_roles snapshot. +async def test_team_info_hydrates_member_names_and_emails_from_the_user_table(): + """/team/info must attach each member's display name and fill in emails missing + from the members_with_roles snapshot. - members_with_roles is written at add-time, so a member added by user_id alone - carries user_email=None forever. Without this join the Admin UI's member table - shows "-" for a user that has an email on their user row. A stored email is left - exactly as-is. + members_with_roles is written at add-time, so it never carries user_alias and a + member added by user_id alone carries user_email=None forever. Without this join + the Admin UI's member table can only show emails. A stored email is left exactly + as-is. """ from fastapi import Request @@ -11246,13 +11247,8 @@ async def test_team_info_hydrates_member_emails_from_the_user_table(): find_many = AsyncMock( return_value=[ - LiteLLM_UserTable( - user_id="no-email-on-roster", - user_email="real@example.com", - max_budget=None, - spend=0.0, - models=[], - ) + _user_row("no-email-on-roster", "real@example.com", "Real Person"), + _user_row("already-stored", "current@example.com", "Stored Person"), ] ) @@ -11270,12 +11266,12 @@ async def test_team_info_hydrates_member_emails_from_the_user_table(): ) members = response["team_info"].members_with_roles - assert [(m.user_id, m.user_email) for m in members] == [ - ("no-email-on-roster", "real@example.com"), - ("already-stored", "stored@example.com"), + assert [(m.user_id, m.user_email, m.user_alias) for m in members] == [ + ("no-email-on-roster", "real@example.com", "Real Person"), + ("already-stored", "stored@example.com", "Stored Person"), ] - # only the member actually missing an email is looked up - assert find_many.await_args.kwargs["where"] == {"user_id": {"in": ["no-email-on-roster"]}} + find_many.assert_awaited_once() + assert find_many.await_args.kwargs["where"] == {"user_id": {"in": ["already-stored", "no-email-on-roster"]}} @pytest.mark.asyncio @@ -12472,89 +12468,93 @@ async def test_resolve_existing_member_user_ids_skips_the_query_when_no_user_ids repo.return_value.table.find_many.assert_not_awaited() -def _user_row(user_id: str, user_email: str | None) -> LiteLLM_UserTable: +def _user_row(user_id: str, user_email: str | None, user_alias: str | None = None) -> LiteLLM_UserTable: return LiteLLM_UserTable( - user_id=user_id, user_email=user_email, max_budget=None, spend=0.0, models=[] + user_id=user_id, user_email=user_email, user_alias=user_alias, max_budget=None, spend=0.0, models=[] ) @pytest.mark.asyncio -async def test_hydrate_member_emails_fills_in_emails_the_roster_snapshot_never_captured(): - """A member added by user_id alone has user_email=None on the stored roster entry. - - /team/info has to fill it in from the user row, or the UI renders "-" for a user - that plainly has an email. +async def test_hydrate_member_user_details_attaches_alias_and_fills_in_missing_email(): + """The stored roster never carries a display name, and a member added by user_id + alone has user_email=None. /team/info has to fill both in from the user row so the + UI can show and search by a human-readable name instead of only an email. """ - from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_emails + from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_user_details - find_many = AsyncMock(return_value=[_user_row("by-id", "found@example.com")]) + find_many = AsyncMock(return_value=[_user_row("by-id", "found@example.com", "Found Person")]) with patch("litellm.proxy.management_endpoints.team_endpoints.UserRepository") as repo: repo.return_value.table.find_many = find_many - hydrated = await _hydrate_member_emails( + hydrated = await _hydrate_member_user_details( prisma_client=MagicMock(), members=[Member(user_id="by-id", role="admin")], ) - assert [(m.user_id, m.user_email, m.role) for m in hydrated] == [("by-id", "found@example.com", "admin")] + assert [(m.user_id, m.user_email, m.user_alias, m.role) for m in hydrated] == [ + ("by-id", "found@example.com", "Found Person", "admin") + ] find_many.assert_awaited_once() assert find_many.await_args.kwargs["where"] == {"user_id": {"in": ["by-id"]}} @pytest.mark.asyncio -async def test_hydrate_member_emails_never_overwrites_a_stored_email(): - """The snapshot wins wherever it has a value - hydration only fills blanks. - - Overwriting would be a real behavior change to /team/info; filling a null is not. - """ - from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_emails - - find_many = AsyncMock(return_value=[_user_row("has-email", "current@example.com")]) +async def test_hydrate_member_user_details_never_overwrites_a_stored_email(): + """The snapshot wins wherever it has a value - hydration only fills blanks.""" + from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_user_details with patch("litellm.proxy.management_endpoints.team_endpoints.UserRepository") as repo: - repo.return_value.table.find_many = find_many + repo.return_value.table.find_many = AsyncMock( + return_value=[_user_row("has-email", "current@example.com", "Current Name")] + ) - hydrated = await _hydrate_member_emails( + hydrated = await _hydrate_member_user_details( prisma_client=MagicMock(), members=[Member(user_id="has-email", user_email="stored@example.com", role="user")], ) - assert hydrated[0].user_email == "stored@example.com" - # nothing was missing, so no round-trip either - find_many.assert_not_awaited() + assert (hydrated[0].user_email, hydrated[0].user_alias) == ("stored@example.com", "Current Name") @pytest.mark.asyncio -async def test_hydrate_member_emails_leaves_members_alone_when_the_user_row_has_no_email(): - """A user row with no email leaves the member as-is rather than inventing one.""" - from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_emails +async def test_hydrate_member_user_details_leaves_blanks_when_the_user_row_is_bare_or_missing(): + """A user row with no email or alias, or no user row at all, must not invent values.""" + from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_user_details with patch("litellm.proxy.management_endpoints.team_endpoints.UserRepository") as repo: - repo.return_value.table.find_many = AsyncMock(return_value=[_user_row("no-email", None)]) + repo.return_value.table.find_many = AsyncMock(return_value=[_user_row("bare", None)]) - hydrated = await _hydrate_member_emails( + hydrated = await _hydrate_member_user_details( prisma_client=MagicMock(), - members=[Member(user_id="no-email", role="user"), Member(user_email="e@example.com", role="user")], + members=[ + Member(user_id="bare", role="user"), + Member(user_id="deleted", user_email="gone@example.com", role="user"), + Member(user_email="e@example.com", role="user"), + ], ) - assert [m.user_email for m in hydrated] == [None, "e@example.com"] + assert [(m.user_id, m.user_email, m.user_alias) for m in hydrated] == [ + ("bare", None, None), + ("deleted", "gone@example.com", None), + (None, "e@example.com", None), + ] @pytest.mark.asyncio -async def test_hydrate_member_emails_skips_the_query_when_every_member_has_one(): - """No blanks means /team/info pays for no extra query.""" - from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_emails +async def test_hydrate_member_user_details_skips_the_query_when_no_member_has_a_user_id(): + """Email-only roster entries give nothing to look up, so /team/info pays for no query.""" + from litellm.proxy.management_endpoints.team_endpoints import _hydrate_member_user_details with patch("litellm.proxy.management_endpoints.team_endpoints.UserRepository") as repo: repo.return_value.table.find_many = AsyncMock() - hydrated = await _hydrate_member_emails( + hydrated = await _hydrate_member_user_details( prisma_client=MagicMock(), - members=[Member(user_id="a", user_email="a@example.com", role="user")], + members=[Member(user_email="a@example.com", role="user")], ) - assert hydrated[0].user_email == "a@example.com" + assert [(m.user_email, m.user_alias) for m in hydrated] == [("a@example.com", None)] repo.return_value.table.find_many.assert_not_awaited() diff --git a/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx b/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx new file mode 100644 index 00000000000..a16f11eab60 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx @@ -0,0 +1,215 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import type { Member } from "@/components/networking"; + +import { renderWithProviders } from "../../../tests/test-utils"; +import MemberTable, { MemberTableColumn, memberRoleOptions } from "./MemberTable"; + +const MEMBERS: Member[] = [ + { user_id: "u-zed", user_email: "zed@example.com", user_alias: "Zed Ortiz", role: "user" }, + { user_id: "u-nameless", user_email: "mystery@example.com", user_alias: null, role: "user" }, + { user_id: "u-amy", user_email: "amy@example.com", user_alias: "amy chen", role: "admin" }, + { user_id: "u-bob", user_email: null, user_alias: "Bob Lee", role: "user" }, +]; + +const BUDGETS: Record = { "u-zed": 50, "u-nameless": null, "u-amy": 1000, "u-bob": 5 }; + +const budgetColumn: MemberTableColumn = { + title: "Budget", + key: "budget", + sortValue: (member) => BUDGETS[member.user_id ?? ""] ?? null, + render: (member) => {BUDGETS[member.user_id ?? ""] ?? "Unlimited"}, +}; + +const renderTable = (overrides: Partial> = {}) => { + const props = { + members: MEMBERS, + canEdit: true, + onEdit: vi.fn(), + onDelete: vi.fn(), + extraColumns: [budgetColumn], + ...overrides, + }; + renderWithProviders(); + return props; +}; + +const rowIds = (): (string | null)[] => + Array.from(document.querySelectorAll("tbody tr[data-row-id]")).map((row) => row.getAttribute("data-row-id")); + +const search = (value: string) => fireEvent.change(screen.getByTestId("datatable-search"), { target: { value } }); + +describe("MemberTable display", () => { + it("shows each member's name, falling back to a dash when there is none", () => { + renderTable(); + + expect(within(screen.getByRole("row", { name: /zed@example\.com/ })).getByText("Zed Ortiz")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /^name/i })).toBeInTheDocument(); + const cells = within(screen.getByRole("row", { name: /mystery@example\.com/ })).getAllByRole("cell"); + expect(cells[0]).toHaveTextContent("-"); + }); + + it("orders members by name with nameless members last by default", () => { + renderTable(); + + expect(rowIds()).toEqual(["u-amy", "u-bob", "u-zed", "u-nameless"]); + }); + + it("reports the full member count", () => { + renderTable(); + + expect(screen.getByText("4 Members")).toBeInTheDocument(); + }); +}); + +describe("MemberTable search", () => { + it("matches on name case-insensitively", async () => { + renderTable(); + + search("ZED"); + + await waitFor(() => expect(rowIds()).toEqual(["u-zed"])); + }); + + it("matches on email", async () => { + renderTable(); + + search("mystery@"); + + await waitFor(() => expect(rowIds()).toEqual(["u-nameless"])); + }); + + it("matches on user id", async () => { + renderTable(); + + search("u-bob"); + + await waitFor(() => expect(rowIds()).toEqual(["u-bob"])); + }); + + it("still searches names when the first member has no name", async () => { + renderTable({ members: [MEMBERS[1], MEMBERS[0]] }); + + search("ortiz"); + + await waitFor(() => expect(rowIds()).toEqual(["u-zed"])); + }); + + it("does not match on role", async () => { + renderTable(); + + search("admin"); + + await waitFor(() => expect(rowIds()).toEqual([])); + expect(screen.getByText("No members match your search or filters")).toBeInTheDocument(); + }); +}); + +describe("MemberTable sorting", () => { + it("sorts by email with missing emails last in both directions", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("sort-header-user_email")); + expect(rowIds()).toEqual(["u-amy", "u-nameless", "u-zed", "u-bob"]); + + await user.click(screen.getByTestId("sort-header-user_email")); + expect(rowIds()).toEqual(["u-zed", "u-nameless", "u-amy", "u-bob"]); + }); + + it("sorts by role", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("sort-header-role")); + expect(rowIds()[0]).toBe("u-amy"); + + await user.click(screen.getByTestId("sort-header-role")); + expect(rowIds()[3]).toBe("u-amy"); + }); + + it("sorts an extra column numerically by its sort value with blanks last", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("sort-header-budget")); + expect(rowIds()).toEqual(["u-bob", "u-zed", "u-amy", "u-nameless"]); + + await user.click(screen.getByTestId("sort-header-budget")); + expect(rowIds()).toEqual(["u-amy", "u-zed", "u-bob", "u-nameless"]); + }); + + it("flips name order on the second click", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("sort-header-user_alias")); + expect(rowIds()).toEqual(["u-zed", "u-bob", "u-amy", "u-nameless"]); + }); + + it("leaves extra columns without a sort value unsortable", () => { + renderTable({ + extraColumns: [{ title: "Rate Limits", key: "rate_limits", render: () => No Limits }], + }); + + expect(screen.getByRole("columnheader", { name: "Rate Limits" })).toBeInTheDocument(); + expect(screen.queryByTestId("sort-header-rate_limits")).not.toBeInTheDocument(); + }); +}); + +describe("MemberTable role filter", () => { + it("shows only members with the chosen role and clears on reset", async () => { + const user = userEvent.setup(); + renderTable({ roleColumnTitle: "Team Role" }); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(screen.getByTestId("filter-role")); + await user.click(await screen.findByRole("option", { name: "admin" })); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(rowIds()).toEqual(["u-amy"])); + expect(screen.getByTestId("filter-chip-role")).toHaveTextContent("Team Role"); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(screen.getByTestId("filter-drawer-reset")); + + await waitFor(() => expect(rowIds()).toHaveLength(4)); + }); + + it("offers the roles present in the roster", () => { + expect(memberRoleOptions(MEMBERS)).toEqual(["admin", "user"]); + expect(memberRoleOptions([{ user_id: "x", role: "" }])).toEqual([]); + }); +}); + +describe("MemberTable actions", () => { + it("passes the clicked member to onEdit and onDelete", async () => { + const user = userEvent.setup(); + const { onEdit, onDelete } = renderTable(); + const row = screen.getByRole("row", { name: /amy@example\.com/ }); + + await user.click(within(row).getByTestId("edit-member")); + await user.click(within(row).getByTestId("delete-member")); + + expect(onEdit).toHaveBeenCalledWith(MEMBERS[2]); + expect(onDelete).toHaveBeenCalledWith(MEMBERS[2]); + }); + + it("hides delete for members the caller excludes", () => { + renderTable({ showDeleteForMember: (member) => member.role !== "admin" }); + + expect(screen.getAllByTestId("delete-member")).toHaveLength(3); + expect( + within(screen.getByRole("row", { name: /amy@example\.com/ })).queryByTestId("delete-member"), + ).not.toBeInTheDocument(); + }); + + it("shows the empty text when there are no members at all", () => { + renderTable({ members: [], emptyText: "No members found" }); + + expect(screen.getByText("No members found")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx index 8a0b7671078..e54994c1ab7 100644 --- a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx @@ -1,17 +1,29 @@ -import { SimpleTooltip } from "@/components/ui/tooltip"; +import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table"; +import { Crown, Info, User, UserPlus } from "lucide-react"; +import React, { useState } from "react"; + import { Member } from "@/components/networking"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableSortHeader, + DataTableToolbar, +} from "@/components/shared/DataTable"; import { StatusBadge } from "@/components/shared/table_cells"; import { Button } from "@/components/ui/button"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Crown, Info, User, UserPlus } from "lucide-react"; -import React from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { SimpleTooltip } from "@/components/ui/tooltip"; + import TableIconActionButton from "./IconActionButton/TableIconActionButtons/TableIconActionButton"; +export type MemberTableSortValue = string | number | null | undefined; + export interface MemberTableColumn { title: React.ReactNode; - key: React.Key; - dataIndex?: keyof Member; - render?: (value: Member[keyof Member], member: Member, index: number) => React.ReactNode; + key: string; + render: (member: Member) => React.ReactNode; + sortValue?: (member: Member) => MemberTableSortValue; } export interface MemberTableProps { @@ -27,12 +39,152 @@ export interface MemberTableProps { emptyText?: string; } -const extraColumnCell = (column: MemberTableColumn, member: Member, index: number): React.ReactNode => { - const value = column.dataIndex ? member[column.dataIndex] : undefined; - return column.render ? column.render(value, member, index) : value; +const ALL_ROLES = "all"; + +export const memberRowId = (member: Member): string => member.user_id ?? member.user_email ?? JSON.stringify(member); + +export const memberRoleOptions = (members: readonly Member[]): string[] => + Array.from(new Set(members.map((member) => member.role).filter((role) => role !== ""))).sort(); + +const isAdminRole = (role: string): boolean => { + const normalized = role.toLowerCase(); + return normalized === "admin" || normalized === "org_admin"; }; -const STICKY_ACTIONS_CLASS = "sticky right-0 w-[120px] bg-background"; +function RoleHeaderTitle({ title, tooltip }: { title: string; tooltip?: string }) { + if (tooltip === undefined) return <>{title}; + return ( + + {title} + + + + + ); +} + +const ACTIONS_COLUMN_WIDTH = 120; + +interface MemberColumnDeps { + canEdit: boolean; + onEdit: (member: Member) => void; + onDelete: (member: Member) => void; + roleColumnTitle: string; + roleTooltip?: string; + extraColumns: MemberTableColumn[]; + showDeleteForMember?: (member: Member) => boolean; +} + +const extraColumnDef = (column: MemberTableColumn): ColumnDef => { + const { sortValue } = column; + if (sortValue === undefined) { + return { + id: column.key, + header: () => {column.title}, + enableSorting: false, + enableGlobalFilter: false, + cell: ({ row }) => column.render(row.original), + }; + } + return { + id: column.key, + accessorFn: (member) => sortValue(member) ?? undefined, + header: ({ column: tableColumn }) => , + sortDescFirst: false, + sortUndefined: "last", + enableGlobalFilter: false, + cell: ({ row }) => column.render(row.original), + }; +}; + +const buildColumns = ({ + canEdit, + onEdit, + onDelete, + roleColumnTitle, + roleTooltip, + extraColumns, + showDeleteForMember, +}: MemberColumnDeps): ColumnDef[] => [ + { + id: "user_alias", + accessorFn: (member) => member.user_alias || undefined, + header: ({ column }) => , + sortingFn: "text", + sortUndefined: "last", + enableGlobalFilter: true, + meta: { title: "Name" }, + cell: ({ row }) => row.original.user_alias || -, + }, + { + id: "user_email", + accessorFn: (member) => member.user_email || undefined, + header: ({ column }) => , + sortingFn: "text", + sortUndefined: "last", + enableGlobalFilter: true, + meta: { title: "User Email" }, + cell: ({ row }) => row.original.user_email || "-", + }, + { + id: "user_id", + accessorFn: (member) => member.user_id ?? undefined, + header: "User ID", + enableSorting: false, + enableGlobalFilter: true, + cell: ({ row }) => + row.original.user_id === "default_user_id" ? ( + + ) : ( + row.original.user_id || "-" + ), + }, + { + id: "role", + accessorFn: (member) => member.role, + header: ({ column }) => ( + } /> + ), + sortingFn: "text", + filterFn: "equalsString", + enableGlobalFilter: false, + meta: { title: roleColumnTitle }, + cell: ({ row }) => ( + + {isAdminRole(row.original.role) ? : } + {row.original.role || "-"} + + ), + }, + ...extraColumns.map(extraColumnDef), + { + id: "actions", + header: "Actions", + size: ACTIONS_COLUMN_WIDTH, + enableSorting: false, + enableGlobalFilter: false, + meta: { pinned: "right" }, + cell: ({ row }) => + canEdit ? ( + + onEdit(row.original)} + /> + {(!showDeleteForMember || showDeleteForMember(row.original)) && ( + onDelete(row.original)} + /> + )} + + ) : null, + }, +]; export default function MemberTable({ members, @@ -46,90 +198,89 @@ export default function MemberTable({ showDeleteForMember, emptyText, }: MemberTableProps) { + const [globalFilter, setGlobalFilter] = useState(""); + const [columnFilters, setColumnFilters] = useState([]); + const [filtersOpen, setFiltersOpen] = useState(false); + + const columnDeps: MemberColumnDeps = { + canEdit, + onEdit, + onDelete, + roleColumnTitle, + roleTooltip, + extraColumns, + showDeleteForMember, + }; + const columns = buildColumns(columnDeps); + const roleFilterItems = [ + { value: ALL_ROLES, label: "All Roles" }, + ...memberRoleOptions(members).map((role) => ({ value: role, label: role })), + ]; + + const isNarrowed = globalFilter !== "" || columnFilters.length > 0; + return (
{members.length} Member{members.length !== 1 ? "s" : ""} - - - - User Email - User ID - - {roleTooltip ? ( - - {roleColumnTitle} - - - - - ) : ( - roleColumnTitle + + {isNarrowed ? "No members match your search or filters" : emptyText ?? "No data"} + + } + toolbar={(table) => ( + <> + setFiltersOpen(true)} + showViewOptions={false} + /> + + {({ get, set }) => ( + + + )} - - {extraColumns.map((column) => ( - {column.title} - ))} - Actions - - - - {members.length === 0 ? ( - - - {emptyText ?? "No data"} - - - ) : ( - members.map((member, memberIndex) => ( - - {member.user_email || "-"} - - {member.user_id === "default_user_id" ? ( - - ) : ( - member.user_id || "-" - )} - - - - {member.role?.toLowerCase() === "admin" || member.role?.toLowerCase() === "org_admin" ? ( - - ) : ( - - )} - {member.role || "-"} - - - {extraColumns.map((column) => ( - {extraColumnCell(column, member, memberIndex)} - ))} - - {canEdit ? ( - - onEdit(member)} - /> - {(!showDeleteForMember || showDeleteForMember(member)) && ( - onDelete(member)} - /> - )} - - ) : null} - - - )) - )} - -
+ + + )} + /> {onAddMember && canEdit && (