feat(KAN-29): rework backlog ticket tree
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m3s

This commit is contained in:
2026-09-12 06:18:47 +00:00
parent c8fa76e08a
commit b76b4adaec
11 changed files with 1050 additions and 243 deletions
+399 -166
View File
@@ -1,70 +1,155 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import { TicketListItem } from '@/components/tickets';
import { Head, Link } from '@inertiajs/vue3';
import {
ArrowLeft,
ChevronsDownUp,
ChevronsUpDown,
Columns3,
Plus,
RotateCcw,
Search,
} from 'lucide-vue-next';
import { computed, reactive, ref, watch } from 'vue';
import BacklogTicketTree from '@/components/BacklogTicketTree.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
import {
buildBacklogTicketTree,
countBacklogTreeTickets,
expandableBacklogTicketIds,
filterBacklogTicketTree,
} from '@/lib/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects/index';
import { index as projectIndex, show } from '@/routes/projects/index';
import { create as createTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
import type {
BacklogFilterState,
BreadcrumbItem,
ProjectBacklogPageProps,
} from '@/types';
const props = defineProps<ProjectBacklogPageProps>();
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.backlog'), href: backlogIndex(props.project.id) },
]);
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Backlog',
href: backlogIndex(props.project.id),
},
];
const filters = reactive<BacklogFilterState>({
assigneeId: '',
priorityId: '',
query: '',
statusId: '',
typeId: '',
});
const collapsedTicketIds = reactive(new Set<number>());
const pendingTicketId = ref<number | null>(null);
const ticketTree = computed(() => buildBacklogTicketTree(props.tickets));
const filteredTicketTree = computed(() =>
filterBacklogTicketTree(ticketTree.value, filters),
);
const visibleTicketCount = computed(() =>
countBacklogTreeTickets(filteredTicketTree.value),
);
const expandableTicketIds = computed(() =>
expandableBacklogTicketIds(filteredTicketTree.value),
);
const hasActiveFilters = computed(
() =>
filters.query.trim() !== '' ||
filters.typeId !== '' ||
filters.priorityId !== '' ||
filters.statusId !== '' ||
filters.assigneeId !== '',
);
const allVisibleBranchesCollapsed = computed(
() =>
expandableTicketIds.value.length > 0 &&
expandableTicketIds.value.every((ticketId) =>
collapsedTicketIds.has(ticketId),
),
);
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
);
};
function clearFilters(): void {
filters.query = '';
filters.typeId = '';
filters.priorityId = '';
filters.statusId = '';
filters.assigneeId = '';
}
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
function toggleAllBranches(): void {
if (allVisibleBranchesCollapsed.value) {
collapsedTicketIds.clear();
return;
}
for (const ticketId of expandableTicketIds.value) {
collapsedTicketIds.add(ticketId);
}
}
function finishMove(ticketId: number): void {
if (pendingTicketId.value === ticketId) {
pendingTicketId.value = null;
}
}
watch(
() => props.project.id,
() => {
clearFilters();
collapsedTicketIds.clear();
pendingTicketId.value = null;
},
);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} backlog`" />
<Head :title="t('backlog.title', { name: project.name })" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
<div class="gap-page 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="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="`${project.name} backlog`"
description="All newly created tickets start here before they move onto the board."
/>
<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: tickets.length }) }}
</p>
</div>
<h1
class="text-headline-mobile md:text-headline wrap-anywhere"
>
{{ t('backlog.title', { name: project.name }) }}
</h1>
<p class="text-caption text-muted-foreground max-w-2xl">
{{ t('backlog.description') }}
</p>
</div>
<div class="flex flex-wrap gap-3">
<div class="flex flex-wrap gap-2">
<Button v-if="canCreateTickets" as-child>
<Link
:href="
@@ -74,138 +159,286 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
)
"
>
Create ticket
<Plus aria-hidden="true" />
{{ t('navigation.createTicket') }}
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
<Link :href="boardIndex(project.id)">
<Columns3 aria-hidden="true" />
{{ t('backlog.openBoard') }}
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
<Button as-child variant="ghost">
<Link :href="show(project.id)">
<ArrowLeft aria-hidden="true" />
{{ t('backlog.backToProject') }}
</Link>
</Button>
</div>
</header>
<FeedbackState v-if="status" state="success" :title="status" />
<section
aria-labelledby="backlog-filter-title"
class="bg-card gap-card p-card grid min-w-0 rounded-md border"
>
<div
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
>
<div class="grid min-w-0 gap-1">
<h2 id="backlog-filter-title" class="text-title">
{{ t('backlog.filters.title') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('backlog.filters.description') }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
:disabled="!hasActiveFilters"
@click="clearFilters"
>
<RotateCcw aria-hidden="true" />
{{ t('backlog.filters.clear') }}
</Button>
</div>
<div
class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,2fr)_repeat(4,minmax(9rem,1fr))]"
>
<div
class="grid min-w-0 gap-1.5 sm:col-span-2 xl:col-span-1"
>
<label
for="backlog-query"
class="text-caption font-semibold"
>
{{ t('backlog.filters.search') }}
</label>
<div class="relative min-w-0">
<Search
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="backlog-query"
v-model="filters.query"
type="search"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border py-2 pl-9 pr-3 focus-visible:outline-none focus-visible:ring-2"
:placeholder="
t('backlog.filters.searchPlaceholder')
"
/>
</div>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-type"
class="text-caption font-semibold"
>
{{ t('backlog.filters.type') }}
</label>
<select
id="backlog-type"
v-model="filters.typeId"
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"
>
<option value="">
{{ t('backlog.filters.allTypes') }}
</option>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-priority"
class="text-caption font-semibold"
>
{{ t('backlog.filters.priority') }}
</label>
<select
id="backlog-priority"
v-model="filters.priorityId"
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"
>
<option value="">
{{ t('backlog.filters.allPriorities') }}
</option>
<option
v-for="priority in ticketPriorities"
:key="priority.id"
:value="priority.id"
>
{{ priority.name }}
</option>
</select>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-status"
class="text-caption font-semibold"
>
{{ t('backlog.filters.status') }}
</label>
<select
id="backlog-status"
v-model="filters.statusId"
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"
>
<option value="">
{{ t('backlog.filters.allStatuses') }}
</option>
<option
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{ ticketStatus.name }}
</option>
</select>
</div>
<div class="grid min-w-0 gap-1.5">
<label
for="backlog-assignee"
class="text-caption font-semibold"
>
{{ t('backlog.filters.assignee') }}
</label>
<select
id="backlog-assignee"
v-model="filters.assigneeId"
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"
>
<option value="">
{{ t('backlog.filters.allAssignees') }}
</option>
<option value="unassigned">
{{ t('tickets.unassigned') }}
</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
</div>
</div>
</section>
<div
v-if="tickets.length === 0"
class="grid justify-items-start gap-3"
>
<FeedbackState
state="empty"
:title="t('backlog.empty.title')"
:description="t('backlog.empty.description')"
/>
<Button v-if="canCreateTickets" as-child size="sm">
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'backlog' } },
)
"
>
<Plus aria-hidden="true" />
{{ t('backlog.empty.action') }}
</Link>
</Button>
</div>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
v-else-if="visibleTicketCount === 0"
class="grid justify-items-start gap-3"
>
{{ status }}
<FeedbackState
state="empty"
:title="t('backlog.filters.empty.title')"
:description="t('backlog.filters.empty.description')"
/>
<Button
type="button"
variant="outline"
size="sm"
@click="clearFilters"
>
<RotateCcw aria-hidden="true" />
{{ t('backlog.filters.clear') }}
</Button>
</div>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Backlog tickets</CardTitle>
<CardDescription>
Tickets remain here while their status belongs to
backlog.
</CardDescription>
</CardHeader>
<CardContent class="p-0">
<div
v-if="tickets.length === 0"
class="text-muted-foreground p-6 text-sm"
>
No backlog tickets exist yet.
<section
v-else
aria-labelledby="backlog-results-title"
class="grid min-w-0 gap-3"
>
<div
class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="grid min-w-0 gap-1">
<h2 id="backlog-results-title" class="text-title">
{{ t('backlog.results.title') }}
</h2>
<p class="text-caption text-muted-foreground">
{{
t('backlog.results.count', {
count: visibleTicketCount,
total: tickets.length,
})
}}
</p>
</div>
<Button
v-if="expandableTicketIds.length > 0"
type="button"
variant="outline"
size="sm"
@click="toggleAllBranches"
>
<ChevronsUpDown
v-if="allVisibleBranchesCollapsed"
aria-hidden="true"
/>
<ChevronsDownUp v-else aria-hidden="true" />
{{
t(
allVisibleBranchesCollapsed
? 'backlog.tree.expandAll'
: 'backlog.tree.collapseAll',
)
}}
</Button>
</div>
<ul v-else class="gap-column grid p-4">
<TicketListItem
v-for="ticket in tickets"
:key="ticket.id"
:project-id="project.id"
:ticket="ticket"
>
<template v-if="canEditTickets" #actions>
<Form
v-bind="
ProjectBoardController.move.form({
project: project.id,
ticket: ticket.id,
})
"
class="flex w-full flex-col gap-2"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<label
:for="`backlog-ticket-status-${ticket.id}`"
class="text-label text-muted-foreground uppercase"
>
Move to status
</label>
<div
class="flex flex-col gap-2 sm:flex-row"
>
<select
:id="`backlog-ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
allowedTargetStatuses(ticket)
.length === 0 || processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-control min-w-0 flex-1 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<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,
'Unknown status',
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(ticket)
.length === 0
"
>
Move
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-caption text-muted-foreground"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</template>
</TicketListItem>
</ul>
</CardContent>
</Card>
<BacklogTicketTree
:nodes="filteredTicketTree"
:project-id="project.id"
:can-edit-tickets="canEditTickets"
:collapsed-ticket-ids="collapsedTicketIds"
:pending-ticket-id="pendingTicketId"
@toggle="toggleTicket"
@move-start="pendingTicketId = $event"
@move-finish="finishMove"
/>
</section>
</div>
</AppLayout>
</template>