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 { TaskCard } from "./TaskCard";
|
||||||
import { WorktreeGroup } from "./WorktreeGroup";
|
import { WorktreeGroup } from "./WorktreeGroup";
|
||||||
import { QuickEntryBox } from "./QuickEntryBox";
|
import { QuickEntryBox } from "./QuickEntryBox";
|
||||||
|
import { PluginSlot } from "./PluginSlot";
|
||||||
import { groupByWorktree } from "../utils/worktreeGrouping";
|
import { groupByWorktree } from "../utils/worktreeGrouping";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { ChevronDown, ChevronUp, Archive } from "lucide-react";
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { NodeConfig, ProjectStatus } from "@fusion/core";
|
|||||||
import { fetchScripts } from "../api";
|
import { fetchScripts } from "../api";
|
||||||
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
||||||
import { NodeStatusIndicator } from "./NodeStatusIndicator";
|
import { NodeStatusIndicator } from "./NodeStatusIndicator";
|
||||||
|
import { PluginSlot } from "./PluginSlot";
|
||||||
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
||||||
|
|
||||||
export { useViewportMode };
|
export { useViewportMode };
|
||||||
@@ -930,6 +931,9 @@ export function Header({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Plugin UI slot for header actions */}
|
||||||
|
<PluginSlot slotId="header-action" projectId={projectId} />
|
||||||
|
|
||||||
{/* Compact overflow menu trigger (mobile + tablet) */}
|
{/* Compact overflow menu trigger (mobile + tablet) */}
|
||||||
{isCompact && !hideFullNav && (
|
{isCompact && !hideFullNav && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ThemeSelector } from "./ThemeSelector";
|
|||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { FileEditor } from "./FileEditor";
|
import { FileEditor } from "./FileEditor";
|
||||||
import { PluginManager } from "./PluginManager";
|
import { PluginManager } from "./PluginManager";
|
||||||
|
import { PluginSlot } from "./PluginSlot";
|
||||||
import { AgentPromptsManager } from "./AgentPromptsManager";
|
import { AgentPromptsManager } from "./AgentPromptsManager";
|
||||||
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
|
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
|
||||||
|
|
||||||
@@ -2564,6 +2565,7 @@ export function SettingsModal({
|
|||||||
<>
|
<>
|
||||||
<h4 className="settings-section-heading">Plugins</h4>
|
<h4 className="settings-section-heading">Plugins</h4>
|
||||||
<PluginManager addToast={addToast} projectId={projectId} />
|
<PluginManager addToast={addToast} projectId={projectId} />
|
||||||
|
<PluginSlot slotId="settings-section" projectId={projectId} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "authentication":
|
case "authentication":
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { COLUMN_LABELS, VALID_TRANSITIONS } from "@fusion/core";
|
|||||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||||
import { GitHubBadge } from "./GitHubBadge";
|
import { GitHubBadge } from "./GitHubBadge";
|
||||||
import { pickPreferredBadge } from "./TaskCardBadge";
|
import { pickPreferredBadge } from "./TaskCardBadge";
|
||||||
|
import { PluginSlot } from "./PluginSlot";
|
||||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||||
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
||||||
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
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>}
|
{(queued || task.status === "queued") && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<PluginSlot slotId="task-card-badge" projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { TaskChangesTab } from "./TaskChangesTab";
|
|||||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||||
import { WorkflowResultsTab } from "./WorkflowResultsTab";
|
import { WorkflowResultsTab } from "./WorkflowResultsTab";
|
||||||
import { TaskDocumentsTab } from "./TaskDocumentsTab";
|
import { TaskDocumentsTab } from "./TaskDocumentsTab";
|
||||||
|
import { PluginSlot } from "./PluginSlot";
|
||||||
|
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||||
|
|
||||||
interface ModelSelection {
|
interface ModelSelection {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -167,7 +169,7 @@ function formatBytes(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
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 {
|
interface TaskDetailModalProps {
|
||||||
task: Task | TaskDetail;
|
task: Task | TaskDetail;
|
||||||
@@ -316,6 +318,10 @@ export function TaskDetailModal({
|
|||||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const actionsMenuRef = 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
|
// Track mount state to avoid setting state on unmounted component
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
@@ -1188,6 +1194,19 @@ export function TaskDetailModal({
|
|||||||
>
|
>
|
||||||
Workflow
|
Workflow
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{activeTab === "workflow" ? (
|
{activeTab === "workflow" ? (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
@@ -1271,6 +1290,10 @@ export function TaskDetailModal({
|
|||||||
onTaskUpdated={onTaskUpdated}
|
onTaskUpdated={onTaskUpdated}
|
||||||
canEdit={canEdit}
|
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 */}
|
{/* Summary section - only for done tasks with summary */}
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ vi.mock("lucide-react", () => ({
|
|||||||
Clock: () => null,
|
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 {
|
function makeTask(id: string): Task {
|
||||||
return {
|
return {
|
||||||
id,
|
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),
|
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
|
// Mock matchMedia for mobile/tablet/desktop viewport tests
|
||||||
type ViewportTier = "mobile" | "tablet" | "desktop";
|
type ViewportTier = "mobile" | "tablet" | "desktop";
|
||||||
|
|
||||||
@@ -1597,4 +1609,31 @@ describe("Header", () => {
|
|||||||
globalThis.fetch = originalFetch;
|
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";
|
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchGlobalConcurrency, updateGlobalConcurrency } from "../../api";
|
||||||
|
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
@@ -3154,6 +3166,45 @@ describe("SettingsModal", () => {
|
|||||||
expect(payload.runStepsInNewSessions).toBe(true);
|
expect(payload.runStepsInNewSessions).toBe(true);
|
||||||
expect(payload.maxParallelSteps).toBe(3);
|
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", () => {
|
describe("Prompts section", () => {
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ vi.mock("lucide-react", () => ({
|
|||||||
Trash2: ({ size }: { size?: number }) => <span data-testid="trash-icon">🗑️</span>,
|
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(() => {
|
beforeEach(() => {
|
||||||
mockUseBadgeWebSocket.mockReset();
|
mockUseBadgeWebSocket.mockReset();
|
||||||
mockUseBadgeWebSocket.mockReturnValue({
|
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() })),
|
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 {
|
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||||
return {
|
return {
|
||||||
id: "FN-099",
|
id: "FN-099",
|
||||||
@@ -5271,4 +5283,97 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(screen.queryByText("Loading specification…")).toBeNull();
|
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