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 { 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,7 +59,8 @@ 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}
|
||||||
>
|
/>
|
||||||
|
{/* Beside the notification, not inside it: Carbon throws on interactive children. */}
|
||||||
<div className="voiced-notice-actions">
|
<div className="voiced-notice-actions">
|
||||||
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
|
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
|
||||||
Open file
|
Open file
|
||||||
@@ -63,7 +69,6 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
|
|||||||
Show in folder
|
Show in folder
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</InlineNotification>
|
|
||||||
{result.identicalToIssued === false ? (
|
{result.identicalToIssued === false ? (
|
||||||
<InlineNotification
|
<InlineNotification
|
||||||
kind="warning"
|
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}` };
|
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);
|
||||||
|
|||||||
@@ -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 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>
|
||||||
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
<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} />
|
<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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
<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} />
|
<ExportFeedback exp={exp} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<InlineLoading description="Loading invoices" />
|
<InlineLoading description="Loading invoices" />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
<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} />
|
<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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user