306 lines
13 KiB
Vue
306 lines
13 KiB
Vue
<script setup lang="ts">
|
|
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 { 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(
|
|
(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>
|
|
<section
|
|
id="ticket-relationships"
|
|
aria-labelledby="ticket-relationships-title"
|
|
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
|
|
>
|
|
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
|
|
{{ t('ticketDetail.relationships') }}
|
|
</h2>
|
|
<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
|
|
>
|
|
</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>
|
|
<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="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
|
>
|
|
{{ t('ticketDetail.noLinks') }}
|
|
</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)]"
|
|
>
|
|
<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,
|
|
}))
|
|
"
|
|
: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
|
|
>
|
|
</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>
|
|
</template>
|