From ce260bfd464b22822657bc7b22a27d3ac1baff36 Mon Sep 17 00:00:00 2001 From: Koda Date: Sun, 24 May 2026 15:24:29 +0000 Subject: [PATCH] Fix parent ticket assignment and display --- .../BuildProjectTicketOptionsPayload.php | 9 ++ app/Actions/Tickets/CreateProjectTicket.php | 2 + .../Requests/StoreProjectTicketRequest.php | 39 +++++++ app/Http/Resources/TicketResource.php | 6 + resources/js/pages/projects/Ticket.vue | 43 +++++++- resources/js/pages/projects/TicketCreate.vue | 23 ++++ resources/js/types/models/ticket-model.ts | 2 + resources/js/types/page-props.ts | 1 + .../Projects/ProjectTicketControllerTest.php | 103 ++++++++++++++++++ 9 files changed, 227 insertions(+), 1 deletion(-) diff --git a/app/Actions/Projects/BuildProjectTicketOptionsPayload.php b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php index 7c6a307..bd4bfc1 100644 --- a/app/Actions/Projects/BuildProjectTicketOptionsPayload.php +++ b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php @@ -4,8 +4,10 @@ namespace App\Actions\Projects; use App\Http\Resources\ProjectResource; use App\Http\Resources\TicketPriorityResource; +use App\Http\Resources\TicketReferenceResource; use App\Http\Resources\TicketStatusResource; use App\Http\Resources\TicketTypeResource; +use App\Models\Ticket; use App\Http\Resources\UserResource; use App\Models\Project; use App\Models\User; @@ -26,6 +28,13 @@ class BuildProjectTicketOptionsPayload return [ 'defaultTicketStatusId' => $defaultTicketStatus?->id, + 'parentTicketOptions' => TicketReferenceResource::collection( + Ticket::query() + ->where('project_id', $project->id) + ->whereHas('ticketType', fn ($query) => $query->where('is_parent', true)) + ->orderByDesc('sequence_number') + ->get(), + )->resolve(), 'project' => ProjectResource::make($project)->resolve(), 'source' => $source, 'status' => $status, diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index a2c26a5..b2be15a 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -30,6 +30,7 @@ class CreateProjectTicket * }>, * assignee_user_id?: int|null, * description: string|null, + * parent_ticket_id?: int|null, * story_points?: int|null, * ticket_priority_id: int, * ticket_status_id: int, @@ -54,6 +55,7 @@ class CreateProjectTicket 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), 'author_user_id' => $actor?->id, 'description' => Arr::get($attributes, 'description'), + 'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'), 'project_id' => $project->id, 'sequence_number' => $nextSequenceNumber, 'ticket_key' => "{$project->key}-{$nextSequenceNumber}", diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php index 505a267..59036c9 100644 --- a/app/Http/Requests/StoreProjectTicketRequest.php +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -4,9 +4,12 @@ namespace App\Http\Requests; use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Models\Project; +use App\Models\Ticket; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Arr; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class StoreProjectTicketRequest extends FormRequest { @@ -55,6 +58,11 @@ class StoreProjectTicketRequest extends FormRequest 'acceptance_criteria.*.indent' => ['required', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'description' => ['nullable', 'string'], + 'parent_ticket_id' => [ + 'nullable', + 'integer', + Rule::exists('tickets', 'id')->where('project_id', $project->id), + ], 'ticket_priority_id' => [ 'required', 'integer', @@ -74,4 +82,35 @@ class StoreProjectTicketRequest extends FormRequest 'title' => ['required', 'string', 'max:255'], ]; } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + /** @var array $validated */ + $validated = $this->safe()->all(); + + /** @var Project $project */ + $project = $this->route('project'); + + $parentTicketId = Arr::get($validated, 'parent_ticket_id'); + + if ($parentTicketId === null) { + return; + } + + $parentTicket = Ticket::query() + ->where('project_id', $project->id) + ->with('ticketType') + ->find($parentTicketId); + + if ($parentTicket !== null && ! $parentTicket->ticketType?->is_parent) { + $validator->errors()->add('parent_ticket_id', 'Only tickets with a parent-enabled type can be selected as a parent.'); + } + }, + ]; + } } diff --git a/app/Http/Resources/TicketResource.php b/app/Http/Resources/TicketResource.php index faacda9..88368f4 100644 --- a/app/Http/Resources/TicketResource.php +++ b/app/Http/Resources/TicketResource.php @@ -43,6 +43,12 @@ class TicketResource extends JsonResource fn (): array => TicketFieldValueResource::collection($this->fieldValues)->resolve(), ), 'is_blocked' => $this->is_blocked, + 'parent_ticket' => $this->whenLoaded( + 'parentTicket', + fn (): ?array => $this->parentTicket === null + ? null + : TicketReferenceResource::make($this->parentTicket)->resolve(), + ), 'parent_ticket_id' => $this->parent_ticket_id, 'project_id' => $this->project_id, 'sequence_number' => $this->sequence_number, diff --git a/resources/js/pages/projects/Ticket.vue b/resources/js/pages/projects/Ticket.vue index fac373b..c4edf71 100644 --- a/resources/js/pages/projects/Ticket.vue +++ b/resources/js/pages/projects/Ticket.vue @@ -83,6 +83,15 @@ const canViewAuthor = computed( () => props.project.show_author || props.canManageProject, ); +const currentParentTicket = computed( + () => + props.ticket.parent_ticket ?? + props.parentTicketOptions.find( + (parentTicket) => parentTicket.id === props.ticket.parent_ticket_id, + ) ?? + null, +); + const defaultBranchRepositoryConnectionId = computed(() => String( linkedRepositoryConnections.value.find( @@ -1521,7 +1530,10 @@ const formatDateTime = (value: string): string => { - + Child tickets @@ -1547,6 +1559,35 @@ const formatDateTime = (value: string): string => { + + + Parent ticket + + This ticket belongs to the parent shown here. + + + + + +
+ {{ currentParentTicket.ticket_key }} +
+
+ {{ currentParentTicket.title }} +
+ +
+
+ Worklog diff --git a/resources/js/pages/projects/TicketCreate.vue b/resources/js/pages/projects/TicketCreate.vue index 442bfb0..731f205 100644 --- a/resources/js/pages/projects/TicketCreate.vue +++ b/resources/js/pages/projects/TicketCreate.vue @@ -67,6 +67,7 @@ const form = useForm({ acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value), assignee_user_id: '', description: '', + parent_ticket_id: '', story_points: '', ticket_priority_id: String(props.ticketPriorities[0]?.id ?? ''), ticket_status_id: String( @@ -312,6 +313,28 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => { +
+ + + +
+
handle($project); + $priority = $project->ticketPriorities()->firstOrFail(); + $parentTicket = Ticket::factory()->create([ + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $this->parentType($project)->id, + 'title' => 'Parent ticket', + ]); + $boardColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, @@ -391,6 +401,7 @@ class ProjectTicketControllerTest extends TestCase ->assertInertia(fn (Assert $page) => $page ->component('projects/TicketCreate') ->where('defaultTicketStatusId', $boardStatus->id) + ->where('parentTicketOptions.0.ticket_key', $parentTicket->ticket_key) ->where('project.id', $project->id) ->where('source', 'board') ->where('ticketStatusOptions.1.id', $boardStatus->id), @@ -451,6 +462,98 @@ class ProjectTicketControllerTest extends TestCase ]); } + /** + * Ensure tickets can be created with a parent ticket assignment. + */ + public function test_users_with_write_permission_can_create_tickets_with_a_parent_ticket(): void + { + $editor = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $priority = $project->ticketPriorities()->firstOrFail(); + $backlogStatus = $project->ticketStatuses() + ->whereNull('board_column_id') + ->firstOrFail(); + $parentTicket = Ticket::factory()->create([ + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $this->parentType($project)->id, + 'title' => 'Parent ticket', + ]); + + $this->syncProjectPermissions($project, $editor, ['write', 'view']); + + $this->actingAs($editor) + ->post(route('projects.tickets.store', $project), [ + 'assignee_user_id' => null, + 'description' => 'Created with parent.', + 'parent_ticket_id' => $parentTicket->id, + 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $backlogStatus->id, + 'ticket_type_id' => $this->nonParentType($project)->id, + 'title' => 'Child ticket', + ]) + ->assertRedirect(route('projects.tickets.show', [$project, 2])) + ->assertSessionHas('status', 'CORE-2 was created.'); + + $this->assertDatabaseHas('tickets', [ + 'project_id' => $project->id, + 'ticket_key' => 'CORE-2', + 'parent_ticket_id' => $parentTicket->id, + 'title' => 'Child ticket', + ]); + } + + /** + * Ensure ticket detail payloads expose the linked parent ticket when assigned. + */ + public function test_ticket_detail_pages_include_the_linked_parent_ticket(): void + { + $viewer = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $priority = $project->ticketPriorities()->firstOrFail(); + $parentTicket = Ticket::factory()->create([ + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $this->parentType($project)->id, + 'title' => 'Parent ticket', + ]); + $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' => $this->nonParentType($project)->id, + 'title' => 'Child ticket', + ]); + + $this->syncProjectPermissions($project, $viewer, ['view']); + + $this->actingAs($viewer) + ->get(route('projects.tickets.show', [$project, $childTicket])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('projects/Ticket') + ->where('ticket.parent_ticket.ticket_key', $parentTicket->ticket_key) + ->where('ticket.parent_ticket.title', $parentTicket->title) + ->where('childTicketTree', []), + ); + } + /** * Ensure updating ticket details requires the project edit permission. */