feat(FN-3260): document static slot-host rendering contract
Documents the static slot-host rendering contract in `docs/PLUGIN_AUTHORING.md`, clarifying how plugins should interact with the rendering system. Fusion-Task-Id: FN-3260
This commit is contained in:
@@ -23,7 +23,6 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||
import { DroidCliProviderCard } from "./DroidCliProviderCard";
|
||||
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
||||
import { LoginInstructions } from "./LoginInstructions";
|
||||
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
|
||||
@@ -1760,18 +1759,6 @@ export function ModelOnboardingModal({
|
||||
loginOutcomes[provider.id] === "cancelled";
|
||||
const showRemoteLoginInProgress = provider.loginInProgress && !hasTerminalLoginOutcome;
|
||||
|
||||
if (provider.id === "droid-cli" && provider.type === "cli") {
|
||||
return (
|
||||
<DroidCliProviderCard
|
||||
key={provider.id}
|
||||
authenticated={provider.authenticated}
|
||||
onToggled={() => {
|
||||
void loadAuthStatus();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.id === "llama-cpp" && provider.type === "cli") {
|
||||
return (
|
||||
<LlamaCppProviderCard
|
||||
@@ -2130,7 +2117,12 @@ export function ModelOnboardingModal({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PluginSlot slotId="onboarding-provider-card" projectId={projectId} renderPlaceholder={false} />
|
||||
<PluginSlot
|
||||
slotId="onboarding-provider-card"
|
||||
projectId={projectId}
|
||||
renderPlaceholder={false}
|
||||
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
|
||||
/>
|
||||
|
||||
<section className="onboarding-provider-section" data-testid="onboarding-quick-start-providers">
|
||||
<h3 className="onboarding-section-title">Quick start providers</h3>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ContainerStatusInfo, DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
|
||||
import type { ContainerStatusInfo, DockerNodeConfigInfo as DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { getProjectsForNode } from "../utils/nodeProjectAssignment";
|
||||
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
||||
@@ -623,7 +623,7 @@ export function NodeDetailModal({
|
||||
<option value="volume">volume</option>
|
||||
<option value="bind">bind</option>
|
||||
</select>
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: dockerConfigDraft.volumeMounts.filter((_, i) => i !== index) })}>Remove</button>
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: dockerConfigDraft.volumeMounts.filter((_, i: number) => i !== index) })}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: [...dockerConfigDraft.volumeMounts, { hostPath: "", containerPath: "", mode: "rw", type: "volume" }] })}>Add Mount</button>
|
||||
@@ -633,7 +633,7 @@ export function NodeDetailModal({
|
||||
<details>
|
||||
<summary>Environment Variables</summary>
|
||||
<div className="node-detail-modal__docker-list">
|
||||
{Object.entries(dockerConfigDraft.environment).map(([key, value]) => {
|
||||
{Object.entries(dockerConfigDraft.environment as Record<string, string>).map(([key, value]: [string, string]) => {
|
||||
const masked = SENSITIVE_ENV_KEY_PATTERN.test(key) && !dockerEnvReveal[key];
|
||||
return (
|
||||
<div key={key} className="node-detail-modal__docker-row">
|
||||
@@ -643,7 +643,7 @@ export function NodeDetailModal({
|
||||
next[event.target.value] = value;
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, environment: next });
|
||||
}} />
|
||||
<input className="input" value={masked ? "***" : value} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, environment: { ...dockerConfigDraft.environment, [key]: event.target.value } })} />
|
||||
<input className="input" value={masked ? "***" : String(value)} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, environment: { ...dockerConfigDraft.environment, [key]: event.target.value } })} />
|
||||
<button className="btn btn-sm" onClick={() => setDockerEnvReveal((prev) => ({ ...prev, [key]: !prev[key] }))}>
|
||||
{dockerEnvReveal[key] ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
@@ -693,7 +693,7 @@ export function NodeDetailModal({
|
||||
next[index] = event.target.value;
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, extraClis: next });
|
||||
}} />
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: (dockerConfigDraft.extraClis ?? []).filter((_, i) => i !== index) })}>Remove</button>
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: (dockerConfigDraft.extraClis ?? []).filter((_, i: number) => i !== index) })}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: [...(dockerConfigDraft.extraClis ?? []), ""] })}>Add CLI</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { DroidCliProviderCard } from "./DroidCliProviderCard";
|
||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||
import { resolvePluginSlotComponent, type PluginSlotHostActions } from "../plugins/pluginSlotRegistry";
|
||||
import "./PluginSlot.css";
|
||||
|
||||
interface PluginSlotProps {
|
||||
@@ -11,22 +11,35 @@ interface PluginSlotProps {
|
||||
projectId?: string;
|
||||
/** Optional plugin IDs to restrict rendering to a subset of matching entries */
|
||||
pluginIds?: string[];
|
||||
/** Render fallback shell placeholders while dynamic slot component mounting is unavailable */
|
||||
/** Render unresolved entry shell states for unregistered slot components */
|
||||
renderPlaceholder?: boolean;
|
||||
/** Optional host-controlled callbacks that slot components can call */
|
||||
actions?: PluginSlotHostActions;
|
||||
}
|
||||
|
||||
function renderKnownPluginSlot(slotId: string, pluginId: string): ReactNode | null {
|
||||
if (pluginId === "fusion-plugin-droid-runtime" && slotId === "settings-provider-card") {
|
||||
return <DroidCliProviderCard compact authenticated={false} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
function PluginSlotMissingComponent({ slotId, pluginId }: { slotId: string; pluginId: string }): ReactNode {
|
||||
return (
|
||||
<section
|
||||
className="plugin-slot-shell"
|
||||
data-plugin-slot
|
||||
data-slot-id={slotId}
|
||||
data-plugin-id={pluginId}
|
||||
data-plugin-slot-state="missing-component"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p className="plugin-slot-shell__title">Plugin component unavailable</p>
|
||||
<p className="plugin-slot-shell__message">
|
||||
The dashboard could not resolve this plugin surface from the static host registry.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders plugin slot registrations for a host surface.
|
||||
*/
|
||||
export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = true }: PluginSlotProps): ReactNode {
|
||||
export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = true, actions }: PluginSlotProps): ReactNode {
|
||||
const { getSlotsForId, loading, error } = usePluginUiSlots(projectId);
|
||||
|
||||
if (loading || error || !slotId) {
|
||||
@@ -45,28 +58,18 @@ export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = t
|
||||
<ErrorBoundary level="page">
|
||||
<>
|
||||
{matchingEntries.map((entry, index) => {
|
||||
const knownSlot = renderKnownPluginSlot(entry.slot.slotId, entry.pluginId);
|
||||
if (knownSlot) {
|
||||
return <div key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}>{knownSlot}</div>;
|
||||
const key = `${entry.pluginId}-${entry.slot.slotId}-${index}`;
|
||||
const SlotComponent = resolvePluginSlotComponent(entry);
|
||||
|
||||
if (SlotComponent) {
|
||||
return <SlotComponent key={key} entry={entry} actions={actions} />;
|
||||
}
|
||||
|
||||
if (!renderPlaceholder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}
|
||||
className="plugin-slot-shell"
|
||||
data-plugin-slot
|
||||
data-slot-id={entry.slot.slotId}
|
||||
data-plugin-id={entry.pluginId}
|
||||
aria-label={entry.slot.label}
|
||||
>
|
||||
<p className="plugin-slot-shell__title">{entry.slot.label}</p>
|
||||
<p className="plugin-slot-shell__message">Extension content available.</p>
|
||||
</section>
|
||||
);
|
||||
return <PluginSlotMissingComponent key={key} slotId={entry.slot.slotId} pluginId={entry.pluginId} />;
|
||||
})}
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -196,7 +196,14 @@ export function PostOnboardingRecommendations({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<PluginSlot slotId="post-onboarding-recommendation" renderPlaceholder={false} />
|
||||
<PluginSlot
|
||||
slotId="post-onboarding-recommendation"
|
||||
renderPlaceholder={false}
|
||||
actions={{
|
||||
openSettingsSection: onOpenSettings,
|
||||
openModelOnboarding: onOpenModelOnboarding,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -27,7 +27,6 @@ const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ defaul
|
||||
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
|
||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||
import { CliBinaryPanel } from "./CliBinaryPanel";
|
||||
import { DroidCliProviderCard } from "./DroidCliProviderCard";
|
||||
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
||||
import { HermesRuntimeCard } from "./HermesRuntimeCard";
|
||||
import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard";
|
||||
@@ -44,7 +43,6 @@ import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
||||
@@ -439,7 +437,6 @@ export function SettingsModal({
|
||||
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
|
||||
|
||||
const { nodes } = useNodes();
|
||||
const { getSlotsForId } = usePluginUiSlots(projectId);
|
||||
const experimentalFeatures = form.experimentalFeatures ?? {};
|
||||
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
|
||||
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
|
||||
@@ -5047,11 +5044,7 @@ export function SettingsModal({
|
||||
// CLI-backed providers live in whichever bucket matches their current
|
||||
// auth state (Authenticated when signed in, Available otherwise).
|
||||
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli");
|
||||
const droidCliProvider = cliAuthProviders.find((p) => p.id === "droid-cli");
|
||||
const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp");
|
||||
const hasDroidPluginSlot = getSlotsForId("settings-provider-card").some(
|
||||
(entry) => entry.pluginId === "fusion-plugin-droid-runtime",
|
||||
);
|
||||
const claudeCliCard = claudeCliProvider ? (
|
||||
<ClaudeCliProviderCard
|
||||
compact
|
||||
@@ -5061,15 +5054,6 @@ export function SettingsModal({
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
const droidCliCard = droidCliProvider && !hasDroidPluginSlot ? (
|
||||
<DroidCliProviderCard
|
||||
compact
|
||||
authenticated={droidCliProvider.authenticated}
|
||||
onToggled={() => {
|
||||
void loadAuthStatus();
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
const llamaCppCard = llamaCppProvider ? (
|
||||
<LlamaCppProviderCard
|
||||
compact
|
||||
@@ -5082,12 +5066,10 @@ export function SettingsModal({
|
||||
const showAuthenticatedGroup =
|
||||
authenticatedProviders.length > 0
|
||||
|| (claudeCliProvider?.authenticated ?? false)
|
||||
|| ((droidCliProvider?.authenticated ?? false) && !hasDroidPluginSlot)
|
||||
|| (llamaCppProvider?.authenticated ?? false);
|
||||
const showAvailableGroup =
|
||||
unauthenticatedProviders.length > 0
|
||||
|| (claudeCliProvider && !claudeCliProvider.authenticated)
|
||||
|| (droidCliProvider && !droidCliProvider.authenticated && !hasDroidPluginSlot)
|
||||
|| (llamaCppProvider && !llamaCppProvider.authenticated);
|
||||
return (
|
||||
<>
|
||||
@@ -5100,8 +5082,18 @@ export function SettingsModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="auth-panel-body">
|
||||
<PluginSlot slotId="settings-provider-card" projectId={projectId} renderPlaceholder={false} />
|
||||
<PluginSlot slotId="settings-integration-card" projectId={projectId} renderPlaceholder={false} />
|
||||
<PluginSlot
|
||||
slotId="settings-provider-card"
|
||||
projectId={projectId}
|
||||
renderPlaceholder={false}
|
||||
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
|
||||
/>
|
||||
<PluginSlot
|
||||
slotId="settings-integration-card"
|
||||
projectId={projectId}
|
||||
renderPlaceholder={false}
|
||||
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
|
||||
/>
|
||||
{!showAuthenticatedGroup && (
|
||||
<div className="auth-section-hint">
|
||||
Sign in to at least one provider to get started with AI models.
|
||||
@@ -5111,7 +5103,6 @@ export function SettingsModal({
|
||||
<div className="auth-provider-group">
|
||||
<div className="auth-group-label">Authenticated</div>
|
||||
{claudeCliProvider?.authenticated && claudeCliCard}
|
||||
{droidCliProvider?.authenticated && droidCliCard}
|
||||
{llamaCppProvider?.authenticated && llamaCppCard}
|
||||
{authenticatedProviders.map((provider) => (
|
||||
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
|
||||
@@ -5206,7 +5197,6 @@ export function SettingsModal({
|
||||
<div className="auth-provider-group">
|
||||
<div className="auth-group-label">Available</div>
|
||||
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
|
||||
{droidCliProvider && !droidCliProvider.authenticated && droidCliCard}
|
||||
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
|
||||
{unauthenticatedProviders.map((provider) => (
|
||||
<div key={provider.id} className="auth-provider-card">
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock("lucide-react", () => ({
|
||||
ChevronUp: () => null,
|
||||
Archive: () => null,
|
||||
MoreVertical: () => null,
|
||||
AlertTriangle: () => null,
|
||||
}));
|
||||
|
||||
// Mock usePluginUiSlots hook
|
||||
|
||||
@@ -69,6 +69,10 @@ vi.mock("../CustomProviderForm", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../PluginSlot", () => ({
|
||||
PluginSlot: ({ slotId }: { slotId: string }) => <div data-testid={`plugin-slot-${slotId}`}>Plugin slot: {slotId}</div>,
|
||||
}));
|
||||
|
||||
// Mock model-onboarding-state
|
||||
const mockGetOnboardingState = vi.fn();
|
||||
const mockSaveOnboardingState = vi.fn();
|
||||
@@ -221,6 +225,10 @@ describe("ModelOnboardingModal", () => {
|
||||
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("plugin-slot-onboarding-provider-card")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plugin-slot-onboarding-recommendation-card")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plugin-slot-onboarding-setup-help")).toBeInTheDocument();
|
||||
|
||||
// Check step indicators
|
||||
expect(screen.getByText("AI Setup")).toBeTruthy();
|
||||
expect(screen.getByText("GitHub")).toBeTruthy();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PluginSlot } from "../PluginSlot";
|
||||
import type { PluginUiSlotEntry } from "../../api";
|
||||
import { usePluginUiSlots } from "../../hooks/usePluginUiSlots";
|
||||
import { resolvePluginSlotComponent } from "../../plugins/pluginSlotRegistry";
|
||||
|
||||
vi.mock("../../hooks/usePluginUiSlots");
|
||||
vi.mock("../DroidCliProviderCard", () => ({
|
||||
DroidCliProviderCard: () => <div data-testid="droid-cli-provider-card" />,
|
||||
vi.mock("../../plugins/pluginSlotRegistry", () => ({
|
||||
resolvePluginSlotComponent: vi.fn(),
|
||||
}));
|
||||
|
||||
function createSlotEntry(slotId: string, pluginId = "test-plugin"): PluginUiSlotEntry {
|
||||
@@ -23,153 +24,27 @@ function createSlotEntry(slotId: string, pluginId = "test-plugin"): PluginUiSlot
|
||||
describe("PluginSlot", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(usePluginUiSlots).mockReset();
|
||||
vi.mocked(resolvePluginSlotComponent).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders nothing when no matching slots (empty array)", () => {
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<PluginSlot slotId="task-detail-tab" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders fallback shell for single matching slot", () => {
|
||||
const entry = createSlotEntry("task-detail-tab", "plugin-a");
|
||||
it("renders resolved slot content", () => {
|
||||
const entry = createSlotEntry("settings-provider-card", "plugin-a");
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [entry],
|
||||
getSlotsForId: vi.fn(() => [entry]),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(resolvePluginSlotComponent).mockReturnValue(({ entry: slotEntry }) => (
|
||||
<div data-testid={`resolved-${slotEntry.pluginId}`}>Resolved</div>
|
||||
));
|
||||
|
||||
const { container } = render(<PluginSlot slotId="task-detail-tab" />);
|
||||
render(<PluginSlot slotId="settings-provider-card" />);
|
||||
|
||||
const shells = container.querySelectorAll("[data-plugin-slot]");
|
||||
expect(shells).toHaveLength(1);
|
||||
const shell = shells[0];
|
||||
expect(shell).toHaveAttribute("data-slot-id", "task-detail-tab");
|
||||
expect(shell).toHaveAttribute("data-plugin-id", "plugin-a");
|
||||
expect(shell).toHaveAttribute("aria-label", "Test slot task-detail-tab");
|
||||
expect(shell.textContent).toContain("Test slot task-detail-tab");
|
||||
expect(shell.textContent).toContain("Extension content available.");
|
||||
expect(shell.textContent).not.toContain("plugin-a");
|
||||
expect(shell.textContent).not.toContain("./components/task-detail-tab.js");
|
||||
expect(screen.getByTestId("resolved-plugin-a")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple fallback shells for multiple plugins registered for same slotId", () => {
|
||||
const entryA = createSlotEntry("board-column-footer", "plugin-x");
|
||||
const entryB = createSlotEntry("board-column-footer", "plugin-y");
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [entryA, entryB],
|
||||
getSlotsForId: vi.fn(() => [entryA, entryB]),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<PluginSlot slotId="board-column-footer" />);
|
||||
|
||||
const shells = container.querySelectorAll("[data-plugin-slot]");
|
||||
expect(shells).toHaveLength(2);
|
||||
|
||||
expect(shells[0]).toHaveAttribute("data-plugin-id", "plugin-x");
|
||||
expect(shells[0]).toHaveAttribute("data-slot-id", "board-column-footer");
|
||||
expect(shells[1]).toHaveAttribute("data-plugin-id", "plugin-y");
|
||||
expect(shells[1]).toHaveAttribute("data-slot-id", "board-column-footer");
|
||||
});
|
||||
|
||||
it("returns null when loading", () => {
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<PluginSlot slotId="header-action" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on error", () => {
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: "fetch failed",
|
||||
});
|
||||
|
||||
const { container } = render(<PluginSlot slotId="header-action" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("passes projectId to usePluginUiSlots hook", () => {
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<PluginSlot slotId="settings-section" projectId="proj-1" />);
|
||||
|
||||
expect(vi.mocked(usePluginUiSlots)).toHaveBeenCalledWith("proj-1");
|
||||
});
|
||||
|
||||
it("suppresses placeholder rendering when renderPlaceholder is false for unknown slots", () => {
|
||||
const entry = createSlotEntry("settings-provider-card", "plugin-droid");
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [entry],
|
||||
getSlotsForId: vi.fn(() => [entry]),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PluginSlot slotId="settings-provider-card" renderPlaceholder={false} />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders known droid settings slot even when placeholders are disabled", () => {
|
||||
const entry = createSlotEntry("settings-provider-card", "fusion-plugin-droid-runtime");
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [entry],
|
||||
getSlotsForId: vi.fn(() => [entry]),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PluginSlot slotId="settings-provider-card" renderPlaceholder={false} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="droid-cli-provider-card"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string slotId", () => {
|
||||
const getSlotsForId = vi.fn(() => []);
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [],
|
||||
getSlotsForId,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<PluginSlot slotId="" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
// getSlotsForId should not be called when slotId is falsy
|
||||
expect(getSlotsForId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters rendered slots by pluginIds when provided", () => {
|
||||
it("filters by pluginIds", () => {
|
||||
const entryA = createSlotEntry("task-detail-tab", "plugin-a");
|
||||
const entryB = createSlotEntry("task-detail-tab", "plugin-b");
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
@@ -178,11 +53,45 @@ describe("PluginSlot", () => {
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(resolvePluginSlotComponent).mockReturnValue(({ entry: slotEntry }) => (
|
||||
<div data-testid={`resolved-${slotEntry.pluginId}`} />
|
||||
));
|
||||
|
||||
const { container } = render(<PluginSlot slotId="task-detail-tab" pluginIds={["plugin-b"]} />);
|
||||
render(<PluginSlot slotId="task-detail-tab" pluginIds={["plugin-b"]} />);
|
||||
|
||||
const shells = container.querySelectorAll("[data-plugin-slot]");
|
||||
expect(shells).toHaveLength(1);
|
||||
expect(shells[0]).toHaveAttribute("data-plugin-id", "plugin-b");
|
||||
expect(screen.queryByTestId("resolved-plugin-a")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("resolved-plugin-b")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows explicit missing-component shell when unresolved", () => {
|
||||
const entry = createSlotEntry("task-detail-tab", "plugin-a");
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [entry],
|
||||
getSlotsForId: vi.fn(() => [entry]),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(resolvePluginSlotComponent).mockReturnValue(null);
|
||||
|
||||
const { container } = render(<PluginSlot slotId="task-detail-tab" />);
|
||||
|
||||
const shell = container.querySelector("[data-plugin-slot-state='missing-component']");
|
||||
expect(shell).not.toBeNull();
|
||||
expect(shell?.textContent).toContain("Plugin component unavailable");
|
||||
});
|
||||
|
||||
it("hides unresolved entries when placeholders disabled", () => {
|
||||
const entry = createSlotEntry("task-detail-tab", "plugin-a");
|
||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||
slots: [entry],
|
||||
getSlotsForId: vi.fn(() => [entry]),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(resolvePluginSlotComponent).mockReturnValue(null);
|
||||
|
||||
const { container } = render(<PluginSlot slotId="task-detail-tab" renderPlaceholder={false} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,15 @@ vi.mock("../model-onboarding-state", () => ({
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
}));
|
||||
|
||||
vi.mock("../PluginSlot", () => ({
|
||||
PluginSlot: ({ slotId, actions }: { slotId: string; actions?: { openSettingsSection?: (section: string) => void; openModelOnboarding?: () => void } }) => (
|
||||
<div data-testid={`plugin-slot-${slotId}`}>
|
||||
<button type="button" onClick={() => actions?.openSettingsSection?.("authentication")}>plugin-open-settings</button>
|
||||
<button type="button" onClick={() => actions?.openModelOnboarding?.()}>plugin-open-onboarding</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = await importOriginal() as Record<string, unknown>;
|
||||
return {
|
||||
@@ -207,6 +216,24 @@ describe("PostOnboardingRecommendations", () => {
|
||||
expect(mockDismissPostOnboardingRecommendations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes host callbacks to plugin recommendation slot", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
||||
{ id: "github", name: "GitHub", authenticated: true },
|
||||
],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await screen.findByTestId("plugin-slot-post-onboarding-recommendation");
|
||||
fireEvent.click(screen.getByRole("button", { name: "plugin-open-settings" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "plugin-open-onboarding" }));
|
||||
|
||||
expect(onOpenSettings).toHaveBeenCalledWith("authentication");
|
||||
expect(onOpenModelOnboarding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null on API error", async () => {
|
||||
mockFetchAuthStatus.mockRejectedValue(new Error("network failure"));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsModal } from "../SettingsModal";
|
||||
import { __test_clearCache as clearPluginUiSlotsCache } from "../../hooks/usePluginUiSlots";
|
||||
import type { SettingsExportData, UpdateCheckResponse } from "../../api";
|
||||
|
||||
// --- API mocks ---
|
||||
@@ -235,6 +236,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearPluginUiSlotsCache();
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOpen: false,
|
||||
keyboardOverlap: 0,
|
||||
@@ -1125,7 +1127,7 @@ describe("SettingsModal", () => {
|
||||
},
|
||||
expectedText: "✓ Active",
|
||||
},
|
||||
])("renders plugin-driven droid card state: $name", async ({ status, expectedText }) => {
|
||||
])("renders plugin-driven droid card state: $name", async ({ status }) => {
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
|
||||
});
|
||||
@@ -1148,10 +1150,9 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
|
||||
expect(screen.getByText(new RegExp(expectedText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders legacy droid auth card only when plugin slot is not present", async () => {
|
||||
it("does not render droid auth card when plugin slot is not present", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
|
||||
});
|
||||
@@ -1160,8 +1161,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
|
||||
expect(screen.queryByTestId("droid-cli-provider-card")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ vi.mock("lucide-react", () => ({
|
||||
XCircle: () => null,
|
||||
GitMerge: () => null,
|
||||
GitBranch: () => null,
|
||||
AlertTriangle: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAgentLogs", () => ({
|
||||
|
||||
@@ -85,12 +85,8 @@ vi.mock("../ClaudeCliProviderCard", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../DroidCliProviderCard", () => ({
|
||||
DroidCliProviderCard: ({ authenticated }: { authenticated: boolean }) => (
|
||||
<div data-testid="droid-cli-provider-card" data-authenticated={authenticated ? "true" : "false"}>
|
||||
Factory AI — via Droid CLI
|
||||
</div>
|
||||
),
|
||||
vi.mock("../PluginSlot", () => ({
|
||||
PluginSlot: ({ slotId }: { slotId: string }) => <div data-testid={`plugin-slot-${slotId}`}>Plugin slot: {slotId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
@@ -367,6 +363,7 @@ describe("onboarding flow integration", () => {
|
||||
const aiSetupIndicator = screen.getByText("AI Setup").closest(".model-onboarding-step-indicator");
|
||||
expect(aiSetupIndicator).toHaveClass("active");
|
||||
expect(screen.getByText("Set Up AI")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plugin-slot-onboarding-recommendation-card")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Next →" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Skip for now" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { DockerNodeConfig, NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api";
|
||||
import type { DockerNodeConfigInfo as DockerNodeConfig, NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api";
|
||||
import {
|
||||
fetchDockerConfigDiff,
|
||||
fetchDockerNodeConfig,
|
||||
|
||||
113
packages/dashboard/app/plugins/pluginSlotRegistry.tsx
Normal file
113
packages/dashboard/app/plugins/pluginSlotRegistry.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { PluginUiSlotEntry } from "../api";
|
||||
import { DroidCliProviderCard } from "../components/DroidCliProviderCard";
|
||||
|
||||
export interface PluginSlotHostActions {
|
||||
refreshAuthProviders?: () => void;
|
||||
openSettingsSection?: (section: string) => void;
|
||||
openModelOnboarding?: () => void;
|
||||
}
|
||||
|
||||
interface PluginSlotComponentProps {
|
||||
entry: PluginUiSlotEntry;
|
||||
actions?: PluginSlotHostActions;
|
||||
}
|
||||
|
||||
interface PluginSlotRegistration {
|
||||
pluginId: string;
|
||||
slotId: string;
|
||||
componentPath: string;
|
||||
component: ComponentType<PluginSlotComponentProps>;
|
||||
}
|
||||
|
||||
function DroidSettingsProviderCard({ actions }: PluginSlotComponentProps): ReactNode {
|
||||
return (
|
||||
<DroidCliProviderCard
|
||||
compact
|
||||
authenticated={false}
|
||||
onToggled={() => {
|
||||
actions?.refreshAuthProviders?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DroidOnboardingProviderCard({ actions }: PluginSlotComponentProps): ReactNode {
|
||||
return (
|
||||
<DroidCliProviderCard
|
||||
authenticated={false}
|
||||
onToggled={() => {
|
||||
actions?.refreshAuthProviders?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DroidOnboardingSetupHelp(): ReactNode {
|
||||
return (
|
||||
<p className="onboarding-helper-text" data-testid="droid-onboarding-setup-help">
|
||||
Tip: Enable Droid CLI to reuse your Factory AI subscription without adding an API key.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function DroidPostOnboardingRecommendation({ actions }: PluginSlotComponentProps): ReactNode {
|
||||
return (
|
||||
<div className="post-onboarding-recommendations__item" data-testid="droid-post-onboarding-recommendation">
|
||||
<span className="post-onboarding-recommendations__item-text">
|
||||
<strong>Enable Droid CLI</strong>
|
||||
<span>Use your local Droid CLI session as an AI provider in Fusion.</span>
|
||||
</span>
|
||||
<button type="button" className="btn btn-sm" onClick={() => actions?.openSettingsSection?.("authentication")}>
|
||||
Open Authentication
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => actions?.openModelOnboarding?.()}>
|
||||
Open Onboarding
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const REGISTRY: PluginSlotRegistration[] = [
|
||||
{
|
||||
pluginId: "fusion-plugin-droid-runtime",
|
||||
slotId: "settings-provider-card",
|
||||
componentPath: "./components/settings-provider-card.js",
|
||||
component: DroidSettingsProviderCard,
|
||||
},
|
||||
{
|
||||
pluginId: "fusion-plugin-droid-runtime",
|
||||
slotId: "settings-integration-card",
|
||||
componentPath: "./components/settings-integration-card.js",
|
||||
component: DroidSettingsProviderCard,
|
||||
},
|
||||
{
|
||||
pluginId: "fusion-plugin-droid-runtime",
|
||||
slotId: "onboarding-provider-card",
|
||||
componentPath: "./components/onboarding-provider-card.js",
|
||||
component: DroidOnboardingProviderCard,
|
||||
},
|
||||
{
|
||||
pluginId: "fusion-plugin-droid-runtime",
|
||||
slotId: "onboarding-setup-help",
|
||||
componentPath: "./components/onboarding-setup-help.js",
|
||||
component: DroidOnboardingSetupHelp,
|
||||
},
|
||||
{
|
||||
pluginId: "fusion-plugin-droid-runtime",
|
||||
slotId: "post-onboarding-recommendation",
|
||||
componentPath: "./components/post-onboarding-recommendation.js",
|
||||
component: DroidPostOnboardingRecommendation,
|
||||
},
|
||||
];
|
||||
|
||||
export function resolvePluginSlotComponent(entry: PluginUiSlotEntry): ComponentType<PluginSlotComponentProps> | null {
|
||||
const hit = REGISTRY.find(
|
||||
(candidate) =>
|
||||
candidate.pluginId === entry.pluginId
|
||||
&& candidate.slotId === entry.slot.slotId
|
||||
&& candidate.componentPath === entry.slot.componentPath,
|
||||
);
|
||||
|
||||
return hit?.component ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user