Implement project board status workflow

This commit is contained in:
2026-03-25 08:04:34 +01:00
parent f7a0543ea1
commit 7e767b0c6c
22 changed files with 1076 additions and 7 deletions
@@ -0,0 +1,28 @@
<?php
namespace App\Actions\Tickets;
use App\Models\Ticket;
use App\Models\TicketWorklogEntry;
use App\Models\User;
class CreateTicketWorklogEntry
{
/**
* @param array<string, mixed>|null $changes
*/
public function handle(
Ticket $ticket,
?User $actor,
string $entryType,
string $summary,
?array $changes = null,
): TicketWorklogEntry {
return $ticket->worklogEntries()->create([
'actor_id' => $actor?->id,
'changes' => $changes,
'entry_type' => $entryType,
'summary' => $summary,
]);
}
}
@@ -27,6 +27,8 @@ class ProjectBacklogController extends Controller
);
return Inertia::render('projects/Backlog', [
'boardColumns' => $project->boardColumns()->get(),
'canEditTickets' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'),
'canCreateTickets' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'create'),
'project' => $project,
'status' => session('status'),
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Tickets\CreateTicketWorklogEntry;
use App\Http\Requests\MoveProjectTicketRequest;
use App\Models\Project;
use App\Models\Ticket;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectBoardController extends Controller
{
public function __construct(
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
private readonly ProjectPermissionService $projectPermissionService,
) {}
public function index(Request $request, Project $project): Response
{
abort_unless(
$this->projectPermissionService->userCanViewProject($request->user(), $project),
403,
);
return Inertia::render('projects/Board', [
'boardColumns' => $project->boardColumns()
->with([
'tickets' => fn ($query) => $query
->with(['ticketPriority', 'ticketType']),
])
->get(),
'canEditTickets' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'),
'project' => $project,
'status' => session('status'),
]);
}
public function move(
MoveProjectTicketRequest $request,
Project $project,
Ticket $ticket,
): RedirectResponse {
abort_unless(
$this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'),
403,
);
$destinationColumn = $project->boardColumns()
->find($request->boardColumnId());
$originBoardColumnId = $ticket->board_column_id;
$originLabel = $ticket->boardColumn?->name ?? 'Backlog';
$destinationLabel = $destinationColumn?->name ?? 'Backlog';
if ($ticket->board_column_id === $request->boardColumnId()) {
return to_route($request->redirectRouteName(), $project)
->with('status', "{$ticket->ticket_key} already belongs to {$destinationLabel}.");
}
$ticket->update([
'board_column_id' => $destinationColumn?->id,
]);
$this->createTicketWorklogEntry->handle(
$ticket->fresh(),
$request->user(),
'status_changed',
"Moved from {$originLabel} to {$destinationLabel}.",
[
'from' => [
'board_column_id' => $originBoardColumnId,
'label' => $originLabel,
],
'to' => [
'board_column_id' => $destinationColumn?->id,
'label' => $destinationLabel,
],
],
);
return to_route($request->redirectRouteName(), $project)
->with('status', "{$ticket->ticket_key} moved to {$destinationLabel}.");
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class MoveProjectTicketRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|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),
],
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
];
}
public function boardColumnId(): ?int
{
$boardColumnId = $this->validated('board_column_id');
if ($boardColumnId === null || $boardColumnId === '') {
return null;
}
return (int) $boardColumnId;
}
public function redirectRouteName(): string
{
return $this->validated('redirect_to') === 'backlog'
? 'projects.backlog.index'
: 'projects.board.index';
}
}
+7
View File
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id'])]
class BoardColumn extends Model
@@ -18,4 +19,10 @@ class BoardColumn extends Model
{
return $this->belongsTo(Project::class);
}
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class)
->orderByDesc('sequence_number');
}
}
+8
View File
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'acceptance_criteria',
@@ -43,4 +44,11 @@ class Ticket extends Model
{
return $this->belongsTo(TicketType::class);
}
public function worklogEntries(): HasMany
{
return $this->hasMany(TicketWorklogEntry::class)
->orderByDesc('created_at')
->orderByDesc('id');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Database\Factories\TicketWorklogEntryFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'actor_id',
'changes',
'entry_type',
'summary',
'ticket_id',
])]
class TicketWorklogEntry extends Model
{
/** @use HasFactory<TicketWorklogEntryFactory> */
use HasFactory;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'changes' => 'array',
];
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
}