Skip to content

Commit ea113c5

Browse files
Marek Danovishu-bh
authored andcommitted
feat(ui-rewrite): update prompt details panel, add common copy values and back button component
Signed-off-by: Marek Dano <Marek.Dano@ibm.com>
1 parent 026fb2c commit ea113c5

31 files changed

Lines changed: 854 additions & 553 deletions

client/src/components/gateways/VirtualServerDetailsPanel.tsx

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { MCPIcon } from "@/components/icons/MCPIcon";
1818
import { Badge } from "@/components/ui/badge";
1919
import { Button } from "@/components/ui/button";
2020
import { InlineTagAdd } from "@/components/ui/inline-tag-add";
21+
import { CopyValue } from "@/components/ui/copy-value";
2122
import { Input } from "@/components/ui/input";
2223
import { cn } from "@/lib/utils";
2324
import type { MCPServer, VirtualServer } from "@/types/server";
@@ -95,24 +96,6 @@ function DetailRow({
9596
);
9697
}
9798

98-
function CopyValue({ label, value }: { label: string; value: string }) {
99-
return (
100-
<div className="flex min-w-0 items-center gap-2">
101-
<span className="min-w-0 flex-1 truncate font-mono text-[12px]">{truncateMiddle(value)}</span>
102-
<Button
103-
type="button"
104-
variant="ghost"
105-
size="icon-xs"
106-
className="size-5 text-muted-foreground"
107-
aria-label={`Copy ${label}`}
108-
onClick={() => copyToClipboard(value)}
109-
>
110-
<Copy className="size-3.5" />
111-
</Button>
112-
</div>
113-
);
114-
}
115-
11699
function getComponentIcon(type: Exclude<ComponentFilter, "all">) {
117100
if (type === "tools") return <Wrench className="size-3.5" />;
118101
if (type === "resources") return <Box className="size-3.5" />;

client/src/components/prompts/PromptDetailsPanel.test.tsx

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,140 @@ describe("PromptDetailsPanel", () => {
248248
expect(screen.getByText("Local prompt · private · 1 argument")).toBeInTheDocument();
249249
});
250250

251+
it("renders the overflow menu button when onEdit is provided", () => {
252+
render(
253+
<PromptDetailsPanel
254+
prompts={[mockPrompt()]}
255+
title="hugging-face"
256+
open={true}
257+
onClose={vi.fn()}
258+
onEdit={vi.fn()}
259+
/>,
260+
);
261+
262+
expect(
263+
screen.getByRole("button", { name: /more options for greet_user/i }),
264+
).toBeInTheDocument();
265+
});
266+
267+
it("renders the overflow menu button when onDelete is provided", () => {
268+
render(
269+
<PromptDetailsPanel
270+
prompts={[mockPrompt()]}
271+
title="hugging-face"
272+
open={true}
273+
onClose={vi.fn()}
274+
onDelete={vi.fn()}
275+
/>,
276+
);
277+
278+
expect(
279+
screen.getByRole("button", { name: /more options for greet_user/i }),
280+
).toBeInTheDocument();
281+
});
282+
283+
it("does not render the overflow menu when neither onEdit nor onDelete is provided", () => {
284+
render(
285+
<PromptDetailsPanel
286+
prompts={[mockPrompt()]}
287+
title="hugging-face"
288+
open={true}
289+
onClose={vi.fn()}
290+
/>,
291+
);
292+
293+
expect(screen.queryByRole("button", { name: /more options/i })).not.toBeInTheDocument();
294+
});
295+
296+
it("calls onEdit with the selected prompt when Edit is clicked", async () => {
297+
const onEdit = vi.fn();
298+
const user = userEvent.setup();
299+
const prompt = mockPrompt();
300+
render(
301+
<PromptDetailsPanel
302+
prompts={[prompt]}
303+
title="hugging-face"
304+
open={true}
305+
onClose={vi.fn()}
306+
onEdit={onEdit}
307+
/>,
308+
);
309+
310+
await user.click(screen.getByRole("button", { name: /more options for greet_user/i }));
311+
await user.click(screen.getByRole("menuitem", { name: /^edit$/i }));
312+
expect(onEdit).toHaveBeenCalledWith(prompt);
313+
});
314+
315+
it("calls onDelete with the selected prompt when Delete is clicked", async () => {
316+
const onDelete = vi.fn();
317+
const user = userEvent.setup();
318+
const prompt = mockPrompt();
319+
render(
320+
<PromptDetailsPanel
321+
prompts={[prompt]}
322+
title="hugging-face"
323+
open={true}
324+
onClose={vi.fn()}
325+
onDelete={onDelete}
326+
/>,
327+
);
328+
329+
await user.click(screen.getByRole("button", { name: /more options for greet_user/i }));
330+
await user.click(screen.getByRole("menuitem", { name: /^delete$/i }));
331+
expect(onDelete).toHaveBeenCalledWith(prompt);
332+
});
333+
334+
it("renders Technical name and Prompt ID with copy buttons", () => {
335+
render(
336+
<PromptDetailsPanel
337+
prompts={[mockPrompt()]}
338+
title="hugging-face"
339+
open={true}
340+
onClose={vi.fn()}
341+
/>,
342+
);
343+
344+
expect(screen.getByText("Technical name")).toBeInTheDocument();
345+
expect(screen.getByRole("button", { name: /copy technical name/i })).toBeInTheDocument();
346+
expect(screen.getByText("Prompt ID")).toBeInTheDocument();
347+
expect(screen.getByRole("button", { name: /copy prompt id/i })).toBeInTheDocument();
348+
});
349+
350+
it("renders Source URL with copy button when federationSource is set", () => {
351+
const prompt = mockPrompt({ federationSource: "https://mcp.example.com/prompts" });
352+
render(<PromptDetailsPanel prompts={[prompt]} title="test" open={true} onClose={vi.fn()} />);
353+
354+
expect(screen.getByText("Source URL")).toBeInTheDocument();
355+
expect(screen.getByRole("button", { name: /copy source url/i })).toBeInTheDocument();
356+
});
357+
358+
it("omits Source URL when federationSource is not set", () => {
359+
render(
360+
<PromptDetailsPanel prompts={[mockPrompt()]} title="test" open={true} onClose={vi.fn()} />,
361+
);
362+
363+
expect(screen.queryByText("Source URL")).not.toBeInTheDocument();
364+
});
365+
366+
it("renders Version when version is set", () => {
367+
const prompt = mockPrompt({ version: 3 });
368+
render(<PromptDetailsPanel prompts={[prompt]} title="test" open={true} onClose={vi.fn()} />);
369+
370+
expect(screen.getByText("Version")).toBeInTheDocument();
371+
expect(screen.getByText("3")).toBeInTheDocument();
372+
});
373+
374+
it("omits Version when version is null", () => {
375+
const prompt = mockPrompt({ version: null });
376+
render(<PromptDetailsPanel prompts={[prompt]} title="test" open={true} onClose={vi.fn()} />);
377+
378+
expect(screen.queryByText("Version")).not.toBeInTheDocument();
379+
});
380+
251381
it("calls onAddTag with the merged, de-duplicated tag list", async () => {
252382
const user = userEvent.setup();
253383
const onAddTag = vi.fn().mockResolvedValue(undefined);
254384
const prompt = mockPrompt({ id: "p1", tags: [{ id: "dev", label: "dev" }] });
255-
256385
render(
257386
<PromptDetailsPanel
258387
prompts={[prompt]}

client/src/components/prompts/PromptDetailsPanel.tsx

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
import { useEffect, useMemo, useRef, useState } from "react";
22
import type { ReactNode } from "react";
3-
import { Activity, Globe, MessageSquareCode, PanelRightClose } from "lucide-react";
3+
import {
4+
Activity,
5+
Globe,
6+
MessageSquareCode,
7+
MoreVertical,
8+
PanelRightClose,
9+
} from "lucide-react";
410
import { useIntl } from "react-intl";
5-
611
import type { PromptRead } from "@/generated/types";
712
import { Badge } from "@/components/ui/badge";
813
import { Button } from "@/components/ui/button";
914
import { InlineTagAdd } from "@/components/ui/inline-tag-add";
15+
import {
16+
DropdownMenu,
17+
DropdownMenuContent,
18+
DropdownMenuItem,
19+
DropdownMenuTrigger,
20+
} from "@/components/ui/dropdown-menu";
1021
import { cn } from "@/lib/utils";
1122
import { getTagDisplay } from "@/components/gateways/utils";
1223
import { formatDateTime } from "@/utils/format";
@@ -34,6 +45,8 @@ export interface PromptDetailsPanelProps {
3445
* a non-interactive "add" affordance.
3546
*/
3647
onAddTag?: (promptId: string, tags: string[]) => Promise<void>;
48+
onEdit?: (prompt: NonNullable<PromptRead>) => void;
49+
onDelete?: (prompt: NonNullable<PromptRead>) => void;
3750
}
3851

3952
/**
@@ -53,6 +66,8 @@ export function PromptDetailsPanel({
5366
open,
5467
onClose,
5568
onAddTag,
69+
onEdit,
70+
onDelete,
5671
}: PromptDetailsPanelProps) {
5772
const intl = useIntl();
5873
const closeButtonRef = useRef<HTMLButtonElement>(null);
@@ -126,25 +141,48 @@ export function PromptDetailsPanel({
126141
Prompt details: {title}
127142
</h2>
128143

129-
<div className="flex items-start justify-between gap-4">
130-
<div className="flex min-w-0 items-start gap-3">
131-
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-sm bg-emerald-300 text-neutral-950">
132-
<MessageSquareCode className="size-4" />
144+
<div className="flex min-w-0 items-start gap-3">
145+
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-sm bg-emerald-300 text-neutral-950">
146+
<MessageSquareCode className="size-4" />
147+
</span>
148+
<div className="flex min-w-0 items-center gap-2">
149+
<span aria-hidden="true" className="truncate text-xl font-semibold text-foreground">
150+
{title}
133151
</span>
134-
<div className="min-w-0">
135-
<div className="flex min-w-0 items-center gap-2">
136-
<span
137-
aria-hidden="true"
138-
className="truncate text-xl font-semibold text-foreground"
139-
>
140-
{title}
141-
</span>
142-
</div>
143-
</div>
152+
{selected && (onEdit ?? onDelete) && (
153+
<DropdownMenu>
154+
<DropdownMenuTrigger asChild>
155+
<Button
156+
type="button"
157+
variant="ghost"
158+
size="sm"
159+
className="h-8 w-8 shrink-0 p-0"
160+
aria-label={intl.formatMessage(
161+
{ id: "prompts.details.moreOptionsFor" },
162+
{ name: selected.name },
163+
)}
164+
aria-haspopup="menu"
165+
>
166+
<MoreVertical className="h-4 w-4" aria-hidden="true" />
167+
</Button>
168+
</DropdownMenuTrigger>
169+
<DropdownMenuContent align="start">
170+
{onEdit && (
171+
<DropdownMenuItem onSelect={() => onEdit(selected)}>
172+
{intl.formatMessage({ id: "prompts.details.action.edit" })}
173+
</DropdownMenuItem>
174+
)}
175+
{onDelete && (
176+
<DropdownMenuItem onSelect={() => onDelete(selected)}>
177+
{intl.formatMessage({ id: "prompts.details.action.delete" })}
178+
</DropdownMenuItem>
179+
)}
180+
</DropdownMenuContent>
181+
</DropdownMenu>
182+
)}
144183
</div>
145184
</div>
146185

147-
{/* TODO(#5563): full local-prompt drawer variant (Try it / Definition tabs). */}
148186
{selected && (
149187
<p className="mt-7 max-w-4xl text-[15px] leading-6 text-muted-foreground">
150188
{selected.gatewayId || selected.gatewaySlug
@@ -223,26 +261,35 @@ export function PromptDetailsPanel({
223261
{selected && (
224262
<>
225263
<div className="border-b border-border p-4 pt-8">
226-
<h3 className="mb-7 text-sm font-semibold text-foreground">Prompt details</h3>
264+
<h3 className="mb-7 text-sm font-semibold text-foreground">
265+
{intl.formatMessage({ id: "prompts.details.promptDetails" })}
266+
</h3>
227267

228268
<dl className="space-y-4">
229-
<DetailRow label="Status">
269+
<DetailRow label={intl.formatMessage({ id: "prompts.details.label.status" })}>
230270
<span className="flex items-center gap-2">
231271
<Activity
232272
className={`size-3.5 ${
233273
selected.enabled ? "text-emerald-400" : "text-gray-400"
234274
}`}
235275
/>
236-
{selected.enabled ? "Active" : "Inactive"}
276+
{selected.enabled
277+
? intl.formatMessage({ id: "prompts.details.status.active" })
278+
: intl.formatMessage({ id: "prompts.details.status.inactive" })}
237279
</span>
238280
</DetailRow>
239-
<DetailRow label="Visibility">
281+
<DetailRow
282+
label={intl.formatMessage({ id: "prompts.details.label.visibility" })}
283+
>
240284
<span className="flex items-center gap-2">
241285
<Globe className="size-3.5 text-muted-foreground" />
242-
{selected.visibility
243-
? selected.visibility.charAt(0).toUpperCase() +
244-
selected.visibility.slice(1)
245-
: "Not available"}
286+
{selected.visibility === "team"
287+
? intl.formatMessage({ id: "prompts.details.visibility.team" })
288+
: selected.visibility === "public"
289+
? intl.formatMessage({ id: "prompts.details.visibility.public" })
290+
: selected.visibility === "private"
291+
? intl.formatMessage({ id: "prompts.details.visibility.private" })
292+
: intl.formatMessage({ id: "prompts.details.notAvailable" })}
246293
</span>
247294
</DetailRow>
248295
{(() => {
@@ -278,10 +325,16 @@ export function PromptDetailsPanel({
278325
</div>
279326

280327
<div className="p-4">
281-
<h3 className="mb-7 text-sm font-semibold text-foreground">Activity</h3>
328+
<h3 className="mb-7 text-sm font-semibold text-foreground">
329+
{intl.formatMessage({ id: "prompts.details.activity" })}
330+
</h3>
282331
<dl className="space-y-4">
283-
<DetailRow label="Created">{formatDateTime(selected.createdAt)}</DetailRow>
284-
<DetailRow label="Last modified">
332+
<DetailRow label={intl.formatMessage({ id: "prompts.details.label.created" })}>
333+
{formatDateTime(selected.createdAt)}
334+
</DetailRow>
335+
<DetailRow
336+
label={intl.formatMessage({ id: "prompts.details.label.lastModified" })}
337+
>
285338
{formatDateTime(selected.updatedAt)}
286339
</DetailRow>
287340
</dl>

client/src/components/prompts/PromptForm.test.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,20 @@ describe("PromptForm", () => {
125125
expect(onSuccess).toHaveBeenCalled();
126126
});
127127

128-
it("renders required field errors after submit", async () => {
128+
it("disables the submit button when required fields are empty", () => {
129129
renderPromptForm();
130130

131-
await userEvent.setup().click(screen.getByRole("button", { name: "Add prompt" }));
132-
133-
expect(screen.getByText("Name is required")).toBeInTheDocument();
134-
expect(screen.getByText("Template is required")).toBeInTheDocument();
131+
expect(screen.getByRole("button", { name: "Add prompt" })).toBeDisabled();
135132
expect(mockPost).not.toHaveBeenCalled();
136133
});
137134

135+
it("enables the submit button once all required fields are filled", async () => {
136+
renderPromptForm();
137+
await fillRequiredFields();
138+
139+
expect(screen.getByRole("button", { name: "Add prompt" })).toBeEnabled();
140+
});
141+
138142
it("requires an active team when visibility is set to team", async () => {
139143
renderPromptForm();
140144
const user = await fillRequiredFields();

0 commit comments

Comments
 (0)