* feat(apps): AppsScreen and InspectorView wiring (#1263) Adds the Apps tab as a top-level screen composed of `AppListItem`, `AppDetailPanel`, and `AppRenderer`. The screen is presentational — selection, form values, running, and maximize state are local; data and side effects come in via props and callbacks. InspectorView filters `tools` through `isAppTool` (wrapped in a try/catch so a malformed `_meta.ui.resourceUri` skips one tool instead of dropping every following App), exposes the Apps tab in `ALL_TABS` after Tools, and wires Phase-3 stub callbacks plus a stub bridge factory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(apps): extract AppControls and rename Refresh button Pulls the sidebar list, search input, "List updated" indicator, and Refresh button out of `AppsScreen` into a dedicated `AppControls` group, mirroring `ToolControls` and `TaskControls`. The Refresh button moves from a full-width row beneath the list to the top-right of the title row (single label "Refresh", matching `TaskControls`). `AppsScreen` keeps the right-pane orchestration (selection, form, running, maximize, header actions) and now delegates the entire sidebar to `AppControls`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(stories): seed InspectorView demo tools with MCP Apps Adds three apps (Cohort Data, Weather Widget, Ops Dashboard) carrying real `_meta.ui.resourceUri` to the demo tools list so the Apps tab has content in the Default story alongside the existing tools. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(apps): drop AppDetailPanel title row to avoid duplicate name The parent AppsScreen header already shows the app name, so the panel's own title/icon row rendered the name twice. Remove the title row (and its now-unused imports, styled subcomponents, and the four tests that asserted on title/icon rendering). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(resources): drop annotation badges from ResourceListItem The list row now renders just the resource title/name. Audience and priority badges remain visible in the preview/template panels, so the data is still surfaced — just not duplicated in the picker. Drops the now-unused AnnotationBadge import, the badge-only stories, and the badge-rendering test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(apps): address review — share hasInputFields, show header icon - Extracts the duplicated `Object.keys(inputSchema.properties ?? {}).length > 0` check from `AppsScreen` (and the equivalent in `AppDetailPanel`'s `hasFields`) into a shared `hasInputFields` helper in `toolUtils`, with tests. - Renders the selected tool's first icon next to the right-pane header title in `AppsScreen`, restoring the icon visibility that was lost when `AppDetailPanel`'s title row was removed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(apps): finish hasInputFields extraction; document isOpening - AppDetailPanel now actually calls the shared `hasInputFields` helper instead of inlining the same `Object.keys(...).length > 0` check. - AppsScreen comments why `isOpening={false}` is hardcoded: the screen flips to AppRenderer synchronously, so the loading state is unreachable here — the prop remains for standalone use and Phase 3 wiring. 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
323f42d11c
commit
5f296589ea
8 changed files with 1210 additions and 164 deletions
|
|
@ -0,0 +1,88 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { fn, userEvent, within } from "storybook/test";
|
||||
import { AppControls } from "./AppControls";
|
||||
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
|
||||
|
||||
const sampleApps: Tool[] = [
|
||||
{
|
||||
name: "get-cohort-data",
|
||||
title: "Cohort Data",
|
||||
description: "Returns cohort retention heatmap data.",
|
||||
inputSchema: { type: "object" },
|
||||
_meta: { ui: { resourceUri: "ui://apps/cohort" } },
|
||||
},
|
||||
{
|
||||
name: "weather-widget",
|
||||
title: "Weather Widget",
|
||||
description: "Live weather and a five-day forecast for any city.",
|
||||
icons: [{ src: SUN_ICON_SVG, mimeType: "image/svg+xml" }],
|
||||
inputSchema: { type: "object" },
|
||||
_meta: { ui: { resourceUri: "ui://apps/weather" } },
|
||||
},
|
||||
{
|
||||
name: "ops-dashboard",
|
||||
title: "Ops Dashboard",
|
||||
description: "Current operational status across services.",
|
||||
inputSchema: { type: "object" },
|
||||
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
||||
},
|
||||
{
|
||||
name: "git_log",
|
||||
description: "Recent commits on the current branch.",
|
||||
inputSchema: { type: "object" },
|
||||
_meta: { ui: { resourceUri: "ui://apps/git-log" } },
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<typeof AppControls> = {
|
||||
title: "Groups/AppControls",
|
||||
component: AppControls,
|
||||
args: {
|
||||
onRefreshList: fn(),
|
||||
onSelectApp: fn(),
|
||||
listChanged: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AppControls>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
tools: sampleApps,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: {
|
||||
tools: sampleApps,
|
||||
selectedName: "weather-widget",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSearch: Story = {
|
||||
args: {
|
||||
tools: sampleApps,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.type(
|
||||
await canvas.findByPlaceholderText("Search apps..."),
|
||||
"git",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ListChanged: Story = {
|
||||
args: {
|
||||
tools: sampleApps,
|
||||
listChanged: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
tools: [],
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
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 { AppControls } from "./AppControls";
|
||||
|
||||
const sampleApps: Tool[] = [
|
||||
{
|
||||
name: "weather",
|
||||
title: "Weather Widget",
|
||||
inputSchema: { type: "object" },
|
||||
_meta: { ui: { resourceUri: "ui://apps/weather" } },
|
||||
},
|
||||
{
|
||||
name: "ops",
|
||||
title: "Ops Dashboard",
|
||||
inputSchema: { type: "object" },
|
||||
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
||||
},
|
||||
{
|
||||
name: "git_status",
|
||||
inputSchema: { type: "object" },
|
||||
_meta: { ui: { resourceUri: "ui://apps/git-status" } },
|
||||
},
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
tools: sampleApps,
|
||||
listChanged: false,
|
||||
onRefreshList: vi.fn(),
|
||||
onSelectApp: vi.fn(),
|
||||
};
|
||||
|
||||
describe("AppControls", () => {
|
||||
it("renders the title with the app count and a search input", () => {
|
||||
renderWithMantine(<AppControls {...baseProps} />);
|
||||
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Search apps...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all apps by default", () => {
|
||||
renderWithMantine(<AppControls {...baseProps} />);
|
||||
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ops Dashboard")).toBeInTheDocument();
|
||||
expect(screen.getByText("git_status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters apps by name when typing in the search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<AppControls {...baseProps} />);
|
||||
await user.type(screen.getByPlaceholderText("Search apps..."), "git");
|
||||
expect(screen.getByText("git_status")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Weather Widget")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters apps by title when typing in the search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<AppControls {...baseProps} />);
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Search apps..."),
|
||||
"weather widget",
|
||||
);
|
||||
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Ops Dashboard")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'No apps available' when the tool list is empty", () => {
|
||||
renderWithMantine(<AppControls {...baseProps} tools={[]} />);
|
||||
expect(screen.getByText("No apps available")).toBeInTheDocument();
|
||||
expect(screen.getByText("MCP Apps (0)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'No matching apps' when search yields no results", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<AppControls {...baseProps} />);
|
||||
await user.type(screen.getByPlaceholderText("Search apps..."), "zzz");
|
||||
expect(screen.getByText("No matching apps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onSelectApp when an unselected app is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectApp = vi.fn();
|
||||
renderWithMantine(<AppControls {...baseProps} onSelectApp={onSelectApp} />);
|
||||
await user.click(screen.getByText("git_status"));
|
||||
expect(onSelectApp).toHaveBeenCalledWith("git_status");
|
||||
});
|
||||
|
||||
it("does not invoke onSelectApp when the already-selected app is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectApp = vi.fn();
|
||||
renderWithMantine(
|
||||
<AppControls
|
||||
{...baseProps}
|
||||
selectedName="git_status"
|
||||
onSelectApp={onSelectApp}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("git_status"));
|
||||
expect(onSelectApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invokes onRefreshList when the toolbar Refresh button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRefreshList = vi.fn();
|
||||
renderWithMantine(
|
||||
<AppControls {...baseProps} onRefreshList={onRefreshList} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not show the list-changed indicator when listChanged is false", () => {
|
||||
renderWithMantine(<AppControls {...baseProps} />);
|
||||
expect(screen.queryByText("List updated")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the list-changed indicator when listChanged is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRefreshList = vi.fn();
|
||||
renderWithMantine(
|
||||
<AppControls {...baseProps} listChanged onRefreshList={onRefreshList} />,
|
||||
);
|
||||
expect(screen.getByText("List updated")).toBeInTheDocument();
|
||||
// Both the toolbar button and the list-changed indicator's button render
|
||||
// as "Refresh"; either one should drive onRefreshList.
|
||||
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
|
||||
expect(refreshButtons).toHaveLength(2);
|
||||
await user.click(refreshButtons[1]);
|
||||
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
|
||||
import { AppListItem } from "../AppListItem/AppListItem";
|
||||
|
||||
export interface AppControlsProps {
|
||||
tools: Tool[];
|
||||
selectedName?: string;
|
||||
listChanged: boolean;
|
||||
onRefreshList: () => void;
|
||||
onSelectApp: (name: string) => void;
|
||||
}
|
||||
|
||||
const LIST_MAX_HEIGHT =
|
||||
"calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)";
|
||||
|
||||
const ToolbarButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "sm",
|
||||
});
|
||||
|
||||
const EmptyState = Text.withProps({
|
||||
c: "dimmed",
|
||||
ta: "center",
|
||||
py: "xl",
|
||||
});
|
||||
|
||||
export function AppControls({
|
||||
tools,
|
||||
selectedName,
|
||||
listChanged,
|
||||
onRefreshList,
|
||||
onSelectApp,
|
||||
}: AppControlsProps) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const query = searchText.toLowerCase();
|
||||
const filteredTools = searchText
|
||||
? tools.filter(
|
||||
(tool) =>
|
||||
tool.name.toLowerCase().includes(query) ||
|
||||
(tool.title?.toLowerCase().includes(query) ?? false),
|
||||
)
|
||||
: tools;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Title order={4}>MCP Apps ({tools.length})</Title>
|
||||
<ToolbarButton onClick={onRefreshList}>Refresh</ToolbarButton>
|
||||
</Group>
|
||||
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
|
||||
<TextInput
|
||||
placeholder="Search apps..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.currentTarget.value)}
|
||||
/>
|
||||
<ScrollArea.Autosize mah={LIST_MAX_HEIGHT}>
|
||||
<Stack gap="xs">
|
||||
{filteredTools.length === 0 ? (
|
||||
<EmptyState>
|
||||
{tools.length === 0 ? "No apps available" : "No matching apps"}
|
||||
</EmptyState>
|
||||
) : (
|
||||
filteredTools.map((tool) => (
|
||||
<AppListItem
|
||||
key={tool.name}
|
||||
tool={tool}
|
||||
selected={tool.name === selectedName}
|
||||
onClick={() => {
|
||||
if (tool.name !== selectedName) onSelectApp(tool.name);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,8 +4,6 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { AppDetailPanel } from "./AppDetailPanel";
|
||||
|
||||
const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E";
|
||||
|
||||
const noFieldsTool: Tool = {
|
||||
name: "no_input_app",
|
||||
title: "No Input App",
|
||||
|
|
@ -25,16 +23,6 @@ const requiredFieldTool: Tool = {
|
|||
},
|
||||
};
|
||||
|
||||
const optionalFieldTool: Tool = {
|
||||
name: "greet",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "The name to greet" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
formValues: {},
|
||||
isOpening: false,
|
||||
|
|
@ -43,20 +31,6 @@ const baseProps = {
|
|||
};
|
||||
|
||||
describe("AppDetailPanel", () => {
|
||||
it("prefers the title over the name", () => {
|
||||
renderWithMantine(
|
||||
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
|
||||
);
|
||||
expect(screen.getByText("Greet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the name when title is missing", () => {
|
||||
renderWithMantine(
|
||||
<AppDetailPanel {...baseProps} tool={optionalFieldTool} />,
|
||||
);
|
||||
expect(screen.getByText("greet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the description when provided", () => {
|
||||
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
|
||||
expect(screen.getByText("Takes no parameters")).toBeInTheDocument();
|
||||
|
|
@ -69,22 +43,6 @@ describe("AppDetailPanel", () => {
|
|||
expect(screen.queryByText("Takes no parameters")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the first icon when tool.icons is present", () => {
|
||||
renderWithMantine(
|
||||
<AppDetailPanel
|
||||
{...baseProps}
|
||||
tool={{ ...noFieldsTool, icons: [{ src: ICON_SRC }] }}
|
||||
/>,
|
||||
);
|
||||
const img = screen.getByRole("presentation");
|
||||
expect(img).toHaveAttribute("src", ICON_SRC);
|
||||
});
|
||||
|
||||
it("does not render an icon when tool.icons is missing", () => {
|
||||
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
|
||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the schema form using the tool's inputSchema", () => {
|
||||
renderWithMantine(
|
||||
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Button, Divider, Group, Image, Stack, Text } from "@mantine/core";
|
||||
import { Button, Divider, Stack, Text } from "@mantine/core";
|
||||
import { MdPlayArrow } from "react-icons/md";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { resolveDisplayLabel } from "../../../utils/toolUtils";
|
||||
import { SchemaForm } from "../SchemaForm/SchemaForm";
|
||||
import { hasInputFields } from "../../../utils/toolUtils";
|
||||
|
||||
export interface AppDetailPanelProps {
|
||||
tool: Tool;
|
||||
|
|
@ -12,29 +12,11 @@ export interface AppDetailPanelProps {
|
|||
onOpenApp: () => void;
|
||||
}
|
||||
|
||||
const PanelTitle = Text.withProps({
|
||||
fw: 700,
|
||||
size: "lg",
|
||||
truncate: true,
|
||||
});
|
||||
|
||||
const DescriptionText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
});
|
||||
|
||||
const TitleRow = Group.withProps({
|
||||
gap: "sm",
|
||||
align: "center",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const PanelIcon = Image.withProps({
|
||||
w: 24,
|
||||
h: 24,
|
||||
fit: "contain",
|
||||
});
|
||||
|
||||
function hasMissingRequiredFields(
|
||||
schema: Tool["inputSchema"],
|
||||
values: Record<string, unknown>,
|
||||
|
|
@ -53,19 +35,13 @@ export function AppDetailPanel({
|
|||
onFormChange,
|
||||
onOpenApp,
|
||||
}: AppDetailPanelProps) {
|
||||
const { name, title, description, icons, inputSchema } = tool;
|
||||
const iconSrc = icons?.[0]?.src;
|
||||
const { description, inputSchema } = tool;
|
||||
const hasErrors = hasMissingRequiredFields(inputSchema, formValues);
|
||||
const disabled = isOpening || hasErrors;
|
||||
const hasFields = Object.keys(inputSchema.properties ?? {}).length > 0;
|
||||
const hasFields = hasInputFields(tool);
|
||||
|
||||
return (
|
||||
<Stack gap="md" miw={0}>
|
||||
<TitleRow>
|
||||
{iconSrc && <PanelIcon src={iconSrc} alt="" />}
|
||||
<PanelTitle>{resolveDisplayLabel(name, title)}</PanelTitle>
|
||||
</TitleRow>
|
||||
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
|
||||
{hasFields && <Divider />}
|
||||
|
|
|
|||
|
|
@ -53,48 +53,3 @@ export const Template: Story = {
|
|||
selected: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAudience: Story = {
|
||||
args: {
|
||||
resource: {
|
||||
name: "settings.json",
|
||||
uri: "file:///settings.json",
|
||||
annotations: { audience: ["user"] },
|
||||
},
|
||||
selected: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPriority: Story = {
|
||||
args: {
|
||||
resource: {
|
||||
name: "schema.sql",
|
||||
uri: "file:///schema.sql",
|
||||
annotations: { priority: 0.8 },
|
||||
},
|
||||
selected: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAudienceAndPriority: Story = {
|
||||
args: {
|
||||
resource: {
|
||||
name: "config.json",
|
||||
title: "Configuration File",
|
||||
uri: "file:///config.json",
|
||||
annotations: { audience: ["user", "assistant"], priority: 0.5 },
|
||||
},
|
||||
selected: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const TemplateWithAnnotations: Story = {
|
||||
args: {
|
||||
resource: {
|
||||
name: "User Profile",
|
||||
uriTemplate: "file:///users/{userId}/profile",
|
||||
annotations: { audience: ["assistant"], priority: 0.2 },
|
||||
},
|
||||
selected: false,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ describe("ResourceListItem", () => {
|
|||
expect(screen.getByText("Configuration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders annotation badges when annotations are present", () => {
|
||||
it("does not render annotation badges even when annotations are present", () => {
|
||||
renderWithMantine(
|
||||
<ResourceListItem
|
||||
resource={{
|
||||
|
|
@ -40,19 +40,8 @@ describe("ResourceListItem", () => {
|
|||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("audience: user")).toBeInTheDocument();
|
||||
expect(screen.getByText("priority: high")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides badges when only an empty audience annotation exists", () => {
|
||||
renderWithMantine(
|
||||
<ResourceListItem
|
||||
resource={{ ...baseResource, annotations: { audience: [] } }}
|
||||
selected={false}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/audience/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/priority/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onClick when the row is clicked", async () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Group, Text, UnstyledButton } from "@mantine/core";
|
||||
import { Text, UnstyledButton } from "@mantine/core";
|
||||
import type {
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
||||
|
||||
export interface ResourceListItemProps {
|
||||
resource: Resource | ResourceTemplate;
|
||||
|
|
@ -11,28 +10,11 @@ export interface ResourceListItemProps {
|
|||
onClick: () => void;
|
||||
}
|
||||
|
||||
const RowGroup = Group.withProps({
|
||||
gap: "xs",
|
||||
wrap: "wrap",
|
||||
justify: "space-between",
|
||||
});
|
||||
|
||||
const BadgeGroup = Group.withProps({
|
||||
gap: "xs",
|
||||
wrap: "wrap",
|
||||
});
|
||||
|
||||
export function ResourceListItem({
|
||||
resource,
|
||||
selected,
|
||||
onClick,
|
||||
}: ResourceListItemProps) {
|
||||
const audience = resource.annotations?.audience;
|
||||
const priority = resource.annotations?.priority;
|
||||
const showBadges =
|
||||
(audience !== undefined && audience.length > 0) || priority !== undefined;
|
||||
const label = <Text fw={500}>{resource.title ?? resource.name}</Text>;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
|
|
@ -41,21 +23,7 @@ export function ResourceListItem({
|
|||
bg={selected ? "var(--mantine-primary-color-light)" : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
{showBadges ? (
|
||||
<RowGroup>
|
||||
{label}
|
||||
<BadgeGroup>
|
||||
{audience !== undefined && audience.length > 0 && (
|
||||
<AnnotationBadge facet="audience" value={audience} />
|
||||
)}
|
||||
{priority !== undefined && (
|
||||
<AnnotationBadge facet="priority" value={priority} />
|
||||
)}
|
||||
</BadgeGroup>
|
||||
</RowGroup>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
<Text fw={500}>{resource.title ?? resource.name}</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue