mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(ui): link the User ID and Team ID cells on the Memory page (#40752)
Both columns rendered as dead pills, so tracing a memory row back to its owner meant copying an id into another page's search box. IdCell grows an href prop that turns the pill into a client-routed link, and the Memory columns pass the shared entityLinks helpers so the proxy admin and dashboard sentinels stay unlinked. Claude-Session: https://claude.ai/code/session_01NfwfQhamRNnSqgXMUjf3h4
This commit is contained in:
parent
b957c25241
commit
1be930664f
4 changed files with 83 additions and 4 deletions
|
|
@ -8,6 +8,8 @@ import { MemoryRow } from "@/components/networking";
|
|||
|
||||
import { MemoryTable } from "./MemoryTable";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
const makeMemory = (overrides: Partial<MemoryRow> = {}): MemoryRow => ({
|
||||
memory_id: "mem-1",
|
||||
key: "user:profile",
|
||||
|
|
@ -36,6 +38,23 @@ const baseProps = {
|
|||
};
|
||||
|
||||
describe("MemoryTable", () => {
|
||||
it("links the User ID and Team ID cells to their detail pages", () => {
|
||||
render(<MemoryTable {...baseProps} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "user-42" })).toHaveAttribute("href", "/ui/users?user=user-42");
|
||||
expect(screen.getByRole("link", { name: "team-7" })).toHaveAttribute("href", "/ui/teams?team=team-7");
|
||||
});
|
||||
|
||||
it("leaves the proxy admin and dashboard sentinels unlinked", () => {
|
||||
const sentinelRow = makeMemory({ user_id: "default_user_id", team_id: "litellm-dashboard" });
|
||||
render(<MemoryTable {...baseProps} data={[sentinelRow]} />);
|
||||
|
||||
expect(screen.getByText("default_user_id")).toBeInTheDocument();
|
||||
expect(screen.getByText("litellm-dashboard")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "default_user_id" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "litellm-dashboard" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders every column header", () => {
|
||||
render(<MemoryTable {...baseProps} />);
|
||||
for (const header of ["ID", "Name", "Preview", "User ID", "Team ID", "Updated"]) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { teamDetailHref, userDetailHref } from "@/utils/entityLinks";
|
||||
|
||||
interface MemoryRowActionsProps {
|
||||
row: MemoryRow;
|
||||
|
|
@ -109,7 +110,10 @@ export const getMemoryTableColumns = ({
|
|||
header: "User ID",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.user_id} />,
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.user_id;
|
||||
return <IdCell value={userId} href={userId ? userDetailHref(userId) : undefined} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "team_id",
|
||||
|
|
@ -118,7 +122,10 @@ export const getMemoryTableColumns = ({
|
|||
header: "Team ID",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.team_id} />,
|
||||
cell: ({ row }) => {
|
||||
const teamId = row.original.team_id;
|
||||
return <IdCell value={teamId} href={teamId ? teamDetailHref(teamId) : undefined} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated_at",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import { IdCell } from "./id_cell";
|
||||
|
||||
const { routerPushMock } = vi.hoisted(() => ({ routerPushMock: vi.fn() }));
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: routerPushMock }) }));
|
||||
|
||||
const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/utils/dataUtils", async (importOriginal) => ({
|
||||
|
|
@ -83,4 +87,23 @@ describe("IdCell", () => {
|
|||
render(<IdCell value="k-1" dataTestId="key-id-cell" />);
|
||||
expect(screen.getByTestId("key-id-cell")).toHaveTextContent("k-1");
|
||||
});
|
||||
|
||||
it("renders the id as a link and routes client side when href is set", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<IdCell value="user-42" href="/ui/users?user=user-42" />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "user-42" });
|
||||
expect(link).toHaveAttribute("href", "/ui/users?user=user-42");
|
||||
expect(link).toHaveClass("cursor-pointer");
|
||||
|
||||
await user.click(link);
|
||||
expect(routerPushMock).toHaveBeenCalledWith("/ui/users?user=user-42");
|
||||
});
|
||||
|
||||
it("stays plain text when href is undefined", () => {
|
||||
render(<IdCell value="default_user_id" href={undefined} />);
|
||||
|
||||
expect(screen.getByText("default_user_id").tagName).toBe("SPAN");
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useEntityLinkClick } from "@/components/shared/EntityLink";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ export type IdCellVariant = "pill" | "plain";
|
|||
interface IdCellProps {
|
||||
value: string | null | undefined;
|
||||
variant?: IdCellVariant;
|
||||
href?: string;
|
||||
onClick?: (value: string) => void;
|
||||
copyable?: boolean;
|
||||
copyLabel?: string;
|
||||
|
|
@ -38,6 +40,7 @@ const VARIANT_CLASS: Record<IdCellVariant, { base: string; clickable: string }>
|
|||
export function IdCell({
|
||||
value,
|
||||
variant = "pill",
|
||||
href,
|
||||
onClick,
|
||||
copyable = false,
|
||||
copyLabel = "Copy ID",
|
||||
|
|
@ -52,16 +55,17 @@ export function IdCell({
|
|||
return <span className="text-muted-foreground">{fallback}</span>;
|
||||
}
|
||||
|
||||
const linked = !!href && !disabled;
|
||||
const clickable = !!onClick && !disabled;
|
||||
const classes = cn(
|
||||
VARIANT_CLASS[variant].base,
|
||||
clickable && VARIANT_CLASS[variant].clickable,
|
||||
(linked || clickable) && VARIANT_CLASS[variant].clickable,
|
||||
truncate && "block max-w-[15ch] truncate",
|
||||
disabled && "opacity-50",
|
||||
className,
|
||||
);
|
||||
|
||||
const idElement = clickable ? (
|
||||
const unlinkedElement = clickable ? (
|
||||
<button type="button" className={classes} data-testid={dataTestId} onClick={() => onClick(value)}>
|
||||
{value}
|
||||
</button>
|
||||
|
|
@ -71,6 +75,14 @@ export function IdCell({
|
|||
</span>
|
||||
);
|
||||
|
||||
const idElement = linked ? (
|
||||
<IdLink href={href} className={classes} dataTestId={dataTestId}>
|
||||
{value}
|
||||
</IdLink>
|
||||
) : (
|
||||
unlinkedElement
|
||||
);
|
||||
|
||||
const withTooltip = <CellTooltip content={tooltip ?? value} trigger={idElement} />;
|
||||
|
||||
if (!copyable) {
|
||||
|
|
@ -94,3 +106,21 @@ export function IdCell({
|
|||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface IdLinkProps extends React.ComponentPropsWithoutRef<"a"> {
|
||||
href: string;
|
||||
dataTestId?: string;
|
||||
}
|
||||
|
||||
const IdLink = React.forwardRef<HTMLAnchorElement, IdLinkProps>(function IdLink(
|
||||
{ href, dataTestId, children, ...props },
|
||||
ref,
|
||||
) {
|
||||
const handleClick = useEntityLinkClick(href);
|
||||
|
||||
return (
|
||||
<a {...props} ref={ref} href={href} data-testid={dataTestId} onClick={handleClick}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue