659 lines
25 KiB
Vue
659 lines
25 KiB
Vue
<script setup lang="ts">
|
|
import { Head, Link, router } from '@inertiajs/vue3';
|
|
import { CircleUserRound, GripVertical, Plus } from 'lucide-vue-next';
|
|
import { onBeforeUnmount, ref } from 'vue';
|
|
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
|
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
|
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import AppLayout from '@/layouts/AppLayout.vue';
|
|
import { index as projectIndex } from '@/routes/projects';
|
|
import { index as boardIndex } from '@/routes/projects/board';
|
|
import { index as settingsIndex } from '@/routes/projects/settings';
|
|
import {
|
|
create as createTicket,
|
|
show as showTicket,
|
|
} from '@/routes/projects/tickets';
|
|
import type {
|
|
BoardDragPointer,
|
|
BoardDragTicket,
|
|
BoardDropTarget,
|
|
} from '@/types/board-drag';
|
|
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
|
import type { TicketModel } from '@/types/models/ticket-model';
|
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
|
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
|
|
|
const props = defineProps<ProjectBoardPageProps>();
|
|
|
|
const breadcrumbs: BreadcrumbItem[] = [
|
|
{
|
|
title: 'Projects',
|
|
href: projectIndex(),
|
|
},
|
|
{
|
|
title: props.project.name,
|
|
href: boardIndex(props.project.id),
|
|
},
|
|
{
|
|
title: 'Board',
|
|
href: boardIndex(props.project.id),
|
|
},
|
|
];
|
|
|
|
const boardScroller = ref<HTMLElement | null>(null);
|
|
const pendingDragTicket = ref<BoardDragTicket | null>(null);
|
|
const draggedTicket = ref<BoardDragTicket | null>(null);
|
|
const dragPointer = ref<BoardDragPointer | null>(null);
|
|
const dropTarget = ref<BoardDropTarget | null>(null);
|
|
const movingTicketId = ref<number | null>(null);
|
|
const moveError = ref<string | null>(null);
|
|
const pointerX = ref(0);
|
|
const pointerY = ref(0);
|
|
|
|
let autoScrollFrame: number | undefined;
|
|
let longPressTimer: number | undefined;
|
|
let suppressClickTicketId: number | null = null;
|
|
|
|
const allowedBoardTargetStatuses = (ticket: TicketModel): TicketStatusModel[] =>
|
|
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
|
(ticketStatus) =>
|
|
ticketStatus.id !== ticket.ticket_status_id &&
|
|
ticketStatus.board_column_id !== null,
|
|
);
|
|
|
|
const allowedStatusesForColumn = (
|
|
ticket: TicketModel,
|
|
boardColumnId: number,
|
|
): TicketStatusModel[] =>
|
|
allowedBoardTargetStatuses(ticket).filter(
|
|
(ticketStatus) => ticketStatus.board_column_id === boardColumnId,
|
|
);
|
|
|
|
const isAllowedColumn = (boardColumnId: number): boolean => {
|
|
if (draggedTicket.value === null) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
allowedStatusesForColumn(draggedTicket.value.ticket, boardColumnId)
|
|
.length > 0
|
|
);
|
|
};
|
|
|
|
const isActiveDropTarget = (
|
|
boardColumnId: number,
|
|
ticketStatusId?: number,
|
|
): boolean => {
|
|
if (dropTarget.value?.boardColumnId !== boardColumnId) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
ticketStatusId === undefined ||
|
|
dropTarget.value.ticketStatusId === ticketStatusId
|
|
);
|
|
};
|
|
|
|
const clearLongPressTimer = (): void => {
|
|
if (longPressTimer !== undefined) {
|
|
window.clearTimeout(longPressTimer);
|
|
longPressTimer = undefined;
|
|
}
|
|
};
|
|
|
|
const stopAutoScroll = (): void => {
|
|
if (autoScrollFrame !== undefined) {
|
|
window.cancelAnimationFrame(autoScrollFrame);
|
|
autoScrollFrame = undefined;
|
|
}
|
|
};
|
|
|
|
const runAutoScroll = (): void => {
|
|
const scroller = boardScroller.value;
|
|
|
|
if (draggedTicket.value === null || scroller === null) {
|
|
stopAutoScroll();
|
|
return;
|
|
}
|
|
|
|
const bounds = scroller.getBoundingClientRect();
|
|
const edgeSize = Math.min(88, bounds.width * 0.18);
|
|
const maximumStep = 20;
|
|
|
|
if (pointerX.value < bounds.left + edgeSize) {
|
|
const intensity =
|
|
1 - Math.max(0, pointerX.value - bounds.left) / edgeSize;
|
|
scroller.scrollLeft -= Math.max(5, maximumStep * intensity);
|
|
} else if (pointerX.value > bounds.right - edgeSize) {
|
|
const intensity =
|
|
1 - Math.max(0, bounds.right - pointerX.value) / edgeSize;
|
|
scroller.scrollLeft += Math.max(5, maximumStep * intensity);
|
|
}
|
|
|
|
autoScrollFrame = window.requestAnimationFrame(runAutoScroll);
|
|
};
|
|
|
|
const startDragging = (): void => {
|
|
if (pendingDragTicket.value === null || draggedTicket.value !== null) {
|
|
return;
|
|
}
|
|
|
|
clearLongPressTimer();
|
|
draggedTicket.value = pendingDragTicket.value;
|
|
suppressClickTicketId = pendingDragTicket.value.ticket.id;
|
|
document.body.classList.add('select-none');
|
|
autoScrollFrame = window.requestAnimationFrame(runAutoScroll);
|
|
};
|
|
|
|
const updateDropTarget = (clientX: number, clientY: number): void => {
|
|
const drag = draggedTicket.value;
|
|
const element = document.elementFromPoint(clientX, clientY);
|
|
const columnElement = element?.closest<HTMLElement>(
|
|
'[data-board-column-id]',
|
|
);
|
|
|
|
if (
|
|
drag === null ||
|
|
columnElement === undefined ||
|
|
columnElement === null
|
|
) {
|
|
dropTarget.value = null;
|
|
return;
|
|
}
|
|
|
|
const boardColumnId = Number(columnElement.dataset.boardColumnId);
|
|
const allowedStatuses = allowedStatusesForColumn(
|
|
drag.ticket,
|
|
boardColumnId,
|
|
);
|
|
const statusElement = element?.closest<HTMLElement>(
|
|
'[data-ticket-status-id]',
|
|
);
|
|
const statusId = Number(statusElement?.dataset.ticketStatusId);
|
|
|
|
if (
|
|
statusElement !== undefined &&
|
|
statusElement !== null &&
|
|
allowedStatuses.some((ticketStatus) => ticketStatus.id === statusId)
|
|
) {
|
|
dropTarget.value = {
|
|
boardColumnId,
|
|
ticketStatusId: statusId,
|
|
};
|
|
return;
|
|
}
|
|
|
|
dropTarget.value =
|
|
allowedStatuses.length === 1
|
|
? {
|
|
boardColumnId,
|
|
ticketStatusId: allowedStatuses[0].id,
|
|
}
|
|
: null;
|
|
};
|
|
|
|
const handlePointerDown = (
|
|
event: PointerEvent,
|
|
ticket: TicketModel,
|
|
boardColumn: BoardColumnModel,
|
|
): void => {
|
|
if (
|
|
!props.canEditTickets ||
|
|
movingTicketId.value !== null ||
|
|
event.button !== 0 ||
|
|
allowedBoardTargetStatuses(ticket).length === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
pointerX.value = event.clientX;
|
|
pointerY.value = event.clientY;
|
|
pendingDragTicket.value = {
|
|
originColumnId: boardColumn.id,
|
|
ticket,
|
|
};
|
|
dragPointer.value = {
|
|
pointerId: event.pointerId,
|
|
pointerType: event.pointerType,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
};
|
|
|
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
|
|
if (event.pointerType === 'touch') {
|
|
longPressTimer = window.setTimeout(startDragging, 240);
|
|
}
|
|
};
|
|
|
|
const handlePointerMove = (event: PointerEvent): void => {
|
|
const pointer = dragPointer.value;
|
|
|
|
if (pointer === null || pointer.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
|
|
pointerX.value = event.clientX;
|
|
pointerY.value = event.clientY;
|
|
|
|
const distance = Math.hypot(
|
|
event.clientX - pointer.startX,
|
|
event.clientY - pointer.startY,
|
|
);
|
|
|
|
if (draggedTicket.value === null) {
|
|
if (pointer.pointerType === 'touch') {
|
|
if (distance > 10) {
|
|
clearLongPressTimer();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (distance < 7) {
|
|
return;
|
|
}
|
|
|
|
startDragging();
|
|
}
|
|
|
|
if (draggedTicket.value !== null) {
|
|
event.preventDefault();
|
|
updateDropTarget(event.clientX, event.clientY);
|
|
}
|
|
};
|
|
|
|
const resetDragState = (): void => {
|
|
clearLongPressTimer();
|
|
stopAutoScroll();
|
|
pendingDragTicket.value = null;
|
|
draggedTicket.value = null;
|
|
dragPointer.value = null;
|
|
dropTarget.value = null;
|
|
document.body.classList.remove('select-none');
|
|
};
|
|
|
|
const moveTicket = (drag: BoardDragTicket, target: BoardDropTarget): void => {
|
|
moveError.value = null;
|
|
movingTicketId.value = drag.ticket.id;
|
|
|
|
router.patch(
|
|
ProjectBoardController.move.url({
|
|
project: props.project.id,
|
|
ticket: drag.ticket.id,
|
|
}),
|
|
{
|
|
redirect_to: 'board',
|
|
ticket_status_id: target.ticketStatusId,
|
|
},
|
|
{
|
|
onError: (errors) => {
|
|
moveError.value =
|
|
Object.values(errors)[0] ??
|
|
'The ticket could not be moved. Please try again.';
|
|
},
|
|
onFinish: () => {
|
|
movingTicketId.value = null;
|
|
},
|
|
preserveScroll: true,
|
|
},
|
|
);
|
|
};
|
|
|
|
const finishPointer = (event: PointerEvent): void => {
|
|
const pointer = dragPointer.value;
|
|
const drag = draggedTicket.value;
|
|
const target = dropTarget.value;
|
|
|
|
if (pointer === null || pointer.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const targetElement = event.currentTarget as HTMLElement;
|
|
if (targetElement.hasPointerCapture(event.pointerId)) {
|
|
targetElement.releasePointerCapture(event.pointerId);
|
|
}
|
|
|
|
resetDragState();
|
|
|
|
if (drag !== null) {
|
|
window.setTimeout(() => {
|
|
suppressClickTicketId = null;
|
|
}, 300);
|
|
|
|
if (target !== null) {
|
|
moveTicket(drag, target);
|
|
}
|
|
}
|
|
};
|
|
|
|
const cancelPointer = (event: PointerEvent): void => {
|
|
if (dragPointer.value?.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
|
|
resetDragState();
|
|
suppressClickTicketId = null;
|
|
};
|
|
|
|
const handleTicketClick = (event: MouseEvent, ticketId: number): void => {
|
|
if (suppressClickTicketId !== ticketId) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
|
|
onBeforeUnmount(() => {
|
|
resetDragState();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
|
|
<Head :title="`${project.name} board`" />
|
|
|
|
<div
|
|
class="app-page min-h-[calc(100dvh-3.5rem)] max-w-none gap-4 overflow-hidden"
|
|
>
|
|
<header class="flex items-end justify-between gap-4">
|
|
<div class="min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<Badge variant="outline" class="rounded px-2.5 py-1">
|
|
{{ project.key }}
|
|
</Badge>
|
|
<span class="text-muted-foreground text-xs font-medium">
|
|
Project board
|
|
</span>
|
|
</div>
|
|
<h1 class="mt-2 truncate text-2xl font-bold tracking-tight">
|
|
{{ project.name }}
|
|
</h1>
|
|
</div>
|
|
|
|
<Button v-if="canCreateTickets" as-child size="sm">
|
|
<Link
|
|
:href="
|
|
createTicket(
|
|
{ project: project.id },
|
|
{ query: { source: 'board' } },
|
|
)
|
|
"
|
|
>
|
|
<Plus class="size-4" />
|
|
Create ticket
|
|
</Link>
|
|
</Button>
|
|
</header>
|
|
|
|
<div class="overflow-x-auto">
|
|
<ProjectTabs
|
|
active="board"
|
|
:can-manage-project="canManageProject"
|
|
:project="project"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="status" class="status-success">
|
|
{{ status }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="moveError"
|
|
class="rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200"
|
|
>
|
|
{{ moveError }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="boardColumns.length === 0"
|
|
class="empty-state flex min-h-[calc(100dvh-15rem)] flex-col items-center justify-center gap-4"
|
|
>
|
|
<p>The board has no workflow columns yet.</p>
|
|
<Button v-if="canManageProject" as-child variant="outline">
|
|
<Link
|
|
:href="
|
|
settingsIndex(project.id, {
|
|
query: { section: 'board' },
|
|
})
|
|
"
|
|
>
|
|
Configure board
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
ref="boardScroller"
|
|
class="-mx-4 flex min-h-[max(28rem,calc(100dvh-14rem))] flex-1 snap-x snap-proximity items-stretch gap-3 overflow-x-auto px-4 pb-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"
|
|
>
|
|
<section
|
|
v-for="boardColumn in boardColumns"
|
|
:key="boardColumn.id"
|
|
:data-board-column-id="boardColumn.id"
|
|
:class="[
|
|
'bg-surface-container-lowest flex min-h-full w-[min(21rem,calc(100vw-2.5rem))] shrink-0 snap-start flex-col rounded border transition-[border-color,background-color,filter,opacity,box-shadow] duration-150 sm:w-[21rem]',
|
|
draggedTicket === null
|
|
? 'border-outline-variant/70'
|
|
: isAllowedColumn(boardColumn.id)
|
|
? isActiveDropTarget(boardColumn.id)
|
|
? 'border-primary bg-primary/8 ring-primary/25 ring-2'
|
|
: 'border-primary/70 bg-primary/5 ring-primary/10 ring-1'
|
|
: 'border-outline-variant/40 bg-muted/40 opacity-45 grayscale',
|
|
]"
|
|
>
|
|
<header
|
|
class="bg-surface-container-lowest/95 border-outline-variant/70 sticky top-0 z-10 rounded-t border-b px-3.5 py-3 backdrop-blur"
|
|
>
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<h2 class="truncate text-sm font-bold">
|
|
{{ boardColumn.name }}
|
|
</h2>
|
|
<p class="text-muted-foreground mt-0.5 text-xs">
|
|
{{ boardColumn.tickets?.length ?? 0 }}
|
|
tickets
|
|
</p>
|
|
</div>
|
|
<Badge variant="secondary" class="rounded">
|
|
{{ boardColumn.tickets?.length ?? 0 }}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div
|
|
v-if="
|
|
draggedTicket !== null &&
|
|
allowedStatusesForColumn(
|
|
draggedTicket.ticket,
|
|
boardColumn.id,
|
|
).length > 1
|
|
"
|
|
class="mt-3 grid gap-1.5"
|
|
>
|
|
<div
|
|
v-for="ticketStatus in allowedStatusesForColumn(
|
|
draggedTicket.ticket,
|
|
boardColumn.id,
|
|
)"
|
|
:key="ticketStatus.id"
|
|
:data-ticket-status-id="ticketStatus.id"
|
|
:class="[
|
|
'rounded border border-dashed px-3 py-2 text-center text-xs font-semibold transition-colors',
|
|
isActiveDropTarget(
|
|
boardColumn.id,
|
|
ticketStatus.id,
|
|
)
|
|
? 'border-primary bg-primary text-primary-foreground'
|
|
: 'border-primary/60 bg-primary/8 text-primary',
|
|
]"
|
|
>
|
|
Drop as {{ ticketStatus.name }}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="flex flex-1 flex-col gap-2.5 p-2.5">
|
|
<div
|
|
v-if="(boardColumn.tickets?.length ?? 0) === 0"
|
|
class="text-muted-foreground flex min-h-28 flex-1 items-center justify-center rounded border border-dashed p-4 text-center text-xs"
|
|
>
|
|
No tickets in this column.
|
|
</div>
|
|
|
|
<template v-else>
|
|
<article
|
|
v-for="ticket in boardColumn.tickets"
|
|
:key="ticket.id"
|
|
:class="[
|
|
'bg-card shadow-xs hover:border-primary/40 group relative rounded border transition-[border-color,box-shadow,opacity,transform] hover:shadow-sm',
|
|
ticket.is_blocked
|
|
? 'border-amber-300 dark:border-amber-800'
|
|
: 'border-outline-variant/70',
|
|
canEditTickets &&
|
|
allowedBoardTargetStatuses(ticket).length >
|
|
0
|
|
? 'cursor-grab active:cursor-grabbing'
|
|
: '',
|
|
draggedTicket?.ticket.id === ticket.id
|
|
? 'border-primary scale-[0.99] opacity-30'
|
|
: '',
|
|
movingTicketId === ticket.id
|
|
? 'pointer-events-none animate-pulse opacity-55'
|
|
: '',
|
|
]"
|
|
@pointerdown="
|
|
handlePointerDown(
|
|
$event,
|
|
ticket,
|
|
boardColumn,
|
|
)
|
|
"
|
|
@pointermove="handlePointerMove"
|
|
@pointerup="finishPointer"
|
|
@pointercancel="cancelPointer"
|
|
>
|
|
<Link
|
|
:href="
|
|
showTicket({
|
|
project: project.id,
|
|
ticket: ticket.id,
|
|
})
|
|
"
|
|
class="block touch-pan-y select-none p-3.5"
|
|
draggable="false"
|
|
@click="
|
|
handleTicketClick($event, ticket.id)
|
|
"
|
|
@dragstart.prevent
|
|
>
|
|
<div
|
|
class="flex items-start justify-between gap-2"
|
|
>
|
|
<div
|
|
class="flex flex-wrap items-center gap-1.5"
|
|
>
|
|
<span
|
|
class="text-primary text-[0.68rem] font-bold uppercase tracking-wide"
|
|
>
|
|
{{ ticket.ticket_key }}
|
|
</span>
|
|
<Badge
|
|
v-if="ticket.is_blocked"
|
|
variant="destructive"
|
|
class="rounded text-[0.65rem]"
|
|
>
|
|
Blocked
|
|
</Badge>
|
|
</div>
|
|
<GripVertical
|
|
v-if="
|
|
canEditTickets &&
|
|
allowedBoardTargetStatuses(
|
|
ticket,
|
|
).length > 0
|
|
"
|
|
class="text-muted-foreground/50 size-4 shrink-0"
|
|
/>
|
|
</div>
|
|
|
|
<h3
|
|
class="group-hover:text-primary mt-2 text-sm font-semibold leading-snug transition-colors"
|
|
>
|
|
{{ ticket.title }}
|
|
</h3>
|
|
<p
|
|
v-if="ticket.description"
|
|
class="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed"
|
|
>
|
|
{{ ticket.description }}
|
|
</p>
|
|
|
|
<div
|
|
class="border-outline-variant/60 mt-3 flex items-center justify-between gap-3 border-t pt-3"
|
|
>
|
|
<div
|
|
class="flex min-w-0 items-center gap-2"
|
|
>
|
|
<UserAvatar
|
|
v-if="ticket.assignee"
|
|
:user="ticket.assignee"
|
|
class="size-6"
|
|
/>
|
|
<span
|
|
v-else
|
|
class="border-outline-variant text-muted-foreground flex size-6 items-center justify-center rounded-full border border-dashed"
|
|
>
|
|
<CircleUserRound
|
|
class="size-3.5"
|
|
/>
|
|
</span>
|
|
<span
|
|
class="text-muted-foreground truncate text-[0.68rem]"
|
|
>
|
|
{{
|
|
ticket.assignee?.name ??
|
|
'Unassigned'
|
|
}}
|
|
</span>
|
|
</div>
|
|
<div
|
|
class="text-muted-foreground flex shrink-0 items-center gap-1.5 text-[0.68rem] font-medium"
|
|
>
|
|
<span
|
|
class="bg-primary size-1.5 rounded-full"
|
|
/>
|
|
{{ ticket.ticket_priority.name }}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</article>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="draggedTicket"
|
|
class="bg-card border-primary pointer-events-none fixed z-[100] w-72 -translate-x-1/2 -translate-y-1/2 rounded border px-4 py-3 shadow-2xl"
|
|
:style="{
|
|
left: `${pointerX}px`,
|
|
top: `${pointerY}px`,
|
|
}"
|
|
>
|
|
<div class="text-primary text-[0.68rem] font-bold uppercase">
|
|
{{ draggedTicket.ticket.ticket_key }}
|
|
</div>
|
|
<div class="mt-1 line-clamp-2 text-sm font-semibold">
|
|
{{ draggedTicket.ticket.title }}
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</AppLayout>
|
|
</template>
|