From 70b90f32d42e98ac0fbfe13649c8bd63011be526 Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 25 Mar 2026 09:12:17 +0100 Subject: [PATCH] Implement ticket links and blocker state --- app/Actions/Tickets/UpdateProjectTicket.php | 23 ++ .../Controllers/ProjectTicketController.php | 59 +++++ .../ProjectTicketLinkController.php | 122 +++++++++++ ...rojectTicketMetadataSettingsController.php | 67 ++++++ app/Http/Requests/StoreTicketLinkRequest.php | 74 +++++++ .../Requests/UpdateProjectTicketRequest.php | 14 ++ app/Models/Project.php | 6 + app/Models/Ticket.php | 24 +++ app/Models/TicketLink.php | 31 +++ app/Models/TicketLinkType.php | 28 +++ database/factories/TicketFactory.php | 2 + database/factories/TicketLinkFactory.php | 75 +++++++ database/factories/TicketLinkTypeFactory.php | 27 +++ ...5_add_blocking_fields_to_tickets_table.php | 33 +++ ..._073715_create_ticket_link_types_table.php | 33 +++ ...03_25_073715_create_ticket_links_table.php | 32 +++ database/seeders/DatabaseSeeder.php | 2 + database/seeders/TicketLinkSeeder.php | 41 ++++ database/seeders/TicketLinkTypeSeeder.php | 30 +++ resources/js/pages/projects/Board.vue | 19 +- resources/js/pages/projects/Ticket.vue | 190 ++++++++++++++++- .../js/pages/projects/settings/Metadata.vue | 122 ++++++++++- .../js/types/models/ticket-link-model.ts | 14 ++ .../js/types/models/ticket-link-type-model.ts | 9 + resources/js/types/models/ticket-model.ts | 2 + .../js/types/models/ticket-reference-model.ts | 5 + resources/js/types/page-props.ts | 13 +- routes/web.php | 12 ++ tests/Feature/Models/TicketLinkTest.php | 93 ++++++++ tests/Feature/Models/TicketLinkTypeTest.php | 51 +++++ tests/Feature/Models/TicketTest.php | 70 ++++++ .../Projects/ProjectTicketControllerTest.php | 161 ++++++++++++++ .../ProjectTicketLinkControllerTest.php | 201 ++++++++++++++++++ ...ctTicketMetadataSettingsControllerTest.php | 47 +++- 34 files changed, 1722 insertions(+), 10 deletions(-) create mode 100644 app/Http/Controllers/ProjectTicketLinkController.php create mode 100644 app/Http/Requests/StoreTicketLinkRequest.php create mode 100644 app/Models/TicketLink.php create mode 100644 app/Models/TicketLinkType.php create mode 100644 database/factories/TicketLinkFactory.php create mode 100644 database/factories/TicketLinkTypeFactory.php create mode 100644 database/migrations/2026_03_25_073715_add_blocking_fields_to_tickets_table.php create mode 100644 database/migrations/2026_03_25_073715_create_ticket_link_types_table.php create mode 100644 database/migrations/2026_03_25_073715_create_ticket_links_table.php create mode 100644 database/seeders/TicketLinkSeeder.php create mode 100644 database/seeders/TicketLinkTypeSeeder.php create mode 100644 resources/js/types/models/ticket-link-model.ts create mode 100644 resources/js/types/models/ticket-link-type-model.ts create mode 100644 resources/js/types/models/ticket-reference-model.ts create mode 100644 tests/Feature/Models/TicketLinkTest.php create mode 100644 tests/Feature/Models/TicketLinkTypeTest.php create mode 100644 tests/Feature/Projects/ProjectTicketLinkControllerTest.php diff --git a/app/Actions/Tickets/UpdateProjectTicket.php b/app/Actions/Tickets/UpdateProjectTicket.php index 5fd7f4e..df5032e 100644 --- a/app/Actions/Tickets/UpdateProjectTicket.php +++ b/app/Actions/Tickets/UpdateProjectTicket.php @@ -18,9 +18,12 @@ class UpdateProjectTicket /** * @param array{ * acceptance_criteria: string|null, + * blocked_comment: string|null, * description: string|null, + * is_blocked: bool, * ticket_priority_id: int, * ticket_type_id: int, + * parent_ticket_id: int|null, * title: string * } $ticketAttributes * @param arrayloadMissing([ 'fieldValues.ticketFieldDefinition', 'fieldValues.userValue', @@ -88,6 +95,14 @@ class UpdateProjectTicket $ticket->acceptance_criteria, $ticketAttributes['acceptance_criteria'], ); + $this->trackScalarChange( + $changes, + $summaryLabels, + 'blocked_comment', + 'blocker comment', + $ticket->blocked_comment, + $ticketAttributes['blocked_comment'], + ); if ($ticket->ticket_type_id !== $ticketAttributes['ticket_type_id']) { $changes['ticket_type'] = [ @@ -105,6 +120,14 @@ class UpdateProjectTicket $summaryLabels[] = 'priority'; } + if ($ticket->is_blocked !== $ticketAttributes['is_blocked']) { + $changes['is_blocked'] = [ + 'from' => $ticket->is_blocked, + 'to' => $ticketAttributes['is_blocked'], + ]; + $summaryLabels[] = 'blocked state'; + } + if ($ticket->parent_ticket_id !== $ticketAttributes['parent_ticket_id']) { $newParentTicket = $ticketAttributes['parent_ticket_id'] === null ? null diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index 5848590..47b12ad 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -7,6 +7,7 @@ use App\Actions\Tickets\UpdateProjectTicket; use App\Http\Requests\UpdateProjectTicketRequest; use App\Models\Project; use App\Models\Ticket; +use App\Models\TicketLink; use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -32,6 +33,7 @@ class ProjectTicketController extends Controller return Inertia::render('projects/Ticket', [ 'canEditTicket' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'), 'childTicketTree' => $this->buildChildTicketTree($ticket), + 'linkedTickets' => $this->linkedTickets($ticket), 'parentTicketOptions' => Ticket::query() ->where('project_id', $project->id) ->whereNotIn('id', $excludedParentIds) @@ -54,6 +56,17 @@ class ProjectTicketController extends Controller 'worklogEntries.actor', ]), 'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(), + 'ticketLinkOptions' => Ticket::query() + ->where('project_id', $project->id) + ->whereKeyNot($ticket->id) + ->orderByDesc('sequence_number') + ->get() + ->map(fn (Ticket $linkedTicket): array => [ + 'id' => $linkedTicket->id, + 'ticket_key' => $linkedTicket->ticket_key, + 'title' => $linkedTicket->title, + ]), + 'ticketLinkTypes' => $project->ticketLinkTypes()->get(), 'ticketPriorities' => $project->ticketPriorities()->get(), 'ticketTypes' => $project->ticketTypes()->get(), 'users' => User::query() @@ -124,4 +137,50 @@ class ProjectTicketController extends Controller ->values() ->all(); } + + /** + * @return array + */ + private function linkedTickets(Ticket $ticket): array + { + return TicketLink::query() + ->with(['sourceTicket', 'targetTicket', 'ticketLinkType']) + ->where(function ($query) use ($ticket): void { + $query + ->where('source_ticket_id', $ticket->id) + ->orWhere('target_ticket_id', $ticket->id); + }) + ->orderBy('id') + ->get() + ->map(function (TicketLink $ticketLink) use ($ticket): array { + $linkedTicket = $ticketLink->source_ticket_id === $ticket->id + ? $ticketLink->targetTicket + : $ticketLink->sourceTicket; + + return [ + 'created_at' => $ticketLink->created_at?->toISOString(), + 'id' => $ticketLink->id, + 'linked_ticket' => [ + 'id' => $linkedTicket->id, + 'ticket_key' => $linkedTicket->ticket_key, + 'title' => $linkedTicket->title, + ], + 'source_ticket_id' => $ticketLink->source_ticket_id, + 'target_ticket_id' => $ticketLink->target_ticket_id, + 'ticket_link_type' => $ticketLink->ticketLinkType, + 'ticket_link_type_id' => $ticketLink->ticket_link_type_id, + 'updated_at' => $ticketLink->updated_at?->toISOString(), + ]; + }) + ->all(); + } } diff --git a/app/Http/Controllers/ProjectTicketLinkController.php b/app/Http/Controllers/ProjectTicketLinkController.php new file mode 100644 index 0000000..fd9f3f2 --- /dev/null +++ b/app/Http/Controllers/ProjectTicketLinkController.php @@ -0,0 +1,122 @@ +projectPermissionService->userHasPermission($request->user(), $project, 'edit'), + 403, + ); + + $linkedTicket = Ticket::query() + ->where('project_id', $project->id) + ->findOrFail($request->linkedTicketId()); + + [$sourceTicketId, $targetTicketId] = $this->normalizeTicketPair($ticket->id, $linkedTicket->id); + + $ticketLink = DB::transaction(function () use ($linkedTicket, $request, $sourceTicketId, $targetTicketId, $ticket): TicketLink { + $ticketLink = TicketLink::query()->updateOrCreate( + [ + 'source_ticket_id' => $sourceTicketId, + 'target_ticket_id' => $targetTicketId, + ], + [ + 'ticket_link_type_id' => $request->ticketLinkTypeId(), + ], + ); + + $ticketLink->load('ticketLinkType'); + + $this->createTicketWorklogEntry->handle( + $ticket, + $request->user(), + 'link_updated', + "Linked {$linkedTicket->ticket_key} as {$ticketLink->ticketLinkType->label}.", + [ + 'linked_ticket_key' => $linkedTicket->ticket_key, + 'ticket_link_type' => $ticketLink->ticketLinkType->label, + ], + ); + + return $ticketLink; + }); + + return to_route('projects.tickets.show', [$project, $ticket]) + ->with('status', "{$ticketLink->ticketLinkType->label} link was saved."); + } + + public function destroy( + Request $request, + Project $project, + Ticket $ticket, + TicketLink $ticketLink, + ): RedirectResponse { + abort_unless( + $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'), + 403, + ); + + $ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']); + + abort_unless( + $ticketLink->source_ticket_id === $ticket->id || $ticketLink->target_ticket_id === $ticket->id, + 404, + ); + + $linkedTicket = $ticketLink->source_ticket_id === $ticket->id + ? $ticketLink->targetTicket + : $ticketLink->sourceTicket; + + $label = $ticketLink->ticketLinkType->label; + + DB::transaction(function () use ($label, $linkedTicket, $request, $ticket, $ticketLink): void { + $ticketLink->delete(); + + $this->createTicketWorklogEntry->handle( + $ticket, + $request->user(), + 'link_removed', + "Removed {$label} link to {$linkedTicket->ticket_key}.", + [ + 'linked_ticket_key' => $linkedTicket->ticket_key, + 'ticket_link_type' => $label, + ], + ); + }); + + return to_route('projects.tickets.show', [$project, $ticket]) + ->with('status', "{$label} link was removed."); + } + + /** + * @return array{0: int, 1: int} + */ + private function normalizeTicketPair(int $firstTicketId, int $secondTicketId): array + { + return [ + min($firstTicketId, $secondTicketId), + max($firstTicketId, $secondTicketId), + ]; + } +} diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php index e740838..8826911 100644 --- a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -10,6 +10,7 @@ use App\Http\Requests\UpdateProjectNamedSettingRequest; use App\Http\Requests\UpdateTicketFieldDefinitionRequest; use App\Models\Project; use App\Models\TicketFieldDefinition; +use App\Models\TicketLinkType; use App\Models\TicketPriority; use App\Models\TicketType; use Illuminate\Database\Eloquent\Model; @@ -42,6 +43,7 @@ class ProjectTicketMetadataSettingsController extends Controller 'project' => $project, 'status' => session('status'), 'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(), + 'ticketLinkTypes' => $project->ticketLinkTypes()->get(), 'ticketPriorities' => $project->ticketPriorities()->get(), 'ticketTypes' => $project->ticketTypes()->get(), ]); @@ -249,6 +251,71 @@ class ProjectTicketMetadataSettingsController extends Controller ->with('status', "{$deletedLabel} field was removed."); } + public function storeLinkType(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse + { + $this->abortUnlessCanManage($request, $project); + + $ticketLinkType = DB::transaction(function () use ($project, $request): TicketLinkType { + $ticketLinkType = $project->ticketLinkTypes()->create([ + 'label' => $request->validated('name'), + 'position' => $project->ticketLinkTypes()->count() + 1, + ]); + + $this->reorderModels( + $project->ticketLinkTypes()->whereKeyNot($ticketLinkType->id)->get()->values(), + $ticketLinkType, + $request->position() ?? $project->ticketLinkTypes()->count(), + ); + + return $ticketLinkType; + }); + + return to_route('projects.settings.metadata', $project) + ->with('status', "{$ticketLinkType->label} link type was added."); + } + + public function updateLinkType( + UpdateProjectNamedSettingRequest $request, + Project $project, + TicketLinkType $ticketLinkType, + ): RedirectResponse { + $this->abortUnlessCanManage($request, $project); + + DB::transaction(function () use ($project, $request, $ticketLinkType): void { + $ticketLinkType->update([ + 'label' => $request->validated('name'), + ]); + + $this->reorderModels( + $project->ticketLinkTypes()->whereKeyNot($ticketLinkType->id)->get()->values(), + $ticketLinkType, + $request->position(), + ); + }); + + return to_route('projects.settings.metadata', $project) + ->with('status', "{$ticketLinkType->fresh()->label} link type was updated."); + } + + public function destroyLinkType( + Request $request, + Project $project, + TicketLinkType $ticketLinkType, + ): RedirectResponse { + $this->abortUnlessCanManage($request, $project); + + $deletedLabel = $ticketLinkType->label; + + DB::transaction(function () use ($project, $ticketLinkType): void { + $ticketLinkType->delete(); + + $this->persistOrderedModels($project->ticketLinkTypes()->get()->values()); + }); + + return to_route('projects.settings.metadata', $project) + ->with('status', "{$deletedLabel} link type was removed."); + } + private function abortUnlessCanManage(Request $request, Project $project): void { abort_unless( diff --git a/app/Http/Requests/StoreTicketLinkRequest.php b/app/Http/Requests/StoreTicketLinkRequest.php new file mode 100644 index 0000000..134eccb --- /dev/null +++ b/app/Http/Requests/StoreTicketLinkRequest.php @@ -0,0 +1,74 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + /** @var Project $project */ + $project = $this->route('project'); + + return [ + 'linked_ticket_id' => [ + 'required', + 'integer', + Rule::exists('tickets', 'id')->where('project_id', $project->id), + ], + 'ticket_link_type_id' => [ + 'required', + 'integer', + Rule::exists('ticket_link_types', 'id')->where('project_id', $project->id), + ], + ]; + } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + /** @var Ticket $ticket */ + $ticket = $this->route('ticket'); + + $linkedTicketId = $this->input('linked_ticket_id'); + + if (is_numeric($linkedTicketId) && (int) $linkedTicketId === $ticket->id) { + $validator->errors()->add('linked_ticket_id', 'A ticket cannot be linked to itself.'); + } + }, + ]; + } + + public function linkedTicketId(): int + { + return (int) $this->validated('linked_ticket_id'); + } + + public function ticketLinkTypeId(): int + { + return (int) $this->validated('ticket_link_type_id'); + } +} diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php index 1046b65..4a543df 100644 --- a/app/Http/Requests/UpdateProjectTicketRequest.php +++ b/app/Http/Requests/UpdateProjectTicketRequest.php @@ -33,6 +33,7 @@ class UpdateProjectTicketRequest extends FormRequest return [ 'acceptance_criteria' => ['nullable', 'string'], + 'blocked_comment' => ['nullable', 'string'], 'description' => ['nullable', 'string'], 'field_values' => ['array'], 'field_values.*' => ['array'], @@ -43,6 +44,7 @@ class UpdateProjectTicketRequest extends FormRequest ], 'field_values.*.value_number' => ['nullable', 'numeric'], 'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], + 'is_blocked' => ['required', 'boolean'], 'parent_ticket_id' => [ 'nullable', 'integer', @@ -116,6 +118,10 @@ class UpdateProjectTicketRequest extends FormRequest ) { $validator->errors()->add('ticket_type_id', 'Tickets with child tickets must keep a Parent type.'); } + + if ($this->boolean('is_blocked') && blank($this->input('blocked_comment'))) { + $validator->errors()->add('blocked_comment', 'Blocked tickets require a blocker comment.'); + } }, ]; } @@ -123,7 +129,9 @@ class UpdateProjectTicketRequest extends FormRequest /** * @return array{ * acceptance_criteria: string|null, + * blocked_comment: string|null, * description: string|null, + * is_blocked: bool, * parent_ticket_id: int|null, * ticket_priority_id: int, * ticket_type_id: int, @@ -134,7 +142,9 @@ class UpdateProjectTicketRequest extends FormRequest { /** @var array{ * acceptance_criteria: string|null, + * blocked_comment: string|null, * description: string|null, + * is_blocked: bool, * parent_ticket_id: int|null, * ticket_priority_id: int, * ticket_type_id: int, @@ -144,12 +154,16 @@ class UpdateProjectTicketRequest extends FormRequest $attributes = array_merge( [ 'acceptance_criteria' => null, + 'blocked_comment' => null, 'description' => null, + 'is_blocked' => false, 'parent_ticket_id' => null, ], Arr::only($this->validated(), [ 'acceptance_criteria', + 'blocked_comment', 'description', + 'is_blocked', 'parent_ticket_id', 'ticket_priority_id', 'ticket_type_id', diff --git a/app/Models/Project.php b/app/Models/Project.php index d4fb8e2..61ebcd6 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -41,6 +41,12 @@ class Project extends Model ->orderBy('position'); } + public function ticketLinkTypes(): HasMany + { + return $this->hasMany(TicketLinkType::class) + ->orderBy('position'); + } + public function ticketTypes(): HasMany { return $this->hasMany(TicketType::class) diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index f98f36e..2d23953 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -12,7 +12,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'acceptance_criteria', 'board_column_id', + 'blocked_comment', 'description', + 'is_blocked', 'parent_ticket_id', 'project_id', 'sequence_number', @@ -26,6 +28,16 @@ class Ticket extends Model /** @use HasFactory */ use HasFactory; + /** + * @return array + */ + protected function casts(): array + { + return [ + 'is_blocked' => 'boolean', + ]; + } + public function boardColumn(): BelongsTo { return $this->belongsTo(BoardColumn::class); @@ -48,6 +60,18 @@ class Ticket extends Model return $this->belongsTo(Ticket::class, 'parent_ticket_id'); } + public function ticketLinksAsSource(): HasMany + { + return $this->hasMany(TicketLink::class, 'source_ticket_id') + ->orderBy('id'); + } + + public function ticketLinksAsTarget(): HasMany + { + return $this->hasMany(TicketLink::class, 'target_ticket_id') + ->orderBy('id'); + } + public function project(): BelongsTo { return $this->belongsTo(Project::class); diff --git a/app/Models/TicketLink.php b/app/Models/TicketLink.php new file mode 100644 index 0000000..d555598 --- /dev/null +++ b/app/Models/TicketLink.php @@ -0,0 +1,31 @@ + */ + use HasFactory; + + public function sourceTicket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'source_ticket_id'); + } + + public function targetTicket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'target_ticket_id'); + } + + public function ticketLinkType(): BelongsTo + { + return $this->belongsTo(TicketLinkType::class); + } +} diff --git a/app/Models/TicketLinkType.php b/app/Models/TicketLinkType.php new file mode 100644 index 0000000..2799a0e --- /dev/null +++ b/app/Models/TicketLinkType.php @@ -0,0 +1,28 @@ + */ + use HasFactory; + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function ticketLinks(): HasMany + { + return $this->hasMany(TicketLink::class) + ->orderBy('id'); + } +} diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index c34570f..9f11f69 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -23,7 +23,9 @@ class TicketFactory extends Factory return [ 'acceptance_criteria' => fake()->paragraph(), 'board_column_id' => null, + 'blocked_comment' => null, 'description' => fake()->paragraphs(2, true), + 'is_blocked' => false, 'parent_ticket_id' => null, 'sequence_number' => 1, 'ticket_key' => 'TEST-1', diff --git a/database/factories/TicketLinkFactory.php b/database/factories/TicketLinkFactory.php new file mode 100644 index 0000000..43997c1 --- /dev/null +++ b/database/factories/TicketLinkFactory.php @@ -0,0 +1,75 @@ + + */ +class TicketLinkFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'source_ticket_id' => null, + 'target_ticket_id' => null, + 'ticket_link_type_id' => null, + ]; + } + + public function configure(): static + { + return $this->afterMaking(function (TicketLink $ticketLink): void { + $sourceTicket = $ticketLink->source_ticket_id === null + ? Ticket::factory()->create() + : Ticket::query()->findOrFail($ticketLink->source_ticket_id); + + $targetTicket = $ticketLink->target_ticket_id === null + ? Ticket::factory()->create([ + 'project_id' => $sourceTicket->project_id, + 'ticket_priority_id' => $sourceTicket->ticket_priority_id, + 'ticket_type_id' => $sourceTicket->ticket_type_id, + 'sequence_number' => $sourceTicket->sequence_number + 1, + 'ticket_key' => "{$sourceTicket->project->key}-".($sourceTicket->sequence_number + 1), + ]) + : Ticket::query()->findOrFail($ticketLink->target_ticket_id); + + if ($sourceTicket->id === $targetTicket->id) { + $targetTicket = Ticket::factory()->create([ + 'project_id' => $sourceTicket->project_id, + 'ticket_priority_id' => $sourceTicket->ticket_priority_id, + 'ticket_type_id' => $sourceTicket->ticket_type_id, + 'sequence_number' => $sourceTicket->sequence_number + 1, + 'ticket_key' => "{$sourceTicket->project->key}-".($sourceTicket->sequence_number + 1), + ]); + } + + $ticketLinkType = $ticketLink->ticket_link_type_id === null + ? TicketLinkType::query() + ->where('project_id', $sourceTicket->project_id) + ->orderBy('position') + ->first() + ?? TicketLinkType::factory()->for($sourceTicket->project)->create([ + 'label' => 'Relates to', + 'position' => 1, + ]) + : TicketLinkType::query()->findOrFail($ticketLink->ticket_link_type_id); + + $sortedIds = [$sourceTicket->id, $targetTicket->id]; + sort($sortedIds); + + $ticketLink->source_ticket_id = $sortedIds[0]; + $ticketLink->target_ticket_id = $sortedIds[1]; + $ticketLink->ticket_link_type_id = $ticketLinkType->id; + }); + } +} diff --git a/database/factories/TicketLinkTypeFactory.php b/database/factories/TicketLinkTypeFactory.php new file mode 100644 index 0000000..67fab9b --- /dev/null +++ b/database/factories/TicketLinkTypeFactory.php @@ -0,0 +1,27 @@ + + */ +class TicketLinkTypeFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'label' => fake()->unique()->words(2, true), + 'position' => 1, + 'project_id' => Project::factory(), + ]; + } +} diff --git a/database/migrations/2026_03_25_073715_add_blocking_fields_to_tickets_table.php b/database/migrations/2026_03_25_073715_add_blocking_fields_to_tickets_table.php new file mode 100644 index 0000000..790b389 --- /dev/null +++ b/database/migrations/2026_03_25_073715_add_blocking_fields_to_tickets_table.php @@ -0,0 +1,33 @@ +boolean('is_blocked') + ->default(false) + ->after('parent_ticket_id'); + $table->text('blocked_comment') + ->nullable() + ->after('is_blocked'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + $table->dropColumn(['blocked_comment', 'is_blocked']); + }); + } +}; diff --git a/database/migrations/2026_03_25_073715_create_ticket_link_types_table.php b/database/migrations/2026_03_25_073715_create_ticket_link_types_table.php new file mode 100644 index 0000000..4cc8785 --- /dev/null +++ b/database/migrations/2026_03_25_073715_create_ticket_link_types_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->string('label'); + $table->unsignedInteger('position'); + $table->timestamps(); + + $table->unique(['project_id', 'label']); + $table->unique(['project_id', 'position']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_link_types'); + } +}; diff --git a/database/migrations/2026_03_25_073715_create_ticket_links_table.php b/database/migrations/2026_03_25_073715_create_ticket_links_table.php new file mode 100644 index 0000000..d9800d5 --- /dev/null +++ b/database/migrations/2026_03_25_073715_create_ticket_links_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('ticket_link_type_id')->constrained()->cascadeOnDelete(); + $table->foreignId('source_ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('target_ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['source_ticket_id', 'target_ticket_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_links'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 5f9789b..ff76848 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -19,6 +19,8 @@ class DatabaseSeeder extends Seeder ProjectTicketMetadataSeeder::class, TicketSeeder::class, TicketFieldValueSeeder::class, + TicketLinkTypeSeeder::class, + TicketLinkSeeder::class, TicketWorklogEntrySeeder::class, TeamSeeder::class, ProjectAccessSeeder::class, diff --git a/database/seeders/TicketLinkSeeder.php b/database/seeders/TicketLinkSeeder.php new file mode 100644 index 0000000..3514680 --- /dev/null +++ b/database/seeders/TicketLinkSeeder.php @@ -0,0 +1,41 @@ +with(['tickets', 'ticketLinkTypes']) + ->orderBy('id') + ->get() + ->each(function (Project $project): void { + /** @var Ticket|null $firstTicket */ + $firstTicket = $project->tickets->sortBy('sequence_number')->values()->get(0); + /** @var Ticket|null $secondTicket */ + $secondTicket = $project->tickets->sortBy('sequence_number')->values()->get(1); + /** @var TicketLinkType|null $ticketLinkType */ + $ticketLinkType = $project->ticketLinkTypes->sortBy('position')->first(); + + if ($firstTicket === null || $secondTicket === null || $ticketLinkType === null) { + return; + } + + TicketLink::factory()->create([ + 'source_ticket_id' => min($firstTicket->id, $secondTicket->id), + 'target_ticket_id' => max($firstTicket->id, $secondTicket->id), + 'ticket_link_type_id' => $ticketLinkType->id, + ]); + }); + } +} diff --git a/database/seeders/TicketLinkTypeSeeder.php b/database/seeders/TicketLinkTypeSeeder.php new file mode 100644 index 0000000..b83724f --- /dev/null +++ b/database/seeders/TicketLinkTypeSeeder.php @@ -0,0 +1,30 @@ +orderBy('id') + ->get() + ->each(function (Project $project): void { + TicketLinkType::factory() + ->count(2) + ->sequence( + ['label' => 'Relates to', 'position' => 1], + ['label' => 'Blocks', 'position' => 2], + ) + ->for($project) + ->create(); + }); + } +} diff --git a/resources/js/pages/projects/Board.vue b/resources/js/pages/projects/Board.vue index 586637a..8d07e92 100644 --- a/resources/js/pages/projects/Board.vue +++ b/resources/js/pages/projects/Board.vue @@ -136,13 +136,24 @@ const statusOptions = [
{{ ticket.ticket_key }} + + Blocked + {{ ticket.ticket_type.name }} @@ -169,6 +180,12 @@ const statusOptions = [ > {{ ticket.description }} + + {{ ticket.blocked_comment }} + diff --git a/resources/js/pages/projects/Ticket.vue b/resources/js/pages/projects/Ticket.vue index 9d1450e..bee5c84 100644 --- a/resources/js/pages/projects/Ticket.vue +++ b/resources/js/pages/projects/Ticket.vue @@ -1,5 +1,6 @@