fix: adjusted drag and drop
CI/CD / test_and_check_app (push) Successful in 6m29s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Successful in 6m52s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-09-11 15:55:13 +02:00
parent ccd199a695
commit fab1e9c7a8
6 changed files with 776 additions and 6 deletions
+295 -3
View File
@@ -1,13 +1,24 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { ArrowLeft, ListTodo, Plus } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
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 AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
@@ -67,6 +78,140 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(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>
<template>
@@ -130,6 +275,13 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
</header>
<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
v-if="columns.length === 0"
state="empty"
@@ -168,10 +320,18 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
>
{{ t('board.scrollHint') }}
</p>
<p
v-if="canEditTickets"
id="board-drag-hint"
class="text-caption text-muted-foreground"
>
{{ t('board.dragHint') }}
</p>
<section
:aria-label="t('board.columns')"
aria-describedby="board-scroll-hint"
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"
>
<section
@@ -179,9 +339,11 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
:id="`board-column-${column.id}`"
:key="`${project.id}-${column.id}`"
:aria-labelledby="`board-column-title-${column.id}`"
:data-board-drop-column="column.id"
: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',
selectedColumnId === column.id ? 'flex' : 'hidden',
dropClasses(column.id),
]"
>
<header
@@ -250,6 +412,10 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
:class="{
'opacity-50':
draggedTicket?.id === ticket.id,
}"
>
<TicketCard
:project-id="project.id"
@@ -260,6 +426,36 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
v-if="canEditTickets"
#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
v-bind="
move.form({
@@ -272,6 +468,12 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
:options="{
preserveScroll: true,
}"
:on-before="
() =>
!busy && !draggedTicket
"
@start="menuPending = ticket.id"
@finish="menuPending = null"
class="grid min-w-0 gap-2"
>
<input
@@ -292,6 +494,7 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
busy ||
processing ||
allowedTargetStatuses(
ticket,
@@ -345,6 +548,7 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
type="submit"
variant="outline"
:disabled="
busy ||
processing ||
allowedTargetStatuses(
ticket,
@@ -381,6 +585,94 @@ const allowedTargetStatuses = (ticket: TicketModel) =>
</section>
</section>
</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>
</AppLayout>
</template>