Implement ticket links and blocker state

This commit is contained in:
2026-03-25 09:12:17 +01:00
parent 3b31e6bb7d
commit 70b90f32d4
34 changed files with 1722 additions and 10 deletions
+18 -1
View File
@@ -136,13 +136,24 @@ const statusOptions = [
<Card
v-for="ticket in boardColumn.tickets"
:key="ticket.id"
class="gap-0"
:class="[
'gap-0',
ticket.is_blocked
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
: '',
]"
>
<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>
@@ -169,6 +180,12 @@ const statusOptions = [
>
{{ 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">
+189 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useForm, Head, Link } from '@inertiajs/vue3';
import { Form, useForm, Head, Link } from '@inertiajs/vue3';
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
@@ -44,6 +45,7 @@ const breadcrumbs: BreadcrumbItem[] = [
const form = useForm({
acceptance_criteria: props.ticket.acceptance_criteria ?? '',
blocked_comment: props.ticket.blocked_comment ?? '',
description: props.ticket.description ?? '',
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
const currentFieldValue = props.ticket.field_values?.find(
@@ -58,6 +60,7 @@ const form = useForm({
value_user_id: currentFieldValue?.value_user_id ?? '',
};
}),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
@@ -284,6 +287,42 @@ const formatDateTime = (value: string): string => {
/>
</div>
<div class="grid gap-3 rounded-xl border border-border p-4">
<div class="flex items-center gap-3">
<input
id="is_blocked"
v-model="form.is_blocked"
type="checkbox"
:disabled="
!canEditTicket || form.processing
"
class="size-4 rounded border-input text-primary focus:ring-ring"
/>
<Label for="is_blocked">
Mark ticket as blocked
</Label>
</div>
<div class="grid gap-2">
<Label for="blocked_comment">
Blocker comment
</Label>
<textarea
id="blocked_comment"
v-model="form.blocked_comment"
rows="3"
:disabled="
!canEditTicket || form.processing
"
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60"
placeholder="Describe what currently blocks progress on this ticket."
/>
<InputError
:message="form.errors.blocked_comment"
/>
</div>
</div>
<div class="grid gap-4">
<div class="space-y-1">
<h3 class="text-sm font-semibold">
@@ -388,6 +427,155 @@ const formatDateTime = (value: string): string => {
</Card>
<div class="space-y-6">
<Card class="h-fit">
<CardHeader>
<CardTitle>Linked tickets</CardTitle>
<CardDescription>
Track cross-ticket relationships with
project-defined link labels.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Form
v-if="
canEditTicket &&
ticketLinkTypes.length > 0 &&
ticketLinkOptions.length > 0
"
v-bind="
ProjectTicketLinkController.store.form({
project: project.id,
ticket: ticket.id,
})
"
class="grid gap-4 rounded-xl border border-dashed border-border p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="ticket_link_type_id">
Link type
</Label>
<select
id="ticket_link_type_id"
name="ticket_link_type_id"
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<option
v-for="ticketLinkType in ticketLinkTypes"
:key="ticketLinkType.id"
:value="ticketLinkType.id"
>
{{ ticketLinkType.label }}
</option>
</select>
<InputError
:message="errors.ticket_link_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="linked_ticket_id">
Linked ticket
</Label>
<select
id="linked_ticket_id"
name="linked_ticket_id"
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<option
v-for="ticketLinkOption in ticketLinkOptions"
:key="ticketLinkOption.id"
:value="ticketLinkOption.id"
>
{{
`${ticketLinkOption.ticket_key} - ${ticketLinkOption.title}`
}}
</option>
</select>
<InputError
:message="errors.linked_ticket_id"
/>
</div>
<Button
type="submit"
:disabled="processing"
>
Save link
</Button>
</Form>
<div
v-if="linkedTickets.length === 0"
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
>
No ticket links are configured for this ticket
yet.
</div>
<div v-else class="space-y-3">
<div
v-for="linkedTicket in linkedTickets"
:key="linkedTicket.id"
class="rounded-xl border border-border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
linkedTicket.ticket_link_type
.label
}}
</Badge>
<Link
:href="
showTicket({
project: project.id,
ticket: linkedTicket
.linked_ticket.id,
})
"
class="font-medium text-foreground transition hover:text-primary"
>
{{
linkedTicket.linked_ticket
.ticket_key
}}
</Link>
</div>
<p class="mt-2 text-sm text-muted-foreground">
{{ linkedTicket.linked_ticket.title }}
</p>
<Form
v-if="canEditTicket"
v-bind="
ProjectTicketLinkController.destroy.form(
{
project: project.id,
ticket: ticket.id,
ticketLink:
linkedTicket.id,
},
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
size="sm"
:disabled="deleting"
>
Remove
</Button>
</Form>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>Child tickets</CardTitle>
@@ -57,7 +57,7 @@ const breadcrumbs: BreadcrumbItem[] = [
</Badge>
<Heading
:title="`${project.name} ticket settings`"
description="Configure project-specific ticket types, priorities, and custom field definitions."
description="Configure project-specific ticket types, priorities, custom field definitions, and ticket link labels."
/>
</div>
@@ -81,7 +81,7 @@ const breadcrumbs: BreadcrumbItem[] = [
{{ status }}
</div>
<div class="grid gap-6 xl:grid-cols-3">
<div class="grid gap-6 xl:grid-cols-2">
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Ticket types</CardTitle>
@@ -497,6 +497,124 @@ const breadcrumbs: BreadcrumbItem[] = [
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Link types</CardTitle>
<CardDescription>
Define project-specific labels for ticket-to-ticket
relationships.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storeLinkType.form(
project.id,
)
"
class="space-y-4 rounded-xl border border-dashed border-border p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="link-type-name">
New link type name
</Label>
<Input
id="link-type-name"
name="name"
required
placeholder="Relates to"
/>
<InputError :message="errors.name" />
</div>
<Button type="submit" :disabled="processing">
Add link type
</Button>
</Form>
<div
v-for="ticketLinkType in ticketLinkTypes"
:key="ticketLinkType.id"
class="rounded-xl border border-border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updateLinkType.form(
[project.id, ticketLinkType.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`link-type-name-${ticketLinkType.id}`"
>
Label
</Label>
<Input
:id="`link-type-name-${ticketLinkType.id}`"
name="name"
:value="ticketLinkType.label"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`link-type-position-${ticketLinkType.id}`"
>
Position
</Label>
<Input
:id="`link-type-position-${ticketLinkType.id}`"
name="position"
type="number"
min="1"
:max="ticketLinkTypes.length"
:value="ticketLinkType.position"
required
/>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyLinkType.form(
[project.id, ticketLinkType.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout>