feat(FN-1926): merge fusion/fn-1926
This commit is contained in:
@@ -5,6 +5,7 @@ import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@fusion/core";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import { WorktreeGroup } from "./WorktreeGroup";
|
||||
import { QuickEntryBox } from "./QuickEntryBox";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { groupByWorktree } from "../utils/worktreeGrouping";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ChevronDown, ChevronUp, Archive } from "lucide-react";
|
||||
@@ -273,6 +274,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PluginSlot slotId="board-column-footer" projectId={projectId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { NodeConfig, ProjectStatus } from "@fusion/core";
|
||||
import { fetchScripts } from "../api";
|
||||
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
||||
import { NodeStatusIndicator } from "./NodeStatusIndicator";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
||||
|
||||
export { useViewportMode };
|
||||
@@ -930,6 +931,9 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Plugin UI slot for header actions */}
|
||||
<PluginSlot slotId="header-action" projectId={projectId} />
|
||||
|
||||
{/* Compact overflow menu trigger (mobile + tablet) */}
|
||||
{isCompact && !hideFullNav && (
|
||||
<button
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ThemeSelector } from "./ThemeSelector";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { PluginManager } from "./PluginManager";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { AgentPromptsManager } from "./AgentPromptsManager";
|
||||
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
|
||||
|
||||
@@ -2564,6 +2565,7 @@ export function SettingsModal({
|
||||
<>
|
||||
<h4 className="settings-section-heading">Plugins</h4>
|
||||
<PluginManager addToast={addToast} projectId={projectId} />
|
||||
<PluginSlot slotId="settings-section" projectId={projectId} />
|
||||
</>
|
||||
);
|
||||
case "authentication":
|
||||
|
||||
@@ -5,6 +5,7 @@ import { COLUMN_LABELS, VALID_TRANSITIONS } from "@fusion/core";
|
||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||
import { GitHubBadge } from "./GitHubBadge";
|
||||
import { pickPreferredBadge } from "./TaskCardBadge";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
||||
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
||||
@@ -1056,6 +1057,7 @@ function TaskCardComponent({
|
||||
{(queued || task.status === "queued") && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
|
||||
</div>
|
||||
)}
|
||||
<PluginSlot slotId="task-card-badge" projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { TaskChangesTab } from "./TaskChangesTab";
|
||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||
import { WorkflowResultsTab } from "./WorkflowResultsTab";
|
||||
import { TaskDocumentsTab } from "./TaskDocumentsTab";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
@@ -167,7 +169,7 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
type TabId = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "documents";
|
||||
type TabId = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "documents" | `plugin-${string}`;
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: Task | TaskDetail;
|
||||
@@ -316,6 +318,10 @@ export function TaskDetailModal({
|
||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||
const actionsMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Plugin UI slots for task-detail-tab
|
||||
const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId);
|
||||
const pluginTabSlots = getPluginSlots("task-detail-tab");
|
||||
|
||||
// Track mount state to avoid setting state on unmounted component
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -1188,6 +1194,19 @@ export function TaskDetailModal({
|
||||
>
|
||||
Workflow
|
||||
</button>
|
||||
{/* Plugin tabs */}
|
||||
{pluginTabSlots.map((entry, index) => {
|
||||
const pluginTabId = `plugin-${index}` as TabId;
|
||||
return (
|
||||
<button
|
||||
key={`plugin-tab-${entry.pluginId}`}
|
||||
className={`detail-tab${activeTab === pluginTabId ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab(pluginTabId)}
|
||||
>
|
||||
{entry.slot.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{activeTab === "workflow" ? (
|
||||
<div className="detail-section">
|
||||
@@ -1271,6 +1290,10 @@ export function TaskDetailModal({
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : typeof activeTab === "string" && activeTab.startsWith("plugin-") ? (
|
||||
<div className="detail-section">
|
||||
<PluginSlot slotId="task-detail-tab" projectId={projectId} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary section - only for done tasks with summary */}
|
||||
|
||||
@@ -34,6 +34,18 @@ vi.mock("lucide-react", () => ({
|
||||
Clock: () => null,
|
||||
}));
|
||||
|
||||
// Mock usePluginUiSlots hook
|
||||
const mockUsePluginUiSlots = vi.fn(() => ({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/usePluginUiSlots", () => ({
|
||||
usePluginUiSlots: (...args: unknown[]) => mockUsePluginUiSlots(...args),
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
return {
|
||||
id,
|
||||
@@ -353,3 +365,46 @@ describe("Column same-column drop", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column PluginSlot integration", () => {
|
||||
it("renders PluginSlot for board-column-footer", () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [{ pluginId: "test-plugin", slot: { slotId: "board-column-footer", label: "Column Footer", componentPath: "./test.js" } }],
|
||||
getSlotsForId: (id: string) => id === "board-column-footer" ? [{ pluginId: "test-plugin", slot: { slotId: "board-column-footer", label: "Column Footer", componentPath: "./test.js" } }] : [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
const { container } = render(
|
||||
<Column
|
||||
{...defaultProps}
|
||||
column="triage"
|
||||
tasks={[]}
|
||||
/>,
|
||||
);
|
||||
// Check that column-body exists
|
||||
const columnBody = container.querySelector(".column-body");
|
||||
expect(columnBody).not.toBeNull();
|
||||
// Check for plugin slot inside column-body (always rendered, even for empty columns)
|
||||
const slot = container.querySelector('[data-slot-id="board-column-footer"]');
|
||||
expect(slot).not.toBeNull();
|
||||
expect(slot).toHaveAttribute("data-plugin-id", "test-plugin");
|
||||
});
|
||||
|
||||
it("renders nothing when no plugins register for board-column-footer slot", () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
const { container } = render(
|
||||
<Column
|
||||
{...defaultProps}
|
||||
column="triage"
|
||||
tasks={[]}
|
||||
/>,
|
||||
);
|
||||
const slot = container.querySelector('[data-slot-id="board-column-footer"]');
|
||||
expect(slot).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,18 @@ vi.mock("../api", () => ({
|
||||
fetchScripts: (...args: unknown[]) => mockFetchScripts(...args),
|
||||
}));
|
||||
|
||||
// Mock usePluginUiSlots hook
|
||||
const mockUsePluginUiSlots = vi.fn(() => ({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/usePluginUiSlots", () => ({
|
||||
usePluginUiSlots: (...args: unknown[]) => mockUsePluginUiSlots(...args),
|
||||
}));
|
||||
|
||||
// Mock matchMedia for mobile/tablet/desktop viewport tests
|
||||
type ViewportTier = "mobile" | "tablet" | "desktop";
|
||||
|
||||
@@ -1597,4 +1609,31 @@ describe("Header", () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
describe("PluginSlot integration", () => {
|
||||
it("renders PluginSlot for header-action slot", () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [{ pluginId: "test-plugin", slot: { slotId: "header-action", label: "Test Action", componentPath: "./test.js" } }],
|
||||
getSlotsForId: (id: string) => id === "header-action" ? [{ pluginId: "test-plugin", slot: { slotId: "header-action", label: "Test Action", componentPath: "./test.js" } }] : [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
const { container } = render(<Header />);
|
||||
const slot = container.querySelector('[data-slot-id="header-action"]');
|
||||
expect(slot).not.toBeNull();
|
||||
expect(slot).toHaveAttribute("data-plugin-id", "test-plugin");
|
||||
});
|
||||
|
||||
it("renders nothing when no plugins register for header-action slot", () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
const { container } = render(<Header />);
|
||||
const slot = container.querySelector('[data-slot-id="header-action"]');
|
||||
expect(slot).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,18 @@ vi.mock("../PluginManager", () => ({
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock usePluginUiSlots hook
|
||||
const mockUsePluginUiSlots = vi.fn(() => ({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/usePluginUiSlots", () => ({
|
||||
usePluginUiSlots: (...args: unknown[]) => mockUsePluginUiSlots(...args),
|
||||
}));
|
||||
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchGlobalConcurrency, updateGlobalConcurrency } from "../../api";
|
||||
|
||||
const onClose = vi.fn();
|
||||
@@ -3154,6 +3166,45 @@ describe("SettingsModal", () => {
|
||||
expect(payload.runStepsInNewSessions).toBe(true);
|
||||
expect(payload.maxParallelSteps).toBe(3);
|
||||
});
|
||||
|
||||
describe("PluginSlot integration", () => {
|
||||
it("renders PluginSlot for settings-section in plugins section", async () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [{ pluginId: "test-plugin", slot: { slotId: "settings-section", label: "Test Settings", componentPath: "./test.js" } }],
|
||||
getSlotsForId: (id: string) => id === "settings-section" ? [{ pluginId: "test-plugin", slot: { slotId: "settings-section", label: "Test Settings", componentPath: "./test.js" } }] : [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Navigate to Plugins section
|
||||
await userEvent.click(screen.getByText("Plugins"));
|
||||
await waitFor(() => expect(screen.getByTestId("plugin-manager")).toBeDefined());
|
||||
|
||||
// Verify slot renders
|
||||
const slot = container.querySelector('[data-slot-id="settings-section"]');
|
||||
expect(slot).not.toBeNull();
|
||||
expect(slot).toHaveAttribute("data-plugin-id", "test-plugin");
|
||||
});
|
||||
|
||||
it("renders nothing when no plugins register for settings-section slot", async () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
await userEvent.click(screen.getByText("Plugins"));
|
||||
await waitFor(() => expect(screen.getByTestId("plugin-manager")).toBeDefined());
|
||||
|
||||
const slot = container.querySelector('[data-slot-id="settings-section"]');
|
||||
expect(slot).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prompts section", () => {
|
||||
|
||||
@@ -50,6 +50,18 @@ vi.mock("lucide-react", () => ({
|
||||
Trash2: ({ size }: { size?: number }) => <span data-testid="trash-icon">🗑️</span>,
|
||||
}));
|
||||
|
||||
// Mock usePluginUiSlots hook
|
||||
const mockUsePluginUiSlots = vi.fn(() => ({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/usePluginUiSlots", () => ({
|
||||
usePluginUiSlots: (...args: unknown[]) => mockUsePluginUiSlots(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseBadgeWebSocket.mockReset();
|
||||
mockUseBadgeWebSocket.mockReturnValue({
|
||||
@@ -4093,3 +4105,90 @@ describe("TaskCard delete button", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard PluginSlot integration", () => {
|
||||
function makeTaskWithDeps(id: string): Task {
|
||||
return {
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: "Test description",
|
||||
column: "todo",
|
||||
status: "todo",
|
||||
priority: 0,
|
||||
size: "M",
|
||||
dependencies: ["FN-001"],
|
||||
steps: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
it("renders PluginSlot for task-card-badge", () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [{ pluginId: "test-plugin", slot: { slotId: "task-card-badge", label: "Badge", componentPath: "./test.js" } }],
|
||||
getSlotsForId: (id: string) => id === "task-card-badge" ? [{ pluginId: "test-plugin", slot: { slotId: "task-card-badge", label: "Badge", componentPath: "./test.js" } }] : [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
const task = makeTaskWithDeps("FN-001");
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const slot = container.querySelector('[data-slot-id="task-card-badge"]');
|
||||
expect(slot).not.toBeNull();
|
||||
expect(slot).toHaveAttribute("data-plugin-id", "test-plugin");
|
||||
});
|
||||
|
||||
it("renders PluginSlot even when task has no dependencies", () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [{ pluginId: "test-plugin", slot: { slotId: "task-card-badge", label: "Badge", componentPath: "./test.js" } }],
|
||||
getSlotsForId: (id: string) => id === "task-card-badge" ? [{ pluginId: "test-plugin", slot: { slotId: "task-card-badge", label: "Badge", componentPath: "./test.js" } }] : [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
const task: Task = {
|
||||
id: "FN-002",
|
||||
title: "Task without deps",
|
||||
column: "todo",
|
||||
status: "todo",
|
||||
priority: 0,
|
||||
size: "M",
|
||||
steps: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const slot = container.querySelector('[data-slot-id="task-card-badge"]');
|
||||
expect(slot).not.toBeNull();
|
||||
expect(slot).toHaveAttribute("data-plugin-id", "test-plugin");
|
||||
});
|
||||
|
||||
it("renders nothing when no plugins register for task-card-badge slot", () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
const task = makeTaskWithDeps("FN-001");
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const slot = container.querySelector('[data-slot-id="task-card-badge"]');
|
||||
expect(slot).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,18 @@ vi.mock("../../hooks/useAgentLogs", () => ({
|
||||
useAgentLogs: vi.fn(() => ({ entries: [], loading: false, clear: vi.fn() })),
|
||||
}));
|
||||
|
||||
// Mock usePluginUiSlots hook
|
||||
const mockUsePluginUiSlots = vi.fn(() => ({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/usePluginUiSlots", () => ({
|
||||
usePluginUiSlots: (...args: unknown[]) => mockUsePluginUiSlots(...args),
|
||||
}));
|
||||
|
||||
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
return {
|
||||
id: "FN-099",
|
||||
@@ -5271,4 +5283,97 @@ describe("TaskDetailModal", () => {
|
||||
expect(screen.queryByText("Loading specification…")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginSlot integration", () => {
|
||||
it("renders plugin tabs when plugins register for task-detail-tab slot", async () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [
|
||||
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
|
||||
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
|
||||
],
|
||||
getSlotsForId: (id: string) => id === "task-detail-tab" ? [
|
||||
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
|
||||
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
|
||||
] : [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onOpenDetail={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Both plugin tabs should appear
|
||||
expect(screen.getByText("Plugin A Tab")).toBeDefined();
|
||||
expect(screen.getByText("Plugin B Tab")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows plugin tab content when plugin tab is clicked", async () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [
|
||||
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
|
||||
],
|
||||
getSlotsForId: (id: string) => id === "task-detail-tab" ? [
|
||||
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
|
||||
] : [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onOpenDetail={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click the plugin tab
|
||||
await userEvent.click(screen.getByText("Plugin A Tab"));
|
||||
|
||||
// Verify plugin slot renders with task-detail-tab slotId
|
||||
const slot = container.querySelector('[data-slot-id="task-detail-tab"]');
|
||||
expect(slot).not.toBeNull();
|
||||
expect(slot).toHaveAttribute("data-plugin-id", "plugin-a");
|
||||
});
|
||||
|
||||
it("renders no extra tabs when no plugins register", () => {
|
||||
mockUsePluginUiSlots.mockReturnValue({
|
||||
slots: [],
|
||||
getSlotsForId: vi.fn(() => []),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onOpenDetail={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Only standard tabs should be visible (Definition, Logs, etc.)
|
||||
expect(screen.getByText("Definition")).toBeDefined();
|
||||
expect(screen.getByText("Logs")).toBeDefined();
|
||||
// Plugin tabs should not exist
|
||||
expect(screen.queryByText("Plugin A Tab")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user