mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(settings): report the outcome of quiet settings actions
Adding or removing a passkey or a security key only redrew the list. Disconnecting an integration and revoking a share link ran from a dialog that had already closed, so a refused request left everything as it was without a word. The injection-site exclusions, the local OCR switch and the preventive-care reminder switches saved, or did not, in silence. Each now reports through the outcome toast and, where the card has no inline error line, the shared error toast with its error id.
This commit is contained in:
1 parent
b6e3b47bec
commit
2bb3783b14
16 files changed
+302
-30
No files matched your search
@@ -14,6 +14,7 @@
|
||||
* View / sort / order persist client-side via `useModuleListPrefs("labs")`.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import {
|
||||
ArrowDownUp,
|
||||
FlaskConical,
|
||||
@@ -29,6 +30,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
|
||||
@@ -139,7 +141,16 @@ export function LabsSettings() {
|
||||
<Switch
|
||||
checked={localOcr.data?.labsLocalOcrEnabled ?? false}
|
||||
disabled={localOcr.isLoading || updateLocalOcr.isPending}
|
||||
onCheckedChange={(checked) => updateLocalOcr.mutate(checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
updateLocalOcr.mutate(checked, {
|
||||
onSuccess: () =>
|
||||
toastWrittenOutcome("success", t("settings.saved")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.savingError"), {
|
||||
cause: failure,
|
||||
}),
|
||||
})
|
||||
}
|
||||
aria-label={t("labs.localOcr.heading")}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* reminder PATCH route.
|
||||
*/
|
||||
import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from "@/hooks/use-measurement-reminders";
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
function resolveLabel(
|
||||
reminder: MeasurementReminder,
|
||||
@@ -127,10 +129,20 @@ export function VorsorgeSettings() {
|
||||
<Switch
|
||||
checked={reminder.enabled}
|
||||
onCheckedChange={(next) =>
|
||||
update.mutate({
|
||||
id: reminder.id,
|
||||
body: { enabled: next },
|
||||
})
|
||||
update.mutate(
|
||||
{ id: reminder.id, body: { enabled: next } },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
t("settings.saved"),
|
||||
),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.savingError"), {
|
||||
cause: failure,
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
disabled={update.isPending}
|
||||
// Label each row's Switch by its own name span so SR
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Settings actions say how they went.
|
||||
*
|
||||
* Each action below finished in silence on at least one side. Adding or
|
||||
* removing a passkey or a security key only redrew the list; disconnecting an
|
||||
* integration and revoking a share link fired from a dialog that had already
|
||||
* closed, so a refused request left everything as it was and said nothing;
|
||||
* the injection-site exclusions, the local-OCR switch and the preventive-care
|
||||
* reminder switches saved, or did not, without a word.
|
||||
*
|
||||
* They use the pair the rest of the app reports a write with —
|
||||
* `toastWrittenOutcome("success", …)`, the outcome module's one door to the
|
||||
* success toast, and `showErrorToast` (which carries the error id of a server
|
||||
* failure). The passkey and security-key cards keep their inline error line,
|
||||
* which also carries the step-up dialog's outcome, and gain the success half.
|
||||
*
|
||||
* Mutation callbacks only run after an interaction, so the wiring is read
|
||||
* from the source: per action, the success callback raises the success
|
||||
* outcome and, where the card has no inline error line, the error callback raises
|
||||
* `showErrorToast`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
interface Site {
|
||||
file: string;
|
||||
/** `const <name> = useMutation({…})`, or `<name>.mutate(…, {…})`. */
|
||||
name: string;
|
||||
shape: "definition" | "call";
|
||||
needsErrorToast: boolean;
|
||||
}
|
||||
|
||||
const SITES: Site[] = [
|
||||
{
|
||||
file: "src/components/settings/security-section/passkey-list-section.tsx",
|
||||
name: "add",
|
||||
shape: "definition",
|
||||
needsErrorToast: false,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/security-section/passkey-list-section.tsx",
|
||||
name: "remove",
|
||||
shape: "definition",
|
||||
needsErrorToast: false,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/security-section/security-keys-card.tsx",
|
||||
name: "add",
|
||||
shape: "definition",
|
||||
needsErrorToast: false,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/security-section/security-keys-card.tsx",
|
||||
name: "remove",
|
||||
shape: "definition",
|
||||
needsErrorToast: false,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/integrations/oauth-integration-card.tsx",
|
||||
name: "disconnect",
|
||||
shape: "definition",
|
||||
needsErrorToast: true,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/sharing-section.tsx",
|
||||
name: "revokeMutation",
|
||||
shape: "definition",
|
||||
needsErrorToast: true,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/injection-sites-card.tsx",
|
||||
name: "mutation",
|
||||
shape: "definition",
|
||||
needsErrorToast: true,
|
||||
},
|
||||
{
|
||||
file: "src/components/labs/labs-settings.tsx",
|
||||
name: "updateLocalOcr",
|
||||
shape: "call",
|
||||
needsErrorToast: true,
|
||||
},
|
||||
{
|
||||
file: "src/components/measurement-reminders/vorsorge-settings.tsx",
|
||||
name: "update",
|
||||
shape: "call",
|
||||
needsErrorToast: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** The `{ onSuccess, onError, … }` literal for a site, or null. */
|
||||
function callbacks(site: Site): ts.ObjectLiteralExpression | null {
|
||||
const text = readFileSync(join(process.cwd(), site.file), "utf8");
|
||||
const sf = ts.createSourceFile(site.file, text, ts.ScriptTarget.Latest, true);
|
||||
let found: ts.ObjectLiteralExpression | null = null;
|
||||
const visit = (node: ts.Node) => {
|
||||
if (found) return;
|
||||
if (
|
||||
site.shape === "definition" &&
|
||||
ts.isVariableDeclaration(node) &&
|
||||
node.name.getText(sf) === site.name &&
|
||||
node.initializer &&
|
||||
ts.isCallExpression(node.initializer) &&
|
||||
node.initializer.expression.getText(sf) === "useMutation"
|
||||
) {
|
||||
const arg = node.initializer.arguments[0];
|
||||
if (arg && ts.isObjectLiteralExpression(arg)) found = arg;
|
||||
}
|
||||
if (
|
||||
site.shape === "call" &&
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.getText(sf) === `${site.name}.mutate`
|
||||
) {
|
||||
const arg = node.arguments[1];
|
||||
if (arg && ts.isObjectLiteralExpression(arg)) found = arg;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return found;
|
||||
}
|
||||
|
||||
function property(obj: ts.ObjectLiteralExpression, name: string): string {
|
||||
const prop = obj.properties.find((p) => p.name && p.name.getText() === name);
|
||||
return prop ? prop.getText() : "";
|
||||
}
|
||||
|
||||
describe("settings actions report their outcome", () => {
|
||||
for (const site of SITES) {
|
||||
describe(`${site.file} → ${site.name}`, () => {
|
||||
const obj = callbacks(site);
|
||||
|
||||
it("is found", () => {
|
||||
expect(obj, "the mutation options were not found").not.toBeNull();
|
||||
});
|
||||
|
||||
it("raises a success toast", () => {
|
||||
expect(property(obj!, "onSuccess")).toMatch(
|
||||
/toastWrittenOutcome\(\s*"success"/,
|
||||
);
|
||||
});
|
||||
|
||||
if (site.needsErrorToast) {
|
||||
it("raises an error toast through showErrorToast", () => {
|
||||
expect(property(obj!, "onError")).toContain("showErrorToast(");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Syringe } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import { apiFetchRaw, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
/**
|
||||
* v1.8.5 — Settings → global injection-site exclusion.
|
||||
@@ -56,7 +58,10 @@ export function InjectionSitesCard({
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.injectionSitePrefs(),
|
||||
});
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
},
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.savingError"), { cause: failure }),
|
||||
});
|
||||
|
||||
function toggle(site: InjectionSiteKey, checked: boolean) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
|
||||
import { loadDocument } from "@/lib/navigation/load-document";
|
||||
@@ -298,7 +300,20 @@ export function OAuthIntegrationCardView({
|
||||
mutationFn: async () => {
|
||||
await apiPost(`/api/${provider}/disconnect`);
|
||||
},
|
||||
onSuccess: invalidateStatus,
|
||||
onSuccess: () => {
|
||||
invalidateStatus();
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
t("settings.integrationDisconnected", { provider: providerName }),
|
||||
);
|
||||
},
|
||||
// The confirmation dialog has closed by now; without this a refused
|
||||
// disconnect left the card connected and said nothing.
|
||||
onError: (failure) =>
|
||||
showErrorToast(
|
||||
t("settings.integrationDisconnectFailed", { provider: providerName }),
|
||||
{ cause: failure },
|
||||
),
|
||||
});
|
||||
|
||||
const resume = useMutation({
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -142,6 +143,7 @@ export function PasskeyListSection({
|
||||
setReauthSecret("");
|
||||
setReauthMethod("password");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
toastWrittenOutcome("success", t("settings.passkeyAddedToast"));
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -178,6 +180,7 @@ export function PasskeyListSection({
|
||||
onSuccess: () => {
|
||||
setMsg(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
toastWrittenOutcome("success", t("settings.passkeyRemovedToast"));
|
||||
},
|
||||
// A refusal the dialog could not take up keeps its own sentence. The
|
||||
// server's prose says "second-factor", which is wrong for the passkey-only
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -133,6 +134,7 @@ export function SecurityKeysCard({
|
||||
setReauthOpen(false);
|
||||
setReauthError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
toastWrittenOutcome("success", t("settings.security.keys.addedToast"));
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err) && nothingToReprove(err)) {
|
||||
@@ -192,6 +194,7 @@ export function SecurityKeysCard({
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
toastWrittenOutcome("success", t("settings.security.keys.removedToast"));
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { FileText, KeyRound, Link2, List, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -53,6 +54,7 @@ import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
export function SharingSection() {
|
||||
// v1.18.7 — the visible heading + subtitle come from the shared settings
|
||||
@@ -127,7 +129,12 @@ function ShareLinksCard() {
|
||||
mutationFn: (id: string) => apiDelete(`/api/share-links/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
|
||||
toastWrittenOutcome("success", t("settings.sharing.revokedToast"));
|
||||
},
|
||||
// A refused revoke leaves the link working; that has to be said, since
|
||||
// the dialog that asked has already closed.
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.sharing.revokeFailed"), { cause: failure }),
|
||||
});
|
||||
|
||||
const activeLinks = useMemo(
|
||||
|
||||
Reference in new issue
Block a user