Add searchable and flattened export, history re-export and diagnostics self-test
issueAndArchive keeps an issued invoice even when rendering or archiving fails (Issued - not archived, with a Render & archive action). The export menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is the invoice number alone; flatten failures offer Retry or searchable export and never silently lower the resolution. History re-exports the original as issued by default and warns when a re-render's layout fingerprint differs. The render client gains a FIFO assemble lane that previews cannot supersede. A Diagnostics panel runs fixtures in the real worker and compares against goldens generated from Node.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, ComposedModal, InlineLoading, InlineNotification, ModalBody, ModalFooter, ModalHeader, Tag } from "@carbon/react";
|
||||
import { reportToText, runSelfTest, type Check, type SelfTestReport } from "../lib/selfTest";
|
||||
|
||||
const STATUS_TAG: Record<Check["status"], { type: "green" | "red" | "gray"; text: string }> = {
|
||||
pass: { type: "green", text: "Pass" },
|
||||
fail: { type: "red", text: "Fail" },
|
||||
info: { type: "gray", text: "Info" },
|
||||
};
|
||||
|
||||
/** Renders the fixtures in this webview's real render client and reports what it found. */
|
||||
export default function DiagnosticsPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [report, setReport] = useState<SelfTestReport | null>(null);
|
||||
const [fatal, setFatal] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<"ok" | "failed" | null>(null);
|
||||
const runId = useRef(0);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
const id = ++runId.current;
|
||||
setReport(null);
|
||||
setFatal(null);
|
||||
setCopied(null);
|
||||
try {
|
||||
// Loaded on demand: the fixtures, goldens and pdf.js are only needed here.
|
||||
const { browserSelfTestDeps } = await import("../lib/selfTestBrowser");
|
||||
await runSelfTest(browserSelfTestDeps(), (r) => {
|
||||
if (runId.current === id) setReport(r);
|
||||
});
|
||||
} catch (e) {
|
||||
if (runId.current === id) setFatal(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void run();
|
||||
else runId.current++;
|
||||
}, [open, run]);
|
||||
|
||||
const copy = async () => {
|
||||
if (!report) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(reportToText(report));
|
||||
setCopied("ok");
|
||||
} catch {
|
||||
setCopied("failed");
|
||||
}
|
||||
};
|
||||
|
||||
const running = open && !fatal && !report?.done;
|
||||
|
||||
return (
|
||||
<ComposedModal open={open} onClose={onClose} size="md" preventCloseOnClickOutside aria-label="Diagnostics">
|
||||
<ModalHeader title="Diagnostics" label="Settings" />
|
||||
<ModalBody hasScrollingContent>
|
||||
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
|
||||
Renders two sample invoices in this app's own renderer, reads them back and compares them with the reference layouts. Nothing is saved or sent.
|
||||
</p>
|
||||
{fatal ? <InlineNotification kind="error" lowContrast hideCloseButton title="The self-test could not run" subtitle={fatal} /> : null}
|
||||
{report && report.done ? (
|
||||
<InlineNotification
|
||||
kind={report.passed ? "success" : "error"}
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={report.passed ? "All checks passed" : "Some checks failed"}
|
||||
subtitle={report.passed ? undefined : "Copy the report and include it when you report the problem."}
|
||||
/>
|
||||
) : null}
|
||||
{report?.sections.map((section) => (
|
||||
<section key={section.title} style={{ marginTop: "1.5rem" }} aria-label={section.title}>
|
||||
<h4 style={{ marginBottom: "0.5rem" }}>{section.title}</h4>
|
||||
{section.checks.map((check, i) => (
|
||||
<div key={`${check.label}-${i}`} className="voiced-diagnostics__check">
|
||||
<div className="voiced-diagnostics__head">
|
||||
<Tag type={STATUS_TAG[check.status].type} size="sm">
|
||||
{STATUS_TAG[check.status].text}
|
||||
</Tag>
|
||||
<span className="voiced-diagnostics__label">{check.label}</span>
|
||||
{check.ms !== undefined ? <span className="voiced-diagnostics__ms">{check.ms} ms</span> : null}
|
||||
</div>
|
||||
{check.detail ? <p className="voiced-diagnostics__detail">{check.detail}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
{running ? (
|
||||
<div style={{ marginTop: "1.5rem" }}>
|
||||
<InlineLoading description="Running checks…" />
|
||||
</div>
|
||||
) : null}
|
||||
{copied ? (
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<InlineNotification
|
||||
kind={copied === "ok" ? "success" : "warning"}
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title={copied === "ok" ? "Report copied" : "Could not copy"}
|
||||
subtitle={copied === "ok" ? undefined : "The clipboard is not available here."}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button kind="secondary" onClick={() => void run()} disabled={running}>
|
||||
Run again
|
||||
</Button>
|
||||
<Button kind="secondary" onClick={() => void copy()} disabled={!report}>
|
||||
Copy report
|
||||
</Button>
|
||||
<Button kind="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ComposedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { MenuButton, MenuItem, OverflowMenu, OverflowMenuItem } from "@carbon/react";
|
||||
import { ChevronDown } from "@carbon/icons-react";
|
||||
import type { ExportMode } from "../lib/filename";
|
||||
import { EXPORT_MODE_LABEL, saveExportMode } from "../lib/exportMode";
|
||||
|
||||
const MODES: ExportMode[] = ["searchable", "flattened"];
|
||||
|
||||
/** "Export PDF" menu for an issued invoice. Choosing a mode also makes it the remembered one. */
|
||||
export default function ExportButton({ onExport, disabled }: { onExport: (mode: ExportMode) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<MenuButton label="Export PDF" kind="tertiary" disabled={disabled} menuAlignment="bottom-start">
|
||||
{MODES.map((mode) => (
|
||||
<MenuItem
|
||||
key={mode}
|
||||
label={EXPORT_MODE_LABEL[mode]}
|
||||
onClick={() => {
|
||||
saveExportMode(mode);
|
||||
onExport(mode);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
/** The format the primary "Issue & export" action uses; a small menu next to that button. */
|
||||
export function ExportModePicker({ mode, onChange, disabled }: { mode: ExportMode; onChange: (mode: ExportMode) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<OverflowMenu
|
||||
aria-label="Choose the PDF format for Issue & export"
|
||||
iconDescription={`PDF format: ${EXPORT_MODE_LABEL[mode]}`}
|
||||
renderIcon={ChevronDown}
|
||||
flipped
|
||||
size="lg"
|
||||
disabled={disabled}
|
||||
>
|
||||
{MODES.map((m) => (
|
||||
<OverflowMenuItem
|
||||
key={m}
|
||||
itemText={m === mode ? `${EXPORT_MODE_LABEL[m]} (selected)` : EXPORT_MODE_LABEL[m]}
|
||||
onClick={() => {
|
||||
saveExportMode(m);
|
||||
onChange(m);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</OverflowMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, InlineLoading, InlineNotification } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import type { InvoiceExport } from "../hooks/useInvoiceExport";
|
||||
import FlattenProgressModal from "./FlattenProgressModal";
|
||||
|
||||
/** Progress modal, spinner, success and error notices for one `useInvoiceExport`. */
|
||||
export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
|
||||
const { busy, progress, request, error, result } = exp;
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
useEffect(() => setActionError(null), [result]);
|
||||
|
||||
const flattenFailed = request?.mode === "flattened" && error?.flatten === true;
|
||||
const modalOpen = request?.mode === "flattened" && ((busy && progress?.phase !== "saving") || flattenFailed);
|
||||
|
||||
const run = async (action: () => Promise<void>) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
setActionError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlattenProgressModal
|
||||
open={modalOpen}
|
||||
progress={progress}
|
||||
error={flattenFailed ? error!.message : null}
|
||||
onCancel={exp.cancel}
|
||||
onRetry={exp.retry}
|
||||
onExportSearchable={exp.exportSearchableInstead}
|
||||
onClose={exp.dismiss}
|
||||
/>
|
||||
|
||||
{busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null}
|
||||
|
||||
{error && !flattenFailed ? (
|
||||
<InlineNotification kind="error" lowContrast title="The PDF was not exported" subtitle={error.message} onCloseButtonClick={exp.dismiss}>
|
||||
<div className="voiced-notice-actions">
|
||||
<Button kind="ghost" size="sm" onClick={exp.retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</InlineNotification>
|
||||
) : null}
|
||||
|
||||
{result ? (
|
||||
<>
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
lowContrast
|
||||
title={`Saved to ${result.path}`}
|
||||
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
|
||||
onCloseButtonClick={exp.dismiss}
|
||||
>
|
||||
<div className="voiced-notice-actions">
|
||||
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
|
||||
Open file
|
||||
</Button>
|
||||
<Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}>
|
||||
Show in folder
|
||||
</Button>
|
||||
</div>
|
||||
</InlineNotification>
|
||||
{result.identicalToIssued === false ? (
|
||||
<InlineNotification
|
||||
kind="warning"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="Layout differs from the issued invoice"
|
||||
subtitle="The current template lays this invoice out differently from the PDF issued. The archived original is unchanged; use Export PDF to get it."
|
||||
/>
|
||||
) : null}
|
||||
{result.archiveError ? (
|
||||
<InlineNotification kind="warning" lowContrast hideCloseButton title="This invoice is still not archived" subtitle={result.archiveError} />
|
||||
) : null}
|
||||
{actionError ? <InlineNotification kind="error" lowContrast title="Could not open the file" subtitle={actionError} onCloseButtonClick={() => setActionError(null)} /> : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Button, ComposedModal, InlineNotification, ModalBody, ModalFooter, ModalHeader, ProgressBar } from "@carbon/react";
|
||||
import type { ExportProgress } from "../lib/exportFlow";
|
||||
|
||||
export interface FlattenProgressModalProps {
|
||||
open: boolean;
|
||||
progress: ExportProgress | null;
|
||||
/** The flatten failed: show it with Retry and "Export searchable instead". */
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
onExportSearchable: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function describe(p: ExportProgress | null): { label: string; value?: number; max: number } {
|
||||
if (p?.phase === "flattening" && p.page && p.pages) {
|
||||
// The page being rendered counts as in progress, so the bar shows the pages finished before it.
|
||||
return { label: `Rendering page ${p.page} of ${p.pages}…`, value: p.page - 1, max: p.pages };
|
||||
}
|
||||
if (p?.phase === "assembling") return { label: "Building the PDF…", value: 1, max: 1 };
|
||||
return { label: "Preparing the invoice…", max: 1 };
|
||||
}
|
||||
|
||||
export default function FlattenProgressModal({ open, progress, error, onCancel, onRetry, onExportSearchable, onClose }: FlattenProgressModalProps) {
|
||||
const { label, value, max } = describe(progress);
|
||||
return (
|
||||
<ComposedModal open={open} size="sm" preventCloseOnClickOutside onClose={() => (error ? onClose() : onCancel())} aria-label="Flattened PDF export">
|
||||
<ModalHeader title="Flattened PDF" label="Export" />
|
||||
<ModalBody hasForm={false}>
|
||||
{error ? (
|
||||
<InlineNotification
|
||||
kind="error"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="The flattened PDF could not be made"
|
||||
subtitle={`${error} Nothing was saved, and the resolution was not lowered.`}
|
||||
/>
|
||||
) : (
|
||||
<ProgressBar label={label} value={value} max={max} helperText="Each page is drawn at 300 DPI; this takes a few seconds per page." />
|
||||
)}
|
||||
</ModalBody>
|
||||
{error ? (
|
||||
// Three direct children: Carbon lays a three-button footer out differently from a two-button one.
|
||||
<ModalFooter>
|
||||
<Button kind="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button kind="tertiary" onClick={onExportSearchable}>
|
||||
Export searchable instead
|
||||
</Button>
|
||||
<Button kind="primary" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
) : (
|
||||
<ModalFooter>
|
||||
<Button kind="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</ComposedModal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user