ticket-rework-status #29
@@ -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.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[mcp_servers.laravel-boost]
|
||||
command = "php"
|
||||
args = ["artisan", "boost:mcp"]
|
||||
cwd = "/Users/nico/dev/openSource/KanbanGit"
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -8831,5 +8831,5 @@
|
||||
"php": "^8.4"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class DatabaseSeeder extends Seeder
|
||||
RepositoryConnectionSeeder::class,
|
||||
BoardColumnSeeder::class,
|
||||
ProjectTicketMetadataSeeder::class,
|
||||
TicketStatusSeeder::class,
|
||||
TicketSeeder::class,
|
||||
TicketFieldValueSeeder::class,
|
||||
TicketLinkTypeSeeder::class,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user