fix(briefs): add settings and dismissal controls

This commit is contained in:
Dotta committed 2026-05-24 02:35:26 +00:00
1 parent 602c68370c
commit f9a88c02da
16 files changed
+1060 -147

No files matched your search

+1
View File
@@ -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" />} />
+2 -2
View File
@@ -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();
+8 -10
View File
@@ -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" />
+122 -26
View File
@@ -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();
});
});
+21 -9
View File
@@ -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"