feat: overhaul project workspace navigation
This commit is contained in:
@@ -1,30 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import { ArrowRight, CircleUserRound } from 'lucide-vue-next';
|
||||
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 Heading from '@/components/Heading.vue';
|
||||
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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||
import { board as boardSettings } from '@/routes/projects/settings';
|
||||
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 { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
||||
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>();
|
||||
|
||||
@@ -35,7 +34,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Board',
|
||||
@@ -43,208 +42,558 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
||||
if (ticketStatus === null) {
|
||||
return 'Unknown status';
|
||||
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;
|
||||
}
|
||||
|
||||
if (
|
||||
ticketStatus.board_column === null ||
|
||||
ticketStatus.board_column === undefined
|
||||
) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
||||
};
|
||||
|
||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
||||
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 max-w-none">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} board`"
|
||||
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button v-if="canCreateTickets" as-child>
|
||||
<Link
|
||||
:href="
|
||||
createTicket(
|
||||
{ project: project.id },
|
||||
{ query: { source: 'board' } },
|
||||
)
|
||||
"
|
||||
>
|
||||
Create ticket
|
||||
</Link>
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="backlogIndex(project.id)"
|
||||
>Open backlog</Link
|
||||
>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<ProjectTabs
|
||||
active="board"
|
||||
:can-manage-project="canManageProject"
|
||||
:project="project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<Card v-if="boardColumns.length === 0">
|
||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||
<p class="text-muted-foreground">
|
||||
The board has no workflow columns yet, so no tickets can
|
||||
appear here.
|
||||
</p>
|
||||
<div v-if="canEditTickets">
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="boardSettings(project.id)">
|
||||
Open board settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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
|
||||
class="-mx-4 flex snap-x snap-proximity items-start gap-4 overflow-x-auto px-4 pb-5 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"
|
||||
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"
|
||||
>
|
||||
<Card
|
||||
<section
|
||||
v-for="boardColumn in boardColumns"
|
||||
:key="boardColumn.id"
|
||||
class="bg-surface-container-low w-[min(21rem,calc(100vw-3rem))] shrink-0 snap-start gap-0 overflow-hidden border-0 py-0 shadow-none hover:shadow-none sm:w-[21rem]"
|
||||
: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',
|
||||
]"
|
||||
>
|
||||
<CardHeader
|
||||
class="border-outline-variant/60 sticky top-0 z-10 border-b px-4 py-4"
|
||||
<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-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>{{ boardColumn.name }}</CardTitle>
|
||||
<CardDescription>
|
||||
<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 }}
|
||||
active ticket(s)
|
||||
</CardDescription>
|
||||
<div
|
||||
v-if="
|
||||
(boardColumn.ticket_statuses?.length ??
|
||||
0) > 0
|
||||
"
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
>
|
||||
<Badge
|
||||
v-for="ticketStatus in boardColumn.ticket_statuses"
|
||||
:key="ticketStatus.id"
|
||||
variant="outline"
|
||||
>
|
||||
{{ ticketStatus.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
tickets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Badge variant="secondary">
|
||||
<Badge variant="secondary" class="rounded">
|
||||
{{ boardColumn.tickets?.length ?? 0 }}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
class="flex min-h-48 flex-1 flex-col gap-3 p-3"
|
||||
>
|
||||
<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="empty-state bg-transparent"
|
||||
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 are currently in this column.
|
||||
No tickets in this column.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<Card
|
||||
<template v-else>
|
||||
<article
|
||||
v-for="ticket in boardColumn.tickets"
|
||||
:key="ticket.id"
|
||||
:class="[
|
||||
'shadow-xs gap-0 py-0 hover:-translate-y-0.5',
|
||||
'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 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
|
||||
? '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"
|
||||
>
|
||||
<CardHeader class="gap-3 px-4 pb-3 pt-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-primary"
|
||||
<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"
|
||||
>
|
||||
{{ ticket.ticket_key }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="ticket.is_blocked"
|
||||
variant="destructive"
|
||||
>
|
||||
Blocked
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ ticket.ticket_type.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle class="text-base">
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
<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="hover:text-primary transition"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
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="line-clamp-3"
|
||||
class="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed"
|
||||
>
|
||||
{{ ticket.description }}
|
||||
</CardDescription>
|
||||
<CardDescription
|
||||
v-if="
|
||||
ticket.is_blocked &&
|
||||
ticket.blocked_comment
|
||||
"
|
||||
class="mt-2 text-amber-900 dark:text-amber-200"
|
||||
>
|
||||
{{ ticket.blocked_comment }}
|
||||
</CardDescription>
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="border-outline-variant/60 flex items-center justify-between gap-3 border-t pt-3"
|
||||
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"
|
||||
@@ -252,18 +601,18 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
<UserAvatar
|
||||
v-if="ticket.assignee"
|
||||
:user="ticket.assignee"
|
||||
class="size-7"
|
||||
class="size-6"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="border-outline-variant text-muted-foreground flex size-7 items-center justify-center rounded-full border border-dashed"
|
||||
class="border-outline-variant text-muted-foreground flex size-6 items-center justify-center rounded-full border border-dashed"
|
||||
>
|
||||
<CircleUserRound
|
||||
class="size-4"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="text-muted-foreground truncate text-xs"
|
||||
class="text-muted-foreground truncate text-[0.68rem]"
|
||||
>
|
||||
{{
|
||||
ticket.assignee?.name ??
|
||||
@@ -272,110 +621,38 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-1.5"
|
||||
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"
|
||||
/>
|
||||
<span
|
||||
class="text-muted-foreground text-xs font-medium"
|
||||
>
|
||||
{{
|
||||
ticket.ticket_priority.name
|
||||
}}
|
||||
</span>
|
||||
{{ ticket.ticket_priority.name }}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
v-if="canEditTickets"
|
||||
class="border-outline-variant/60 border-t px-4 py-3"
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardController.move.form({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirect_to"
|
||||
value="board"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
:id="`ticket-status-${ticket.id}`"
|
||||
name="ticket_status_id"
|
||||
:disabled="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0 || processing
|
||||
"
|
||||
class="min-w-0 flex-1 text-xs"
|
||||
>
|
||||
<option
|
||||
v-if="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
value=""
|
||||
>
|
||||
No allowed moves
|
||||
</option>
|
||||
<option
|
||||
v-for="ticketStatus in allowedTargetStatuses(
|
||||
ticket,
|
||||
)"
|
||||
:key="ticketStatus.id"
|
||||
:value="ticketStatus.id"
|
||||
>
|
||||
{{
|
||||
ticketStatusLabel(
|
||||
ticketStatus,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
:disabled="
|
||||
processing ||
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
>
|
||||
<ArrowRight class="size-4" />
|
||||
<span class="sr-only"
|
||||
>Update status</span
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
v-if="
|
||||
allowedTargetStatuses(ticket)
|
||||
.length === 0
|
||||
"
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
This status does not allow moving
|
||||
anywhere else.
|
||||
</p>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user