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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user