fix(frontend): the scheduler's two number editors stop swallowing what you typed
Both refused an out-of-range value by discarding it: the entry vanished, the box snapped back to the server's number, and nothing said whether the save had happened, been rejected, or been ignored. That is the house rule about honest states, applied to a write instead of a read. They now keep the draft, mark the field aria-invalid and name the range (HU+EN), and the Save control is disabled rather than silently inert. The interval editor also dropped a keyboard user's focus on every exit — Escape, Cancel and Save all unmount the focused input — so it hands focus back to the pencil that opened it, the pattern IssueComposer already uses. Tests: the four interval-editor cases added in S2 are relabelled CHARACTERIZATION, because they pass against the pre-sweep code too (the old effect was already guarded by `editing`). The editor that genuinely lost typing to a poll is the maintenance batch box, whose old effect had no guard at all, and it had no such test — it does now, anchored on a value that follows the server so it cannot pass on a page that simply has not polled yet.
This commit is contained in:
@@ -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<string | null>(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 <body> and the next Tab restarts at the
|
||||
// top of the page. Same pattern as IssueComposer's review buttons.
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(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"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
|
||||
<FocusSafeButton
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
disabled={saving || !valid}
|
||||
className="text-success hover:opacity-80 disabled:opacity-50"
|
||||
aria-label={t("common.save")}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</FocusSafeButton>
|
||||
<button
|
||||
onClick={() => setDraft(null)}
|
||||
onClick={close}
|
||||
className="text-muted hover:text-fg"
|
||||
aria-label={t("common.cancel")}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{!valid && (
|
||||
<span id={rangeId} role="alert" className="text-2xs text-danger">
|
||||
{t("scheduler.range", { min: 1, max: 1440 })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip hint={t("scheduler.editInterval")}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setDraft(String(job.interval_minutes))}
|
||||
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
|
||||
>
|
||||
@@ -258,15 +284,22 @@ function MaintenanceSettings({
|
||||
saving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Same shape as the job row above: a draft while editing, the server's value otherwise.
|
||||
// Same shape as the job row above: a draft while editing, the server's value otherwise. This is
|
||||
// the editor a poll really could overwrite mid-keystroke before E27 S2 — its old effect had no
|
||||
// `editing` guard at all.
|
||||
const rangeId = useId();
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const val = draft ?? String(m.revalidate_batch);
|
||||
const n = parseInt(val, 10);
|
||||
const valid = Number.isFinite(n) && n >= m.min && n <= m.max;
|
||||
|
||||
function save() {
|
||||
const n = parseInt(val, 10);
|
||||
if (Number.isFinite(n) && n >= m.min && n <= m.max && n !== m.revalidate_batch) onSave(n);
|
||||
// Dropping the draft is what lets the box follow the server again once the save lands — and what
|
||||
// clears a value that failed validation instead of leaving it stuck in the field.
|
||||
// Out of range is REFUSED, not swallowed: keep the draft so the typed value stays visible and
|
||||
// correctable, and say why. Dropping it left the field showing the server's number with nothing
|
||||
// to tell the user whether the save had happened, been rejected, or been ignored.
|
||||
if (!valid) return;
|
||||
if (n !== m.revalidate_batch) onSave(n);
|
||||
// Dropping the draft is what lets the box follow the server again once the save lands.
|
||||
setDraft(null);
|
||||
}
|
||||
const dirty = String(m.revalidate_batch) !== val;
|
||||
@@ -288,14 +321,26 @@ function MaintenanceSettings({
|
||||
step={1000}
|
||||
value={val}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && save()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save();
|
||||
if (e.key === "Escape") setDraft(null);
|
||||
}}
|
||||
aria-label={t("scheduler.maintenance.batchLabel")}
|
||||
className="w-28 bg-surface border border-border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
aria-invalid={!valid}
|
||||
aria-errormessage={valid ? undefined : rangeId}
|
||||
className={`w-28 bg-surface border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 ${
|
||||
valid ? "border-border focus:ring-accent/40" : "border-danger focus:ring-danger/40"
|
||||
}`}
|
||||
/>
|
||||
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving}>
|
||||
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving || !valid}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{!valid && (
|
||||
<div id={rangeId} role="alert" className="text-2xs text-danger mt-1.5">
|
||||
{t("scheduler.range", { min: m.min, max: m.max })}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-2xs text-muted mt-1.5">
|
||||
{t("scheduler.maintenance.batchNote", {
|
||||
default: formatNumber(m.revalidate_batch_default),
|
||||
|
||||
Reference in New Issue
Block a user