From fab1e9c7a831101c88f66ccbb996b22bf69c410f Mon Sep 17 00:00:00 2001 From: ericasch6 Date: Fri, 11 Sep 2026 15:55:13 +0200 Subject: [PATCH] fix: adjusted drag and drop --- resources/js/composables/useBoardDrag.ts | 154 +++++++++ resources/js/locales/en.ts | 11 + resources/js/pages/projects/Board.vue | 298 +++++++++++++++++- .../Projects/ProjectBoardControllerTest.php | 13 + tests/Fixtures/board-drag-browser.mjs | 288 +++++++++++++++++ tests/Fixtures/board.mjs | 18 +- 6 files changed, 776 insertions(+), 6 deletions(-) create mode 100644 resources/js/composables/useBoardDrag.ts create mode 100644 tests/Fixtures/board-drag-browser.mjs diff --git a/resources/js/composables/useBoardDrag.ts b/resources/js/composables/useBoardDrag.ts new file mode 100644 index 0000000..782b4a4 --- /dev/null +++ b/resources/js/composables/useBoardDrag.ts @@ -0,0 +1,154 @@ +import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'; +import type { TicketModel } from '@/types/models/ticket-model'; + +/** Pointer capture keeps mouse, pen and touch drags on the same code path. */ +export function useBoardDrag(options: { + canStart: (ticket: TicketModel) => boolean; + drop: (ticket: TicketModel, columnId: number) => void; +}) { + const ticket = shallowRef(null); + const active = ref(false); + const x = ref(0); + const y = ref(0); + const columnId = ref(null); + let pointerId: number | null = null; + let handle: HTMLElement | null = null; + let startX = 0; + let startY = 0; + let frame = 0; + + const draggedTicket = computed(() => (active.value ? ticket.value : null)); + + function locateTarget(): void { + const target = document + .elementFromPoint(x.value, y.value) + ?.closest('[data-board-drop-column]'); + columnId.value = target ? Number(target.dataset.boardDropColumn) : null; + } + + function scroll(): void { + if (!active.value) { + return; + } + const element = document.elementFromPoint(x.value, y.value); + const region = element?.closest( + '[data-board-drag-scroll]', + ); + if (region) { + const bounds = region.getBoundingClientRect(); + const left = Math.max(0, bounds.left); + const right = Math.min(window.innerWidth, bounds.right); + const delta = + x.value < left + 44 ? -10 : x.value > right - 44 ? 10 : 0; + region.scrollLeft += delta; + } + if (!element?.closest('[data-board-mobile-targets]')) { + const delta = + y.value < 64 ? -10 : y.value > window.innerHeight - 64 ? 10 : 0; + window.scrollBy(0, delta); + } + locateTarget(); + frame = requestAnimationFrame(scroll); + } + + function cancel(): void { + const previousPointer = pointerId; + pointerId = null; + active.value = false; + ticket.value = null; + columnId.value = null; + cancelAnimationFrame(frame); + if ( + previousPointer !== null && + handle?.hasPointerCapture(previousPointer) + ) { + handle.releasePointerCapture(previousPointer); + } + handle = null; + } + + function start(event: PointerEvent, source: TicketModel): void { + if ( + pointerId !== null || + !event.isPrimary || + event.button !== 0 || + !options.canStart(source) + ) { + return; + } + handle = event.currentTarget as HTMLElement; + handle.focus({ preventScroll: true }); + handle.setPointerCapture(event.pointerId); + pointerId = event.pointerId; + ticket.value = source; + startX = x.value = event.clientX; + startY = y.value = event.clientY; + } + + function move(event: PointerEvent): void { + if (event.pointerId !== pointerId) { + return; + } + x.value = event.clientX; + y.value = event.clientY; + if ( + !active.value && + Math.hypot(x.value - startX, y.value - startY) >= 6 + ) { + active.value = true; + frame = requestAnimationFrame(scroll); + } + if (active.value) { + event.preventDefault(); + locateTarget(); + } + } + + function finish(event: PointerEvent): void { + if (event.pointerId !== pointerId) { + return; + } + x.value = event.clientX; + y.value = event.clientY; + locateTarget(); + const source = draggedTicket.value; + const target = columnId.value; + cancel(); + if (source && target !== null) { + options.drop(source, target); + } + } + + function cancelPointer(event: PointerEvent): void { + if (event.pointerId === pointerId) { + cancel(); + } + } + + function keydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && pointerId !== null) { + event.preventDefault(); + cancel(); + } + } + + onMounted(() => { + document.addEventListener('pointermove', move, { passive: false }); + document.addEventListener('pointerup', finish); + document.addEventListener('pointercancel', cancelPointer); + document.addEventListener('lostpointercapture', cancelPointer); + document.addEventListener('keydown', keydown); + window.addEventListener('blur', cancel); + }); + onUnmounted(() => { + cancel(); + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', finish); + document.removeEventListener('pointercancel', cancelPointer); + document.removeEventListener('lostpointercapture', cancelPointer); + document.removeEventListener('keydown', keydown); + window.removeEventListener('blur', cancel); + }); + + return { draggedTicket, columnId, x, y, start, cancel }; +} diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index 4fd0d4d..79b3837 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -18,6 +18,17 @@ export const en = { 'board.updateStatus': 'Update status', 'board.updatingStatus': 'Updating…', 'board.noMoves': 'No allowed moves', + 'board.drag': 'Drag to move', + 'board.dragTicket': 'Drag {key} to change status', + 'board.dragHint': + 'Drag a handle to a highlighted column, or use the status controls. Press Escape to cancel.', + 'board.dropHint': 'Drop on a column. Hold near an edge to scroll.', + 'board.dragging': + 'Dragging {key}. Drop on an allowed column or press Escape to cancel.', + 'board.chooseStatus': 'Choose a destination status', + 'board.chooseStatusDescription': + 'More than one status is available for {key} in this column.', + 'board.targetStatus': 'Destination status', 'board.noMovesDescription': 'This status does not allow moving anywhere else.', 'projectCreation.title': 'Create a project', diff --git a/resources/js/pages/projects/Board.vue b/resources/js/pages/projects/Board.vue index 057efcb..1c3a203 100644 --- a/resources/js/pages/projects/Board.vue +++ b/resources/js/pages/projects/Board.vue @@ -1,13 +1,24 @@ +
+

+ {{ t('board.dropHint') }} +

+
+
+ {{ column.name }} +
+
+
+ +

+ {{ + draggedTicket + ? t('board.dragging', { key: draggedTicket.ticket_key }) + : '' + }} +

+ + + + {{ t('board.chooseStatus') }} + {{ + t('board.chooseStatusDescription', { + key: choiceTicket?.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}`}`, });