Release 1.2.0: Reset all data #3
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { InvoiceExport } from "../hooks/useInvoiceExport";
|
||||
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}
|
||||
|
||||
{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>
|
||||
// Carbon throws if a notification has buttons inside it, and an uncaught throw blanks the whole window.
|
||||
// A single action goes through ActionableNotification's own button instead.
|
||||
<ActionableNotification
|
||||
inline
|
||||
kind="error"
|
||||
lowContrast
|
||||
title="The PDF was not exported"
|
||||
subtitle={error.message}
|
||||
actionButtonLabel="Retry"
|
||||
onActionButtonClick={exp.retry}
|
||||
onCloseButtonClick={exp.dismiss}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{result ? (
|
||||
@@ -54,7 +59,8 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
|
||||
title={`Saved to ${result.path}`}
|
||||
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
|
||||
onCloseButtonClick={exp.dismiss}
|
||||
>
|
||||
/>
|
||||
{/* Beside the notification, not inside it: Carbon throws on interactive children. */}
|
||||
<div className="voiced-notice-actions">
|
||||
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
|
||||
Open file
|
||||
@@ -63,7 +69,6 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
|
||||
Show in folder
|
||||
</Button>
|
||||
</div>
|
||||
</InlineNotification>
|
||||
{result.identicalToIssued === false ? (
|
||||
<InlineNotification
|
||||
kind="warning"
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,8 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
|
||||
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 () => {
|
||||
const r = await exportWith("flattened", "original", flattenedPath);
|
||||
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 () => {
|
||||
const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n");
|
||||
const before = await api.readArchive(inv.id);
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import "./styles/carbon.scss";
|
||||
import { maybeRunSelfTest } from "./selftestBoot";
|
||||
|
||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
syncTag,
|
||||
} from "../lib/erpnextUi";
|
||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||
import ErrorBoundary from "../components/ErrorBoundary";
|
||||
import ExportFeedback from "../components/ExportFeedback";
|
||||
import PdfPreview from "../components/PdfPreview";
|
||||
import RecordPaymentModal from "../components/RecordPaymentModal";
|
||||
@@ -385,7 +386,9 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
|
||||
<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__main voiced-stack">
|
||||
|
||||
@@ -49,6 +49,7 @@ import { openInErpnext } from "../lib/erpnextActions";
|
||||
import type { ErpnextConfig, ErpnextSyncStatus } from "../lib/erpnext";
|
||||
import { canOpenInErpnext, indexSyncStatuses, isConfigured, pushDisabledReason, sendableSelection, syncTag } from "../lib/erpnextUi";
|
||||
import SendToErpnextModal, { type SendTarget } from "../components/SendToErpnextModal";
|
||||
import ErrorBoundary from "../components/ErrorBoundary";
|
||||
import ExportFeedback from "../components/ExportFeedback";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
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>
|
||||
<p className="voiced-page__subtitle">Every invoice you have issued. Open one to see its PDF, record payments or export it.</p>
|
||||
|
||||
<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 ? (
|
||||
<InlineLoading description="Loading invoices" />
|
||||
|
||||
@@ -61,6 +61,7 @@ import { useInvoicePdf } from "../hooks/useInvoicePdf";
|
||||
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||
import { autoPushIssued } from "../lib/erpnextUi";
|
||||
import ExportButton, { ExportModePicker } from "../components/ExportButton";
|
||||
import ErrorBoundary from "../components/ErrorBoundary";
|
||||
import ExportFeedback from "../components/ExportFeedback";
|
||||
import { createExportDeps } from "../lib/exportDeps";
|
||||
import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
|
||||
@@ -784,7 +785,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
onActionButtonClick={() => void retryArchive()}
|
||||
/>
|
||||
) : null}
|
||||
<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" }}>
|
||||
<ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} />
|
||||
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}>
|
||||
|
||||
Reference in New Issue
Block a user