mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
refactor(ui): migrate policy attachments table onto shared DataTable (#33827)
* refactor(ui): migrate policy attachments table onto shared DataTable * refactor(ui): pass a specific success message to the attachment copy action
This commit is contained in:
parent
e18966625d
commit
377d54e694
7 changed files with 309 additions and 385 deletions
|
|
@ -881,19 +881,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/policies/_components/attachment_table.test.tsx": {
|
||||
"react/display-name": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/policies/_components/attachment_table.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/policies/_components/guardrail_selection_modal.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,63 +1,17 @@
|
|||
import React from "react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AttachmentTable from "./attachment_table";
|
||||
import AttachmentTable from "./AttachmentTable";
|
||||
import { PolicyAttachment } from "@/components/policies/types";
|
||||
|
||||
vi.mock("./impact_popover", () => ({
|
||||
default: () => <button aria-label="View blast radius" />,
|
||||
}));
|
||||
|
||||
vi.mock("@heroicons/react/outline", () => ({
|
||||
TrashIcon: function TrashIcon() {
|
||||
return null;
|
||||
},
|
||||
SwitchVerticalIcon: function SwitchVerticalIcon() {
|
||||
return null;
|
||||
},
|
||||
ChevronUpIcon: function ChevronUpIcon() {
|
||||
return null;
|
||||
},
|
||||
ChevronDownIcon: function ChevronDownIcon() {
|
||||
return null;
|
||||
default: function ImpactPopoverMock() {
|
||||
return <button aria-label="View blast radius" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tremor/react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tremor/react")>();
|
||||
return {
|
||||
...actual,
|
||||
Button: React.forwardRef<HTMLButtonElement, any>(({ children, ...props }, ref) =>
|
||||
React.createElement("button", { ...props, ref }, children),
|
||||
),
|
||||
Tooltip: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
Switch: ({
|
||||
checked,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
checked?: boolean;
|
||||
onChange?: (v: boolean) => void;
|
||||
className?: string;
|
||||
}) =>
|
||||
React.createElement("input", {
|
||||
type: "checkbox",
|
||||
role: "switch",
|
||||
checked,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onChange?.(e.target.checked),
|
||||
className,
|
||||
}),
|
||||
Icon: ({ icon: IconComp, onClick, className }: any) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{ type: "button", onClick, className },
|
||||
IconComp?.displayName ?? IconComp?.name ?? "icon",
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const makeAttachment = (overrides: Partial<PolicyAttachment> = {}): PolicyAttachment => ({
|
||||
attachment_id: "att-abcdef1",
|
||||
policy_name: "my-policy",
|
||||
|
|
@ -82,19 +36,26 @@ describe("AttachmentTable", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
it("should render column headers", () => {
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} />);
|
||||
expect(screen.getByText("Attachment ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("Policy")).toBeInTheDocument();
|
||||
expect(screen.getByText("Scope")).toBeInTheDocument();
|
||||
expect(screen.getByText("Teams")).toBeInTheDocument();
|
||||
expect(screen.getByText("Keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tags")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created At")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a loading message when isLoading is true", () => {
|
||||
it("should show skeleton rows when isLoading is true", () => {
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} isLoading />);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show 'No attachments found' when there are no attachments", () => {
|
||||
it("should show the empty state when there are no attachments", () => {
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} />);
|
||||
expect(screen.getByText(/no attachments found/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("No attachments found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a row for each attachment", () => {
|
||||
|
|
@ -107,13 +68,24 @@ describe("AttachmentTable", () => {
|
|||
expect(screen.getByText("policy-beta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should sort rows by created_at descending by default", () => {
|
||||
const attachments = [
|
||||
makeAttachment({ attachment_id: "att-old0001", policy_name: "older-policy", created_at: "2024-01-01T00:00:00Z" }),
|
||||
makeAttachment({ attachment_id: "att-new0001", policy_name: "newer-policy", created_at: "2025-06-01T00:00:00Z" }),
|
||||
];
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} attachments={attachments} />);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(within(rows[0]).getByText("newer-policy")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText("older-policy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Global (*)' badge when scope is '*'", () => {
|
||||
const attachments = [makeAttachment({ scope: "*" })];
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} attachments={attachments} />);
|
||||
expect(screen.getByText("Global (*)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show team tags when the attachment has teams", () => {
|
||||
it("should show team chips when the attachment has teams", () => {
|
||||
const attachments = [makeAttachment({ teams: ["team-alpha", "team-beta"] })];
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} attachments={attachments} />);
|
||||
expect(screen.getByText("team-alpha")).toBeInTheDocument();
|
||||
|
|
@ -126,18 +98,37 @@ describe("AttachmentTable", () => {
|
|||
expect(screen.getByText("+2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onDeleteClick with the attachment_id when the delete icon is clicked", async () => {
|
||||
it("should call onDeleteClick with the attachment_id from the actions menu", async () => {
|
||||
const attachment = makeAttachment({ attachment_id: "att-del-me1" });
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} attachments={[attachment]} />);
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(screen.getByTestId("attachment-actions-att-del-me1"));
|
||||
await user.click(await screen.findByTestId("attachment-action-delete"));
|
||||
expect(defaultProps.onDeleteClick).toHaveBeenCalledWith("att-del-me1");
|
||||
});
|
||||
|
||||
it("should not show the delete icon for non-admins", () => {
|
||||
it("should not show the delete item for non-admins", async () => {
|
||||
const attachment = makeAttachment({ attachment_id: "att-nonadmin" });
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} attachments={[attachment]} isAdmin={false} />);
|
||||
await user.click(screen.getByTestId("attachment-actions-att-nonadmin"));
|
||||
expect(await screen.findByTestId("attachment-action-copy-id")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("attachment-action-delete")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should copy the attachment id from the actions menu", async () => {
|
||||
const attachment = makeAttachment({ attachment_id: "att-copy-me1" });
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} attachments={[attachment]} />);
|
||||
await user.click(screen.getByTestId("attachment-actions-att-copy-me1"));
|
||||
await user.click(await screen.findByTestId("attachment-action-copy-id"));
|
||||
expect(await window.navigator.clipboard.readText()).toBe("att-copy-me1");
|
||||
});
|
||||
|
||||
it("should show the blast radius action for non-admins", () => {
|
||||
const attachment = makeAttachment();
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} attachments={[attachment]} isAdmin={false} />);
|
||||
expect(screen.queryByRole("button", { name: /TrashIcon/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "View blast radius" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the attachment ID as truncated plain mono text", () => {
|
||||
|
|
@ -149,7 +140,7 @@ describe("AttachmentTable", () => {
|
|||
expect(idElement.className).not.toContain("bg-blue-50");
|
||||
});
|
||||
|
||||
it("should render model tags when the attachment has models", () => {
|
||||
it("should render model chips when the attachment has models", () => {
|
||||
const attachments = [makeAttachment({ models: ["gpt-4", "claude-3"] })];
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} attachments={attachments} />);
|
||||
expect(screen.getByText("gpt-4")).toBeInTheDocument();
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { PolicyAttachment } from "@/components/policies/types";
|
||||
|
||||
import { getAttachmentTableColumns } from "./AttachmentTableColumns";
|
||||
|
||||
interface AttachmentTableProps {
|
||||
attachments: PolicyAttachment[];
|
||||
isLoading: boolean;
|
||||
onDeleteClick: (attachmentId: string) => void;
|
||||
isAdmin: boolean;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No attachments found</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Attach a policy to teams, keys, models, or tags to control where it applies.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
||||
attachments,
|
||||
isLoading,
|
||||
onDeleteClick,
|
||||
isAdmin,
|
||||
accessToken,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const deps = { isAdmin, accessToken, onDeleteClick };
|
||||
return getAttachmentTableColumns(deps);
|
||||
}, [isAdmin, accessToken, onDeleteClick]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={attachments}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.attachment_id}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading attachments…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentTable;
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Copy, MoreHorizontal, Trash2 } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { PolicyAttachment } from "@/components/policies/types";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
import ImpactPopover from "./impact_popover";
|
||||
|
||||
function ChipList({ values }: { values: string[] }) {
|
||||
if (values.length === 0) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{values.slice(0, 2).map((value) => (
|
||||
<StatusBadge key={value} tone="neutral" label={value} />
|
||||
))}
|
||||
{values.length > 2 && (
|
||||
<StatusBadge tone="neutral" label={`+${values.length - 2}`} tooltip={values.slice(2).join(", ")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttachmentRowActionsProps {
|
||||
attachment: PolicyAttachment;
|
||||
isAdmin: boolean;
|
||||
onDeleteClick: (attachmentId: string) => void;
|
||||
}
|
||||
|
||||
function AttachmentRowActions({ attachment, isAdmin, onDeleteClick }: AttachmentRowActionsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open attachment actions"
|
||||
data-testid={`attachment-actions-${attachment.attachment_id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem
|
||||
data-testid="attachment-action-copy-id"
|
||||
onClick={() => void copyToClipboard(attachment.attachment_id, "Attachment ID copied")}
|
||||
>
|
||||
<Copy />
|
||||
Copy attachment ID
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
data-testid="attachment-action-delete"
|
||||
onClick={() => onDeleteClick(attachment.attachment_id)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete attachment
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttachmentTableColumnsDeps {
|
||||
isAdmin: boolean;
|
||||
accessToken: string | null;
|
||||
onDeleteClick: (attachmentId: string) => void;
|
||||
}
|
||||
|
||||
export const getAttachmentTableColumns = ({
|
||||
isAdmin,
|
||||
accessToken,
|
||||
onDeleteClick,
|
||||
}: AttachmentTableColumnsDeps): ColumnDef<PolicyAttachment>[] => [
|
||||
{
|
||||
id: "attachment_id",
|
||||
accessorKey: "attachment_id",
|
||||
meta: { title: "Attachment ID" },
|
||||
header: "Attachment ID",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.attachment_id} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "policy_name",
|
||||
accessorKey: "policy_name",
|
||||
meta: { title: "Policy", skeleton: "badge" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Policy" />,
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <StatusBadge tone="info" label={row.original.policy_name} />,
|
||||
},
|
||||
{
|
||||
id: "scope",
|
||||
accessorFn: (row) => row.scope ?? "",
|
||||
meta: { title: "Scope", skeleton: "badge" },
|
||||
header: "Scope",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const scope = row.original.scope;
|
||||
if (!scope) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
if (scope === "*") {
|
||||
return <StatusBadge tone="warning" label="Global (*)" />;
|
||||
}
|
||||
return (
|
||||
<span className="block max-w-40 truncate text-xs" title={scope}>
|
||||
{scope}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
meta: { title: "Teams", skeleton: "chips" },
|
||||
header: "Teams",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ChipList values={row.original.teams ?? []} />,
|
||||
},
|
||||
{
|
||||
id: "keys",
|
||||
meta: { title: "Keys", skeleton: "chips" },
|
||||
header: "Keys",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ChipList values={row.original.keys ?? []} />,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ChipList values={row.original.models ?? []} />,
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
meta: { title: "Tags", skeleton: "chips" },
|
||||
header: "Tags",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ChipList values={row.original.tags ?? []} />,
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorFn: (row) => row.created_at ?? "",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 88,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ImpactPopover attachment={row.original} accessToken={accessToken} />
|
||||
<AttachmentRowActions attachment={row.original} isAdmin={isAdmin} onDeleteClick={onDeleteClick} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon, Badge } from "@tremor/react";
|
||||
import { TrashIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
|
||||
import { Tooltip, Tag } from "antd";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import { PolicyAttachment } from "@/components/policies/types";
|
||||
import ImpactPopover from "./impact_popover";
|
||||
|
||||
interface AttachmentTableProps {
|
||||
attachments: PolicyAttachment[];
|
||||
isLoading: boolean;
|
||||
onDeleteClick: (attachmentId: string) => void;
|
||||
isAdmin: boolean;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
||||
attachments,
|
||||
isLoading,
|
||||
onDeleteClick,
|
||||
isAdmin,
|
||||
accessToken,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
|
||||
|
||||
const columns: ColumnDef<PolicyAttachment>[] = [
|
||||
{
|
||||
header: "Attachment ID",
|
||||
accessorKey: "attachment_id",
|
||||
cell: (info: any) => <IdCell value={info.getValue()} variant="plain" />,
|
||||
},
|
||||
{
|
||||
header: "Policy",
|
||||
accessorKey: "policy_name",
|
||||
cell: ({ row }) => {
|
||||
const attachment = row.original;
|
||||
return (
|
||||
<Badge color="blue" size="xs">
|
||||
{attachment.policy_name}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Scope",
|
||||
accessorKey: "scope",
|
||||
cell: ({ row }) => {
|
||||
const attachment = row.original;
|
||||
if (attachment.scope === "*") {
|
||||
return (
|
||||
<Badge color="amber" size="xs">
|
||||
Global (*)
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return attachment.scope ? (
|
||||
<span className="text-xs">{attachment.scope}</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Teams",
|
||||
accessorKey: "teams",
|
||||
cell: ({ row }) => {
|
||||
const attachment = row.original;
|
||||
const teams = attachment.teams || [];
|
||||
if (teams.length === 0) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{teams.slice(0, 2).map((t, i) => (
|
||||
<Tag key={i} color="cyan" className="text-xs">
|
||||
{t}
|
||||
</Tag>
|
||||
))}
|
||||
{teams.length > 2 && (
|
||||
<Tooltip title={teams.slice(2).join(", ")}>
|
||||
<Tag className="text-xs">+{teams.length - 2}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Keys",
|
||||
accessorKey: "keys",
|
||||
cell: ({ row }) => {
|
||||
const attachment = row.original;
|
||||
const keys = attachment.keys || [];
|
||||
if (keys.length === 0) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{keys.slice(0, 2).map((k, i) => (
|
||||
<Tag key={i} color="purple" className="text-xs">
|
||||
{k}
|
||||
</Tag>
|
||||
))}
|
||||
{keys.length > 2 && (
|
||||
<Tooltip title={keys.slice(2).join(", ")}>
|
||||
<Tag className="text-xs">+{keys.length - 2}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Models",
|
||||
accessorKey: "models",
|
||||
cell: ({ row }) => {
|
||||
const attachment = row.original;
|
||||
const models = attachment.models || [];
|
||||
if (models.length === 0) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{models.slice(0, 2).map((m, i) => (
|
||||
<Tag key={i} color="green" className="text-xs">
|
||||
{m}
|
||||
</Tag>
|
||||
))}
|
||||
{models.length > 2 && (
|
||||
<Tooltip title={models.slice(2).join(", ")}>
|
||||
<Tag className="text-xs">+{models.length - 2}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Tags",
|
||||
accessorKey: "tags",
|
||||
cell: ({ row }) => {
|
||||
const attachment = row.original;
|
||||
const tags = attachment.tags || [];
|
||||
if (tags.length === 0) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.slice(0, 2).map((t, i) => (
|
||||
<Tag key={i} color="orange" className="text-xs">
|
||||
{t}
|
||||
</Tag>
|
||||
))}
|
||||
{tags.length > 2 && (
|
||||
<Tooltip title={tags.slice(2).join(", ")}>
|
||||
<Tag className="text-xs">+{tags.length - 2}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const attachment = row.original;
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<ImpactPopover attachment={attachment} accessToken={accessToken} />
|
||||
{isAdmin && (
|
||||
<Tooltip title="Delete attachment">
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => onDeleteClick(attachment.attachment_id)}
|
||||
className="cursor-pointer hover:text-red-500"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: attachments,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
enableSorting: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
className={`py-1 h-8 ${
|
||||
header.id === "actions" ? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]" : ""
|
||||
}`}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center">
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
{header.id !== "actions" && (
|
||||
<div className="w-4">
|
||||
{header.column.getIsSorted() ? (
|
||||
{
|
||||
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
|
||||
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
|
||||
}[header.column.getIsSorted() as string]
|
||||
) : (
|
||||
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : attachments.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
|
||||
cell.column.id === "actions"
|
||||
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>No attachments found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentTable;
|
||||
|
|
@ -56,21 +56,6 @@ vi.mock("./impact_popover", () => ({
|
|||
default: () => <button type="button" aria-label="View blast radius" />,
|
||||
}));
|
||||
|
||||
vi.mock("@heroicons/react/outline", () => ({
|
||||
TrashIcon: function TrashIcon() {
|
||||
return null;
|
||||
},
|
||||
SwitchVerticalIcon: function SwitchVerticalIcon() {
|
||||
return null;
|
||||
},
|
||||
ChevronUpIcon: function ChevronUpIcon() {
|
||||
return null;
|
||||
},
|
||||
ChevronDownIcon: function ChevronDownIcon() {
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tremor/react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tremor/react")>();
|
||||
return {
|
||||
|
|
@ -95,8 +80,6 @@ vi.mock("@tremor/react", async (importOriginal) => {
|
|||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onChange?.(e.target.checked),
|
||||
className,
|
||||
}),
|
||||
Icon: ({ icon: _IconComp, onClick, className }: any) =>
|
||||
React.createElement("button", { type: "button", onClick, className }, "TrashIcon"),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -163,7 +146,8 @@ describe("PoliciesPanel attachment delete", () => {
|
|||
expect(screen.getByText("test-policy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(screen.getByTestId(`attachment-actions-${EXPECTED_ATTACHMENT_ID}`));
|
||||
await user.click(await screen.findByTestId("attachment-action-delete"));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", {}, { timeout: 5000 });
|
||||
expect(within(dialog).getByText(/Are you sure you want to delete this attachment/i)).toBeInTheDocument();
|
||||
|
|
@ -195,7 +179,8 @@ describe("PoliciesPanel attachment delete", () => {
|
|||
expect(screen.getByText("test-policy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(screen.getByTestId(`attachment-actions-${EXPECTED_ATTACHMENT_ID}`));
|
||||
await user.click(await screen.findByTestId("attachment-action-delete"));
|
||||
const dialog = await screen.findByRole("dialog", {}, { timeout: 5000 });
|
||||
|
||||
const deleteButton = within(dialog).getByRole("button", { name: /^delete$/i });
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import PolicyTable from "./PolicyTable";
|
|||
import PolicyInfoView from "./policy_info";
|
||||
import AddPolicyForm from "./add_policy_form";
|
||||
import { FlowBuilderPage } from "./pipeline_flow_builder";
|
||||
import AttachmentTable from "./attachment_table";
|
||||
import AttachmentTable from "./AttachmentTable";
|
||||
import AddAttachmentForm from "./add_attachment_form";
|
||||
import PolicyTestPanel from "./policy_test_panel";
|
||||
import PolicyTemplates from "./policy_templates";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue