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
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import InputError from '@/components/InputError.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type {
BacklogTicketTreeEmits,
BacklogTicketTreeProps,
} from '@/types/backlog';
import type { TicketModel } from '@/types/models/ticket-model';
const props = defineProps<BacklogTicketTreeProps>();
const emit = defineEmits<BacklogTicketTreeEmits>();
const { t } = useLocalization();
const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(status) => status.id !== ticket.ticket_status_id,
);
</script>
<template>
<ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.ticket.id" class="min-w-0">
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex justify-center pt-1">
<button
v-if="node.children.length > 0"
type="button"
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
:aria-expanded="!collapsedTicketIds.has(node.ticket.id)"
:aria-controls="`backlog-children-${node.ticket.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.ticket.id)
? 'backlog.tree.expand'
: 'backlog.tree.collapse',
{ key: node.ticket.ticket_key },
)
"
@click="emit('toggle', node.ticket.id)"
>
<ChevronRight
v-if="collapsedTicketIds.has(node.ticket.id)"
class="size-4"
aria-hidden="true"
/>
<ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div>
<div class="grid min-w-0 gap-2">
<Badge
v-if="!node.matchesFilters"
variant="secondary"
class="w-fit"
>
{{ t('backlog.filters.parentContext') }}
</Badge>
<TicketCard
:project-id="projectId"
:ticket="node.ticket"
layout="list"
:class="{ 'opacity-80': !node.matchesFilters }"
>
<template v-if="canEditTickets" #actions>
<Form
v-bind="
move.form({
project: projectId,
ticket: node.ticket.id,
})
"
v-slot="{ processing, errors }"
:error-bag="`backlog-ticket-${node.ticket.id}`"
:options="{ preserveScroll: true }"
:on-before="() => pendingTicketId === null"
class="grid min-w-0 gap-2"
@start="emit('moveStart', node.ticket.id)"
@finish="emit('moveFinish', node.ticket.id)"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<label
:for="`backlog-ticket-status-${node.ticket.id}`"
class="text-caption text-muted-foreground font-semibold"
>
{{
t('backlog.moveTicket', {
key: node.ticket.ticket_key,
})
}}
</label>
<select
:id="`backlog-ticket-status-${node.ticket.id}`"
name="ticket_status_id"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
:aria-invalid="
!!errors.ticket_status_id || undefined
"
:aria-describedby="
errors.ticket_status_id
? `backlog-ticket-status-error-${node.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(node.ticket)
.length === 0
"
value=""
>
{{ t('board.noMoves') }}
</option>
<option
v-for="target in allowedTargetStatuses(
node.ticket,
)"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t('tickets.noStatus'),
)
}}
</option>
</select>
<InputError
:id="`backlog-ticket-status-error-${node.ticket.id}`"
:message="errors.ticket_status_id"
/>
<Button
type="submit"
variant="outline"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
>
{{
t(
processing
? 'board.updatingStatus'
: 'board.updateStatus',
)
}}
</Button>
<p
v-if="
allowedTargetStatuses(node.ticket)
.length === 0
"
class="text-caption text-muted-foreground"
>
{{ t('board.noMovesDescription') }}
</p>
</Form>
</template>
</TicketCard>
</div>
</div>
<div
v-if="
node.children.length > 0 &&
!collapsedTicketIds.has(node.ticket.id)
"
:id="`backlog-children-${node.ticket.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
>
<BacklogTicketTree
:nodes="node.children"
:project-id="projectId"
:can-edit-tickets="canEditTickets"
:collapsed-ticket-ids="collapsedTicketIds"
:pending-ticket-id="pendingTicketId"
@toggle="emit('toggle', $event)"
@move-start="emit('moveStart', $event)"
@move-finish="emit('moveFinish', $event)"
/>
</div>
</li>
</ul>
</template>
+75 -27
View File
@@ -1,45 +1,93 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { reactive } from 'vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
import type { TicketTreeListProps } from '@/types/ticket-tree';
defineProps<{
nodes: TicketTreeNodeModel[];
projectId: number;
}>();
defineProps<TicketTreeListProps>();
const { t } = useLocalization();
const collapsedTicketIds = reactive(new Set<number>());
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
</script>
<template>
<ul class="space-y-3">
<li v-for="node in nodes" :key="node.id">
<div class="border-border rounded-xl border px-4 py-4">
<div class="flex flex-wrap items-center gap-2">
<Link
:href="
showTicket({
project: projectId,
ticket: node.id,
})
<ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.id" class="min-w-0">
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex justify-center pt-1">
<button
v-if="node.children.length > 0"
type="button"
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
:aria-expanded="!collapsedTicketIds.has(node.id)"
:aria-controls="`ticket-tree-children-${node.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.id)
? 'tickets.tree.expand'
: 'tickets.tree.collapse',
{ key: node.ticket_key },
)
"
class="text-foreground hover:text-primary font-medium transition"
@click="toggleTicket(node.id)"
>
{{ node.ticket_key }}
</Link>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
<ChevronRight
v-if="collapsedTicketIds.has(node.id)"
class="size-4"
aria-hidden="true"
/>
<ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div>
<div class="text-foreground mt-2 text-sm">
{{ node.title }}
</div>
<Link
:href="
showTicket({
project: projectId,
ticket: node.id,
})
"
class="bg-card text-card-foreground hover:border-primary focus-visible:ring-ring/35 gap-stack group grid min-w-0 rounded-md border p-4 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline">
{{ node.ticket_key }}
</Badge>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
</div>
<span
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ node.title }}
</span>
</Link>
</div>
<div
v-if="node.children.length > 0"
class="border-border mt-3 border-l pl-4"
v-if="
node.children.length > 0 && !collapsedTicketIds.has(node.id)
"
:id="`ticket-tree-children-${node.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
>
<TicketTreeList
:nodes="node.children"
+73 -50
View File
@@ -11,6 +11,7 @@ import type {
} from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), {
layout: 'card',
showDescription: true,
showStatus: true,
});
@@ -22,63 +23,85 @@ const { t } = useLocalization();
<template>
<article
:class="[
'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
'bg-card text-card-foreground min-w-0 rounded-md border',
layout === 'card' ? 'shadow-sm' : '',
ticket.is_blocked ? 'border-warning' : 'border-border',
]"
>
<Link
:href="showTicket({ project: projectId, ticket: ticket.id })"
:aria-label="
t('tickets.open', {
key: ticket.ticket_key,
title: ticket.title,
})
<div
:class="
layout === 'list'
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch'
: ''
"
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start justify-between gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ ticket.title }}
</h3>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
<Link
:href="showTicket({ project: projectId, ticket: ticket.id })"
:aria-label="
t('tickets.open', {
key: ticket.ticket_key,
title: ticket.title,
})
}}
</p>
</Link>
"
:class="[
'focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2',
layout === 'list'
? 'rounded-t-md lg:rounded-l-md lg:rounded-tr-none'
: 'rounded-t-md',
]"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start justify-between gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<div v-if="$slots.actions" class="border-t p-4">
<slot name="actions" />
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ ticket.title }}
</h3>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div
v-if="$slots.actions"
:class="[
'border-t p-4',
layout === 'list'
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
: '',
]"
>
<slot name="actions" />
</div>
</div>
</article>
</template>