// @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" });
});
});