Skip to content

Commit 195877f

Browse files
Marek Danovishu-bh
authored andcommitted
feat(ui-rewrite): add Try it / Definition tabs with prompts table to prompt details panel
Signed-off-by: Marek Dano <Marek.Dano@ibm.com>
1 parent ea113c5 commit 195877f

13 files changed

Lines changed: 666 additions & 95 deletions
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { screen, within } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { renderWithProviders as render } from "@/test/test-utils";
5+
import { PromptDefinitionTable } from "./PromptDefinitionTable";
6+
import type { PromptRead } from "@/generated/types";
7+
8+
function mockPrompt(overrides?: Partial<NonNullable<PromptRead>>): NonNullable<PromptRead> {
9+
return {
10+
id: "p1",
11+
name: "greet_user",
12+
originalName: "greet_user",
13+
customName: "",
14+
customNameSlug: "greet_user",
15+
description: "Greets the user",
16+
template: "Hello {user_name}",
17+
arguments: [],
18+
createdAt: "2026-06-30T00:00:00",
19+
updatedAt: "2026-06-30T00:00:00",
20+
enabled: true,
21+
...overrides,
22+
};
23+
}
24+
25+
describe("PromptDefinitionTable", () => {
26+
it("renders a row per prompt with its name and a copyable ID", () => {
27+
const a = mockPrompt({ id: "a", name: "prompt_a" });
28+
const b = mockPrompt({ id: "b", name: "prompt_b" });
29+
render(<PromptDefinitionTable prompts={[a, b]} onSelectPrompt={vi.fn()} />);
30+
31+
expect(screen.getByRole("cell", { name: "prompt_a" })).toBeInTheDocument();
32+
expect(screen.getByRole("cell", { name: "prompt_b" })).toBeInTheDocument();
33+
expect(screen.getByText("a")).toBeInTheDocument();
34+
expect(screen.getByText("b")).toBeInTheDocument();
35+
expect(screen.getAllByRole("button", { name: /copy prompt id/i })).toHaveLength(2);
36+
});
37+
38+
it("does not render a Source URL column", () => {
39+
render(<PromptDefinitionTable prompts={[mockPrompt()]} onSelectPrompt={vi.fn()} />);
40+
41+
expect(screen.queryByRole("columnheader", { name: /source url/i })).not.toBeInTheDocument();
42+
});
43+
44+
it("prefers displayName over the technical name for the Name column", () => {
45+
const prompt = mockPrompt({ displayName: "Greet User" });
46+
render(<PromptDefinitionTable prompts={[prompt]} onSelectPrompt={vi.fn()} />);
47+
48+
expect(screen.getByRole("cell", { name: "Greet User" })).toBeInTheDocument();
49+
});
50+
51+
it("calls onSelectPrompt with the row's prompt when the row is clicked", async () => {
52+
const onSelectPrompt = vi.fn();
53+
const user = userEvent.setup();
54+
const a = mockPrompt({ id: "a", name: "prompt_a" });
55+
const b = mockPrompt({ id: "b", name: "prompt_b" });
56+
render(<PromptDefinitionTable prompts={[a, b]} onSelectPrompt={onSelectPrompt} />);
57+
58+
await user.click(screen.getByRole("cell", { name: "prompt_b" }));
59+
expect(onSelectPrompt).toHaveBeenCalledWith(b);
60+
});
61+
62+
it("does not select the row when the copy button is clicked", async () => {
63+
const onSelectPrompt = vi.fn();
64+
const user = userEvent.setup();
65+
render(<PromptDefinitionTable prompts={[mockPrompt()]} onSelectPrompt={onSelectPrompt} />);
66+
67+
await user.click(screen.getByRole("button", { name: /copy prompt id/i }));
68+
expect(onSelectPrompt).not.toHaveBeenCalled();
69+
});
70+
71+
it("selects the row via keyboard, but not when a key fires from an in-row control", async () => {
72+
const onSelectPrompt = vi.fn();
73+
const user = userEvent.setup();
74+
render(<PromptDefinitionTable prompts={[mockPrompt()]} onSelectPrompt={onSelectPrompt} />);
75+
76+
// Enter from the copy button must not bubble up into a row selection.
77+
screen.getByRole("button", { name: /copy prompt id/i }).focus();
78+
await user.keyboard("{Enter}");
79+
expect(onSelectPrompt).not.toHaveBeenCalled();
80+
81+
// Enter on the row itself does select it.
82+
const dataRow = screen.getAllByRole("row")[1];
83+
dataRow.focus();
84+
await user.keyboard("{Enter}");
85+
expect(onSelectPrompt).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it("omits the row overflow menu when neither onEdit nor onDelete is provided", () => {
89+
render(<PromptDefinitionTable prompts={[mockPrompt()]} onSelectPrompt={vi.fn()} />);
90+
91+
expect(screen.queryByRole("button", { name: /more options/i })).not.toBeInTheDocument();
92+
});
93+
94+
it("invokes onEdit and onDelete with the row's prompt from the overflow menu", async () => {
95+
const onEdit = vi.fn();
96+
const onDelete = vi.fn();
97+
const onSelectPrompt = vi.fn();
98+
const user = userEvent.setup();
99+
const prompt = mockPrompt();
100+
render(
101+
<PromptDefinitionTable
102+
prompts={[prompt]}
103+
onSelectPrompt={onSelectPrompt}
104+
onEdit={onEdit}
105+
onDelete={onDelete}
106+
/>,
107+
);
108+
109+
await user.click(screen.getByRole("button", { name: /more options for greet_user/i }));
110+
await user.click(screen.getByRole("menuitem", { name: /^edit$/i }));
111+
expect(onEdit).toHaveBeenCalledWith(prompt);
112+
113+
await user.click(screen.getByRole("button", { name: /more options for greet_user/i }));
114+
await user.click(screen.getByRole("menuitem", { name: /^delete$/i }));
115+
expect(onDelete).toHaveBeenCalledWith(prompt);
116+
117+
// Opening the menu / picking an action must not also select the row.
118+
expect(onSelectPrompt).not.toHaveBeenCalled();
119+
});
120+
121+
it("renders only the actions provided", async () => {
122+
const user = userEvent.setup();
123+
render(
124+
<PromptDefinitionTable prompts={[mockPrompt()]} onSelectPrompt={vi.fn()} onEdit={vi.fn()} />,
125+
);
126+
127+
await user.click(screen.getByRole("button", { name: /more options for greet_user/i }));
128+
const menu = screen.getByRole("menu");
129+
expect(within(menu).getByRole("menuitem", { name: /^edit$/i })).toBeInTheDocument();
130+
expect(within(menu).queryByRole("menuitem", { name: /^delete$/i })).not.toBeInTheDocument();
131+
});
132+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { Copy, MoreHorizontal } from "lucide-react";
2+
import { useIntl } from "react-intl";
3+
4+
import type { PromptRead } from "@/generated/types";
5+
import { Button } from "@/components/ui/button";
6+
import {
7+
DropdownMenu,
8+
DropdownMenuContent,
9+
DropdownMenuItem,
10+
DropdownMenuTrigger,
11+
} from "@/components/ui/dropdown-menu";
12+
import {
13+
Table,
14+
TableBody,
15+
TableCell,
16+
TableHead,
17+
TableHeader,
18+
TableRow,
19+
} from "@/components/ui/table";
20+
import { copyToClipboard } from "@/lib/clipboard";
21+
import { truncateMiddle } from "@/components/gateways/utils";
22+
23+
export interface PromptDefinitionTableProps {
24+
prompts: NonNullable<PromptRead>[];
25+
selectedPromptId?: string;
26+
onSelectPrompt: (prompt: NonNullable<PromptRead>) => void;
27+
onEdit?: (prompt: NonNullable<PromptRead>) => void;
28+
onDelete?: (prompt: NonNullable<PromptRead>) => void;
29+
}
30+
31+
/**
32+
* "Definition" tab content for the prompt details drawer. Styled to match the
33+
* Tools/Resources tables: lists every prompt in the group with its name and a
34+
* copyable ID, plus a per-row overflow menu (Edit/Delete). Selecting a row
35+
* updates the Prompt details sidebar. The overflow menu replaces the one that
36+
* previously sat beside the panel title.
37+
*
38+
* a11y: row selection is conveyed visually via `data-state` only, matching the
39+
* Tools/Resources tables. Exposing it to assistive tech (grid role +
40+
* aria-selected) is a cross-cutting follow-up across all three tables.
41+
*/
42+
export function PromptDefinitionTable({
43+
prompts,
44+
selectedPromptId,
45+
onSelectPrompt,
46+
onEdit,
47+
onDelete,
48+
}: PromptDefinitionTableProps) {
49+
const intl = useIntl();
50+
51+
return (
52+
<Table>
53+
<TableHeader>
54+
<TableRow className="hover:bg-transparent">
55+
<TableHead className="h-9 w-[30%] px-4 py-2.5 text-xs font-medium">
56+
{intl.formatMessage({ id: "prompts.details.label.name" })}
57+
</TableHead>
58+
<TableHead className="h-9 px-4 py-2.5 text-xs font-medium">
59+
{intl.formatMessage({ id: "prompts.details.label.promptId" })}
60+
</TableHead>
61+
<TableHead className="h-9 w-[40px] px-4 py-2.5" />
62+
</TableRow>
63+
</TableHeader>
64+
<TableBody className="[&_tr]:border-0">
65+
{prompts.map((prompt) => (
66+
<TableRow
67+
key={prompt.id}
68+
data-state={selectedPromptId === prompt.id ? "selected" : undefined}
69+
onClick={() => onSelectPrompt(prompt)}
70+
tabIndex={0}
71+
onKeyDown={(e) => {
72+
// Ignore keys bubbling up from in-row controls (copy / menu) so
73+
// activating them doesn't also select the row.
74+
if (e.target !== e.currentTarget) return;
75+
if (e.key === "Enter" || e.key === " ") {
76+
e.preventDefault();
77+
onSelectPrompt(prompt);
78+
}
79+
}}
80+
className="cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset"
81+
>
82+
<TableCell className="px-4 py-3 text-sm text-foreground">
83+
<span className="line-clamp-1">{prompt.displayName || prompt.name}</span>
84+
</TableCell>
85+
86+
<TableCell className="px-4 py-3">
87+
<div className="flex min-w-0 items-center">
88+
<span className="min-w-0 truncate font-mono text-xs text-muted-foreground">
89+
{truncateMiddle(prompt.id, 40)}
90+
</span>
91+
<Button
92+
type="button"
93+
variant="ghost"
94+
size="icon-xs"
95+
aria-label={intl.formatMessage(
96+
{ id: "prompts.details.table.copyPromptId" },
97+
{ name: prompt.name },
98+
)}
99+
className="ml-4 size-4 shrink-0 text-muted-foreground hover:text-foreground"
100+
onClick={(e) => {
101+
e.stopPropagation();
102+
copyToClipboard(prompt.id);
103+
}}
104+
>
105+
<Copy className="size-3" />
106+
</Button>
107+
</div>
108+
</TableCell>
109+
110+
<TableCell className="px-4 py-3 text-center">
111+
{(onEdit || onDelete) && (
112+
<DropdownMenu>
113+
<DropdownMenuTrigger asChild>
114+
<Button
115+
type="button"
116+
variant="ghost"
117+
size="icon-xs"
118+
aria-label={intl.formatMessage(
119+
{ id: "prompts.details.moreOptionsFor" },
120+
{ name: prompt.name },
121+
)}
122+
className="size-5 text-muted-foreground hover:text-foreground"
123+
onClick={(e) => {
124+
e.stopPropagation();
125+
}}
126+
>
127+
<MoreHorizontal className="size-4" />
128+
</Button>
129+
</DropdownMenuTrigger>
130+
<DropdownMenuContent align="end">
131+
{onEdit && (
132+
<DropdownMenuItem
133+
onClick={(e) => {
134+
e.stopPropagation();
135+
onEdit(prompt);
136+
}}
137+
>
138+
{intl.formatMessage({ id: "prompts.details.action.edit" })}
139+
</DropdownMenuItem>
140+
)}
141+
{onDelete && (
142+
<DropdownMenuItem
143+
onClick={(e) => {
144+
e.stopPropagation();
145+
onDelete(prompt);
146+
}}
147+
>
148+
{intl.formatMessage({ id: "prompts.details.action.delete" })}
149+
</DropdownMenuItem>
150+
)}
151+
</DropdownMenuContent>
152+
</DropdownMenu>
153+
)}
154+
</TableCell>
155+
</TableRow>
156+
))}
157+
</TableBody>
158+
</Table>
159+
);
160+
}

0 commit comments

Comments
 (0)