diff --git a/e2e/drive-backed-apps/specs/drive-writer/integration.spec.ts b/e2e/drive-backed-apps/specs/drive-writer/integration.spec.ts index 41b3bfe331..a2eb945b0c 100644 --- a/e2e/drive-backed-apps/specs/drive-writer/integration.spec.ts +++ b/e2e/drive-backed-apps/specs/drive-writer/integration.spec.ts @@ -72,13 +72,14 @@ test("renames, moves, trashes, restores, and reopens a Writer document from Driv await expect(documentRow).toContainText(title); await openEntityActions(owner.page, file.name); await owner.page.getByRole("button", { name: "Rename", exact: true }).click(); - const renameDialog = owner.page.getByRole("dialog", { name: "Rename" }); - await renameDialog.getByRole("textbox").fill(renamedTitle); + // Renaming is inline in the row, not a dialog. + const renameInput = documentRow.getByRole("textbox"); + await renameInput.fill(renamedTitle); const [renameResponse] = await Promise.all([ owner.page.waitForResponse( (response) => response.url().includes("suite.drive.api.files.rename"), ), - renameDialog.getByRole("button", { name: "Confirm" }).click(), + renameInput.press("Enter"), ]); if (!renameResponse.ok()) throw new Error(await renameResponse.text()); await expect(documentRow).toContainText(renamedTitle); diff --git a/e2e/drive-backed-apps/specs/drive/breadcrumbs.spec.ts b/e2e/drive-backed-apps/specs/drive/breadcrumbs.spec.ts new file mode 100644 index 0000000000..91a9b3813d --- /dev/null +++ b/e2e/drive-backed-apps/specs/drive/breadcrumbs.spec.ts @@ -0,0 +1,178 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "../../fixtures/test"; +import { createFolder } from "../../helpers/drive"; + +function uniqueName(runId: string, label: string): string { + return `${label}-${runId}-${Date.now()}`; +} + +/** The breadcrumb trail a user can read in the navbar, outermost first. */ +async function trail(page: Page): Promise { + const crumbs = page.getByTestId("breadcrumbs"); + await expect(crumbs).toBeVisible(); + await expect(page.getByTestId("breadcrumbs-loading")).toHaveCount(0); + return (await crumbs.innerText()) + .split("/") + .map((part: string) => part.trim()) + .filter(Boolean); +} + +async function openFolder(page: Page, entityName: string): Promise { + const row = page.getByTestId(`drive-entity-${entityName}`); + await expect(row).toBeVisible(); + await row.click(); +} + +test("each section shows its own name, and the navbar never vanishes while navigating", async ({ + owner, +}) => { + const page = owner.page; + await page.goto("/drive"); + await expect.poll(() => trail(page)).toEqual(["Home"]); + + for (const section of ["Recents", "Shared", "Favourites", "Trash"]) { + await page.getByRole("link", { name: section, exact: true }).click(); + await expect(page.locator("#navbar")).toBeVisible(); + await expect.poll(() => trail(page)).toEqual([section]); + } + + await page.getByRole("link", { name: "Home", exact: true }).click(); + await expect.poll(() => trail(page)).toEqual(["Home"]); +}); + +test("nested folders build up the trail and an ancestor crumb navigates back", async ({ + owner, + run, +}) => { + const page = owner.page; + await page.goto("/drive"); + const parentName = uniqueName(run.run_id, "parent"); + const parent = await createFolder(page, parentName); + + await page.goto(`/drive/d/${parent.name}`); + await expect.poll(() => trail(page)).toEqual(["Home", parentName]); + + const childName = uniqueName(run.run_id, "child"); + const child = await createFolder(page, childName, parent.name); + await page.reload(); + await openFolder(page, child.name); + await expect(page).toHaveURL(new RegExp(child.name)); + await expect.poll(() => trail(page)).toEqual(["Home", parentName, childName]); + + await page.locator("#navbar").getByRole("link", { name: parentName }).click(); + await expect(page).toHaveURL(new RegExp(parent.name)); + await expect.poll(() => trail(page)).toEqual(["Home", parentName]); +}); + +test("a deep link into a nested folder shows the full trail", async ({ + owner, + run, +}) => { + const page = owner.page; + await page.goto("/drive"); + const parentName = uniqueName(run.run_id, "deep"); + const parent = await createFolder(page, parentName); + await page.goto(`/drive/d/${parent.name}`); + const childName = uniqueName(run.run_id, "deepchild"); + const child = await createFolder(page, childName, parent.name); + + await page.goto(`/drive/d/${child.name}`); + await expect.poll(() => trail(page)).toEqual(["Home", parentName, childName]); + + await page.reload(); + await expect.poll(() => trail(page)).toEqual(["Home", parentName, childName]); +}); + +test("browser back restores the previous trail", async ({ owner, run }) => { + const page = owner.page; + await page.goto("/drive"); + const parentName = uniqueName(run.run_id, "back"); + const parent = await createFolder(page, parentName); + await page.goto(`/drive/d/${parent.name}`); + const childName = uniqueName(run.run_id, "backchild"); + const child = await createFolder(page, childName, parent.name); + await page.reload(); + await openFolder(page, child.name); + await expect.poll(() => trail(page)).toEqual(["Home", parentName, childName]); + + await page.goBack(); + await expect.poll(() => trail(page)).toEqual(["Home", parentName]); + + await page.goBack(); + await expect.poll(() => trail(page)).toEqual(["Home"]); +}); + +test("renaming the current folder updates its crumb", async ({ owner, run }) => { + const page = owner.page; + await page.goto("/drive"); + const original = uniqueName(run.run_id, "renamecrumb"); + const folder = await createFolder(page, original); + + await page.goto(`/drive/d/${folder.name}`); + await expect.poll(() => trail(page)).toEqual(["Home", original]); + + const renamed = `${original}-renamed`; + const crumb = page.getByTestId("breadcrumbs").getByText(original, { exact: true }); + const input = page.locator("#navbar").getByRole("textbox"); + await expect(async () => { + await crumb.click(); + await expect(input).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 20000 }); + await input.fill(renamed); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes("suite.drive.api.files.rename") && + response.ok(), + ), + input.press("Enter"), + ]); + await expect.poll(() => trail(page)).toEqual(["Home", renamed]); + + await page.reload(); + await expect.poll(() => trail(page)).toEqual(["Home", renamed]); +}); + +test("attachments pages show the doctype and document they belong to", async ({ + owner, +}) => { + const page = owner.page; + await page.goto("/drive/attachments"); + await expect.poll(() => trail(page)).toEqual(["Attachments"]); + + await page.goto("/drive/attachments/User"); + await expect.poll(() => trail(page)).toEqual(["Attachments", "User"]); +}); + +test("moving the current folder updates its trail without a reload", async ({ + owner, + run, +}) => { + const page = owner.page; + await page.goto("/drive"); + const destinationName = uniqueName(run.run_id, "dest"); + const destination = await createFolder(page, destinationName); + const movedName = uniqueName(run.run_id, "moved"); + const moved = await createFolder(page, movedName); + + await page.goto(`/drive/d/${moved.name}`); + await expect.poll(() => trail(page)).toEqual(["Home", movedName]); + + await page.getByRole("button", { name: "Entity actions" }).click(); + await page.getByRole("menuitem", { name: "Move" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByText(destinationName, { exact: true }).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes("suite.drive.api.files.move") && response.ok(), + ), + dialog.getByRole("button", { name: "Move", exact: true }).click(), + ]); + + await expect + .poll(() => trail(page)) + .toEqual(["Home", destinationName, movedName]); + await expect(page).toHaveURL(new RegExp(moved.name)); +}); diff --git a/e2e/drive-backed-apps/specs/drive/drive.spec.ts b/e2e/drive-backed-apps/specs/drive/drive.spec.ts index 881c3c32e9..b69b351f6d 100644 --- a/e2e/drive-backed-apps/specs/drive/drive.spec.ts +++ b/e2e/drive-backed-apps/specs/drive/drive.spec.ts @@ -50,9 +50,12 @@ test.describe.serial("Drive critical paths", () => { await openEntityActions(page, uploaded.name); await page.getByRole("button", { name: "Rename", exact: true }).click(); - const renameDialog = page.getByRole("dialog", { name: "Rename" }); - await renameDialog.getByRole("textbox").fill(renamedLabel); - await renameDialog.getByRole("button", { name: "Confirm" }).click(); + // Renaming is inline in the row, not a dialog. + const renameInput = page + .getByTestId(`drive-entity-${uploaded.name}`) + .getByRole("textbox"); + await renameInput.fill(renamedLabel); + await renameInput.press("Enter"); await expect(page.getByTestId(`drive-entity-${uploaded.name}`)).toContainText(renamedLabel); await openEntityActions(page, uploaded.name); diff --git a/e2e/drive-backed-apps/specs/drive/tree-expand.spec.ts b/e2e/drive-backed-apps/specs/drive/tree-expand.spec.ts new file mode 100644 index 0000000000..602bd399ac --- /dev/null +++ b/e2e/drive-backed-apps/specs/drive/tree-expand.spec.ts @@ -0,0 +1,109 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { expect, test } from "../../fixtures/test"; +import { createFolder, driveEntities, waitForDriveEntity } from "../../helpers/drive"; +import type { Page } from "@playwright/test"; + +const uploadFixture = resolve(__dirname, "fixtures/drive-upload.txt"); + +// Playwright's mouse-driven dragTo doesn't start an HTML5 drag in Chromium, so +// drive the same three events the row handlers listen for. +async function dragRowOnto(page: Page, source: string, target: string) { + await page.evaluate( + ({ source, target }) => { + const row = (name: string) => + document.querySelector(`[data-testid="drive-entity-${name}"]`); + const from = row(source); + const to = row(target); + if (!from || !to) throw new Error(`missing row: ${!from ? source : target}`); + const dataTransfer = new DataTransfer(); + from.dispatchEvent(new DragEvent("dragstart", { dataTransfer, bubbles: true })); + to.dispatchEvent( + new DragEvent("dragover", { dataTransfer, bubbles: true, cancelable: true }), + ); + to.dispatchEvent(new DragEvent("drop", { dataTransfer, bubbles: true })); + from.dispatchEvent(new DragEvent("dragend", { dataTransfer, bubbles: true })); + }, + { source, target }, + ); +} + +async function uploadInto(page: Page, folder: string, fileName: string) { + await page.goto(`/drive/d/${folder}`); + await Promise.all([ + page.waitForResponse( + (response) => response.url().includes("upload_file") && response.ok(), + ), + page.getByTestId("drive-file-input").setInputFiles({ + name: fileName, + mimeType: "text/plain", + buffer: readFileSync(uploadFixture), + }), + ]); + return waitForDriveEntity(page.request, fileName, folder); +} + +test("expands a folder inline in the list view and moves a subfile into another folder", async ({ + owner, + run, +}) => { + const suffix = `${run.run_id}-${Date.now()}`; + const sourceName = `tree-source-${suffix}`; + const destinationName = `tree-dest-${suffix}`; + const fileName = `tree-child-${suffix}.txt`; + + await owner.page.goto("/drive"); + const source = await createFolder(owner.page, sourceName); + const destination = await createFolder(owner.page, destinationName); + const child = await uploadInto(owner.page, source.name, fileName); + + await owner.page.goto("/drive"); + const sourceRow = owner.page.getByTestId(`drive-entity-${source.name}`); + await expect(sourceRow).toBeVisible(); + const childRow = owner.page.getByTestId(`drive-entity-${child.name}`); + await expect(childRow).toHaveCount(0); + + await owner.page.getByTestId(`drive-expand-${source.name}`).click(); + await expect(childRow).toBeVisible(); + await expect(childRow).toContainText(fileName.replace(/\.txt$/, "")); + // Expanding must not navigate away from the folder listing. + await expect(owner.page).toHaveURL(/\/drive(\?.*)?$/); + + await dragRowOnto(owner.page, child.name, destination.name); + + await expect(childRow).toHaveCount(0); + await expect + .poll(async () => + (await driveEntities(owner.page.request, destination.name)).map( + (entity) => entity.name, + ), + ) + .toContain(child.name); + expect( + (await driveEntities(owner.page.request, source.name)).map((entity) => entity.name), + ).not.toContain(child.name); + + // The destination shows the moved file once expanded. + await owner.page.reload(); + await owner.page.getByTestId(`drive-expand-${destination.name}`).click(); + await expect(childRow).toBeVisible(); +}); + +test("collapsing a folder hides its children again", async ({ owner, run }) => { + const suffix = `${run.run_id}-${Date.now()}`; + const folderName = `tree-collapse-${suffix}`; + const fileName = `tree-collapse-child-${suffix}.txt`; + + await owner.page.goto("/drive"); + const folder = await createFolder(owner.page, folderName); + const child = await uploadInto(owner.page, folder.name, fileName); + + await owner.page.goto("/drive"); + const toggle = owner.page.getByTestId(`drive-expand-${folder.name}`); + const childRow = owner.page.getByTestId(`drive-entity-${child.name}`); + + await toggle.click(); + await expect(childRow).toBeVisible(); + await toggle.click(); + await expect(childRow).toHaveCount(0); +}); diff --git a/e2e/drive-backed-apps/specs/writer/rename.spec.ts b/e2e/drive-backed-apps/specs/writer/rename.spec.ts new file mode 100644 index 0000000000..905258f8b1 --- /dev/null +++ b/e2e/drive-backed-apps/specs/writer/rename.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from "../../fixtures/test"; +import { + createWriterDocument, + openWriterDocument, + uniqueWriterTitle, +} from "../../helpers/writer"; +import type { Locator, Page } from "@playwright/test"; + +// The editable last crumb is the only text-bearing button in the navbar — parent +// crumbs render as links and the other navbar buttons are icon-only. +function titleCrumb(page: Page): Locator { + return page + .locator("#navbar") + .getByRole("button") + .filter({ hasText: /\S/ }) + .first(); +} + +// While renaming, the inline field is the only in the navbar. +function renameInput(page: Page): Locator { + return page.locator("#navbar input"); +} + +test("renames a document from the breadcrumb and persists", async ({ owner, run }) => { + const title = uniqueWriterTitle(run.run_id, "rename"); + const file = await createWriterDocument(owner.page.request, title); + await openWriterDocument(owner.page, file.name); + + const crumb = titleCrumb(owner.page); + await expect(crumb).toHaveText(title); + + await crumb.click(); + + const input = renameInput(owner.page); + await expect(input).toBeVisible(); + // Autofocuses with the current name pre-filled and selected. + await expect(input).toBeFocused(); + await expect(input).toHaveValue(title); + + const newTitle = `${title} renamed`; + await input.fill(newTitle); + await input.press("Enter"); + + await expect(input).toBeHidden(); + await expect(titleCrumb(owner.page)).toHaveText(newTitle); + + // Persisted server-side: survives a reload. + await owner.page.reload(); + await expect(titleCrumb(owner.page)).toHaveText(newTitle); +}); + +test("keeps focus and selection when the breadcrumb rename opens", async ({ owner, run }) => { + const title = uniqueWriterTitle(run.run_id, "focus"); + const file = await createWriterDocument(owner.page.request, title); + await openWriterDocument(owner.page, file.name); + + await titleCrumb(owner.page).click(); + const input = renameInput(owner.page); + + // Regression: the field must stay focused (not blur-commit and exit) so the + // selection stays visible for an immediate retype. + await expect(input).toBeFocused(); + await expect(input).toBeVisible(); + await owner.page.waitForTimeout(400); + await expect(input).toBeFocused(); + + const selectionLength = await input.evaluate( + (el: HTMLInputElement) => (el.selectionEnd ?? 0) - (el.selectionStart ?? 0), + ); + expect(selectionLength).toBe(title.length); +}); + +test("Escape cancels the rename without changing the name", async ({ owner, run }) => { + const title = uniqueWriterTitle(run.run_id, "cancel"); + const file = await createWriterDocument(owner.page.request, title); + await openWriterDocument(owner.page, file.name); + + await titleCrumb(owner.page).click(); + const input = renameInput(owner.page); + await expect(input).toBeVisible(); + + await input.fill("Discarded name"); + await input.press("Escape"); + + await expect(input).toBeHidden(); + await expect(titleCrumb(owner.page)).toHaveText(title); +}); diff --git a/e2e/drive-backed-apps/specs/writer/tabs.spec.ts b/e2e/drive-backed-apps/specs/writer/tabs.spec.ts index 4aee749ba7..71c914e67f 100644 --- a/e2e/drive-backed-apps/specs/writer/tabs.spec.ts +++ b/e2e/drive-backed-apps/specs/writer/tabs.spec.ts @@ -3,7 +3,29 @@ import { createWriterDocument, openWriterDocument, uniqueWriterTitle, + writerEditor, } from "../../helpers/writer"; +import type { Page } from "@playwright/test"; + +// Tab buttons in the desktop ToC are the only draggable elements on the page. +function tocTabs(page: Page) { + return page.locator('[draggable="true"]'); +} + +function tabPanel(page: Page, label: string) { + return page.locator(`[data-tab-label="${label}"]`); +} + +// Typing a first line and pressing Enter renames an "Untitled" tab to that line. +async function addTab(page: Page, text: string, first = false) { + await page + .getByRole("button", { name: first ? "Create tab" : "Add tab" }) + .click(); + await writerEditor(page).click(); + await page.keyboard.type(text); + await page.keyboard.press("Enter"); + await expect(tocTabs(page).filter({ hasText: text })).toHaveCount(1); +} // Regression for the mobile tab bar (ToCMobile): // 1. a tab added while the bar is already mounted must appear in it, and @@ -50,3 +72,58 @@ test("mobile tab bar reflects added tabs and switches the active panel", async ( await expect(page.locator(`[data-tab-id="${ids[0]}"]`)).toBeVisible(); await expect(page.locator(`[data-tab-id="${ids[1]}"]`)).toBeHidden(); }); + +// Reordering used to delete the tab node and re-insert a copy, which Yjs cannot +// merge as a move: the tab could end up duplicated, with its content split +// across both copies. Order now lives in an attribute and nodes never move. +test("reordering tabs preserves every tab's content", async ({ owner, run }) => { + const { page } = owner; + const duplicateWarnings: string[] = []; + page.on("console", (message) => { + if (message.text().includes("duplicate tab ids")) { + duplicateWarnings.push(message.text()); + } + }); + + const file = await createWriterDocument( + page.request, + uniqueWriterTitle(run.run_id, "tabs-reorder"), + ); + await page.addInitScript(() => window.localStorage.setItem("showToc", "true")); + await openWriterDocument(page, file.name); + + await addTab(page, "alpha", true); + await addTab(page, "bravo"); + await addTab(page, "charlie"); + await expect(tocTabs(page)).toHaveText(["alpha", "bravo", "charlie"]); + + // Drop above the midpoint of the first tab so charlie lands at the top. + await tocTabs(page) + .nth(2) + .dragTo(tocTabs(page).nth(0), { targetPosition: { x: 10, y: 2 } }); + + await expect(tocTabs(page)).toHaveText(["charlie", "alpha", "bravo"]); + + const panels = page.locator("[data-tab-id]"); + await expect(panels).toHaveCount(3); + const ids = await panels.evaluateAll((els) => + els.map((el) => el.getAttribute("data-tab-id")), + ); + expect(new Set(ids).size).toBe(3); + + for (const label of ["alpha", "bravo", "charlie"]) { + await expect(tabPanel(page, label)).toContainText(label); + await expect(tabPanel(page, label)).toHaveCount(1); + } + + // The new order and the content survive a reload of the stored document. + await page.reload(); + await expect(writerEditor(page)).toBeVisible(); + await expect(tocTabs(page)).toHaveText(["charlie", "alpha", "bravo"]); + for (const label of ["alpha", "bravo", "charlie"]) { + await expect(tabPanel(page, label)).toContainText(label); + } + await expect(page.locator("[data-tab-id]")).toHaveCount(3); + + expect(duplicateWarnings).toEqual([]); +}); diff --git a/frontend/src/apps/drive/components/ConfirmDialog.vue b/frontend/src/apps/drive/components/ConfirmDialog.vue deleted file mode 100644 index 2ee2489218..0000000000 --- a/frontend/src/apps/drive/components/ConfirmDialog.vue +++ /dev/null @@ -1,157 +0,0 @@ - - diff --git a/frontend/src/apps/drive/components/CustomListRow.vue b/frontend/src/apps/drive/components/CustomListRow.vue deleted file mode 100644 index 4b874c54ed..0000000000 --- a/frontend/src/apps/drive/components/CustomListRow.vue +++ /dev/null @@ -1,76 +0,0 @@ - - diff --git a/frontend/src/apps/drive/components/DriveListRow.vue b/frontend/src/apps/drive/components/DriveListRow.vue new file mode 100644 index 0000000000..9c2b2160a4 --- /dev/null +++ b/frontend/src/apps/drive/components/DriveListRow.vue @@ -0,0 +1,314 @@ + + + diff --git a/frontend/src/apps/drive/components/DriveListSkeleton.vue b/frontend/src/apps/drive/components/DriveListSkeleton.vue index 75dc8ecde9..9485220f2b 100644 --- a/frontend/src/apps/drive/components/DriveListSkeleton.vue +++ b/frontend/src/apps/drive/components/DriveListSkeleton.vue @@ -1,5 +1,5 @@