// Multi-select on the project page: deleting several videos at once, and moving // several videos into another project in the same workspace. // // Both flows are behind a selection mode that is only reachable through a card's // overflow menu, so the entry point is exercised here too. The assertions are // deliberately about rows that disappear from one page and appear on another, // not about the toast: a toast can be rendered by a handler that then does // nothing. import { test, expect } from './fixtures'; import { createProject, createVideo, createVersion } from '../factories'; import type { Page } from '@playwright/test'; /** * The overflow ("more") button on the card for `title`. * * That button is icon-only and has no accessible name, so it cannot be reached * by role and name. The

title can, and the button is a sibling of the link * that wraps it: h3 -> link -> the flex row that holds both. See * components/video-card.tsx. */ function cardMenuFor(page: Page, title: string) { return page .getByRole('heading', { name: title, level: 3 }) .locator('xpath=../..') .getByRole('button'); } /** Puts the page into selection mode through the first card's overflow menu. */ async function enterSelectionMode(page: Page, anyTitle: string): Promise { await cardMenuFor(page, anyTitle).click(); await page.getByRole('menuitem', { name: 'Select' }).click(); await expect(page.getByText('Selection mode')).toBeVisible(); } /** * Three videos in one project, each with an active version. * * The provider is `youtube`, which needs no object storage: nothing here plays * a video, it only lists and deletes them. */ async function seedVideos(projectId: string, titles: string[]): Promise { for (const title of titles) { const video = await createVideo({ projectId, title }); await createVersion({ videoParentId: video.id, providerId: 'youtube', providerVideoId: 'dQw4w9WgXcQ', title, duration: 120, }); } } test('two of three videos are selected and deleted, and the third survives', async ({ page, seed, seededUser, }) => { const stamp = Date.now(); const doomedA = `Bulk Doomed A ${stamp}`; const doomedB = `Bulk Doomed B ${stamp}`; const survivor = `Bulk Survivor ${stamp}`; const { project } = await seed.project(seededUser); await seedVideos(project.id, [doomedA, doomedB, survivor]); await page.goto(`/projects/${project.id}`); for (const title of [doomedA, doomedB, survivor]) { await expect(page.getByRole('heading', { name: title, level: 3 })).toBeVisible(); } await enterSelectionMode(page, doomedA); // Nothing is selected by the act of entering the mode, so the destructive // button starts disabled. That is the control for the click below. const deleteSelected = page.getByRole('button', { name: 'Delete selected' }); await expect(deleteSelected).toBeDisabled(); await page.getByRole('checkbox', { name: `Select ${doomedA}` }).click(); await page.getByRole('checkbox', { name: `Select ${doomedB}` }).click(); await expect(page.getByText('2 selected')).toBeVisible(); await expect(deleteSelected).toBeEnabled(); await deleteSelected.click(); const dialog = page.getByRole('alertdialog'); await expect(dialog.getByRole('heading', { name: 'Delete 2 videos?' })).toBeVisible(); await dialog.getByRole('button', { name: 'Delete selected' }).click(); await expect(page.getByRole('heading', { name: doomedA, level: 3 })).toHaveCount(0); await expect(page.getByRole('heading', { name: doomedB, level: 3 })).toHaveCount(0); await expect(page.getByRole('heading', { name: survivor, level: 3 })).toBeVisible(); // Gone from the database, not just from the client-side list that // handleDeleteSelected filters. A reload re-renders from the server. await page.reload(); await expect(page.getByRole('heading', { name: survivor, level: 3 })).toBeVisible(); await expect(page.getByRole('heading', { name: doomedA, level: 3 })).toHaveCount(0); await expect(page.getByRole('heading', { name: doomedB, level: 3 })).toHaveCount(0); }); test('selected videos are moved into another project in the same workspace', async ({ page, seed, seededUser, }) => { const stamp = Date.now(); const moving = `Bulk Moving ${stamp}`; const staying = `Bulk Staying ${stamp}`; // Both projects must share a workspace: the move dialog offers only // destinations inside it (app/api/projects/[projectId]/videos/move GET). const { project: source, workspaceId } = await seed.project(seededUser); const destination = await createProject({ ownerId: seededUser.id, workspaceId, name: `Bulk Destination ${stamp}`, slug: `e2e-bulk-destination-${stamp}`, }); await seedVideos(source.id, [moving, staying]); await page.goto(`/projects/${source.id}`); await enterSelectionMode(page, moving); await page.getByRole('checkbox', { name: `Select ${moving}` }).click(); await expect(page.getByText('1 selected')).toBeVisible(); await page.getByRole('button', { name: 'Move to project' }).click(); const dialog = page.getByRole('dialog'); await expect( dialog.getByRole('heading', { name: 'Move video to another project' }) ).toBeVisible(); // The destination list is fetched when the dialog opens; the combobox does // not exist until it arrives. const destinationSelect = dialog.getByRole('combobox'); await expect(destinationSelect).toBeVisible(); await destinationSelect.click(); await page.getByRole('option', { name: destination.name }).click(); await dialog.getByRole('button', { name: 'Move' }).click(); await expect(page.getByText('1 video moved')).toBeVisible(); // Left the source... await page.reload(); await expect(page.getByRole('heading', { name: moving, level: 3 })).toHaveCount(0); await expect(page.getByRole('heading', { name: staying, level: 3 })).toBeVisible(); // ...and arrived in the destination. Without both halves this passes for a // delete as readily as for a move. await page.goto(`/projects/${destination.id}`); await expect(page.getByRole('heading', { name: moving, level: 3 })).toBeVisible(); });