feat(FN-3787): add approval workflow and share blocks to reports plugin

Added an approval workflow to the reports plugin comprising a state machine (`approval.ts`), share blocks logic (`share-blocks.ts`), API routes for approvals, and two new dashboard panels (ReportApprovalPanel and ShareBlocksPanel), with corresponding tests; also updated the plugin README and added a

Fusion-Task-Id: FN-3787
This commit is contained in:
Fusion
2026-05-10 14:06:49 -07:00
committed by gsxdsm
parent 1209e6b452
commit 63fe25e014
23 changed files with 1063 additions and 17 deletions

View File

@@ -0,0 +1,59 @@
.report-approval-panel {
border-top: var(--btn-border-width) solid var(--border);
display: flex;
flex-direction: column;
gap: var(--space-md);
padding-top: var(--space-md);
}
.report-approval-panel__header {
align-items: center;
display: flex;
gap: var(--space-sm);
justify-content: space-between;
}
.report-approval-panel__header h4 {
font-size: 0.875rem;
margin: 0;
}
.report-approval-panel__note {
min-height: calc(var(--space-2xl) * 2);
}
.card-status-badge--awaiting_approval {
background: color-mix(in srgb, var(--color-warning) 20%, transparent);
color: var(--color-warning);
}
.card-status-badge--approved,
.card-status-badge--published {
background: color-mix(in srgb, var(--color-success) 20%, transparent);
color: var(--color-success);
}
.card-status-badge--rejected {
background: color-mix(in srgb, var(--color-error) 20%, transparent);
color: var(--color-error);
}
.report-approval-panel__actions {
display: flex;
gap: var(--space-sm);
}
.report-approval-panel__history {
color: var(--text-muted);
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin: 0;
padding-left: var(--space-lg);
}
@media (max-width: 768px) {
.report-approval-panel__actions {
flex-direction: column;
}
}

View File

