679 lines
32 KiB
Vue
679 lines
32 KiB
Vue
<script setup lang="ts">
|
|
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';
|
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
|
import { index as boardIndex } from '@/routes/projects/board';
|
|
import { index as projectIndex, show } from '@/routes/projects/index';
|
|
import { create as createTicket } from '@/routes/projects/tickets';
|
|
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
|
import type { TicketModel } from '@/types/models/ticket-model';
|
|
|
|
const props = defineProps<ProjectBoardPageProps>();
|
|
const { t } = useLocalization();
|
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|
{ title: t('navigation.projects'), href: projectIndex() },
|
|
{ title: props.project.name, href: show(props.project.id) },
|
|
{ title: t('navigation.board'), href: boardIndex(props.project.id) },
|
|
]);
|
|
const selectedColumnId = ref<number>();
|
|
watch(
|
|
() =>
|
|
[
|
|
props.project.id,
|
|
props.boardColumns.map((column) => column.id),
|
|
] as const,
|
|
([projectId, columnIds], previous) => {
|
|
if (
|
|
projectId !== previous?.[0] ||
|
|
!columnIds.includes(selectedColumnId.value ?? -1)
|
|
) {
|
|
selectedColumnId.value = columnIds[0];
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
const columns = computed(() =>
|
|
props.boardColumns.map((column) => {
|
|
const ticketsByStatus = new Map<number | null, TicketModel[]>();
|
|
for (const ticket of column.tickets ?? []) {
|
|
const tickets = ticketsByStatus.get(ticket.ticket_status_id) ?? [];
|
|
tickets.push(ticket);
|
|
ticketsByStatus.set(ticket.ticket_status_id, tickets);
|
|
}
|
|
return {
|
|
...column,
|
|
count: column.tickets?.length ?? 0,
|
|
groups: (column.ticket_statuses ?? []).map((status) => ({
|
|
status,
|
|
tickets: ticketsByStatus.get(status.id) ?? [],
|
|
})),
|
|
};
|
|
}),
|
|
);
|
|
const ticketCount = computed(() =>
|
|
columns.value.reduce((count, column) => count + column.count, 0),
|
|
);
|
|
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>
|
|
<AppLayout :breadcrumbs="breadcrumbs">
|
|
<Head :title="t('board.title', { name: project.name })" />
|
|
|
|
<div class="gap-column md:p-page flex min-w-0 flex-1 flex-col p-4">
|
|
<header
|
|
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
|
>
|
|
<div class="grid min-w-0 gap-2">
|
|
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
|
<Badge
|
|
variant="outline"
|
|
class="wrap-anywhere max-w-full whitespace-normal"
|
|
>{{ project.key }}</Badge
|
|
>
|
|
<p class="text-caption text-muted-foreground">
|
|
{{ t('tickets.count', { count: ticketCount }) }}
|
|
</p>
|
|
</div>
|
|
<h1
|
|
class="text-headline-mobile md:text-headline wrap-anywhere"
|
|
>
|
|
{{ t('board.title', { name: project.name }) }}
|
|
</h1>
|
|
<p class="text-caption text-muted-foreground max-w-2xl">
|
|
{{ t('board.description') }}
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button v-if="canCreateTickets" as-child>
|
|
<Link
|
|
:href="
|
|
createTicket(
|
|
{ project: project.id },
|
|
{ query: { source: 'board' } },
|
|
)
|
|
"
|
|
>
|
|
<Plus aria-hidden="true" />{{
|
|
t('navigation.createTicket')
|
|
}}
|
|
</Link>
|
|
</Button>
|
|
<Button as-child variant="outline">
|
|
<Link :href="backlogIndex(project.id)"
|
|
><ListTodo aria-hidden="true" />{{
|
|
t('board.openBacklog')
|
|
}}</Link
|
|
>
|
|
</Button>
|
|
<Button as-child variant="ghost">
|
|
<Link :href="show(project.id)"
|
|
><ArrowLeft aria-hidden="true" />{{
|
|
t('board.backToProject')
|
|
}}</Link
|
|
>
|
|
</Button>
|
|
</div>
|
|
</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"
|
|
:title="t('board.empty.title')"
|
|
:description="t('board.empty.description')"
|
|
/>
|
|
|
|
<template v-else>
|
|
<div class="grid min-w-0 gap-2 md:hidden">
|
|
<label for="board-column" class="text-body font-semibold">{{
|
|
t('board.selectColumn')
|
|
}}</label>
|
|
<select
|
|
id="board-column"
|
|
v-model="selectedColumnId"
|
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
|
|
:aria-controls="`board-column-${selectedColumnId}`"
|
|
>
|
|
<option
|
|
v-for="column in columns"
|
|
:key="column.id"
|
|
:value="column.id"
|
|
>
|
|
{{
|
|
t('board.columnOption', {
|
|
name: column.name,
|
|
count: column.count,
|
|
})
|
|
}}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<p
|
|
id="board-scroll-hint"
|
|
class="text-caption text-muted-foreground hidden md:block"
|
|
>
|
|
{{ 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
|
|
v-for="column in columns"
|
|
: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
|
|
class="border-primary/25 flex min-w-0 items-start justify-between gap-3 border-b-2 pb-3"
|
|
>
|
|
<h2
|
|
:id="`board-column-title-${column.id}`"
|
|
class="text-title wrap-anywhere"
|
|
>
|
|
{{ column.name }}
|
|
</h2>
|
|
<Badge
|
|
variant="secondary"
|
|
class="shrink-0 tabular-nums"
|
|
:aria-label="
|
|
t('tickets.count', { count: column.count })
|
|
"
|
|
>{{ column.count }}</Badge
|
|
>
|
|
</header>
|
|
<p
|
|
v-if="column.count === 0"
|
|
class="text-caption text-muted-foreground rounded-md border border-dashed p-3"
|
|
>
|
|
{{ t('board.emptyColumn') }}
|
|
</p>
|
|
<p
|
|
v-if="column.groups.length === 0"
|
|
class="text-caption text-muted-foreground"
|
|
>
|
|
{{ t('board.noStatuses') }}
|
|
</p>
|
|
<section
|
|
v-for="group in column.groups"
|
|
:key="group.status.id"
|
|
:aria-labelledby="`board-status-${group.status.id}`"
|
|
class="grid min-w-0 gap-3"
|
|
>
|
|
<div
|
|
class="text-caption text-muted-foreground flex min-w-0 items-center justify-between gap-2"
|
|
>
|
|
<h3
|
|
:id="`board-status-${group.status.id}`"
|
|
class="wrap-anywhere font-semibold"
|
|
>
|
|
{{ group.status.name }}
|
|
</h3>
|
|
<span
|
|
class="shrink-0 tabular-nums"
|
|
:aria-label="
|
|
t('tickets.count', {
|
|
count: group.tickets.length,
|
|
})
|
|
"
|
|
>{{ group.tickets.length }}</span
|
|
>
|
|
</div>
|
|
<p
|
|
v-if="group.tickets.length === 0"
|
|
class="text-caption text-muted-foreground border-border rounded-md border border-dashed p-3"
|
|
>
|
|
{{ t('board.emptyStatus') }}
|
|
</p>
|
|
<ul v-else class="grid min-w-0 gap-3">
|
|
<li
|
|
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"
|
|
:ticket="ticket"
|
|
:show-status="false"
|
|
>
|
|
<template
|
|
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({
|
|
project: project.id,
|
|
ticket: ticket.id,
|
|
})
|
|
"
|
|
v-slot="{ processing, errors }"
|
|
:error-bag="`board-ticket-${ticket.id}`"
|
|
:options="{
|
|
preserveScroll: true,
|
|
}"
|
|
:on-before="
|
|
() =>
|
|
!busy && !draggedTicket
|
|
"
|
|
@start="menuPending = ticket.id"
|
|
@finish="menuPending = null"
|
|
class="grid min-w-0 gap-2"
|
|
>
|
|
<input
|
|
type="hidden"
|
|
name="redirect_to"
|
|
value="board"
|
|
/>
|
|
<label
|
|
:for="`ticket-status-${ticket.id}`"
|
|
class="text-caption text-muted-foreground font-semibold"
|
|
>{{
|
|
t('board.moveTicket', {
|
|
key: ticket.ticket_key,
|
|
})
|
|
}}</label
|
|
>
|
|
<select
|
|
:id="`ticket-status-${ticket.id}`"
|
|
name="ticket_status_id"
|
|
:disabled="
|
|
busy ||
|
|
processing ||
|
|
allowedTargetStatuses(
|
|
ticket,
|
|
).length === 0
|
|
"
|
|
:aria-invalid="
|
|
!!errors.ticket_status_id ||
|
|
undefined
|
|
"
|
|
:aria-describedby="
|
|
errors.ticket_status_id
|
|
? `ticket-status-error-${ticket.id}`
|
|
: undefined
|
|
"
|
|
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<option
|
|
v-if="
|
|
allowedTargetStatuses(
|
|
ticket,
|
|
).length === 0
|
|
"
|
|
value=""
|
|
>
|
|
{{ t('board.noMoves') }}
|
|
</option>
|
|
<option
|
|
v-for="target in allowedTargetStatuses(
|
|
ticket,
|
|
)"
|
|
:key="target.id"
|
|
:value="target.id"
|
|
>
|
|
{{
|
|
ticketStatusLabel(
|
|
target,
|
|
t(
|
|
'tickets.noStatus',
|
|
),
|
|
)
|
|
}}
|
|
</option>
|
|
</select>
|
|
<InputError
|
|
:id="`ticket-status-error-${ticket.id}`"
|
|
:message="
|
|
errors.ticket_status_id
|
|
"
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
variant="outline"
|
|
:disabled="
|
|
busy ||
|
|
processing ||
|
|
allowedTargetStatuses(
|
|
ticket,
|
|
).length === 0
|
|
"
|
|
>{{
|
|
t(
|
|
processing
|
|
? 'board.updatingStatus'
|
|
: 'board.updateStatus',
|
|
)
|
|
}}</Button
|
|
>
|
|
<p
|
|
v-if="
|
|
allowedTargetStatuses(
|
|
ticket,
|
|
).length === 0
|
|
"
|
|
class="text-caption text-muted-foreground"
|
|
>
|
|
{{
|
|
t(
|
|
'board.noMovesDescription',
|
|
)
|
|
}}
|
|
</p>
|
|
</Form>
|
|
</template>
|
|
</TicketCard>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
</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>
|