1
2
32(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
33import { act } from "react";
34import { createRoot, type Root } from "react-dom/client";
35import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
36import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
37
38type MutateArgs = { recordId: string; version: number; properties: Record<string, unknown>; title?: string; titlePropertyId?: string };
39const mutate = vi.fn<(_args: MutateArgs, _opts?: { onSuccess?: () => void; onError?: (e: Error) => void }) => void>();
40
41
42
43
44vi.mock("@/hooks/use-database", () => {
45 const findOption = (
46 options: { id: string; name: string }[] | undefined | null,
47 ref: unknown
48 ): { id: string; name: string } | undefined => {
49 if (typeof ref !== "string" || ref === "" || !options) return undefined;
50 return options.find((o) => o.id === ref) ?? options.find((o) => o.name === ref);
51 };
52 const valueToText = (
53 value: Record<string, unknown> | undefined | null,
54 property?: { type?: string; config?: { options?: { id: string; name: string }[]; format?: string; currency?: string } }
55 ): string => {
56 if (value == null) return "";
57 if (typeof value === "string") return value;
58 const v = value as Record<string, unknown>;
59 if (v.text != null) return String(v.text);
60 if (v.value != null) {
61 if (property?.config?.format === "currency" && typeof v.value === "number") {
62 return `${property.config.currency ?? "USD"} ${v.value}`;
63 }
64 return String(v.value);
65 }
66 if (v.optionId != null && property) return findOption(property.config?.options, v.optionId)?.name ?? "";
67 if (Array.isArray(v.optionIds) && (v.optionIds as unknown[]).length && property) {
68 return (v.optionIds as string[])
69 .map((id) => findOption(property.config?.options, id)?.name)
70 .filter(Boolean)
71 .join(", ");
72 }
73 if (v.start) return String(v.start).slice(0, 10);
74 if (v.recordIds) return `${(v.recordIds as unknown[]).length} linked`;
75 if (v.fileIds) return `${(v.fileIds as unknown[]).length} file(s)`;
76 if (v.userIds) return `${(v.userIds as unknown[]).length} person(s)`;
77 return JSON.stringify(value);
78 };
79 return {
80 findOption,
81 valueToText,
82 useUpdateRecord: () => ({ mutate, isPending: false }),
83 useRelations: () => ({ data: [] }),
84 };
85});
86
87vi.mock("@/lib/api", () => ({ apiFetch: vi.fn(), uploadFile: vi.fn() }));
88vi.mock("@/stores/ui-store", () => ({ toast: vi.fn() }));
89
90import { CellEditor, dateToInput } from "@/components/database/cell-editor";
91import type { DatabaseProperty, RecordDto, RecordValue } from "@/hooks/use-database";
92
93
94
95
96
97const dateProp = (config: Record<string, unknown> = {}, type: "date" | "date_range" = "date"): DatabaseProperty =>
98 ({ id: "p-date", name: "When", type, config }) as unknown as DatabaseProperty;
99
100describe("dateToInput (pure)", () => {
101 it("empty/null date → \"\" (NOT valueToText's raw JSON — the seed mismatch)", () => {
102 expect(dateToInput(dateProp(), { start: null, end: null })).toBe("");
103 expect(dateToInput(dateProp(), undefined)).toBe("");
104 expect(dateToInput(dateProp(), null)).toBe("");
105 });
106
107 it("date-only date → yyyy-mm-dd (input format), regardless of stored time/offset", () => {
108 expect(dateToInput(dateProp(), { start: "2026-01-15T00:00:00.000Z", end: null })).toBe("2026-01-15");
109 });
110
111 it("includeTime → datetime-local format (yyyy-mm-ddTHH:MM)", () => {
112 expect(dateToInput(dateProp({ includeTime: true }), { start: "2026-01-15T09:30:00.000Z", end: null })).toBe(
113 "2026-01-15T09:30"
114 );
115 });
116
117 it("date_range → \"start|end\"", () => {
118 expect(dateToInput(dateProp({}, "date_range"), { start: "2026-01-15T00:00:00.000Z", end: "2026-01-20T00:00:00.000Z" })).toBe(
119 "2026-01-15|2026-01-20"
120 );
121 });
122
123 it("date_range with no end → \"start|\"", () => {
124 expect(dateToInput(dateProp({}, "date_range"), { start: "2026-01-15T00:00:00.000Z", end: null })).toBe("2026-01-15|");
125 });
126});
127
128
129
130
131
132const makeRecord = (propertyId: string, value: RecordValue): RecordDto =>
133 ({
134 id: "rec-1",
135 workspaceId: "ws-1",
136 databaseId: "db-1",
137 pageId: null,
138 icon: null,
139 coverUrl: null,
140 archivedAt: null,
141 isSubItem: false,
142 position: "a",
143 version: 7,
144 createdBy: "u1",
145 createdTime: "2026-01-01T00:00:00.000Z",
146 lastEditedBy: null,
147 lastEditedTime: "2026-01-01T00:00:00.000Z",
148 properties: { [propertyId]: value }
149 }) as unknown as RecordDto;
150
151let container: HTMLDivElement;
152let root: Root;
153
154beforeEach(() => {
155 mutate.mockClear();
156 container = document.createElement("div");
157 document.body.appendChild(container);
158 root = createRoot(container);
159});
160
161afterEach(() => {
162 root.unmount();
163 container.remove();
164});
165
166function renderEditor(property: DatabaseProperty, record: RecordDto) {
167 const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
168 let done: () => void = () => undefined;
169 act(() => {
170 root.render(
171 <QueryClientProvider client={qc}>
172 <CellEditor record={record} property={property} onDone={done} />
173 </QueryClientProvider>
174 );
175 });
176 return { container, done: () => done() };
177}
178
179
180
181const blur = (el: Element) =>
182 act(() => {
183 (el as HTMLElement).dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
184 });
185
186
187const firstCall = (): MutateArgs => {
188 expect(mutate.mock.calls.length, "mutate was not called").toBeGreaterThan(0);
189 return mutate.mock.calls[0]![0];
190};
191
192describe("CellEditor — date no-op guard (the PATCH storm)", () => {
193 it("an empty date seeds the INPUT (\"\"), not valueToText's raw JSON", () => {
194 renderEditor(dateProp(), makeRecord("p-date", { start: null, end: null }));
195 const input = container.querySelector("input[type=date]");
196 expect(input, "expected a date input to be mounted").not.toBeNull();
197
198 expect(input?.getAttribute("value") ?? (input as HTMLInputElement).value).toBe("");
199 expect(mutate).not.toHaveBeenCalled();
200 });
201
202 it("a blur with NO change fires ZERO commits (no {start:null,end:null} write)", () => {
203 renderEditor(dateProp(), makeRecord("p-date", { start: null, end: null }));
204 const input = container.querySelector("input") as HTMLInputElement;
205 blur(input);
206 expect(mutate, "unchanged blur must not commit").not.toHaveBeenCalled();
207 });
208
209 it("a real date change commits exactly once with { start: ISO, end: null }", () => {
210 renderEditor(dateProp(), makeRecord("p-date", { start: null, end: null }));
211 const input = container.querySelector("input") as HTMLInputElement;
212 act(() => {
213 const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
214 setter.call(input, "2026-01-15");
215 input.dispatchEvent(new Event("input", { bubbles: true }));
216 });
217 blur(input);
218 expect(mutate).toHaveBeenCalledTimes(1);
219 const call = firstCall();
220 expect(call.recordId).toBe("rec-1");
221 expect(call.version).toBe(7);
222 expect(call.properties["p-date"]).toEqual({
223 start: new Date("2026-01-15").toISOString(),
224 end: null
225 });
226 });
227
228 it("an unchanged SET date seeds the input and blurs as a no-op", () => {
229 renderEditor(dateProp(), makeRecord("p-date", { start: "2026-03-02T00:00:00.000Z", end: null }));
230 const input = container.querySelector("input") as HTMLInputElement;
231 expect(input.value).toBe("2026-03-02");
232 blur(input);
233 expect(mutate, "unchanged set-date blur must not commit").not.toHaveBeenCalled();
234 });
235
236 it("includeTime renders a datetime-local input (seeded to the minute)", () => {
237 renderEditor(
238 dateProp({ includeTime: true }),
239 makeRecord("p-date", { start: "2026-01-15T09:30:00.000Z", end: null })
240 );
241 const input = container.querySelector("input") as HTMLInputElement;
242 expect(input.type).toBe("datetime-local");
243 expect(input.value).toBe("2026-01-15T09:30");
244 });
245});
246
247describe("CellEditor — status/select one-shot commit (the board column)", () => {
248 const statusProp = (): DatabaseProperty =>
249 ({
250 id: "p-status",
251 name: "Status",
252 type: "status",
253 config: {
254 options: [
255 { id: "opt-to-do", name: "To Do", color: "default" },
256 { id: "opt-in-progress", name: "In Progress", color: "orange" },
257 { id: "opt-done", name: "Done", color: "green" }
258 ]
259 }
260 }) as unknown as DatabaseProperty;
261
262 it("is mounted with the dropdown already open (options visible)", () => {
263 renderEditor(statusProp(), makeRecord("p-status", { optionId: "opt-in-progress" }));
264 expect(container.querySelector("ul[role=listbox]"), "status dropdown should be open on mount").not.toBeNull();
265 expect(mutate).not.toHaveBeenCalled();
266 });
267
268 it("picking a new option commits { optionId } EXACTLY ONCE (no re-commit storm)", () => {
269 renderEditor(statusProp(), makeRecord("p-status", { optionId: "opt-in-progress" }));
270 const option = [...container.querySelectorAll("ul[role=listbox] li button")].find(
271 (b) => b.textContent?.includes("Done")
272 );
273 expect(option, "expected a 'Done' option in the open listbox").not.toBeUndefined();
274 act(() => {
275 option!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
276 });
277 expect(mutate, "one click = one commit").toHaveBeenCalledTimes(1);
278 const call = firstCall();
279 expect(call.properties["p-status"]).toEqual({ optionId: "opt-done" });
280
281 expect(container.querySelector("ul[role=listbox]")).toBeNull();
282 expect(mutate).toHaveBeenCalledTimes(1);
283 });
284
285 it("re-picking the CURRENT option still commits once with that optionId (explicit user action)", () => {
286 renderEditor(statusProp(), makeRecord("p-status", { optionId: "opt-to-do" }));
287 const option = [...container.querySelectorAll("ul[role=listbox] li button")].find(
288 (b) => b.textContent?.includes("To Do")
289 );
290 act(() => {
291 option!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
292 });
293 expect(mutate).toHaveBeenCalledTimes(1);
294 expect(firstCall().properties["p-status"]).toEqual({ optionId: "opt-to-do" });
295 });
296});
297