mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
fix(briefs): add settings and dismissal controls
This commit is contained in:
1 parent
602c68370c
commit
f9a88c02da
16 files changed
+1060
-147
No files matched your search
@@ -141,6 +141,7 @@ function boardRoutes() {
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||
<Route path="u/:userSlug" element={<UserProfile />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="instance/settings/plugins/:pluginId" element={<PluginSettings />} />
|
||||
<Route path="instance/settings/adapters" element={<AdapterManager />} />
|
||||
<Route path=":pluginRoutePath/*" element={<PluginPage />} />
|
||||
<Route path="*" element={<NotFoundPage scope="board" />} />
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("PluginPage", () => {
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
it("hides the Back button for the Briefs dashboard while keeping settings available", async () => {
|
||||
it("hides the host Back and settings buttons for the Briefs dashboard", async () => {
|
||||
mockParams.pluginRoutePath = "briefs";
|
||||
mockPluginsApi.listUiContributions.mockResolvedValue([
|
||||
pageContribution({
|
||||
@@ -192,7 +192,7 @@ describe("PluginPage", () => {
|
||||
|
||||
expect(container.textContent).not.toContain("Back");
|
||||
expect(container.querySelector('a[href="/PAP/dashboard"]')).toBeNull();
|
||||
expect(container.querySelector('a[href="/instance/settings/plugins/plugin-briefs"]')).not.toBeNull();
|
||||
expect(container.querySelector('a[href="/instance/settings/plugins/plugin-briefs"]')).toBeNull();
|
||||
|
||||
root.unmount();
|
||||
await flushReact();
|
||||
|
||||
@@ -161,20 +161,18 @@ export function PluginPage() {
|
||||
return <Navigate to={settingsPath} replace />;
|
||||
}
|
||||
|
||||
const hideBackButton = pageSlot.pluginKey === "paperclipai.plugin-briefs";
|
||||
const isBriefsDashboard = pageSlot.pluginKey === "paperclipai.plugin-briefs";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!routeSidebarActive && (
|
||||
{!routeSidebarActive && !isBriefsDashboard && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{hideBackButton ? <span aria-hidden /> : (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" asChild title={`${pageSlot.pluginDisplayName} settings`}>
|
||||
<Link to={`/instance/settings/plugins/${pageSlot.pluginId}`} aria-label={`${pageSlot.pluginDisplayName} settings`}>
|
||||
<Settings className="h-4 w-4" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -17,6 +17,31 @@ const mockPluginsApi = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockPluginSlots = vi.hoisted(() => ({
|
||||
slots: [] as Array<{
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
displayName: string;
|
||||
type: string;
|
||||
}>,
|
||||
}));
|
||||
const mockRouteParams = vi.hoisted(() => ({
|
||||
pluginId: "plugin-1" as string,
|
||||
companyPrefix: "PAP" as string | undefined,
|
||||
}));
|
||||
const mockCompanyState = vi.hoisted(() => ({
|
||||
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }] as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
issuePrefix: string;
|
||||
}>,
|
||||
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" } as {
|
||||
id: string;
|
||||
name: string;
|
||||
issuePrefix: string;
|
||||
} | null,
|
||||
selectedCompanyId: "company-1" as string | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/plugins", () => ({
|
||||
pluginsApi: mockPluginsApi,
|
||||
@@ -29,21 +54,22 @@ vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" },
|
||||
selectedCompanyId: "company-1",
|
||||
}),
|
||||
useCompany: () => mockCompanyState,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
||||
Navigate: () => null,
|
||||
useParams: () => ({ companyPrefix: "PAP", pluginId: "plugin-1" }),
|
||||
useParams: () => ({ companyPrefix: mockRouteParams.companyPrefix, pluginId: mockRouteParams.pluginId }),
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
PluginSlotMount: () => null,
|
||||
usePluginSlots: () => ({ slots: [] }),
|
||||
PluginSlotMount: ({ slot, context }: { slot: { displayName: string }; context: { companyId?: string | null; companyPrefix?: string | null } }) => (
|
||||
<div data-testid="plugin-slot-mount" data-company-id={context.companyId ?? ""} data-company-prefix={context.companyPrefix ?? ""}>
|
||||
{slot.displayName}
|
||||
</div>
|
||||
),
|
||||
usePluginSlots: () => ({ slots: mockPluginSlots.slots }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/PageTabBar", () => ({
|
||||
@@ -54,10 +80,10 @@ vi.mock("@/components/PageTabBar", () => ({
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function basePlugin(overrides: Record<string, unknown> = {}) {
|
||||
@@ -124,7 +150,7 @@ async function renderSettings(container: HTMLDivElement) {
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PluginSettings />
|
||||
@@ -153,6 +179,12 @@ describe("PluginSettings", () => {
|
||||
declarations: [],
|
||||
folders: [],
|
||||
});
|
||||
mockPluginSlots.slots = [];
|
||||
mockRouteParams.pluginId = "plugin-1";
|
||||
mockRouteParams.companyPrefix = "PAP";
|
||||
mockCompanyState.companies = [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }];
|
||||
mockCompanyState.selectedCompany = { id: "company-1", name: "Paperclip", issuePrefix: "PAP" };
|
||||
mockCompanyState.selectedCompanyId = "company-1";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -169,9 +201,8 @@ describe("PluginSettings", () => {
|
||||
const link = container.querySelector('a[href="/company/settings/environments"]');
|
||||
expect(link?.textContent).toContain("Open Company Environments");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
flushSync(() => root.unmount());
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
it("renders unconfigured manifest local folders with required paths", async () => {
|
||||
@@ -205,9 +236,8 @@ describe("PluginSettings", () => {
|
||||
expect(container.textContent).toContain("Missing directories: raw, wiki");
|
||||
expect(container.textContent).toContain("Missing files: WIKI.md, index.md");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
flushSync(() => root.unmount());
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
it("renders invalid configured folders with validation problems", async () => {
|
||||
@@ -247,9 +277,8 @@ describe("PluginSettings", () => {
|
||||
expect(container.textContent).toContain("Required file is missing.");
|
||||
expect(container.textContent).toContain("Missing files: WIKI.md");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
flushSync(() => root.unmount());
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
it("does not render required paths as present when the configured root cannot be inspected", async () => {
|
||||
@@ -286,9 +315,8 @@ describe("PluginSettings", () => {
|
||||
expect(container.textContent).toContain("Configured root was not inspected.");
|
||||
expect(container.textContent).not.toContain("Present");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
flushSync(() => root.unmount());
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
it("renders healthy folders without validation problems", async () => {
|
||||
@@ -330,8 +358,76 @@ describe("PluginSettings", () => {
|
||||
expect(container.textContent).toContain("Present");
|
||||
expect(container.textContent).not.toContain("Validation problems");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
flushSync(() => root.unmount());
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
it("matches custom settings slots when routed by plugin key", async () => {
|
||||
mockRouteParams.pluginId = "paperclipai.plugin-briefs";
|
||||
mockPluginsApi.get.mockResolvedValue(basePlugin({
|
||||
id: "plugin-briefs-uuid",
|
||||
pluginKey: "paperclipai.plugin-briefs",
|
||||
packageName: "@paperclipai/plugin-briefs",
|
||||
status: "ready",
|
||||
manifestJson: {
|
||||
displayName: "Briefs",
|
||||
version: "0.1.0",
|
||||
description: "Briefing cards.",
|
||||
author: "Paperclip",
|
||||
capabilities: [],
|
||||
},
|
||||
}));
|
||||
mockPluginSlots.slots = [{
|
||||
pluginId: "plugin-briefs-uuid",
|
||||
pluginKey: "paperclipai.plugin-briefs",
|
||||
displayName: "Briefing",
|
||||
type: "settingsPage",
|
||||
}];
|
||||
|
||||
const root = await renderSettings(container);
|
||||
|
||||
expect(container.querySelector('[data-testid="plugin-slot-mount"]')?.textContent).toBe("Briefing");
|
||||
|
||||
flushSync(() => root.unmount());
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
it("uses the route company for custom settings slots even when another company is selected", async () => {
|
||||
mockRouteParams.pluginId = "paperclipai.plugin-briefs";
|
||||
mockRouteParams.companyPrefix = "PAP";
|
||||
mockCompanyState.companies = [
|
||||
{ id: "company-pap", name: "Paperclip", issuePrefix: "PAP" },
|
||||
{ id: "company-fr", name: "Forgotten Runes", issuePrefix: "FR" },
|
||||
];
|
||||
mockCompanyState.selectedCompany = { id: "company-fr", name: "Forgotten Runes", issuePrefix: "FR" };
|
||||
mockCompanyState.selectedCompanyId = "company-fr";
|
||||
mockPluginsApi.get.mockResolvedValue(basePlugin({
|
||||
id: "plugin-briefs-uuid",
|
||||
pluginKey: "paperclipai.plugin-briefs",
|
||||
packageName: "@paperclipai/plugin-briefs",
|
||||
status: "ready",
|
||||
manifestJson: {
|
||||
displayName: "Briefs",
|
||||
version: "0.1.0",
|
||||
description: "Briefing cards.",
|
||||
author: "Paperclip",
|
||||
capabilities: [],
|
||||
},
|
||||
}));
|
||||
mockPluginSlots.slots = [{
|
||||
pluginId: "plugin-briefs-uuid",
|
||||
pluginKey: "paperclipai.plugin-briefs",
|
||||
displayName: "Briefing",
|
||||
type: "settingsPage",
|
||||
}];
|
||||
|
||||
const root = await renderSettings(container);
|
||||
const slot = container.querySelector('[data-testid="plugin-slot-mount"]');
|
||||
|
||||
expect(slot?.getAttribute("data-company-id")).toBe("company-pap");
|
||||
expect(slot?.getAttribute("data-company-prefix")).toBe("PAP");
|
||||
|
||||
flushSync(() => root.unmount());
|
||||
await flushReact();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle, FolderOpen, Save } from "lucide-react";
|
||||
import type { PluginLocalFolderDeclaration } from "@paperclipai/shared";
|
||||
@@ -60,10 +60,17 @@ import {
|
||||
* @see doc/plugins/PLUGIN_SPEC.md §19.8 — Plugin Settings UI.
|
||||
*/
|
||||
export function PluginSettings() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { companies, selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { companyPrefix, pluginId } = useParams<{ companyPrefix?: string; pluginId: string }>();
|
||||
const [activeTab, setActiveTab] = useState<"configuration" | "status">("configuration");
|
||||
const routeCompany = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
const requested = companyPrefix.toUpperCase();
|
||||
return companies.find((company) => company.issuePrefix.toUpperCase() === requested) ?? null;
|
||||
}, [companies, companyPrefix]);
|
||||
const activeCompany = routeCompany ?? selectedCompany ?? null;
|
||||
const activeCompanyId = routeCompany?.id ?? selectedCompanyId;
|
||||
|
||||
const { data: plugin, isLoading: pluginLoading } = useQuery({
|
||||
queryKey: queryKeys.plugins.detail(pluginId!),
|
||||
@@ -104,24 +111,29 @@ export function PluginSettings() {
|
||||
|
||||
const { slots } = usePluginSlots({
|
||||
slotTypes: ["settingsPage"],
|
||||
companyId: selectedCompanyId,
|
||||
enabled: !!selectedCompanyId,
|
||||
companyId: activeCompanyId,
|
||||
enabled: !!activeCompanyId,
|
||||
});
|
||||
|
||||
// Filter slots to only show settings pages for this specific plugin
|
||||
const pluginSlots = slots.filter((slot) => slot.pluginId === pluginId);
|
||||
const pluginSlots = slots.filter((slot) =>
|
||||
slot.pluginId === pluginId
|
||||
|| slot.pluginId === plugin?.id
|
||||
|| slot.pluginKey === pluginId
|
||||
|| slot.pluginKey === plugin?.pluginKey
|
||||
);
|
||||
|
||||
// If the plugin has a custom settingsPage slot, prefer that over auto-generated form
|
||||
const hasCustomSettingsPage = pluginSlots.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: activeCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/instance/settings/heartbeats" },
|
||||
{ label: "Plugins", href: "/instance/settings/plugins" },
|
||||
{ label: plugin?.manifestJson?.displayName ?? plugin?.packageName ?? "Plugin Details" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs, companyPrefix, plugin]);
|
||||
}, [activeCompany?.name, setBreadcrumbs, companyPrefix, plugin]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab("configuration");
|
||||
@@ -224,7 +236,7 @@ export function PluginSettings() {
|
||||
{hasLocalFolders ? (
|
||||
<PluginLocalFoldersSettings
|
||||
pluginId={pluginId!}
|
||||
companyId={selectedCompanyId}
|
||||
companyId={activeCompanyId}
|
||||
declarations={localFolderDeclarations}
|
||||
/>
|
||||
) : null}
|
||||
@@ -235,7 +247,7 @@ export function PluginSettings() {
|
||||
key={`${slot.pluginKey}:${slot.id}`}
|
||||
slot={slot}
|
||||
context={{
|
||||
companyId: selectedCompanyId,
|
||||
companyId: activeCompanyId,
|
||||
companyPrefix: companyPrefix ?? null,
|
||||
}}
|
||||
missingBehavior="placeholder"
|
||||
|
||||
Reference in new issue
Block a user