File
Blob: tests/e2e/specs/09-sidebar-move-actions.spec.ts
| 1 | import type { Locator, Page } from "@playwright/test"; |
| 2 | import { test, expect, createTestWorkspace } from "../fixtures/bland-test"; |
| 3 | |
| 4 | interface ApiPage { |
| 5 | id: string; |
| 6 | parent_id: string | null; |
| 7 | position: number; |
| 8 | title: string; |
| 9 | } |
| 10 | |
| 11 | function authHeaders(accessToken: string): Record<string, string> { |
| 12 | return { Authorization: `Bearer ${accessToken}` }; |
| 13 | } |
| 14 | |
| 15 | async function createPage( |
| 16 | page: Page, |
| 17 | accessToken: string, |
| 18 | workspaceId: string, |
| 19 | title: string, |
| 20 | parentId?: string, |
| 21 | ): Promise<ApiPage> { |
| 22 | const data: Record<string, unknown> = { title }; |
| 23 | if (parentId) data.parent_id = parentId; |
| 24 | const res = await page.request.post(`/api/v1/workspaces/${workspaceId}/pages`, { |
| 25 | data, |
| 26 | headers: authHeaders(accessToken), |
| 27 | }); |
| 28 | if (!res.ok()) throw new Error(`Failed to create page "${title}": ${res.status()}`); |
| 29 | const body = (await res.json()) as { page: ApiPage }; |
| 30 | return body.page; |
| 31 | } |
| 32 | |
| 33 | async function listPages(page: Page, accessToken: string, workspaceId: string): Promise<ApiPage[]> { |
| 34 | const res = await page.request.get(`/api/v1/workspaces/${workspaceId}/pages`, { |
| 35 | headers: authHeaders(accessToken), |
| 36 | }); |
| 37 | const data = (await res.json()) as { pages: ApiPage[] }; |
| 38 | return data.pages; |
| 39 | } |
| 40 | |
| 41 | function rowLocator(page: Page, pageId: string): Locator { |
| 42 | return page.locator(`[data-page-row][data-page-id="${pageId}"]`); |
| 43 | } |
| 44 | |
| 45 | async function expandRow(page: Page, pageId: string): Promise<void> { |
| 46 | const row = rowLocator(page, pageId); |
| 47 | const chevron = row.locator('button[aria-label="Expand"]'); |
| 48 | if (await chevron.count()) { |
| 49 | await chevron.click().catch(() => {}); |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | async function openRowMenu(page: Page, pageId: string): Promise<void> { |
| 54 | const row = rowLocator(page, pageId); |
| 55 | await row.locator('button[aria-label="Page options"]').click(); |
| 56 | } |
| 57 | |
| 58 | async function clickMenuItem(page: Page, label: string): Promise<void> { |
| 59 | await page.getByRole("menuitem", { name: label, exact: true }).click(); |
| 60 | } |
| 61 | |
| 62 | test.describe("sidebar move actions", () => { |
| 63 | test("reorders root pages with Move down", async ({ authenticatedPage: { page, accessToken } }) => { |
| 64 | const workspace = await createTestWorkspace(page, accessToken, "Move Actions"); |
| 65 | const a = await createPage(page, accessToken, workspace.workspaceId, "Root A"); |
| 66 | const b = await createPage(page, accessToken, workspace.workspaceId, "Root B"); |
| 67 | const c = await createPage(page, accessToken, workspace.workspaceId, "Root C"); |
| 68 | |
| 69 | await page.goto(`/${workspace.workspaceSlug}`); |
| 70 | await rowLocator(page, a.id).waitFor({ timeout: 15_000 }); |
| 71 | await rowLocator(page, c.id).waitFor({ timeout: 15_000 }); |
| 72 | |
| 73 | await openRowMenu(page, b.id); |
| 74 | await clickMenuItem(page, "Move down"); |
| 75 | |
| 76 | await expect |
| 77 | .poll( |
| 78 | async () => { |
| 79 | const pages = await listPages(page, accessToken, workspace.workspaceId); |
| 80 | return pages |
| 81 | .filter((candidate) => [a.id, b.id, c.id].includes(candidate.id) && candidate.parent_id === null) |
| 82 | .sort((x, y) => x.position - y.position) |
| 83 | .map((candidate) => candidate.id); |
| 84 | }, |
| 85 | { timeout: 15_000 }, |
| 86 | ) |
| 87 | .toEqual([a.id, c.id, b.id]); |
| 88 | }); |
| 89 | |
| 90 | test("indents a page into the previous sibling", async ({ authenticatedPage: { page, accessToken } }) => { |
| 91 | const workspace = await createTestWorkspace(page, accessToken, "Move Actions"); |
| 92 | const parent = await createPage(page, accessToken, workspace.workspaceId, "Parent"); |
| 93 | const sibling = await createPage(page, accessToken, workspace.workspaceId, "Sibling"); |
| 94 | |
| 95 | await page.goto(`/${workspace.workspaceSlug}`); |
| 96 | await rowLocator(page, parent.id).waitFor({ timeout: 15_000 }); |
| 97 | await rowLocator(page, sibling.id).waitFor({ timeout: 15_000 }); |
| 98 | |
| 99 | await openRowMenu(page, sibling.id); |
| 100 | await clickMenuItem(page, "Indent"); |
| 101 | |
| 102 | await expect |
| 103 | .poll( |
| 104 | async () => { |
| 105 | const pages = await listPages(page, accessToken, workspace.workspaceId); |
| 106 | return pages.find((candidate) => candidate.id === sibling.id)?.parent_id ?? null; |
| 107 | }, |
| 108 | { timeout: 15_000 }, |
| 109 | ) |
| 110 | .toBe(parent.id); |
| 111 | }); |
| 112 | |
| 113 | test("outdents a child to after its former parent subtree", async ({ authenticatedPage: { page, accessToken } }) => { |
| 114 | const workspace = await createTestWorkspace(page, accessToken, "Move Actions"); |
| 115 | const parent = await createPage(page, accessToken, workspace.workspaceId, "Outdent Parent"); |
| 116 | const childOne = await createPage(page, accessToken, workspace.workspaceId, "Child One", parent.id); |
| 117 | const childTwo = await createPage(page, accessToken, workspace.workspaceId, "Child Two", parent.id); |
| 118 | const tail = await createPage(page, accessToken, workspace.workspaceId, "Tail Root"); |
| 119 | |
| 120 | await page.goto(`/${workspace.workspaceSlug}`); |
| 121 | await rowLocator(page, parent.id).waitFor({ timeout: 15_000 }); |
| 122 | await expandRow(page, parent.id); |
| 123 | await rowLocator(page, childOne.id).waitFor({ timeout: 15_000 }); |
| 124 | |
| 125 | await openRowMenu(page, childOne.id); |
| 126 | await clickMenuItem(page, "Outdent"); |
| 127 | |
| 128 | await expect |
| 129 | .poll( |
| 130 | async () => { |
| 131 | const pages = await listPages(page, accessToken, workspace.workspaceId); |
| 132 | const rootOrder = pages |
| 133 | .filter( |
| 134 | (candidate) => [parent.id, childOne.id, tail.id].includes(candidate.id) && candidate.parent_id === null, |
| 135 | ) |
| 136 | .sort((x, y) => x.position - y.position) |
| 137 | .map((candidate) => candidate.id); |
| 138 | const remainingChildren = pages |
| 139 | .filter( |
| 140 | (candidate) => [childOne.id, childTwo.id].includes(candidate.id) && candidate.parent_id === parent.id, |
| 141 | ) |
| 142 | .sort((x, y) => x.position - y.position) |
| 143 | .map((candidate) => candidate.id); |
| 144 | return { rootOrder, remainingChildren }; |
| 145 | }, |
| 146 | { timeout: 15_000 }, |
| 147 | ) |
| 148 | .toEqual({ |
| 149 | rootOrder: [parent.id, childOne.id, tail.id], |
| 150 | remainingChildren: [childTwo.id], |
| 151 | }); |
| 152 | }); |
| 153 | |
| 154 | test("moves a page into another branch with Move…", async ({ authenticatedPage: { page, accessToken } }) => { |
| 155 | const workspace = await createTestWorkspace(page, accessToken, "Move Actions"); |
| 156 | const alpha = await createPage(page, accessToken, workspace.workspaceId, "Alpha"); |
| 157 | const beta = await createPage(page, accessToken, workspace.workspaceId, "Beta"); |
| 158 | |
| 159 | await page.goto(`/${workspace.workspaceSlug}`); |
| 160 | await rowLocator(page, alpha.id).waitFor({ timeout: 15_000 }); |
| 161 | await rowLocator(page, beta.id).waitFor({ timeout: 15_000 }); |
| 162 | |
| 163 | await openRowMenu(page, beta.id); |
| 164 | await clickMenuItem(page, "Move…"); |
| 165 | |
| 166 | const dialog = page.getByRole("dialog"); |
| 167 | await expect(dialog).toBeVisible(); |
| 168 | await dialog.getByRole("button").filter({ hasText: "Alpha" }).first().click(); |
| 169 | await dialog.getByRole("button", { name: "Inside", exact: true }).click(); |
| 170 | await dialog.getByRole("button", { name: "Move", exact: true }).click(); |
| 171 | |
| 172 | await expect |
| 173 | .poll( |
| 174 | async () => { |
| 175 | const pages = await listPages(page, accessToken, workspace.workspaceId); |
| 176 | return pages.find((candidate) => candidate.id === beta.id)?.parent_id ?? null; |
| 177 | }, |
| 178 | { timeout: 15_000 }, |
| 179 | ) |
| 180 | .toBe(alpha.id); |
| 181 | }); |
| 182 | }); |