1
33import { test, expect } from "../fixtures";
34
35interface BlockRow {
36 id: string;
37 type: string;
38 content: unknown;
39 properties: Record<string, unknown>;
40}
41
42interface BlocksResponse {
43 data: BlockRow[];
44}
45
46async function createPage(api: import("@playwright/test").APIRequestContext, wsId: string, title: string): Promise<string> {
47 const res = await api.post(`/api/v1/workspaces/${wsId}/pages`, { data: { title } });
48 expect(res.status(), await res.text()).toBe(201);
49 return ((await res.json()) as { id: string }).id;
50}
51
52async function createBlock(
53 api: import("@playwright/test").APIRequestContext,
54 pageId: string,
55 type: string,
56 text: string,
57): Promise<string> {
58 const res = await api.post(`/api/v1/pages/${pageId}/blocks`, {
59 data: { type, content: { text }, parentId: null },
60 });
61 expect(res.status(), await res.text()).toBe(201);
62 return ((await res.json()) as { id: string }).id;
63}
64
65
79async function focusFreshBlock(page: import("@playwright/test").Page) {
80 const area = page.locator("#workspace-main").getByTestId("new-block");
81 await area.click();
82 const settled = () =>
83 page.waitForFunction(
84 () => {
85 const ae = document.activeElement;
86 return (
87 ae instanceof HTMLElement &&
88 ae.isContentEditable &&
89 (ae.textContent ?? "").trim() === ""
90 );
91 },
92 undefined,
93 { timeout: 3000 },
94 );
95 try {
96 await settled();
97 } catch {
98
99
100 await page.evaluate(() => {
101 const eds = Array.from(
102 document.querySelectorAll<HTMLElement>("#workspace-main [contenteditable='true']"),
103 );
104 const empty = [...eds].reverse().find((el) => (el.textContent ?? "").trim() === "");
105 empty?.click();
106 });
107 await settled();
108 }
109}
110
111test.describe("Editor table + TOC — T-146/T-147", () => {
112 test("/table → insert dialog → pre-created grid; add row + edit cell persist to the API", async ({
113 authPage,
114 api,
115 freshWorkspace,
116 }) => {
117 const ws = await freshWorkspace("Table Space");
118 const pageId = await createPage(api, ws.id, "Table page");
119 await authPage.goto(`/w/${ws.slug}/page/${pageId}`);
120 await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 });
121
122
123
124 await focusFreshBlock(authPage);
125 await authPage.keyboard.type("/", { delay: 30 });
126 const menu = authPage.getByTestId("slash-menu");
127 await expect(menu).toBeVisible({ timeout: 10_000 });
128
129
130
131
132 await authPage.keyboard.type("table", { delay: 30 });
133 await menu.getByTestId("slash-menu-item-table").click();
134 const dialog = authPage.getByTestId("insert-dialog");
135 await expect(dialog).toBeVisible({ timeout: 10_000 });
136 await expect(dialog.getByRole("tab", { name: "Table" })).toHaveAttribute("aria-selected", "true");
137
138
139 await dialog.locator("#tbl-rows").fill("2");
140 await dialog.locator("#tbl-cols").fill("2");
141 await dialog.getByRole("button", { name: "Insert table" }).click();
142 await expect(dialog).toHaveCount(0);
143
144
145
146 const table = authPage.locator("table").first();
147 await expect(table).toBeVisible({ timeout: 10_000 });
148 await expect(table.locator("thead th").filter({ hasText: "Column 1" })).toBeVisible();
149 await expect(table.locator("thead th").filter({ hasText: "Column 2" })).toBeVisible();
150 await expect(table.locator("tbody input")).toHaveCount(4);
151
152
153
154
155 await authPage.getByRole("button", { name: "+ Add row" }).click();
156 await expect(table.locator("tbody tr")).toHaveCount(3, { timeout: 10_000 });
157
158
159 const firstCell = table.locator("tbody input").first();
160 await firstCell.fill("HELLO");
161 await firstCell.blur();
162
163
164
165
166 await expect
167 .poll(
168 async () => {
169 const res = await api.get(`/api/v1/pages/${pageId}/blocks`);
170 const body = (await res.json()) as BlocksResponse;
171 const t = body.data.find((b) => b.type === "TABLE");
172 if (!t) return false;
173 const rows = t.properties.rows as string[][];
174 const cols = t.properties.columns as unknown[];
175 return (
176 Array.isArray(rows) &&
177 rows.length === 3 &&
178 Array.isArray(cols) &&
179 cols.length === 2 &&
180 rows[0][0] === "HELLO"
181 );
182 },
183 { timeout: 15_000 },
184 )
185 .toBe(true);
186 });
187
188 test("/toc → table of contents lists the headings and deep-links to them", async ({
189 authPage,
190 api,
191 freshWorkspace,
192 }) => {
193 const ws = await freshWorkspace("TOC Space");
194 const pageId = await createPage(api, ws.id, "TOC page");
195
196
197 const h1Id = await createBlock(api, pageId, "heading_1", "Alpha");
198 const h2Id = await createBlock(api, pageId, "heading_2", "Beta");
199
200 await authPage.goto(`/w/${ws.slug}/page/${pageId}`);
201 await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 });
202
203 await focusFreshBlock(authPage);
204 await authPage.keyboard.type("/", { delay: 30 });
205 const menu = authPage.getByTestId("slash-menu");
206 await expect(menu).toBeVisible({ timeout: 10_000 });
207 await authPage.keyboard.type("toc", { delay: 30 });
208 await menu.getByTestId("slash-menu-item-toc").click();
209
210
211 const toc = authPage.getByRole("navigation", { name: "Table of contents" });
212 await expect(toc).toBeVisible({ timeout: 10_000 });
213 const entries = toc.locator("a");
214 await expect(entries.first()).toHaveText("Alpha");
215 await expect(entries.nth(1)).toHaveText("Beta");
216
217
218 await expect(toc.getByText("Untitled")).toHaveCount(0);
219
220
221
222
223 await toc.getByRole("link", { name: "Alpha" }).click();
224 await expect
225 .poll(() => authPage.url(), { timeout: 10_000 })
226 .toBe(new URL(`/w/${ws.slug}/page/${pageId}/${h1Id}`, authPage.url()).toString());
227 await expect(authPage.getByTestId(`block-${h1Id}`)).toContainText("Alpha");
228
229 await toc.getByRole("link", { name: "Beta" }).click();
230 await expect
231 .poll(() => authPage.url(), { timeout: 10_000 })
232 .toBe(new URL(`/w/${ws.slug}/page/${pageId}/${h2Id}`, authPage.url()).toString());
233 });
234});
235