diff --git a/.changeset/table-row-context-menus.md b/.changeset/table-row-context-menus.md new file mode 100644 index 00000000000..94c8c4c3ec0 --- /dev/null +++ b/.changeset/table-row-context-menus.md @@ -0,0 +1,5 @@ +--- +"dashboard": minor +--- + +Add right-click context menus to every table row, card, and list entry with per-entry actions, mirroring each entry's "⋯" menu: sources, deployments, exclusions, policy center, shadow MCP inventory, team members, roles, remote identity provider tabs, tool lists, chat logs, project cards, and plugin cards. Menus share one action definition with the visible kebab so the two stay in sync, and sources table rows are now real links (native open-in-new-tab and copy-link). diff --git a/client/dashboard/package.json b/client/dashboard/package.json index 1ca52dad5d7..c39349d040b 100644 --- a/client/dashboard/package.json +++ b/client/dashboard/package.json @@ -54,7 +54,7 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", "@react-three/postprocessing": "^3.0.4", - "@speakeasy-api/moonshine": "1.43.1", + "@speakeasy-api/moonshine": "1.44.0", "@tailwindcss/vite": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-virtual": "^3.14.5", diff --git a/client/dashboard/src/components/card-context-menu.tsx b/client/dashboard/src/components/card-context-menu.tsx index ef80df53f39..f5d3899bff7 100644 --- a/client/dashboard/src/components/card-context-menu.tsx +++ b/client/dashboard/src/components/card-context-menu.tsx @@ -1,9 +1,11 @@ import { Icon } from "@speakeasy-api/moonshine"; +import * as React from "react"; import { cn } from "@/lib/utils"; import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuTrigger, } from "./ui/context-menu"; import type { Action } from "./ui/more-actions"; @@ -38,21 +40,47 @@ export function CardContextMenu({
{children}
- - {actions.map((action, index) => ( + + + ); +} + +/** + * The `ContextMenuContent` for a right-click menu built from an `Action[]`. + * Shared by CardContextMenu and TableRowContextMenu so every context menu in + * the app maps actions to items the same way. + */ +export function ActionContextMenuContent({ + actions, +}: { + actions: Action[]; +}): React.JSX.Element { + return ( + + {actions.map((action, index) => ( + + {action.separatorBefore && index > 0 && } action.onClick()} > - {action.label} + {action.description ? ( + + {action.label} + + {action.description} + + + ) : ( + action.label + )} {action.icon && ( )} - ))} - - + + ))} + ); } diff --git a/client/dashboard/src/components/shadow-mcp/ShadowMCPInventoryActions.tsx b/client/dashboard/src/components/shadow-mcp/ShadowMCPInventoryActions.tsx index 5a6104a8f78..c098fe7c482 100644 --- a/client/dashboard/src/components/shadow-mcp/ShadowMCPInventoryActions.tsx +++ b/client/dashboard/src/components/shadow-mcp/ShadowMCPInventoryActions.tsx @@ -11,6 +11,7 @@ import { } from "@/components/ui/sheet"; import { Type } from "@/components/ui/type"; import { cn } from "@/lib/utils"; +import { shadowMCPInventoryActions } from "./shadowMCPInventoryActionItems"; import type { AccessMember } from "@gram/client/models/components/accessmember.js"; import type { Role } from "@gram/client/models/components/role.js"; import type { RiskPolicy } from "@gram/client/models/components/riskpolicy.js"; @@ -146,11 +147,6 @@ function initialPolicyIDsForAction( return shadowMCPPolicyIDs; } -function openActionFromMenu(event: Event, openAction: () => void) { - event.stopPropagation(); - window.setTimeout(openAction, 0); -} - export function ShadowMCPInventoryActionMenu({ disabled, onOpenAction, @@ -163,8 +159,7 @@ export function ShadowMCPInventoryActionMenu({ ) => void; server: ShadowMCPInventoryServer; }): JSX.Element { - const hasRequest = server.requestCount > 0; - const hasAllowDecision = server.access === "allowed"; + const actions = shadowMCPInventoryActions(server, { disabled, onOpenAction }); return ( @@ -183,42 +178,17 @@ export function ShadowMCPInventoryActionMenu({ align="end" onClick={(event) => event.stopPropagation()} > - {hasRequest && ( + {actions.map((action, index) => ( { - openActionFromMenu(event, () => onOpenAction("review", server)); + event.stopPropagation(); + action.onClick(); }} > - Review Request + {action.label} - )} - {!hasRequest && !hasAllowDecision && ( - { - openActionFromMenu(event, () => onOpenAction("add", server)); - }} - > - Add Allow Rule - - )} - {hasAllowDecision && ( - <> - { - openActionFromMenu(event, () => onOpenAction("edit", server)); - }} - > - Edit Rule - - { - openActionFromMenu(event, () => onOpenAction("delete", server)); - }} - > - Delete Rule - - - )} + ))} ); diff --git a/client/dashboard/src/components/shadow-mcp/ShadowMCPInventoryTable.tsx b/client/dashboard/src/components/shadow-mcp/ShadowMCPInventoryTable.tsx index 35623451b67..21cde16260e 100644 --- a/client/dashboard/src/components/shadow-mcp/ShadowMCPInventoryTable.tsx +++ b/client/dashboard/src/components/shadow-mcp/ShadowMCPInventoryTable.tsx @@ -22,6 +22,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { formatShortDate } from "@/components/access/shadow-mcp-utils"; +import { TableRowContextMenu } from "@/components/table-row-context-menu"; import { cn } from "@/lib/utils"; import { type ActiveInventoryAction, @@ -30,6 +31,7 @@ import { ShadowMCPInventoryActionSheet, type ShadowMCPPolicy, } from "./ShadowMCPInventoryActions"; +import { shadowMCPInventoryActions } from "./shadowMCPInventoryActionItems"; import { shadowMCPInventoryStatus, shadowMCPInventoryStatusBadgeVariant, @@ -460,6 +462,18 @@ export function ShadowMCPInventoryTable({ onRowClick={onOpenServer} rowKey={(row) => row.canonicalServerUrl} className="min-h-0 content-start overflow-y-auto" + renderRow={(row, rowElement) => ( + + setActiveAction({ mode, server: selectedServer }), + })} + > + {rowElement} + + )} /> diff --git a/client/dashboard/src/components/shadow-mcp/shadowMCPInventoryActionItems.ts b/client/dashboard/src/components/shadow-mcp/shadowMCPInventoryActionItems.ts new file mode 100644 index 00000000000..24b50c6a363 --- /dev/null +++ b/client/dashboard/src/components/shadow-mcp/shadowMCPInventoryActionItems.ts @@ -0,0 +1,59 @@ +import type { Action } from "@/components/ui/more-actions"; +import type { ShadowMCPInventoryServer } from "@gram/client/models/components/shadowmcpinventoryserver.js"; +import type { InventoryActionMode } from "./ShadowMCPInventoryActions"; + +/** + * The per-server action set, shared by the visible "⋯" dropdown and the row's + * right-click context menu so the two stay in sync. The entries are additive + * over the server's state — a server with both a pending request and an + * existing allow rule offers Review Request AND Edit/Delete Rule. Each + * onClick defers via setTimeout so the menu can close before a sheet opens. + */ +export function shadowMCPInventoryActions( + server: ShadowMCPInventoryServer, + { + disabled, + onOpenAction, + }: { + disabled: boolean; + onOpenAction: ( + mode: InventoryActionMode, + server: ShadowMCPInventoryServer, + ) => void; + }, +): Action[] { + const hasRequest = server.requestCount > 0; + const hasAllowDecision = server.access === "allowed"; + + const openAction = (mode: InventoryActionMode) => () => { + window.setTimeout(() => onOpenAction(mode, server), 0); + }; + + const actions: Action[] = []; + if (hasRequest) { + actions.push({ + label: "Review Request", + disabled, + onClick: openAction("review"), + }); + } + if (!hasRequest && !hasAllowDecision) { + actions.push({ + label: "Add Allow Rule", + disabled, + onClick: openAction("add"), + }); + } + if (hasAllowDecision) { + actions.push( + { label: "Edit Rule", disabled, onClick: openAction("edit") }, + { + label: "Delete Rule", + destructive: true, + disabled, + onClick: openAction("delete"), + }, + ); + } + return actions; +} diff --git a/client/dashboard/src/components/sources/SourceTableRow.tsx b/client/dashboard/src/components/sources/SourceTableRow.tsx index 95f6262bf23..7eb49cc6459 100644 --- a/client/dashboard/src/components/sources/SourceTableRow.tsx +++ b/client/dashboard/src/components/sources/SourceTableRow.tsx @@ -1,3 +1,4 @@ +import { TableRowContextMenu } from "@/components/table-row-context-menu"; import { DotRow } from "@/components/ui/dot-row"; import { MoreActions } from "@/components/ui/more-actions"; import { Type } from "@/components/ui/type"; @@ -125,71 +126,74 @@ export function SourceTableRow({ : asset.name; return ( - routes.sources.source.goTo(sourceKind, asset.slug)} - > - {/* Name */} - - - {displayName} - - + + + {/* Name */} + + + {displayName} + + - {/* Type */} - - {sourceTypeLabel} - + {/* Type */} + + {sourceTypeLabel} + - {/* Tools */} - - - {toolCount} - - + {/* Tools */} + + + {toolCount} + + - {/* Created */} - - - {formatDate(createdAt)} - - + {/* Created */} + + + {formatDate(createdAt)} + + - {/* Updated */} - - - {formatDate(updatedAt)} - - + {/* Updated */} + + + {formatDate(updatedAt)} + + - {/* Health */} - - {causingFailure && ( -
- - - Error - -
- )} - + {/* Health */} + + {causingFailure && ( +
+ + + Error + +
+ )} + - {/* Actions */} - - {actions.length > 0 && ( -
e.stopPropagation()} - > - -
- )} - -
+ {/* Actions */} + + {actions.length > 0 && ( +
e.stopPropagation()} + > + +
+ )} + +
+ ); } diff --git a/client/dashboard/src/components/table-row-context-menu.test.tsx b/client/dashboard/src/components/table-row-context-menu.test.tsx new file mode 100644 index 00000000000..1acaba073a1 --- /dev/null +++ b/client/dashboard/src/components/table-row-context-menu.test.tsx @@ -0,0 +1,107 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Stub the Radix-backed primitive so we can assert TableRowContextMenu's +// mapping and selection behavior without driving a real right-click in +// happy-dom. +vi.mock("./ui/context-menu", () => ({ + ContextMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ContextMenuTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ContextMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ContextMenuItem: ({ + children, + onSelect, + disabled, + variant, + }: { + children: React.ReactNode; + onSelect?: () => void; + disabled?: boolean; + variant?: string; + }) => ( + + ), +})); +vi.mock("@speakeasy-api/moonshine", () => ({ + Icon: () => , +})); + +import { TableRowContextMenu } from "./table-row-context-menu"; + +afterEach(cleanup); + +describe("TableRowContextMenu", () => { + it("renders an item per action and invokes onClick on select", () => { + const del = vi.fn(); + const edit = vi.fn(); + render( + { + edit(); + }, + }, + { + label: "Delete", + onClick: () => { + del(); + }, + destructive: true, + }, + ]} + > +
row
+
, + ); + + expect(screen.getByText("Edit")).toBeTruthy(); + fireEvent.click(screen.getByText("Delete")); + expect(del).toHaveBeenCalledTimes(1); + expect(edit).not.toHaveBeenCalled(); + }); + + it("marks destructive actions with the destructive variant", () => { + render( + {} }, + { label: "Delete", onClick: () => {}, destructive: true }, + ]} + > +
row
+
, + ); + + expect(screen.getByText("Delete").getAttribute("data-variant")).toBe( + "destructive", + ); + expect(screen.getByText("Edit").getAttribute("data-variant")).toBe( + "default", + ); + }); + + it("renders children unwrapped when there are no actions", () => { + render( + +
row
+
, + ); + + expect(screen.getByTestId("row")).toBeTruthy(); + expect(screen.queryByText("Delete")).toBeNull(); + }); +}); diff --git a/client/dashboard/src/components/table-row-context-menu.tsx b/client/dashboard/src/components/table-row-context-menu.tsx new file mode 100644 index 00000000000..e9dd4124f87 --- /dev/null +++ b/client/dashboard/src/components/table-row-context-menu.tsx @@ -0,0 +1,30 @@ +import { ContextMenu, ContextMenuTrigger } from "./ui/context-menu"; +import { ActionContextMenuContent } from "./card-context-menu"; +import type { Action } from "./ui/more-actions"; + +/** + * Row variant of CardContextMenu. Wraps a single row element (``, list + * row, row button) in a right-click menu of the same `Action[]` the row + * feeds its visible "⋯" menu — keeping the two in sync. Uses `asChild`, so + * `children` must be one element that forwards refs and props (DotRow, + * moonshine Table rows via `renderRow`, native elements). Renders children + * unwrapped when `actions` is empty, so it's a safe no-op to apply broadly. + */ +export function TableRowContextMenu({ + actions, + children, +}: { + actions: Action[]; + children: React.ReactElement; +}): React.JSX.Element { + if (actions.length === 0) { + return <>{children}; + } + + return ( + + {children} + + + ); +} diff --git a/client/dashboard/src/components/tool-list/ToolList.tsx b/client/dashboard/src/components/tool-list/ToolList.tsx index 11b45a10371..17e50522c97 100644 --- a/client/dashboard/src/components/tool-list/ToolList.tsx +++ b/client/dashboard/src/components/tool-list/ToolList.tsx @@ -4,6 +4,7 @@ import { Dialog } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { MoreActions } from "@/components/ui/more-actions"; import { TextArea } from "@/components/ui/textarea"; +import { TableRowContextMenu } from "@/components/table-row-context-menu"; import { TagsVariationEditor } from "@/components/tool-variation-tags-editor"; import { useCommandPalette } from "@/contexts/CommandPalette"; import { useLatestDeployment } from "@/hooks/toolTypes"; @@ -444,59 +445,61 @@ function ToolRow({ return ( <> -
onToolClick?.(tool)} - > -
- {!readOnly && ( - e.stopPropagation()} - className={cn( - "shrink-0 transition-opacity", - !isSelected && - !isFocused && - "opacity-0 group-hover:opacity-100", - )} - /> + +
- -

- {toolPrefix && ( - - {toolPrefix} - + onClick={() => onToolClick?.(tool)} + > +

+ {!readOnly && ( + e.stopPropagation()} + className={cn( + "shrink-0 transition-opacity", + !isSelected && + !isFocused && + "opacity-0 group-hover:opacity-100", )} - {toolNameNoPrefix} + /> + )} +
+ +

+ {toolPrefix && ( + + {toolPrefix} + + )} + {toolNameNoPrefix} +

+ + +
+

+ {tool.description || "No description"}

- - - -

- {tool.description || "No description"} -

+
+
+
+ {tool.type === "http" && tool.httpMethod && ( + + )} + {tool.type === "prompt" && ( + + )} + {!readOnly && }
-
- {tool.type === "http" && tool.httpMethod && ( - - )} - {tool.type === "prompt" && ( - - )} - {!readOnly && } -
-
+ diff --git a/client/dashboard/src/components/ui/context-menu.tsx b/client/dashboard/src/components/ui/context-menu.tsx index 36d96fd95ef..e0f864b196f 100644 --- a/client/dashboard/src/components/ui/context-menu.tsx +++ b/client/dashboard/src/components/ui/context-menu.tsx @@ -60,4 +60,25 @@ function ContextMenuItem({ ); } -export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem }; +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps< + typeof ContextMenuPrimitive.Separator +>): React.JSX.Element { + return ( + + ); +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, +}; diff --git a/client/dashboard/src/components/ui/more-actions.tsx b/client/dashboard/src/components/ui/more-actions.tsx index 5d75f7c5a06..847047c9599 100644 --- a/client/dashboard/src/components/ui/more-actions.tsx +++ b/client/dashboard/src/components/ui/more-actions.tsx @@ -16,6 +16,10 @@ export type Action = { onClick: () => void; disabled?: boolean; destructive?: boolean; + /** Secondary line under the label, e.g. why a disabled action is unavailable. */ + description?: string; + /** Render a separator above this item (context menus and custom dropdown renderers). */ + separatorBefore?: boolean; }; export function MoreActions({ diff --git a/client/dashboard/src/pages/access/RolesTab.tsx b/client/dashboard/src/pages/access/RolesTab.tsx index dfcd01d8667..e4ad20368be 100644 --- a/client/dashboard/src/pages/access/RolesTab.tsx +++ b/client/dashboard/src/pages/access/RolesTab.tsx @@ -28,10 +28,27 @@ import { DeleteRoleDialog } from "./DeleteRoleDialog"; import { MemberFacepile } from "@/components/member-facepile"; import { Ellipsis } from "lucide-react"; import { RequireScope } from "@/components/require-scope"; +import { TableRowContextMenu } from "@/components/table-row-context-menu"; +import type { Action } from "@/components/ui/more-actions"; import { useRBAC } from "@/hooks/useRBAC"; import { cn } from "@/lib/utils"; import { visiblePermissionCount } from "./roleDialogState"; +// Single source of truth for the per-role actions: the "⋯" dropdown and the +// row's right-click context menu both render from this list. Edit always; +// Delete only for non-system roles. +function roleActions( + role: Role, + { onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }, +): Action[] { + return [ + { label: "Edit", onClick: onEdit }, + ...(!role.isSystem + ? [{ label: "Delete", destructive: true, onClick: onDelete }] + : []), + ]; +} + function RoleActionsMenu({ role, onEdit, @@ -42,6 +59,7 @@ function RoleActionsMenu({ onDelete: () => void; }) { const [open, setOpen] = useState(false); + const actions = roleActions(role, { onEdit, onDelete }); return ( // Render-prop form: the dropdown content is portaled to , so it @@ -64,22 +82,16 @@ function RoleActionsMenu({ - { - void setTimeout(onEdit, 0); - }} - > - Edit - - {!role.isSystem && ( + {actions.map((action) => ( { - void setTimeout(onDelete, 0); + void setTimeout(action.onClick, 0); }} > - Delete + {action.label} - )} + ))} )} @@ -117,46 +129,54 @@ function RoleRow({ photoUrl: m.photoUrl, })); + // Mirror the row-actions menu via the shared builder. Without org:admin the + // menu is empty and the row stays unwrapped. + const actions: Action[] = canManageRoles + ? roleActions(role, { onEdit, onDelete }) + : []; + return ( -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onEdit(); + +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onEdit(); + } } - } - : undefined - } - className={cn( - "border-border col-span-full grid grid-cols-subgrid items-center gap-x-6 border-b px-4 py-3 last:border-b-0", - canManageRoles && "hover:bg-muted/50 cursor-pointer", - )} - > -
- - {role.name} - - {role.isSystem && ( - - System - + : undefined + } + className={cn( + "border-border col-span-full grid grid-cols-subgrid items-center gap-x-6 border-b px-4 py-3 last:border-b-0", + canManageRoles && "hover:bg-muted/50 cursor-pointer", )} + > +
+ + {role.name} + + {role.isSystem && ( + + System + + )} +
+ + {role.description} + + {visiblePermissionCount(role.grants)} + +
+
e.stopPropagation()} className="flex justify-end"> + +
- - {role.description} - - {visiblePermissionCount(role.grants)} - -
-
e.stopPropagation()} className="flex justify-end"> - -
-
+ ); } diff --git a/client/dashboard/src/pages/chatLogs/ChatLogsTable.tsx b/client/dashboard/src/pages/chatLogs/ChatLogsTable.tsx index 2d0ec543281..0aa063425ad 100644 --- a/client/dashboard/src/pages/chatLogs/ChatLogsTable.tsx +++ b/client/dashboard/src/pages/chatLogs/ChatLogsTable.tsx @@ -1,5 +1,6 @@ import { AccountTypeIcon } from "@/components/account-type-icon"; import { personalAccountEmail } from "@/components/observe/account-display-utils"; +import { TableRowContextMenu } from "@/components/table-row-context-menu"; import { Dialog } from "@/components/ui/dialog"; import { SimpleTooltip } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -212,115 +213,125 @@ export function ChatLogsTable({ chat.lastMessageTimestamp ?? chat.createdAt; return ( - + + ); })}
diff --git a/client/dashboard/src/pages/deployments/Deployments.tsx b/client/dashboard/src/pages/deployments/Deployments.tsx index 3d5b18cba39..86d00c0afa5 100644 --- a/client/dashboard/src/pages/deployments/Deployments.tsx +++ b/client/dashboard/src/pages/deployments/Deployments.tsx @@ -1,8 +1,16 @@ import { Page } from "@/components/page-layout"; import { RequireScope } from "@/components/require-scope"; +import { TableRowContextMenu } from "@/components/table-row-context-menu"; import { Heading } from "@/components/ui/heading"; +import type { Action } from "@/components/ui/more-actions"; +import { useRBAC } from "@/hooks/useRBAC"; import { useRoutes } from "@/routes"; import { useListDeploymentsSuspense } from "@gram/client/react-query/listDeployments.js"; +import { + mutationKeyRedeployDeployment, + type RedeployDeploymentMutationVariables, +} from "@gram/client/react-query/redeployDeployment.js"; +import { useMutationState } from "@tanstack/react-query"; import { Badge, Button, @@ -53,20 +61,35 @@ type DeploymentSummary = { externalMcpToolCount: number; }; -function DeploymentActionsDropdown({ - deployment, - latest, -}: { - deployment: DeploymentSummary; - latest: boolean; -}) { - const [isOpen, setIsOpen] = useState(false); - +function useDeploymentActions( + deployment: DeploymentSummary, + latest: boolean, + { onSettled }: { onSettled?: () => void } = {}, +): Action[] { const redeployMutation = useRedeployDeployment({ onSettled() { - setIsOpen(false); + onSettled?.(); + }, + }); + + // A redeploy can be started from either the kebab dropdown or the row's + // context menu, which are separate hook instances. Observe pending + // redeploys for this deployment across all instances via the shared + // mutation key so both menus show the pending label and disable together. + const pendingDeploymentIds = useMutationState({ + filters: { + mutationKey: mutationKeyRedeployDeployment(), + status: "pending", }, + select: (mutation) => + ( + mutation.state.variables as + | RedeployDeploymentMutationVariables + | undefined + )?.request.redeployRequestBody.deploymentId, }); + const isRedeploying = + redeployMutation.isPending || pendingDeploymentIds.includes(deployment.id); // Find the current deployment to check its status const isCompletedDeployment = deployment.status === "completed"; @@ -75,7 +98,7 @@ function DeploymentActionsDropdown({ // 1. Latest deployment (regardless of status) - shows "Retry" // 2. Completed deployments that are not the latest - shows "Redeploy" if (!latest && !isCompletedDeployment) { - return null; + return []; } const handleRedeploy = () => { @@ -88,7 +111,6 @@ function DeploymentActionsDropdown({ }); }; - const isRedeploying = redeployMutation.isPending; const actionText = latest ? "Retry Deployment" : "Rollback"; const buttonText = isRedeploying ? latest @@ -96,6 +118,35 @@ function DeploymentActionsDropdown({ : "Rolling Back..." : actionText; + return [ + { + icon: "refresh-cw", + label: buttonText, + disabled: isRedeploying, + onClick: handleRedeploy, + }, + ]; +} + +function DeploymentActionsDropdown({ + deployment, + latest, +}: { + deployment: DeploymentSummary; + latest: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + + const actions = useDeploymentActions(deployment, latest, { + onSettled() { + setIsOpen(false); + }, + }); + + if (actions.length === 0) { + return null; + } + return ( @@ -108,20 +159,50 @@ function DeploymentActionsDropdown({ - - - {buttonText} - + {actions.map((action, index) => ( + + {action.icon && ( + + )} + {action.label} + + ))} ); } +function DeploymentRowContextMenu({ + deployment, + latest, + children, +}: { + deployment: DeploymentSummary; + latest: boolean; + children: React.ReactElement; +}) { + const actions = useDeploymentActions(deployment, latest); + + // Same gate as the kebab's RequireScope, checked via RBAC directly: + // RequireScope's component-level wrapper would insert divs around the + // `` (invalid table markup) and gray out rows for read-only users, + // so the row renders unwrapped with an empty menu instead. + const { hasAnyScope } = useRBAC(); + const canWrite = hasAnyScope(["project:write"]); + + return ( + + {children} + + ); +} + function DeploymentsTable({ showHeader = true, }: { showHeader?: boolean } = {}) { @@ -258,6 +339,15 @@ function DeploymentsTable({ rowKey={(row) => row.id} data={deployments} className="mb-8 overflow-auto" + renderRow={(row, rowElement) => ( + + {rowElement} + + )} /> ); diff --git a/client/dashboard/src/pages/org/OrgHome.tsx b/client/dashboard/src/pages/org/OrgHome.tsx index a05af601300..c957475527c 100644 --- a/client/dashboard/src/pages/org/OrgHome.tsx +++ b/client/dashboard/src/pages/org/OrgHome.tsx @@ -5,9 +5,11 @@ import { ProjectAvatar } from "@/components/project-menu"; import { DEFAULT_DATE_RANGE_PRESET } from "@/components/observe/useDateRangeFilter"; import { buildProjectOverviewQuery } from "@/components/project/projectOverviewQuery"; import { RequireScope } from "@/components/require-scope"; +import { CardContextMenu } from "@/components/card-context-menu"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Heading } from "@/components/ui/heading"; +import type { Action } from "@/components/ui/more-actions"; import { SearchBar } from "@/components/ui/search-bar"; import { Tooltip, @@ -45,6 +47,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + type IconName, } from "@speakeasy-api/moonshine"; import { ChevronDown, @@ -60,6 +63,7 @@ import { ShieldCheck, Star, UserPlus, + type LucideIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "react-router"; @@ -540,61 +544,66 @@ function ProjectRow({ onToggleFavorite: () => void; }) { const { orgSlug } = useSlugs(); + const actions = useProjectActions(project, { isFavorite, onToggleFavorite }); return ( -
- {/* Decorative content: pointer-events-none routes clicks through to the - Link overlay below, while the actions region opts back in. */} - - -
-
- - {project.name} - - - {project.slug} - + + {/* The row div keeps `relative`, so the Link overlay below still fills + the row rather than the context-menu wrapper. */} +
+ {/* Decorative content: pointer-events-none routes clicks through to the + Link overlay below, while the actions region opts back in. */} + + +
+
+ + {project.name} + + + {project.slug} + +
+ +
+ +
-
- +
{ + // Keep clicks on the facepile from triggering the row's Link overlay. + e.preventDefault(); + e.stopPropagation(); + }} + > +
-
-
{ - // Keep clicks on the facepile from triggering the row's Link overlay. - e.preventDefault(); - e.stopPropagation(); - }} - > - -
+ - - - {/* Anchor overlay sits on top of pointer-events-none children, so the - entire row is one navigation target — interactive controls above - opt in via pointer-events-auto. */} - -
+ {/* Anchor overlay sits on top of pointer-events-none children, so the + entire row is one navigation target — interactive controls above + opt in via pointer-events-auto. */} + +
+ ); } @@ -612,69 +621,125 @@ function ProjectCard({ onToggleFavorite: () => void; }) { const { orgSlug } = useSlugs(); + const actions = useProjectActions(project, { isFavorite, onToggleFavorite }); return ( -
-
- -
- - {project.name} - - - {project.slug} - + + {/* The card div keeps `relative`, so the Link overlay below still fills + the card rather than the context-menu wrapper. */} +
+
+ +
+ + {project.name} + + + {project.slug} + +
-
-
- -
+
+ +
-
-
{ - e.preventDefault(); - e.stopPropagation(); - }} - > - +
+
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + +
+
-
- - -
+
); } +/** + * The per-project actions shared by the visible "⋯" dropdown and the + * right-click context menu, so both stay in sync. + */ +function useProjectActions( + project: OrgProject, + { + isFavorite, + onToggleFavorite, + }: { isFavorite: boolean; onToggleFavorite: () => void }, +): Action[] { + const { orgSlug } = useSlugs(); + const navigate = useNavigate(); + + return [ + { + icon: "star", + label: isFavorite ? "Remove from favorites" : "Add to favorites", + onClick: onToggleFavorite, + }, + { + icon: "settings", + label: "Project settings", + onClick: () => { + void navigate(`/${orgSlug}/projects/${project.slug}/settings`); + }, + }, + { + icon: "history", + label: "View audit logs", + onClick: () => { + void navigate(`/${orgSlug}/audit-logs?project=${project.slug}`); + }, + }, + { + icon: "copy", + label: "Copy slug", + onClick: () => { + void navigator.clipboard?.writeText(project.slug); + }, + }, + ]; +} + +// Lucide equivalents of the moonshine icon names used by useProjectActions, +// so the dropdown keeps its existing lucide icons. +const projectActionIcons: Partial> = { + star: Star, + settings: Settings, + history: History, + copy: Copy, +}; + function ProjectRowActions({ - project, + actions, isFavorite, onToggleFavorite, }: { - project: OrgProject; + actions: Action[]; isFavorite: boolean; onToggleFavorite: () => void; }) { - const { orgSlug } = useSlugs(); - const navigate = useNavigate(); const [menuOpen, setMenuOpen] = useState(false); const closeAnd = (cb: () => void) => () => { @@ -721,34 +786,28 @@ function ProjectRowActions({ - - - {isFavorite ? "Remove from favorites" : "Add to favorites"} - - { - void navigate(`/${orgSlug}/projects/${project.slug}/settings`); - })} - > - - Project settings - - { - void navigate(`/${orgSlug}/audit-logs?project=${project.slug}`); - })} - > - - View audit logs - - { - void navigator.clipboard?.writeText(project.slug); - })} - > - - Copy slug - + {actions.map((action, index) => { + const ActionIcon = action.icon + ? projectActionIcons[action.icon] + : undefined; + return ( + + {ActionIcon && ( + + )} + {action.label} + + ); + })}
diff --git a/client/dashboard/src/pages/plugins/PluginCard.tsx b/client/dashboard/src/pages/plugins/PluginCard.tsx index cff69e74715..6a3403901d5 100644 --- a/client/dashboard/src/pages/plugins/PluginCard.tsx +++ b/client/dashboard/src/pages/plugins/PluginCard.tsx @@ -1,4 +1,6 @@ +import { CardContextMenu } from "@/components/card-context-menu"; import { DotCard } from "@/components/ui/dot-card"; +import type { Action } from "@/components/ui/more-actions"; import { Type } from "@/components/ui/type"; import { HumanizeDateTime } from "@/lib/dates"; import { useRoutes } from "@/routes"; @@ -16,7 +18,7 @@ import { Icon, } from "@speakeasy-api/moonshine"; import { ArrowRight, Puzzle, Server } from "lucide-react"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { Link, useNavigate } from "react-router"; import { toast } from "sonner"; import { DEFAULT_PLUGIN_DESCRIPTION } from "./default-plugin"; @@ -60,164 +62,193 @@ export function PluginCard({ } }; + // Single source of truth for the Install split-button dropdown. The + // right-click context menu reuses these entries (plus View) so every card + // action is reachable from either menu without being defined twice. + const installActions: Action[] = [ + { + label: "GitHub installation (preferred)", + disabled: !installTarget, + description: installTarget ? undefined : "Requires marketplace setup", + onClick: () => { + // Defer until after the menu has fully closed to avoid a Radix + // focus-trap/body-lock conflict between the closing menu and the + // opening sheet (same pattern as MCPDetails.tsx). + setTimeout(() => setIsInstallOpen(true), 0); + }, + }, + { + label: "Download as zip — Claude", + separatorBefore: true, + onClick: () => { + void handleDownload("claude"); + }, + }, + { + label: "Download as zip — Cursor", + onClick: () => { + void handleDownload("cursor"); + }, + }, + { + label: "Download as zip — Codex", + onClick: () => { + void handleDownload("codex"); + }, + }, + ]; + + const actions: Action[] = [ + ...installActions, + { + label: "View", + onClick: () => { + void navigate(detailHref); + }, + }, + ]; + return ( -
- { - void navigate(detailHref); - }} - icon={} - > -
-
-
+ +
+ { + void navigate(detailHref); + }} + icon={ + + } + > +
+
+
+ + {plugin.name} + + {isDefault && ( + + Default + + )} + {publishStatus?.upToDate === false && ( + + Needs syncing + + )} +
- {plugin.name} + {plugin.slug} - {isDefault && ( - - Default - - )} - {publishStatus?.upToDate === false && ( - - Needs syncing - - )}
- - {plugin.slug} - + + + + + + {serverCount} {serverCount === 1 ? "server" : "servers"} + +
- - - - - - {serverCount} {serverCount === 1 ? "server" : "servers"} - - -
- {description && ( - - {description} - - )} - - {publishStatus?.lastPublishedAt ? ( - <> - Published{" "} - - - ) : ( - <> - Updated - + {description && ( + + {description} + )} - + + {publishStatus?.lastPublishedAt ? ( + <> + Published{" "} + + + ) : ( + <> + Updated + + )} + -
-
-
e.stopPropagation()}> - - - - - - { - // Defer until after the dropdown has fully closed to - // avoid a Radix focus-trap/body-lock conflict between - // the closing menu and the opening sheet (same pattern - // as MCPDetails.tsx). - setTimeout(() => setIsInstallOpen(true), 0); - }} - > -
- GitHub installation (preferred) - {!installTarget && ( - - Requires marketplace setup - - )} -
-
- - { - void handleDownload("claude"); - }} - > - Download as zip — Claude - - { - void handleDownload("cursor"); - }} - > - Download as zip — Cursor - - { - void handleDownload("codex"); - }} - > - Download as zip — Codex - -
-
+
+
+
e.stopPropagation()}> + + + + + + {installActions.map((action) => ( + + {action.separatorBefore && } + + {action.description ? ( +
+ {action.label} + + {action.description} + +
+ ) : ( + action.label + )} +
+
+ ))} +
+
+
+ e.stopPropagation()}> + +
- e.stopPropagation()}> - -
-
- - {installTarget && ( -
e.stopPropagation()}> - -
- )} -
+ + {installTarget && ( +
e.stopPropagation()}> + +
+ )} +
+
); } diff --git a/client/dashboard/src/pages/remote-identity-providers/tabs/client/McpServersTab.tsx b/client/dashboard/src/pages/remote-identity-providers/tabs/client/McpServersTab.tsx index 6dc58d302d5..afd7d41fce8 100644 --- a/client/dashboard/src/pages/remote-identity-providers/tabs/client/McpServersTab.tsx +++ b/client/dashboard/src/pages/remote-identity-providers/tabs/client/McpServersTab.tsx @@ -1,8 +1,11 @@ import { RequireScope } from "@/components/require-scope"; +import { TableRowContextMenu } from "@/components/table-row-context-menu"; import { DotRow } from "@/components/ui/dot-row"; import { DotTable } from "@/components/ui/dot-table"; +import type { Action } from "@/components/ui/more-actions"; import { Type } from "@/components/ui/type"; import { useSlugs } from "@/contexts/Sdk"; +import { useRBAC } from "@/hooks/useRBAC"; import { cn } from "@/lib/utils"; import { formatRemoteMcpDisplay } from "@/lib/sources"; import type { OrganizationMcpServer } from "@gram/client/models/components/organizationmcpserver.js"; @@ -40,6 +43,8 @@ function mcpServerHref( export function McpServersTab({ clientId }: { clientId: string }): JSX.Element { const { orgSlug } = useSlugs(); const queryClient = useQueryClient(); + const { hasAnyScope } = useRBAC(); + const canManage = hasAnyScope(["org:admin"]); const { data, isLoading, isError } = useOrganizationRemoteSessionClientMcpServers({ clientId, @@ -87,64 +92,81 @@ export function McpServersTab({ clientId }: { clientId: string }): JSX.Element { name: server.name, url: server.url ?? "", }); + const actions: Action[] = [ + { + label: "Remove from server", + destructive: true, + disabled: remove.isPending, + onClick: () => + remove.mutate({ + request: { + removeClientFromMcpServerRequestBody: { + clientId, + mcpServerId: server.id, + }, + }, + }), + }, + ]; return ( - - } - href={href ?? undefined} - ariaLabel={href ? `View MCP server ${label}` : undefined} + actions={canManage ? actions : []} > - - - {label} - - - - -
e.stopPropagation()} + + } + href={href ?? undefined} + ariaLabel={href ? `View MCP server ${label}` : undefined} + > + + - - - - - - - remove.mutate({ - request: { - removeClientFromMcpServerRequestBody: { - clientId, - mcpServerId: server.id, - }, - }, - }) - } - > - Remove from server - - - -
-
- -
+ {label} + + + + +
e.stopPropagation()} + > + + + + + + {actions.map((action, index) => ( + action.onClick()} + > + {action.label} + + ))} + + +
+
+ + + ); })} diff --git a/client/dashboard/src/pages/remote-identity-providers/tabs/client/SessionsTab.tsx b/client/dashboard/src/pages/remote-identity-providers/tabs/client/SessionsTab.tsx index 4a98eb0f433..479b1f9e8f6 100644 --- a/client/dashboard/src/pages/remote-identity-providers/tabs/client/SessionsTab.tsx +++ b/client/dashboard/src/pages/remote-identity-providers/tabs/client/SessionsTab.tsx @@ -1,7 +1,10 @@ import { RequireScope } from "@/components/require-scope"; +import { TableRowContextMenu } from "@/components/table-row-context-menu"; import { DotRow } from "@/components/ui/dot-row"; import { DotTable } from "@/components/ui/dot-table"; +import type { Action } from "@/components/ui/more-actions"; import { Type } from "@/components/ui/type"; +import { useRBAC } from "@/hooks/useRBAC"; import type { RemoteSession } from "@gram/client/models/components/remotesession.js"; import { invalidateAllOrganizationRemoteSessionClientSessions, @@ -26,6 +29,8 @@ import { formatTimestamp } from "./formatTimestamp"; export function SessionsTab({ clientId }: { clientId: string }): JSX.Element { const queryClient = useQueryClient(); + const { hasAnyScope } = useRBAC(); + const canManage = hasAnyScope(["org:admin"]); const { data, isLoading, isError } = useOrganizationRemoteSessionClientSessions({ clientId, @@ -94,79 +99,89 @@ export function SessionsTab({ clientId }: { clientId: string }): JSX.Element { { label: "" }, ]} > - {sessionItems.map((session: RemoteSession) => ( - - } - > - - - {session.subjectDisplayName ?? - session.subjectEmail ?? - session.subjectUrn} - - - - - {formatTimestamp(session.createdAt)} - - - - - {formatTimestamp(session.refreshExpiresAt)} - - - - - {formatTimestamp(session.accessExpiresAt)} - - - - -
e.stopPropagation()}> - - - - - - {session.hasRefreshToken && ( - - refresh.mutate({ - request: { - id: session.id, - }, - }) - } - > - Refresh now - - )} - - revoke.mutate({ - request: { - id: session.id, - }, - }) - } - > - Revoke session - - - -
-
- -
- ))} + {sessionItems.map((session: RemoteSession) => { + const actions: Action[] = [ + ...(session.hasRefreshToken + ? [ + { + label: "Refresh now", + disabled: refresh.isPending, + onClick: () => + refresh.mutate({ request: { id: session.id } }), + }, + ] + : []), + { + label: "Revoke session", + destructive: true, + onClick: () => revoke.mutate({ request: { id: session.id } }), + }, + ]; + return ( + + + } + > + + + {session.subjectDisplayName ?? + session.subjectEmail ?? + session.subjectUrn} + + + + + {formatTimestamp(session.createdAt)} + + + + + {formatTimestamp(session.refreshExpiresAt)} + + + + + {formatTimestamp(session.accessExpiresAt)} + + + + +
e.stopPropagation()}> + + + + + + {actions.map((action, index) => ( + action.onClick()} + > + {action.label} + + ))} + + +
+
+ +
+
+ ); + })} )} diff --git a/client/dashboard/src/pages/remote-identity-providers/tabs/issuer/ClientsTab.tsx b/client/dashboard/src/pages/remote-identity-providers/tabs/issuer/ClientsTab.tsx index 1a8a4d83e76..bfc6c95d6b0 100644 --- a/client/dashboard/src/pages/remote-identity-providers/tabs/issuer/ClientsTab.tsx +++ b/client/dashboard/src/pages/remote-identity-providers/tabs/issuer/ClientsTab.tsx @@ -1,7 +1,10 @@ import { RequireScope } from "@/components/require-scope"; +import { TableRowContextMenu } from "@/components/table-row-context-menu"; import { DotRow } from "@/components/ui/dot-row"; import { DotTable } from "@/components/ui/dot-table"; +import type { Action } from "@/components/ui/more-actions"; import { Type } from "@/components/ui/type"; +import { useRBAC } from "@/hooks/useRBAC"; import { useOrgRoutes } from "@/routes"; import type { OrganizationRemoteSessionClient } from "@gram/client/models/components/organizationremotesessionclient.js"; import type { RemoteSessionIssuer } from "@gram/client/models/components/remotesessionissuer.js"; @@ -26,6 +29,8 @@ export function ClientsTab({ issuer: RemoteSessionIssuer; }): JSX.Element { const orgRoutes = useOrgRoutes(); + const { hasAnyScope } = useRBAC(); + const canManage = hasAnyScope(["org:admin"]); const { data, isLoading, isError } = useOrganizationRemoteSessionClients({ issuerId: issuer.id, }); @@ -58,62 +63,83 @@ export function ClientsTab({ { label: "" }, ]} > - {items.map((item) => ( - } - href={orgRoutes.remoteIdentityProviders.clientDetail.href( - issuer.id, - item.client.id, - )} - ariaLabel={`View client ${remoteSessionClientDisplayName(item.client)}`} - > - - { + const actions: Action[] = [ + { + label: "Delete client", + destructive: true, + onClick: () => setDeleteTarget(item), + }, + ]; + return ( + + + } + href={orgRoutes.remoteIdentityProviders.clientDetail.href( + issuer.id, + item.client.id, + )} + ariaLabel={`View client ${remoteSessionClientDisplayName(item.client)}`} > - {remoteSessionClientDisplayName(item.client)} - - - - - {item.mcpServerCount}{" "} - {item.mcpServerCount === 1 ? "server" : "servers"} - - - - - {item.activeSessionCount}{" "} - {item.activeSessionCount === 1 ? "session" : "sessions"} - - - - -
e.stopPropagation()} - > - - - - - - setDeleteTarget(item)}> - Delete client - - - -
-
- -
- ))} + + + {remoteSessionClientDisplayName(item.client)} + + + + + {item.mcpServerCount}{" "} + {item.mcpServerCount === 1 ? "server" : "servers"} + + + + + {item.activeSessionCount}{" "} + {item.activeSessionCount === 1 ? "session" : "sessions"} + + + + +
e.stopPropagation()} + > + + + + + + {actions.map((action, index) => ( + action.onClick()} + > + {action.label} + + ))} + + +
+
+ + + + ); + })} ); } diff --git a/client/dashboard/src/pages/security/ExclusionsTab.tsx b/client/dashboard/src/pages/security/ExclusionsTab.tsx index d5b07c1895e..7e461fea70d 100644 --- a/client/dashboard/src/pages/security/ExclusionsTab.tsx +++ b/client/dashboard/src/pages/security/ExclusionsTab.tsx @@ -1,4 +1,6 @@ import { Badge } from "@/components/ui/badge"; +import { TableRowContextMenu } from "@/components/table-row-context-menu"; +import type { Action } from "@/components/ui/more-actions"; import { Type } from "@/components/ui/type"; import { Switch } from "@/components/ui/switch"; import { @@ -73,6 +75,20 @@ export function ExclusionsTab({ }); }; + const exclusionActions = (exclusion: RiskExclusion): Action[] => [ + { + label: "Edit", + onClick: () => onSheetChange({ mode: "edit", exclusion }), + }, + { + label: "Delete", + destructive: true, + onClick: () => { + deleteMutation.mutate({ request: { id: exclusion.id } }); + }, + }, + ]; + const columns: Column[] = [ { key: "criteria", @@ -131,12 +147,7 @@ export function ExclusionsTab({ width: "0.3fr", render: (exclusion) => (
e.stopPropagation()}> - onSheetChange({ mode: "edit", exclusion })} - onDelete={() => { - deleteMutation.mutate({ request: { id: exclusion.id } }); - }} - /> +
), }, @@ -158,6 +169,14 @@ export function ExclusionsTab({ data={exclusions} rowKey={(exclusion) => exclusion.id} onRowClick={(exclusion) => onSheetChange({ mode: "edit", exclusion })} + renderRow={(exclusion, rowElement) => ( + + {rowElement} + + )} /> ); } @@ -176,13 +195,7 @@ export function ExclusionsTab({ ); } -function ExclusionActionsMenu({ - onEdit, - onDelete, -}: { - onEdit: () => void; - onDelete: () => void; -}): JSX.Element { +function ExclusionActionsMenu({ actions }: { actions: Action[] }): JSX.Element { return ( @@ -193,22 +206,22 @@ function ExclusionActionsMenu({ - { - setTimeout(onEdit, 0); - }} - > - Edit - - { - setTimeout(onDelete, 0); - }} - > - Delete - + {actions.map((action) => ( + { + setTimeout(action.onClick, 0); + }} + > + {action.label} + + ))} ); diff --git a/client/dashboard/src/pages/security/PolicyCenter.tsx b/client/dashboard/src/pages/security/PolicyCenter.tsx index 5295e7700a2..f2e20325db0 100644 --- a/client/dashboard/src/pages/security/PolicyCenter.tsx +++ b/client/dashboard/src/pages/security/PolicyCenter.tsx @@ -2,6 +2,8 @@ import { InsightsConfig } from "@/components/insights-dock"; import { INSIGHTS_SUGGESTIONS } from "@/lib/insights-suggestions"; import { Page } from "@/components/page-layout"; import { RequireScope } from "@/components/require-scope"; +import { TableRowContextMenu } from "@/components/table-row-context-menu"; +import type { Action } from "@/components/ui/more-actions"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; @@ -683,6 +685,34 @@ function PolicyCenterContent() { setPolicyToDelete(row); }; + const policyActions = (row: PolicyRow): Action[] => [ + { + label: "Edit", + onClick: () => { + // Both prompt and standard policies now edit on their + // dedicated detail page. + routes.policyCenter.detail.goTo(row.policy.id); + }, + }, + ...(row.kind === "risk" + ? [ + { + label: "View Progress", + onClick: () => { + setTimeout(() => setRunPanelPolicy(row.policy), 0); + }, + }, + ] + : []), + { + label: "Delete", + destructive: true, + onClick: () => { + setTimeout(() => handleDelete(row), 0); + }, + }, + ]; + const confirmDelete = () => { if (!policyToDelete) return; deleteMutation.mutate({ request: { id: policyToDelete.policy.id } }); @@ -901,34 +931,19 @@ function PolicyCenterContent() { - { - // Both prompt and standard policies now edit on their - // dedicated detail page. - routes.policyCenter.detail.goTo(row.policy.id); - }} - > - Edit - - {row.kind === "risk" && ( + {policyActions(row).map((action) => ( { - setTimeout(() => setRunPanelPolicy(row.policy), 0); - }} + key={action.label} + className={cn( + "cursor-pointer", + action.destructive && + "text-destructive focus:text-destructive", + )} + onSelect={() => action.onClick()} > - View Progress + {action.label} - )} - { - setTimeout(() => handleDelete(row), 0); - }} - > - Delete - + ))}
@@ -965,6 +980,11 @@ function PolicyCenterContent() { // (eval workbench for prompt, on-page editor for standard). routes.policyCenter.detail.goTo(row.policy.id) } + renderRow={(row, rowElement) => ( + + {rowElement} + + )} /> ); if (isLoading) { diff --git a/client/dashboard/src/pages/sources/AnnotationToggle.tsx b/client/dashboard/src/pages/sources/AnnotationToggle.tsx new file mode 100644 index 00000000000..f65f1facb55 --- /dev/null +++ b/client/dashboard/src/pages/sources/AnnotationToggle.tsx @@ -0,0 +1,33 @@ +import { Switch } from "@/components/ui/switch"; +import { useId } from "react"; + +export function AnnotationToggle({ + label, + description, + checked, + onCheckedChange, +}: { + label: string; + description: string; + checked: boolean; + onCheckedChange: (value: boolean) => void; +}): JSX.Element { + const descriptionId = useId(); + + return ( +
+
+

{label}

+

+ {description} +

+
+ +
+ ); +} diff --git a/client/dashboard/src/pages/sources/SourceToolsTab.tsx b/client/dashboard/src/pages/sources/SourceToolsTab.tsx index 2273bc24999..c435ffc59d3 100644 --- a/client/dashboard/src/pages/sources/SourceToolsTab.tsx +++ b/client/dashboard/src/pages/sources/SourceToolsTab.tsx @@ -1,11 +1,13 @@ +import { TableRowContextMenu } from "@/components/table-row-context-menu"; import { ToolVariationBadge } from "@/components/tool-variation-badge"; +import { MoreActions } from "@/components/ui/more-actions"; import { SearchBar } from "@/components/ui/search-bar"; import { Type } from "@/components/ui/type"; import { ToolUpdateFields } from "@/hooks/useToolUpdate"; import type { Tool } from "@/lib/toolTypes"; import { Badge } from "@speakeasy-api/moonshine"; import { useState } from "react"; -import { SourceToolActions } from "./SourceToolActions"; +import { useSourceToolActions } from "./useSourceToolActions"; type HttpTool = Extract; type FunctionTool = Extract; @@ -75,30 +77,35 @@ function HttpToolRow({ onToolUpdate, isToolUpdating, }: { tool: HttpTool } & ToolActionsProps) { + const { actions, dialog } = useSourceToolActions({ + tool, + onUpdate: (updates) => onToolUpdate?.(tool, updates), + isUpdating: isToolUpdating, + }); + return ( -
-
- - {tool.httpMethod} - -
-
- {tool.path} -
-
-
- {tool.name} - + <> + +
+
+ + {tool.httpMethod} + +
+
+ {tool.path} +
+
+
+ {tool.name} + +
+ {onToolUpdate && } +
- {onToolUpdate && ( - onToolUpdate(tool, updates)} - isUpdating={isToolUpdating} - /> - )} -
-
+ + {dialog} + ); } @@ -107,30 +114,35 @@ function FunctionToolRow({ onToolUpdate, isToolUpdating, }: { tool: FunctionTool } & ToolActionsProps) { + const { actions, dialog } = useSourceToolActions({ + tool, + onUpdate: (updates) => onToolUpdate?.(tool, updates), + isUpdating: isToolUpdating, + }); + return ( -
-
- - {tool.runtime} - -
-
- {tool.name} - -
-
-
- {tool.description} + <> + +
+
+ + {tool.runtime} + +
+
+ {tool.name} + +
+
+
+ {tool.description} +
+ {onToolUpdate && } +
- {onToolUpdate && ( - onToolUpdate(tool, updates)} - isUpdating={isToolUpdating} - /> - )} -
-
+ + {dialog} + ); } diff --git a/client/dashboard/src/pages/sources/SourceToolActions.tsx b/client/dashboard/src/pages/sources/useSourceToolActions.tsx similarity index 50% rename from client/dashboard/src/pages/sources/SourceToolActions.tsx rename to client/dashboard/src/pages/sources/useSourceToolActions.tsx index c5aec2d58a3..ac1500ead6d 100644 --- a/client/dashboard/src/pages/sources/SourceToolActions.tsx +++ b/client/dashboard/src/pages/sources/useSourceToolActions.tsx @@ -1,10 +1,10 @@ import { TagsVariationEditor } from "@/components/tool-variation-tags-editor"; +import { AnnotationToggle } from "./AnnotationToggle"; import { Button } from "@/components/ui/button"; import { Dialog } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { MoreActions } from "@/components/ui/more-actions"; -import { Switch } from "@/components/ui/switch"; +import { Action } from "@/components/ui/more-actions"; import { TextArea } from "@/components/ui/textarea"; import { Type } from "@/components/ui/type"; import { ToolUpdateFields } from "@/hooks/useToolUpdate"; @@ -36,15 +36,17 @@ function dialogDescription(mode: EditMode, toolName: string): string { } } -export function SourceToolActions({ - tool, - onUpdate, - isUpdating, -}: { +export type SourceToolActionsProps = { tool: SourceTool; onUpdate: (updates: ToolUpdateFields) => void | Promise; isUpdating?: boolean; -}): JSX.Element { +}; + +export function useSourceToolActions({ + tool, + onUpdate, + isUpdating, +}: SourceToolActionsProps): { actions: Action[]; dialog: JSX.Element } { const [editDialogOpen, setEditDialogOpen] = useState(false); const [editMode, setEditMode] = useState("name"); const [editValue, setEditValue] = useState(""); @@ -149,7 +151,7 @@ export function SourceToolActions({ await navigator.clipboard.writeText(tool.name); }; - const actions = [ + const actions: Action[] = [ { label: "Edit name", onClick: () => openEditDialog("name"), @@ -172,166 +174,137 @@ export function SourceToolActions({ }, { label: "Copy name", - onClick: handleCopyName, + onClick: () => void handleCopyName(), icon: "copy" as const, }, ]; - return ( - <> - - - - - - {DIALOG_TITLES[editMode]} - - {dialogDescription(editMode, tool.name)} - - -
- {editMode === "annotations" && ( - + const dialog = ( + + + + {DIALOG_TITLES[editMode]} + + {dialogDescription(editMode, tool.name)} + + +
+ {editMode === "annotations" && ( + +
+ + +
+
+
- - + + +
-
- -
- - - - + + )} + {editMode === "tags" && ( + + )} + {editMode === "name" && ( + + + {tool.variation?.name && + tool.variation?.name !== tool.canonical?.name && ( + + -
-
- - )} - {editMode === "tags" && ( - + Original name: + + + {tool.canonical?.name} + + + )} + + )} + {editMode === "description" && ( + +