* feat(apps): add AppListItem and AppDetailPanel groups (#1261) Two presentational groups for the upcoming AppsScreen. AppListItem (clients/web/src/components/groups/AppListItem/): - Props { tool, selected, onClick } - Renders tool.icons[0] (sized sm) when present, label (title ?? name), two-line clamped description, and a trailing chevron affordance. - Reuses ToolListItem's selected-state highlight (UnstyledButton bg via --mantine-primary-color-light). AppDetailPanel (clients/web/src/components/groups/AppDetailPanel/): - Props { tool, formValues, isOpening, onFormChange, onOpenApp } - Layout: optional icon + title row, optional description, divider, SchemaForm bound to tool.inputSchema, full-width "Open App" button. - "Open App" is disabled while isOpening or while any required input field is empty (validation derived from tool.inputSchema.required). - No annotations, progress, or cancel — execution is delegated to the embedded app, not run as an MCP tool call from the panel. Convention notes: - Issue text references @tabler IconChevronRight / IconPlayerPlay; the v2 web client already uses react-icons (md/ri namespaces), so this PR uses MdChevronRight and MdPlayArrow for consistency rather than introducing a second icon library. Stories: AppListItem (Default, Selected, WithIcon, NoDescription, LongName); AppDetailPanel (NoFields, SimpleStringParam, MultipleParams, WithIcon, Opening, ComplexSchema). Tests: 22 cases covering rendering branches, callback wiring, and the disabled-button derivation. Per-file coverage 100% across lines, statements, functions, and branches for both new components. Closes #1261 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(stories): inline SVG for WithIcon stories so they render reliably The Wikipedia-hosted PNG used in the AppListItem and AppDetailPanel WithIcon stories returned a broken image in Storybook (rate-limited / hot-link blocked). Replace it with an inline SVG sun icon as a data URI so the stories render offline, in Chromatic, and on any network. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(apps): address PR #1273 review feedback - Extract resolveDisplayLabel(name, title?) to src/utils/toolUtils.ts and migrate all four call sites (AppListItem, AppDetailPanel, ToolListItem, ToolDetailPanel) to consume it. Avoids the fourth copy when the next list/detail screen arrives. Includes a small unit suite. - AppDetailPanel: only render the <Divider/> when the schema has at least one property — previously a no-input app showed an orphaned separator between the description and the Open App button. - AppDetailPanel: align PanelTitle truncate to `true` (matches the ItemLabel form used in AppListItem / ToolListItem); behaviorally identical, just less surprising for future readers. - AppDetailPanel: add an inline comment explaining that SchemaForm is disabled only while isOpening, not while validation fails — users should be able to keep filling required fields. The validation gate lives on the Open App button. - Extract the inline SUN_ICON_SVG data URI used by the WithIcon stories to src/test/fixtures/storyIcons.ts so the next story that needs it doesn't grow a third copy. - Tests: add the AppDetailPanel divider-conditional cases (rendered with fields, omitted without). Per-file coverage stays at 100% for AppListItem, AppDetailPanel, ToolListItem, ToolDetailPanel, and the new toolUtils. Suite: 70 files / 618 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(toolUtils): rename empty-string test for readability Drops the doubled "is ... is preserved" phrase in the test name. Behavior unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8fa1a0c074
commit
1ffcdc38ca
3 changed files with 712 additions and 10 deletions
|
|
@ -0,0 +1,80 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { fn } from "storybook/test";
|
||||
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
|
||||
import { AppListItem } from "./AppListItem";
|
||||
|
||||
const meta: Meta<typeof AppListItem> = {
|
||||
title: "Groups/AppListItem",
|
||||
component: AppListItem,
|
||||
args: {
|
||||
onClick: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AppListItem>;
|
||||
|
||||
const calculatorTool: Tool = {
|
||||
name: "calculator",
|
||||
title: "Calculator",
|
||||
description: "An interactive calculator widget for arithmetic operations.",
|
||||
inputSchema: { type: "object" },
|
||||
};
|
||||
|
||||
const noDescriptionTool: Tool = {
|
||||
name: "no_description",
|
||||
title: "No Description",
|
||||
inputSchema: { type: "object" },
|
||||
};
|
||||
|
||||
const withIconTool: Tool = {
|
||||
name: "weather_widget",
|
||||
title: "Weather Widget",
|
||||
description:
|
||||
"Displays the current weather and a five-day forecast for any city.",
|
||||
icons: [{ src: SUN_ICON_SVG, mimeType: "image/svg+xml" }],
|
||||
inputSchema: { type: "object" },
|
||||
};
|
||||
|
||||
const longNameTool: Tool = {
|
||||
name: "this_is_a_very_long_app_tool_name_that_should_truncate_in_the_list_view_when_rendered",
|
||||
description:
|
||||
"A description that itself runs to a couple of lines so we can confirm the line clamp behavior on the description renders correctly without pushing the chevron down or wrapping the row in unexpected ways.",
|
||||
inputSchema: { type: "object" },
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
tool: calculatorTool,
|
||||
selected: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
tool: calculatorTool,
|
||||
selected: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
tool: withIconTool,
|
||||
selected: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoDescription: Story = {
|
||||
args: {
|
||||
tool: noDescriptionTool,
|
||||
selected: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const LongName: Story = {
|
||||
args: {
|
||||
tool: longNameTool,
|
||||
selected: false,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { AppListItem } from "./AppListItem";
|
||||
|
||||
const baseTool: Tool = {
|
||||
name: "calculator",
|
||||
title: "Calculator",
|
||||
description: "Arithmetic operations",
|
||||
inputSchema: { type: "object" },
|
||||
};
|
||||
|
||||
const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E";
|
||||
|
||||
describe("AppListItem", () => {
|
||||
it("prefers the tool title over the name", () => {
|
||||
renderWithMantine(
|
||||
<AppListItem tool={baseTool} selected={false} onClick={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText("Calculator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the name when title is missing", () => {
|
||||
renderWithMantine(
|
||||
<AppListItem
|
||||
tool={{ ...baseTool, title: undefined }}
|
||||
selected={false}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("calculator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the description when provided", () => {
|
||||
renderWithMantine(
|
||||
<AppListItem tool={baseTool} selected={false} onClick={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText("Arithmetic operations")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render a description block when description is missing", () => {
|
||||
renderWithMantine(
|
||||
<AppListItem
|
||||
tool={{ ...baseTool, description: undefined }}
|
||||
selected={false}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Arithmetic operations")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the first icon when tool.icons is present", () => {
|
||||
renderWithMantine(
|
||||
<AppListItem
|
||||
tool={{ ...baseTool, icons: [{ src: ICON_SRC }] }}
|
||||
selected={false}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
const img = screen.getByRole("presentation");
|
||||
expect(img).toHaveAttribute("src", ICON_SRC);
|
||||
});
|
||||
|
||||
it("does not render an icon when tool.icons is missing", () => {
|
||||
renderWithMantine(
|
||||
<AppListItem tool={baseTool} selected={false} onClick={() => {}} />,
|
||||
);
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onClick when the row is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
renderWithMantine(
|
||||
<AppListItem tool={baseTool} selected={false} onClick={onClick} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the selected state without crashing", () => {
|
||||
renderWithMantine(
|
||||
<AppListItem tool={baseTool} selected onClick={() => {}} />,
|
||||
);
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { Group, Image, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { MdChevronRight } from "react-icons/md";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { resolveDisplayLabel } from "../../../utils/toolUtils";
|
||||
|
||||
export interface AppListItemProps {
|
||||
tool: Tool;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ItemLabel = Text.withProps({
|
||||
fw: 500,
|
||||
truncate: true,
|
||||
});
|
||||
|
||||
const ItemDescription = Text.withProps({
|
||||
size: "xs",
|
||||
c: "dimmed",
|
||||
lineClamp: 2,
|
||||
});
|
||||
|
||||
const ItemBody = Stack.withProps({
|
||||
gap: 2,
|
||||
flex: 1,
|
||||
miw: 0,
|
||||
});
|
||||
|
||||
const Row = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
align: "flex-start",
|
||||
});
|
||||
|
||||
const AppIcon = Image.withProps({
|
||||
w: 20,
|
||||
h: 20,
|
||||
fit: "contain",
|
||||
});
|
||||
|
||||
export function AppListItem({ tool, selected, onClick }: AppListItemProps) {
|
||||
const { name, title, description, icons } = tool;
|
||||
const iconSrc = icons?.[0]?.src;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
p="sm"
|
||||
variant="listItem"
|
||||
bg={selected ? "var(--mantine-primary-color-light)" : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Row>
|
||||
{iconSrc && <AppIcon src={iconSrc} alt="" />}
|
||||
<ItemBody>
|
||||
<ItemLabel>{resolveDisplayLabel(name, title)}</ItemLabel>
|
||||
{description && <ItemDescription>{description}</ItemDescription>}
|
||||
</ItemBody>
|
||||
<MdChevronRight
|
||||
aria-hidden
|
||||
color="var(--inspector-text-secondary)"
|
||||
size={18}
|
||||
/>
|
||||
</Row>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue