diff --git a/frontend/src/components/Scheduler.dom.test.tsx b/frontend/src/components/Scheduler.dom.test.tsx index 89d34d87..c8d63ad8 100644 --- a/frontend/src/components/Scheduler.dom.test.tsx +++ b/frontend/src/components/Scheduler.dom.test.tsx @@ -80,6 +80,11 @@ afterEach(() => { const intervalBox = () => screen.queryByRole("spinbutton", { name: "" }); const batchBox = () => screen.getByLabelText("scheduler.maintenance.batchLabel"); +// The four tests below are CHARACTERIZATION, not regression guards for E27 S2: the old JobRow's +// re-sync effect was already guarded by `if (!editing)` and the mirror only rendered while editing, +// so every one of them passes against the pre-sweep code too. They are here because the editor had +// no tests at all. The editor that genuinely lost the user's typing to a poll is the maintenance +// batch box below — its old effect had no guard — and that one has its own test. describe("Scheduler · the job interval editor", () => { it("opens on the current interval and commits a new one", async () => { const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never); @@ -129,9 +134,77 @@ describe("Scheduler · the job interval editor", () => { fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); expect(intervalBox()).toHaveProperty("value", "90"); }); + + it("refuses a value outside the range instead of silently dropping it", async () => { + const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never); + await mount(); + + fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); + fireEvent.change(intervalBox()!, { target: { value: "99999" } }); + fireEvent.keyDown(intervalBox()!, { key: "Enter" }); + + expect(update).not.toHaveBeenCalled(); + // Still open, still showing what was typed, and saying why. + expect(intervalBox()).toHaveProperty("value", "99999"); + expect(intervalBox()!.getAttribute("aria-invalid")).toBe("true"); + expect(screen.getByRole("alert").textContent).toBe("scheduler.range"); + }); + + it("hands focus back to the trigger when the editor closes", async () => { + await mount(); + const trigger = screen.getByRole("button", { name: /scheduler.everyMin/ }); + fireEvent.click(trigger); + expect(document.activeElement).toBe(intervalBox()); + + fireEvent.keyDown(intervalBox()!, { key: "Escape" }); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: /scheduler.everyMin/ }), + ), + ); + }); }); describe("Scheduler · the maintenance batch editor", () => { + it("keeps what you are typing when a poll changes the batch underneath", async () => { + // THE regression this editor needed: its pre-E27 effect re-synced on every + // `revalidate_batch` change with no editing guard, so a poll landing mid-keystroke replaced + // what the admin had typed. + await mount(); + fireEvent.change(batchBox(), { target: { value: "9000" } }); + + // The poll carries a new interval TOO, and the interval label follows the server unconditionally + // — so waiting for it is how this test knows the new status has actually been rendered. Without + // that anchor the assertion below passes on a page that simply has not polled yet. + await poll( + status({ + jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }], + maintenance: { ...status().maintenance, revalidate_batch: 12000 }, + }), + ); + await waitFor(() => + expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(), + ); + + expect(batchBox()).toHaveProperty("value", "9000"); + }); + + it("refuses a value outside min..max instead of silently dropping it", async () => { + const save = vi.spyOn(api, "updateMaintenanceBatch").mockResolvedValue({ + revalidate_batch: 5000, + }); + await mount(); + + fireEvent.change(batchBox(), { target: { value: "10" } }); // below m.min = 1000 + fireEvent.keyDown(batchBox(), { key: "Enter" }); + + expect(save).not.toHaveBeenCalled(); + expect(batchBox()).toHaveProperty("value", "10"); + expect(batchBox().getAttribute("aria-invalid")).toBe("true"); + expect(screen.getByRole("alert").textContent).toBe("scheduler.range"); + expect(screen.getByRole("button", { name: "common.save" })).toHaveProperty("disabled", true); + }); + it("is not dirty until edited, and stops being dirty once saved", async () => { const save = vi .spyOn(api, "updateMaintenanceBatch") diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 5d02ee29..bb3659f7 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import i18n from "../i18n"; @@ -143,15 +143,29 @@ function JobRow({ // nothing for an effect to re-sync: `null` means "follow the job". The poll behind this page // refreshes every few seconds, which is exactly when a mirror-plus-effect would have shown the // previous value for a frame. + const rangeId = useId(); const [draft, setDraft] = useState(null); const editing = draft !== null; const val = draft ?? String(job.interval_minutes); const next = secsUntil(job.next_run); + const m = parseInt(val, 10); + const valid = Number.isFinite(m) && m >= 1 && m <= 1440; + // Every way out of the editor unmounts the focused control, so hand focus back to the trigger that + // opened it — otherwise Escape drops a keyboard user onto and the next Tab restarts at the + // top of the page. Same pattern as IssueComposer's review buttons. + const triggerRef = useRef(null); + function close() { + setDraft(null); + queueMicrotask(() => triggerRef.current?.focus()); + } function save() { - const m = parseInt(val, 10); - if (Number.isFinite(m) && m >= 1 && m <= 1440 && m !== job.interval_minutes) onSave(m); - setDraft(null); + // A value outside the range is REFUSED, not swallowed: the draft stays so the user can see and + // correct what they typed, and `aria-invalid` + the hint say why. Dropping it silently left the + // field showing the server's number with no clue whether the save had happened. + if (!valid) return; + if (m !== job.interval_minutes) onSave(m); + close(); } return ( @@ -172,33 +186,45 @@ function JobRow({ max={1440} value={val} autoFocus + aria-invalid={!valid} + aria-errormessage={valid ? undefined : rangeId} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") save(); - if (e.key === "Escape") setDraft(null); + if (e.key === "Escape") close(); }} - className="w-16 bg-surface border border-border rounded-sm px-1.5 py-0.5 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40" + className={`w-16 bg-surface border rounded-sm px-1.5 py-0.5 text-xs focus:outline-hidden focus:ring-2 ${ + valid + ? "border-border focus:ring-accent/40" + : "border-danger focus:ring-danger/40" + }`} /> {t("scheduler.minutes")} + {!valid && ( + + {t("scheduler.range", { min: 1, max: 1440 })} + + )} ) : ( + {!valid && ( + + )}
{t("scheduler.maintenance.batchNote", { default: formatNumber(m.revalidate_batch_default), diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index bb17b854..61f5e184 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -23,6 +23,7 @@ "quotaRemaining": "{{formatted}} left", "quotaNote": "Shared YouTube Data API budget; resets at midnight US Pacific. Backfill yields to leave headroom for interactive use.", "minutes": "min", + "range": "Must be between {{min}} and {{max}}.", "editInterval": "Click to change how often this runs", "intervalFailed": "Couldn't change the interval", "runNow": "Run now", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 9de4ee15..9b66c95e 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -23,6 +23,7 @@ "quotaRemaining": "{{formatted}} maradt", "quotaNote": "Közös YouTube Data API-keret; éjfélkor (US Pacific) nullázódik. A backfill hátrébb sorol, hogy maradjon hely az interaktív használatnak.", "minutes": "perc", + "range": "{{min}} és {{max}} között lehet.", "editInterval": "Kattints a gyakoriság módosításához", "intervalFailed": "Nem sikerült módosítani a gyakoriságot", "runNow": "Futtatás most",