mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into feat-prism-provider
This commit is contained in:
commit
1ea086167e
11 changed files with 617 additions and 195 deletions
|
|
@ -4388,7 +4388,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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, number | null> = { "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) => <span>{BUDGETS[member.user_id ?? ""] ?? "Unlimited"}</span>,
|
||||
};
|
||||
|
||||
const renderTable = (overrides: Partial<React.ComponentProps<typeof MemberTable>> = {}) => {
|
||||
const props = {
|
||||
members: MEMBERS,
|
||||
canEdit: true,
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
extraColumns: [budgetColumn],
|
||||
...overrides,
|
||||
};
|
||||
renderWithProviders(<MemberTable {...props} />);
|
||||
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: () => <span>No Limits</span> }],
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{title}
|
||||
<SimpleTooltip content={tooltip}>
|
||||
<Info className="size-3.5" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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<Member> => {
|
||||
const { sortValue } = column;
|
||||
if (sortValue === undefined) {
|
||||
return {
|
||||
id: column.key,
|
||||
header: () => <span className="font-medium">{column.title}</span>,
|
||||
enableSorting: false,
|
||||
enableGlobalFilter: false,
|
||||
cell: ({ row }) => column.render(row.original),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: column.key,
|
||||
accessorFn: (member) => sortValue(member) ?? undefined,
|
||||
header: ({ column: tableColumn }) => <DataTableSortHeader column={tableColumn} title={column.title} />,
|
||||
sortDescFirst: false,
|
||||
sortUndefined: "last",
|
||||
enableGlobalFilter: false,
|
||||
cell: ({ row }) => column.render(row.original),
|
||||
};
|
||||
};
|
||||
|
||||
const buildColumns = ({
|
||||
canEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
roleColumnTitle,
|
||||
roleTooltip,
|
||||
extraColumns,
|
||||
showDeleteForMember,
|
||||
}: MemberColumnDeps): ColumnDef<Member>[] => [
|
||||
{
|
||||
id: "user_alias",
|
||||
accessorFn: (member) => member.user_alias || undefined,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
|
||||
sortingFn: "text",
|
||||
sortUndefined: "last",
|
||||
enableGlobalFilter: true,
|
||||
meta: { title: "Name" },
|
||||
cell: ({ row }) => row.original.user_alias || <span className="text-muted-foreground">-</span>,
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
accessorFn: (member) => member.user_email || undefined,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="User Email" />,
|
||||
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" ? (
|
||||
<StatusBadge tone="info" label="Default Proxy Admin" />
|
||||
) : (
|
||||
row.original.user_id || "-"
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorFn: (member) => member.role,
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={<RoleHeaderTitle title={roleColumnTitle} tooltip={roleTooltip} />} />
|
||||
),
|
||||
sortingFn: "text",
|
||||
filterFn: "equalsString",
|
||||
enableGlobalFilter: false,
|
||||
meta: { title: roleColumnTitle },
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{isAdminRole(row.original.role) ? <Crown className="size-3.5" /> : <User className="size-3.5" />}
|
||||
<span className="capitalize">{row.original.role || "-"}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...extraColumns.map(extraColumnDef),
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
size: ACTIONS_COLUMN_WIDTH,
|
||||
enableSorting: false,
|
||||
enableGlobalFilter: false,
|
||||
meta: { pinned: "right" },
|
||||
cell: ({ row }) =>
|
||||
canEdit ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit member"
|
||||
dataTestId="edit-member"
|
||||
onClick={() => onEdit(row.original)}
|
||||
/>
|
||||
{(!showDeleteForMember || showDeleteForMember(row.original)) && (
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete member"
|
||||
dataTestId="delete-member"
|
||||
onClick={() => onDelete(row.original)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : 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<ColumnFiltersState>([]);
|
||||
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 (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<span className="inline-flex text-sm text-foreground">
|
||||
{members.length} Member{members.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User Email</TableHead>
|
||||
<TableHead>User ID</TableHead>
|
||||
<TableHead>
|
||||
{roleTooltip ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{roleColumnTitle}
|
||||
<SimpleTooltip content={roleTooltip}>
|
||||
<Info className="size-3.5" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
) : (
|
||||
roleColumnTitle
|
||||
<DataTable
|
||||
data={members}
|
||||
columns={columns}
|
||||
getRowId={memberRowId}
|
||||
sortingMode="client"
|
||||
defaultSorting={[{ id: "user_alias", desc: false }]}
|
||||
filterMode="client"
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={setColumnFilters}
|
||||
globalFilter={globalFilter}
|
||||
onGlobalFilterChange={setGlobalFilter}
|
||||
noDataMessage={
|
||||
<span className="text-muted-foreground">
|
||||
{isNarrowed ? "No members match your search or filters" : emptyText ?? "No data"}
|
||||
</span>
|
||||
}
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Search by name, email, or user ID"
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
showViewOptions={false}
|
||||
/>
|
||||
<DataTableFilterDrawer
|
||||
table={table}
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
title="Filters"
|
||||
description="Narrow down members"
|
||||
>
|
||||
{({ get, set }) => (
|
||||
<DataTableFilterField label={roleColumnTitle}>
|
||||
<Select
|
||||
items={roleFilterItems}
|
||||
value={(get("role") as string | undefined) ?? ALL_ROLES}
|
||||
onValueChange={(value) => set("role", value === ALL_ROLES ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger className="w-full" data-testid="filter-role">
|
||||
<SelectValue placeholder="All Roles" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleFilterItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
)}
|
||||
</TableHead>
|
||||
{extraColumns.map((column) => (
|
||||
<TableHead key={column.key}>{column.title}</TableHead>
|
||||
))}
|
||||
<TableHead className={STICKY_ACTIONS_CLASS}>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={extraColumns.length + 4} className="text-center text-muted-foreground">
|
||||
{emptyText ?? "No data"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
members.map((member, memberIndex) => (
|
||||
<TableRow key={member.user_id ?? member.user_email ?? JSON.stringify(member)}>
|
||||
<TableCell>{member.user_email || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{member.user_id === "default_user_id" ? (
|
||||
<StatusBadge tone="info" label="Default Proxy Admin" />
|
||||
) : (
|
||||
member.user_id || "-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{member.role?.toLowerCase() === "admin" || member.role?.toLowerCase() === "org_admin" ? (
|
||||
<Crown className="size-3.5" />
|
||||
) : (
|
||||
<User className="size-3.5" />
|
||||
)}
|
||||
<span className="capitalize">{member.role || "-"}</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
{extraColumns.map((column) => (
|
||||
<TableCell key={column.key}>{extraColumnCell(column, member, memberIndex)}</TableCell>
|
||||
))}
|
||||
<TableCell className={STICKY_ACTIONS_CLASS}>
|
||||
{canEdit ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit member"
|
||||
dataTestId="edit-member"
|
||||
onClick={() => onEdit(member)}
|
||||
/>
|
||||
{(!showDeleteForMember || showDeleteForMember(member)) && (
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete member"
|
||||
dataTestId="delete-member"
|
||||
onClick={() => onDelete(member)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableFilterDrawer>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{onAddMember && canEdit && (
|
||||
<Button onClick={onAddMember} className="self-start">
|
||||
<UserPlus className="size-4" />
|
||||
|
|
|
|||
|
|
@ -2946,6 +2946,7 @@ export interface Member {
|
|||
role: string;
|
||||
user_id: string | null;
|
||||
user_email?: string | null;
|
||||
user_alias?: string | null;
|
||||
max_budget_in_team?: number | null;
|
||||
tpm_limit?: number | null;
|
||||
rpm_limit?: number | null;
|
||||
|
|
|
|||
|
|
@ -121,23 +121,23 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
return <div className="p-4">Organization not found</div>;
|
||||
}
|
||||
|
||||
const orgMemberById = new Map((orgData.members || []).map((m) => [m.user_id, m]));
|
||||
const orgMemberFor = (record: Member) => (record.user_id != null ? orgMemberById.get(record.user_id) : undefined);
|
||||
|
||||
const orgExtraColumns: MemberTableColumn[] = [
|
||||
{
|
||||
title: "Spend (USD)",
|
||||
key: "spend",
|
||||
render: (_: unknown, record: Member) => {
|
||||
const orgMember =
|
||||
record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined;
|
||||
return <MoneyCell value={orgMember?.spend} decimals={4} />;
|
||||
},
|
||||
sortValue: (record: Member) => orgMemberFor(record)?.spend ?? null,
|
||||
render: (record: Member) => <MoneyCell value={orgMemberFor(record)?.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
title: "Created At",
|
||||
key: "created_at",
|
||||
render: (_: unknown, record: Member) => {
|
||||
const orgMember =
|
||||
record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined;
|
||||
return <span>{orgMember?.created_at ? new Date(orgMember.created_at).toLocaleString() : "-"}</span>;
|
||||
sortValue: (record: Member) => orgMemberFor(record)?.created_at ?? null,
|
||||
render: (record: Member) => {
|
||||
const createdAt = orgMemberFor(record)?.created_at;
|
||||
return <span>{createdAt ? new Date(createdAt).toLocaleString() : "-"}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -252,10 +252,12 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
<TabsContent keepMounted={hasVisited("members")} value="members" className="pt-4">
|
||||
<div className="space-y-4">
|
||||
<MemberTable
|
||||
key={orgData.organization_id}
|
||||
members={(orgData.members || []).map((m) => ({
|
||||
role: m.user_role || "",
|
||||
user_id: m.user_id,
|
||||
user_email: m.user_email,
|
||||
user_alias: m.user?.user_alias ?? null,
|
||||
}))}
|
||||
canEdit={canEditOrg}
|
||||
onEdit={(member) => {
|
||||
|
|
|
|||
|
|
@ -390,6 +390,28 @@ describe("DataTable filtering", () => {
|
|||
expect(names()).toEqual(["Alice"]);
|
||||
});
|
||||
|
||||
it("client global filter searches an opted-in column even when the first row has no value", () => {
|
||||
const nicknameColumns: ColumnDef<Person, unknown>[] = [
|
||||
...nameEmailColumns,
|
||||
{
|
||||
id: "nickname",
|
||||
accessorFn: (row) => (row.id === "b" ? "Bobby" : undefined),
|
||||
enableGlobalFilter: true,
|
||||
header: "Nickname",
|
||||
},
|
||||
];
|
||||
render(
|
||||
<DataTable
|
||||
data={CHARLIE_ALICE_BOB}
|
||||
columns={nicknameColumns}
|
||||
filterMode="client"
|
||||
globalFilter="bobby"
|
||||
onGlobalFilterChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(names()).toEqual(["Bob"]);
|
||||
});
|
||||
|
||||
it("server mode never filters locally even when columnFilters is set", () => {
|
||||
render(
|
||||
<DataTable
|
||||
|
|
|
|||
|
|
@ -96,6 +96,13 @@ function derivePinning<TData, TValue>(columns: ColumnDef<TData, TValue>[]): Colu
|
|||
return { left: collect("left"), right: collect("right") };
|
||||
}
|
||||
|
||||
function columnCanGlobalFilter<TData>(firstRow: TData | undefined, column: Column<TData, unknown>): boolean {
|
||||
if (column.columnDef.enableGlobalFilter === true) return true;
|
||||
if (firstRow === undefined || column.accessorFn === undefined) return false;
|
||||
const firstValue: unknown = column.accessorFn(firstRow, 0);
|
||||
return typeof firstValue === "string" || typeof firstValue === "number";
|
||||
}
|
||||
|
||||
function buildRowModels<TData>(
|
||||
sortingMode: SortingMode,
|
||||
paginationMode: PaginationMode,
|
||||
|
|
@ -516,6 +523,7 @@ function useDataTableInstance<TData extends RowData, TValue>(
|
|||
onRowSelectionChange: rowSelectionState.onChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
getColumnCanGlobalFilter: (column) => columnCanGlobalFilter(data[0], column),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...buildRowModels(sortingMode, paginationMode, filterMode, expansionGuard),
|
||||
...(getRowId !== undefined ? { getRowId } : {}),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import { fireEvent, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
|
|
@ -168,6 +168,27 @@ describe("TeamMembersComponent", () => {
|
|||
expect(table).toHaveTextContent("admin");
|
||||
});
|
||||
|
||||
it("clears the member search when a different team is shown", () => {
|
||||
const props = {
|
||||
canEditTeam: false,
|
||||
handleMemberDelete: mockHandleMemberDelete,
|
||||
setSelectedEditMember: mockSetSelectedEditMember,
|
||||
setIsEditMemberModalVisible: mockSetIsEditMemberModalVisible,
|
||||
setIsAddMemberModalVisible: mockSetIsAddMemberModalVisible,
|
||||
};
|
||||
const { rerender } = renderWithProviders(<TeamMembersComponent teamData={createMockTeamData()} {...props} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "user2" } });
|
||||
expect(screen.queryByText("user1@test.com")).not.toBeInTheDocument();
|
||||
|
||||
const otherTeam = createMockTeamData({ team_id: "team-456" });
|
||||
rerender(<TeamMembersComponent teamData={otherTeam} {...props} />);
|
||||
|
||||
expect(screen.getByTestId("datatable-search")).toHaveValue("");
|
||||
expect(screen.getAllByText("user1@test.com").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("user2@test.com").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should render Add Member button", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export default function TeamMemberTab({
|
|||
</span>
|
||||
),
|
||||
key: "model_scope",
|
||||
render: (_: unknown, record: Member) => {
|
||||
render: (record: Member) => {
|
||||
const models = getUserAllowedModels(record.user_id);
|
||||
if (!models) {
|
||||
return <span className="text-muted-foreground">(all team models)</span>;
|
||||
|
|
@ -141,9 +141,8 @@ export default function TeamMemberTab({
|
|||
</span>
|
||||
),
|
||||
key: "spend",
|
||||
render: (_: unknown, record: Member) => (
|
||||
<MoneyCell value={getUserCurrentCycleSpend(record.user_id)} decimals={2} />
|
||||
),
|
||||
sortValue: (record: Member) => getUserCurrentCycleSpend(record.user_id),
|
||||
render: (record: Member) => <MoneyCell value={getUserCurrentCycleSpend(record.user_id)} decimals={2} />,
|
||||
},
|
||||
{
|
||||
title: (
|
||||
|
|
@ -155,19 +154,22 @@ export default function TeamMemberTab({
|
|||
</span>
|
||||
),
|
||||
key: "total_spend",
|
||||
render: (_: unknown, record: Member) => <MoneyCell value={getUserTotalSpend(record.user_id)} decimals={2} />,
|
||||
sortValue: (record: Member) => getUserTotalSpend(record.user_id),
|
||||
render: (record: Member) => <MoneyCell value={getUserTotalSpend(record.user_id)} decimals={2} />,
|
||||
},
|
||||
{
|
||||
title: "Team Member Budget (USD)",
|
||||
key: "budget",
|
||||
render: (_: unknown, record: Member) => (
|
||||
sortValue: (record: Member) => getUserBudget(record.user_id),
|
||||
render: (record: Member) => (
|
||||
<MoneyCell value={getUserBudget(record.user_id)} decimals={2} emptyText="Unlimited" showZero />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Budget Reset",
|
||||
key: "budget_reset",
|
||||
render: (_: unknown, record: Member) => <DateCell value={getUserBudgetReset(record.user_id)} precision="date" />,
|
||||
sortValue: (record: Member) => getUserBudgetReset(record.user_id),
|
||||
render: (record: Member) => <DateCell value={getUserBudgetReset(record.user_id)} precision="date" />,
|
||||
},
|
||||
{
|
||||
title: (
|
||||
|
|
@ -179,12 +181,13 @@ export default function TeamMemberTab({
|
|||
</span>
|
||||
),
|
||||
key: "rate_limits",
|
||||
render: (_: unknown, record: Member) => <span>{getUserRateLimits(record.user_id)}</span>,
|
||||
render: (record: Member) => <span>{getUserRateLimits(record.user_id)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<MemberTable
|
||||
key={teamData.team_id}
|
||||
members={teamData.team_info.members_with_roles}
|
||||
canEdit={canEditTeam}
|
||||
onEdit={(record) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue