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 { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
|
||||||
import { useForm, useRemember } from '@inertiajs/vue3';
|
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 ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
@@ -20,6 +20,18 @@ import {
|
|||||||
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
||||||
const props = defineProps<TicketDetailContentProps>();
|
const props = defineProps<TicketDetailContentProps>();
|
||||||
const { t } = useLocalization();
|
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(
|
const canEditAuthor = computed(
|
||||||
() => props.canEditTicket && props.canManageProject,
|
() => props.canEditTicket && props.canManageProject,
|
||||||
);
|
);
|
||||||
@@ -279,6 +291,7 @@ const submit = (): void => {
|
|||||||
<details
|
<details
|
||||||
v-if="canEditTicket"
|
v-if="canEditTicket"
|
||||||
id="ticket-edit"
|
id="ticket-edit"
|
||||||
|
ref="editor"
|
||||||
class="bg-card group rounded-xl border shadow-sm"
|
class="bg-card group rounded-xl border shadow-sm"
|
||||||
>
|
>
|
||||||
<summary
|
<summary
|
||||||
|
|||||||
@@ -1,33 +1,76 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
import {
|
import { ArrowRight, GitFork, Link2, Plus } from 'lucide-vue-next';
|
||||||
Card,
|
import { computed, nextTick, useTemplateRef } from 'vue';
|
||||||
CardContent,
|
import { store } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
CardDescription,
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
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 TicketTreeList from '@/components/TicketTreeList.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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { show as showTicket } from '@/routes/projects/tickets';
|
||||||
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
const props = defineProps<TicketDetailRelationshipsProps>();
|
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||||
|
defineEmits<{ editParent: [] }>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const linksHeading = useTemplateRef<HTMLHeadingElement>('linksHeading');
|
||||||
const currentParentTicket = computed(
|
const currentParentTicket = computed(
|
||||||
() =>
|
() =>
|
||||||
props.ticket.parent_ticket ??
|
props.ticket.parent_ticket ??
|
||||||
props.parentTicketOptions.find(
|
props.parentTicketOptions.find(
|
||||||
(parentTicket) => parentTicket.id === props.ticket.parent_ticket_id,
|
(parent) => parent.id === props.ticket.parent_ticket_id,
|
||||||
) ??
|
) ??
|
||||||
null,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -39,184 +82,223 @@ const currentParentTicket = computed(
|
|||||||
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
|
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
|
||||||
{{ t('ticketDetail.relationships') }}
|
{{ t('ticketDetail.relationships') }}
|
||||||
</h2>
|
</h2>
|
||||||
<Card v-if="ticket.ticket_type?.is_parent" class="h-fit">
|
<Card class="min-w-0 gap-5 overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader
|
||||||
<CardTitle>{{ t('ticketDetail.children') }}</CardTitle>
|
class="flex flex-wrap items-center justify-between gap-3"
|
||||||
<CardDescription>{{
|
>
|
||||||
t('ticketDetail.childrenDescription')
|
<h3 class="flex items-center gap-2 font-semibold">
|
||||||
}}</CardDescription>
|
<GitFork class="text-primary size-5" aria-hidden="true" />{{
|
||||||
</CardHeader>
|
t('ticketRelationships.hierarchy')
|
||||||
|
}}
|
||||||
<CardContent>
|
</h3>
|
||||||
<div
|
<Button
|
||||||
v-if="childTicketTree.length === 0"
|
v-if="canEditTicket"
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('editParent')"
|
||||||
|
>{{ t('ticketRelationships.editParent') }}</Button
|
||||||
>
|
>
|
||||||
{{ t('ticketDetail.noChildren') }}
|
|
||||||
</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>
|
</CardHeader>
|
||||||
|
<CardContent class="grid min-w-0 gap-5">
|
||||||
<CardContent>
|
<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
|
<Link
|
||||||
|
v-if="currentParentTicket"
|
||||||
:href="
|
:href="
|
||||||
showTicket({
|
showTicket({
|
||||||
project: project.id,
|
project: project.id,
|
||||||
ticket: currentParentTicket.id,
|
ticket: currentParentTicket.id,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
class="border-border hover:border-primary/60 block rounded-xl border px-4 py-4 transition"
|
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
|
||||||
>
|
>
|
||||||
<div class="font-medium">
|
|
||||||
{{ currentParentTicket.ticket_key }}
|
|
||||||
</div>
|
|
||||||
<div class="text-muted-foreground mt-1 text-sm">
|
|
||||||
{{ currentParentTicket.title }}
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
<p
|
||||||
</Card>
|
v-else
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
<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">
|
{{ t('ticketDetail.noParent') }}
|
||||||
<Label for="ticket_link_type_id">{{
|
</p>
|
||||||
t('ticketDetail.linkType')
|
<p
|
||||||
}}</Label>
|
v-if="canEditTicket"
|
||||||
<select
|
class="text-muted-foreground text-xs"
|
||||||
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
|
{{ t('ticketRelationships.parentHelp') }}
|
||||||
v-for="ticketLinkType in ticketLinkTypes"
|
</p>
|
||||||
:key="ticketLinkType.id"
|
|
||||||
:value="ticketLinkType.id"
|
|
||||||
>
|
|
||||||
{{ ticketLinkType.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError :message="errors.ticket_link_type_id" />
|
|
||||||
</div>
|
</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
|
<div
|
||||||
v-if="linkedTickets.length === 0"
|
v-if="ticket.ticket_type?.is_parent"
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
class="grid min-w-0 gap-3 border-t pt-5"
|
||||||
>
|
|
||||||
{{ 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"
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="secondary">
|
<h4 class="text-sm font-semibold">
|
||||||
{{ linkedTicket.ticket_link_type.label }}
|
{{ t('ticketDetail.children') }}
|
||||||
</Badge>
|
</h4>
|
||||||
<Link
|
<Badge variant="secondary">{{
|
||||||
:href="
|
childTicketTree.length
|
||||||
showTicket({
|
}}</Badge>
|
||||||
project: project.id,
|
|
||||||
ticket: linkedTicket.linked_ticket.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="text-foreground hover:text-primary font-medium transition"
|
|
||||||
>
|
|
||||||
{{ linkedTicket.linked_ticket.ticket_key }}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
<p class="text-muted-foreground text-xs">
|
||||||
{{ linkedTicket.linked_ticket.title }}
|
{{ t('ticketRelationships.childrenHelp') }}
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
<Form
|
v-if="childTicketTree.length === 0"
|
||||||
v-if="canEditTicket"
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
v-bind="
|
|
||||||
ProjectTicketLinkController.destroy.form({
|
|
||||||
project: project.id,
|
|
||||||
ticket: ticket.id,
|
|
||||||
ticketLink: linkedTicket.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="mt-3 flex justify-end"
|
|
||||||
v-slot="{ processing: deleting }"
|
|
||||||
>
|
>
|
||||||
|
{{ 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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
:disabled="form.processing || !selectedTicket"
|
||||||
size="sm"
|
class="sm:justify-self-start"
|
||||||
:disabled="deleting"
|
>{{
|
||||||
>{{ t('ticketDetail.removeLink') }}</Button
|
form.processing
|
||||||
|
? t('forms.saving')
|
||||||
|
: t('ticketDetail.saveLink')
|
||||||
|
}}</Button
|
||||||
>
|
>
|
||||||
</Form>
|
</form>
|
||||||
</div>
|
<p
|
||||||
</div>
|
v-else-if="canEditTicket"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
ticketLinkTypes.length === 0
|
||||||
|
? 'ticketRelationships.noTypes'
|
||||||
|
: 'ticketRelationships.noTargets',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</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':
|
'ticketDetail.noLinks':
|
||||||
'No ticket links are configured for this ticket yet.',
|
'No ticket links are configured for this ticket yet.',
|
||||||
'ticketDetail.removeLink': 'Remove',
|
'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':
|
'ticketDetail.historyDescription':
|
||||||
'Every tracked ticket change is recorded with actor and timestamp.',
|
'Every tracked ticket change is recorded with actor and timestamp.',
|
||||||
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed, useTemplateRef } from 'vue';
|
||||||
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
|
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
|
||||||
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
|
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
|
||||||
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.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';
|
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
||||||
const props = defineProps<ProjectTicketPageProps>();
|
const props = defineProps<ProjectTicketPageProps>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const ticketContent = useTemplateRef('ticketContent');
|
||||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{ title: t('navigation.projects'), href: projectIndex() },
|
{ title: t('navigation.projects'), href: projectIndex() },
|
||||||
{ title: props.project.name, href: show(props.project.id) },
|
{ 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]"
|
class="grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"
|
||||||
>
|
>
|
||||||
<TicketDetailContent
|
<TicketDetailContent
|
||||||
|
ref="ticketContent"
|
||||||
:ticket="ticket"
|
:ticket="ticket"
|
||||||
:project="project"
|
:project="project"
|
||||||
:can-edit-ticket="canEditTicket"
|
:can-edit-ticket="canEditTicket"
|
||||||
@@ -80,6 +82,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|||||||
:linked-tickets="linkedTickets"
|
:linked-tickets="linkedTickets"
|
||||||
:ticket-link-types="ticketLinkTypes"
|
:ticket-link-types="ticketLinkTypes"
|
||||||
:ticket-link-options="ticketLinkOptions"
|
:ticket-link-options="ticketLinkOptions"
|
||||||
|
@edit-parent="ticketContent?.editParent()"
|
||||||
class="xl:col-start-1"
|
class="xl:col-start-1"
|
||||||
/>
|
/>
|
||||||
<TicketDetailHistory :ticket="ticket" class="xl:col-start-1" />
|
<TicketDetailHistory :ticket="ticket" class="xl:col-start-1" />
|
||||||
|
|||||||
@@ -251,15 +251,20 @@ try {
|
|||||||
response.url().includes('/tickets/42/links') &&
|
response.url().includes('/tickets/42/links') &&
|
||||||
response.request().method() === 'POST',
|
response.request().method() === 'POST',
|
||||||
);
|
);
|
||||||
|
await page.getByLabel('Linked ticket', { exact: true }).selectOption('41');
|
||||||
await page.getByRole('button', { name: 'Save link', exact: true }).click();
|
await page.getByRole('button', { name: 'Save link', exact: true }).click();
|
||||||
assert.equal((await linkSaved).status(), 303);
|
assert.equal((await linkSaved).status(), 303);
|
||||||
await page.getByRole('button', { name: 'Remove', exact: true }).waitFor();
|
await page
|
||||||
|
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
|
||||||
|
.waitFor();
|
||||||
const linkRemoved = page.waitForResponse(
|
const linkRemoved = page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
response.url().includes('/tickets/42/links/3?_method=DELETE') &&
|
response.url().includes('/tickets/42/links/3') &&
|
||||||
response.request().method() === 'POST',
|
response.request().method() === 'DELETE',
|
||||||
);
|
);
|
||||||
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
await page
|
||||||
|
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
|
||||||
|
.click();
|
||||||
assert.equal((await linkRemoved).status(), 303);
|
assert.equal((await linkRemoved).status(), 303);
|
||||||
await visit('?readonly&hideAuthor&hidePoints');
|
await visit('?readonly&hideAuthor&hidePoints');
|
||||||
assert.equal(await content().locator('form').count(), 0);
|
assert.equal(await content().locator('form').count(), 0);
|
||||||
@@ -273,7 +278,12 @@ try {
|
|||||||
.count(),
|
.count(),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
await tabTo(content().getByRole('link', { name: 'ENG-41', exact: true }));
|
await tabTo(
|
||||||
|
content().getByRole('link', {
|
||||||
|
name: 'ENG-41 Related infrastructure',
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForURL('**/tickets/41');
|
await page.waitForURL('**/tickets/41');
|
||||||
await page
|
await page
|
||||||
|
|||||||
@@ -182,7 +182,12 @@ function makePage(url) {
|
|||||||
encryptHistory: false,
|
encryptHistory: false,
|
||||||
props: {
|
props: {
|
||||||
name: 'KanbanGit',
|
name: 'KanbanGit',
|
||||||
errors: params.has('rejected')
|
errors: params.has('linkRejected')
|
||||||
|
? {
|
||||||
|
ticket_link_type_id: 'Invalid relationship type.',
|
||||||
|
linked_ticket_id: 'Choose another ticket.',
|
||||||
|
}
|
||||||
|
: params.has('rejected')
|
||||||
? {
|
? {
|
||||||
title: 'This title is not allowed.',
|
title: 'This title is not allowed.',
|
||||||
...(params.has('allErrors')
|
...(params.has('allErrors')
|
||||||
@@ -250,25 +255,45 @@ function makePage(url) {
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
...linked,
|
...linked,
|
||||||
ticket_type: { is_parent: false },
|
ticket_type_name: 'Epic',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 43,
|
||||||
|
ticket_key: 'ENG-43',
|
||||||
|
title: 'Nested child with a long title that must wrap on mobile',
|
||||||
|
ticket_type_name: 'Task',
|
||||||
children: [],
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
children_count: 0,
|
children_count: 0,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
linkedTickets: params.has('empty')
|
linkedTickets:
|
||||||
|
params.has('empty') || params.has('noLinks')
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
ticket_link_type: { id: 1, label: 'Blocks' },
|
source_ticket_id: params.has('incoming')
|
||||||
|
? 41
|
||||||
|
: 42,
|
||||||
|
target_ticket_id: params.has('incoming')
|
||||||
|
? 42
|
||||||
|
: 41,
|
||||||
|
ticket_link_type: {
|
||||||
|
id: 1,
|
||||||
|
label: params.has('incoming')
|
||||||
|
? 'Blocked by'
|
||||||
|
: 'Blocks',
|
||||||
|
},
|
||||||
linked_ticket: linked,
|
linked_ticket: linked,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
ticketLinkOptions: [linked],
|
ticketLinkOptions: params.has('noTargets') ? [] : [linked],
|
||||||
ticketLinkTypes: [
|
ticketLinkTypes: params.has('noTypes')
|
||||||
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
|
? []
|
||||||
],
|
: [{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' }],
|
||||||
repositoryBranches: params.has('empty')
|
repositoryBranches: params.has('empty')
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
@@ -317,7 +342,7 @@ if (serve) {
|
|||||||
for await (const chunk of request) {
|
for await (const chunk of request) {
|
||||||
body += chunk;
|
body += chunk;
|
||||||
}
|
}
|
||||||
const data = JSON.parse(body);
|
const data = body ? JSON.parse(body) : {};
|
||||||
const previous = new URL(request.headers.referer);
|
const previous = new URL(request.headers.referer);
|
||||||
const params = previous.searchParams;
|
const params = previous.searchParams;
|
||||||
if (params.has('serverError')) {
|
if (params.has('serverError')) {
|
||||||
@@ -350,8 +375,17 @@ if (serve) {
|
|||||||
} else if (request.url.includes('/links')) {
|
} else if (request.url.includes('/links')) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
request.url.endsWith('/links/3?_method=DELETE') ||
|
request.url.endsWith('/links/3?_method=DELETE') ||
|
||||||
|
request.method === 'DELETE' ||
|
||||||
Number(data.linked_ticket_id) === 41,
|
Number(data.linked_ticket_id) === 41,
|
||||||
);
|
);
|
||||||
|
if (params.has('rejectLink')) {
|
||||||
|
params.set('linkRejected', '1');
|
||||||
|
} else if (request.method === 'DELETE') {
|
||||||
|
params.set('noLinks', '1');
|
||||||
|
} else {
|
||||||
|
params.delete('noLinks');
|
||||||
|
params.delete('linkRejected');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
(request.method === 'POST' || 'field_values' in data) &&
|
(request.method === 'POST' || 'field_values' in data) &&
|
||||||
@@ -492,6 +526,15 @@ if (serve) {
|
|||||||
assert.match(html, /Add criterion/);
|
assert.match(html, /Add criterion/);
|
||||||
assert.match(html, /Create branch/);
|
assert.match(html, /Create branch/);
|
||||||
assert.match(html, /Save link/);
|
assert.match(html, /Save link/);
|
||||||
|
assert.match(html, /Outgoing/);
|
||||||
|
const incoming = await render('?incoming');
|
||||||
|
assert.match(incoming, /Incoming/);
|
||||||
|
assert.match(incoming, /Blocked by/);
|
||||||
|
assert.match(await render('?noTypes'), /No link types are configured/);
|
||||||
|
assert.match(
|
||||||
|
await render('?noTargets'),
|
||||||
|
/No other tickets are available/,
|
||||||
|
);
|
||||||
assert.match(html, /Remove/);
|
assert.match(html, /Remove/);
|
||||||
const readOnly = await render('?readonly');
|
const readOnly = await render('?readonly');
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
@@ -517,7 +560,11 @@ if (serve) {
|
|||||||
]) {
|
]) {
|
||||||
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
|
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
|
||||||
}
|
}
|
||||||
assert.match(await render('?parent'), /Child tickets/);
|
const hierarchy = await render('?parent');
|
||||||
|
assert.match(hierarchy, /Child tickets/);
|
||||||
|
assert.match(hierarchy, /Platform delivery/);
|
||||||
|
assert.match(hierarchy, /Nested child/);
|
||||||
|
assert.match(hierarchy, /Change parent/);
|
||||||
assert.match(await render('?noAccess'), /Open Git connections/);
|
assert.match(await render('?noAccess'), /Open Git connections/);
|
||||||
assert.match(
|
assert.match(
|
||||||
await render('?gitError'),
|
await render('?gitError'),
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const base = 'http://127.0.0.1:4185/projects/7/tickets/42';
|
||||||
|
const section = () => page.locator('#ticket-relationships');
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
async function visit(query = '') {
|
||||||
|
await page.goto(base + query);
|
||||||
|
await section().waitFor();
|
||||||
|
}
|
||||||
|
const control = (label) => section().getByLabel(label, { exact: true });
|
||||||
|
const save = () =>
|
||||||
|
section().getByRole('button', { name: 'Save link', exact: true });
|
||||||
|
const remove = () =>
|
||||||
|
section().getByRole('button', {
|
||||||
|
name: 'Remove link to ENG-41',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({
|
||||||
|
colorScheme: theme,
|
||||||
|
reducedMotion: 'reduce',
|
||||||
|
});
|
||||||
|
await visit('?parent&incoming');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('html')
|
||||||
|
.evaluate((el) => el.classList.contains('dark')),
|
||||||
|
theme === 'dark',
|
||||||
|
);
|
||||||
|
for (const text of [
|
||||||
|
'Platform delivery',
|
||||||
|
'Child tickets',
|
||||||
|
'Nested child with a long title that must wrap on mobile',
|
||||||
|
'Incoming',
|
||||||
|
'Blocked by',
|
||||||
|
]) {
|
||||||
|
assert.equal(
|
||||||
|
await section()
|
||||||
|
.getByText(text, { exact: true })
|
||||||
|
.isVisible(),
|
||||||
|
true,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const collapse = section().getByRole('button', {
|
||||||
|
name: 'Collapse children of ENG-41',
|
||||||
|
});
|
||||||
|
await collapse.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
assert.equal(
|
||||||
|
await section()
|
||||||
|
.getByText(
|
||||||
|
'Nested child with a long title that must wrap on mobile',
|
||||||
|
{ exact: true },
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
||||||
|
await control('Link type').focus();
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await control('Linked ticket').evaluate(
|
||||||
|
(el) =>
|
||||||
|
el === document.activeElement &&
|
||||||
|
el.matches(':focus-visible'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await control('Linked ticket').selectOption('41');
|
||||||
|
await section()
|
||||||
|
.locator('form')
|
||||||
|
.last()
|
||||||
|
.getByText('ENG-42', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`Overflow ${width}/${theme}`,
|
||||||
|
);
|
||||||
|
await section().screenshot({
|
||||||
|
path: `/private/tmp/kan33-relationships-${width}-${theme}.png`,
|
||||||
|
});
|
||||||
|
await section()
|
||||||
|
.getByRole('button', { name: 'Change parent', exact: true })
|
||||||
|
.click();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('#parent_ticket_id')
|
||||||
|
.evaluate((el) => el === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#ticket-edit').evaluate((el) => el.open),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit('?noLinks');
|
||||||
|
assert.equal(await save().isDisabled(), true);
|
||||||
|
await control('Linked ticket').selectOption('41');
|
||||||
|
let request = page.waitForRequest(
|
||||||
|
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
|
||||||
|
);
|
||||||
|
await save().click();
|
||||||
|
assert.deepEqual((await request).postDataJSON(), {
|
||||||
|
ticket_link_type_id: 1,
|
||||||
|
linked_ticket_id: 41,
|
||||||
|
});
|
||||||
|
await section().getByText('Outgoing', { exact: true }).waitFor();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('#linked_ticket_id')?.value === '',
|
||||||
|
);
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
document.activeElement?.textContent.includes('Linked tickets'),
|
||||||
|
);
|
||||||
|
request = page.waitForRequest(
|
||||||
|
(r) => r.method() === 'DELETE' && r.url().endsWith('/links/3'),
|
||||||
|
);
|
||||||
|
await remove().click();
|
||||||
|
await request;
|
||||||
|
await section()
|
||||||
|
.getByText('No ticket links are configured for this ticket yet.', {
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
document.activeElement?.textContent.includes('Linked tickets'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await visit('?rejectLink');
|
||||||
|
await control('Linked ticket').selectOption('41');
|
||||||
|
await save().click();
|
||||||
|
await section()
|
||||||
|
.getByText('Choose another ticket.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await control('Linked ticket').inputValue(), '41');
|
||||||
|
for (const label of ['Link type', 'Linked ticket']) {
|
||||||
|
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
|
||||||
|
}
|
||||||
|
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
|
||||||
|
await save().click();
|
||||||
|
await section().locator('[data-form-state="network"]').waitFor();
|
||||||
|
assert.equal(await control('Linked ticket').inputValue(), '41');
|
||||||
|
await page.unrouteAll();
|
||||||
|
|
||||||
|
await visit('?serverError');
|
||||||
|
await remove().click();
|
||||||
|
await section().locator('[data-form-state="server"]').waitFor();
|
||||||
|
assert.equal(await section().locator('[data-ticket-link]').count(), 1);
|
||||||
|
let pending;
|
||||||
|
await page.route('**/tickets/42/links/3', (route) => {
|
||||||
|
pending = route;
|
||||||
|
});
|
||||||
|
await section()
|
||||||
|
.getByRole('button', { name: 'Try again', exact: true })
|
||||||
|
.click();
|
||||||
|
await section().locator('[data-form-state="loading"]').waitFor();
|
||||||
|
assert.equal(await remove().isDisabled(), true);
|
||||||
|
await pending.abort();
|
||||||
|
await section().locator('[data-form-state="network"]').waitFor();
|
||||||
|
await page.unrouteAll();
|
||||||
|
|
||||||
|
await visit();
|
||||||
|
await section()
|
||||||
|
.getByRole('button', { name: 'Change parent', exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByLabel('Parent ticket', { exact: true }).selectOption('');
|
||||||
|
request = page.waitForRequest(
|
||||||
|
(r) => r.method() === 'PATCH' && r.url().endsWith('/tickets/42'),
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Save changes', exact: true })
|
||||||
|
.click();
|
||||||
|
assert.equal((await request).postDataJSON().parent_ticket_id, '');
|
||||||
|
await page.getByText('Ticket updated.', { exact: true }).waitFor();
|
||||||
|
|
||||||
|
await visit('?readonly&parent&incoming');
|
||||||
|
assert.equal(await section().locator('form').count(), 0);
|
||||||
|
assert.equal(
|
||||||
|
await section()
|
||||||
|
.getByRole('button', { name: 'Change parent', exact: true })
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await section().getByText('Blocked by', { exact: true }).isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const [query, message] of [
|
||||||
|
['?noTypes', 'No link types are configured for this project.'],
|
||||||
|
[
|
||||||
|
'?noTargets',
|
||||||
|
'No other tickets are available to link in this project.',
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
await visit(query);
|
||||||
|
assert.equal(
|
||||||
|
await section().getByText(message, { exact: true }).isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(await save().count(), 0);
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Ticket relationships browser checks passed: hierarchy, direction, add/remove, validation, retries, permissions, keyboard, mobile and themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user