Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e29fc4526 | ||
|
|
fab1e9c7a8 |
@@ -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<TicketModel | null>(null);
|
||||||
|
const active = ref(false);
|
||||||
|
const x = ref(0);
|
||||||
|
const y = ref(0);
|
||||||
|
const columnId = ref<number | null>(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<HTMLElement>('[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<HTMLElement>(
|
||||||
|
'[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 };
|
||||||
|
}
|
||||||
@@ -18,6 +18,17 @@ export const en = {
|
|||||||
'board.updateStatus': 'Update status',
|
'board.updateStatus': 'Update status',
|
||||||
'board.updatingStatus': 'Updating…',
|
'board.updatingStatus': 'Updating…',
|
||||||
'board.noMoves': 'No allowed moves',
|
'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':
|
'board.noMovesDescription':
|
||||||
'This status does not allow moving anywhere else.',
|
'This status does not allow moving anywhere else.',
|
||||||
'projectCreation.title': 'Create a project',
|
'projectCreation.title': 'Create a project',
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { ArrowLeft, ListTodo, Plus } from 'lucide-vue-next';
|
import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { TicketCard } from '@/components/tickets';
|
import { TicketCard } from '@/components/tickets';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useBoardDrag } from '@/composables/useBoardDrag';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
@@ -67,6 +78,140 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||||
(status) => status.id !== ticket.ticket_status_id,
|
(status) => status.id !== ticket.ticket_status_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const moveForm = useForm({ ticket_status_id: 0, redirect_to: 'board' });
|
||||||
|
const feedback = useFormProtection(moveForm, { isDirty: () => false });
|
||||||
|
const menuPending = ref<number | null>(null);
|
||||||
|
const busy = computed(() => moveForm.processing || menuPending.value !== null);
|
||||||
|
const choice = ref<{ ticketId: number; columnId: number } | null>(null);
|
||||||
|
const lastMove = ref<{ ticketId: number; statusId: number } | null>(null);
|
||||||
|
const findTicket = (id: number) =>
|
||||||
|
props.boardColumns
|
||||||
|
.flatMap((column) => column.tickets ?? [])
|
||||||
|
.find((ticket) => ticket.id === id);
|
||||||
|
const targetsForColumn = (ticket: TicketModel, columnId: number) => {
|
||||||
|
const statusIds =
|
||||||
|
props.boardColumns
|
||||||
|
.find((column) => column.id === columnId)
|
||||||
|
?.ticket_statuses?.map((status) => status.id) ?? [];
|
||||||
|
return allowedTargetStatuses(ticket).filter((status) =>
|
||||||
|
statusIds.includes(status.id),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const choiceTicket = computed(() =>
|
||||||
|
choice.value ? findTicket(choice.value.ticketId) : undefined,
|
||||||
|
);
|
||||||
|
const choiceTargets = computed(() =>
|
||||||
|
choice.value && choiceTicket.value
|
||||||
|
? targetsForColumn(choiceTicket.value, choice.value.columnId)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const choiceOpen = computed({
|
||||||
|
get: () => choice.value !== null,
|
||||||
|
set: (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
choice.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function submitMove(ticketId: number, statusId: number): void {
|
||||||
|
const ticket = findTicket(ticketId);
|
||||||
|
if (
|
||||||
|
busy.value ||
|
||||||
|
!props.canEditTickets ||
|
||||||
|
!ticket ||
|
||||||
|
!allowedTargetStatuses(ticket).some((status) => status.id === statusId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMove.value = { ticketId, statusId };
|
||||||
|
moveForm.ticket_status_id = statusId;
|
||||||
|
moveForm.clearErrors();
|
||||||
|
feedback.submit(move({ project: props.project.id, ticket: ticketId }), {
|
||||||
|
errorBag: `board-ticket-${ticketId}`,
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: async () => {
|
||||||
|
const destination = props.boardColumns.find((column) =>
|
||||||
|
column.ticket_statuses?.some(
|
||||||
|
(status) => status.id === statusId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (destination) {
|
||||||
|
selectedColumnId.value = destination.id;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
document
|
||||||
|
.getElementById(`ticket-status-${ticketId}`)
|
||||||
|
?.focus({ preventScroll: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropTicket(ticket: TicketModel, columnId: number): void {
|
||||||
|
const current = findTicket(ticket.id);
|
||||||
|
if (!current || busy.value || !props.canEditTickets) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = targetsForColumn(current, columnId);
|
||||||
|
if (targets.length === 1) {
|
||||||
|
submitMove(current.id, targets[0].id);
|
||||||
|
} else if (targets.length > 1) {
|
||||||
|
moveForm.ticket_status_id = targets[0].id;
|
||||||
|
choice.value = { ticketId: current.id, columnId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
draggedTicket,
|
||||||
|
columnId: dropColumnId,
|
||||||
|
x: dragX,
|
||||||
|
y: dragY,
|
||||||
|
start: startDrag,
|
||||||
|
cancel: cancelDrag,
|
||||||
|
} = useBoardDrag({
|
||||||
|
canStart: (ticket) =>
|
||||||
|
props.canEditTickets &&
|
||||||
|
!busy.value &&
|
||||||
|
!choice.value &&
|
||||||
|
allowedTargetStatuses(ticket).length > 0,
|
||||||
|
drop: dropTicket,
|
||||||
|
});
|
||||||
|
const canDrop = (columnId: number) =>
|
||||||
|
!!draggedTicket.value &&
|
||||||
|
targetsForColumn(draggedTicket.value, columnId).length > 0;
|
||||||
|
const dropClasses = (columnId: number) =>
|
||||||
|
!draggedTicket.value
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
'border-primary border-dashed': canDrop(columnId),
|
||||||
|
'ring-2 ring-primary bg-primary/10':
|
||||||
|
canDrop(columnId) && dropColumnId.value === columnId,
|
||||||
|
'opacity-40': !canDrop(columnId),
|
||||||
|
};
|
||||||
|
function confirmMove(): void {
|
||||||
|
if (
|
||||||
|
!choice.value ||
|
||||||
|
!choiceTargets.value.some(
|
||||||
|
(status) => status.id === moveForm.ticket_status_id,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ticketId = choice.value.ticketId;
|
||||||
|
choice.value = null;
|
||||||
|
submitMove(ticketId, moveForm.ticket_status_id);
|
||||||
|
}
|
||||||
|
function focusStatus(ticketId: number): void {
|
||||||
|
document.getElementById(`ticket-status-${ticketId}`)?.focus();
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => [props.project.id, props.boardColumns, props.canEditTickets],
|
||||||
|
() => {
|
||||||
|
cancelDrag();
|
||||||
|
choice.value = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -130,6 +275,13 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<FeedbackState v-if="status" state="success" :title="status" />
|
<FeedbackState v-if="status" state="success" :title="status" />
|
||||||
|
<FormFeedback
|
||||||
|
:state="feedback.state.value"
|
||||||
|
:description="moveForm.errors.ticket_status_id"
|
||||||
|
@retry="
|
||||||
|
lastMove && submitMove(lastMove.ticketId, lastMove.statusId)
|
||||||
|
"
|
||||||
|
/>
|
||||||
<FeedbackState
|
<FeedbackState
|
||||||
v-if="columns.length === 0"
|
v-if="columns.length === 0"
|
||||||
state="empty"
|
state="empty"
|
||||||
@@ -168,10 +320,18 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
>
|
>
|
||||||
{{ t('board.scrollHint') }}
|
{{ t('board.scrollHint') }}
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="canEditTickets"
|
||||||
|
id="board-drag-hint"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('board.dragHint') }}
|
||||||
|
</p>
|
||||||
<section
|
<section
|
||||||
:aria-label="t('board.columns')"
|
:aria-label="t('board.columns')"
|
||||||
aria-describedby="board-scroll-hint"
|
aria-describedby="board-scroll-hint"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
data-board-drag-scroll
|
||||||
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
|
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
@@ -179,9 +339,11 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
:id="`board-column-${column.id}`"
|
:id="`board-column-${column.id}`"
|
||||||
:key="`${project.id}-${column.id}`"
|
:key="`${project.id}-${column.id}`"
|
||||||
:aria-labelledby="`board-column-title-${column.id}`"
|
:aria-labelledby="`board-column-title-${column.id}`"
|
||||||
|
:data-board-drop-column="column.id"
|
||||||
:class="[
|
:class="[
|
||||||
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
|
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
|
||||||
selectedColumnId === column.id ? 'flex' : 'hidden',
|
selectedColumnId === column.id ? 'flex' : 'hidden',
|
||||||
|
dropClasses(column.id),
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
@@ -250,6 +412,10 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
v-for="ticket in group.tickets"
|
v-for="ticket in group.tickets"
|
||||||
:key="ticket.id"
|
:key="ticket.id"
|
||||||
class="min-w-0"
|
class="min-w-0"
|
||||||
|
:class="{
|
||||||
|
'opacity-50':
|
||||||
|
draggedTicket?.id === ticket.id,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<TicketCard
|
<TicketCard
|
||||||
:project-id="project.id"
|
:project-id="project.id"
|
||||||
@@ -259,6 +425,36 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
<template
|
<template
|
||||||
v-if="canEditTickets"
|
v-if="canEditTickets"
|
||||||
#actions
|
#actions
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||||
|
:disabled="
|
||||||
|
busy ||
|
||||||
|
allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
|
:aria-label="
|
||||||
|
t('board.dragTicket', {
|
||||||
|
key: ticket.ticket_key,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
aria-describedby="board-drag-hint"
|
||||||
|
@pointerdown="
|
||||||
|
startDrag($event, ticket)
|
||||||
|
"
|
||||||
|
@click.prevent
|
||||||
|
@keydown.enter.prevent="
|
||||||
|
focusStatus(ticket.id)
|
||||||
|
"
|
||||||
|
@keydown.space.prevent="
|
||||||
|
focusStatus(ticket.id)
|
||||||
|
"
|
||||||
|
><GripVertical
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{{ t('board.drag') }}</Button
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
v-bind="
|
v-bind="
|
||||||
@@ -272,6 +468,12 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
:options="{
|
:options="{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
}"
|
}"
|
||||||
|
:on-before="
|
||||||
|
() =>
|
||||||
|
!busy && !draggedTicket
|
||||||
|
"
|
||||||
|
@start="menuPending = ticket.id"
|
||||||
|
@finish="menuPending = null"
|
||||||
class="grid min-w-0 gap-2"
|
class="grid min-w-0 gap-2"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -292,6 +494,7 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
:id="`ticket-status-${ticket.id}`"
|
:id="`ticket-status-${ticket.id}`"
|
||||||
name="ticket_status_id"
|
name="ticket_status_id"
|
||||||
:disabled="
|
:disabled="
|
||||||
|
busy ||
|
||||||
processing ||
|
processing ||
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
@@ -345,6 +548,7 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="
|
:disabled="
|
||||||
|
busy ||
|
||||||
processing ||
|
processing ||
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
@@ -381,6 +585,94 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
<div
|
||||||
|
v-if="draggedTicket"
|
||||||
|
class="bg-card fixed inset-x-0 bottom-0 z-40 border-t p-3 shadow-lg md:hidden"
|
||||||
|
data-board-mobile-targets
|
||||||
|
>
|
||||||
|
<p class="text-caption mb-2 font-semibold">
|
||||||
|
{{ t('board.dropHint') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 overflow-x-auto" data-board-drag-scroll>
|
||||||
|
<div
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="column.id"
|
||||||
|
:data-board-drop-column="column.id"
|
||||||
|
:class="dropClasses(column.id)"
|
||||||
|
class="text-body flex min-h-20 w-36 shrink-0 items-center rounded-md border p-3"
|
||||||
|
>
|
||||||
|
{{ column.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="draggedTicket"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="bg-primary text-primary-foreground pointer-events-none fixed z-50 max-w-48 rounded-md px-3 py-2 shadow-lg"
|
||||||
|
:style="{
|
||||||
|
left: `${Math.max(0, dragX - 80)}px`,
|
||||||
|
top: `${Math.max(0, dragY - 56)}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ draggedTicket.ticket_key }}
|
||||||
|
</div>
|
||||||
|
<p class="sr-only" aria-live="polite">
|
||||||
|
{{
|
||||||
|
draggedTicket
|
||||||
|
? t('board.dragging', { key: draggedTicket.ticket_key })
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Dialog v-model:open="choiceOpen">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ t('board.chooseStatus') }}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('board.chooseStatusDescription', {
|
||||||
|
key: choiceTicket?.ticket_key ?? '',
|
||||||
|
})
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form class="grid gap-4" @submit.prevent="confirmMove">
|
||||||
|
<label
|
||||||
|
for="board-drop-status"
|
||||||
|
class="text-body font-semibold"
|
||||||
|
>{{ t('board.targetStatus') }}</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="board-drop-status"
|
||||||
|
v-model="moveForm.ticket_status_id"
|
||||||
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="target in choiceTargets"
|
||||||
|
:key="target.id"
|
||||||
|
:value="target.id"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
target,
|
||||||
|
t('tickets.noStatus'),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="choiceOpen = false"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="busy || choiceTargets.length === 0"
|
||||||
|
>{{ t('board.updateStatus') }}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -212,6 +212,19 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
'entry_type' => 'status_changed',
|
'entry_type' => 'status_changed',
|
||||||
'summary' => 'Moved from Todo to Done.',
|
'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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -114,6 +114,9 @@ function makePage(url) {
|
|||||||
'InvestigateALongUnbrokenTicketTitleThatMustWrapEvenOnTheSmallestMobileScreenWithoutClipping',
|
'InvestigateALongUnbrokenTicketTitleThatMustWrapEvenOnTheSmallestMobileScreenWithoutClipping',
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
if (params.has('multiple')) {
|
||||||
|
tickets[0].ticket_status.allowed_target_statuses.push(statuses[3]);
|
||||||
|
}
|
||||||
for (const column of boardColumns) {
|
for (const column of boardColumns) {
|
||||||
column.tickets = tickets.filter(
|
column.tickets = tickets.filter(
|
||||||
(item) => item.ticket_status.board_column_id === column.id,
|
(item) => item.ticket_status.board_column_id === column.id,
|
||||||
@@ -167,7 +170,7 @@ function makePage(url) {
|
|||||||
}
|
}
|
||||||
if (serve) {
|
if (serve) {
|
||||||
server.middlewares.use(async (request, response) => {
|
server.middlewares.use(async (request, response) => {
|
||||||
if (request.method === 'POST') {
|
if (request.method === 'POST' || request.method === 'PATCH') {
|
||||||
let body = '';
|
let body = '';
|
||||||
for await (const chunk of request) {
|
for await (const chunk of request) {
|
||||||
body += chunk;
|
body += chunk;
|
||||||
@@ -175,12 +178,21 @@ if (serve) {
|
|||||||
const data = JSON.parse(body);
|
const data = JSON.parse(body);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
request.url,
|
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.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 previous = new URL(request.headers.referer);
|
||||||
const reject = previous.searchParams.has('reject');
|
const reject = previous.searchParams.has('reject');
|
||||||
|
if (previous.searchParams.has('slow')) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
response.writeHead(303, {
|
response.writeHead(303, {
|
||||||
Location: `/projects/7/board?${reject ? 'reject&rejected' : `target=${data.ticket_status_id}`}`,
|
Location: `/projects/7/board?${reject ? 'reject&rejected' : `target=${data.ticket_status_id}`}`,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user