feat(KAN-33): redesign ticket relationships and hierarchy
This commit is contained in:
@@ -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<TicketDetailContentProps>();
|
||||
const { t } = useLocalization();
|
||||
const editor = useTemplateRef<HTMLDetailsElement>('editor');
|
||||
async function editParent(): Promise<void> {
|
||||
if (!props.canEditTicket || !editor.value) {
|
||||
return;
|
||||
}
|
||||
editor.value.open = true;
|
||||
await nextTick();
|
||||
editor.value
|
||||
?.querySelector<HTMLSelectElement>('#parent_ticket_id')
|
||||
?.focus();
|
||||
}
|
||||
defineExpose({ editParent });
|
||||
const canEditAuthor = computed(
|
||||
() => props.canEditTicket && props.canManageProject,
|
||||
);
|
||||
@@ -279,6 +291,7 @@ const submit = (): void => {
|
||||
<details
|
||||
v-if="canEditTicket"
|
||||
id="ticket-edit"
|
||||
ref="editor"
|
||||
class="bg-card group rounded-xl border shadow-sm"
|
||||
>
|
||||
<summary
|
||||
|
||||
@@ -1,33 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
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<TicketDetailRelationshipsProps>();
|
||||
defineEmits<{ editParent: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const linksHeading = useTemplateRef<HTMLHeadingElement>('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<void> {
|
||||
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();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,184 +82,223 @@ const currentParentTicket = computed(
|
||||
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
|
||||
{{ t('ticketDetail.relationships') }}
|
||||
</h2>
|
||||
<Card v-if="ticket.ticket_type?.is_parent" class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.children') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.childrenDescription')
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div
|
||||
v-if="childTicketTree.length === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
<Card class="min-w-0 gap-5 overflow-hidden">
|
||||
<CardHeader
|
||||
class="flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<h3 class="flex items-center gap-2 font-semibold">
|
||||
<GitFork class="text-primary size-5" aria-hidden="true" />{{
|
||||
t('ticketRelationships.hierarchy')
|
||||
}}
|
||||
</h3>
|
||||
<Button
|
||||
v-if="canEditTicket"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="$emit('editParent')"
|
||||
>{{ t('ticketRelationships.editParent') }}</Button
|
||||
>
|
||||
{{ t('ticketDetail.noChildren') }}
|
||||
</CardHeader>
|
||||
<CardContent class="grid min-w-0 gap-5">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<h4
|
||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-wide"
|
||||
>
|
||||
{{ t('ticketDetail.parent') }}
|
||||
</h4>
|
||||
<Link
|
||||
v-if="currentParentTicket"
|
||||
:href="
|
||||
showTicket({
|
||||
project: project.id,
|
||||
ticket: currentParentTicket.id,
|
||||
})
|
||||
"
|
||||
class="border-primary/20 bg-primary/5 hover:border-primary focus-visible:ring-ring/35 group grid min-w-0 gap-2 rounded-xl border p-4 outline-none transition-colors focus-visible:ring-2"
|
||||
>
|
||||
<span
|
||||
class="text-primary font-mono text-xs font-semibold"
|
||||
>{{ currentParentTicket.ticket_key }}</span
|
||||
>
|
||||
<span
|
||||
class="group-hover:text-primary text-sm font-medium"
|
||||
>{{ currentParentTicket.title }}</span
|
||||
>
|
||||
</Link>
|
||||
<p
|
||||
v-else
|
||||
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||
>
|
||||
{{ t('ticketDetail.noParent') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="canEditTicket"
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
{{ t('ticketRelationships.parentHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TicketTreeList
|
||||
v-else
|
||||
:nodes="childTicketTree"
|
||||
:project-id="project.id"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-else-if="currentParentTicket !== null" class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.parent') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.parentDescription')
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: project.id,
|
||||
ticket: currentParentTicket.id,
|
||||
})
|
||||
"
|
||||
class="border-border hover:border-primary/60 block rounded-xl border px-4 py-4 transition"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ currentParentTicket.ticket_key }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-sm">
|
||||
{{ currentParentTicket.title }}
|
||||
</div>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.linkedTickets') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.linkedTicketsDescription')
|
||||
}}</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="border-border grid gap-4 rounded-xl border border-dashed p-4"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="ticket_link_type_id">{{
|
||||
t('ticketDetail.linkType')
|
||||
}}</Label>
|
||||
<select
|
||||
id="ticket_link_type_id"
|
||||
name="ticket_link_type_id"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
>
|
||||
<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 min-w-0 gap-2">
|
||||
<Label for="linked_ticket_id">{{
|
||||
t('ticketDetail.linkedTicket')
|
||||
}}</Label>
|
||||
<select
|
||||
id="linked_ticket_id"
|
||||
name="linked_ticket_id"
|
||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
>
|
||||
<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">{{
|
||||
t('ticketDetail.saveLink')
|
||||
}}</Button>
|
||||
</Form>
|
||||
|
||||
<div
|
||||
v-if="ticket.ticket_type?.is_parent"
|
||||
class="grid min-w-0 gap-3 border-t pt-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="text-sm font-semibold">
|
||||
{{ t('ticketDetail.children') }}
|
||||
</h4>
|
||||
<Badge variant="secondary">{{
|
||||
childTicketTree.length
|
||||
}}</Badge>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ t('ticketRelationships.childrenHelp') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="childTicketTree.length === 0"
|
||||
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||
>
|
||||
{{ t('ticketDetail.noChildren') }}
|
||||
</p>
|
||||
<TicketTreeList
|
||||
v-else
|
||||
:nodes="childTicketTree"
|
||||
:project-id="project.id"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="min-w-0 gap-5 overflow-hidden">
|
||||
<CardHeader>
|
||||
<h3
|
||||
ref="linksHeading"
|
||||
tabindex="-1"
|
||||
class="focus-visible:ring-ring/35 flex flex-wrap items-center gap-2 rounded-md font-semibold outline-none focus-visible:ring-2"
|
||||
>
|
||||
<Link2 class="text-primary size-5" aria-hidden="true" />{{
|
||||
t('ticketDetail.linkedTickets')
|
||||
}}<Badge variant="secondary">{{
|
||||
linkedTickets.length
|
||||
}}</Badge>
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.linkedTicketsDescription') }}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="grid min-w-0 gap-5">
|
||||
<p
|
||||
v-if="linkedTickets.length === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||
>
|
||||
{{ t('ticketDetail.noLinks') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="linkedTicket in linkedTickets"
|
||||
:key="linkedTicket.id"
|
||||
class="border-border rounded-xl border px-4 py-4"
|
||||
</p>
|
||||
<ul v-else class="grid min-w-0 gap-3">
|
||||
<TicketRelationshipLink
|
||||
v-for="link in linkedTickets"
|
||||
:key="link.id"
|
||||
:link="link"
|
||||
:project-id="project.id"
|
||||
:ticket-id="ticket.id"
|
||||
:ticket-key="ticket.ticket_key"
|
||||
:can-edit="canEditTicket"
|
||||
@removed="focusLinks"
|
||||
/>
|
||||
</ul>
|
||||
<form
|
||||
v-if="canAddLink"
|
||||
class="bg-muted/30 grid min-w-0 gap-4 rounded-xl border p-4"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<h4 class="flex items-center gap-2 text-sm font-semibold">
|
||||
<Plus class="size-4" aria-hidden="true" />{{
|
||||
t('ticketRelationships.addLink')
|
||||
}}
|
||||
</h4>
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
|
||||
>
|
||||
<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="text-foreground hover:text-primary font-medium transition"
|
||||
>
|
||||
{{ linkedTicket.linked_ticket.ticket_key }}
|
||||
</Link>
|
||||
</div>
|
||||
<p class="text-muted-foreground mt-2 text-sm">
|
||||
{{ linkedTicket.linked_ticket.title }}
|
||||
</p>
|
||||
|
||||
<Form
|
||||
v-if="canEditTicket"
|
||||
v-bind="
|
||||
ProjectTicketLinkController.destroy.form({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
ticketLink: linkedTicket.id,
|
||||
})
|
||||
<TicketFormField
|
||||
id="ticket_link_type_id"
|
||||
v-model="form.ticket_link_type_id"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.linkType')"
|
||||
:options="
|
||||
ticketLinkTypes.map((type) => ({
|
||||
value: type.id,
|
||||
label: type.label,
|
||||
}))
|
||||
"
|
||||
class="mt-3 flex justify-end"
|
||||
v-slot="{ processing: deleting }"
|
||||
:error="form.errors.ticket_link_type_id"
|
||||
:disabled="form.processing"
|
||||
required
|
||||
/>
|
||||
<TicketFormField
|
||||
id="linked_ticket_id"
|
||||
v-model="form.linked_ticket_id"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.linkedTicket')"
|
||||
:options="[
|
||||
{
|
||||
value: '',
|
||||
label: t(
|
||||
'ticketRelationships.selectTicket',
|
||||
),
|
||||
},
|
||||
...ticketLinkOptions.map((ticket) => ({
|
||||
value: ticket.id,
|
||||
label: `${ticket.ticket_key} — ${ticket.title}`,
|
||||
})),
|
||||
]"
|
||||
:error="form.errors.linked_ticket_id"
|
||||
:disabled="form.processing"
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
<p
|
||||
v-if="selectedType && selectedTicket"
|
||||
aria-live="polite"
|
||||
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||
>
|
||||
<span class="font-mono text-xs">{{
|
||||
ticket.ticket_key
|
||||
}}</span
|
||||
><span>{{ selectedType.label }}</span
|
||||
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||
class="font-mono text-xs"
|
||||
>{{ selectedTicket.ticket_key }}</span
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:disabled="deleting"
|
||||
>{{ t('ticketDetail.removeLink') }}</Button
|
||||
>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ t('ticketRelationships.directionHelp') }}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.processing || !selectedTicket"
|
||||
class="sm:justify-self-start"
|
||||
>{{
|
||||
form.processing
|
||||
? t('forms.saving')
|
||||
: t('ticketDetail.saveLink')
|
||||
}}</Button
|
||||
>
|
||||
</form>
|
||||
<p
|
||||
v-else-if="canEditTicket"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
ticketLinkTypes.length === 0
|
||||
? 'ticketRelationships.noTypes'
|
||||
: 'ticketRelationships.noTargets',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, useForm } from '@inertiajs/vue3';
|
||||
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { show } from '@/routes/projects/tickets';
|
||||
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
|
||||
|
||||
const props = defineProps<{
|
||||
link: TicketLinkModel;
|
||||
projectId: number;
|
||||
ticketId: number;
|
||||
ticketKey: string;
|
||||
canEdit: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ removed: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
|
||||
const form = useForm({});
|
||||
const protection = useFormProtection(form);
|
||||
function remove(): void {
|
||||
if (!props.canEdit) {
|
||||
return;
|
||||
}
|
||||
protection.submit(
|
||||
destroy({
|
||||
project: props.projectId,
|
||||
ticket: props.ticketId,
|
||||
ticketLink: props.link.id,
|
||||
}),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => emit('removed'),
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="grid min-w-0 gap-3 rounded-xl border p-4"
|
||||
:data-ticket-link="link.id"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-muted-foreground max-w-full whitespace-normal"
|
||||
>
|
||||
<ArrowUpRight
|
||||
v-if="outgoing"
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/><ArrowDownLeft
|
||||
v-else
|
||||
class="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{
|
||||
t(
|
||||
outgoing
|
||||
? 'ticketRelationships.outgoing'
|
||||
: 'ticketRelationships.incoming',
|
||||
)
|
||||
}}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground font-mono text-xs">{{
|
||||
ticketKey
|
||||
}}</span>
|
||||
<span class="text-sm font-medium">{{
|
||||
link.ticket_link_type.label
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
|
||||
<Link
|
||||
:href="
|
||||
show({ project: projectId, ticket: link.linked_ticket.id })
|
||||
"
|
||||
class="hover:text-primary focus-visible:ring-ring/35 grid min-w-0 flex-1 basis-40 gap-1 rounded-md outline-none transition-colors focus-visible:ring-2"
|
||||
>
|
||||
<span class="text-primary font-mono text-xs font-semibold">{{
|
||||
link.linked_ticket.ticket_key
|
||||
}}</span>
|
||||
<span class="wrap-anywhere text-sm font-medium">{{
|
||||
link.linked_ticket.title
|
||||
}}</span>
|
||||
</Link>
|
||||
<form v-if="canEdit" @submit.prevent="remove">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-muted-foreground hover:text-destructive"
|
||||
:aria-label="
|
||||
t('ticketRelationships.removeLink', {
|
||||
key: link.linked_ticket.ticket_key,
|
||||
})
|
||||
"
|
||||
:disabled="form.processing"
|
||||
><Unlink class="size-4" aria-hidden="true" />{{
|
||||
t('ticketDetail.removeLink')
|
||||
}}</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
<FormFeedback
|
||||
v-if="canEdit"
|
||||
:state="protection.state.value"
|
||||
@retry="remove"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
@@ -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.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
|
||||
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
|
||||
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
|
||||
@@ -14,6 +14,7 @@ import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
||||
const props = defineProps<ProjectTicketPageProps>();
|
||||
const { t } = useLocalization();
|
||||
const ticketContent = useTemplateRef('ticketContent');
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: projectIndex() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
@@ -49,6 +50,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
class="grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"
|
||||
>
|
||||
<TicketDetailContent
|
||||
ref="ticketContent"
|
||||
:ticket="ticket"
|
||||
:project="project"
|
||||
:can-edit-ticket="canEditTicket"
|
||||
@@ -80,6 +82,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
:linked-tickets="linkedTickets"
|
||||
:ticket-link-types="ticketLinkTypes"
|
||||
:ticket-link-options="ticketLinkOptions"
|
||||
@edit-parent="ticketContent?.editParent()"
|
||||
class="xl:col-start-1"
|
||||
/>
|
||||
<TicketDetailHistory :ticket="ticket" class="xl:col-start-1" />
|
||||
|
||||
Reference in New Issue
Block a user