From 8f4a8276ac17f6d9fae7e0ab0a61a3e10f23d772 Mon Sep 17 00:00:00 2001 From: Twyco Date: Tue, 14 Apr 2026 03:09:12 +0200 Subject: [PATCH] Implement ticket statuses --- .../project-verification-discipline/SKILL.md | 2 + .../InitializeProjectTicketMetadata.php | 7 + app/Actions/Tickets/CreateProjectTicket.php | 12 +- .../Controllers/ProjectBacklogController.php | 16 +- .../Controllers/ProjectBoardController.php | 49 +++- .../ProjectBoardSettingsController.php | 143 +++++++++- .../Controllers/ProjectTicketController.php | 1 + .../Requests/MoveProjectTicketRequest.php | 45 ++- .../Requests/StoreProjectTicketRequest.php | 8 + .../Requests/StoreTicketStatusRequest.php | 39 +++ .../Requests/UpdateTicketStatusRequest.php | 29 ++ app/Models/BoardColumn.php | 17 +- app/Models/Project.php | 7 + app/Models/Ticket.php | 12 +- app/Models/TicketStatus.php | 84 ++++++ database/factories/TicketFactory.php | 40 ++- database/factories/TicketStatusFactory.php | 28 ++ ...14_005330_create_ticket_statuses_table.php | 48 ++++ ...ticket_board_column_with_ticket_status.php | 133 +++++++++ database/seeders/BoardColumnSeeder.php | 9 +- database/seeders/DatabaseSeeder.php | 1 + database/seeders/TicketSeeder.php | 5 + database/seeders/TicketStatusSeeder.php | 45 +++ resources/js/pages/projects/Backlog.vue | 101 ++++++- resources/js/pages/projects/Board.vue | 75 +++-- resources/js/pages/projects/Ticket.vue | 3 + .../js/pages/projects/settings/Board.vue | 257 +++++++++++++++++- .../pages/projects/settings/Repositories.vue | 2 +- .../js/types/models/board-column-model.ts | 2 + resources/js/types/models/ticket-model.ts | 4 +- .../js/types/models/ticket-status-model.ts | 14 + resources/js/types/page-props.ts | 5 +- routes/web.php | 6 + tests/Feature/Models/BoardColumnTest.php | 17 +- tests/Feature/Models/TicketStatusTest.php | 116 ++++++++ tests/Feature/Models/TicketTest.php | 26 +- .../Projects/ProjectBacklogControllerTest.php | 59 +++- .../Projects/ProjectBoardControllerTest.php | 114 +++++++- .../ProjectBoardSettingsControllerTest.php | 111 ++++++++ .../Projects/ProjectControllerTest.php | 10 +- 40 files changed, 1602 insertions(+), 100 deletions(-) create mode 100644 app/Http/Requests/StoreTicketStatusRequest.php create mode 100644 app/Http/Requests/UpdateTicketStatusRequest.php create mode 100644 app/Models/TicketStatus.php create mode 100644 database/factories/TicketStatusFactory.php create mode 100644 database/migrations/2026_04_14_005330_create_ticket_statuses_table.php create mode 100644 database/migrations/2026_04_14_005330_replace_ticket_board_column_with_ticket_status.php create mode 100644 database/seeders/TicketStatusSeeder.php create mode 100644 resources/js/types/models/ticket-status-model.ts create mode 100644 tests/Feature/Models/TicketStatusTest.php diff --git a/.agents/skills/project-verification-discipline/SKILL.md b/.agents/skills/project-verification-discipline/SKILL.md index 3e50d2d..1b24744 100644 --- a/.agents/skills/project-verification-discipline/SKILL.md +++ b/.agents/skills/project-verification-discipline/SKILL.md @@ -52,4 +52,6 @@ Activate this skill whenever work in this project includes: - Follow the project's existing PHPUnit conventions. - Prefer the smallest relevant test run that still proves the change. +- Batch style, type, lint, and test verification at the end of the full implementation rather than after each individual file edit. +- When relevant, run the project's aggregate lint command at the end, such as `composer run lint` for PHP formatting or `npm run lint` for frontend linting. - If model and controller work happen in the same task, verify both sides in that same task. diff --git a/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php b/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php index 33bd60e..a744b4a 100644 --- a/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php +++ b/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php @@ -27,5 +27,12 @@ class InitializeProjectTicketMetadata 'position' => 1, ]); } + + if (! $project->ticketStatuses()->whereNull('board_column_id')->exists()) { + $project->ticketStatuses()->create([ + 'name' => 'Backlog', + 'position' => 1, + ]); + } } } diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index 7a8ce30..c36946e 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -4,6 +4,7 @@ namespace App\Actions\Tickets; use App\Models\Project; use App\Models\Ticket; +use App\Models\TicketStatus; use App\Models\User; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; @@ -21,6 +22,7 @@ class CreateProjectTicket * description: string|null, * story_points?: int|null, * ticket_priority_id: int, + * ticket_status_id: int, * ticket_type_id: int, * title: string * } $attributes @@ -31,17 +33,20 @@ class CreateProjectTicket $nextSequenceNumber = (int) Ticket::query() ->where('project_id', $project->id) ->max('sequence_number') + 1; + $ticketStatus = TicketStatus::query() + ->where('project_id', $project->id) + ->findOrFail(Arr::get($attributes, 'ticket_status_id')); $ticket = Ticket::query()->create([ 'acceptance_criteria' => Arr::get($attributes, 'acceptance_criteria'), 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), 'author_user_id' => $actor?->id, - 'board_column_id' => null, 'description' => Arr::get($attributes, 'description'), 'project_id' => $project->id, 'sequence_number' => $nextSequenceNumber, 'ticket_key' => "{$project->key}-{$nextSequenceNumber}", 'ticket_priority_id' => Arr::get($attributes, 'ticket_priority_id'), + 'ticket_status_id' => $ticketStatus->id, 'ticket_type_id' => Arr::get($attributes, 'ticket_type_id'), 'title' => Arr::get($attributes, 'title'), 'story_points' => Arr::get($attributes, 'story_points'), @@ -52,11 +57,12 @@ class CreateProjectTicket $ticket, $actor, 'created', - 'Ticket was created in Backlog.', + "Ticket was created in {$ticketStatus->name}.", [ 'from' => null, 'to' => [ - 'label' => 'Backlog', + 'label' => $ticketStatus->name, + 'ticket_status_id' => $ticketStatus->id, ], ], ); diff --git a/app/Http/Controllers/ProjectBacklogController.php b/app/Http/Controllers/ProjectBacklogController.php index c772e26..1ff0364 100644 --- a/app/Http/Controllers/ProjectBacklogController.php +++ b/app/Http/Controllers/ProjectBacklogController.php @@ -31,17 +31,27 @@ class ProjectBacklogController extends Controller ); return Inertia::render('projects/Backlog', [ - 'boardColumns' => $project->boardColumns()->get(), + 'backlogTicketStatuses' => $project->ticketStatuses() + ->whereNull('board_column_id') + ->get(), 'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), 'canCreateTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), 'project' => $project, 'status' => session('status'), + 'ticketStatusOptions' => $project->ticketStatuses() + ->with('boardColumn') + ->get(), 'ticketPriorities' => $project->ticketPriorities()->get(), 'ticketTypes' => $project->ticketTypes()->get(), 'tickets' => Ticket::query() - ->with(['ticketPriority', 'ticketType']) + ->with([ + 'ticketPriority', + 'ticketStatus.allowedTargetStatuses.boardColumn', + 'ticketStatus.boardColumn', + 'ticketType', + ]) ->where('project_id', $project->id) - ->whereNull('board_column_id') + ->whereHas('ticketStatus', fn ($query) => $query->whereNull('board_column_id')) ->orderByDesc('sequence_number') ->get(), 'users' => User::query() diff --git a/app/Http/Controllers/ProjectBoardController.php b/app/Http/Controllers/ProjectBoardController.php index 45cf5ae..21a5c38 100644 --- a/app/Http/Controllers/ProjectBoardController.php +++ b/app/Http/Controllers/ProjectBoardController.php @@ -8,6 +8,7 @@ use App\Enums\Permission; use App\Http\Requests\MoveProjectTicketRequest; use App\Models\Project; use App\Models\Ticket; +use App\Models\TicketStatus; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; @@ -33,8 +34,14 @@ class ProjectBoardController extends Controller return Inertia::render('projects/Board', [ 'boardColumns' => $project->boardColumns() ->with([ + 'ticketStatuses', 'tickets' => fn ($query) => $query - ->with(['ticketPriority', 'ticketType']), + ->with([ + 'ticketPriority', + 'ticketStatus.allowedTargetStatuses.boardColumn', + 'ticketStatus.boardColumn', + 'ticketType', + ]), ]) ->get(), 'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), @@ -56,25 +63,28 @@ class ProjectBoardController extends Controller ); $validated = $request->validated(); - $boardColumnId = Arr::get($validated, 'board_column_id'); + $ticketStatusId = (int) Arr::get($validated, 'ticket_status_id'); $redirectRoute = Arr::get($validated, 'redirect_to') === 'backlog' ? 'projects.backlog.index' : 'projects.board.index'; - $destinationColumn = $project->boardColumns() - ->find($boardColumnId); + $destinationStatus = $project->ticketStatuses() + ->with('boardColumn') + ->findOrFail($ticketStatusId); - $originBoardColumnId = $ticket->board_column_id; - $originLabel = $ticket->boardColumn?->name ?? 'Backlog'; - $destinationLabel = $destinationColumn?->name ?? 'Backlog'; + $ticket->loadMissing('ticketStatus.boardColumn'); - if ($ticket->board_column_id === $boardColumnId) { + $originStatusId = $ticket->ticket_status_id; + $originLabel = $this->statusLabel($ticket->ticketStatus); + $destinationLabel = $this->statusLabel($destinationStatus); + + if ($ticket->ticket_status_id === $ticketStatusId) { return to_route($redirectRoute, $project) ->with('status', "{$ticket->ticket_key} already belongs to {$destinationLabel}."); } $ticket->update([ - 'board_column_id' => $destinationColumn?->id, + 'ticket_status_id' => $destinationStatus->id, ]); $this->createTicketWorklogEntry->handle( @@ -84,12 +94,12 @@ class ProjectBoardController extends Controller "Moved from {$originLabel} to {$destinationLabel}.", [ 'from' => [ - 'board_column_id' => $originBoardColumnId, 'label' => $originLabel, + 'ticket_status_id' => $originStatusId, ], 'to' => [ - 'board_column_id' => $destinationColumn?->id, 'label' => $destinationLabel, + 'ticket_status_id' => $destinationStatus->id, ], ], ); @@ -97,4 +107,21 @@ class ProjectBoardController extends Controller return to_route($redirectRoute, $project) ->with('status', "{$ticket->ticket_key} moved to {$destinationLabel}."); } + + private function statusLabel(?TicketStatus $ticketStatus): string + { + if ($ticketStatus === null) { + return 'Unknown status'; + } + + if ($ticketStatus->boardColumn === null) { + return $ticketStatus->name; + } + + if ($ticketStatus->boardColumn->name === $ticketStatus->name) { + return $ticketStatus->name; + } + + return "{$ticketStatus->boardColumn->name} / {$ticketStatus->name}"; + } } diff --git a/app/Http/Controllers/ProjectBoardSettingsController.php b/app/Http/Controllers/ProjectBoardSettingsController.php index cfc0535..0a5a3c2 100644 --- a/app/Http/Controllers/ProjectBoardSettingsController.php +++ b/app/Http/Controllers/ProjectBoardSettingsController.php @@ -5,9 +5,13 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; use App\Enums\Permission; use App\Http\Requests\StoreBoardColumnRequest; +use App\Http\Requests\StoreTicketStatusRequest; use App\Http\Requests\UpdateBoardColumnRequest; +use App\Http\Requests\UpdateTicketStatusRequest; use App\Models\BoardColumn; use App\Models\Project; +use App\Models\TicketStatus; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; @@ -30,7 +34,12 @@ class ProjectBoardSettingsController extends Controller ); return Inertia::render('projects/settings/Board', [ - 'boardColumns' => $project->boardColumns()->get(), + 'backlogTicketStatuses' => $project->ticketStatuses() + ->whereNull('board_column_id') + ->get(), + 'boardColumns' => $project->boardColumns() + ->with('ticketStatuses') + ->get(), 'project' => $project, 'status' => session('status'), ]); @@ -53,6 +62,11 @@ class ProjectBoardSettingsController extends Controller 'name' => Arr::get($validated, 'name'), 'position' => $project->boardColumns()->count() + 1, ]); + $boardColumn->ticketStatuses()->create([ + 'name' => Arr::get($validated, 'name'), + 'position' => 1, + 'project_id' => $project->id, + ]); $this->reorderColumns($project, $boardColumn, $desiredPosition); @@ -99,6 +113,15 @@ class ProjectBoardSettingsController extends Controller $deletedName = $boardColumn->name; DB::transaction(function () use ($boardColumn, $project): void { + $boardColumn->ticketStatuses() + ->get() + ->each(function (TicketStatus $ticketStatus) use ($project): void { + $ticketStatus->update([ + 'board_column_id' => null, + 'position' => $this->nextStatusPosition($project, null), + ]); + }); + $boardColumn->delete(); $this->normalizeColumnPositions($project); @@ -108,6 +131,92 @@ class ProjectBoardSettingsController extends Controller ->with('status', "{$deletedName} was removed from the board workflow."); } + public function storeStatus( + StoreTicketStatusRequest $request, + Project $project, + ): RedirectResponse { + abort_unless( + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), + 403, + ); + $validated = $request->validated(); + $boardColumnId = Arr::get($validated, 'board_column_id'); + $boardColumnId = $boardColumnId === null ? null : (int) $boardColumnId; + + $ticketStatus = DB::transaction(function () use ($boardColumnId, $project, $validated): TicketStatus { + return $project->ticketStatuses()->create([ + 'board_column_id' => $boardColumnId, + 'name' => Arr::get($validated, 'name'), + 'position' => $this->nextStatusPosition($project, $boardColumnId), + ]); + }); + + $ticketStatus->load('boardColumn'); + + return to_route('projects.settings.board', $project) + ->with('status', "{$ticketStatus->name} was added to {$this->statusContextLabel($ticketStatus)}."); + } + + public function updateStatus( + UpdateTicketStatusRequest $request, + Project $project, + TicketStatus $ticketStatus, + ): RedirectResponse { + abort_unless( + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), + 403, + ); + $validated = $request->validated(); + + $ticketStatus->update([ + 'name' => Arr::get($validated, 'name'), + ]); + + return to_route('projects.settings.board', $project) + ->with('status', "{$ticketStatus->fresh()->name} was updated."); + } + + public function destroyStatus( + Request $request, + Project $project, + TicketStatus $ticketStatus, + ): RedirectResponse { + abort_unless( + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), + 403, + ); + + $ticketStatus->load('boardColumn'); + $boardColumnId = $ticketStatus->board_column_id; + + if ($this->statusContextQuery($project, $boardColumnId)->count() <= 1) { + return to_route('projects.settings.board', $project) + ->withErrors([ + 'ticket_status_id' => "{$this->statusContextLabel($ticketStatus)} needs at least one ticket status.", + ]); + } + + $deletedName = $ticketStatus->name; + $contextLabel = $this->statusContextLabel($ticketStatus); + + DB::transaction(function () use ($boardColumnId, $project, $ticketStatus): void { + $replacementStatus = $this->statusContextQuery($project, $boardColumnId) + ->whereKeyNot($ticketStatus->id) + ->firstOrFail(); + + $ticketStatus->tickets()->update([ + 'ticket_status_id' => $replacementStatus->id, + ]); + + $ticketStatus->delete(); + + $this->normalizeStatusPositions($project, $boardColumnId); + }); + + return to_route('projects.settings.board', $project) + ->with('status', "{$deletedName} was removed from {$contextLabel}."); + } + private function normalizeColumnPositions(Project $project): void { $this->persistOrderedColumns( @@ -151,4 +260,36 @@ class ProjectBoardSettingsController extends Controller ])->saveQuietly(); }); } + + private function nextStatusPosition(Project $project, ?int $boardColumnId): int + { + return $this->statusContextQuery($project, $boardColumnId)->count() + 1; + } + + private function normalizeStatusPositions(Project $project, ?int $boardColumnId): void + { + $this->statusContextQuery($project, $boardColumnId) + ->get() + ->values() + ->each(function (TicketStatus $ticketStatus, int $index): void { + $ticketStatus->forceFill([ + 'position' => $index + 1, + ])->saveQuietly(); + }); + } + + private function statusContextLabel(TicketStatus $ticketStatus): string + { + return $ticketStatus->boardColumn?->name ?? 'Backlog'; + } + + private function statusContextQuery(Project $project, ?int $boardColumnId): HasMany + { + return $project->ticketStatuses() + ->when( + $boardColumnId === null, + fn ($query) => $query->whereNull('board_column_id'), + fn ($query) => $query->where('board_column_id', $boardColumnId), + ); + } } diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index 2ce6140..6fe36e7 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -48,6 +48,7 @@ class ProjectTicketController extends Controller 'fieldValues.userValue', 'parentTicket', 'ticketPriority', + 'ticketStatus.boardColumn', 'ticketType', 'worklogEntries.actor', ]); diff --git a/app/Http/Requests/MoveProjectTicketRequest.php b/app/Http/Requests/MoveProjectTicketRequest.php index 9a5a0b3..29d080a 100644 --- a/app/Http/Requests/MoveProjectTicketRequest.php +++ b/app/Http/Requests/MoveProjectTicketRequest.php @@ -3,9 +3,12 @@ namespace App\Http\Requests; 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 MoveProjectTicketRequest extends FormRequest { @@ -28,12 +31,48 @@ class MoveProjectTicketRequest extends FormRequest $project = $this->route('project'); return [ - 'board_column_id' => [ - 'nullable', + 'ticket_status_id' => [ + 'required', 'integer', - Rule::exists('board_columns', 'id')->where('project_id', $project->id), + Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), ], 'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])], ]; } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + /** @var Ticket $ticket */ + $ticket = $this->route('ticket'); + $validated = $validator->validated(); + $ticketStatusId = (int) Arr::get($validated, 'ticket_status_id'); + + if ($ticket->ticket_status_id === $ticketStatusId) { + return; + } + + $ticket->loadMissing('ticketStatus'); + + $isAllowed = $ticket->ticketStatus?->allowedTargetStatuses() + ->whereKey($ticketStatusId) + ->exists() ?? false; + + if (! $isAllowed) { + $validator->errors()->add( + 'ticket_status_id', + 'Choose a status this ticket is allowed to move to.', + ); + } + }, + ]; + } } diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php index 5a0072c..24bac0a 100644 --- a/app/Http/Requests/StoreProjectTicketRequest.php +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -36,6 +36,14 @@ class StoreProjectTicketRequest extends FormRequest 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id), ], + 'ticket_status_id' => [ + 'required', + 'integer', + Rule::exists('ticket_statuses', 'id') + ->where(fn ($query) => $query + ->where('project_id', $project->id) + ->whereNull('board_column_id')), + ], 'ticket_type_id' => [ 'required', 'integer', diff --git a/app/Http/Requests/StoreTicketStatusRequest.php b/app/Http/Requests/StoreTicketStatusRequest.php new file mode 100644 index 0000000..43d4fb8 --- /dev/null +++ b/app/Http/Requests/StoreTicketStatusRequest.php @@ -0,0 +1,39 @@ +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 [ + 'board_column_id' => [ + 'nullable', + 'integer', + Rule::exists('board_columns', 'id')->where('project_id', $project->id), + ], + 'name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTicketStatusRequest.php b/app/Http/Requests/UpdateTicketStatusRequest.php new file mode 100644 index 0000000..fbd2698 --- /dev/null +++ b/app/Http/Requests/UpdateTicketStatusRequest.php @@ -0,0 +1,29 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Models/BoardColumn.php b/app/Models/BoardColumn.php index 6598bdd..eeeb297 100644 --- a/app/Models/BoardColumn.php +++ b/app/Models/BoardColumn.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; #[Fillable(['name', 'position', 'project_id'])] class BoardColumn extends Model @@ -20,9 +21,19 @@ class BoardColumn extends Model return $this->belongsTo(Project::class); } - public function tickets(): HasMany + public function ticketStatuses(): HasMany { - return $this->hasMany(Ticket::class) - ->orderByDesc('sequence_number'); + return $this->hasMany(TicketStatus::class) + ->orderBy('position'); + } + + public function tickets(): HasManyThrough + { + return $this->hasManyThrough( + Ticket::class, + TicketStatus::class, + 'board_column_id', + 'ticket_status_id', + )->orderByDesc('tickets.sequence_number'); } } diff --git a/app/Models/Project.php b/app/Models/Project.php index 38dbd9e..5ee7809 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -68,6 +68,13 @@ class Project extends Model ->orderBy('position'); } + public function ticketStatuses(): HasMany + { + return $this->hasMany(TicketStatus::class) + ->orderBy('board_column_id') + ->orderBy('position'); + } + public function ticketTypes(): HasMany { return $this->hasMany(TicketType::class) diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index ca3de17..7c1726c 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -13,7 +13,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'acceptance_criteria', 'assignee_user_id', 'author_user_id', - 'board_column_id', 'blocked_comment', 'description', 'is_blocked', @@ -22,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'sequence_number', 'ticket_key', 'ticket_priority_id', + 'ticket_status_id', 'ticket_type_id', 'title', 'story_points', @@ -41,11 +41,6 @@ class Ticket extends Model ]; } - public function boardColumn(): BelongsTo - { - return $this->belongsTo(BoardColumn::class); - } - public function assignee(): BelongsTo { return $this->belongsTo(User::class, 'assignee_user_id'); @@ -95,6 +90,11 @@ class Ticket extends Model return $this->belongsTo(TicketPriority::class); } + public function ticketStatus(): BelongsTo + { + return $this->belongsTo(TicketStatus::class); + } + public function ticketType(): BelongsTo { return $this->belongsTo(TicketType::class); diff --git a/app/Models/TicketStatus.php b/app/Models/TicketStatus.php new file mode 100644 index 0000000..f630d27 --- /dev/null +++ b/app/Models/TicketStatus.php @@ -0,0 +1,84 @@ + */ + use HasFactory; + + protected static function booted(): void + { + static::saving(function (self $ticketStatus): void { + $ticketStatus->slug = Str::slug($ticketStatus->name); + }); + + static::created(function (self $ticketStatus): void { + $statusIds = self::query() + ->where('project_id', $ticketStatus->project_id) + ->pluck('id'); + + $ticketStatus->allowedTargetStatuses()->syncWithoutDetaching($statusIds); + + self::query() + ->where('project_id', $ticketStatus->project_id) + ->whereKeyNot($ticketStatus->id) + ->get() + ->each(fn (self $existingStatus): array => $existingStatus + ->allowedTargetStatuses() + ->syncWithoutDetaching([$ticketStatus->id])); + }); + } + + public function allowedSourceStatuses(): BelongsToMany + { + return $this->belongsToMany( + self::class, + 'ticket_status_transitions', + 'to_ticket_status_id', + 'from_ticket_status_id', + ) + ->orderBy('board_column_id') + ->orderBy('position') + ->withTimestamps(); + } + + public function allowedTargetStatuses(): BelongsToMany + { + return $this->belongsToMany( + self::class, + 'ticket_status_transitions', + 'from_ticket_status_id', + 'to_ticket_status_id', + ) + ->orderBy('board_column_id') + ->orderBy('position') + ->withTimestamps(); + } + + public function boardColumn(): BelongsTo + { + return $this->belongsTo(BoardColumn::class); + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class) + ->orderByDesc('sequence_number'); + } +} diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index bbfaba6..3dd26b8 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Models\Project; use App\Models\Ticket; +use App\Models\TicketStatus; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -24,7 +25,6 @@ class TicketFactory extends Factory 'acceptance_criteria' => fake()->paragraph(), 'assignee_user_id' => null, 'author_user_id' => User::factory(), - 'board_column_id' => null, 'blocked_comment' => null, 'description' => fake()->paragraphs(2, true), 'is_blocked' => false, @@ -33,27 +33,49 @@ class TicketFactory extends Factory 'story_points' => null, 'ticket_key' => 'TEST-1', 'title' => fake()->sentence(4), + 'ticket_status_id' => null, ]; } public function configure(): static { return $this->afterMaking(function (Ticket $ticket): void { - if ($ticket->project_id !== null && $ticket->ticket_type_id !== null && $ticket->ticket_priority_id !== null) { + if ( + $ticket->project_id !== null + && $ticket->ticket_type_id !== null + && $ticket->ticket_priority_id !== null + && $ticket->ticket_status_id !== null + ) { return; } - $project = Project::factory()->create([ - 'key' => fake()->unique()->lexify('????'), - ]); + $createdProject = $ticket->project_id === null; + $project = $createdProject + ? Project::factory()->create([ + 'key' => fake()->unique()->lexify('????'), + ]) + : Project::query()->findOrFail($ticket->project_id); app(InitializeProjectTicketMetadata::class)->handle($project); $ticket->project_id = $project->id; - $ticket->ticket_type_id = $project->ticketTypes()->firstOrFail()->id; - $ticket->ticket_priority_id = $project->ticketPriorities()->firstOrFail()->id; - $ticket->sequence_number = 1; - $ticket->ticket_key = "{$project->key}-1"; + $ticket->ticket_type_id ??= $project->ticketTypes()->firstOrFail()->id; + $ticket->ticket_priority_id ??= $project->ticketPriorities()->firstOrFail()->id; + $ticket->ticket_status_id ??= $project->ticketStatuses() + ->whereNull('board_column_id') + ->first()?->id + ?? TicketStatus::factory()->for($project)->create([ + 'name' => 'Backlog', + 'position' => 1, + ])->id; + + if ($createdProject || $ticket->sequence_number === null) { + $ticket->sequence_number = 1; + } + + if ($createdProject || $ticket->ticket_key === null) { + $ticket->ticket_key = "{$project->key}-{$ticket->sequence_number}"; + } }); } } diff --git a/database/factories/TicketStatusFactory.php b/database/factories/TicketStatusFactory.php new file mode 100644 index 0000000..68eadec --- /dev/null +++ b/database/factories/TicketStatusFactory.php @@ -0,0 +1,28 @@ + + */ +class TicketStatusFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'board_column_id' => null, + 'name' => fake()->unique()->words(2, true), + 'position' => 1, + 'project_id' => Project::factory(), + ]; + } +} diff --git a/database/migrations/2026_04_14_005330_create_ticket_statuses_table.php b/database/migrations/2026_04_14_005330_create_ticket_statuses_table.php new file mode 100644 index 0000000..339f51b --- /dev/null +++ b/database/migrations/2026_04_14_005330_create_ticket_statuses_table.php @@ -0,0 +1,48 @@ +id(); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->foreignId('board_column_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->unsignedInteger('position'); + $table->timestamps(); + + $table->index(['project_id', 'board_column_id', 'position']); + $table->index(['project_id', 'slug']); + }); + + Schema::create('ticket_status_transitions', function (Blueprint $table) { + $table->id(); + $table->foreignId('from_ticket_status_id')->constrained('ticket_statuses')->cascadeOnDelete(); + $table->foreignId('to_ticket_status_id')->constrained('ticket_statuses')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique( + ['from_ticket_status_id', 'to_ticket_status_id'], + 'ticket_status_transitions_unique', + ); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_status_transitions'); + Schema::dropIfExists('ticket_statuses'); + } +}; diff --git a/database/migrations/2026_04_14_005330_replace_ticket_board_column_with_ticket_status.php b/database/migrations/2026_04_14_005330_replace_ticket_board_column_with_ticket_status.php new file mode 100644 index 0000000..965a286 --- /dev/null +++ b/database/migrations/2026_04_14_005330_replace_ticket_board_column_with_ticket_status.php @@ -0,0 +1,133 @@ +foreignId('ticket_status_id') + ->nullable() + ->after('board_column_id') + ->constrained() + ->nullOnDelete(); + }); + + $this->backfillTicketStatuses(); + + Schema::table('tickets', function (Blueprint $table) { + $table->dropConstrainedForeignId('board_column_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + $table->foreignId('board_column_id') + ->nullable() + ->after('author_user_id') + ->constrained() + ->nullOnDelete(); + }); + + DB::table('tickets') + ->leftJoin('ticket_statuses', 'tickets.ticket_status_id', '=', 'ticket_statuses.id') + ->update([ + 'tickets.board_column_id' => DB::raw('ticket_statuses.board_column_id'), + ]); + + Schema::table('tickets', function (Blueprint $table) { + $table->dropConstrainedForeignId('ticket_status_id'); + }); + } + + private function backfillTicketStatuses(): void + { + DB::table('projects') + ->orderBy('id') + ->get() + ->each(function (object $project): void { + $backlogStatusId = $this->createTicketStatus( + (int) $project->id, + null, + 'Backlog', + 1, + ); + + DB::table('tickets') + ->where('project_id', $project->id) + ->whereNull('board_column_id') + ->update(['ticket_status_id' => $backlogStatusId]); + + DB::table('board_columns') + ->where('project_id', $project->id) + ->orderBy('position') + ->get() + ->each(function (object $boardColumn) use ($project): void { + $ticketStatusId = $this->createTicketStatus( + (int) $project->id, + (int) $boardColumn->id, + (string) $boardColumn->name, + 1, + ); + + DB::table('tickets') + ->where('project_id', $project->id) + ->where('board_column_id', $boardColumn->id) + ->update(['ticket_status_id' => $ticketStatusId]); + }); + + $this->createFullTransitionMatrix((int) $project->id); + }); + } + + private function createTicketStatus( + int $projectId, + ?int $boardColumnId, + string $name, + int $position, + ): int { + $now = now(); + + return (int) DB::table('ticket_statuses')->insertGetId([ + 'board_column_id' => $boardColumnId, + 'created_at' => $now, + 'name' => $name, + 'position' => $position, + 'project_id' => $projectId, + 'slug' => Str::slug($name), + 'updated_at' => $now, + ]); + } + + private function createFullTransitionMatrix(int $projectId): void + { + $now = now(); + $statusIds = DB::table('ticket_statuses') + ->where('project_id', $projectId) + ->pluck('id') + ->map(fn (int|string $statusId): int => (int) $statusId); + + $statusIds->each(function (int $fromStatusId) use ($now, $statusIds): void { + $statusIds->each(function (int $toStatusId) use ($fromStatusId, $now): void { + DB::table('ticket_status_transitions')->insertOrIgnore([ + 'created_at' => $now, + 'from_ticket_status_id' => $fromStatusId, + 'to_ticket_status_id' => $toStatusId, + 'updated_at' => $now, + ]); + }); + }); + } +}; diff --git a/database/seeders/BoardColumnSeeder.php b/database/seeders/BoardColumnSeeder.php index a6c376e..1d6e1ce 100644 --- a/database/seeders/BoardColumnSeeder.php +++ b/database/seeders/BoardColumnSeeder.php @@ -25,7 +25,14 @@ class BoardColumnSeeder extends Seeder ['name' => 'Done', 'position' => 3], ) ->for($project) - ->create(); + ->create() + ->each(function (BoardColumn $boardColumn): void { + $boardColumn->ticketStatuses()->create([ + 'name' => $boardColumn->name, + 'position' => 1, + 'project_id' => $boardColumn->project_id, + ]); + }); }); } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 9a356b1..33909c8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -19,6 +19,7 @@ class DatabaseSeeder extends Seeder RepositoryConnectionSeeder::class, BoardColumnSeeder::class, ProjectTicketMetadataSeeder::class, + TicketStatusSeeder::class, TicketSeeder::class, TicketFieldValueSeeder::class, TicketLinkTypeSeeder::class, diff --git a/database/seeders/TicketSeeder.php b/database/seeders/TicketSeeder.php index fd8688d..2528980 100644 --- a/database/seeders/TicketSeeder.php +++ b/database/seeders/TicketSeeder.php @@ -37,6 +37,9 @@ class TicketSeeder extends Seeder } $defaultType = $project->ticketTypes()->firstOrFail(); + $defaultStatus = $project->ticketStatuses() + ->whereNull('board_column_id') + ->firstOrFail(); $createProjectTicket->handle($project, [ 'acceptance_criteria' => 'The seeded ticket should be visible in the backlog list.', @@ -44,6 +47,7 @@ class TicketSeeder extends Seeder 'description' => 'Seeded backlog item for demonstrating the first ticket workflow.', 'story_points' => 3, 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $defaultStatus->id, 'ticket_type_id' => $defaultType->id, 'title' => 'Seed backlog item', ], $author); @@ -54,6 +58,7 @@ class TicketSeeder extends Seeder 'description' => 'Second seeded backlog item using the same title on purpose.', 'story_points' => 5, 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $defaultStatus->id, 'ticket_type_id' => $defaultType->id, 'title' => 'Seed backlog item', ], $author); diff --git a/database/seeders/TicketStatusSeeder.php b/database/seeders/TicketStatusSeeder.php new file mode 100644 index 0000000..06602cb --- /dev/null +++ b/database/seeders/TicketStatusSeeder.php @@ -0,0 +1,45 @@ +with('boardColumns') + ->orderBy('id') + ->get() + ->each(function (Project $project) use ($initializeProjectTicketMetadata): void { + $initializeProjectTicketMetadata->handle($project); + + TicketStatus::factory() + ->for($project) + ->create([ + 'name' => 'Ready', + 'position' => $project->ticketStatuses()->whereNull('board_column_id')->count() + 1, + ]); + + $project->boardColumns->each(function (BoardColumn $boardColumn): void { + TicketStatus::factory() + ->for($boardColumn->project) + ->for($boardColumn) + ->create([ + 'name' => "{$boardColumn->name} Review", + 'position' => $boardColumn->ticketStatuses()->count() + 1, + ]); + }); + }); + } +} diff --git a/resources/js/pages/projects/Backlog.vue b/resources/js/pages/projects/Backlog.vue index 699adc4..5e1159b 100644 --- a/resources/js/pages/projects/Backlog.vue +++ b/resources/js/pages/projects/Backlog.vue @@ -22,6 +22,8 @@ import { index, show } from '@/routes/projects/index'; import { show as showTicket } from '@/routes/projects/tickets'; import { metadata as metadataSettings } from '@/routes/projects/settings'; import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types'; +import type { TicketModel } from '@/types/models/ticket-model'; +import type { TicketStatusModel } from '@/types/models/ticket-status-model'; const props = defineProps(); @@ -41,7 +43,35 @@ const breadcrumbs: BreadcrumbItem[] = [ ]; const hasTicketMetadata = - props.ticketTypes.length > 0 && props.ticketPriorities.length > 0; + props.ticketTypes.length > 0 && + props.ticketPriorities.length > 0 && + props.backlogTicketStatuses.length > 0; + +const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => { + if (ticketStatus === null) { + return 'Unknown status'; + } + + if ( + ticketStatus.board_column === null || + ticketStatus.board_column === undefined + ) { + return ticketStatus.name; + } + + if (ticketStatus.board_column.name === ticketStatus.name) { + return ticketStatus.name; + } + + return `${ticketStatus.board_column.name} / ${ticketStatus.name}`; +}; + +const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => { + return ( + ticket.ticket_status?.allowed_target_statuses ?? + props.ticketStatusOptions + ); +};