From 3b31e6bb7d4f8e9678234ab6bce1a64f6dc4fa2b Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 25 Mar 2026 08:35:17 +0100 Subject: [PATCH] Implement parent child ticket hierarchy --- app/Actions/Tickets/UpdateProjectTicket.php | 13 ++ .../Controllers/ProjectTicketController.php | 53 +++++ .../Requests/UpdateProjectTicketRequest.php | 109 +++++++++- app/Models/Ticket.php | 12 ++ database/factories/TicketFactory.php | 1 + ..._add_parent_ticket_id_to_tickets_table.php | 32 +++ resources/js/components/TicketTreeList.vue | 51 +++++ resources/js/pages/projects/Ticket.vue | 145 +++++++++---- resources/js/types/models/ticket-model.ts | 1 + .../js/types/models/ticket-tree-node-model.ts | 7 + resources/js/types/page-props.ts | 7 + tests/Feature/Models/TicketTest.php | 40 ++++ .../Projects/ProjectTicketControllerTest.php | 192 ++++++++++++++++++ 13 files changed, 617 insertions(+), 46 deletions(-) create mode 100644 database/migrations/2026_03_25_072501_add_parent_ticket_id_to_tickets_table.php create mode 100644 resources/js/components/TicketTreeList.vue create mode 100644 resources/js/types/models/ticket-tree-node-model.ts diff --git a/app/Actions/Tickets/UpdateProjectTicket.php b/app/Actions/Tickets/UpdateProjectTicket.php index 5ae957c..5fd7f4e 100644 --- a/app/Actions/Tickets/UpdateProjectTicket.php +++ b/app/Actions/Tickets/UpdateProjectTicket.php @@ -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()) { diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index 2e0b79b..5848590 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -27,13 +27,28 @@ class ProjectTicketController extends Controller 403, ); + $excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)]; + 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([ '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, + * 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), + 'id' => $childTicket->id, + 'ticket_key' => $childTicket->ticket_key, + 'ticket_type_name' => $childTicket->ticketType?->name, + 'title' => $childTicket->title, + ]) + ->all(); + } + + /** + * @return array + */ + private function descendantIds(Ticket $ticket): array + { + return $ticket->childTickets() + ->get() + ->flatMap(function (Ticket $childTicket): array { + return [$childTicket->id, ...$this->descendantIds($childTicket)]; + }) + ->values() + ->all(); + } } diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php index aed4581..1046b65 100644 --- a/app/Http/Requests/UpdateProjectTicketRequest.php +++ b/app/Http/Requests/UpdateProjectTicketRequest.php @@ -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 + */ + 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; + } } diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index f986fab..f98f36e 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -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); diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index 8d8789a..c34570f 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -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), diff --git a/database/migrations/2026_03_25_072501_add_parent_ticket_id_to_tickets_table.php b/database/migrations/2026_03_25_072501_add_parent_ticket_id_to_tickets_table.php new file mode 100644 index 0000000..f04dfe8 --- /dev/null +++ b/database/migrations/2026_03_25_072501_add_parent_ticket_id_to_tickets_table.php @@ -0,0 +1,32 @@ +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'); + }); + } +}; diff --git a/resources/js/components/TicketTreeList.vue b/resources/js/components/TicketTreeList.vue new file mode 100644 index 0000000..c252a21 --- /dev/null +++ b/resources/js/components/TicketTreeList.vue @@ -0,0 +1,51 @@ + + + diff --git a/resources/js/pages/projects/Ticket.vue b/resources/js/pages/projects/Ticket.vue index c3fbe32..9d1450e 100644 --- a/resources/js/pages/projects/Ticket.vue +++ b/resources/js/pages/projects/Ticket.vue @@ -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 => { +
+ + + +
+