Add Reset all data to Settings > Data and release 1.2.0
Reset all data stages a wipe behind a typed RESET confirmation and applies it on the next start, before the database is opened. It deletes the database, assets, PDF archive, imported fonts and every backup, then the setup wizard shows again. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
|
||||
import { Button, InlineLoading, InlineNotification, Modal, TextInput, Tile, Toggle } from "@carbon/react";
|
||||
import { confirm, open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import type { Settings } from "../lib/types";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type BackupStatus,
|
||||
type PendingRestore,
|
||||
} from "../lib/backup";
|
||||
import { clearVoicedLocalState, isResetConfirmed, RESET_WORD } from "../lib/reset";
|
||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
@@ -28,7 +29,7 @@ interface DataBackupPanelProps {
|
||||
onRerunSetup: () => void;
|
||||
}
|
||||
|
||||
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup and re-running setup. */
|
||||
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup, re-running setup and a full reset. */
|
||||
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
|
||||
const toast = useToast();
|
||||
const [status, setStatus] = useState<BackupStatus | null>(null);
|
||||
@@ -38,7 +39,10 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
|
||||
const [staged, setStaged] = useState<PendingRestore | null>(null);
|
||||
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
useReturnFocus(restorePath !== null);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [resetTyped, setResetTyped] = useState("");
|
||||
const [resetting, setResetting] = useState(false);
|
||||
useReturnFocus(restorePath !== null || resetOpen);
|
||||
|
||||
const rerunSetup = async () => {
|
||||
const ok = await confirm(
|
||||
@@ -54,6 +58,31 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
|
||||
}
|
||||
};
|
||||
|
||||
const closeReset = () => {
|
||||
if (resetting) return;
|
||||
setResetOpen(false);
|
||||
setResetTyped("");
|
||||
};
|
||||
|
||||
// Stage the wipe, clear this webview's own saved state, then restart; the next start deletes the data.
|
||||
const resetAll = async () => {
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.resetAllData();
|
||||
} catch (e) {
|
||||
setResetting(false);
|
||||
toast.error("Could not reset", String(e));
|
||||
return;
|
||||
}
|
||||
clearVoicedLocalState();
|
||||
try {
|
||||
await api.restartApp();
|
||||
} catch (e) {
|
||||
setResetting(false);
|
||||
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the reset.`);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setStatus(await api.getBackupStatus());
|
||||
@@ -264,6 +293,47 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<h2 className="voiced-section-title">Reset all data</h2>
|
||||
<p className="voiced-muted">
|
||||
Permanently deletes every invoice, client, payment, your business details, archived PDFs and all backups, then starts Voiced
|
||||
as if it were new.
|
||||
</p>
|
||||
<div className="voiced-actions">
|
||||
<Button size="md" kind="danger--tertiary" onClick={() => setResetOpen(true)} disabled={resetOpen}>
|
||||
Reset all data…
|
||||
</Button>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Modal
|
||||
open={resetOpen}
|
||||
danger
|
||||
modalHeading="Reset all data?"
|
||||
primaryButtonText={resetting ? "Resetting…" : "Delete everything and restart"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={!isResetConfirmed(resetTyped) || resetting}
|
||||
onRequestClose={closeReset}
|
||||
onRequestSubmit={() => void (isResetConfirmed(resetTyped) ? resetAll() : undefined)}
|
||||
onSecondarySubmit={closeReset}
|
||||
>
|
||||
<div className="voiced-stack">
|
||||
<p>
|
||||
All invoices, clients, payments, vendor details, settings, logo and signature images, imported fonts, archived PDFs and
|
||||
every backup (automatic, manual copies kept in Voiced’s folder, and safety copies) will be permanently deleted. This
|
||||
cannot be undone.
|
||||
</p>
|
||||
<p>Backup files you saved elsewhere are not touched. Voiced restarts and shows the setup wizard.</p>
|
||||
<TextInput
|
||||
id="resetConfirm"
|
||||
labelText={`Type ${RESET_WORD} to confirm`}
|
||||
value={resetTyped}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setResetTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={restorePath !== null}
|
||||
danger={step === "confirm"}
|
||||
|
||||
@@ -122,6 +122,8 @@ export const api = {
|
||||
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
|
||||
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
|
||||
restartApp: () => invoke<void>("restart_app"),
|
||||
// Stages a wipe of all data; it is carried out by the next start (see commands/reset.rs), so restart afterwards.
|
||||
resetAllData: () => invoke<void>("reset_all_data"),
|
||||
|
||||
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
|
||||
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearVoicedLocalState, isResetConfirmed } from "./reset";
|
||||
|
||||
describe("isResetConfirmed", () => {
|
||||
it("accepts the word in any case with surrounding spaces", () => {
|
||||
expect(isResetConfirmed("RESET")).toBe(true);
|
||||
expect(isResetConfirmed(" reset ")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects anything else", () => {
|
||||
expect(isResetConfirmed("")).toBe(false);
|
||||
expect(isResetConfirmed("rese")).toBe(false);
|
||||
expect(isResetConfirmed("reset all")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearVoicedLocalState", () => {
|
||||
// The node test environment has no localStorage, so stub a minimal one.
|
||||
beforeEach(() => {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
get length() {
|
||||
return data.size;
|
||||
},
|
||||
key: (i: number) => [...data.keys()][i] ?? null,
|
||||
getItem: (k: string) => data.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void data.set(k, v),
|
||||
removeItem: (k: string) => void data.delete(k),
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("removes only the app's own keys", () => {
|
||||
localStorage.setItem("voiced.theme", "g100");
|
||||
localStorage.setItem("voiced.settingsTab", "data");
|
||||
localStorage.setItem("other.key", "keep");
|
||||
clearVoicedLocalState();
|
||||
expect(localStorage.getItem("voiced.theme")).toBeNull();
|
||||
expect(localStorage.getItem("voiced.settingsTab")).toBeNull();
|
||||
expect(localStorage.getItem("other.key")).toBe("keep");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/** The word typed to enable "Reset all data". */
|
||||
export const RESET_WORD = "RESET";
|
||||
|
||||
export const isResetConfirmed = (typed: string): boolean => typed.trim().toUpperCase() === RESET_WORD;
|
||||
|
||||
/** Removes every `voiced.*` key this app keeps in the webview's localStorage (theme, last tab, export mode). */
|
||||
export function clearVoicedLocalState(): void {
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith("voiced.")) keys.push(key);
|
||||
}
|
||||
keys.forEach((key) => localStorage.removeItem(key));
|
||||
} catch {
|
||||
// Storage can be blocked; there is then nothing to clear.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user