From ebbe4a678af1465c1f0335f742ab1d4444616f8b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 14:44:43 -0400 Subject: [PATCH] DRY pass #2, batch 5, F16: one way an action reports its failure (#5372) composables/useAction.ts: toastOnFailure runs an action and toasts the server's reason or a fallback; useAction adds the busy flag a button waits on; useRowAction keeps the id of the row whose action is running. Import, export, the menu entry, the integration prompt, sign out elsewhere, the account reset link, the group actions (whose local act() it replaces), invite revoke and sync disconnect each wrote that try/catch/finally out. Kept: AccountView's device revoke. It shows a fixed message rather than the server's reason, and moving it would change the text. ShareDialog shows its errors inline, not in a toast. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/AccountList.vue | 22 ++++----- .../components/DesktopIntegrationPrompt.vue | 13 ++--- frontend/src/components/DesktopMenuEntry.vue | 13 ++--- frontend/src/components/ExportNotes.vue | 14 ++---- frontend/src/components/GroupList.vue | 16 +------ frontend/src/components/ImportNotes.vue | 13 ++--- frontend/src/components/InviteList.vue | 9 ++-- frontend/src/composables/useAction.ts | 47 +++++++++++++++++++ frontend/src/views/AccountView.vue | 12 ++--- frontend/src/views/SyncView.vue | 7 ++- 10 files changed, 85 insertions(+), 81 deletions(-) create mode 100644 frontend/src/composables/useAction.ts diff --git a/frontend/src/components/AccountList.vue b/frontend/src/components/AccountList.vue index 1680681..dd73a47 100644 --- a/frontend/src/components/AccountList.vue +++ b/frontend/src/components/AccountList.vue @@ -3,9 +3,9 @@ import { onMounted, ref } from "vue"; import { api } from "../api/client"; import { errorMessage } from "../api/errors"; import { useSessionStore } from "../stores/session"; -import { useUiStore } from "../stores/ui"; import { appLink } from "../router/links"; import OneTimeLink from "./OneTimeLink.vue"; +import { useRowAction } from "../composables/useAction"; // Admin: the accounts on this instance, and a password reset link for any of them // (#5173). There is no mail path, so the admin hands the link over. @@ -18,12 +18,11 @@ interface Account { } const session = useSessionStore(); -const ui = useUiStore(); const accounts = ref([]); const loading = ref(true); const error = ref(""); -const busy = ref(""); +const { busy, act } = useRowAction(); // The reset link just made, and whose it is. Never retrievable again once dismissed. const fresh = ref<{ link: string; email: string } | null>(null); @@ -44,15 +43,14 @@ async function resetLink(account: Account) { ? "Make a password reset link for your own account? Using it signs you out everywhere, this browser included." : `Make a password reset link for ${account.email}? Using it signs them out everywhere and unlinks their apps.`; if (!window.confirm(warning)) return; - busy.value = account.id; - try { - const res = await api.post<{ token: string }>(`/api/accounts/${account.id}/reset-link`, {}); - fresh.value = { link: appLink({ name: "reset-password", query: { token: res.token } }), email: account.email }; - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't make a reset link.")); - } finally { - busy.value = ""; - } + await act( + account.id, + async () => { + const res = await api.post<{ token: string }>(`/api/accounts/${account.id}/reset-link`, {}); + fresh.value = { link: appLink({ name: "reset-password", query: { token: res.token } }), email: account.email }; + }, + "Couldn't make a reset link.", + ); } onMounted(() => { diff --git a/frontend/src/components/DesktopIntegrationPrompt.vue b/frontend/src/components/DesktopIntegrationPrompt.vue index 0a3449e..f1a5a05 100644 --- a/frontend/src/components/DesktopIntegrationPrompt.vue +++ b/frontend/src/components/DesktopIntegrationPrompt.vue @@ -4,7 +4,7 @@ import BaseModal from "./BaseModal.vue"; import BaseButton from "./BaseButton.vue"; import { isDesktop, desktop } from "../desktop/bridge"; import { useUiStore } from "../stores/ui"; -import { errorMessage } from "../api/errors"; +import { useAction } from "../composables/useAction"; // First-run prompt (Linux AppImage only) offering to add Inkwell to the // applications menu so it behaves like an installed app instead of a loose file. @@ -15,7 +15,7 @@ const DISMISS_KEY = "desktop.integration.prompted"; const ui = useUiStore(); const show = ref(false); -const working = ref(false); +const { busy: working, act } = useAction(); onMounted(async () => { if (!isDesktop() || localStorage.getItem(DISMISS_KEY)) return; @@ -33,17 +33,12 @@ function dismiss() { } async function integrate() { - working.value = true; - try { + await act(async () => { await desktop.integrate(); localStorage.setItem(DISMISS_KEY, "1"); show.value = false; ui.showToast("Inkwell added to your applications menu."); - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't add to applications.")); - } finally { - working.value = false; - } + }, "Couldn't add to applications."); } diff --git a/frontend/src/components/DesktopMenuEntry.vue b/frontend/src/components/DesktopMenuEntry.vue index b1daf81..aa2fd71 100644 --- a/frontend/src/components/DesktopMenuEntry.vue +++ b/frontend/src/components/DesktopMenuEntry.vue @@ -2,8 +2,8 @@ import { onMounted, ref } from "vue"; import BaseButton from "./BaseButton.vue"; import { desktop, isDesktop, type IntegrationStatus } from "../desktop/bridge"; -import { errorMessage } from "../api/errors"; import { useUiStore } from "../stores/ui"; +import { useAction } from "../composables/useAction"; // The applications-menu entry for a Linux AppImage: add it, or take it away again. // Renders nothing anywhere else — the web, Windows, or an AppImage-less Linux build. @@ -12,7 +12,7 @@ import { useUiStore } from "../stores/ui"; // first-run prompt promised ("undo any time") with no way to reach it. const ui = useUiStore(); const status = ref(null); -const busy = ref(false); +const { busy, act } = useAction(); onMounted(async () => { if (!isDesktop()) return; @@ -24,17 +24,12 @@ onMounted(async () => { }); async function toggle() { - busy.value = true; - try { + await act(async () => { status.value = status.value?.is_integrated ? await desktop.unintegrate() : await desktop.integrate(); ui.showToast( status.value?.is_integrated ? "Added to your applications menu." : "Removed from your applications menu.", ); - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't update the menu entry.")); - } finally { - busy.value = false; - } + }, "Couldn't update the menu entry."); } diff --git a/frontend/src/components/ExportNotes.vue b/frontend/src/components/ExportNotes.vue index 00c684c..65b6785 100644 --- a/frontend/src/components/ExportNotes.vue +++ b/frontend/src/components/ExportNotes.vue @@ -1,27 +1,21 @@ diff --git a/frontend/src/components/GroupList.vue b/frontend/src/components/GroupList.vue index 0b1ffd8..03bfd62 100644 --- a/frontend/src/components/GroupList.vue +++ b/frontend/src/components/GroupList.vue @@ -2,10 +2,10 @@ import { computed, onMounted, ref } from "vue"; import { api } from "../api/client"; import { errorMessage } from "../api/errors"; -import { useUiStore } from "../stores/ui"; import BaseButton from "./BaseButton.vue"; import BaseInput from "./BaseInput.vue"; import Icon from "./Icon.vue"; +import { useRowAction } from "../composables/useAction"; // Admin: groups of people a note can be shared with in one go (#5177). A group is // the instance's; anyone can share with it, and a note shared with it reaches whoever @@ -23,7 +23,6 @@ interface Group { members: Person[]; } -const ui = useUiStore(); const groups = ref([]); const accounts = ref([]); @@ -31,7 +30,7 @@ const loading = ref(true); const error = ref(""); const name = ref(""); const creating = ref(false); -const busy = ref(""); +const { busy, act } = useRowAction(); // Which group's "Add someone" picker holds which account id. const picks = ref>({}); @@ -64,17 +63,6 @@ function replace(updated: Group) { groups.value = groups.value.map((g) => (g.id === updated.id ? updated : g)).sort(byName); } -async function act(id: string, fn: () => Promise, fallback: string) { - busy.value = id; - try { - await fn(); - } catch (e) { - ui.showToast(errorMessage(e, fallback)); - } finally { - busy.value = ""; - } -} - async function create() { if (!name.value.trim()) return; creating.value = true; diff --git a/frontend/src/components/ImportNotes.vue b/frontend/src/components/ImportNotes.vue index 4890003..d2c1174 100644 --- a/frontend/src/components/ImportNotes.vue +++ b/frontend/src/components/ImportNotes.vue @@ -4,7 +4,7 @@ import Icon from "./Icon.vue"; import { useNotesStore } from "../stores/notes"; import { useLabelsStore } from "../stores/labels"; import { useUiStore } from "../stores/ui"; -import { errorMessage } from "../api/errors"; +import { useAction } from "../composables/useAction"; // Sidebar counterpart to Export: pick a .zip (an Inkwell export for round-trip, // or a Google Keep Takeout archive) and import its notes. Additive — never @@ -13,7 +13,7 @@ const notes = useNotesStore(); const labels = useLabelsStore(); const ui = useUiStore(); const inputRef = ref(null); -const busy = ref(false); +const { busy, act } = useAction(); function pick() { if (!busy.value) inputRef.value?.click(); @@ -24,18 +24,13 @@ async function onFile(e: Event) { const file = input.files?.[0]; input.value = ""; // reset so picking the same file again re-fires change if (!file) return; - busy.value = true; - try { + await act(async () => { const res = await notes.importNotes(file); await labels.load(); // surface any labels the import created const noun = res.imported === 1 ? "note" : "notes"; const tail = res.skipped ? ` (${res.skipped} skipped)` : ""; ui.showToast(`Imported ${res.imported} ${noun}${tail}.`); - } catch (err) { - ui.showToast(errorMessage(err, "Import failed.")); - } finally { - busy.value = false; - } + }, "Import failed."); } diff --git a/frontend/src/components/InviteList.vue b/frontend/src/components/InviteList.vue index 082d1c2..f5a95b9 100644 --- a/frontend/src/components/InviteList.vue +++ b/frontend/src/components/InviteList.vue @@ -2,11 +2,11 @@ import { onMounted, ref } from "vue"; import { api } from "../api/client"; import { errorMessage } from "../api/errors"; -import { useUiStore } from "../stores/ui"; import { appLink } from "../router/links"; import BaseButton from "./BaseButton.vue"; import BaseInput from "./BaseInput.vue"; import OneTimeLink from "./OneTimeLink.vue"; +import { toastOnFailure } from "../composables/useAction"; // Admin: invite one person to register while registration stays closed (#5172). // The server keeps only a hash of each invite's token, so the link is shown once, @@ -29,7 +29,6 @@ const LIFETIMES = [ { days: 30, label: "30 days" }, ]; -const ui = useUiStore(); const invites = ref([]); const loading = ref(true); @@ -42,7 +41,7 @@ const freshLink = ref(""); async function load() { error.value = ""; - try { + await toastOnFailure(async () => { invites.value = (await api.get<{ invites: Invite[] }>("/api/invites")).invites; } catch (e) { error.value = errorMessage(e, "Couldn't load invites."); @@ -75,9 +74,7 @@ async function revoke(invite: Invite) { try { const updated = await api.del(`/api/invites/${invite.id}`); invites.value = invites.value.map((i) => (i.id === updated.id ? updated : i)); - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't revoke that invite.")); - } + }, "Couldn't revoke that invite."); } function who(invite: Invite): string { diff --git a/frontend/src/composables/useAction.ts b/frontend/src/composables/useAction.ts new file mode 100644 index 0000000..3862442 --- /dev/null +++ b/frontend/src/composables/useAction.ts @@ -0,0 +1,47 @@ +import { ref } from "vue"; +import { errorMessage } from "../api/errors"; +import { useUiStore } from "../stores/ui"; + +/** + * Run `work`, and say so in a toast if it fails: the server's own reason when it + * gave one, else `fallback`. The one way a button's action reports a failure it + * has no better place to show. + */ +export async function toastOnFailure(work: () => Promise, fallback: string): Promise { + try { + await work(); + } catch (e) { + useUiStore().showToast(errorMessage(e, fallback)); + } +} + +/** A single action and whether it is running, for the button that starts it. */ +export function useAction() { + const busy = ref(false); + async function act(work: () => Promise, fallback: string): Promise { + busy.value = true; + try { + await toastOnFailure(work, fallback); + } finally { + busy.value = false; + } + } + return { busy, act }; +} + +/** + * The same for a list whose rows each carry an action: `busy` is the id of the row + * whose action is running, or "" when none is, so only that row's buttons wait. + */ +export function useRowAction() { + const busy = ref(""); + async function act(id: string, work: () => Promise, fallback: string): Promise { + busy.value = id; + try { + await toastOnFailure(work, fallback); + } finally { + busy.value = ""; + } + } + return { busy, act }; +} diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index 3a0cce4..d62dc53 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -13,6 +13,7 @@ import { errorMessage } from "../api/errors"; import { formatDateTime } from "../notes/datetime"; import { repo } from "../adapters"; import type { StorageUse } from "../adapters/repo"; +import { useAction } from "../composables/useAction"; // The signed-in person's own account (not admin): the native clients linked to it // (the desktop and Android apps sync with a device bearer token issued here), its @@ -96,7 +97,7 @@ const currentPassword = ref(""); const newPassword = ref(""); const changing = ref(false); const passwordError = ref(""); -const signingOut = ref(false); +const { busy: signingOut, act } = useAction(); function unlinkedNote(n: number): string { if (n === 0) return ""; @@ -124,16 +125,11 @@ async function signOutElsewhere() { "Sign out of every other browser and unlink every device? Each app will need to sign in again.", ); if (!ok) return; - signingOut.value = true; - try { + await act(async () => { const unlinked = await session.signOutElsewhere(); ui.showToast(`Signed out everywhere else.${unlinkedNote(unlinked)}`); await devices.load(); - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't sign out everywhere else.")); - } finally { - signingOut.value = false; - } + }, "Couldn't sign out everywhere else."); } onMounted(() => { diff --git a/frontend/src/views/SyncView.vue b/frontend/src/views/SyncView.vue index 2dc4228..d5b2ed5 100644 --- a/frontend/src/views/SyncView.vue +++ b/frontend/src/views/SyncView.vue @@ -24,6 +24,7 @@ import { } from "../desktop/bridge"; import { errorMessage } from "../api/errors"; import { formatDateTime } from "../notes/datetime"; +import { toastOnFailure } from "../composables/useAction"; // Opt-in server sync for the desktop app. Being UNLINKED is the normal resting // state, not an incomplete setup — the app is local-first and fully usable having @@ -319,7 +320,7 @@ async function disconnect() { ) { return; } - try { + await toastOnFailure(async () => { const result = await syncBridge.unlink(); status.value = result.status; linkedAs.value = ""; @@ -331,9 +332,7 @@ async function disconnect() { ? "Disconnected, and this device is signed out on the server." : "Disconnected. This device now works offline only.", ); - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't disconnect.")); - } + }, "Couldn't disconnect."); } // A background cycle finished — redraw from it, so this screen is never staler