Implement parent child ticket hierarchy

This commit is contained in:
2026-03-25 08:35:17 +01:00
parent 1ff8f5d68f
commit 3b31e6bb7d
13 changed files with 617 additions and 46 deletions
+40
View File
@@ -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),
);
}
}