Release 1.2.0: Reset all data #3

Merged
xavierk merged 7 commits from release/1.2.0 into main 2026-10-06 07:14:35 +00:00
9 changed files with 225 additions and 22 deletions
Showing only changes of commit cd067ad584 - Show all commits
+51
View File
@@ -0,0 +1,51 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
/** What to show instead of the children. Without it, a full-window notice with a Reload button. */
fallback?: (error: Error, reset: () => void) => ReactNode;
}
interface State {
error: Error | null;
}
/**
* React unmounts the whole tree when a component throws and nothing catches it, which leaves a blank window that
* looks like a hang. This keeps the failure to the part that threw (or, at the top, shows what happened and a way back).
* Plain elements on purpose: the component library may be what threw.
*/
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Voiced UI error:", error, info.componentStack);
}
reset = () => this.setState({ error: null });
render() {
const { error } = this.state;
if (!error) return this.props.children;
if (this.props.fallback) return this.props.fallback(error, this.reset);
return (
<div
role="alert"
style={{ padding: "2rem", maxWidth: "40rem", margin: "0 auto", color: "var(--cds-text-primary, #161616)", background: "var(--cds-background, #f4f4f4)", minHeight: "100vh" }}
>
<h1 style={{ fontSize: "1.5rem", marginBottom: "0.75rem" }}>Something went wrong</h1>
<p style={{ marginBottom: "1rem" }}>
Voiced hit an unexpected error. Your invoices and drafts are saved. Reload to carry on.
</p>
<pre style={{ whiteSpace: "pre-wrap", fontSize: "0.8125rem", marginBottom: "1.5rem" }}>{error.message}</pre>
<button type="button" className="cds--btn cds--btn--primary" onClick={() => window.location.reload()}>
Reload Voiced
</button>
</div>
);
}
}
+23 -18
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification } from "@carbon/react"; import { ActionableNotification, Button, InlineLoading, InlineNotification } from "@carbon/react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { InvoiceExport } from "../hooks/useInvoiceExport"; import type { InvoiceExport } from "../hooks/useInvoiceExport";
import FlattenProgressModal from "./FlattenProgressModal"; import FlattenProgressModal from "./FlattenProgressModal";
@@ -37,13 +37,18 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
{busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null} {busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null}
{error && !flattenFailed ? ( {error && !flattenFailed ? (
<InlineNotification kind="error" lowContrast title="The PDF was not exported" subtitle={error.message} onCloseButtonClick={exp.dismiss}> // Carbon throws if a notification has buttons inside it, and an uncaught throw blanks the whole window.
<div className="voiced-notice-actions"> // A single action goes through ActionableNotification's own button instead.
<Button kind="ghost" size="sm" onClick={exp.retry}> <ActionableNotification
Retry inline
</Button> kind="error"
</div> lowContrast
</InlineNotification> title="The PDF was not exported"
subtitle={error.message}
actionButtonLabel="Retry"
onActionButtonClick={exp.retry}
onCloseButtonClick={exp.dismiss}
/>
) : null} ) : null}
{result ? ( {result ? (
@@ -54,16 +59,16 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
title={`Saved to ${result.path}`} title={`Saved to ${result.path}`}
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined} subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
onCloseButtonClick={exp.dismiss} onCloseButtonClick={exp.dismiss}
> />
<div className="voiced-notice-actions"> {/* Beside the notification, not inside it: Carbon throws on interactive children. */}
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}> <div className="voiced-notice-actions">
Open file <Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
</Button> Open file
<Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}> </Button>
Show in folder <Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}>
</Button> Show in folder
</div> </Button>
</InlineNotification> </div>
{result.identicalToIssued === false ? ( {result.identicalToIssued === false ? (
<InlineNotification <InlineNotification
kind="warning" kind="warning"
+63
View File
@@ -0,0 +1,63 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
/**
* Carbon's notifications throw "component should have no interactive child nodes" when a button or link sits
* inside them, and a throw in an effect unmounts the whole app: the window goes blank (this made every successful
* export look like a freeze). There is no DOM test setup here, so this reads the sources instead. Put the
* buttons beside the notification, or use ActionableNotification's own action.
*/
const SRC = path.resolve(process.cwd(), "src");
const NOTIFICATIONS = ["InlineNotification", "ActionableNotification", "ToastNotification", "Callout"];
const INTERACTIVE = /<(Button|IconButton|button|a|Link|TextInput|Select|Checkbox|Toggle|OverflowMenu|ComboBox|Dropdown)\b/;
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) return name === "pdf" ? [] : sourceFiles(full);
return /\.tsx$/.test(name) && !/\.test\.tsx$/.test(name) ? [full] : [];
});
}
/** Bodies of the notifications that have children, with the line they start on. */
function notificationBodies(source: string): Array<{ tag: string; line: number; body: string }> {
const out: Array<{ tag: string; line: number; body: string }> = [];
for (const tag of NOTIFICATIONS) {
const open = new RegExp(`<${tag}\\b`, "g");
for (let m = open.exec(source); m; m = open.exec(source)) {
let i = m.index + m[0].length;
let braces = 0;
for (; i < source.length; i++) {
const c = source[i];
if (c === "{") braces++;
else if (c === "}") braces--;
else if (c === ">" && braces === 0) break;
}
if (source[i - 1] === "/") continue; // self-closing: no children
const end = source.indexOf(`</${tag}>`, i);
out.push({ tag, line: source.slice(0, m.index).split("\n").length, body: source.slice(i + 1, end < 0 ? undefined : end) });
}
}
return out;
}
describe("Carbon notifications", () => {
it("never wrap an interactive element", () => {
const offenders: string[] = [];
for (const file of sourceFiles(SRC)) {
for (const { tag, line, body } of notificationBodies(readFileSync(file, "utf8"))) {
if (INTERACTIVE.test(body)) offenders.push(`${path.relative(SRC, file)}:${line} <${tag}>`);
}
}
expect(offenders).toEqual([]);
});
it("the scan finds an offender when there is one", () => {
const bad = `<InlineNotification kind="success" title="x">\n <Button>Open</Button>\n</InlineNotification>`;
const fine = `<InlineNotification kind="success" title="x" />\n<Button>Open</Button>`;
expect(notificationBodies(bad).some((n) => INTERACTIVE.test(n.body))).toBe(true);
expect(notificationBodies(fine)).toEqual([]);
});
});
+13
View File
@@ -125,6 +125,8 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` }; return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` };
}); });
await log.run("e2e: export notices render without crashing the window", async () => (await import("./selfTestUi")).checkExportFeedbackRenders());
await log.run("e2e: export flattened original", async () => { await log.run("e2e: export flattened original", async () => {
const r = await exportWith("flattened", "original", flattenedPath); const r = await exportWith("flattened", "original", flattenedPath);
return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` }; return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` };
@@ -158,6 +160,17 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
}; };
}); });
await log.run("e2e: a new invoice can be issued and exported after the first", async () => {
const first = inv.number;
const outcome = await issueAndArchive({ ...input, notes: "Self-test invoice 2" }, prefs, deps);
const next = `${outDir}/e2e-second.pdf`;
const r = await exportInvoice({ invoice: outcome.invoice, mode: "searchable", source: "original" }, { ...deps, save: async () => next });
return {
ok: outcome.archived && outcome.invoice.number !== first && r?.path === next,
detail: `${first}, then ${outcome.invoice.number} (archived=${outcome.archived}), exported ${r?.path}`,
};
});
await log.run("e2e: archiving different bytes is refused", async () => { await log.run("e2e: archiving different bytes is refused", async () => {
const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n"); const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n");
const before = await api.readArchive(inv.id); const before = await api.readArchive(inv.id);
+59
View File
@@ -0,0 +1,59 @@
import { createRoot } from "react-dom/client";
import { Theme } from "@carbon/react";
import ExportFeedback from "../components/ExportFeedback";
import type { InvoiceExport } from "../hooks/useInvoiceExport";
import type { Invoice } from "./types";
/**
* Part of the real-webview self-test: mounts the export notices the way the app does and fails if React reports an
* uncaught error. Carbon throws when a notification has a button inside it, and that took the whole window
* down after every successful export; nothing that runs without a real DOM can see it.
*/
const noop = () => {};
const idle: InvoiceExport = {
busy: false,
progress: null,
request: null,
error: null,
result: null,
start: async () => null,
cancel: noop,
retry: noop,
exportSearchableInstead: noop,
dismiss: noop,
};
const CASES: Array<{ name: string; expect: string; exp: InvoiceExport }> = [
{
name: "saved",
expect: "Saved to",
exp: { ...idle, result: { path: "/tmp/voiced-selftest.pdf", mode: "searchable", invoice: {} as Invoice, identicalToIssued: false, archiveError: "not archived" } },
},
{ name: "failed", expect: "was not exported", exp: { ...idle, error: { message: "disk full", flatten: false } } },
];
export async function checkExportFeedbackRenders(): Promise<{ ok: boolean; detail: string }> {
const problems: string[] = [];
for (const c of CASES) {
const host = document.createElement("div");
document.body.appendChild(host);
const errors: string[] = [];
const root = createRoot(host, {
onUncaughtError: (e) => errors.push(e instanceof Error ? e.message : String(e)),
onRecoverableError: (e) => errors.push(e instanceof Error ? e.message : String(e)),
});
root.render(
<Theme theme="g10">
<ExportFeedback exp={c.exp} />
</Theme>,
);
// Effects (where Carbon checks its children) run after the first paint.
await new Promise((r) => setTimeout(r, 400));
if (errors.length > 0) problems.push(`${c.name}: ${errors.join("; ").slice(0, 300)}`);
else if (!(host.textContent ?? "").includes(c.expect)) problems.push(`${c.name}: the notice did not render`);
root.unmount();
host.remove();
}
return { ok: problems.length === 0, detail: problems.join(" | ") || "the saved and failed notices render without throwing" };
}
+4 -1
View File
@@ -1,12 +1,15 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import "./styles/carbon.scss"; import "./styles/carbon.scss";
import { maybeRunSelfTest } from "./selftestBoot"; import { maybeRunSelfTest } from "./selftestBoot";
createRoot(document.getElementById("root") as HTMLElement).render( createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
+4 -1
View File
@@ -44,6 +44,7 @@ import {
syncTag, syncTag,
} from "../lib/erpnextUi"; } from "../lib/erpnextUi";
import { useReturnFocus } from "../hooks/useReturnFocus"; import { useReturnFocus } from "../hooks/useReturnFocus";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import PdfPreview from "../components/PdfPreview"; import PdfPreview from "../components/PdfPreview";
import RecordPaymentModal from "../components/RecordPaymentModal"; import RecordPaymentModal from "../components/RecordPaymentModal";
@@ -385,7 +386,9 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
</OverflowMenu> </OverflowMenu>
</div> </div>
<ExportFeedback exp={exp} /> <ErrorBoundary fallback={(e) => <p className="voiced-muted">The export result could not be shown ({e.message}). The PDF was still saved.</p>}>
<ExportFeedback exp={exp} />
</ErrorBoundary>
<div className="voiced-detail__grid"> <div className="voiced-detail__grid">
<div className="voiced-detail__main voiced-stack"> <div className="voiced-detail__main voiced-stack">
+4 -1
View File
@@ -49,6 +49,7 @@ import { openInErpnext } from "../lib/erpnextActions";
import type { ErpnextConfig, ErpnextSyncStatus } from "../lib/erpnext"; import type { ErpnextConfig, ErpnextSyncStatus } from "../lib/erpnext";
import { canOpenInErpnext, indexSyncStatuses, isConfigured, pushDisabledReason, sendableSelection, syncTag } from "../lib/erpnextUi"; import { canOpenInErpnext, indexSyncStatuses, isConfigured, pushDisabledReason, sendableSelection, syncTag } from "../lib/erpnextUi";
import SendToErpnextModal, { type SendTarget } from "../components/SendToErpnextModal"; import SendToErpnextModal, { type SendTarget } from "../components/SendToErpnextModal";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import { useToast } from "../components/ToastProvider"; import { useToast } from "../components/ToastProvider";
import type { InvoiceSummary, Settings } from "../lib/types"; import type { InvoiceSummary, Settings } from "../lib/types";
@@ -226,7 +227,9 @@ export default function InvoiceHistory({ settings, active, onOpen, onOpenSetting
<h1 className="voiced-page__title">Invoices</h1> <h1 className="voiced-page__title">Invoices</h1>
<p className="voiced-page__subtitle">Every invoice you have issued. Open one to see its PDF, record payments or export it.</p> <p className="voiced-page__subtitle">Every invoice you have issued. Open one to see its PDF, record payments or export it.</p>
<ExportFeedback exp={exp} /> <ErrorBoundary fallback={(e) => <p className="voiced-muted">The export result could not be shown ({e.message}). The PDF was still saved.</p>}>
<ExportFeedback exp={exp} />
</ErrorBoundary>
{loading ? ( {loading ? (
<InlineLoading description="Loading invoices" /> <InlineLoading description="Loading invoices" />
+4 -1
View File
@@ -61,6 +61,7 @@ import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { useInvoiceExport } from "../hooks/useInvoiceExport"; import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { autoPushIssued } from "../lib/erpnextUi"; import { autoPushIssued } from "../lib/erpnextUi";
import ExportButton, { ExportModePicker } from "../components/ExportButton"; import ExportButton, { ExportModePicker } from "../components/ExportButton";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import { createExportDeps } from "../lib/exportDeps"; import { createExportDeps } from "../lib/exportDeps";
import { issueAndArchive, renderAndArchive } from "../lib/exportFlow"; import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
@@ -784,7 +785,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
onActionButtonClick={() => void retryArchive()} onActionButtonClick={() => void retryArchive()}
/> />
) : null} ) : null}
<ExportFeedback exp={exp} /> <ErrorBoundary fallback={(e) => <p className="voiced-muted">The export result could not be shown ({e.message}). The PDF was still saved.</p>}>
<ExportFeedback exp={exp} />
</ErrorBoundary>
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}> <div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
<ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} /> <ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} />
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}> <Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}>