feat(KAN-26): add reusable ticket presentations
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s

This commit is contained in:
2026-09-11 14:20:02 +02:00
parent 810d1850ae
commit 313e2d2396
17 changed files with 706 additions and 257 deletions
+84 -169
View File
@@ -2,6 +2,7 @@
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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -12,13 +13,11 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects/index';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
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';
@@ -40,25 +39,6 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
}
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,
@@ -132,163 +112,98 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
No backlog tickets exist yet.
</div>
<div v-else class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
<ul v-else class="gap-column grid p-4">
<TicketListItem
v-for="ticket in tickets"
:key="ticket.id"
:project-id="project.id"
:ticket="ticket"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th class="px-4 py-3 font-medium">Key</th>
<th class="px-4 py-3 font-medium">Title</th>
<th class="px-4 py-3 font-medium">Type</th>
<th class="px-4 py-3 font-medium">
Status
</th>
<th class="px-4 py-3 font-medium">
Priority
</th>
<th
v-if="canEditTickets"
class="px-4 py-3 font-medium"
<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
</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="ticket in tickets"
:key="ticket.id"
class="bg-background"
>
<td class="px-4 py-4">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
</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"
>
<Badge variant="outline">
{{ ticket.ticket_key }}
</Badge>
</Link>
</td>
<td class="px-4 py-4">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="text-foreground hover:text-primary font-medium transition"
>
{{ ticket.title }}
</Link>
<div
v-if="ticket.description"
class="text-muted-foreground mt-1 line-clamp-2"
>
{{ ticket.description }}
</div>
</td>
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_type.name }}
</td>
<td class="text-muted-foreground px-4 py-4">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</td>
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_priority.name }}
</td>
<td v-if="canEditTickets" class="px-4 py-4">
<Form
v-bind="
ProjectBoardController.move.form(
{
project: project.id,
ticket: ticket.id,
},
)
"
class="flex flex-col gap-2 sm:flex-row"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<select
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-w-44 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,
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Move
</Button>
<p
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
class="text-muted-foreground text-xs sm:basis-full"
value=""
>
This status does not allow
moving anywhere else.
</p>
</Form>
</td>
</tr>
</tbody>
</table>
</div>
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>
</div>
+10 -88
View File
@@ -2,6 +2,7 @@
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -12,14 +13,12 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
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 boardIndex } from '@/routes/projects/board';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import { create as createTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
@@ -41,25 +40,6 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
}
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,
@@ -180,72 +160,13 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</div>
<div v-else class="flex flex-col gap-4">
<Card
<TicketCard
v-for="ticket in boardColumn.tickets"
:key="ticket.id"
:class="[
'gap-0',
ticket.is_blocked
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
: '',
]"
:project-id="project.id"
:ticket="ticket"
>
<CardHeader class="pb-4">
<div class="flex flex-wrap gap-2">
<Badge variant="outline">
{{ ticket.ticket_key }}
</Badge>
<Badge
v-if="ticket.is_blocked"
variant="destructive"
>
Blocked
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_type.name }}
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_priority.name }}
</Badge>
<Badge variant="outline">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</Badge>
</div>
<CardTitle class="text-base">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="hover:text-primary transition"
>
{{ ticket.title }}
</Link>
</CardTitle>
<CardDescription
v-if="ticket.description"
class="line-clamp-3"
>
{{ 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>
</CardHeader>
<CardContent v-if="canEditTickets" class="pt-0">
<template v-if="canEditTickets" #actions>
<Form
v-bind="
ProjectBoardController.move.form({
@@ -300,6 +221,7 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
{{
ticketStatusLabel(
ticketStatus,
'Unknown status',
)
}}
</option>
@@ -328,8 +250,8 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
anywhere else.
</p>
</Form>
</CardContent>
</Card>
</template>
</TicketCard>
</div>
</CardContent>
</Card>