// @vitest-environment jsdom /** * CellEditor — board-status / date-commit regression (the "selecting a status * in the dropdown is not reflective in the board column" bug). * * Root cause: opening a record panel mounted one CellEditor per editable * property. The DATE editor seeded its `text` state from `valueToText`, which * for an empty `{ start:null, end:null }` returns the raw JSON string — never * matching the `` format — and committed on blur with NO * no-op guard. Combined with an autofocus effect keyed on `[onDone]` (the panel * passes an inline `onDone`, so it re-ran every re-render), every refetch * re-focused → re-blurred → re-committed `{start:null,end:null}`: an infinite * PATCH storm (~1,200 identical writes in seconds) that churned the board's * grouped query so a status change never settled into the column. * * The fix (cell-editor.tsx): * 1. `dateToInput` — seed + compare the date `text` state in the SAME * `` string, so an unchanged blur is an * exact string no-op; * 2. `onDateCommit` — the no-op guard (unchanged → `onDone()`, no write); * 3. focus-on-mount effect split off the Esc handler (`[]` deps) so an inline * `onDone` can no longer re-steal focus on every re-render. * * These tests pin the contract deterministically (no network, no timer): * - `dateToInput` returns `""` (not raw JSON) for an empty date, and the * input-format string for set dates (date-only / includeTime / range); * - an empty-date editor seeds the INPUT (not the JSON), and a blur with NO * change fires ZERO commits (the storm); * - a real date change commits exactly once with `{ start: ISO, end: null }`; * - a status/select pick commits `{ optionId }` exactly once (no re-commit). */ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; type MutateArgs = { recordId: string; version: number; properties: Record; title?: string; titlePropertyId?: string }; const mutate = vi.fn<(_args: MutateArgs, _opts?: { onSuccess?: () => void; onError?: (e: Error) => void }) => void>(); // CellEditor pulls its data layer + hooks from use-database. Stub the whole // module (same approach as cell-renderer.test.tsx): pure helpers faithfully // re-implemented, hooks mocked so no query layer runs. vi.mock("@/hooks/use-database", () => { const findOption = ( options: { id: string; name: string }[] | undefined | null, ref: unknown ): { id: string; name: string } | undefined => { if (typeof ref !== "string" || ref === "" || !options) return undefined; return options.find((o) => o.id === ref) ?? options.find((o) => o.name === ref); }; const valueToText = ( value: Record | undefined | null, property?: { type?: string; config?: { options?: { id: string; name: string }[]; format?: string; currency?: string } } ): string => { if (value == null) return ""; if (typeof value === "string") return value; const v = value as Record; if (v.text != null) return String(v.text); if (v.value != null) { if (property?.config?.format === "currency" && typeof v.value === "number") { return `${property.config.currency ?? "USD"} ${v.value}`; } return String(v.value); } if (v.optionId != null && property) return findOption(property.config?.options, v.optionId)?.name ?? ""; if (Array.isArray(v.optionIds) && (v.optionIds as unknown[]).length && property) { return (v.optionIds as string[]) .map((id) => findOption(property.config?.options, id)?.name) .filter(Boolean) .join(", "); } if (v.start) return String(v.start).slice(0, 10); if (v.recordIds) return `${(v.recordIds as unknown[]).length} linked`; if (v.fileIds) return `${(v.fileIds as unknown[]).length} file(s)`; if (v.userIds) return `${(v.userIds as unknown[]).length} person(s)`; return JSON.stringify(value); }; return { findOption, valueToText, useUpdateRecord: () => ({ mutate, isPending: false }), useRelations: () => ({ data: [] }), }; }); vi.mock("@/lib/api", () => ({ apiFetch: vi.fn(), uploadFile: vi.fn() })); vi.mock("@/stores/ui-store", () => ({ toast: vi.fn() })); import { CellEditor, dateToInput } from "@/components/database/cell-editor"; import type { DatabaseProperty, RecordDto, RecordValue } from "@/hooks/use-database"; // --------------------------------------------------------------------------- // dateToInput (pure) — the seed/compare canonical string // --------------------------------------------------------------------------- const dateProp = (config: Record = {}, type: "date" | "date_range" = "date"): DatabaseProperty => ({ id: "p-date", name: "When", type, config }) as unknown as DatabaseProperty; describe("dateToInput (pure)", () => { it("empty/null date → \"\" (NOT valueToText's raw JSON — the seed mismatch)", () => { expect(dateToInput(dateProp(), { start: null, end: null })).toBe(""); expect(dateToInput(dateProp(), undefined)).toBe(""); expect(dateToInput(dateProp(), null)).toBe(""); }); it("date-only date → yyyy-mm-dd (input format), regardless of stored time/offset", () => { expect(dateToInput(dateProp(), { start: "2026-01-15T00:00:00.000Z", end: null })).toBe("2026-01-15"); }); it("includeTime → datetime-local format (yyyy-mm-ddTHH:MM)", () => { expect(dateToInput(dateProp({ includeTime: true }), { start: "2026-01-15T09:30:00.000Z", end: null })).toBe( "2026-01-15T09:30" ); }); it("date_range → \"start|end\"", () => { expect(dateToInput(dateProp({}, "date_range"), { start: "2026-01-15T00:00:00.000Z", end: "2026-01-20T00:00:00.000Z" })).toBe( "2026-01-15|2026-01-20" ); }); it("date_range with no end → \"start|\"", () => { expect(dateToInput(dateProp({}, "date_range"), { start: "2026-01-15T00:00:00.000Z", end: null })).toBe("2026-01-15|"); }); }); // --------------------------------------------------------------------------- // CellEditor (rendered) — no-op guard + one-shot commit // --------------------------------------------------------------------------- const makeRecord = (propertyId: string, value: RecordValue): RecordDto => ({ id: "rec-1", workspaceId: "ws-1", databaseId: "db-1", pageId: null, icon: null, coverUrl: null, archivedAt: null, isSubItem: false, position: "a", version: 7, createdBy: "u1", createdTime: "2026-01-01T00:00:00.000Z", lastEditedBy: null, lastEditedTime: "2026-01-01T00:00:00.000Z", properties: { [propertyId]: value } }) as unknown as RecordDto; let container: HTMLDivElement; let root: Root; beforeEach(() => { mutate.mockClear(); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { root.unmount(); container.remove(); }); function renderEditor(property: DatabaseProperty, record: RecordDto) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); let done: () => void = () => undefined; act(() => { root.render( ); }); return { container, done: () => done() }; } // React 18 delegates onBlur to the native `focusout` event (it does NOT // listen to `blur` directly — a raw `blur` event never fires the handler). const blur = (el: Element) => act(() => { (el as HTMLElement).dispatchEvent(new FocusEvent("focusout", { bubbles: true })); }); /** The single commit payload (mutate is asserted to have been called once). */ const firstCall = (): MutateArgs => { expect(mutate.mock.calls.length, "mutate was not called").toBeGreaterThan(0); return mutate.mock.calls[0]![0]; }; describe("CellEditor — date no-op guard (the PATCH storm)", () => { it("an empty date seeds the INPUT (\"\"), not valueToText's raw JSON", () => { renderEditor(dateProp(), makeRecord("p-date", { start: null, end: null })); const input = container.querySelector("input[type=date]"); expect(input, "expected a date input to be mounted").not.toBeNull(); // The old bug: valueToText({start:null,end:null}) = '{"start":null,"end":null}'. expect(input?.getAttribute("value") ?? (input as HTMLInputElement).value).toBe(""); expect(mutate).not.toHaveBeenCalled(); }); it("a blur with NO change fires ZERO commits (no {start:null,end:null} write)", () => { renderEditor(dateProp(), makeRecord("p-date", { start: null, end: null })); const input = container.querySelector("input") as HTMLInputElement; blur(input); expect(mutate, "unchanged blur must not commit").not.toHaveBeenCalled(); }); it("a real date change commits exactly once with { start: ISO, end: null }", () => { renderEditor(dateProp(), makeRecord("p-date", { start: null, end: null })); const input = container.querySelector("input") as HTMLInputElement; act(() => { const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; setter.call(input, "2026-01-15"); input.dispatchEvent(new Event("input", { bubbles: true })); }); blur(input); expect(mutate).toHaveBeenCalledTimes(1); const call = firstCall(); expect(call.recordId).toBe("rec-1"); expect(call.version).toBe(7); expect(call.properties["p-date"]).toEqual({ start: new Date("2026-01-15").toISOString(), end: null }); }); it("an unchanged SET date seeds the input and blurs as a no-op", () => { renderEditor(dateProp(), makeRecord("p-date", { start: "2026-03-02T00:00:00.000Z", end: null })); const input = container.querySelector("input") as HTMLInputElement; expect(input.value).toBe("2026-03-02"); blur(input); expect(mutate, "unchanged set-date blur must not commit").not.toHaveBeenCalled(); }); it("includeTime renders a datetime-local input (seeded to the minute)", () => { renderEditor( dateProp({ includeTime: true }), makeRecord("p-date", { start: "2026-01-15T09:30:00.000Z", end: null }) ); const input = container.querySelector("input") as HTMLInputElement; expect(input.type).toBe("datetime-local"); expect(input.value).toBe("2026-01-15T09:30"); }); }); describe("CellEditor — status/select one-shot commit (the board column)", () => { const statusProp = (): DatabaseProperty => ({ id: "p-status", name: "Status", type: "status", config: { options: [ { id: "opt-to-do", name: "To Do", color: "default" }, { id: "opt-in-progress", name: "In Progress", color: "orange" }, { id: "opt-done", name: "Done", color: "green" } ] } }) as unknown as DatabaseProperty; it("is mounted with the dropdown already open (options visible)", () => { renderEditor(statusProp(), makeRecord("p-status", { optionId: "opt-in-progress" })); expect(container.querySelector("ul[role=listbox]"), "status dropdown should be open on mount").not.toBeNull(); expect(mutate).not.toHaveBeenCalled(); }); it("picking a new option commits { optionId } EXACTLY ONCE (no re-commit storm)", () => { renderEditor(statusProp(), makeRecord("p-status", { optionId: "opt-in-progress" })); const option = [...container.querySelectorAll("ul[role=listbox] li button")].find( (b) => b.textContent?.includes("Done") ); expect(option, "expected a 'Done' option in the open listbox").not.toBeUndefined(); act(() => { option!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); }); expect(mutate, "one click = one commit").toHaveBeenCalledTimes(1); const call = firstCall(); expect(call.properties["p-status"]).toEqual({ optionId: "opt-done" }); // The listbox closes after the pick (no second commit surface left open). expect(container.querySelector("ul[role=listbox]")).toBeNull(); expect(mutate).toHaveBeenCalledTimes(1); }); it("re-picking the CURRENT option still commits once with that optionId (explicit user action)", () => { renderEditor(statusProp(), makeRecord("p-status", { optionId: "opt-to-do" })); const option = [...container.querySelectorAll("ul[role=listbox] li button")].find( (b) => b.textContent?.includes("To Do") ); act(() => { option!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); }); expect(mutate).toHaveBeenCalledTimes(1); expect(firstCall().properties["p-status"]).toEqual({ optionId: "opt-to-do" }); }); });