30e6cba810
* feat(plugins): add plugin-settings slot to per-plugin settings page
Add a named "plugin-settings" host slot so an installed plugin can render
its own inline UI (e.g. a live integration/health status card) on its
per-plugin settings page (Settings > Plugins > <plugin>), between the
schema-driven settings form and the manifest card.
The slot renders on every plugin's detail page, so the host forwards the
current plugin's identity via slotProps ({ pluginId, status }); registered
components must gate on slotProps.pluginId to avoid rendering on another
plugin's page. Documented in PLUGIN-API.md and mirrored in types.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(plugins): make plugin-settings slot owner-scoped in the host
Instead of forcing every plugin author to early-return unless
slotProps.pluginId matches its own id, the host now filters the
"plugin-settings" slot by the owning plugin. The registry already records
the owning pluginId per registration, so PluginSlot gains an optional
ownerPluginId prop backed by a new getSlotComponentsForPlugin() getter,
and the plugin-detail page passes the current plugin's id.
A plugin's settings card now renders only on its own settings page, with
no gating boilerplate in the plugin. Docs/types updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(plugins): add plugin-settings slot usage mockups
Add illustrative mockups of the plugin-settings slot rendered on a plugin's
settings page (Provider Usage health card) plus a strip of possible usages
(healthy, degraded, and a generic integration), referenced from PLUGIN-API.md.
Clearly labeled as mockups, not real product screenshots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): reset error boundary per owner in owner-scoped slots
Address review feedback on PR #1836:
- The PluginErrorBoundary key was `${name}-${index}`, identical across
plugins for an owner-scoped slot. Navigating /settings/plugins/A -> B in
the same settings shell (without remounting PluginSlot) reused A's errored
boundary and silently hid B's healthy card. Include ownerPluginId in the
key so each owner gets an independent boundary; add a regression test.
- Trim the verbose multi-line slot comment in plugin-detail to one line per
the repo's comment convention.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(plugins): document plugin-settings slot; drop mockup binaries
- Public docs: add the owner-scoped `plugin-settings` slot to the Named
slots table and a "Plugin settings page" section in plugins-authoring.md,
and mention inline status cards in plugins.md's per-plugin settings.
- Remove the illustrative mockup PNGs (and their PLUGIN-API.md embeds) that
were committed to the branch — image binaries should not merge into main;
PR screenshots belong on a throwaway media ref instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(plugins): condense owner-key boundary comment to one line
Address review nit — the repo prefers one short comment line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
4.4 KiB
TypeScript
122 lines
4.4 KiB
TypeScript
import { cleanup, render, screen } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { pluginRegistry } from "@/lib/plugins/registry";
|
|
import { PluginSlot } from "./plugin-slot";
|
|
|
|
const SLOT = "task-sidebar";
|
|
const OWNER_SLOT = "plugin-settings";
|
|
|
|
function cleanupPlugins(...pluginIds: string[]) {
|
|
pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id));
|
|
}
|
|
|
|
describe("PluginSlot", () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
cleanupPlugins("plugin-a", "plugin-b");
|
|
});
|
|
|
|
it("renders nothing when no plugin has registered a component for the slot", () => {
|
|
const { container } = render(<PluginSlot name={SLOT} />);
|
|
expect(container.innerHTML).toBe("");
|
|
});
|
|
|
|
it("renders every component registered for the named slot", () => {
|
|
pluginRegistry
|
|
.forPlugin("plugin-a")
|
|
.registerComponent(SLOT, () => <div data-testid="slot-a">A</div>);
|
|
pluginRegistry
|
|
.forPlugin("plugin-b")
|
|
.registerComponent(SLOT, () => <div data-testid="slot-b">B</div>);
|
|
|
|
render(<PluginSlot name={SLOT} />);
|
|
|
|
expect(screen.getByTestId("slot-a")).not.toBeNull();
|
|
expect(screen.getByTestId("slot-b")).not.toBeNull();
|
|
});
|
|
|
|
it("does not render a component registered for a different slot", () => {
|
|
pluginRegistry
|
|
.forPlugin("plugin-a")
|
|
.registerComponent("settings-nav", () => <div data-testid="slot-a">A</div>);
|
|
|
|
render(<PluginSlot name={SLOT} />);
|
|
|
|
expect(screen.queryByTestId("slot-a")).toBeNull();
|
|
});
|
|
|
|
it("passes slotProps through to each registered component", () => {
|
|
pluginRegistry
|
|
.forPlugin("plugin-a")
|
|
.registerComponent(SLOT, ({ slotProps }) => (
|
|
<div data-testid="slot-a">{String((slotProps as { label: string })?.label)}</div>
|
|
));
|
|
|
|
render(<PluginSlot name={SLOT} slotProps={{ label: "hello" }} />);
|
|
|
|
expect(screen.getByTestId("slot-a").textContent).toBe("hello");
|
|
});
|
|
|
|
it("renders only the owning plugin's component when ownerPluginId is set", () => {
|
|
// "plugin-settings" is owner-scoped: the host isolates by owner, so a
|
|
// registered component never has to gate on the current plugin id itself.
|
|
const Card = ({ slotProps }: { slotProps?: unknown }) => (
|
|
<div data-testid="settings-card">
|
|
card for {String((slotProps as { pluginId: string })?.pluginId)}
|
|
</div>
|
|
);
|
|
pluginRegistry.forPlugin("plugin-a").registerComponent(OWNER_SLOT, Card);
|
|
pluginRegistry.forPlugin("plugin-b").registerComponent(OWNER_SLOT, Card);
|
|
|
|
// Viewing plugin-b's page: only plugin-b's component renders, with its id.
|
|
render(
|
|
<PluginSlot
|
|
name={OWNER_SLOT}
|
|
ownerPluginId="plugin-b"
|
|
slotProps={{ pluginId: "plugin-b", status: "active" }}
|
|
/>,
|
|
);
|
|
|
|
const cards = screen.getAllByTestId("settings-card");
|
|
expect(cards).toHaveLength(1);
|
|
expect(cards[0]?.textContent).toBe("card for plugin-b");
|
|
});
|
|
|
|
it("resets the error boundary when the owner changes so a healthy plugin's card is not hidden", () => {
|
|
// eslint-disable-next-line no-console -- expected error boundary log, assert + silence it
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
pluginRegistry.forPlugin("plugin-a").registerComponent(OWNER_SLOT, () => {
|
|
throw new Error("boom");
|
|
});
|
|
pluginRegistry
|
|
.forPlugin("plugin-b")
|
|
.registerComponent(OWNER_SLOT, () => <div data-testid="slot-b">B</div>);
|
|
|
|
// Same PluginSlot instance: plugin-a throws, then we "navigate" to plugin-b.
|
|
const { rerender } = render(<PluginSlot name={OWNER_SLOT} ownerPluginId="plugin-a" />);
|
|
rerender(<PluginSlot name={OWNER_SLOT} ownerPluginId="plugin-b" />);
|
|
|
|
// plugin-b is healthy and must render, not be hidden behind plugin-a's error.
|
|
expect(screen.getByTestId("slot-b")).not.toBeNull();
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it("isolates a throwing slot component so a sibling still renders", () => {
|
|
// eslint-disable-next-line no-console -- expected error boundary log, assert + silence it
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, () => {
|
|
throw new Error("boom");
|
|
});
|
|
pluginRegistry
|
|
.forPlugin("plugin-b")
|
|
.registerComponent(SLOT, () => <div data-testid="slot-b">B</div>);
|
|
|
|
render(<PluginSlot name={SLOT} />);
|
|
|
|
expect(screen.getByTestId("slot-b")).not.toBeNull();
|
|
consoleError.mockRestore();
|
|
});
|
|
});
|