Implement parent child ticket hierarchy #12
@@ -39,6 +39,7 @@ class UpdateProjectTicket
|
||||
$ticket->loadMissing([
|
||||
'fieldValues.ticketFieldDefinition',
|
||||
'fieldValues.userValue',
|
||||
'parentTicket',
|
||||
'project',
|
||||
'ticketPriority',
|
||||
'ticketType',
|
||||
@@ -104,6 +105,18 @@ class UpdateProjectTicket
|
||||
$summaryLabels[] = 'priority';
|
||||
}
|
||||
|
||||
if ($ticket->parent_ticket_id !== $ticketAttributes['parent_ticket_id']) {
|
||||
$newParentTicket = $ticketAttributes['parent_ticket_id'] === null
|
||||
? null
|
||||
: Ticket::query()->find($ticketAttributes['parent_ticket_id']);
|
||||
|
||||
$changes['parent_ticket'] = [
|
||||
'from' => $ticket->parentTicket?->ticket_key,
|
||||
'to' => $newParentTicket?->ticket_key,
|
||||
];
|
||||
$summaryLabels[] = 'parent ticket';
|
||||
}
|
||||
|
||||
$ticket->fill($ticketAttributes);
|
||||
|
||||
if ($ticket->isDirty()) {
|
||||
|
||||
@@ -27,13 +27,28 @@ class ProjectTicketController extends Controller
|
||||
403,
|
||||
);
|
||||
|
||||
$excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)];
|
||||
|
||||
|
Twyco marked this conversation as resolved
|
||||
return Inertia::render('projects/Ticket', [
|
||||
'canEditTicket' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'),
|
||||
'childTicketTree' => $this->buildChildTicketTree($ticket),
|
||||
'parentTicketOptions' => Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->whereNotIn('id', $excludedParentIds)
|
||||
->whereHas('ticketType', fn ($query) => $query->where('system_key', 'parent'))
|
||||
->orderByDesc('sequence_number')
|
||||
->get()
|
||||
->map(fn (Ticket $parentTicket): array => [
|
||||
'id' => $parentTicket->id,
|
||||
'ticket_key' => $parentTicket->ticket_key,
|
||||
'title' => $parentTicket->title,
|
||||
]),
|
||||
'project' => $project,
|
||||
'status' => session('status'),
|
||||
'ticket' => $ticket->load([
|
||||
|
Twyco marked this conversation as resolved
Eric
commented
🤨 🤨
|
||||
'fieldValues.ticketFieldDefinition',
|
||||
'fieldValues.userValue',
|
||||
'parentTicket',
|
||||
'ticketPriority',
|
||||
'ticketType',
|
||||
'worklogEntries.actor',
|
||||
@@ -71,4 +86,42 @@ class ProjectTicketController extends Controller
|
||||
return to_route('projects.tickets.show', [$project, $ticket])
|
||||
->with('status', $statusMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* children: array<int, mixed>,
|
||||
* id: int,
|
||||
* ticket_key: string,
|
||||
* ticket_type_name: string|null,
|
||||
* title: string
|
||||
* }>
|
||||
*/
|
||||
private function buildChildTicketTree(Ticket $ticket): array
|
||||
{
|
||||
return $ticket->childTickets()
|
||||
->with('ticketType')
|
||||
->get()
|
||||
->map(fn (Ticket $childTicket): array => [
|
||||
'children' => $this->buildChildTicketTree($childTicket),
|
||||
|
Twyco marked this conversation as resolved
Eric
commented
Rekursiv macht mir angst, könnte lange laufzeit sein oder? Je nachdem wie viele ebenen die childs haben Rekursiv macht mir angst, könnte lange laufzeit sein oder? Je nachdem wie viele ebenen die childs haben
Bitte checken
|
||||
'id' => $childTicket->id,
|
||||
'ticket_key' => $childTicket->ticket_key,
|
||||
'ticket_type_name' => $childTicket->ticketType?->name,
|
||||
'title' => $childTicket->title,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function descendantIds(Ticket $ticket): array
|
||||
{
|
||||
return $ticket->childTickets()
|
||||
->get()
|
||||
->flatMap(function (Ticket $childTicket): array {
|
||||
return [$childTicket->id, ...$this->descendantIds($childTicket)];
|
||||
|
Twyco marked this conversation as resolved
Eric
commented
Hier auch muss man sehen wie es in der Praxis sich verhält Hier auch muss man sehen wie es in der Praxis sich verhält
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class UpdateProjectTicketRequest extends FormRequest
|
||||
{
|
||||
@@ -40,6 +43,11 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
],
|
||||
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
'parent_ticket_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('tickets', 'id')->where('project_id', $project->id),
|
||||
],
|
||||
'ticket_priority_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
@@ -54,10 +62,69 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, callable(Validator): void>
|
||||
*/
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator): void {
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
|
||||
/** @var Ticket $ticket */
|
||||
$ticket = $this->route('ticket');
|
||||
|
||||
$parentTicketId = $this->input('parent_ticket_id');
|
||||
$parentTicketId = is_numeric($parentTicketId) ? (int) $parentTicketId : null;
|
||||
|
||||
if ($parentTicketId !== null) {
|
||||
if ($parentTicketId === $ticket->id) {
|
||||
$validator->errors()->add('parent_ticket_id', 'A ticket cannot be its own parent.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$parentTicket = Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->with('ticketType')
|
||||
->find($parentTicketId);
|
||||
|
||||
if ($parentTicket !== null && $parentTicket->ticketType?->system_key !== 'parent') {
|
||||
$validator->errors()->add('parent_ticket_id', 'Only tickets of type Parent can be selected as a parent.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($parentTicket !== null && $this->createsCycle($ticket, $parentTicket)) {
|
||||
$validator->errors()->add('parent_ticket_id', 'A ticket cannot be assigned to one of its descendants.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$requestedTicketTypeId = $this->input('ticket_type_id');
|
||||
$requestedTicketType = is_numeric($requestedTicketTypeId)
|
||||
? TicketType::query()
|
||||
->where('project_id', $project->id)
|
||||
->find((int) $requestedTicketTypeId)
|
||||
: null;
|
||||
|
||||
if (
|
||||
$requestedTicketType?->system_key !== 'parent'
|
||||
&& $ticket->childTickets()->exists()
|
||||
) {
|
||||
$validator->errors()->add('ticket_type_id', 'Tickets with child tickets must keep a Parent type.');
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* acceptance_criteria: string|null,
|
||||
* description: string|null,
|
||||
* parent_ticket_id: int|null,
|
||||
* ticket_priority_id: int,
|
||||
* ticket_type_id: int,
|
||||
* title: string
|
||||
@@ -68,18 +135,27 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
/** @var array{
|
||||
* acceptance_criteria: string|null,
|
||||
* description: string|null,
|
||||
* parent_ticket_id: int|null,
|
||||
* ticket_priority_id: int,
|
||||
* ticket_type_id: int,
|
||||
* title: string
|
||||
* } $attributes
|
||||
*/
|
||||
$attributes = Arr::only($this->validated(), [
|
||||
'acceptance_criteria',
|
||||
'description',
|
||||
'ticket_priority_id',
|
||||
'ticket_type_id',
|
||||
'title',
|
||||
]);
|
||||
$attributes = array_merge(
|
||||
[
|
||||
'acceptance_criteria' => null,
|
||||
'description' => null,
|
||||
'parent_ticket_id' => null,
|
||||
],
|
||||
Arr::only($this->validated(), [
|
||||
'acceptance_criteria',
|
||||
'description',
|
||||
'parent_ticket_id',
|
||||
'ticket_priority_id',
|
||||
'ticket_type_id',
|
||||
'title',
|
||||
]),
|
||||
);
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
@@ -103,4 +179,23 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
|
||||
return $fieldValues;
|
||||
}
|
||||
|
||||
private function createsCycle(Ticket $ticket, Ticket $candidateParent): bool
|
||||
{
|
||||
$currentParent = $candidateParent;
|
||||
|
||||
while ($currentParent !== null) {
|
||||
if ($currentParent->id === $ticket->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($currentParent->parent_ticket_id === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentParent = Ticket::query()->find($currentParent->parent_ticket_id);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'acceptance_criteria',
|
||||
'board_column_id',
|
||||
'description',
|
||||
'parent_ticket_id',
|
||||
'project_id',
|
||||
'sequence_number',
|
||||
'ticket_key',
|
||||
@@ -30,12 +31,23 @@ class Ticket extends Model
|
||||
return $this->belongsTo(BoardColumn::class);
|
||||
}
|
||||
|
||||
public function childTickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'parent_ticket_id')
|
||||
->orderBy('sequence_number');
|
||||
}
|
||||
|
||||
public function fieldValues(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketFieldValue::class)
|
||||
->orderBy('ticket_field_definition_id');
|
||||
}
|
||||
|
||||
public function parentTicket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class, 'parent_ticket_id');
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
|
||||
@@ -24,6 +24,7 @@ class TicketFactory extends Factory
|
||||
'acceptance_criteria' => fake()->paragraph(),
|
||||
'board_column_id' => null,
|
||||
'description' => fake()->paragraphs(2, true),
|
||||
'parent_ticket_id' => null,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'TEST-1',
|
||||
'title' => fake()->sentence(4),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->foreignId('parent_ticket_id')
|
||||
->nullable()
|
||||
->after('board_column_id')
|
||||
->constrained('tickets')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('parent_ticket_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||
|
||||
defineProps<{
|
||||
nodes: TicketTreeNodeModel[];
|
||||
projectId: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="space-y-3">
|
||||
<li v-for="node in nodes" :key="node.id">
|
||||
<div class="rounded-xl border border-border px-4 py-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: projectId,
|
||||
ticket: node.id,
|
||||
})
|
||||
"
|
||||
class="font-medium text-foreground transition hover:text-primary"
|
||||
>
|
||||
{{ node.ticket_key }}
|
||||
</Link>
|
||||
|
||||
<Badge v-if="node.ticket_type_name" variant="secondary">
|
||||
{{ node.ticket_type_name }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-foreground">
|
||||
{{ node.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="node.children.length > 0"
|
||||
class="mt-3 border-l border-border pl-4"
|
||||
>
|
||||
<TicketTreeList
|
||||
:nodes="node.children"
|
||||
:project-id="projectId"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { useForm, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TicketTreeList from '@/components/TicketTreeList.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -57,6 +58,7 @@ const form = useForm({
|
||||
value_user_id: currentFieldValue?.value_user_id ?? '',
|
||||
};
|
||||
}),
|
||||
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
|
||||
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||
ticket_type_id: props.ticket.ticket_type_id,
|
||||
title: props.ticket.title,
|
||||
@@ -220,6 +222,34 @@ const formatDateTime = (value: string): string => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="parent_ticket_id">
|
||||
Parent ticket
|
||||
</Label>
|
||||
<select
|
||||
id="parent_ticket_id"
|
||||
v-model="form.parent_ticket_id"
|
||||
:disabled="
|
||||
!canEditTicket || form.processing
|
||||
"
|
||||
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="">No parent ticket</option>
|
||||
<option
|
||||
v-for="parentTicketOption in parentTicketOptions"
|
||||
:key="parentTicketOption.id"
|
||||
:value="parentTicketOption.id"
|
||||
>
|
||||
{{
|
||||
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="form.errors.parent_ticket_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Description</Label>
|
||||
<textarea
|
||||
@@ -357,53 +387,90 @@ const formatDateTime = (value: string): string => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>Worklog</CardTitle>
|
||||
<CardDescription>
|
||||
Every tracked ticket change is recorded with actor
|
||||
and timestamp.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div class="space-y-6">
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>Child tickets</CardTitle>
|
||||
<CardDescription>
|
||||
Parent tickets expose their full child tree
|
||||
here, including nested parent chains.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<div
|
||||
v-if="(ticket.worklog_entries?.length ?? 0) === 0"
|
||||
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
No worklog entries exist for this ticket yet.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<CardContent>
|
||||
<div
|
||||
v-for="worklogEntry in ticket.worklog_entries"
|
||||
:key="worklogEntry.id"
|
||||
class="rounded-xl border border-border px-4 py-4"
|
||||
v-if="childTicketTree.length === 0"
|
||||
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{{ worklogEntry.entry_type }}
|
||||
</Badge>
|
||||
<span
|
||||
class="text-sm font-medium text-foreground"
|
||||
No child tickets are assigned to this ticket
|
||||
yet.
|
||||
</div>
|
||||
|
||||
<TicketTreeList
|
||||
v-else
|
||||
:nodes="childTicketTree"
|
||||
:project-id="project.id"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>Worklog</CardTitle>
|
||||
<CardDescription>
|
||||
Every tracked ticket change is recorded with
|
||||
actor and timestamp.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<div
|
||||
v-if="
|
||||
(ticket.worklog_entries?.length ?? 0) === 0
|
||||
"
|
||||
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
No worklog entries exist for this ticket yet.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="worklogEntry in ticket.worklog_entries"
|
||||
:key="worklogEntry.id"
|
||||
class="rounded-xl border border-border px-4 py-4"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge variant="outline">
|
||||
{{ worklogEntry.entry_type }}
|
||||
</Badge>
|
||||
<span
|
||||
class="text-sm font-medium text-foreground"
|
||||
>
|
||||
{{
|
||||
worklogEntry.actor?.name ??
|
||||
'System'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-foreground">
|
||||
{{ worklogEntry.summary }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
worklogEntry.actor?.name ?? 'System'
|
||||
formatDateTime(
|
||||
worklogEntry.created_at,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-foreground">
|
||||
{{ worklogEntry.summary }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
{{
|
||||
formatDateTime(worklogEntry.created_at)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -9,6 +9,7 @@ export type TicketModel = {
|
||||
board_column_id: number | null;
|
||||
description: string | null;
|
||||
field_values?: TicketFieldValueModel[];
|
||||
parent_ticket_id: number | null;
|
||||
project_id: number;
|
||||
sequence_number: number;
|
||||
ticket_key: string;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export type TicketTreeNodeModel = {
|
||||
children: TicketTreeNodeModel[];
|
||||
id: number;
|
||||
ticket_key: string;
|
||||
ticket_type_name: string | null;
|
||||
title: string;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import type { TeamModel } from '@/types/models/team-model';
|
||||
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||
import type { UserModel } from '@/types/models/user-model';
|
||||
|
||||
@@ -82,6 +83,12 @@ export type ProjectBacklogPageProps = {
|
||||
|
||||
export type ProjectTicketPageProps = {
|
||||
canEditTicket: boolean;
|
||||
childTicketTree: TicketTreeNodeModel[];
|
||||
parentTicketOptions: Array<{
|
||||
id: number;
|
||||
ticket_key: string;
|
||||
title: string;
|
||||
}>;
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
ticket: TicketModel;
|
||||
|
||||
@@ -125,4 +125,44 @@ class TicketTest extends TestCase
|
||||
$ticket->fresh()->fieldValues->modelKeys(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure tickets expose parent and child relationships for hierarchy rendering.
|
||||
*/
|
||||
public function test_ticket_parent_and_child_relationships_are_available(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
|
||||
$priority = TicketPriority::factory()->for($project)->create([
|
||||
'name' => 'Medium',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
|
||||
$parentType = $project->ticketTypes()->where('system_key', 'parent')->firstOrFail();
|
||||
|
||||
$parentTicket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'CORE-1',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $parentType->id,
|
||||
]);
|
||||
|
||||
$childTicket = Ticket::factory()->create([
|
||||
'parent_ticket_id' => $parentTicket->id,
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 2,
|
||||
'ticket_key' => 'CORE-2',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $taskType->id,
|
||||
]);
|
||||
|
||||
$this->assertSame($parentTicket->id, $childTicket->fresh()->parentTicket?->id);
|
||||
$this->assertSame([$childTicket->id], $parentTicket->fresh()->childTickets->modelKeys());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,4 +230,196 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'summary' => 'Updated title, description, acceptance criteria, type, priority, Assignee field, and Story Points field.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure non-parent tickets cannot be assigned as a parent target.
|
||||
*/
|
||||
public function test_assigning_a_non_parent_ticket_as_parent_is_rejected(): void
|
||||
{
|
||||
$editor = User::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
|
||||
$priority = TicketPriority::factory()->for($project)->create([
|
||||
'name' => 'Medium',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
|
||||
|
||||
$candidateParent = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'CORE-1',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $taskType->id,
|
||||
'title' => 'Task ticket',
|
||||
]);
|
||||
|
||||
$childTicket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 2,
|
||||
'ticket_key' => 'CORE-2',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $taskType->id,
|
||||
'title' => 'Child ticket',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->from(route('projects.tickets.show', [$project, $childTicket]))
|
||||
->patch(route('projects.tickets.update', [$project, $childTicket]), [
|
||||
'title' => $childTicket->title,
|
||||
'description' => $childTicket->description,
|
||||
'acceptance_criteria' => $childTicket->acceptance_criteria,
|
||||
'parent_ticket_id' => $candidateParent->id,
|
||||
'ticket_type_id' => $childTicket->ticket_type_id,
|
||||
'ticket_priority_id' => $childTicket->ticket_priority_id,
|
||||
'field_values' => [],
|
||||
])
|
||||
->assertRedirect(route('projects.tickets.show', [$project, $childTicket]))
|
||||
->assertSessionHasErrors('parent_ticket_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure parent relationships can be assigned and are written to the worklog.
|
||||
*/
|
||||
public function test_users_with_edit_permission_can_assign_parent_tickets(): void
|
||||
{
|
||||
$editor = User::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
|
||||
$priority = TicketPriority::factory()->for($project)->create([
|
||||
'name' => 'Medium',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
|
||||
$parentType = $project->ticketTypes()->where('system_key', 'parent')->firstOrFail();
|
||||
|
||||
$parentTicket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'CORE-1',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $parentType->id,
|
||||
'title' => 'Parent ticket',
|
||||
]);
|
||||
|
||||
$childTicket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 2,
|
||||
'ticket_key' => 'CORE-2',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $taskType->id,
|
||||
'title' => 'Child ticket',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->patch(route('projects.tickets.update', [$project, $childTicket]), [
|
||||
'title' => $childTicket->title,
|
||||
'description' => $childTicket->description,
|
||||
'acceptance_criteria' => $childTicket->acceptance_criteria,
|
||||
'parent_ticket_id' => $parentTicket->id,
|
||||
'ticket_type_id' => $childTicket->ticket_type_id,
|
||||
'ticket_priority_id' => $childTicket->ticket_priority_id,
|
||||
'field_values' => [],
|
||||
])
|
||||
->assertRedirect(route('projects.tickets.show', [$project, $childTicket]))
|
||||
->assertSessionHas('status', 'CORE-2 was updated.');
|
||||
|
||||
$this->assertDatabaseHas('tickets', [
|
||||
'id' => $childTicket->id,
|
||||
'parent_ticket_id' => $parentTicket->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||
'ticket_id' => $childTicket->id,
|
||||
'actor_id' => $editor->id,
|
||||
'entry_type' => 'updated',
|
||||
'summary' => 'Updated parent ticket.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure parent ticket detail pages show nested child trees.
|
||||
*/
|
||||
public function test_parent_ticket_detail_pages_show_nested_child_trees(): void
|
||||
{
|
||||
$viewer = User::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
|
||||
$priority = TicketPriority::factory()->for($project)->create([
|
||||
'name' => 'Medium',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
|
||||
$parentType = $project->ticketTypes()->where('system_key', 'parent')->firstOrFail();
|
||||
|
||||
$rootParent = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'CORE-1',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $parentType->id,
|
||||
'title' => 'Root parent',
|
||||
]);
|
||||
|
||||
$nestedParent = Ticket::factory()->create([
|
||||
'parent_ticket_id' => $rootParent->id,
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 2,
|
||||
'ticket_key' => 'CORE-2',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $parentType->id,
|
||||
'title' => 'Nested parent',
|
||||
]);
|
||||
|
||||
$directTask = Ticket::factory()->create([
|
||||
'parent_ticket_id' => $rootParent->id,
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 3,
|
||||
'ticket_key' => 'CORE-3',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $taskType->id,
|
||||
'title' => 'Direct task',
|
||||
]);
|
||||
|
||||
$nestedTask = Ticket::factory()->create([
|
||||
'parent_ticket_id' => $nestedParent->id,
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 4,
|
||||
'ticket_key' => 'CORE-4',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $taskType->id,
|
||||
'title' => 'Nested task',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->get(route('projects.tickets.show', [$project, $rootParent]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/Ticket')
|
||||
->where('childTicketTree.0.ticket_key', $nestedParent->ticket_key)
|
||||
->where('childTicketTree.0.children.0.ticket_key', $nestedTask->ticket_key)
|
||||
->where('childTicketTree.1.ticket_key', $directTask->ticket_key),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user
Das sehr unschön, besser in Action oderso auslagern