From fab1e9c7a831101c88f66ccbb996b22bf69c410f Mon Sep 17 00:00:00 2001
From: ericasch6
+ {{ t('board.dragHint') }} +
+ {{ t('board.dropHint') }} +
++ {{ + draggedTicket + ? t('board.dragging', { key: draggedTicket.ticket_key }) + : '' + }} +
+ diff --git a/tests/Feature/Projects/ProjectBoardControllerTest.php b/tests/Feature/Projects/ProjectBoardControllerTest.php index 63d66f7..4e62766 100644 --- a/tests/Feature/Projects/ProjectBoardControllerTest.php +++ b/tests/Feature/Projects/ProjectBoardControllerTest.php @@ -212,6 +212,19 @@ class ProjectBoardControllerTest extends TestCase 'entry_type' => 'status_changed', 'summary' => 'Moved from Todo to Done.', ]); + + $this->assertSame(1, $ticket->worklogEntries()->count()); + + $this->actingAs($user) + ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ + 'redirect_to' => 'board', + 'ticket_status_id' => $doneStatus->id, + ]) + ->assertRedirect(route('projects.board.index', $project)) + ->assertSessionHasNoErrors(); + + $this->assertSame($doneStatus->id, $ticket->fresh()->ticket_status_id); + $this->assertSame(1, $ticket->worklogEntries()->count()); } /** diff --git a/tests/Fixtures/board-drag-browser.mjs b/tests/Fixtures/board-drag-browser.mjs new file mode 100644 index 0000000..0db1f24 --- /dev/null +++ b/tests/Fixtures/board-drag-browser.mjs @@ -0,0 +1,288 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; + +const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright'); +const { chromium } = playwright.default ?? playwright; +const browser = await chromium.launch({ channel: 'chrome', headless: true }); +const page = await browser.newPage({ + viewport: { width: 1440, height: 1200 }, + hasTouch: true, +}); +page.setDefaultTimeout(10000); +const failures = []; +const requests = []; +page.on('pageerror', (error) => failures.push(error.message)); +page.on('request', (request) => { + if (['POST', 'PATCH'].includes(request.method())) { + requests.push(request); + } +}); +const base = 'http://127.0.0.1:4181/projects/7/board'; +const handle = () => + page.getByRole('button', { + name: 'Drag ENG-42 to change status', + exact: true, + }); +const card = () => page.locator('article').filter({ hasText: 'ENG-42' }); +const visit = async (query = '') => { + await page.goto(base + query); + await page.getByRole('heading', { level: 1 }).waitFor(); + requests.length = 0; +}; +const center = async (locator) => { + const box = await locator.boundingBox(); + assert.ok(box); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +}; +const begin = async () => { + await handle().scrollIntoViewIfNeeded(); + const from = await center(handle()); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(from.x + 12, from.y, { steps: 3 }); +}; +const moveToColumn = async (id) => { + const to = await center(page.locator(`#board-column-title-${id}`)); + await page.mouse.move(to.x, to.y, { steps: 10 }); +}; +const dragToColumn = async (id) => { + await begin(); + await moveToColumn(id); + await page.mouse.up(); +}; +const originalCard = async () => { + assert.equal( + await page + .locator('#board-column-1 article') + .filter({ hasText: 'ENG-42' }) + .count(), + 1, + ); +}; +try { + for (const theme of ['light', 'dark']) { + await page.emulateMedia({ + colorScheme: theme, + reducedMotion: 'reduce', + }); + await visit(); + assert.equal( + await page + .getByRole('button', { name: 'Drag ENG-43 to change status' }) + .isDisabled(), + true, + ); + await begin(); + assert.equal( + await page + .locator('#board-column-2') + .evaluate((el) => el.classList.contains('border-primary')), + true, + ); + assert.equal( + await page + .locator('#board-column-3') + .evaluate((el) => el.classList.contains('opacity-40')), + true, + ); + await moveToColumn(3); + await page.mouse.up(); + await originalCard(); + assert.equal(requests.length, 0); + + await begin(); + await moveToColumn(2); + await page.keyboard.press('Escape'); + await page.mouse.up(); + assert.equal(requests.length, 0); + await originalCard(); + + // Pointer capture cancellation must never become a drop. + await begin(); + await handle().dispatchEvent('pointercancel', { pointerId: 1 }); + await page.mouse.up(); + assert.equal(requests.length, 0); + + await visit('?multiple'); + await dragToColumn(2); + const dialog = page.getByRole('dialog'); + await dialog.waitFor(); + assert.equal(requests.length, 0); + assert.deepEqual( + await dialog + .locator('option') + .evaluateAll((options) => + options.map((option) => option.value), + ), + ['20', '21'], + ); + await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'hidden' }); + assert.equal( + await handle().evaluate((el) => el === document.activeElement), + true, + ); + assert.equal(requests.length, 0); + await originalCard(); + await dragToColumn(2); + const select = dialog.getByLabel('Destination status'); + await select.focus(); + await select.selectOption('21'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + assert.equal(await page.locator(':focus').innerText(), 'Update status'); + await page.keyboard.press('Enter'); + await page + .getByText('ENG-42 moved successfully.', { exact: true }) + .waitFor(); + assert.equal(requests.length, 1); + assert.deepEqual(requests[0].postDataJSON(), { + ticket_status_id: 21, + redirect_to: 'board', + }); + assert.equal( + await page + .locator('#board-column-2 article') + .filter({ hasText: 'ENG-42' }) + .count(), + 1, + ); + + await visit('?reject'); + await dragToColumn(2); + await page + .getByText('This transition is no longer allowed.', { exact: true }) + .waitFor(); + await originalCard(); + assert.equal(requests.length, 1); + + await visit('?slow'); + await dragToColumn(2); + await page.locator('[data-form-state="loading"]').waitFor(); + assert.equal(await handle().isDisabled(), true); + assert.equal( + await page.getByLabel('Move ENG-42', { exact: true }).isDisabled(), + true, + ); + // Attempt a competing form submit even though its controls are disabled. + await card() + .locator('form') + .evaluate((form) => form.requestSubmit()); + await page + .getByText('ENG-42 moved successfully.', { exact: true }) + .waitFor(); + assert.equal(requests.length, 1); + + await visit('?slow'); + await card() + .getByRole('button', { name: 'Update status', exact: true }) + .click(); + assert.equal(await handle().isDisabled(), true); + await page + .getByText('ENG-42 moved successfully.', { exact: true }) + .waitFor(); + assert.equal(requests.length, 1); + + await visit(); + await page.route('**/tickets/42/status', (route) => + route.abort('failed'), + ); + await dragToColumn(2); + await page.locator('[data-form-state="network"]').waitFor(); + await originalCard(); + assert.equal(await handle().isEnabled(), true); + await page.unroute('**/tickets/42/status'); + await page + .getByRole('button', { name: 'Try again', exact: true }) + .click(); + await page + .getByText('ENG-42 moved successfully.', { exact: true }) + .waitFor(); + assert.equal(requests.length, 2); + + await visit(); + await page.route('**/tickets/42/status', (route) => + route.fulfill({ status: 500, body: 'Unavailable' }), + ); + await dragToColumn(2); + await page.locator('[data-form-state="server"]').waitFor(); + await originalCard(); + await page.unroute('**/tickets/42/status'); + + // Normal touch gestures still scroll; only the explicit handle captures a drag. + await page.setViewportSize({ width: 390, height: 1000 }); + await visit('?multiple'); + await handle().scrollIntoViewIfNeeded(); + assert.equal( + await handle().evaluate((el) => getComputedStyle(el).touchAction), + 'none', + ); + assert.notEqual( + await card().evaluate((el) => getComputedStyle(el).touchAction), + 'none', + ); + const cdp = await page.context().newCDPSession(page); + const from = await center(handle()); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ ...from, id: 1 }], + }); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x: from.x + 16, y: from.y, id: 1 }], + }); + const tray = page.locator('[data-board-mobile-targets]'); + await tray.waitFor(); + const to = await center(tray.locator('[data-board-drop-column="2"]')); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ ...to, id: 1 }], + }); + await page.screenshot({ + path: `/private/tmp/kan28-mobile-drag-${theme}.png`, + fullPage: true, + }); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + true, + ); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + }); + await page.getByRole('dialog').waitFor(); + await page.getByLabel('Destination status').selectOption('20'); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Update status' }) + .click(); + await page + .getByText('ENG-42 moved successfully.', { exact: true }) + .waitFor(); + assert.equal( + await page.getByLabel('Choose a column').inputValue(), + '2', + ); + assert.equal(requests.length, 1); + await cdp.detach(); + await page.screenshot({ + path: `/private/tmp/kan28-mobile-result-${theme}.png`, + fullPage: true, + }); + + await visit('?readonly'); + assert.equal( + await page.getByRole('button', { name: /Drag ENG/ }).count(), + 0, + ); + await page.setViewportSize({ width: 1440, height: 1200 }); + } + assert.deepEqual(failures, []); + console.log( + 'Board drag checks passed: mouse, touch, workflow restrictions, selection, cancellation, focus, request guards and recovery in both themes.', + ); +} finally { + await browser.close(); +} diff --git a/tests/Fixtures/board.mjs b/tests/Fixtures/board.mjs index 7443472..c513792 100644 --- a/tests/Fixtures/board.mjs +++ b/tests/Fixtures/board.mjs @@ -114,6 +114,9 @@ function makePage(url) { 'InvestigateALongUnbrokenTicketTitleThatMustWrapEvenOnTheSmallestMobileScreenWithoutClipping', ), ]; + if (params.has('multiple')) { + tickets[0].ticket_status.allowed_target_statuses.push(statuses[3]); + } for (const column of boardColumns) { column.tickets = tickets.filter( (item) => item.ticket_status.board_column_id === column.id, @@ -167,7 +170,7 @@ function makePage(url) { } if (serve) { server.middlewares.use(async (request, response) => { - if (request.method === 'POST') { + if (request.method === 'POST' || request.method === 'PATCH') { let body = ''; for await (const chunk of request) { body += chunk; @@ -175,12 +178,21 @@ if (serve) { const data = JSON.parse(body); assert.equal( request.url, - '/projects/7/tickets/42/status?_method=PATCH', + request.method === 'POST' + ? '/projects/7/tickets/42/status?_method=PATCH' + : '/projects/7/tickets/42/status', ); + assert.deepEqual(Object.keys(data).sort(), [ + 'redirect_to', + 'ticket_status_id', + ]); assert.equal(data.redirect_to, 'board'); - assert.ok([11, 20, 99].includes(Number(data.ticket_status_id))); + assert.ok([11, 20, 21, 99].includes(Number(data.ticket_status_id))); const previous = new URL(request.headers.referer); const reject = previous.searchParams.has('reject'); + if (previous.searchParams.has('slow')) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } response.writeHead(303, { Location: `/projects/7/board?${reject ? 'reject&rejected' : `target=${data.ticket_status_id}`}`, }); -- 2.54.0