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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user