Merge pull request 'Feature/kan 33 ticketbeziehungen und hierarchie' (#128) from feature/KAN-33-ticketbeziehungen-und-hierarchie into main
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 20m52s
CI/CD / Publish immutable image (push) Successful in 3m39s

Reviewed-on: #128
This commit was merged in pull request #128.
This commit is contained in:
2026-09-14 09:45:54 +00:00
8 changed files with 740 additions and 229 deletions
@@ -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>
+17
View File
@@ -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.',
+4 -1
View File
@@ -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" />
+15 -5
View File
@@ -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
+57 -10
View File
@@ -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();
}