ticket-rework-status #29

Merged
Twyco merged 10 commits from ticket-rework-status into main 2026-04-15 09:11:58 +00:00
46 changed files with 2038 additions and 138 deletions
@@ -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.
-4
View File
@@ -1,4 +0,0 @@
[mcp_servers.laravel-boost]
command = "php"
args = ["artisan", "boost:mcp"]
cwd = "/Users/nico/dev/openSource/KanbanGit"
+1
View File
@@ -27,3 +27,4 @@ yarn-error.log
/.nova
/.vscode
/.zed
.codex/config.toml
@@ -27,5 +27,12 @@ class InitializeProjectTicketMetadata
'position' => 1,
]);
}
if (! $project->ticketStatuses()->whereNull('board_column_id')->exists()) {
$project->ticketStatuses()->create([
'name' => 'Backlog',
'position' => 1,
]);
}
}
}
+9 -3
View File
@@ -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,
],
],
);
@@ -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,14 @@ 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\Http\Requests\UpdateTicketStatusTransitionsRequest;
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,9 +35,17 @@ 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'),
'ticketStatuses' => $project->ticketStatuses()
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
->get(),
]);
}
@@ -53,6 +66,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 +117,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 +135,114 @@ 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}.");
}
public function updateStatusTransitions(
UpdateTicketStatusTransitionsRequest $request,
Project $project,
TicketStatus $ticketStatus,
): RedirectResponse {
abort_unless(
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
$validated = $request->validated();
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
->unique()
->values()
->all();
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
return to_route('projects.settings.board', $project)
->with('status', "{$ticketStatus->name} move targets were updated.");
}
private function normalizeColumnPositions(Project $project): void
{
$this->persistOrderedColumns(
@@ -151,4 +286,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'],
];
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use App\Models\TicketStatus;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateTicketStatusTransitionsRequest 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 [
'allowed_ticket_status_ids' => ['nullable', 'array'],
'allowed_ticket_status_ids.*' => [
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
],
];
}
/**
* @return array<int, callable(Validator): void>
*/
public function after(): array
{
return [
function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
/** @var TicketStatus $ticketStatus */
$ticketStatus = $this->route('ticketStatus');
$validated = $validator->validated();
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId);
if ($allowedTicketStatusIds->contains($ticketStatus->id)) {
$validator->errors()->add(
'allowed_ticket_status_ids',
'A status cannot allow moves to itself.',
);
}
},
];
}
}
+14 -3
View File
@@ -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');
}
}
+7
View File
@@ -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)
+6 -6
View File
@@ -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);
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use Database\Factories\TicketStatusFactory;
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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'slug'])]
class TicketStatus extends Model
{
/** @use HasFactory<TicketStatusFactory> */
use HasFactory;
protected static function booted(): void
{
static::saving(function (self $ticketStatus): void {
$ticketStatus->slug = Str::slug($ticketStatus->name);
});
}
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');
}
}
Generated
+1 -1
View File
@@ -8831,5 +8831,5 @@
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}
+31 -9
View File
@@ -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}";
}
});
}
}
@@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<TicketStatus>
*/
class TicketStatusFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'board_column_id' => null,
'name' => fake()->unique()->words(2, true),
'position' => 1,
'project_id' => Project::factory(),
];
}
}
@@ -18,7 +18,7 @@ return new class extends Migration
$table->foreignId('ticket_priority_id')->constrained()->cascadeOnDelete();
$table->foreignId('assignee_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('author_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('board_column_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('ticket_status_id')->nullable()->constrained()->nullOnDelete();
$table->unsignedInteger('sequence_number');
$table->string('ticket_key')->unique();
$table->string('title');
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ticket_statuses', function (Blueprint $table) {
$table->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']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ticket_statuses');
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
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');
}
};
+8 -1
View File
@@ -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,
]);
});
});
}
}
+1
View File
@@ -19,6 +19,7 @@ class DatabaseSeeder extends Seeder
RepositoryConnectionSeeder::class,
BoardColumnSeeder::class,
ProjectTicketMetadataSeeder::class,
TicketStatusSeeder::class,
TicketSeeder::class,
TicketFieldValueSeeder::class,
TicketLinkTypeSeeder::class,
+5
View File
@@ -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);
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Database\Seeders;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\TicketStatus;
use Illuminate\Database\Seeder;
class TicketStatusSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$initializeProjectTicketMetadata = app(InitializeProjectTicketMetadata::class);
Project::query()
->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,
]);
});
});
}
}
+11 -20
View File
@@ -2,31 +2,22 @@
### Ticket
- general:
- (mehr über middlewares steuern)
- why uses everywhere forceFill?
#### general
- (mehr über middlewares steuern)
- why uses everywhere forceFill?
- app/Http/Requests/UpdateProjectTicketRequest.php and other ProjectTicket only to Ticket...
- mehrere actions/quereys in services auslagern
#### later
- repository_connections check base Url maybe unnessecary?
- Git listBranches replace baseUrl, owner, rperoName with GItReprository
- Git prs replace baseUrl, owner, rperoName with GItReprository
- replace Kanbangit with env App Name
- 06
- add Ticket Status
- ticketstatus can have one or more board columns
- one board cloumn cant be in two or more ticket statuses
- replace board_colum_id on ticket with ticket status id
- ticket status has an n:m relation in which relatin the ticket can switch
- (ui friendly example all following or select)
- change Project Backlog Controller → Project Controller
- remove store from project board controller and route projects/{project}/backlog, change to ticket controller, create
- 09
- app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null
- later:
- repository_connections check base Url maybe unnessecary?
- Git listBranches replace baseUrl, owner, rperoName with GItReprository
- Git prs replace baseUrl, owner, rperoName with GItReprository
- replace Kanbangit with env App Name
- 08
- 08
- worklog überarbeiten → type als worklog enum → json je nach enum type
- UpdateProjectTicket aufräumen
- 09
- app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null
+111 -21
View File
@@ -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<ProjectBacklogPageProps>();
@@ -41,7 +43,34 @@ 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 ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
);
};
</script>
<template>
@@ -85,7 +114,7 @@ const hasTicketMetadata =
<CardTitle>Create ticket</CardTitle>
<CardDescription>
New tickets receive the next immutable project key and
automatically enter backlog.
enter the selected backlog status.
</CardDescription>
</CardHeader>
@@ -182,6 +211,28 @@ const hasTicketMetadata =
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">
Status
</Label>
<select
id="ticket_status_id"
name="ticket_status_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{ ticketStatus.name }}
</option>
</select>
<InputError
:message="errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
@@ -237,7 +288,7 @@ const hasTicketMetadata =
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
Ticket creation needs at least one type and one
priority.
priority, plus one backlog status.
</p>
<div>
<Button as-child variant="outline">
@@ -253,8 +304,8 @@ const hasTicketMetadata =
<CardHeader class="border-b">
<CardTitle>Backlog tickets</CardTitle>
<CardDescription>
Tickets remain here until they are moved into a board
column.
Tickets remain here while their status belongs to
backlog.
</CardDescription>
</CardHeader>
@@ -277,6 +328,9 @@ const hasTicketMetadata =
<th class="px-4 py-3 font-medium">Key</th>
<th class="px-4 py-3 font-medium">Title</th>
<th class="px-4 py-3 font-medium">Type</th>
<th class="px-4 py-3 font-medium">
Status
</th>
<th class="px-4 py-3 font-medium">
Priority
</th>
@@ -284,7 +338,7 @@ const hasTicketMetadata =
v-if="canEditTickets"
class="px-4 py-3 font-medium"
>
Move to board
Move to status
</th>
</tr>
</thead>
@@ -330,12 +384,18 @@ const hasTicketMetadata =
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_type.name }}
</td>
<td class="text-muted-foreground px-4 py-4">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</td>
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_priority.name }}
</td>
<td v-if="canEditTickets" class="px-4 py-4">
<Form
v-if="boardColumns.length > 0"
v-bind="
ProjectBoardController.move.form(
{
@@ -353,32 +413,62 @@ const hasTicketMetadata =
value="backlog"
/>
<select
name="board_column_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
name="ticket_status_id"
:disabled="
allowedTargetStatuses(
ticket,
).length === 0 || processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="boardColumn in boardColumns"
:key="boardColumn.id"
:value="boardColumn.id"
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
{{ boardColumn.name }}
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
ticketStatusLabel(
ticketStatus,
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="processing"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Move
</Button>
<p
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
class="text-muted-foreground text-xs sm:basis-full"
>
This status does not allow
moving anywhere else.
</p>
</Form>
<span
v-else
class="text-muted-foreground text-sm"
>
Add board columns first.
</span>
</td>
</tr>
</tbody>
+90 -19
View File
@@ -18,6 +18,8 @@ import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board';
import { show as showTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectBoardPageProps>();
@@ -36,16 +38,30 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const statusOptions = [
{
label: 'Backlog',
value: '',
},
...props.boardColumns.map((boardColumn) => ({
label: boardColumn.name,
value: String(boardColumn.id),
})),
];
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 ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
);
};
</script>
<template>
@@ -116,6 +132,21 @@ const statusOptions = [
{{ boardColumn.tickets?.length ?? 0 }}
active ticket(s)
</CardDescription>
<div
v-if="
(boardColumn.ticket_statuses?.length ??
0) > 0
"
class="mt-3 flex flex-wrap gap-2"
>
<Badge
v-for="ticketStatus in boardColumn.ticket_statuses"
:key="ticketStatus.id"
variant="outline"
>
{{ ticketStatus.name }}
</Badge>
</div>
</div>
<Badge variant="secondary">
@@ -160,6 +191,13 @@ const statusOptions = [
<Badge variant="secondary">
{{ ticket.ticket_priority.name }}
</Badge>
<Badge variant="outline">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</Badge>
</div>
<CardTitle class="text-base">
<Link
@@ -218,28 +256,61 @@ const statusOptions = [
>
<select
:id="`ticket-status-${ticket.id}`"
name="board_column_id"
:value="
ticket.board_column_id ?? ''
name="ticket_status_id"
:disabled="
allowedTargetStatuses(
ticket,
).length === 0 || processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="statusOption in statusOptions"
:key="statusOption.label"
:value="statusOption.value"
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
{{ statusOption.label }}
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
ticketStatusLabel(
ticketStatus,
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="processing"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Update status
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-muted-foreground text-xs"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</CardContent>
</Card>
+3
View File
@@ -354,6 +354,9 @@ const formatDateTime = (value: string): string => {
<Badge variant="secondary">
{{ ticket.ticket_priority.name }}
</Badge>
<Badge v-if="ticket.ticket_status" variant="outline">
{{ ticket.ticket_status.name }}
</Badge>
<Badge v-if="!canEditTicket" variant="outline">
Read only
</Badge>
+405 -4
View File
@@ -22,6 +22,7 @@ import {
repositories as repositorySettings,
} from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectBoardSettingsPageProps>();
@@ -39,6 +40,40 @@ const breadcrumbs: BreadcrumbItem[] = [
href: boardSettings(props.project.id),
},
];
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
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 transitionTargetOptions = (
sourceStatus: TicketStatusModel,
): TicketStatusModel[] => {
return props.ticketStatuses.filter(
(ticketStatus) => ticketStatus.id !== sourceStatus.id,
);
};
const statusAllowsTarget = (
sourceStatus: TicketStatusModel,
targetStatus: TicketStatusModel,
): boolean => {
return (
sourceStatus.allowed_target_statuses?.some(
(allowedTargetStatus) => allowedTargetStatus.id === targetStatus.id,
) ?? false
);
};
</script>
<template>
@@ -93,20 +128,252 @@ const breadcrumbs: BreadcrumbItem[] = [
<CardHeader>
<CardTitle>Workflow model</CardTitle>
<CardDescription>
Backlog is always the intake queue and stays outside the
normal board column list.
Backlog is always the intake queue; board columns group
the statuses that appear on the board.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-3">
<Badge class="px-3 py-1">Backlog</Badge>
<Badge
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
variant="outline"
class="px-3 py-1"
>
{{ ticketStatus.name }}
</Badge>
<p class="text-muted-foreground text-sm">
Tickets will later be valid only in backlog or in one of
the ordered columns below.
Tickets move between statuses; backlog statuses are not
included in a board column.
</p>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Status moves</CardTitle>
<CardDescription>
Choose exactly where each status is allowed to move
next.
</CardDescription>
</CardHeader>
<CardContent class="p-0">
<div class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th class="px-4 py-3 font-medium">
Source status
</th>
<th class="px-4 py-3 font-medium">
Allowed move targets
</th>
<th class="px-4 py-3 font-medium">Save</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="sourceStatus in ticketStatuses"
:key="sourceStatus.id"
class="bg-background align-top"
>
<td class="px-4 py-4">
<Badge variant="outline">
{{
ticketStatusLabel(sourceStatus)
}}
</Badge>
</td>
<td class="px-4 py-4">
<Form
:id="`status-transitions-${sourceStatus.id}`"
v-bind="
ProjectBoardSettingsController.updateStatusTransitions.form(
[
project.id,
sourceStatus.id,
],
)
"
class="grid gap-3"
v-slot="{ errors }"
>
<div
v-if="
transitionTargetOptions(
sourceStatus,
).length === 0
"
class="text-muted-foreground"
>
No other statuses exist yet.
</div>
<div
v-else
class="grid gap-2 md:grid-cols-2 xl:grid-cols-3"
>
<label
v-for="targetStatus in transitionTargetOptions(
sourceStatus,
)"
:key="targetStatus.id"
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<input
type="checkbox"
name="allowed_ticket_status_ids[]"
:value="targetStatus.id"
:default-checked="
statusAllowsTarget(
sourceStatus,
targetStatus,
)
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>{{
ticketStatusLabel(
targetStatus,
)
}}</span>
</label>
</div>
<InputError
:message="
errors.allowed_ticket_status_ids
"
/>
</Form>
</td>
<td class="px-4 py-4">
<Button
type="submit"
variant="outline"
:form="`status-transitions-${sourceStatus.id}`"
>
Save
</Button>
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Backlog statuses</CardTitle>
<CardDescription>
New backlog tickets choose one of these statuses.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
class="border-border rounded-lg border p-4"
>
<Form
v-bind="
ProjectBoardSettingsController.updateStatus.form(
[project.id, ticketStatus.id],
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`backlog-status-${ticketStatus.id}`"
>
Status name
</Label>
<Input
:id="`backlog-status-${ticketStatus.id}`"
name="name"
:default-value="ticketStatus.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroyStatus.form(
[project.id, ticketStatus.id],
)
"
v-slot="{ errors, processing }"
>
<InputError
:message="errors.ticket_status_id"
class="mb-2"
/>
<Button
type="submit"
variant="destructive"
:disabled="
processing ||
backlogTicketStatuses.length <= 1
"
>
Delete
</Button>
</Form>
</div>
</div>
<Form
v-bind="
ProjectBoardSettingsController.storeStatus.form(
project.id,
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="backlog-status-name">
New backlog status
</Label>
<Input
id="backlog-status-name"
name="name"
placeholder="Ready"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button type="submit" :disabled="processing">
Add status
</Button>
</div>
</Form>
</CardContent>
</Card>
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Card>
<CardHeader>
@@ -239,6 +506,140 @@ const breadcrumbs: BreadcrumbItem[] = [
</Button>
</Form>
</div>
<div class="border-border mt-4 border-t pt-4">
<div class="mb-3">
<p class="text-sm font-medium">
Ticket statuses
</p>
<p class="text-muted-foreground text-sm">
Moving into this column requires a
specific status.
</p>
</div>
<div class="space-y-3">
<div
v-for="ticketStatus in column.ticket_statuses ??
[]"
:key="ticketStatus.id"
class="border-border rounded-lg border p-3"
>
<Form
v-bind="
ProjectBoardSettingsController.updateStatus.form(
[
project.id,
ticketStatus.id,
],
)
"
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`column-status-${ticketStatus.id}`"
>
Status name
</Label>
<Input
:id="`column-status-${ticketStatus.id}`"
name="name"
:default-value="
ticketStatus.name
"
required
/>
<InputError
:message="errors.name"
/>
</div>
<div class="flex items-end">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<div class="mt-3 flex justify-end">
<Form
v-bind="
ProjectBoardSettingsController.destroyStatus.form(
[
project.id,
ticketStatus.id,
],
)
"
v-slot="{ errors, processing }"
>
<InputError
:message="
errors.ticket_status_id
"
class="mb-2"
/>
<Button
type="submit"
variant="destructive"
:disabled="
processing ||
(column.ticket_statuses
?.length ?? 0) <= 1
"
>
Delete
</Button>
</Form>
</div>
</div>
</div>
<Form
v-bind="
ProjectBoardSettingsController.storeStatus.form(
project.id,
)
"
class="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
v-slot="{ errors, processing }"
>
<input
type="hidden"
name="board_column_id"
:value="column.id"
/>
<div class="grid gap-2">
<Label
:for="`new-column-status-${column.id}`"
>
New status
</Label>
<Input
:id="`new-column-status-${column.id}`"
name="name"
placeholder="Ready for QA"
required
/>
<InputError :message="errors.name" />
</div>
<div class="flex items-end">
<Button
type="submit"
:disabled="processing"
>
Add status
</Button>
</div>
</Form>
</div>
</div>
</CardContent>
</Card>
@@ -205,7 +205,7 @@ const submitUpdate = (): void => {
updateForm.submit(
ProjectRepositorySettingsController.update([
props.project.id,
updateForm.repository_connection_id,
Number(updateForm.repository_connection_id),
]),
{
preserveScroll: true,
@@ -1,10 +1,12 @@
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
export type BoardColumnModel = {
id: number;
name: string;
position: number;
project_id: number;
ticket_statuses?: TicketStatusModel[];
tickets?: TicketModel[];
created_at: string;
updated_at: string;
+3 -1
View File
@@ -1,5 +1,6 @@
import type { TicketFieldValueModel } from '@/types/models/ticket-field-value-model';
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { TicketWorklogEntryModel } from '@/types/models/ticket-worklog-entry-model';
import type { UserModel } from '@/types/models/user-model';
@@ -11,7 +12,6 @@ export type TicketModel = {
assignee_user_id: number | null;
author: UserModel | null;
author_user_id: number | null;
board_column_id: number | null;
blocked_comment: string | null;
description: string | null;
field_values?: TicketFieldValueModel[];
@@ -22,6 +22,8 @@ export type TicketModel = {
ticket_key: string;
ticket_priority: TicketPriorityModel;
ticket_priority_id: number;
ticket_status: TicketStatusModel | null;
ticket_status_id: number | null;
ticket_type: TicketTypeModel;
ticket_type_id: number;
title: string;
@@ -0,0 +1,14 @@
import type { BoardColumnModel } from '@/types/models/board-column-model';
export type TicketStatusModel = {
id: number;
allowed_target_statuses?: TicketStatusModel[];
board_column?: BoardColumnModel | null;
board_column_id: number | null;
name: string;
position: number;
project_id: number;
slug: string;
created_at: string;
updated_at: string;
};
+5 -1
View File
@@ -11,6 +11,7 @@ import type { TicketReferenceModel } from '@/types/models/ticket-reference-model
import type { TicketRepositoryPullRequestModel } from '@/types/models/ticket-repository-pull-request-model';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
@@ -58,9 +59,11 @@ export type TeamsIndexPageProps = {
};
export type ProjectBoardSettingsPageProps = {
backlogTicketStatuses: TicketStatusModel[];
boardColumns: BoardColumnModel[];
project: ProjectModel;
status?: string | null;
ticketStatuses: TicketStatusModel[];
};
export type ProjectGeneralSettingsPageProps = {
@@ -99,12 +102,13 @@ export type ProjectRepositorySettingsPageProps = {
};
export type ProjectBacklogPageProps = {
boardColumns: BoardColumnModel[];
backlogTicketStatuses: TicketStatusModel[];
canCreateTickets: boolean;
canEditTickets: boolean;
project: ProjectModel;
status?: string | null;
ticketPriorities: TicketPriorityModel[];
ticketStatusOptions: TicketStatusModel[];
tickets: TicketModel[];
ticketTypes: TicketTypeModel[];
users: UserModel[];
+8
View File
@@ -68,6 +68,14 @@ Route::middleware(['auth', 'verified'])->group(function () {
->name('projects.settings.board.columns.update');
Route::delete('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'destroy'])
->name('projects.settings.board.columns.destroy');
Route::post('projects/{project}/settings/board/statuses', [ProjectBoardSettingsController::class, 'storeStatus'])
->name('projects.settings.board.statuses.store');
Route::patch('projects/{project}/settings/board/statuses/{ticketStatus}', [ProjectBoardSettingsController::class, 'updateStatus'])
->name('projects.settings.board.statuses.update');
Route::delete('projects/{project}/settings/board/statuses/{ticketStatus}', [ProjectBoardSettingsController::class, 'destroyStatus'])
->name('projects.settings.board.statuses.destroy');
Route::patch('projects/{project}/settings/board/statuses/{ticketStatus}/transitions', [ProjectBoardSettingsController::class, 'updateStatusTransitions'])
->name('projects.settings.board.statuses.transitions.update');
Route::get('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'show'])
->name('projects.settings.metadata');
Route::get('projects/{project}/settings/repositories', [ProjectRepositorySettingsController::class, 'show'])
+15 -2
View File
@@ -6,6 +6,7 @@ use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketStatus;
use Database\Seeders\BoardColumnSeeder;
use Database\Seeders\ProjectSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -69,22 +70,29 @@ class BoardColumnTest extends TestCase
'name' => 'Doing',
'position' => 1,
]);
$ticketStatus = TicketStatus::factory()
->for($project)
->for($boardColumn)
->create([
'name' => 'Doing',
'position' => 1,
]);
Ticket::factory()->create([
'board_column_id' => $boardColumn->id,
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $ticketStatus->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'title' => 'First ticket',
]);
Ticket::factory()->create([
'board_column_id' => $boardColumn->id,
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $ticketStatus->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'title' => 'Second ticket',
]);
@@ -112,6 +120,11 @@ class BoardColumnTest extends TestCase
'name' => 'Todo',
'position' => 1,
]);
$this->assertDatabaseHas('ticket_statuses', [
'project_id' => $coreProject->id,
'name' => 'Todo',
'slug' => 'todo',
]);
$this->assertDatabaseHas('board_columns', [
'project_id' => $coreProject->id,
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace Tests\Feature\Models;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\TicketStatus;
use Database\Seeders\BoardColumnSeeder;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder;
use Database\Seeders\TicketStatusSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TicketStatusTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure ticket statuses belong to exactly one board column or backlog.
*/
public function test_ticket_status_factory_creates_backlog_statuses(): void
{
$ticketStatus = TicketStatus::factory()->create([
'name' => 'Ready for QA',
]);
$this->assertNotNull($ticketStatus->project);
$this->assertNull($ticketStatus->boardColumn);
$this->assertSame('ready-for-qa', $ticketStatus->slug);
$this->assertSame(1, $ticketStatus->position);
}
/**
* Ensure project metadata initialization creates a default backlog status.
*/
public function test_project_metadata_initialization_creates_backlog_status(): void
{
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->assertDatabaseHas('ticket_statuses', [
'project_id' => $project->id,
'board_column_id' => null,
'name' => 'Backlog',
'slug' => 'backlog',
'position' => 1,
]);
}
/**
* Ensure new statuses do not allow moves until transitions are configured.
*/
public function test_ticket_statuses_do_not_create_allowed_transition_relations_by_default(): void
{
$project = Project::factory()->create();
$backlogStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Backlog',
'position' => 1,
]);
$readyStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Ready',
'position' => 2,
]);
$this->assertFalse($backlogStatus->allowedTargetStatuses()->whereKey($readyStatus->id)->exists());
$this->assertFalse($readyStatus->allowedTargetStatuses()->whereKey($backlogStatus->id)->exists());
$this->assertCount(0, $backlogStatus->allowedTargetStatuses);
$this->assertCount(0, $readyStatus->allowedTargetStatuses);
}
/**
* Ensure allowed status moves are controlled by the transition relation.
*/
public function test_ticket_statuses_sync_allowed_transition_relations(): void
{
$project = Project::factory()->create();
$backlogStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Backlog',
'position' => 1,
]);
$readyStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Ready',
'position' => 2,
]);
$backlogStatus->allowedTargetStatuses()->sync([$readyStatus->id]);
$this->assertSame(
[$readyStatus->id],
$backlogStatus->allowedTargetStatuses()->get()->pluck('id')->all(),
);
$this->assertFalse($readyStatus->allowedTargetStatuses()->whereKey($backlogStatus->id)->exists());
$backlogStatus->allowedTargetStatuses()->sync([]);
$this->assertCount(0, $backlogStatus->allowedTargetStatuses()->get());
}
/**
* Ensure board columns expose their grouped ticket statuses in position order.
*/
public function test_board_column_ticket_statuses_are_returned_in_position_order(): void
{
$project = Project::factory()->create();
$boardColumn = BoardColumn::factory()->for($project)->create();
TicketStatus::factory()->for($project)->for($boardColumn)->create([
'name' => 'Second',
'position' => 2,
]);
TicketStatus::factory()->for($project)->for($boardColumn)->create([
'name' => 'First',
'position' => 1,
]);
$this->assertSame(
['First', 'Second'],
$boardColumn->fresh()->ticketStatuses->pluck('name')->all(),
);
}
/**
* Ensure the ticket status seeder creates extra statuses for seeded projects.
*/
public function test_ticket_status_seeder_creates_statuses_for_seeded_projects(): void
{
$this->seed([
ProjectSeeder::class,
BoardColumnSeeder::class,
ProjectTicketMetadataSeeder::class,
TicketStatusSeeder::class,
]);
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$this->assertDatabaseHas('ticket_statuses', [
'project_id' => $coreProject->id,
'board_column_id' => null,
'name' => 'Ready',
'slug' => 'ready',
]);
}
}
+23 -3
View File
@@ -9,6 +9,7 @@ use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue;
use App\Models\TicketLink;
use App\Models\TicketLinkType;
use App\Models\TicketStatus;
use App\Models\TicketWorklogEntry;
use App\Models\User;
use Database\Seeders\BoardColumnSeeder;
@@ -29,7 +30,7 @@ class TicketTest extends TestCase
{
$ticket = Ticket::factory()->create();
$this->assertNull($ticket->board_column_id);
$this->assertNull($ticket->ticketStatus?->board_column_id);
$this->assertStringContainsString('-', $ticket->ticket_key);
$this->assertSame(1, $ticket->sequence_number);
}
@@ -47,19 +48,22 @@ class TicketTest extends TestCase
]);
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$backlogStatus = $coreProject->ticketStatuses()
->whereNull('board_column_id')
->firstOrFail();
$this->assertDatabaseHas('tickets', [
'project_id' => $coreProject->id,
'ticket_key' => 'CORE-1',
'title' => 'Seed backlog item',
'board_column_id' => null,
'ticket_status_id' => $backlogStatus->id,
]);
$this->assertDatabaseHas('tickets', [
'project_id' => $coreProject->id,
'ticket_key' => 'CORE-2',
'title' => 'Seed backlog item',
'board_column_id' => null,
'ticket_status_id' => $backlogStatus->id,
]);
}
@@ -200,6 +204,22 @@ class TicketTest extends TestCase
$this->assertSame($author->id, $ticket->fresh()->author?->id);
}
/**
* Ensure tickets expose their status relation.
*/
public function test_ticket_status_relation_is_available(): void
{
$ticketStatus = TicketStatus::factory()->create([
'name' => 'Backlog',
]);
$ticket = Ticket::factory()->create([
'project_id' => $ticketStatus->project_id,
'ticket_status_id' => $ticketStatus->id,
]);
$this->assertSame($ticketStatus->id, $ticket->fresh()->ticketStatus?->id);
}
/**
* Ensure tickets expose incoming and outgoing link relations.
*/
@@ -7,6 +7,7 @@ use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
@@ -33,10 +34,14 @@ class ProjectBacklogControllerTest extends TestCase
]);
$priority = $project->ticketPriorities()->firstOrFail();
$backlogStatus = $project->ticketStatuses()
->whereNull('board_column_id')
->firstOrFail();
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_key' => 'AUTH-1',
'sequence_number' => 1,
@@ -50,7 +55,7 @@ class ProjectBacklogControllerTest extends TestCase
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Backlog')
->where('boardColumns.0.name', 'Todo')
->where('backlogTicketStatuses.0.name', 'Backlog')
->where('canEditTickets', false)
->where('project.id', $project->id)
->where('tickets.0.ticket_key', $ticket->ticket_key)
@@ -69,6 +74,9 @@ class ProjectBacklogControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$backlogStatus = $project->ticketStatuses()
->whereNull('board_column_id')
->firstOrFail();
$this->syncProjectPermissions($project, $user, ['view']);
@@ -79,6 +87,7 @@ class ProjectBacklogControllerTest extends TestCase
'acceptance_criteria' => 'Criteria',
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
])
->assertForbidden();
}
@@ -100,6 +109,9 @@ class ProjectBacklogControllerTest extends TestCase
'name' => 'High',
'position' => 2,
]);
$backlogStatus = $project->ticketStatuses()
->whereNull('board_column_id')
->firstOrFail();
$this->syncProjectPermissions($project, $user, ['create', 'view']);
@@ -111,6 +123,7 @@ class ProjectBacklogControllerTest extends TestCase
'story_points' => 3,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
];
$this->actingAs($user)
@@ -129,8 +142,8 @@ class ProjectBacklogControllerTest extends TestCase
'title' => 'Same title',
'assignee_user_id' => $assignee->id,
'author_user_id' => $user->id,
'board_column_id' => null,
'story_points' => 3,
'ticket_status_id' => $backlogStatus->id,
]);
$this->assertDatabaseHas('tickets', [
@@ -139,8 +152,8 @@ class ProjectBacklogControllerTest extends TestCase
'title' => 'Same title',
'assignee_user_id' => $assignee->id,
'author_user_id' => $user->id,
'board_column_id' => null,
'story_points' => 3,
'ticket_status_id' => $backlogStatus->id,
]);
$this->assertDatabaseCount('ticket_worklog_entries', 2);
@@ -149,4 +162,44 @@ class ProjectBacklogControllerTest extends TestCase
'summary' => 'Ticket was created in Backlog.',
]);
}
/**
* Ensure ticket creation only accepts backlog statuses.
*/
public function test_creating_tickets_rejects_board_column_statuses(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Todo',
'position' => 1,
]);
$boardStatus = TicketStatus::factory()
->for($project)
->for($boardColumn)
->create([
'name' => 'Todo',
'position' => 1,
]);
$this->syncProjectPermissions($project, $user, ['create', 'view']);
$this->actingAs($user)
->from(route('projects.backlog.index', $project))
->post(route('projects.backlog.store', $project), [
'title' => 'New ticket',
'description' => 'Description',
'acceptance_criteria' => 'Criteria',
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
'ticket_status_id' => $boardStatus->id,
])
->assertRedirect(route('projects.backlog.index', $project))
->assertSessionHasErrors('ticket_status_id');
$this->assertDatabaseCount('tickets', 0);
}
}
@@ -7,6 +7,7 @@ use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
@@ -36,6 +37,9 @@ class ProjectBoardControllerTest extends TestCase
'name' => 'Doing',
'position' => 2,
]);
$this->createColumnStatus($todoColumn);
$doingStatus = $this->createColumnStatus($doingColumn);
$backlogStatus = $this->backlogStatus($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
@@ -43,21 +47,21 @@ class ProjectBoardControllerTest extends TestCase
]);
Ticket::factory()->create([
'board_column_id' => null,
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'title' => 'Backlog ticket',
]);
$boardTicket = Ticket::factory()->create([
'board_column_id' => $doingColumn->id,
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $doingStatus->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'title' => 'Board ticket',
]);
@@ -96,6 +100,8 @@ class ProjectBoardControllerTest extends TestCase
'name' => 'Todo',
'position' => 1,
]);
$todoStatus = $this->createColumnStatus($todoColumn);
$backlogStatus = $this->backlogStatus($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
@@ -103,28 +109,29 @@ class ProjectBoardControllerTest extends TestCase
]);
$ticket = Ticket::factory()->create([
'board_column_id' => null,
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'title' => 'Backlog ticket',
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->allowMove($backlogStatus, $todoStatus);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'board_column_id' => $todoColumn->id,
'redirect_to' => 'backlog',
'ticket_status_id' => $todoStatus->id,
])
->assertRedirect(route('projects.backlog.index', $project))
->assertSessionHas('status', 'CORE-1 moved to Todo.');
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'board_column_id' => $todoColumn->id,
'ticket_status_id' => $todoStatus->id,
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
@@ -155,6 +162,8 @@ class ProjectBoardControllerTest extends TestCase
'name' => 'Done',
'position' => 2,
]);
$todoStatus = $this->createColumnStatus($todoColumn);
$doneStatus = $this->createColumnStatus($doneColumn);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
@@ -162,28 +171,29 @@ class ProjectBoardControllerTest extends TestCase
]);
$ticket = Ticket::factory()->create([
'board_column_id' => $todoColumn->id,
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $todoStatus->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'title' => 'Board ticket',
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->allowMove($todoStatus, $doneStatus);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'board_column_id' => $doneColumn->id,
'redirect_to' => 'board',
'ticket_status_id' => $doneStatus->id,
])
->assertRedirect(route('projects.board.index', $project))
->assertSessionHas('status', 'CORE-1 moved to Done.');
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'board_column_id' => $doneColumn->id,
'ticket_status_id' => $doneStatus->id,
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
@@ -210,6 +220,8 @@ class ProjectBoardControllerTest extends TestCase
'name' => 'Doing',
'position' => 1,
]);
$doingStatus = $this->createColumnStatus($doingColumn);
$backlogStatus = $this->backlogStatus($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
@@ -217,28 +229,29 @@ class ProjectBoardControllerTest extends TestCase
]);
$ticket = Ticket::factory()->create([
'board_column_id' => $doingColumn->id,
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $doingStatus->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'title' => 'Board ticket',
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->allowMove($doingStatus, $backlogStatus);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'board_column_id' => null,
'redirect_to' => 'board',
'ticket_status_id' => $backlogStatus->id,
])
->assertRedirect(route('projects.board.index', $project))
->assertSessionHas('status', 'CORE-1 moved to Backlog.');
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'board_column_id' => null,
'ticket_status_id' => $backlogStatus->id,
]);
$this->actingAs($user)
@@ -266,6 +279,8 @@ class ProjectBoardControllerTest extends TestCase
'name' => 'Foreign',
'position' => 1,
]);
$otherProjectStatus = $this->createColumnStatus($otherProjectColumn);
$backlogStatus = $this->backlogStatus($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
@@ -273,9 +288,9 @@ class ProjectBoardControllerTest extends TestCase
]);
$ticket = Ticket::factory()->create([
'board_column_id' => null,
'project_id' => $project->id,
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]);
@@ -284,17 +299,213 @@ class ProjectBoardControllerTest extends TestCase
$this->actingAs($user)
->from(route('projects.backlog.index', $project))
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'board_column_id' => $otherProjectColumn->id,
'redirect_to' => 'backlog',
'ticket_status_id' => $otherProjectStatus->id,
])
->assertRedirect(route('projects.backlog.index', $project))
->assertSessionHasErrors('board_column_id');
->assertSessionHasErrors('ticket_status_id');
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'board_column_id' => null,
'ticket_status_id' => $backlogStatus->id,
]);
$this->assertDatabaseCount('ticket_worklog_entries', 0);
}
/**
* Ensure the status transition relation controls valid ticket moves.
*/
public function test_status_moves_without_an_allowed_transition_are_rejected(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$todoColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Todo',
'position' => 1,
]);
$todoStatus = $this->createColumnStatus($todoColumn);
$backlogStatus = $this->backlogStatus($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
'position' => 2,
]);
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'title' => 'Backlog ticket',
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->from(route('projects.backlog.index', $project))
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'redirect_to' => 'backlog',
'ticket_status_id' => $todoStatus->id,
])
->assertRedirect(route('projects.backlog.index', $project))
->assertSessionHasErrors('ticket_status_id');
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'ticket_status_id' => $backlogStatus->id,
]);
$this->assertDatabaseCount('ticket_worklog_entries', 0);
}
/**
* Ensure configured status moves can model a restricted workflow.
*/
public function test_users_with_edit_permission_can_follow_configured_status_transitions(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$progressColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Progress',
'position' => 1,
]);
$reviewColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Review',
'position' => 2,
]);
$doneColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Done',
'position' => 3,
]);
$progressStatus = $this->createColumnStatus($progressColumn);
$reviewStatus = $this->createColumnStatus($reviewColumn);
$doneStatus = $this->createColumnStatus($doneColumn);
$this->allowMove($progressStatus, $reviewStatus);
$this->allowMove($reviewStatus, $doneStatus);
$this->allowMove($reviewStatus, $progressStatus);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
'position' => 2,
]);
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $progressStatus->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'title' => 'Board ticket',
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'redirect_to' => 'board',
'ticket_status_id' => $reviewStatus->id,
])
->assertRedirect(route('projects.board.index', $project))
->assertSessionHas('status', 'CORE-1 moved to Review.');
$ticket->refresh();
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'redirect_to' => 'board',
'ticket_status_id' => $progressStatus->id,
])
->assertRedirect(route('projects.board.index', $project))
->assertSessionHas('status', 'CORE-1 moved to Progress.');
$ticket->refresh();
$this->actingAs($user)
->from(route('projects.board.index', $project))
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'redirect_to' => 'board',
'ticket_status_id' => $doneStatus->id,
])
->assertRedirect(route('projects.board.index', $project))
->assertSessionHasErrors('ticket_status_id');
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'ticket_status_id' => $progressStatus->id,
]);
$this->assertDatabaseCount('ticket_worklog_entries', 2);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'redirect_to' => 'board',
'ticket_status_id' => $reviewStatus->id,
])
->assertRedirect(route('projects.board.index', $project));
$ticket->refresh();
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'redirect_to' => 'board',
'ticket_status_id' => $doneStatus->id,
])
->assertRedirect(route('projects.board.index', $project))
->assertSessionHas('status', 'CORE-1 moved to Done.');
$ticket->refresh();
$this->actingAs($user)
->from(route('projects.board.index', $project))
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'redirect_to' => 'board',
'ticket_status_id' => $reviewStatus->id,
])
->assertRedirect(route('projects.board.index', $project))
->assertSessionHasErrors('ticket_status_id');
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'ticket_status_id' => $doneStatus->id,
]);
$this->assertDatabaseCount('ticket_worklog_entries', 4);
}
private function backlogStatus(Project $project): TicketStatus
{
return $project->ticketStatuses()
->whereNull('board_column_id')
->firstOrFail();
}
private function createColumnStatus(BoardColumn $boardColumn): TicketStatus
{
return TicketStatus::factory()
->for($boardColumn->project)
->for($boardColumn)
->create([
'name' => $boardColumn->name,
'position' => 1,
]);
}
private function allowMove(TicketStatus $sourceStatus, TicketStatus $targetStatus): void
{
$sourceStatus->allowedTargetStatuses()->syncWithoutDetaching([$targetStatus->id]);
}
}
@@ -2,8 +2,11 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
@@ -22,6 +25,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
BoardColumn::factory()->for($project)->create([
'name' => 'Todo',
'position' => 1,
@@ -35,7 +39,9 @@ class ProjectBoardSettingsControllerTest extends TestCase
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/Board')
->where('project.id', $project->id)
->where('boardColumns.0.name', 'Todo'),
->where('backlogTicketStatuses.0.name', 'Backlog')
->where('boardColumns.0.name', 'Todo')
->where('ticketStatuses.0.name', 'Backlog'),
);
}
@@ -76,6 +82,13 @@ class ProjectBoardSettingsControllerTest extends TestCase
'name' => 'In Review',
'position' => 1,
]);
$this->assertDatabaseHas('ticket_statuses', [
'project_id' => $project->id,
'name' => 'In Review',
'slug' => 'in-review',
'position' => 1,
]);
}
/**
@@ -154,4 +167,191 @@ class ProjectBoardSettingsControllerTest extends TestCase
'id' => $secondColumn->id,
]);
}
/**
* Ensure authorized users can manage multiple statuses for backlog and columns.
*/
public function test_users_with_edit_permission_can_manage_ticket_statuses(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
$this->syncProjectPermissions($project, $user, ['edit']);
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Todo',
'position' => 1,
]);
$defaultStatus = TicketStatus::factory()
->for($project)
->for($boardColumn)
->create([
'name' => 'Todo',
'position' => 1,
]);
$this->actingAs($user)
->post(route('projects.settings.board.statuses.store', $project), [
'board_column_id' => $boardColumn->id,
'name' => 'Ready for QA',
])
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHas('status', 'Ready for QA was added to Todo.');
$newStatus = TicketStatus::query()
->where('project_id', $project->id)
->where('name', 'Ready for QA')
->firstOrFail();
$this->assertSame($boardColumn->id, $newStatus->board_column_id);
$this->assertSame('ready-for-qa', $newStatus->slug);
$this->assertFalse($defaultStatus->allowedTargetStatuses()->whereKey($newStatus->id)->exists());
$this->actingAs($user)
->patch(route('projects.settings.board.statuses.update', [$project, $newStatus]), [
'name' => 'QA Ready',
])
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHas('status', 'QA Ready was updated.');
$newStatus->refresh();
$this->assertSame('QA Ready', $newStatus->name);
$this->assertSame('qa-ready', $newStatus->slug);
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'ticket_status_id' => $newStatus->id,
]);
$this->actingAs($user)
->delete(route('projects.settings.board.statuses.destroy', [$project, $newStatus]))
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHas('status', 'QA Ready was removed from Todo.');
$this->assertDatabaseMissing('ticket_statuses', [
'id' => $newStatus->id,
]);
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'ticket_status_id' => $defaultStatus->id,
]);
}
/**
* Ensure authorized users can configure the allowed move targets for a status.
*/
public function test_users_with_edit_permission_can_update_ticket_status_transitions(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
$this->syncProjectPermissions($project, $user, ['edit']);
$sourceStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Progress',
'position' => 1,
]);
$reviewStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Review',
'position' => 2,
]);
$doneStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Done',
'position' => 3,
]);
$this->actingAs($user)
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
'allowed_ticket_status_ids' => [$reviewStatus->id, $doneStatus->id],
])
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHas('status', 'Progress move targets were updated.');
$this->assertEqualsCanonicalizing(
[$reviewStatus->id, $doneStatus->id],
$sourceStatus->allowedTargetStatuses()->get()->pluck('id')->all(),
);
$this->actingAs($user)
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [])
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHas('status', 'Progress move targets were updated.');
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
}
/**
* Ensure transition updates only accept targets from the same project and never the source status itself.
*/
public function test_ticket_status_transition_updates_reject_foreign_and_self_targets(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
$otherProject = Project::factory()->create([
'key' => 'EXT',
]);
$this->syncProjectPermissions($project, $user, ['edit']);
$sourceStatus = TicketStatus::factory()->for($project)->create([
'name' => 'Progress',
'position' => 1,
]);
$foreignStatus = TicketStatus::factory()->for($otherProject)->create([
'name' => 'External review',
'position' => 1,
]);
$this->actingAs($user)
->from(route('projects.settings.board', $project))
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
'allowed_ticket_status_ids' => [$foreignStatus->id],
])
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHasErrors('allowed_ticket_status_ids.0');
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
$this->actingAs($user)
->from(route('projects.settings.board', $project))
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
'allowed_ticket_status_ids' => [$sourceStatus->id],
])
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHasErrors('allowed_ticket_status_ids');
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
}
/**
* Ensure the last status in a column or backlog cannot be deleted.
*/
public function test_last_ticket_status_for_a_context_cannot_be_deleted(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['edit']);
$backlogStatus = TicketStatus::factory()
->for($project)
->create([
'name' => 'Backlog',
'position' => 1,
]);
$this->actingAs($user)
->from(route('projects.settings.board', $project))
->delete(route('projects.settings.board.statuses.destroy', [$project, $backlogStatus]))
->assertRedirect(route('projects.settings.board', $project))
->assertSessionHasErrors('ticket_status_id');
$this->assertDatabaseHas('ticket_statuses', [
'id' => $backlogStatus->id,
]);
}
}
@@ -7,6 +7,7 @@ use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
@@ -346,12 +347,19 @@ class ProjectControllerTest extends TestCase
'name' => 'In Progress',
'position' => 1,
]);
$ticketStatus = TicketStatus::factory()
->for($project)
->for($boardColumn)
->create([
'name' => 'In Progress',
'position' => 1,
]);
$ticket = Ticket::factory()->create([
'board_column_id' => $boardColumn->id,
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $ticketStatus->id,
'ticket_type_id' => $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')