From a1a5688fd034720a7e82b59db01e0e4e7d25564f Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Tue, 3 Mar 2026 09:45:21 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=85(frontend)=20add=20custom=20columns=20?= =?UTF-8?q?E2E=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test column visibility toggling, sort interactions, and preference persistence across navigation. Add shared test utilities for column assertions. --- CHANGELOG.md | 1 + src/frontend/apps/drive/__mocks__/fileMock.js | 2 +- .../apps/drive/__mocks__/pretty-bytes.js | 3 + src/frontend/apps/drive/jest.config.ts | 3 +- src/frontend/apps/drive/svg.d.ts | 2 +- .../app-drive/custom-columns.spec.ts | 243 ++++++++++++++++++ .../app-drive/heic-file-preview.spec.ts | 9 +- .../app-drive/utils/custom-columns-utils.ts | 149 +++++++++++ .../apps/e2e/__tests__/app-drive/wopi.spec.ts | 9 +- 9 files changed, 407 insertions(+), 14 deletions(-) create mode 100644 src/frontend/apps/drive/__mocks__/pretty-bytes.js create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/custom-columns.spec.ts create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/utils/custom-columns-utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a7bf36..a7304fba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to - ✨(frontend) add PDF viewer with thumbnail sidebar, zoom and page navigation - ✨(frontend) integrate PDF viewer into file preview modal - 📝(doc) add local network setup documentation +- ✨(global) add custom columns feature with configurable grid columns ### Changed diff --git a/src/frontend/apps/drive/__mocks__/fileMock.js b/src/frontend/apps/drive/__mocks__/fileMock.js index 57a4949e..47ac7290 100644 --- a/src/frontend/apps/drive/__mocks__/fileMock.js +++ b/src/frontend/apps/drive/__mocks__/fileMock.js @@ -1,2 +1,2 @@ -module.exports = "test-file-stub"; +module.exports = { src: "test-file-stub", height: 1, width: 1 }; diff --git a/src/frontend/apps/drive/__mocks__/pretty-bytes.js b/src/frontend/apps/drive/__mocks__/pretty-bytes.js new file mode 100644 index 00000000..5347ac00 --- /dev/null +++ b/src/frontend/apps/drive/__mocks__/pretty-bytes.js @@ -0,0 +1,3 @@ +module.exports = function prettyBytes(number) { + return String(number); +}; diff --git a/src/frontend/apps/drive/jest.config.ts b/src/frontend/apps/drive/jest.config.ts index 8f83dcd4..96fd0318 100644 --- a/src/frontend/apps/drive/jest.config.ts +++ b/src/frontend/apps/drive/jest.config.ts @@ -11,6 +11,7 @@ const config: Config = { // Handle static assets FIRST (before path aliases) "\\.(css|less|scss|sass|svg|png|jpg|jpeg|gif)$": "/__mocks__/fileMock.js", + "^pretty-bytes$": "/__mocks__/pretty-bytes.js", // Then handle path aliases ...pathsToModuleNameMapper(tsconfig.compilerOptions.paths || {}, { prefix: "/", @@ -21,7 +22,7 @@ const config: Config = { "ts-jest", { tsconfig: { - jsx: "react", + jsx: "react-jsx", moduleResolution: "node", }, }, diff --git a/src/frontend/apps/drive/svg.d.ts b/src/frontend/apps/drive/svg.d.ts index e1527082..f10a3b32 100644 --- a/src/frontend/apps/drive/svg.d.ts +++ b/src/frontend/apps/drive/svg.d.ts @@ -1,5 +1,5 @@ declare module "*.svg" { - const content: string; + const content: { src: string; height: number; width: number }; export default content; } diff --git a/src/frontend/apps/e2e/__tests__/app-drive/custom-columns.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/custom-columns.spec.ts new file mode 100644 index 00000000..d7b18f0d --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-drive/custom-columns.spec.ts @@ -0,0 +1,243 @@ +import test, { expect } from "@playwright/test"; +import path from "path"; +import { clearDb, login } from "./utils-common"; +import { clickToMyFiles, clickToRecent } from "./utils-navigate"; +import { + createFolderInCurrentFolder, + createFileFromTemplate, + importFile, +} from "./utils-item"; +import { + changeColumnType, + clickColumnSortButton, + clickNameSortButton, + expectColumnHeaderLabel, + expectRowNamesInOrder, + getCellText, + getColumnHeader, +} from "./utils/custom-columns-utils"; + +const PDF_FILE_PATH = path.join(__dirname, "/assets/pv_cm.pdf"); +const DOCX_FILE_PATH = path.join(__dirname, "/assets/empty_doc.docx"); + +test.describe("Custom columns", () => { + test.beforeEach(async ({ page }) => { + await clearDb(); + await login(page, "drive@example.com"); + await page.goto("/"); + await clickToMyFiles(page); + }); + + // ── Group 1: Default columns ────────────────────────────────── + + test("Default columns are Last modified and Created by", async ({ + page, + }) => { + await createFolderInCurrentFolder(page, "TestFolder"); + + await expectColumnHeaderLabel(page, 1, "Last modified"); + await expectColumnHeaderLabel(page, 2, "Created by"); + + // The Last modified cell should show a relative time + const cellText = await getCellText(page, "TestFolder", 1); + expect(cellText).toMatch(/seconds? ago|minute/); + }); + + // ── Group 2: Change column types ────────────────────────────── + + test("Change column 1 type via dropdown", async ({ page }) => { + await importFile(page, PDF_FILE_PATH); + // Wait for the uploaded file to appear (no optimistic update, needs API round-trip) + const fileRow = page.getByRole("row").filter({ hasText: "pv_cm" }).first(); + await expect(fileRow).toBeVisible({ timeout: 15000 }); + + await changeColumnType(page, 1, "File size"); + await expectColumnHeaderLabel(page, 1, "File size"); + + // File should show a size value (not "-") + const cellText = await getCellText(page, "pv_cm", 1); + expect(cellText).not.toBe("-"); + expect(cellText).toBeTruthy(); + }); + + test("Change column 2 type via dropdown", async ({ page }) => { + await createFolderInCurrentFolder(page, "MyFolder"); + + await changeColumnType(page, 2, "File type"); + await expectColumnHeaderLabel(page, 2, "File type"); + + const cellText = await getCellText(page, "MyFolder", 2); + expect(cellText).toBe("Folder"); + }); + + test("Change both columns", async ({ page }) => { + await createFolderInCurrentFolder(page, "MyFolder"); + + await changeColumnType(page, 1, "Created"); + await changeColumnType(page, 2, "File size"); + + await expectColumnHeaderLabel(page, 1, "Created"); + await expectColumnHeaderLabel(page, 2, "File size"); + + // Folder has no file size + const sizeText = await getCellText(page, "MyFolder", 2); + expect(sizeText).toBe("-"); + + // Created should show a relative time + const createdText = await getCellText(page, "MyFolder", 1); + expect(createdText).toMatch(/seconds? ago|minute/); + }); + + // ── Group 3: Persistence ────────────────────────────────────── + + test("Column preferences persist after page reload", async ({ page }) => { + await createFolderInCurrentFolder(page, "MyFolder"); + + await changeColumnType(page, 1, "File type"); + await expectColumnHeaderLabel(page, 1, "File type"); + + await page.reload(); + await clickToMyFiles(page); + + await expectColumnHeaderLabel(page, 1, "File type"); + }); + + test("Column preferences are shared across views", async ({ page }) => { + // Create a file (not a folder) because Recents uses files_only mode + await createFileFromTemplate(page, "TestDoc"); + + await changeColumnType(page, 1, "File size"); + await expectColumnHeaderLabel(page, 1, "File size"); + + await clickToRecent(page); + // Wait for the file to appear in Recents + const fileRow = page + .getByRole("row") + .filter({ hasText: "TestDoc" }) + .first(); + await expect(fileRow).toBeVisible({ timeout: 10000 }); + + await expectColumnHeaderLabel(page, 1, "File size"); + }); + + // ── Group 4: Sorting ────────────────────────────────────────── + + test("Sort by Name column cycles through asc/desc/default", async ({ + page, + }) => { + await createFolderInCurrentFolder(page, "AAA"); + await createFolderInCurrentFolder(page, "CCC"); + await createFolderInCurrentFolder(page, "BBB"); + + // Default order: folders first, sorted by title = AAA, BBB, CCC + await expectRowNamesInOrder(page, ["AAA", "BBB", "CCC"]); + + // Click sort → ascending + await clickNameSortButton(page); + await expectRowNamesInOrder(page, ["AAA", "BBB", "CCC"]); + + // Click sort → descending + await clickNameSortButton(page); + await expectRowNamesInOrder(page, ["CCC", "BBB", "AAA"]); + + // Click sort → reset to default + await clickNameSortButton(page); + await expectRowNamesInOrder(page, ["AAA", "BBB", "CCC"]); + }); + + test("Sort by a customizable column (Last modified)", async ({ page }) => { + await createFolderInCurrentFolder(page, "First"); + // Delay to ensure distinct updated_at timestamps + await page.waitForTimeout(2000); + await createFolderInCurrentFolder(page, "Second"); + + // Click sort on col1 (Last modified) → ascending (oldest first) + await clickColumnSortButton(page, 1); + await expectRowNamesInOrder(page, ["First", "Second"]); + + // Click again → descending (newest first) + await clickColumnSortButton(page, 1); + await expectRowNamesInOrder(page, ["Second", "First"]); + }); + + test("Sort by File size after changing column type", async ({ page }) => { + // Import two files of different sizes to avoid folders_first interference + await importFile(page, PDF_FILE_PATH); + await page.getByRole("row").filter({ hasText: "pv_cm" }).first().waitFor({ + state: "visible", + timeout: 15000, + }); + + await importFile(page, DOCX_FILE_PATH); + await page + .getByRole("row") + .filter({ hasText: "empty_doc" }) + .first() + .waitFor({ state: "visible", timeout: 15000 }); + + await changeColumnType(page, 1, "File size"); + await expectColumnHeaderLabel(page, 1, "File size"); + + // Click sort on col1 → ascending (smallest first) + await clickColumnSortButton(page, 1); + await expectRowNamesInOrder(page, ["empty_doc", "pv_cm"]); + + // Click again → descending (largest first) + await clickColumnSortButton(page, 1); + await expectRowNamesInOrder(page, ["pv_cm", "empty_doc"]); + }); + + // ── Group 5: Cell content ───────────────────────────────────── + + test("Cells display correct content for each column type", async ({ + page, + }) => { + await createFolderInCurrentFolder(page, "TestFolder"); + + // Switch col1 to File size → folders show "-" + await changeColumnType(page, 1, "File size"); + const sizeText = await getCellText(page, "TestFolder", 1); + expect(sizeText).toBe("-"); + + // Switch col2 to File type → folders show "Folder" + await changeColumnType(page, 2, "File type"); + const typeText = await getCellText(page, "TestFolder", 2); + expect(typeText).toBe("Folder"); + + // Switch col1 to Created → shows relative time + await changeColumnType(page, 1, "Created"); + const createdText = await getCellText(page, "TestFolder", 1); + expect(createdText).toMatch(/seconds? ago|minute/); + }); + + // ── Group 6: Default marker in dropdown ─────────────────────── + + test("Default column option is marked in dropdown", async ({ page }) => { + await createFolderInCurrentFolder(page, "TestFolder"); + + // Open col1 dropdown — default for col1 is "Last modified" + const header1 = getColumnHeader(page, 1); + const dropdownButton1 = header1 + .locator(".explorer__grid__header button") + .first(); + await dropdownButton1.click(); + + await expect( + page.getByRole("menuitem", { name: "Last modified (default)" }), + ).toBeVisible(); + + // Close dropdown + await page.keyboard.press("Escape"); + + // Open col2 dropdown — default for col2 is "Created by" + const header2 = getColumnHeader(page, 2); + const dropdownButton2 = header2 + .locator(".explorer__grid__header button") + .first(); + await dropdownButton2.click(); + + await expect( + page.getByRole("menuitem", { name: "Created by (default)" }), + ).toBeVisible(); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-drive/heic-file-preview.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/heic-file-preview.spec.ts index 8c5b8576..a6af93ae 100644 --- a/src/frontend/apps/e2e/__tests__/app-drive/heic-file-preview.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-drive/heic-file-preview.spec.ts @@ -2,6 +2,7 @@ import test, { expect } from "@playwright/test"; import { clearDb, login } from "./utils-common"; import path from "path"; import { clickToMyFiles } from "./utils-navigate"; +import { getRowItem } from "./utils-embedded-grid"; import { uploadFile } from "./utils/upload-utils"; test("Display HEIC not supported message when opening a HEIC file", async ({ @@ -21,14 +22,10 @@ test("Display HEIC not supported message when opening a HEIC file", async ({ // Wait for the file to be uploaded and visible in the list await expect(page.getByText("Drop your files here")).not.toBeVisible(); - await expect(page.getByRole("cell", { name: "test-image.heic" })).toBeVisible( - { - timeout: 10000, - }, - ); // Click on the HEIC file to open the preview - await page.getByRole("cell", { name: "test-image.heic" }).dblclick(); + const row = await getRowItem(page, "test-image"); + await row.dblclick(); // Check that the file preview is visible const filePreview = page.getByTestId("file-preview"); diff --git a/src/frontend/apps/e2e/__tests__/app-drive/utils/custom-columns-utils.ts b/src/frontend/apps/e2e/__tests__/app-drive/utils/custom-columns-utils.ts new file mode 100644 index 00000000..a1b27a07 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-drive/utils/custom-columns-utils.ts @@ -0,0 +1,149 @@ +import { expect, Locator, Page } from "@playwright/test"; + +/** + * Get the element for a customizable column slot. + */ +export const getColumnHeader = (page: Page, slot: 1 | 2): Locator => { + return page.locator(`th.explorer__grid__th--info-col-${slot}`); +}; + +/** + * Change the type of a customizable column via its dropdown menu. + */ +export const changeColumnType = async ( + page: Page, + slot: 1 | 2, + columnLabel: string, +) => { + const header = getColumnHeader(page, slot); + // Click the dropdown button inside the header (first button = dropdown trigger) + const dropdownButton = header + .locator(".explorer__grid__header button") + .first(); + await dropdownButton.click(); + + // Select the desired column type — use exact to avoid "Created" matching "Created by" + const menuItem = page.getByRole("menuitem", { + name: columnLabel, + exact: true, + }); + await expect(menuItem).toBeVisible(); + await menuItem.click(); +}; + +/** + * Assert that a column header displays the expected label. + */ +export const expectColumnHeaderLabel = async ( + page: Page, + slot: 1 | 2, + expectedLabel: string, +) => { + const header = getColumnHeader(page, slot); + const button = header.locator(".explorer__grid__header button").first(); + await expect(button).toContainText(expectedLabel); +}; + +/** + * Click a sort button via native JS click (bypasses tooltip overlay). + * Waits for the aria-label to change, confirming the click was processed. + */ +const clickSortAndWaitForStateChange = async ( + sortButton: Locator, + nextLabel: string, +) => { + // Use evaluate to trigger a native click — tooltips can intercept Playwright clicks + await sortButton.evaluate((el) => (el as HTMLButtonElement).click()); + // Wait for the button aria-label to change (state updated) + await expect(sortButton).toHaveAttribute("aria-label", nextLabel, { + timeout: 10000, + }); +}; + +const SORT_TRANSITIONS: Record = { + "Sort ascending": "Sort descending", + "Sort descending": "Reset sorting", + "Reset sorting": "Sort ascending", +}; + +/** + * Click the sort button for the "Name" column header. + */ +export const clickNameSortButton = async (page: Page) => { + const nameHeader = page.locator("th").nth(1); // second th = Name column + const sortButton = nameHeader.locator( + ".explorer__grid__header button[aria-label]", + ); + const ariaLabel = await sortButton.getAttribute("aria-label"); + const nextLabel = SORT_TRANSITIONS[ariaLabel ?? ""]; + if (nextLabel) { + await clickSortAndWaitForStateChange(sortButton, nextLabel); + } else { + await sortButton.evaluate((el) => (el as HTMLButtonElement).click()); + } +}; + +/** + * Click the sort button for a customizable column (slot 1 or 2). + */ +export const clickColumnSortButton = async (page: Page, slot: 1 | 2) => { + const header = getColumnHeader(page, slot); + // Second button in the header = sort button (first = dropdown trigger) + const sortButton = header.locator(".explorer__grid__header button").nth(1); + const ariaLabel = await sortButton.getAttribute("aria-label"); + const nextLabel = SORT_TRANSITIONS[ariaLabel ?? ""]; + if (nextLabel) { + await clickSortAndWaitForStateChange(sortButton, nextLabel); + } else { + await sortButton.evaluate((el) => (el as HTMLButtonElement).click()); + } +}; + +/** + * Get all visible row names in order from the grid. + */ +export const getRowNamesInOrder = async (page: Page): Promise => { + const nameElements = page.locator( + "tbody tr.selectable .explorer__grid__item__name", + ); + const count = await nameElements.count(); + if (count === 0) return []; + const names: string[] = []; + for (let i = 0; i < count; i++) { + const text = (await nameElements.nth(i).textContent()) ?? ""; + names.push(text.trim()); + } + return names; +}; + +/** + * Wait for row names to match the expected order (retries automatically). + */ +export const expectRowNamesInOrder = async ( + page: Page, + expectedNames: string[], +) => { + await expect + .poll(async () => getRowNamesInOrder(page), { timeout: 10000 }) + .toEqual(expectedNames); +}; + +/** + * Get the text content of a cell in a specific row at a given column slot. + * Slot 1 = first info column (3rd td), slot 2 = second info column (4th td). + */ +export const getCellText = async ( + page: Page, + rowName: string, + slot: 1 | 2, +): Promise => { + const row = page + .getByRole("row", { name: rowName }) + .filter({ hasText: rowName }) + .first(); + await expect(row).toBeVisible(); + // td indices: 0=mobile, 1=title, 2=info-col-1, 3=info-col-2, 4=actions + const cellIndex = slot === 1 ? 2 : 3; + const cell = row.locator("td").nth(cellIndex); + return ((await cell.textContent()) ?? "").trim(); +}; diff --git a/src/frontend/apps/e2e/__tests__/app-drive/wopi.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/wopi.spec.ts index cb6b3738..b91dc7ac 100644 --- a/src/frontend/apps/e2e/__tests__/app-drive/wopi.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-drive/wopi.spec.ts @@ -2,6 +2,7 @@ import path from "path"; import test, { expect } from "@playwright/test"; import { clearDb, login } from "./utils-common"; import { clickToMyFiles } from "./utils-navigate"; +import { getRowItem } from "./utils-embedded-grid"; import { uploadFile } from "./utils/upload-utils"; import { grantClipboardPermissions } from "./utils/various-utils"; @@ -19,12 +20,10 @@ test("Copy and paste works in wopi editor", async ({ page, context, browserName // Wait for the file to be uploaded and visible in the list await expect(page.getByText("Drop your files here")).not.toBeVisible(); - await expect(page.getByRole("cell", { name: "empty_doc.docx" })).toBeVisible({ - timeout: 10000, - }); - // Click on the HEIC file to open the preview - await page.getByRole("cell", { name: "empty_doc.docx" }).dblclick(); + // Click on the file to open the preview + const row = await getRowItem(page, "empty_doc"); + await row.dblclick(); // Check that the file preview is visible const filePreview = page.getByTestId("file-preview");