From f32918dce2b091d2a422e8248a223ec965607cc6 Mon Sep 17 00:00:00 2001
From: Twyco
Date: Mon, 14 Sep 2026 09:58:36 +0200
Subject: [PATCH] feat(KAN-33): redesign ticket relationships and hierarchy
---
.../tickets/detail/TicketDetailContent.vue | 15 +-
.../detail/TicketDetailRelationships.vue | 450 +++++++++++-------
.../tickets/detail/TicketRelationshipLink.vue | 116 +++++
resources/js/locales/en.ts | 17 +
resources/js/pages/projects/Ticket.vue | 5 +-
tests/Fixtures/ticket-detail-browser.mjs | 20 +-
tests/Fixtures/ticket-detail.mjs | 123 +++--
.../Fixtures/ticket-relationships-browser.mjs | 223 +++++++++
8 files changed, 740 insertions(+), 229 deletions(-)
create mode 100644 resources/js/components/tickets/detail/TicketRelationshipLink.vue
create mode 100644 tests/Fixtures/ticket-relationships-browser.mjs
diff --git a/resources/js/components/tickets/detail/TicketDetailContent.vue b/resources/js/components/tickets/detail/TicketDetailContent.vue
index 17681e7..b488033 100644
--- a/resources/js/components/tickets/detail/TicketDetailContent.vue
+++ b/resources/js/components/tickets/detail/TicketDetailContent.vue
@@ -3,7 +3,7 @@ import { useLocalization } from '@/composables/useLocalization';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useForm, useRemember } from '@inertiajs/vue3';
-import { computed, ref, toRef, unref } from 'vue';
+import { computed, ref, toRef, unref, useTemplateRef, nextTick } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
@@ -20,6 +20,18 @@ import {
import type { TicketDetailContentProps } from '@/types/ticket-detail';
const props = defineProps();
const { t } = useLocalization();
+const editor = useTemplateRef('editor');
+async function editParent(): Promise {
+ if (!props.canEditTicket || !editor.value) {
+ return;
+ }
+ editor.value.open = true;
+ await nextTick();
+ editor.value
+ ?.querySelector('#parent_ticket_id')
+ ?.focus();
+}
+defineExpose({ editParent });
const canEditAuthor = computed(
() => props.canEditTicket && props.canManageProject,
);
@@ -279,6 +291,7 @@ const submit = (): void => {
-import { useLocalization } from '@/composables/useLocalization';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
-
-import { Form, Link } from '@inertiajs/vue3';
-import { computed } from 'vue';
-import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
-import InputError from '@/components/InputError.vue';
+import { Link, useForm } from '@inertiajs/vue3';
+import { ArrowRight, GitFork, Link2, Plus } from 'lucide-vue-next';
+import { computed, nextTick, useTemplateRef } from 'vue';
+import { store } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
+import FormFeedback from '@/components/FormFeedback.vue';
import TicketTreeList from '@/components/TicketTreeList.vue';
+import TicketFormField from '@/components/tickets/TicketFormField.vue';
+import TicketRelationshipLink from '@/components/tickets/detail/TicketRelationshipLink.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
-import { Label } from '@/components/ui/label';
+import { Card, CardContent, CardHeader } from '@/components/ui/card';
+import { useFormProtection } from '@/composables/useFormProtection';
+import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
+
const props = defineProps();
+defineEmits<{ editParent: [] }>();
const { t } = useLocalization();
+const linksHeading = useTemplateRef('linksHeading');
const currentParentTicket = computed(
() =>
props.ticket.parent_ticket ??
props.parentTicketOptions.find(
- (parentTicket) => parentTicket.id === props.ticket.parent_ticket_id,
+ (parent) => parent.id === props.ticket.parent_ticket_id,
) ??
null,
);
+const canAddLink = computed(
+ () =>
+ props.canEditTicket &&
+ props.ticketLinkTypes.length > 0 &&
+ props.ticketLinkOptions.length > 0,
+);
+const form = useForm({
+ ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
+ linked_ticket_id: '',
+});
+const protection = useFormProtection(form, {
+ isDirty: () => props.canEditTicket && form.isDirty,
+});
+const selectedType = computed(() =>
+ props.ticketLinkTypes.find(
+ (type) => String(type.id) === String(form.ticket_link_type_id),
+ ),
+);
+const selectedTicket = computed(() =>
+ props.ticketLinkOptions.find(
+ (ticket) => String(ticket.id) === String(form.linked_ticket_id),
+ ),
+);
+
+async function focusLinks(): Promise {
+ await nextTick();
+ linksHeading.value?.focus();
+}
+function submit(): void {
+ if (!canAddLink.value) {
+ return;
+ }
+ protection.submit(
+ store({ project: props.project.id, ticket: props.ticket.id }),
+ {
+ preserveScroll: true,
+ onSuccess: () => {
+ form.linked_ticket_id = '';
+ form.clearErrors();
+ void focusLinks();
+ },
+ },
+ );
+}
@@ -39,184 +82,223 @@ const currentParentTicket = computed(
{{ t('ticketDetail.relationships') }}
-
-
- {{ t('ticketDetail.children') }}
- {{
- t('ticketDetail.childrenDescription')
- }}
-
-
-
-
+
+
+ {{
+ t('ticketRelationships.hierarchy')
+ }}
+
+ {{ t('ticketRelationships.editParent') }}
- {{ t('ticketDetail.noChildren') }}
+
+
+
+
+ {{ t('ticketDetail.parent') }}
+
+
+
{{ currentParentTicket.ticket_key }}
+
{{ currentParentTicket.title }}
+
+
+ {{ t('ticketDetail.noParent') }}
+
+
+ {{ t('ticketRelationships.parentHelp') }}
+
-
-
-
-
-
-
-
- {{ t('ticketDetail.parent') }}
- {{
- t('ticketDetail.parentDescription')
- }}
-
-
-
-
-
- {{ currentParentTicket.ticket_key }}
-
-
- {{ currentParentTicket.title }}
-
-
-
-
-
-
-
- {{ t('ticketDetail.linkedTickets') }}
- {{
- t('ticketDetail.linkedTicketsDescription')
- }}
-
-
-
-
-
+
+
+ {{ t('ticketDetail.children') }}
+
+ {{
+ childTicketTree.length
+ }}
+
+
+ {{ t('ticketRelationships.childrenHelp') }}
+
+
+ {{ t('ticketDetail.noChildren') }}
+
+
+
+
+
+
+
+
+ {{
+ t('ticketDetail.linkedTickets')
+ }}{{
+ linkedTickets.length
+ }}
+
+
+ {{ t('ticketDetail.linkedTicketsDescription') }}
+
+
+
+
{{ t('ticketDetail.noLinks') }}
-
-
-
+
+
+ {{ t('ticketRelationships.directionHelp') }}
+
+ {{
+ form.processing
+ ? t('forms.saving')
+ : t('ticketDetail.saveLink')
+ }}
+
+
+ {{
+ t(
+ ticketLinkTypes.length === 0
+ ? 'ticketRelationships.noTypes'
+ : 'ticketRelationships.noTargets',
+ )
+ }}
+
diff --git a/resources/js/components/tickets/detail/TicketRelationshipLink.vue b/resources/js/components/tickets/detail/TicketRelationshipLink.vue
new file mode 100644
index 0000000..195226d
--- /dev/null
+++ b/resources/js/components/tickets/detail/TicketRelationshipLink.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+ {{
+ t(
+ outgoing
+ ? 'ticketRelationships.outgoing'
+ : 'ticketRelationships.incoming',
+ )
+ }}
+
+
{{
+ ticketKey
+ }}
+
{{
+ link.ticket_link_type.label
+ }}
+
+
+
+ {{
+ link.linked_ticket.ticket_key
+ }}
+ {{
+ link.linked_ticket.title
+ }}
+
+
+ {{
+ t('ticketDetail.removeLink')
+ }}
+
+
+
+
+
diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts
index b9b5c35..3a11ba4 100644
--- a/resources/js/locales/en.ts
+++ b/resources/js/locales/en.ts
@@ -512,6 +512,23 @@ export const en = {
'ticketDetail.noLinks':
'No ticket links are configured for this ticket yet.',
'ticketDetail.removeLink': 'Remove',
+ 'ticketRelationships.hierarchy': 'Hierarchy',
+ 'ticketRelationships.editParent': 'Change parent',
+ 'ticketRelationships.parentHelp':
+ 'Change parent to assign this ticket or choose “No parent ticket” to detach it.',
+ 'ticketRelationships.childrenHelp':
+ 'Open a child ticket to change its parent. Expand a group to see its descendants.',
+ 'ticketRelationships.addLink': 'Add or update a link',
+ 'ticketRelationships.selectTicket': 'Select a ticket',
+ 'ticketRelationships.directionHelp':
+ 'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
+ 'ticketRelationships.outgoing': 'Outgoing',
+ 'ticketRelationships.incoming': 'Incoming',
+ 'ticketRelationships.removeLink': 'Remove link to {key}',
+ 'ticketRelationships.noTypes':
+ 'No link types are configured for this project.',
+ 'ticketRelationships.noTargets':
+ 'No other tickets are available to link in this project.',
'ticketDetail.historyDescription':
'Every tracked ticket change is recorded with actor and timestamp.',
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
diff --git a/resources/js/pages/projects/Ticket.vue b/resources/js/pages/projects/Ticket.vue
index 629922b..3d72127 100644
--- a/resources/js/pages/projects/Ticket.vue
+++ b/resources/js/pages/projects/Ticket.vue
@@ -1,6 +1,6 @@