From 7494b79a1c3239fb100c5b49be1613dd7b0a983f Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sun, 19 Jul 2026 15:44:03 -0500 Subject: [PATCH 1/3] fix(custom-fields): stop boolean checkbox reverting after save Sync effect tracked saving() as well as props.value, so a finished save re-ran it with the stale prop and clobbered the just-committed value: the checkbox flicked back unchecked until a page reload. Effect now fires only when props.value changes. Failed saves also left the DOM checkbox out of sync with the committed value; onChange now snaps it back and lets a successful save re-check it via signal. --- .../src/components/InlineEditCell.test.tsx | 57 +++++++++++++++++++ frontend/src/components/InlineEditCell.tsx | 27 ++++++--- 2 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/InlineEditCell.test.tsx diff --git a/frontend/src/components/InlineEditCell.test.tsx b/frontend/src/components/InlineEditCell.test.tsx new file mode 100644 index 00000000..90e328f3 --- /dev/null +++ b/frontend/src/components/InlineEditCell.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, fireEvent } from "@solidjs/testing-library"; +import { createSignal } from "solid-js"; + +vi.mock("./Toast", () => ({ showToast: vi.fn() })); + +import InlineEditCell from "./InlineEditCell"; + +function flush() { + return new Promise((r) => setTimeout(r, 0)); +} + +describe("InlineEditCell boolean", () => { + it("keeps the new checked state after save resolves even if props.value is stale", async () => { + // Parent does not refetch after save, so props.value stays at the old value. + const onSave = vi.fn().mockResolvedValue(undefined); + const { container } = render(() => ( + + )); + const box = container.querySelector("input[type=checkbox]") as HTMLInputElement; + expect(box.checked).toBe(false); + + fireEvent.click(box); + await flush(); + + expect(onSave).toHaveBeenCalledWith("true"); + // Bug: setSaving(false) re-ran the sync effect with the stale prop and + // reverted the checkbox to unchecked. + expect(box.checked).toBe(true); + }); + + it("still syncs when props.value actually changes from outside", async () => { + const [value, setValue] = createSignal("false"); + const { container } = render(() => ( + + )); + const box = container.querySelector("input[type=checkbox]") as HTMLInputElement; + expect(box.checked).toBe(false); + + setValue("true"); + await flush(); + expect(box.checked).toBe(true); + }); + + it("reverts to the previous value when save fails", async () => { + const onSave = vi.fn().mockRejectedValue(new Error("boom")); + const { container } = render(() => ( + + )); + const box = container.querySelector("input[type=checkbox]") as HTMLInputElement; + + fireEvent.click(box); + await flush(); + + expect(box.checked).toBe(false); + }); +}); diff --git a/frontend/src/components/InlineEditCell.tsx b/frontend/src/components/InlineEditCell.tsx index cb663081..3ebab586 100644 --- a/frontend/src/components/InlineEditCell.tsx +++ b/frontend/src/components/InlineEditCell.tsx @@ -1,4 +1,4 @@ -import { createSignal, Show, For, createEffect } from "solid-js"; +import { createSignal, Show, For, createEffect, on, untrack } from "solid-js"; import { showToast } from "./Toast"; import { getErrorMessage } from "../utils/errors"; @@ -16,11 +16,15 @@ export default function InlineEditCell(props: Props) { const [saving, setSaving] = createSignal(false); // Sync from props when external data changes (e.g. refetch), but never - // stomp on an in-flight save that has not yet resolved. - createEffect(() => { - const incoming = props.value; - if (!saving()) setLocalValue(incoming); - }); + // stomp on an in-flight save that has not yet resolved. Track only + // props.value: tracking saving() too would re-run this when a save + // finishes and clobber the just-committed value with a stale prop. + createEffect(on( + () => props.value, + (incoming) => { + if (!untrack(saving)) setLocalValue(incoming); + }, + )); // Confirm-then-commit: keep the old value visible (with a spinner) until the // save resolves. On success show the new value; on failure keep the old value @@ -71,7 +75,16 @@ export default function InlineEditCell(props: Props) { type="checkbox" checked={localValue() === "true"} disabled={saving()} - onChange={(e) => void save(e.currentTarget.checked ? "true" : "false")} + onChange={(e) => { + const el = e.currentTarget; + const want = el.checked ? "true" : "false"; + // Confirm-then-commit: snap the DOM back to the committed value; + // a successful save flips localValue and re-checks it. Without + // this a failed save leaves the DOM out of sync, since the + // unchanged signal never rewrites the checked attribute. + el.checked = localValue() === "true"; + void save(want); + }} class="cursor-pointer disabled:opacity-50" /> From 41aa1b3f4c0c66bff2f9022bd78fe2c45a988244 Mon Sep 17 00:00:00 2001 From: Kumar Challa Date: Sun, 19 Jul 2026 16:22:22 -0500 Subject: [PATCH 2/3] fix(custom-fields): stop inline save spinner shifting the control Spinner rendered inline next to the checkbox/select, so in the right-aligned cell it pushed the control ~18px left while a save was in flight and snapped back on completion. Spinner is now absolutely positioned outside the layout flow; controls stay put during saves. --- frontend/src/components/InlineEditCell.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/InlineEditCell.tsx b/frontend/src/components/InlineEditCell.tsx index 3ebab586..2477a082 100644 --- a/frontend/src/components/InlineEditCell.tsx +++ b/frontend/src/components/InlineEditCell.tsx @@ -60,9 +60,12 @@ export default function InlineEditCell(props: Props) { if (e.key === "Escape") setEditing(false); } + // Absolutely positioned so appearing/disappearing never shifts the layout: + // in a right-aligned cell an inline spinner pushes the control left for the + // duration of the save, then snaps back. const Spinner = () => ( ); @@ -70,7 +73,7 @@ export default function InlineEditCell(props: Props) { // Boolean: simple checkbox if (props.columnType === "boolean") { return ( - + + - ); } @@ -98,7 +92,7 @@ export default function InlineEditCell(props: Props) { // Dropdown: select if (props.columnType === "dropdown") { return ( - + - ); } @@ -125,12 +119,11 @@ export default function InlineEditCell(props: Props) { fallback={ {localValue() || "-"} - } >