@@ -0,0 +1,58 @@
import { useMemo, useState } from "react";
import { approveReport, publishReport, rejectReport } from "../api.js";
import type { ReportRecord } from "../types.js";
import "./ReportApprovalPanel.css";
interface Props {
report: ReportRecord;
onReportChange: (report: ReportRecord) => void;
}
export function ReportApprovalPanel({ report, onReportChange }: Props) {
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const approvalState = report.approvalState ?? "not_required";
const canApprove = approvalState === "awaiting_approval";
const canPublish = approvalState === "approved";
const history = useMemo(() => [...(report.approvalHistory ?? [])].reverse(), [report.approvalHistory]);
async function run(action: "approve" | "reject" | "publish") {
setBusy(true);
setError(null);
try {
const next = action === "approve"
? await approveReport(report.id, note)
: action === "reject"
? await rejectReport(report.id, note)
: await publishReport(report.id);
onReportChange(next);
setNote("");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return <section className="report-approval-panel">
<div className="report-approval-panel__header">
<h4>Approval</h4>
<span className={`card-status-badge card-status-badge--${approvalState}`}>{approvalState}</span>
</div>
{canApprove ? <>
<textarea className="input report-approval-panel__note" value={note} onChange={(event) => setNote(event.target.value)} placeholder="Optional note" />
<div className="report-approval-panel__actions">
<button className="btn btn-primary" disabled={busy} onClick={() => run("approve")}>Approve</button>
<button className="btn btn-danger" disabled={busy} onClick={() => run("reject")}>Reject</button>
</div>
</> : null}
{canPublish ? <div className="report-approval-panel__actions"><button className="btn btn-primary" disabled={busy} onClick={() => run("publish")}>Publish</button></div> : null}
{error ? <div className="form-error">{error}</div> : null}
<ul className="report-approval-panel__history">
{history.map((item, index) => <li key={`${item.decidedAt}-${index}`}>{item.action} by {item.decidedBy} at {item.decidedAt}{item.note ? `${item.note}` : ""}</li>)}
</ul>
</section>;
}

View File

@@ -1,23 +1,29 @@
import { useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { getReportExportUrl } from "../api.js";
import { useReportPreview } from "../useReportPreview.js";
import type { ReportRecord } from "../types.js";
import { ReportApprovalPanel } from "./ReportApprovalPanel.js";
import { ShareBlocksPanel } from "./ShareBlocksPanel.js";
const SECTION_IDS = ["summary", "system-wins", "system-highlights", "system-lowlights", "system-proposals", "system-deep-dives", "agent-card", "data-coverage", "review-panel"];
export function ReportDetailPanel({ report, projectId }: { report?: ReportRecord; projectId?: string }) {
const frameRef = useRef<HTMLIFrameElement | null>(null);
const { html, loading, error } = useReportPreview(report?.id, projectId);
const [currentReport, setCurrentReport] = useState<ReportRecord | undefined>(report);
useEffect(() => setCurrentReport(report), [report]);
const { html, loading, error } = useReportPreview(currentReport?.id, projectId);
const sections = useMemo(() => SECTION_IDS, []);
if (!report) return <div className="reports-detail card">Select a report.</div>;
if (!currentReport) return <div className="reports-detail card">Select a report.</div>;
return <div className="reports-detail card">
<div className="reports-detail-header"><h3>{report.title}</h3><a className="btn btn-sm" href={getReportExportUrl(report.id, projectId)} download>Download standalone HTML</a></div>
<div className="reports-detail-meta">{report.cadence} {report.status} {report.periodStart} {report.periodEnd}</div>
<div className="reports-detail-header"><h3>{currentReport.title}</h3><a className="btn btn-sm" href={getReportExportUrl(currentReport.id, projectId)} download>Download standalone HTML</a></div>
<div className="reports-detail-meta">{currentReport.cadence} {currentReport.status} {currentReport.periodStart} {currentReport.periodEnd}</div>
<div className="reports-detail-body">
<nav className="reports-detail-sections">{sections.map((section) => <button key={section} className="btn btn-sm" onClick={() => frameRef.current?.contentWindow?.document.querySelector(`[data-section="${section}"]`)?.scrollIntoView()}>{section}</button>)}</nav>
{loading ? <div>Loading preview...</div> : null}
{error ? <div>{error}</div> : null}
<iframe ref={frameRef} sandbox="allow-same-origin" srcDoc={html} title="Report preview" />
</div>
<ReportApprovalPanel report={currentReport} onReportChange={setCurrentReport} />
<ShareBlocksPanel report={currentReport} />
</div>;
}

View File

@@ -0,0 +1,29 @@
.share-blocks-panel {
border-top: var(--btn-border-width) solid var(--border);
display: flex;
flex-direction: column;
gap: var(--space-md);
padding-top: var(--space-md);
}
.share-blocks-panel__tabs {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.share-blocks-panel__content {
min-height: calc(var(--space-2xl) * 4);
resize: vertical;
}
.share-blocks-panel__locked {
color: var(--text-muted);
margin: 0;
}
@media (max-width: 768px) {
.share-blocks-panel__tabs {
flex-direction: column;
}
}

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { getShareBlocks } from "../api.js";
import type { ReportRecord } from "../types.js";
import type { ShareBlocks } from "../../share-blocks.js";
import "./ShareBlocksPanel.css";
const TABS: Array<{ key: keyof ShareBlocks; label: string }> = [
{ key: "plainText", label: "Plain Text" },
{ key: "markdown", label: "Markdown" },
{ key: "slack", label: "Slack" },
{ key: "emailHtml", label: "Email HTML" },
];
export function ShareBlocksPanel({ report }: { report: ReportRecord }) {
const [active, setActive] = useState<keyof ShareBlocks>("plainText");
const [data, setData] = useState<ShareBlocks | null>(null);
const [locked, setLocked] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
setLocked(false);
setData(null);
getShareBlocks(report.id).then(setData).catch((error: Error) => {
if (error.message.includes("409")) setLocked(true);
});
}, [report.id]);
if (locked) return <section className="share-blocks-panel"><p className="share-blocks-panel__locked">Share blocks unlock after the report is approved.</p></section>;
if (!data) return <section className="share-blocks-panel"><p>Loading share blocks</p></section>;
const value = data[active];
return <section className="share-blocks-panel">
<div className="share-blocks-panel__tabs">
{TABS.map((tab) => <button key={tab.key} className={`btn btn-sm ${active === tab.key ? "btn-primary" : ""}`} onClick={() => setActive(tab.key)}>{tab.label}</button>)}
</div>
<textarea className="input share-blocks-panel__content" readOnly value={value} />
<button className="btn btn-sm" onClick={async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
}}>{copied ? "Copied" : "Copy"}</button>
</section>;
}

View File

@@ -0,0 +1,38 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ReportApprovalPanel } from "../ReportApprovalPanel.js";
vi.mock("../../api.js", () => ({
approveReport: vi.fn(async () => ({ ...baseReport, approvalState: "approved" })),
rejectReport: vi.fn(async () => ({ ...baseReport, approvalState: "rejected" })),
publishReport: vi.fn(async () => ({ ...baseReport, approvalState: "published" })),
}));
const baseReport: any = {
id: "rep_1",
approvalState: "awaiting_approval",
approvalHistory: [],
status: "review_complete",
};
afterEach(() => cleanup());
describe("ReportApprovalPanel", () => {
it("renders actions for awaiting approval and posts approve", async () => {
const onReportChange = vi.fn();
render(<ReportApprovalPanel report={baseReport} onReportChange={onReportChange} />);
fireEvent.click(screen.getByText("Approve"));
await waitFor(() => expect(onReportChange).toHaveBeenCalled());
});
it("renders publish action for approved", () => {
render(<ReportApprovalPanel report={{ ...baseReport, approvalState: "approved" }} onReportChange={vi.fn()} />);
expect(screen.getByText("Publish")).toBeInTheDocument();
});
it("read-only for rejected", () => {
render(<ReportApprovalPanel report={{ id: "rep_1", status: "review_complete", approvalState: "rejected", approvalHistory: [{ action: "reject", decidedAt: "now", decidedBy: "u" }] } as any} onReportChange={vi.fn()} />);
expect(screen.queryByText("Publish")).toBeNull();
expect(screen.getByText(/reject by/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,24 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ShareBlocksPanel } from "../ShareBlocksPanel.js";
const getShareBlocks = vi.fn();
vi.mock("../../api.js", () => ({ getShareBlocks: (...args: unknown[]) => getShareBlocks(...args) }));
describe("ShareBlocksPanel", () => {
it("renders tabs and copies selected block", async () => {
getShareBlocks.mockResolvedValue({ plainText: "a", markdown: "b", slack: "c", emailHtml: "d" });
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
render(<ShareBlocksPanel report={{ id: "rep_1" } as any} />);
await screen.findByText("Plain Text");
fireEvent.click(screen.getByText("Markdown"));
fireEvent.click(screen.getByText("Copy"));
await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith("b"));
});
it("shows locked message on 409", async () => {
getShareBlocks.mockRejectedValue(new Error("409 Conflict"));
render(<ShareBlocksPanel report={{ id: "rep_1" } as any} />);
await screen.findByText(/unlock after the report is approved/i);
});
});