diff --git a/frontend/src/components/DiagnosticsSettings.dom.test.tsx b/frontend/src/components/DiagnosticsSettings.dom.test.tsx index 48e5b165..f4531b10 100644 --- a/frontend/src/components/DiagnosticsSettings.dom.test.tsx +++ b/frontend/src/components/DiagnosticsSettings.dom.test.tsx @@ -63,6 +63,7 @@ function mount(me: Partial) { , ); + return qc; } const levelButton = (name: string) => @@ -113,6 +114,58 @@ describe("DiagnosticsSettings", () => { await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO")); }); + it("the focus goes back to the level picker when the box it was in goes away", async () => { + mount({}); + fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG")); + const cancel = screen.getByRole("button", { name: "settings.diagnostics.consent.cancel" }); + cancel.focus(); + fireEvent.click(cancel); + expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.INFO")); + + fireEvent.click(levelButton("settings.diagnostics.levels.TRACE")); + const confirm = screen.getByTestId("diagnostics-consent-confirm"); + confirm.focus(); + fireEvent.click(confirm); + await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy()); + expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.TRACE")); + + const revert = screen.getByRole("button", { name: "settings.diagnostics.revert" }); + revert.focus(); + fireEvent.click(revert); + await waitFor(() => expect(screen.queryByTestId("diagnostics-status")).toBeNull()); + expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.INFO")); + }); + + it("the ticking status line is not a live region", async () => { + const until = new Date(Date.now() + 3_600_000).toISOString(); + mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } }); + const status = screen.getByTestId("diagnostics-status"); + expect(status.closest('[role="status"], [aria-live]')).toBeNull(); + }); + + it("a `me` fetch that left before the change cannot put the old level back", async () => { + const qc = mount({}); + let answerStale: (me: unknown) => void = () => {}; + const stale = qc + .fetchQuery({ + queryKey: qk.me(), + queryFn: () => new Promise((resolve) => (answerStale = resolve)), + }) + .catch(() => null); + fireEvent.click(levelButton("settings.diagnostics.levels.TRACE")); + fireEvent.click(screen.getByTestId("diagnostics-consent-confirm")); + await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy()); + answerStale({ + id: 1, + is_demo: false, + preferences: {}, + diagnostics: { level: "INFO", until: null, retention_days: 7 }, + }); + await stale; + expect(qc.getQueryData(qk.me())?.diagnostics.level).toBe("TRACE"); + expect(screen.getByTestId("diagnostics-status")).toBeTruthy(); + }); + it("a lapsed level shows as the base level", () => { const until = new Date(Date.now() - 60_000).toISOString(); mount({ diagnostics: { level: "TRACE", until, retention_days: 7 } }); diff --git a/frontend/src/components/DiagnosticsSettings.tsx b/frontend/src/components/DiagnosticsSettings.tsx index 8b0380fd..c1a67440 100644 --- a/frontend/src/components/DiagnosticsSettings.tsx +++ b/frontend/src/components/DiagnosticsSettings.tsx @@ -1,11 +1,10 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { api, type DiagnosticsLevel, type Me } from "../lib/api"; +import { api, type DiagnosticsLevel } from "../lib/api"; import { levelInForce } from "../lib/diagnostics"; -import { qk } from "../lib/queryKeys"; -import { useMe } from "../lib/useMe"; +import { updateMeCache, useMe } from "../lib/useMe"; import { Button } from "./ui/Button"; import InlineNotice from "./ui/InlineNotice"; import SegmentedToggle from "./ui/SegmentedToggle"; @@ -42,17 +41,42 @@ export default function DiagnosticsSettings() { const [pending, setPending] = useState(null); const remaining = useRemaining(current === "INFO" ? null : (state?.until ?? null)); + // The consent box and the status line each go away with the button that closed them — the one + // holding the focus. Put it back on the level picker (its chosen option) rather than let it fall + // to , where the next Tab starts over from the top of the page. Only when it did fall: a + // user who moved on while the request was out keeps their place. + // + // Watched until that button is actually GONE, not for one render: a saved level arrives as two + // updates (the `me` cache, then the closed box), and the first render still has the button in the + // page — measured in Chrome, where a one-shot check spent itself there and the focus then fell. + const pickerRef = useRef(null); + const closedFrom = useRef(null); + useEffect(() => { + const from = closedFrom.current; + if (!from || from.isConnected) return; + closedFrom.current = null; + if (document.activeElement && document.activeElement !== document.body) return; + pickerRef.current?.querySelector('[aria-pressed="true"]')?.focus(); + }); + const watchFocus = () => { + closedFrom.current = document.activeElement; + }; + const change = useMutation({ mutationFn: api.setDiagnosticsLevel, meta: { failTitle: "settings.diagnostics.failed" }, onSuccess: (result) => { - qc.setQueryData(qk.me(), (prev) => - prev ? { ...prev, diagnostics: result.diagnostics } : prev, - ); + watchFocus(); + updateMeCache(qc, (prev) => ({ ...prev, diagnostics: result.diagnostics })); setPending(null); }, }); + const cancel = () => { + watchFocus(); + setPending(null); + }; + const choose = (level: DiagnosticsLevel) => { if (level === "INFO") { setPending(null); @@ -74,13 +98,15 @@ export default function DiagnosticsSettings() { label={t("settings.diagnostics.level")} hint={t("settings.diagnostics.levelHint")} > - ({ value: level, label: levelName(level) }))} - /> +
+ ({ value: level, label: levelName(level) }))} + /> +
)} @@ -111,7 +137,7 @@ export default function DiagnosticsSettings() {
-