Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 125 additions & 22 deletions frontend/src/components/PluginsPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export default function PluginsPanel({ sessionId, onClose }) {
const [copied, setCopied] = useState(false);
const [logs, setLogs] = useState([]);

// State for contextual menu and action toast feedback (#605)
const [activeMenuId, setActiveMenuId] = useState(null);
const [notification, setNotification] = useState("");

// Persistence: View collapsed state (#592)
const [isCollapsed, setIsCollapsed] = useState(() => {
try {
Expand Down Expand Up @@ -102,6 +106,67 @@ export default function PluginsPanel({ sessionId, onClose }) {
setCopied(false);
}

// Close contextual action menu on global click or Escape key
useEffect(() => {
const handleGlobalClick = () => setActiveMenuId(null);
const handleKeyDown = (e) => {
if (e.key === "Escape") setActiveMenuId(null);
};
window.addEventListener("click", handleGlobalClick);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("click", handleGlobalClick);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);

const showNotification = (msg) => {
setNotification(msg);
setTimeout(() => setNotification(""), 3000);
};

// Export Action (#605)
const handleExportPlugin = (e, plugin) => {
e.stopPropagation();
setActiveMenuId(null);
try {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(plugin, null, 2));
const downloadAnchor = document.createElement("a");
downloadAnchor.setAttribute("href", dataStr);
downloadAnchor.setAttribute("download", `${plugin.id}-config.json`);
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
showNotification(`Exported ${plugin.name} configuration.`);
} catch (err) {
console.error("Export failed", err);
setError("Failed to export plugin config.");
}
};

// Share Action (#605)
const handleSharePlugin = async (e, plugin) => {
e.stopPropagation();
setActiveMenuId(null);
const shareUrl = `${window.location.origin}${window.location.pathname}?plugin=${plugin.id}`;
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(shareUrl);
showNotification(`Copied share link for ${plugin.name}!`);
} else {
showNotification(`Share link: ${shareUrl}`);
}
} catch (err) {
console.error("Share failed", err);
showNotification(`Share link: ${shareUrl}`);
}
};

const toggleContextMenu = (e, pluginId) => {
e.stopPropagation();
setActiveMenuId((prev) => (prev === pluginId ? null : pluginId));
};

async function run() {
if (!selected || !input.trim() || running) return;
setRunning(true);
Expand Down Expand Up @@ -135,7 +200,7 @@ export default function PluginsPanel({ sessionId, onClose }) {
};

return (
<div className="border-b border-gray-800 bg-gray-900 px-5 py-4 shrink-0" data-testid="plugins-panel">
<div className="border-b border-gray-800 bg-gray-900 px-5 py-4 shrink-0 relative" data-testid="plugins-panel">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1.5">
{/* Collapse/Expand toggle button */}
Expand Down Expand Up @@ -181,6 +246,14 @@ export default function PluginsPanel({ sessionId, onClose }) {
</button>
</div>

{/* Action Notification Banner (#605) */}
{notification && (
<div data-testid="action-notification" className="mb-3 text-xs bg-purple-950/60 border border-purple-800 text-purple-300 p-2 rounded-lg flex items-center justify-between shadow-sm">
<span>{notification}</span>
<button onClick={() => setNotification("")} className="text-purple-400 hover:text-white font-bold ml-2">×</button>
</div>
)}

{/* Global Inline Error Banner (#588) */}
{error && (
<div
Expand All @@ -206,28 +279,58 @@ export default function PluginsPanel({ sessionId, onClose }) {
{/* Collapsible Panel Section (#592) */}
{!isCollapsed && (
<>
{/* Plugin selector row */}
{/* Plugin selector row with contextual dropdown menus (#605) */}
<div data-testid="plugin-selector-list" className="flex flex-wrap gap-2 mb-4 md:mb-3 shrink-0">
{plugins.map((p) => (
<button
key={p.id}
type="button"
data-testid={`plugin-btn-${p.id}`}
onClick={() => handleSelectPlugin(p)}
className={`text-xs px-3.5 py-2 md:py-1.5 rounded-lg border transition font-medium touch-manipulation
${selected?.id === p.id ? "border-purple-500 bg-purple-900/30 text-purple-300 shadow-sm shadow-purple-500/10" : "border-gray-700 text-gray-400 hover:bg-gray-800"}`}
>
{(() => {
const Icon = PLUGIN_ICONS[p.icon] || PlugIcon;
return (
<span className="inline-flex items-center gap-1">
<Icon className="w-3.5 h-3.5" />
<span>{p.name}</span>
</span>
);
})()}
</button>
))}
{plugins.map((p) => {
const Icon = PLUGIN_ICONS[p.icon] || PlugIcon;
const isMenuOpen = activeMenuId === p.id;

return (
<div
key={p.id}
onClick={() => handleSelectPlugin(p)}
data-testid={`plugin-btn-${p.id}`}
className={`relative text-xs px-3.5 py-2 md:py-1.5 rounded-lg border transition font-medium flex items-center gap-1.5 cursor-pointer touch-manipulation select-none
${selected?.id === p.id ? "border-purple-500 bg-purple-900/30 text-purple-300 shadow-sm shadow-purple-500/10" : "border-gray-700 text-gray-400 hover:bg-gray-800"}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{p.name}</span>

{/* Context Menu Trigger Button */}
<button
type="button"
onClick={(e) => toggleContextMenu(e, p.id)}
aria-label={`Options for ${p.name}`}
className="ml-1 text-gray-500 hover:text-gray-200 transition px-1 rounded hover:bg-gray-700/50 font-bold"
>
</button>

{/* Context Dropdown Menu (#605) */}
{isMenuOpen && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute left-0 top-full mt-1 w-36 bg-gray-950 border border-gray-800 rounded-lg shadow-xl z-50 py-1 text-xs text-gray-300 font-normal"
>
<button
type="button"
onClick={(e) => handleSharePlugin(e, p)}
className="w-full text-left px-3 py-1.5 hover:bg-gray-800 hover:text-white"
>
Share Plugin
</button>
<button
type="button"
onClick={(e) => handleExportPlugin(e, p)}
className="w-full text-left px-3 py-1.5 hover:bg-gray-800 hover:text-white"
>
Export Config
</button>
</div>
)}
</div>
);
})}
</div>

{/* Plugin Input/Output Area OR Empty-State Guidance */}
Expand Down
59 changes: 58 additions & 1 deletion frontend/src/components/PluginsPanel.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ vi.mock("../utils/api", () => ({
getPluginLogs: vi.fn().mockResolvedValue({ logs: [] }),
}));

// Mock icons
// Mock icon components
vi.mock("./Icons", () => ({
BracesIcon: () => <span data-testid="braces-icon" />,
CalculatorIcon: () => <span data-testid="calculator-icon" />,
Expand Down Expand Up @@ -171,6 +171,63 @@ describe("PluginsPanel Interaction Tests (#595)", () => {
});
});

describe("PluginsPanel Export & Share Suite (#605)", () => {
beforeEach(() => {
vi.clearAllMocks();
api.getPlugins.mockResolvedValue({ plugins: mockPluginsList });
api.getPluginLogs.mockResolvedValue({ logs: [] });
});

afterEach(() => {
cleanup();
});

test("copies shareable plugin URL to clipboard on Share action", async () => {
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: { writeText: writeTextMock },
});

render(<PluginsPanel sessionId="session-605" onClose={vi.fn()} />);

await waitFor(() => {
expect(screen.getByTestId("plugin-btn-calculator")).toBeInTheDocument();
});

const optionsBtn = screen.getByLabelText("Options for Calculator");
fireEvent.click(optionsBtn);

const shareBtn = screen.getByText("Share Plugin");
fireEvent.click(shareBtn);

expect(writeTextMock).toHaveBeenCalledWith(expect.stringContaining("plugin=calculator"));
await waitFor(() => {
expect(screen.getByTestId("action-notification")).toHaveTextContent("Copied share link for Calculator!");
});
});

test("triggers JSON export download on Export Config action", async () => {
const createElementSpy = vi.spyOn(document, "createElement");

render(<PluginsPanel sessionId="session-605-export" onClose={vi.fn()} />);

await waitFor(() => {
expect(screen.getByTestId("plugin-btn-calculator")).toBeInTheDocument();
});

const optionsBtn = screen.getByLabelText("Options for Calculator");
fireEvent.click(optionsBtn);

const exportBtn = screen.getByText("Export Config");
fireEvent.click(exportBtn);

expect(createElementSpy).toHaveBeenCalledWith("a");
await waitFor(() => {
expect(screen.getByTestId("action-notification")).toHaveTextContent("Exported Calculator configuration.");
});
});
});

describe("PluginsPanel View State & Persistence Suite (#592)", () => {
let store = {};

Expand Down
Loading