Merge pull request 'ticket-rework-status' (#29) from ticket-rework-status into main
Reviewed-on: OpenSource/KanbanGit#29
This commit was merged in pull request #29.
This commit is contained in:
@@ -52,4 +52,6 @@ Activate this skill whenever work in this project includes:
|
|||||||
|
|
||||||
- Follow the project's existing PHPUnit conventions.
|
- Follow the project's existing PHPUnit conventions.
|
||||||
- Prefer the smallest relevant test run that still proves the change.
|
- 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.
|
- 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
|
/.nova
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
|
.codex/config.toml
|
||||||
|
|||||||
@@ -27,5 +27,12 @@ class InitializeProjectTicketMetadata
|
|||||||
'position' => 1,
|
'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\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -21,6 +22,7 @@ class CreateProjectTicket
|
|||||||
* description: string|null,
|
* description: string|null,
|
||||||
* story_points?: int|null,
|
* story_points?: int|null,
|
||||||
* ticket_priority_id: int,
|
* ticket_priority_id: int,
|
||||||
|
* ticket_status_id: int,
|
||||||
* ticket_type_id: int,
|
* ticket_type_id: int,
|
||||||
* title: string
|
* title: string
|
||||||
* } $attributes
|
* } $attributes
|
||||||
@@ -31,17 +33,20 @@ class CreateProjectTicket
|
|||||||
$nextSequenceNumber = (int) Ticket::query()
|
$nextSequenceNumber = (int) Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->max('sequence_number') + 1;
|
->max('sequence_number') + 1;
|
||||||
|
$ticketStatus = TicketStatus::query()
|
||||||
|
->where('project_id', $project->id)
|
||||||
|
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||||
|
|
||||||
$ticket = Ticket::query()->create([
|
$ticket = Ticket::query()->create([
|
||||||
'acceptance_criteria' => Arr::get($attributes, 'acceptance_criteria'),
|
'acceptance_criteria' => Arr::get($attributes, 'acceptance_criteria'),
|
||||||
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
||||||
'author_user_id' => $actor?->id,
|
'author_user_id' => $actor?->id,
|
||||||
'board_column_id' => null,
|
|
||||||
'description' => Arr::get($attributes, 'description'),
|
'description' => Arr::get($attributes, 'description'),
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => $nextSequenceNumber,
|
'sequence_number' => $nextSequenceNumber,
|
||||||
'ticket_key' => "{$project->key}-{$nextSequenceNumber}",
|
'ticket_key' => "{$project->key}-{$nextSequenceNumber}",
|
||||||
'ticket_priority_id' => Arr::get($attributes, 'ticket_priority_id'),
|
'ticket_priority_id' => Arr::get($attributes, 'ticket_priority_id'),
|
||||||
|
'ticket_status_id' => $ticketStatus->id,
|
||||||
'ticket_type_id' => Arr::get($attributes, 'ticket_type_id'),
|
'ticket_type_id' => Arr::get($attributes, 'ticket_type_id'),
|
||||||
'title' => Arr::get($attributes, 'title'),
|
'title' => Arr::get($attributes, 'title'),
|
||||||
'story_points' => Arr::get($attributes, 'story_points'),
|
'story_points' => Arr::get($attributes, 'story_points'),
|
||||||
@@ -52,11 +57,12 @@ class CreateProjectTicket
|
|||||||
$ticket,
|
$ticket,
|
||||||
$actor,
|
$actor,
|
||||||
'created',
|
'created',
|
||||||
'Ticket was created in Backlog.',
|
"Ticket was created in {$ticketStatus->name}.",
|
||||||
[
|
[
|
||||||
'from' => null,
|
'from' => null,
|
||||||
'to' => [
|
'to' => [
|
||||||
'label' => 'Backlog',
|
'label' => $ticketStatus->name,
|
||||||
|
'ticket_status_id' => $ticketStatus->id,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,17 +31,27 @@ class ProjectBacklogController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Inertia::render('projects/Backlog', [
|
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),
|
'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||||
'canCreateTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
'canCreateTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||||
'project' => $project,
|
'project' => $project,
|
||||||
'status' => session('status'),
|
'status' => session('status'),
|
||||||
|
'ticketStatusOptions' => $project->ticketStatuses()
|
||||||
|
->with('boardColumn')
|
||||||
|
->get(),
|
||||||
'ticketPriorities' => $project->ticketPriorities()->get(),
|
'ticketPriorities' => $project->ticketPriorities()->get(),
|
||||||
'ticketTypes' => $project->ticketTypes()->get(),
|
'ticketTypes' => $project->ticketTypes()->get(),
|
||||||
'tickets' => Ticket::query()
|
'tickets' => Ticket::query()
|
||||||
->with(['ticketPriority', 'ticketType'])
|
->with([
|
||||||
|
'ticketPriority',
|
||||||
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
|
'ticketStatus.boardColumn',
|
||||||
|
'ticketType',
|
||||||
|
])
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->whereNull('board_column_id')
|
->whereHas('ticketStatus', fn ($query) => $query->whereNull('board_column_id'))
|
||||||
->orderByDesc('sequence_number')
|
->orderByDesc('sequence_number')
|
||||||
->get(),
|
->get(),
|
||||||
'users' => User::query()
|
'users' => User::query()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Enums\Permission;
|
|||||||
use App\Http\Requests\MoveProjectTicketRequest;
|
use App\Http\Requests\MoveProjectTicketRequest;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -33,8 +34,14 @@ class ProjectBoardController extends Controller
|
|||||||
return Inertia::render('projects/Board', [
|
return Inertia::render('projects/Board', [
|
||||||
'boardColumns' => $project->boardColumns()
|
'boardColumns' => $project->boardColumns()
|
||||||
->with([
|
->with([
|
||||||
|
'ticketStatuses',
|
||||||
'tickets' => fn ($query) => $query
|
'tickets' => fn ($query) => $query
|
||||||
->with(['ticketPriority', 'ticketType']),
|
->with([
|
||||||
|
'ticketPriority',
|
||||||
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
|
'ticketStatus.boardColumn',
|
||||||
|
'ticketType',
|
||||||
|
]),
|
||||||
])
|
])
|
||||||
->get(),
|
->get(),
|
||||||
'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||||
@@ -56,25 +63,28 @@ class ProjectBoardController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
$validated = $request->validated();
|
$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'
|
$redirectRoute = Arr::get($validated, 'redirect_to') === 'backlog'
|
||||||
? 'projects.backlog.index'
|
? 'projects.backlog.index'
|
||||||
: 'projects.board.index';
|
: 'projects.board.index';
|
||||||
|
|
||||||
$destinationColumn = $project->boardColumns()
|
$destinationStatus = $project->ticketStatuses()
|
||||||
->find($boardColumnId);
|
->with('boardColumn')
|
||||||
|
->findOrFail($ticketStatusId);
|
||||||
|
|
||||||
$originBoardColumnId = $ticket->board_column_id;
|
$ticket->loadMissing('ticketStatus.boardColumn');
|
||||||
$originLabel = $ticket->boardColumn?->name ?? 'Backlog';
|
|
||||||
$destinationLabel = $destinationColumn?->name ?? 'Backlog';
|
|
||||||
|
|
||||||
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)
|
return to_route($redirectRoute, $project)
|
||||||
->with('status', "{$ticket->ticket_key} already belongs to {$destinationLabel}.");
|
->with('status', "{$ticket->ticket_key} already belongs to {$destinationLabel}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticket->update([
|
$ticket->update([
|
||||||
'board_column_id' => $destinationColumn?->id,
|
'ticket_status_id' => $destinationStatus->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
@@ -84,12 +94,12 @@ class ProjectBoardController extends Controller
|
|||||||
"Moved from {$originLabel} to {$destinationLabel}.",
|
"Moved from {$originLabel} to {$destinationLabel}.",
|
||||||
[
|
[
|
||||||
'from' => [
|
'from' => [
|
||||||
'board_column_id' => $originBoardColumnId,
|
|
||||||
'label' => $originLabel,
|
'label' => $originLabel,
|
||||||
|
'ticket_status_id' => $originStatusId,
|
||||||
],
|
],
|
||||||
'to' => [
|
'to' => [
|
||||||
'board_column_id' => $destinationColumn?->id,
|
|
||||||
'label' => $destinationLabel,
|
'label' => $destinationLabel,
|
||||||
|
'ticket_status_id' => $destinationStatus->id,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -97,4 +107,21 @@ class ProjectBoardController extends Controller
|
|||||||
return to_route($redirectRoute, $project)
|
return to_route($redirectRoute, $project)
|
||||||
->with('status', "{$ticket->ticket_key} moved to {$destinationLabel}.");
|
->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\Actions\Permissions\PermissionService;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Requests\StoreBoardColumnRequest;
|
use App\Http\Requests\StoreBoardColumnRequest;
|
||||||
|
use App\Http\Requests\StoreTicketStatusRequest;
|
||||||
use App\Http\Requests\UpdateBoardColumnRequest;
|
use App\Http\Requests\UpdateBoardColumnRequest;
|
||||||
|
use App\Http\Requests\UpdateTicketStatusRequest;
|
||||||
|
use App\Http\Requests\UpdateTicketStatusTransitionsRequest;
|
||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -30,9 +35,17 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Inertia::render('projects/settings/Board', [
|
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,
|
'project' => $project,
|
||||||
'status' => session('status'),
|
'status' => session('status'),
|
||||||
|
'ticketStatuses' => $project->ticketStatuses()
|
||||||
|
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
|
||||||
|
->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +66,11 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
'name' => Arr::get($validated, 'name'),
|
'name' => Arr::get($validated, 'name'),
|
||||||
'position' => $project->boardColumns()->count() + 1,
|
'position' => $project->boardColumns()->count() + 1,
|
||||||
]);
|
]);
|
||||||
|
$boardColumn->ticketStatuses()->create([
|
||||||
|
'name' => Arr::get($validated, 'name'),
|
||||||
|
'position' => 1,
|
||||||
|
'project_id' => $project->id,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->reorderColumns($project, $boardColumn, $desiredPosition);
|
$this->reorderColumns($project, $boardColumn, $desiredPosition);
|
||||||
|
|
||||||
@@ -99,6 +117,15 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
$deletedName = $boardColumn->name;
|
$deletedName = $boardColumn->name;
|
||||||
|
|
||||||
DB::transaction(function () use ($boardColumn, $project): void {
|
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();
|
$boardColumn->delete();
|
||||||
|
|
||||||
$this->normalizeColumnPositions($project);
|
$this->normalizeColumnPositions($project);
|
||||||
@@ -108,6 +135,114 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
->with('status', "{$deletedName} was removed from the board workflow.");
|
->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
|
private function normalizeColumnPositions(Project $project): void
|
||||||
{
|
{
|
||||||
$this->persistOrderedColumns(
|
$this->persistOrderedColumns(
|
||||||
@@ -151,4 +286,36 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
])->saveQuietly();
|
])->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',
|
'fieldValues.userValue',
|
||||||
'parentTicket',
|
'parentTicket',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
|
'ticketStatus.boardColumn',
|
||||||
'ticketType',
|
'ticketType',
|
||||||
'worklogEntries.actor',
|
'worklogEntries.actor',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
class MoveProjectTicketRequest extends FormRequest
|
class MoveProjectTicketRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -28,12 +31,48 @@ class MoveProjectTicketRequest extends FormRequest
|
|||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'board_column_id' => [
|
'ticket_status_id' => [
|
||||||
'nullable',
|
'required',
|
||||||
'integer',
|
'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'])],
|
'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',
|
'integer',
|
||||||
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
|
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' => [
|
'ticket_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'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\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id'])]
|
#[Fillable(['name', 'position', 'project_id'])]
|
||||||
class BoardColumn extends Model
|
class BoardColumn extends Model
|
||||||
@@ -20,9 +21,19 @@ class BoardColumn extends Model
|
|||||||
return $this->belongsTo(Project::class);
|
return $this->belongsTo(Project::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function tickets(): HasMany
|
public function ticketStatuses(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Ticket::class)
|
return $this->hasMany(TicketStatus::class)
|
||||||
->orderByDesc('sequence_number');
|
->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');
|
->orderBy('position');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function ticketStatuses(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketStatus::class)
|
||||||
|
->orderBy('board_column_id')
|
||||||
|
->orderBy('position');
|
||||||
|
}
|
||||||
|
|
||||||
public function ticketTypes(): HasMany
|
public function ticketTypes(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TicketType::class)
|
return $this->hasMany(TicketType::class)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'acceptance_criteria',
|
'acceptance_criteria',
|
||||||
'assignee_user_id',
|
'assignee_user_id',
|
||||||
'author_user_id',
|
'author_user_id',
|
||||||
'board_column_id',
|
|
||||||
'blocked_comment',
|
'blocked_comment',
|
||||||
'description',
|
'description',
|
||||||
'is_blocked',
|
'is_blocked',
|
||||||
@@ -22,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'sequence_number',
|
'sequence_number',
|
||||||
'ticket_key',
|
'ticket_key',
|
||||||
'ticket_priority_id',
|
'ticket_priority_id',
|
||||||
|
'ticket_status_id',
|
||||||
'ticket_type_id',
|
'ticket_type_id',
|
||||||
'title',
|
'title',
|
||||||
'story_points',
|
'story_points',
|
||||||
@@ -41,11 +41,6 @@ class Ticket extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boardColumn(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(BoardColumn::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function assignee(): BelongsTo
|
public function assignee(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'assignee_user_id');
|
return $this->belongsTo(User::class, 'assignee_user_id');
|
||||||
@@ -95,6 +90,11 @@ class Ticket extends Model
|
|||||||
return $this->belongsTo(TicketPriority::class);
|
return $this->belongsTo(TicketPriority::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function ticketStatus(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TicketStatus::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function ticketType(): BelongsTo
|
public function ticketType(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(TicketType::class);
|
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"
|
"php": "^8.4"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"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\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
@@ -24,7 +25,6 @@ class TicketFactory extends Factory
|
|||||||
'acceptance_criteria' => fake()->paragraph(),
|
'acceptance_criteria' => fake()->paragraph(),
|
||||||
'assignee_user_id' => null,
|
'assignee_user_id' => null,
|
||||||
'author_user_id' => User::factory(),
|
'author_user_id' => User::factory(),
|
||||||
'board_column_id' => null,
|
|
||||||
'blocked_comment' => null,
|
'blocked_comment' => null,
|
||||||
'description' => fake()->paragraphs(2, true),
|
'description' => fake()->paragraphs(2, true),
|
||||||
'is_blocked' => false,
|
'is_blocked' => false,
|
||||||
@@ -33,27 +33,49 @@ class TicketFactory extends Factory
|
|||||||
'story_points' => null,
|
'story_points' => null,
|
||||||
'ticket_key' => 'TEST-1',
|
'ticket_key' => 'TEST-1',
|
||||||
'title' => fake()->sentence(4),
|
'title' => fake()->sentence(4),
|
||||||
|
'ticket_status_id' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configure(): static
|
public function configure(): static
|
||||||
{
|
{
|
||||||
return $this->afterMaking(function (Ticket $ticket): void {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$project = Project::factory()->create([
|
$createdProject = $ticket->project_id === null;
|
||||||
'key' => fake()->unique()->lexify('????'),
|
$project = $createdProject
|
||||||
]);
|
? Project::factory()->create([
|
||||||
|
'key' => fake()->unique()->lexify('????'),
|
||||||
|
])
|
||||||
|
: Project::query()->findOrFail($ticket->project_id);
|
||||||
|
|
||||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
$ticket->project_id = $project->id;
|
$ticket->project_id = $project->id;
|
||||||
$ticket->ticket_type_id = $project->ticketTypes()->firstOrFail()->id;
|
$ticket->ticket_type_id ??= $project->ticketTypes()->firstOrFail()->id;
|
||||||
$ticket->ticket_priority_id = $project->ticketPriorities()->firstOrFail()->id;
|
$ticket->ticket_priority_id ??= $project->ticketPriorities()->firstOrFail()->id;
|
||||||
$ticket->sequence_number = 1;
|
$ticket->ticket_status_id ??= $project->ticketStatuses()
|
||||||
$ticket->ticket_key = "{$project->key}-1";
|
->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('ticket_priority_id')->constrained()->cascadeOnDelete();
|
||||||
$table->foreignId('assignee_user_id')->nullable()->constrained('users')->nullOnDelete();
|
$table->foreignId('assignee_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
$table->foreignId('author_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->unsignedInteger('sequence_number');
|
||||||
$table->string('ticket_key')->unique();
|
$table->string('ticket_key')->unique();
|
||||||
$table->string('title');
|
$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],
|
['name' => 'Done', 'position' => 3],
|
||||||
)
|
)
|
||||||
->for($project)
|
->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,
|
RepositoryConnectionSeeder::class,
|
||||||
BoardColumnSeeder::class,
|
BoardColumnSeeder::class,
|
||||||
ProjectTicketMetadataSeeder::class,
|
ProjectTicketMetadataSeeder::class,
|
||||||
|
TicketStatusSeeder::class,
|
||||||
TicketSeeder::class,
|
TicketSeeder::class,
|
||||||
TicketFieldValueSeeder::class,
|
TicketFieldValueSeeder::class,
|
||||||
TicketLinkTypeSeeder::class,
|
TicketLinkTypeSeeder::class,
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ class TicketSeeder extends Seeder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$defaultType = $project->ticketTypes()->firstOrFail();
|
$defaultType = $project->ticketTypes()->firstOrFail();
|
||||||
|
$defaultStatus = $project->ticketStatuses()
|
||||||
|
->whereNull('board_column_id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
$createProjectTicket->handle($project, [
|
$createProjectTicket->handle($project, [
|
||||||
'acceptance_criteria' => 'The seeded ticket should be visible in the backlog list.',
|
'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.',
|
'description' => 'Seeded backlog item for demonstrating the first ticket workflow.',
|
||||||
'story_points' => 3,
|
'story_points' => 3,
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $defaultStatus->id,
|
||||||
'ticket_type_id' => $defaultType->id,
|
'ticket_type_id' => $defaultType->id,
|
||||||
'title' => 'Seed backlog item',
|
'title' => 'Seed backlog item',
|
||||||
], $author);
|
], $author);
|
||||||
@@ -54,6 +58,7 @@ class TicketSeeder extends Seeder
|
|||||||
'description' => 'Second seeded backlog item using the same title on purpose.',
|
'description' => 'Second seeded backlog item using the same title on purpose.',
|
||||||
'story_points' => 5,
|
'story_points' => 5,
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $defaultStatus->id,
|
||||||
'ticket_type_id' => $defaultType->id,
|
'ticket_type_id' => $defaultType->id,
|
||||||
'title' => 'Seed backlog item',
|
'title' => 'Seed backlog item',
|
||||||
], $author);
|
], $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
|
### Ticket
|
||||||
|
|
||||||
- general:
|
#### general
|
||||||
- (mehr über middlewares steuern)
|
- (mehr über middlewares steuern)
|
||||||
- why uses everywhere forceFill?
|
- why uses everywhere forceFill?
|
||||||
- app/Http/Requests/UpdateProjectTicketRequest.php and other ProjectTicket only to Ticket...
|
- app/Http/Requests/UpdateProjectTicketRequest.php and other ProjectTicket only to Ticket...
|
||||||
- mehrere actions/quereys in services auslagern
|
- 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
|
- 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
|
- change Project Backlog Controller → Project Controller
|
||||||
- remove store from project board controller and route projects/{project}/backlog, change to ticket controller, create
|
- remove store from project board controller and route projects/{project}/backlog, change to ticket controller, create
|
||||||
- 09
|
- 08
|
||||||
- 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
|
|
||||||
- worklog überarbeiten → type als worklog enum → json je nach enum type
|
- worklog überarbeiten → type als worklog enum → json je nach enum type
|
||||||
- UpdateProjectTicket aufräumen
|
- 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 { show as showTicket } from '@/routes/projects/tickets';
|
||||||
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
||||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
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>();
|
const props = defineProps<ProjectBacklogPageProps>();
|
||||||
|
|
||||||
@@ -41,7 +43,34 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const hasTicketMetadata =
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -85,7 +114,7 @@ const hasTicketMetadata =
|
|||||||
<CardTitle>Create ticket</CardTitle>
|
<CardTitle>Create ticket</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
New tickets receive the next immutable project key and
|
New tickets receive the next immutable project key and
|
||||||
automatically enter backlog.
|
enter the selected backlog status.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -182,6 +211,28 @@ const hasTicketMetadata =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="grid gap-2">
|
||||||
<Label for="assignee_user_id">
|
<Label for="assignee_user_id">
|
||||||
Assignee
|
Assignee
|
||||||
@@ -237,7 +288,7 @@ const hasTicketMetadata =
|
|||||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
Ticket creation needs at least one type and one
|
Ticket creation needs at least one type and one
|
||||||
priority.
|
priority, plus one backlog status.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
@@ -253,8 +304,8 @@ const hasTicketMetadata =
|
|||||||
<CardHeader class="border-b">
|
<CardHeader class="border-b">
|
||||||
<CardTitle>Backlog tickets</CardTitle>
|
<CardTitle>Backlog tickets</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Tickets remain here until they are moved into a board
|
Tickets remain here while their status belongs to
|
||||||
column.
|
backlog.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</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">Key</th>
|
||||||
<th class="px-4 py-3 font-medium">Title</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">Type</th>
|
||||||
|
<th class="px-4 py-3 font-medium">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
<th class="px-4 py-3 font-medium">
|
<th class="px-4 py-3 font-medium">
|
||||||
Priority
|
Priority
|
||||||
</th>
|
</th>
|
||||||
@@ -284,7 +338,7 @@ const hasTicketMetadata =
|
|||||||
v-if="canEditTickets"
|
v-if="canEditTickets"
|
||||||
class="px-4 py-3 font-medium"
|
class="px-4 py-3 font-medium"
|
||||||
>
|
>
|
||||||
Move to board
|
Move to status
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -330,12 +384,18 @@ const hasTicketMetadata =
|
|||||||
<td class="text-muted-foreground px-4 py-4">
|
<td class="text-muted-foreground px-4 py-4">
|
||||||
{{ ticket.ticket_type.name }}
|
{{ ticket.ticket_type.name }}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-muted-foreground px-4 py-4">
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
ticket.ticket_status,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
<td class="text-muted-foreground px-4 py-4">
|
<td class="text-muted-foreground px-4 py-4">
|
||||||
{{ ticket.ticket_priority.name }}
|
{{ ticket.ticket_priority.name }}
|
||||||
</td>
|
</td>
|
||||||
<td v-if="canEditTickets" class="px-4 py-4">
|
<td v-if="canEditTickets" class="px-4 py-4">
|
||||||
<Form
|
<Form
|
||||||
v-if="boardColumns.length > 0"
|
|
||||||
v-bind="
|
v-bind="
|
||||||
ProjectBoardController.move.form(
|
ProjectBoardController.move.form(
|
||||||
{
|
{
|
||||||
@@ -353,32 +413,62 @@ const hasTicketMetadata =
|
|||||||
value="backlog"
|
value="backlog"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
name="board_column_id"
|
name="ticket_status_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]"
|
: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
|
<option
|
||||||
v-for="boardColumn in boardColumns"
|
v-if="
|
||||||
:key="boardColumn.id"
|
allowedTargetStatuses(
|
||||||
:value="boardColumn.id"
|
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>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="processing"
|
:disabled="
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
>
|
>
|
||||||
Move
|
Move
|
||||||
</Button>
|
</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>
|
</Form>
|
||||||
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
Add board columns first.
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { board as boardSettings } from '@/routes/projects/settings';
|
|||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
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>();
|
const props = defineProps<ProjectBoardPageProps>();
|
||||||
|
|
||||||
@@ -36,16 +38,30 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusOptions = [
|
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
||||||
{
|
if (ticketStatus === null) {
|
||||||
label: 'Backlog',
|
return 'Unknown status';
|
||||||
value: '',
|
}
|
||||||
},
|
|
||||||
...props.boardColumns.map((boardColumn) => ({
|
if (
|
||||||
label: boardColumn.name,
|
ticketStatus.board_column === null ||
|
||||||
value: String(boardColumn.id),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -116,6 +132,21 @@ const statusOptions = [
|
|||||||
{{ boardColumn.tickets?.length ?? 0 }}
|
{{ boardColumn.tickets?.length ?? 0 }}
|
||||||
active ticket(s)
|
active ticket(s)
|
||||||
</CardDescription>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
@@ -160,6 +191,13 @@ const statusOptions = [
|
|||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{{ ticket.ticket_priority.name }}
|
{{ ticket.ticket_priority.name }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{
|
||||||
|
ticketStatusLabel(
|
||||||
|
ticket.ticket_status,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle class="text-base">
|
<CardTitle class="text-base">
|
||||||
<Link
|
<Link
|
||||||
@@ -218,28 +256,61 @@ const statusOptions = [
|
|||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
:id="`ticket-status-${ticket.id}`"
|
:id="`ticket-status-${ticket.id}`"
|
||||||
name="board_column_id"
|
name="ticket_status_id"
|
||||||
:value="
|
:disabled="
|
||||||
ticket.board_column_id ?? ''
|
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
|
<option
|
||||||
v-for="statusOption in statusOptions"
|
v-if="
|
||||||
:key="statusOption.label"
|
allowedTargetStatuses(
|
||||||
:value="statusOption.value"
|
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>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="processing"
|
:disabled="
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
>
|
>
|
||||||
Update status
|
Update status
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="
|
||||||
|
allowedTargetStatuses(ticket)
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
class="text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
This status does not allow moving
|
||||||
|
anywhere else.
|
||||||
|
</p>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -354,6 +354,9 @@ const formatDateTime = (value: string): string => {
|
|||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{{ ticket.ticket_priority.name }}
|
{{ ticket.ticket_priority.name }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge v-if="ticket.ticket_status" variant="outline">
|
||||||
|
{{ ticket.ticket_status.name }}
|
||||||
|
</Badge>
|
||||||
<Badge v-if="!canEditTicket" variant="outline">
|
<Badge v-if="!canEditTicket" variant="outline">
|
||||||
Read only
|
Read only
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
repositories as repositorySettings,
|
repositories as repositorySettings,
|
||||||
} from '@/routes/projects/settings';
|
} from '@/routes/projects/settings';
|
||||||
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
const props = defineProps<ProjectBoardSettingsPageProps>();
|
const props = defineProps<ProjectBoardSettingsPageProps>();
|
||||||
|
|
||||||
@@ -39,6 +40,40 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
href: boardSettings(props.project.id),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -93,20 +128,252 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Workflow model</CardTitle>
|
<CardTitle>Workflow model</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Backlog is always the intake queue and stays outside the
|
Backlog is always the intake queue; board columns group
|
||||||
normal board column list.
|
the statuses that appear on the board.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent class="flex flex-wrap gap-3">
|
<CardContent class="flex flex-wrap gap-3">
|
||||||
<Badge class="px-3 py-1">Backlog</Badge>
|
<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">
|
<p class="text-muted-foreground text-sm">
|
||||||
Tickets will later be valid only in backlog or in one of
|
Tickets move between statuses; backlog statuses are not
|
||||||
the ordered columns below.
|
included in a board column.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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)]">
|
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -239,6 +506,140 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ const submitUpdate = (): void => {
|
|||||||
updateForm.submit(
|
updateForm.submit(
|
||||||
ProjectRepositorySettingsController.update([
|
ProjectRepositorySettingsController.update([
|
||||||
props.project.id,
|
props.project.id,
|
||||||
updateForm.repository_connection_id,
|
Number(updateForm.repository_connection_id),
|
||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
export type BoardColumnModel = {
|
export type BoardColumnModel = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
position: number;
|
position: number;
|
||||||
project_id: number;
|
project_id: number;
|
||||||
|
ticket_statuses?: TicketStatusModel[];
|
||||||
tickets?: TicketModel[];
|
tickets?: TicketModel[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TicketFieldValueModel } from '@/types/models/ticket-field-value-model';
|
import type { TicketFieldValueModel } from '@/types/models/ticket-field-value-model';
|
||||||
import type { TicketPriorityModel } from '@/types/models/ticket-priority-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 { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||||
import type { TicketWorklogEntryModel } from '@/types/models/ticket-worklog-entry-model';
|
import type { TicketWorklogEntryModel } from '@/types/models/ticket-worklog-entry-model';
|
||||||
import type { UserModel } from '@/types/models/user-model';
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
@@ -11,7 +12,6 @@ export type TicketModel = {
|
|||||||
assignee_user_id: number | null;
|
assignee_user_id: number | null;
|
||||||
author: UserModel | null;
|
author: UserModel | null;
|
||||||
author_user_id: number | null;
|
author_user_id: number | null;
|
||||||
board_column_id: number | null;
|
|
||||||
blocked_comment: string | null;
|
blocked_comment: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
field_values?: TicketFieldValueModel[];
|
field_values?: TicketFieldValueModel[];
|
||||||
@@ -22,6 +22,8 @@ export type TicketModel = {
|
|||||||
ticket_key: string;
|
ticket_key: string;
|
||||||
ticket_priority: TicketPriorityModel;
|
ticket_priority: TicketPriorityModel;
|
||||||
ticket_priority_id: number;
|
ticket_priority_id: number;
|
||||||
|
ticket_status: TicketStatusModel | null;
|
||||||
|
ticket_status_id: number | null;
|
||||||
ticket_type: TicketTypeModel;
|
ticket_type: TicketTypeModel;
|
||||||
ticket_type_id: number;
|
ticket_type_id: number;
|
||||||
title: string;
|
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 { TicketRepositoryPullRequestModel } from '@/types/models/ticket-repository-pull-request-model';
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
import type { TicketPriorityModel } from '@/types/models/ticket-priority-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 { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||||
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||||
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
|
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
|
||||||
@@ -58,9 +59,11 @@ export type TeamsIndexPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectBoardSettingsPageProps = {
|
export type ProjectBoardSettingsPageProps = {
|
||||||
|
backlogTicketStatuses: TicketStatusModel[];
|
||||||
boardColumns: BoardColumnModel[];
|
boardColumns: BoardColumnModel[];
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
ticketStatuses: TicketStatusModel[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectGeneralSettingsPageProps = {
|
export type ProjectGeneralSettingsPageProps = {
|
||||||
@@ -99,12 +102,13 @@ export type ProjectRepositorySettingsPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectBacklogPageProps = {
|
export type ProjectBacklogPageProps = {
|
||||||
boardColumns: BoardColumnModel[];
|
backlogTicketStatuses: TicketStatusModel[];
|
||||||
canCreateTickets: boolean;
|
canCreateTickets: boolean;
|
||||||
canEditTickets: boolean;
|
canEditTickets: boolean;
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
ticketPriorities: TicketPriorityModel[];
|
ticketPriorities: TicketPriorityModel[];
|
||||||
|
ticketStatusOptions: TicketStatusModel[];
|
||||||
tickets: TicketModel[];
|
tickets: TicketModel[];
|
||||||
ticketTypes: TicketTypeModel[];
|
ticketTypes: TicketTypeModel[];
|
||||||
users: UserModel[];
|
users: UserModel[];
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
->name('projects.settings.board.columns.update');
|
->name('projects.settings.board.columns.update');
|
||||||
Route::delete('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'destroy'])
|
Route::delete('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'destroy'])
|
||||||
->name('projects.settings.board.columns.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'])
|
Route::get('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'show'])
|
||||||
->name('projects.settings.metadata');
|
->name('projects.settings.metadata');
|
||||||
Route::get('projects/{project}/settings/repositories', [ProjectRepositorySettingsController::class, 'show'])
|
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\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
use Database\Seeders\BoardColumnSeeder;
|
use Database\Seeders\BoardColumnSeeder;
|
||||||
use Database\Seeders\ProjectSeeder;
|
use Database\Seeders\ProjectSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -69,22 +70,29 @@ class BoardColumnTest extends TestCase
|
|||||||
'name' => 'Doing',
|
'name' => 'Doing',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
$ticketStatus = TicketStatus::factory()
|
||||||
|
->for($project)
|
||||||
|
->for($boardColumn)
|
||||||
|
->create([
|
||||||
|
'name' => 'Doing',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
Ticket::factory()->create([
|
Ticket::factory()->create([
|
||||||
'board_column_id' => $boardColumn->id,
|
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => 1,
|
'sequence_number' => 1,
|
||||||
'ticket_key' => 'CORE-1',
|
'ticket_key' => 'CORE-1',
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $ticketStatus->id,
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'title' => 'First ticket',
|
'title' => 'First ticket',
|
||||||
]);
|
]);
|
||||||
Ticket::factory()->create([
|
Ticket::factory()->create([
|
||||||
'board_column_id' => $boardColumn->id,
|
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => 2,
|
'sequence_number' => 2,
|
||||||
'ticket_key' => 'CORE-2',
|
'ticket_key' => 'CORE-2',
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $ticketStatus->id,
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'title' => 'Second ticket',
|
'title' => 'Second ticket',
|
||||||
]);
|
]);
|
||||||
@@ -112,6 +120,11 @@ class BoardColumnTest extends TestCase
|
|||||||
'name' => 'Todo',
|
'name' => 'Todo',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('ticket_statuses', [
|
||||||
|
'project_id' => $coreProject->id,
|
||||||
|
'name' => 'Todo',
|
||||||
|
'slug' => 'todo',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('board_columns', [
|
$this->assertDatabaseHas('board_columns', [
|
||||||
'project_id' => $coreProject->id,
|
'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\TicketFieldValue;
|
||||||
use App\Models\TicketLink;
|
use App\Models\TicketLink;
|
||||||
use App\Models\TicketLinkType;
|
use App\Models\TicketLinkType;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
use App\Models\TicketWorklogEntry;
|
use App\Models\TicketWorklogEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Database\Seeders\BoardColumnSeeder;
|
use Database\Seeders\BoardColumnSeeder;
|
||||||
@@ -29,7 +30,7 @@ class TicketTest extends TestCase
|
|||||||
{
|
{
|
||||||
$ticket = Ticket::factory()->create();
|
$ticket = Ticket::factory()->create();
|
||||||
|
|
||||||
$this->assertNull($ticket->board_column_id);
|
$this->assertNull($ticket->ticketStatus?->board_column_id);
|
||||||
$this->assertStringContainsString('-', $ticket->ticket_key);
|
$this->assertStringContainsString('-', $ticket->ticket_key);
|
||||||
$this->assertSame(1, $ticket->sequence_number);
|
$this->assertSame(1, $ticket->sequence_number);
|
||||||
}
|
}
|
||||||
@@ -47,19 +48,22 @@ class TicketTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
|
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
|
||||||
|
$backlogStatus = $coreProject->ticketStatuses()
|
||||||
|
->whereNull('board_column_id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
$this->assertDatabaseHas('tickets', [
|
$this->assertDatabaseHas('tickets', [
|
||||||
'project_id' => $coreProject->id,
|
'project_id' => $coreProject->id,
|
||||||
'ticket_key' => 'CORE-1',
|
'ticket_key' => 'CORE-1',
|
||||||
'title' => 'Seed backlog item',
|
'title' => 'Seed backlog item',
|
||||||
'board_column_id' => null,
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('tickets', [
|
$this->assertDatabaseHas('tickets', [
|
||||||
'project_id' => $coreProject->id,
|
'project_id' => $coreProject->id,
|
||||||
'ticket_key' => 'CORE-2',
|
'ticket_key' => 'CORE-2',
|
||||||
'title' => 'Seed backlog item',
|
'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);
|
$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.
|
* Ensure tickets expose incoming and outgoing link relations.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Models\BoardColumn;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketPriority;
|
use App\Models\TicketPriority;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
@@ -33,10 +34,14 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$priority = $project->ticketPriorities()->firstOrFail();
|
$priority = $project->ticketPriorities()->firstOrFail();
|
||||||
|
$backlogStatus = $project->ticketStatuses()
|
||||||
|
->whereNull('board_column_id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
$ticket = Ticket::factory()->create([
|
$ticket = Ticket::factory()->create([
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'ticket_key' => 'AUTH-1',
|
'ticket_key' => 'AUTH-1',
|
||||||
'sequence_number' => 1,
|
'sequence_number' => 1,
|
||||||
@@ -50,7 +55,7 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('projects/Backlog')
|
->component('projects/Backlog')
|
||||||
->where('boardColumns.0.name', 'Todo')
|
->where('backlogTicketStatuses.0.name', 'Backlog')
|
||||||
->where('canEditTickets', false)
|
->where('canEditTickets', false)
|
||||||
->where('project.id', $project->id)
|
->where('project.id', $project->id)
|
||||||
->where('tickets.0.ticket_key', $ticket->ticket_key)
|
->where('tickets.0.ticket_key', $ticket->ticket_key)
|
||||||
@@ -69,6 +74,9 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
$priority = $project->ticketPriorities()->firstOrFail();
|
$priority = $project->ticketPriorities()->firstOrFail();
|
||||||
|
$backlogStatus = $project->ticketStatuses()
|
||||||
|
->whereNull('board_column_id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['view']);
|
$this->syncProjectPermissions($project, $user, ['view']);
|
||||||
|
|
||||||
@@ -79,6 +87,7 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
'acceptance_criteria' => 'Criteria',
|
'acceptance_criteria' => 'Criteria',
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
])
|
])
|
||||||
->assertForbidden();
|
->assertForbidden();
|
||||||
}
|
}
|
||||||
@@ -100,6 +109,9 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
'name' => 'High',
|
'name' => 'High',
|
||||||
'position' => 2,
|
'position' => 2,
|
||||||
]);
|
]);
|
||||||
|
$backlogStatus = $project->ticketStatuses()
|
||||||
|
->whereNull('board_column_id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['create', 'view']);
|
$this->syncProjectPermissions($project, $user, ['create', 'view']);
|
||||||
|
|
||||||
@@ -111,6 +123,7 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
'story_points' => 3,
|
'story_points' => 3,
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
@@ -129,8 +142,8 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
'title' => 'Same title',
|
'title' => 'Same title',
|
||||||
'assignee_user_id' => $assignee->id,
|
'assignee_user_id' => $assignee->id,
|
||||||
'author_user_id' => $user->id,
|
'author_user_id' => $user->id,
|
||||||
'board_column_id' => null,
|
|
||||||
'story_points' => 3,
|
'story_points' => 3,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('tickets', [
|
$this->assertDatabaseHas('tickets', [
|
||||||
@@ -139,8 +152,8 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
'title' => 'Same title',
|
'title' => 'Same title',
|
||||||
'assignee_user_id' => $assignee->id,
|
'assignee_user_id' => $assignee->id,
|
||||||
'author_user_id' => $user->id,
|
'author_user_id' => $user->id,
|
||||||
'board_column_id' => null,
|
|
||||||
'story_points' => 3,
|
'story_points' => 3,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseCount('ticket_worklog_entries', 2);
|
$this->assertDatabaseCount('ticket_worklog_entries', 2);
|
||||||
@@ -149,4 +162,44 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
'summary' => 'Ticket was created in Backlog.',
|
'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\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketPriority;
|
use App\Models\TicketPriority;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
@@ -36,6 +37,9 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
'name' => 'Doing',
|
'name' => 'Doing',
|
||||||
'position' => 2,
|
'position' => 2,
|
||||||
]);
|
]);
|
||||||
|
$this->createColumnStatus($todoColumn);
|
||||||
|
$doingStatus = $this->createColumnStatus($doingColumn);
|
||||||
|
$backlogStatus = $this->backlogStatus($project);
|
||||||
|
|
||||||
$priority = TicketPriority::factory()->for($project)->create([
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
'name' => 'High',
|
'name' => 'High',
|
||||||
@@ -43,21 +47,21 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Ticket::factory()->create([
|
Ticket::factory()->create([
|
||||||
'board_column_id' => null,
|
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => 1,
|
'sequence_number' => 1,
|
||||||
'ticket_key' => 'CORE-1',
|
'ticket_key' => 'CORE-1',
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'title' => 'Backlog ticket',
|
'title' => 'Backlog ticket',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$boardTicket = Ticket::factory()->create([
|
$boardTicket = Ticket::factory()->create([
|
||||||
'board_column_id' => $doingColumn->id,
|
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => 2,
|
'sequence_number' => 2,
|
||||||
'ticket_key' => 'CORE-2',
|
'ticket_key' => 'CORE-2',
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $doingStatus->id,
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'title' => 'Board ticket',
|
'title' => 'Board ticket',
|
||||||
]);
|
]);
|
||||||
@@ -96,6 +100,8 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
'name' => 'Todo',
|
'name' => 'Todo',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
$todoStatus = $this->createColumnStatus($todoColumn);
|
||||||
|
$backlogStatus = $this->backlogStatus($project);
|
||||||
|
|
||||||
$priority = TicketPriority::factory()->for($project)->create([
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
'name' => 'High',
|
'name' => 'High',
|
||||||
@@ -103,28 +109,29 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$ticket = Ticket::factory()->create([
|
$ticket = Ticket::factory()->create([
|
||||||
'board_column_id' => null,
|
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => 1,
|
'sequence_number' => 1,
|
||||||
'ticket_key' => 'CORE-1',
|
'ticket_key' => 'CORE-1',
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'title' => 'Backlog ticket',
|
'title' => 'Backlog ticket',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||||
|
$this->allowMove($backlogStatus, $todoStatus);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||||
'board_column_id' => $todoColumn->id,
|
|
||||||
'redirect_to' => 'backlog',
|
'redirect_to' => 'backlog',
|
||||||
|
'ticket_status_id' => $todoStatus->id,
|
||||||
])
|
])
|
||||||
->assertRedirect(route('projects.backlog.index', $project))
|
->assertRedirect(route('projects.backlog.index', $project))
|
||||||
->assertSessionHas('status', 'CORE-1 moved to Todo.');
|
->assertSessionHas('status', 'CORE-1 moved to Todo.');
|
||||||
|
|
||||||
$this->assertDatabaseHas('tickets', [
|
$this->assertDatabaseHas('tickets', [
|
||||||
'id' => $ticket->id,
|
'id' => $ticket->id,
|
||||||
'board_column_id' => $todoColumn->id,
|
'ticket_status_id' => $todoStatus->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||||
@@ -155,6 +162,8 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
'name' => 'Done',
|
'name' => 'Done',
|
||||||
'position' => 2,
|
'position' => 2,
|
||||||
]);
|
]);
|
||||||
|
$todoStatus = $this->createColumnStatus($todoColumn);
|
||||||
|
$doneStatus = $this->createColumnStatus($doneColumn);
|
||||||
|
|
||||||
$priority = TicketPriority::factory()->for($project)->create([
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
'name' => 'High',
|
'name' => 'High',
|
||||||
@@ -162,28 +171,29 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$ticket = Ticket::factory()->create([
|
$ticket = Ticket::factory()->create([
|
||||||
'board_column_id' => $todoColumn->id,
|
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => 1,
|
'sequence_number' => 1,
|
||||||
'ticket_key' => 'CORE-1',
|
'ticket_key' => 'CORE-1',
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $todoStatus->id,
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'title' => 'Board ticket',
|
'title' => 'Board ticket',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||||
|
$this->allowMove($todoStatus, $doneStatus);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||||
'board_column_id' => $doneColumn->id,
|
|
||||||
'redirect_to' => 'board',
|
'redirect_to' => 'board',
|
||||||
|
'ticket_status_id' => $doneStatus->id,
|
||||||
])
|
])
|
||||||
->assertRedirect(route('projects.board.index', $project))
|
->assertRedirect(route('projects.board.index', $project))
|
||||||
->assertSessionHas('status', 'CORE-1 moved to Done.');
|
->assertSessionHas('status', 'CORE-1 moved to Done.');
|
||||||
|
|
||||||
$this->assertDatabaseHas('tickets', [
|
$this->assertDatabaseHas('tickets', [
|
||||||
'id' => $ticket->id,
|
'id' => $ticket->id,
|
||||||
'board_column_id' => $doneColumn->id,
|
'ticket_status_id' => $doneStatus->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||||
@@ -210,6 +220,8 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
'name' => 'Doing',
|
'name' => 'Doing',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
$doingStatus = $this->createColumnStatus($doingColumn);
|
||||||
|
$backlogStatus = $this->backlogStatus($project);
|
||||||
|
|
||||||
$priority = TicketPriority::factory()->for($project)->create([
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
'name' => 'High',
|
'name' => 'High',
|
||||||
@@ -217,28 +229,29 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$ticket = Ticket::factory()->create([
|
$ticket = Ticket::factory()->create([
|
||||||
'board_column_id' => $doingColumn->id,
|
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => 1,
|
'sequence_number' => 1,
|
||||||
'ticket_key' => 'CORE-1',
|
'ticket_key' => 'CORE-1',
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $doingStatus->id,
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'title' => 'Board ticket',
|
'title' => 'Board ticket',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||||
|
$this->allowMove($doingStatus, $backlogStatus);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||||
'board_column_id' => null,
|
|
||||||
'redirect_to' => 'board',
|
'redirect_to' => 'board',
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
])
|
])
|
||||||
->assertRedirect(route('projects.board.index', $project))
|
->assertRedirect(route('projects.board.index', $project))
|
||||||
->assertSessionHas('status', 'CORE-1 moved to Backlog.');
|
->assertSessionHas('status', 'CORE-1 moved to Backlog.');
|
||||||
|
|
||||||
$this->assertDatabaseHas('tickets', [
|
$this->assertDatabaseHas('tickets', [
|
||||||
'id' => $ticket->id,
|
'id' => $ticket->id,
|
||||||
'board_column_id' => null,
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
@@ -266,6 +279,8 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
'name' => 'Foreign',
|
'name' => 'Foreign',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
$otherProjectStatus = $this->createColumnStatus($otherProjectColumn);
|
||||||
|
$backlogStatus = $this->backlogStatus($project);
|
||||||
|
|
||||||
$priority = TicketPriority::factory()->for($project)->create([
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
'name' => 'High',
|
'name' => 'High',
|
||||||
@@ -273,9 +288,9 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$ticket = Ticket::factory()->create([
|
$ticket = Ticket::factory()->create([
|
||||||
'board_column_id' => null,
|
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -284,17 +299,213 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->from(route('projects.backlog.index', $project))
|
->from(route('projects.backlog.index', $project))
|
||||||
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||||
'board_column_id' => $otherProjectColumn->id,
|
|
||||||
'redirect_to' => 'backlog',
|
'redirect_to' => 'backlog',
|
||||||
|
'ticket_status_id' => $otherProjectStatus->id,
|
||||||
])
|
])
|
||||||
->assertRedirect(route('projects.backlog.index', $project))
|
->assertRedirect(route('projects.backlog.index', $project))
|
||||||
->assertSessionHasErrors('board_column_id');
|
->assertSessionHasErrors('ticket_status_id');
|
||||||
|
|
||||||
$this->assertDatabaseHas('tickets', [
|
$this->assertDatabaseHas('tickets', [
|
||||||
'id' => $ticket->id,
|
'id' => $ticket->id,
|
||||||
'board_column_id' => null,
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
$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;
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
@@ -22,6 +25,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
|||||||
$project = Project::factory()->create([
|
$project = Project::factory()->create([
|
||||||
'key' => 'CORE',
|
'key' => 'CORE',
|
||||||
]);
|
]);
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
BoardColumn::factory()->for($project)->create([
|
BoardColumn::factory()->for($project)->create([
|
||||||
'name' => 'Todo',
|
'name' => 'Todo',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
@@ -35,7 +39,9 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
|||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('projects/settings/Board')
|
->component('projects/settings/Board')
|
||||||
->where('project.id', $project->id)
|
->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',
|
'name' => 'In Review',
|
||||||
'position' => 1,
|
'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,
|
'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\Project;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
@@ -346,12 +347,19 @@ class ProjectControllerTest extends TestCase
|
|||||||
'name' => 'In Progress',
|
'name' => 'In Progress',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
$ticketStatus = TicketStatus::factory()
|
||||||
|
->for($project)
|
||||||
|
->for($boardColumn)
|
||||||
|
->create([
|
||||||
|
'name' => 'In Progress',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
$ticket = Ticket::factory()->create([
|
$ticket = Ticket::factory()->create([
|
||||||
'board_column_id' => $boardColumn->id,
|
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => 1,
|
'sequence_number' => 1,
|
||||||
'ticket_key' => 'CORE-1',
|
'ticket_key' => 'CORE-1',
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $ticketStatus->id,
|
||||||
'ticket_type_id' => $project->ticketTypes()
|
'ticket_type_id' => $project->ticketTypes()
|
||||||
->where('is_parent', false)
|
->where('is_parent', false)
|
||||||
->orderBy('position')
|
->orderBy('position')
|
||||||
|
|||||||
Reference in New Issue
Block a user