Implement ticket statuses

This commit is contained in:
2026-04-14 03:09:12 +02:00
parent 2dc12c5d60
commit 8f4a8276ac
40 changed files with 1602 additions and 100 deletions
@@ -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()
+38 -11
View File
@@ -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}";
}
}
@@ -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),
);
}
}
@@ -48,6 +48,7 @@ class ProjectTicketController extends Controller
'fieldValues.userValue',
'parentTicket',
'ticketPriority',
'ticketStatus.boardColumn',
'ticketType',
'worklogEntries.actor',
]);
+42 -3
View File
@@ -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<int, callable(Validator): void>
*/
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.',
);
}
},
];
}
}
@@ -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',
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTicketStatusRequest 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),
],
'name' => ['required', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTicketStatusRequest 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
{
return [
'name' => ['required', 'string', 'max:255'],
];
}
}