1
2
19(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
20import { act } from "react";
21import { createRoot, type Root } from "react-dom/client";
22import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
23import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
24
25
26
27vi.mock("@/hooks/use-database", () => ({
28 findOption: (options: { id: string }[] | undefined, id?: string) =>
29 (options ?? []).find((o) => o.id === id),
30 useRelations: () => ({
31 data: [{ recordId: "r1", record: { id: "r1", title: "Linked record" } }]
32 }),
33 useUpdateRecord: () => ({ mutateAsync: vi.fn(), isPending: false })
34}));
35
36
37vi.mock("@/lib/api", () => ({
38 apiFetch: vi.fn(async () => ({
39 data: {
40 id: "f1",
41 name: "attachment.png",
42 size: 1024,
43 mimeType: "image/png",
44 url: "http://example.test/attachment.png",
45 thumbnailUrl: null
46 }
47 }))
48}));
49
50import { CellRenderer } from "@/components/database/cell-renderer";
51import { DatabaseProperty, RecordDto, RecordValue } from "@/hooks/use-database";
52
53const record = {
54 id: "rec1",
55 databaseId: "db1",
56 version: 1,
57 title: "My Page",
58 icon: null,
59 properties: {},
60 createdTime: null,
61 lastEditedTime: null,
62 createdAt: "2026-01-01T00:00:00.000Z",
63 updatedAt: "2026-01-01T00:00:00.000Z"
64} as unknown as RecordDto;
65
66const prop = (p: Partial<DatabaseProperty> & { type: DatabaseProperty["type"]; name: string }): DatabaseProperty =>
67 p as unknown as DatabaseProperty;
68
69let container: HTMLDivElement;
70let root: Root;
71
72beforeEach(() => {
73 container = document.createElement("div");
74 document.body.appendChild(container);
75 root = createRoot(container);
76});
77
78afterEach(() => {
79 root.unmount();
80 container.remove();
81});
82
83async function renderCell(property: DatabaseProperty, value?: RecordValue): Promise<HTMLDivElement> {
84 const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
85 act(() => {
86 root.render(
87 <QueryClientProvider client={qc}>
88 <CellRenderer record={record} property={property} value={value} />
89 </QueryClientProvider>
90 );
91 });
92
93 await act(async () => {
94 await new Promise((r) => setTimeout(r, 0));
95 });
96 return container;
97}
98
99async function renderCellWithEdit(
100 property: DatabaseProperty,
101 value: RecordValue,
102 onEdit: (record: RecordDto, property: DatabaseProperty) => void
103): Promise<HTMLDivElement> {
104 const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
105 act(() => {
106 root.render(
107 <QueryClientProvider client={qc}>
108 <CellRenderer record={record} property={property} value={value} onEdit={onEdit} />
109 </QueryClientProvider>
110 );
111 });
112 await act(async () => {
113 await new Promise((r) => setTimeout(r, 0));
114 });
115 return container;
116}
117
118
119function expectNoNestedButtons(el: HTMLElement): void {
120 const bad = [...el.querySelectorAll("button")].filter((b) => b.closest("button") !== b);
121 expect(bad, "found a <button> nested inside a <button>").toHaveLength(0);
122}
123
124describe("CellRenderer DOM validity (no <button> inside <button>)", () => {
125 it("title cell: click-to-edit button wrapper with plain text inside (no nested button)", async () => {
126 const el = await renderCell(
127 prop({ type: "title", name: "Name", config: {} }),
128 { text: "My Page" }
129 );
130
131
132
133
134 expect(el.firstElementChild?.tagName).toBe("BUTTON");
135 const link = el.querySelector("button");
136 expect(link, "exactly one button (the wrapper), no nested button").toBe(el.firstElementChild);
137 expectNoNestedButtons(el);
138 expect(el.querySelector("span")?.textContent).toContain("My Page");
139 });
140
141 it("title cell: clicking fires onEdit (the cell is editable)", async () => {
142 const onEdit = vi.fn();
143 const el = await renderCellWithEdit(
144 prop({ type: "title", name: "Name", config: {} }),
145 { text: "My Page" },
146 onEdit
147 );
148 act(() => {
149 (el.firstElementChild as HTMLButtonElement).click();
150 });
151 expect(onEdit).toHaveBeenCalledTimes(1);
152 });
153
154 it("title cell: empty value shows the 'Untitled' placeholder", async () => {
155 const el = await renderCell(prop({ type: "title", name: "Name", config: {} }), { text: "" });
156 expect(el.querySelector(".text-muted-foreground")?.textContent).toBe("Untitled");
157 });
158
159 it("relation cell: chips are top-level buttons, root is a container", async () => {
160 const el = await renderCell(
161 prop({ type: "relation", name: "Related", config: { targetDatabaseId: "db2" } }),
162 { recordIds: ["r1"] }
163 );
164 expect(el.firstElementChild?.tagName).toBe("DIV");
165 expect(el.querySelectorAll("button").length).toBeGreaterThan(0);
166 expectNoNestedButtons(el);
167 });
168
169 it("files cell: file chip is a top-level button, root is a container", async () => {
170 const el = await renderCell(
171 prop({ type: "files", name: "Attachments", config: {} }),
172 { fileIds: ["f1"] }
173 );
174 expect(el.firstElementChild?.tagName).toBe("DIV");
175 expect(el.querySelector("button")).not.toBeNull();
176 expectNoNestedButtons(el);
177 });
178
179 it("button-type property: the action chip is a single (non-nested) button", async () => {
180 const el = await renderCell(
181 prop({ type: "button", name: "Archive", config: { actions: [{ type: "set_property" }] } })
182 );
183 expect(el.querySelector("button")).not.toBeNull();
184 expectNoNestedButtons(el);
185 });
186
187 it("control: editable select still uses the click-to-edit button wrapper", async () => {
188 const el = await renderCell(
189 prop({
190 type: "select",
191 name: "Status",
192 config: { options: [{ id: "o1", name: "To Do", color: "default" }] }
193 }),
194 { optionId: "o1" }
195 );
196
197 expect(el.firstElementChild?.tagName).toBe("BUTTON");
198 expectNoNestedButtons(el);
199 });
200});
201