added status restriction

This commit is contained in:
2026-04-14 19:07:15 +02:00
parent 9927a91327
commit fb10ecd535
12 changed files with 570 additions and 69 deletions
@@ -8,6 +8,7 @@ use App\Http\Requests\StoreBoardColumnRequest;
use App\Http\Requests\StoreTicketStatusRequest;
use App\Http\Requests\UpdateBoardColumnRequest;
use App\Http\Requests\UpdateTicketStatusRequest;
use App\Http\Requests\UpdateTicketStatusTransitionsRequest;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\TicketStatus;
@@ -42,6 +43,9 @@ class ProjectBoardSettingsController extends Controller
->get(),
'project' => $project,
'status' => session('status'),
'ticketStatuses' => $project->ticketStatuses()
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
->get(),
]);
}
@@ -217,6 +221,28 @@ class ProjectBoardSettingsController extends Controller
->with('status', "{$deletedName} was removed from {$contextLabel}.");
}
public function updateStatusTransitions(
UpdateTicketStatusTransitionsRequest $request,
Project $project,
TicketStatus $ticketStatus,
): RedirectResponse {
abort_unless(
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
$validated = $request->validated();
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
->unique()
->values()
->all();
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
return to_route('projects.settings.board', $project)
->with('status', "{$ticketStatus->name} move targets were updated.");
}
private function normalizeColumnPositions(Project $project): void
{
$this->persistOrderedColumns(
@@ -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.',
);
}
},
];
}
}
-16
View File
@@ -22,22 +22,6 @@ class TicketStatus extends Model
static::saving(function (self $ticketStatus): void {
$ticketStatus->slug = Str::slug($ticketStatus->name);
});
static::created(function (self $ticketStatus): void {
$statusIds = self::query()
->where('project_id', $ticketStatus->project_id)
->pluck('id');
$ticketStatus->allowedTargetStatuses()->syncWithoutDetaching($statusIds);
self::query()
->where('project_id', $ticketStatus->project_id)
->whereKeyNot($ticketStatus->id)
->get()
->each(fn (self $existingStatus): array => $existingStatus
->allowedTargetStatuses()
->syncWithoutDetaching([$ticketStatus->id]));
});
}
public function allowedSourceStatuses(): BelongsToMany
@@ -88,7 +88,6 @@ return new class extends Migration
->update(['ticket_status_id' => $ticketStatusId]);
});
$this->createFullTransitionMatrix((int) $project->id);
});
}
@@ -110,24 +109,4 @@ return new class extends Migration
'updated_at' => $now,
]);
}
private function createFullTransitionMatrix(int $projectId): void
{
$now = now();
$statusIds = DB::table('ticket_statuses')
->where('project_id', $projectId)
->pluck('id')
->map(fn (int|string $statusId): int => (int) $statusId);
$statusIds->each(function (int $fromStatusId) use ($now, $statusIds): void {
$statusIds->each(function (int $toStatusId) use ($fromStatusId, $now): void {
DB::table('ticket_status_transitions')->insertOrIgnore([
'created_at' => $now,
'from_ticket_status_id' => $fromStatusId,
'to_ticket_status_id' => $toStatusId,
'updated_at' => $now,
]);
});
});
}
};
+34 -19
View File
@@ -67,9 +67,8 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
};
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (
ticket.ticket_status?.allowed_target_statuses ??
props.ticketStatusOptions
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
);
};
</script>
@@ -397,10 +396,6 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</td>
<td v-if="canEditTickets" class="px-4 py-4">
<Form
v-if="
allowedTargetStatuses(ticket)
.length > 0
"
v-bind="
ProjectBoardController.move.form(
{
@@ -419,12 +414,23 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
/>
<select
name="ticket_status_id"
:value="
ticket.ticket_status_id ??
''
:disabled="
allowedTargetStatuses(
ticket,
).length === 0 || processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
@@ -442,18 +448,27 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
<Button
type="submit"
size="sm"
:disabled="processing"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Move
</Button>
</Form>
<span
v-else
class="text-muted-foreground text-sm"
<p
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
class="text-muted-foreground text-xs sm:basis-full"
>
No allowed status moves.
</span>
This status does not allow
moving anywhere else.
</p>
</Form>
</td>
</tr>
</tbody>
+34 -6
View File
@@ -58,7 +58,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
};
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return ticket.ticket_status?.allowed_target_statuses ?? [];
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
);
};
</script>
@@ -255,12 +257,23 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:value="
ticket.ticket_status_id ??
''
:disabled="
allowedTargetStatuses(
ticket,
).length === 0 || processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
@@ -278,11 +291,26 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
<Button
type="submit"
size="sm"
:disabled="processing"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Update status
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-muted-foreground text-xs"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</CardContent>
</Card>
@@ -22,6 +22,7 @@ import {
repositories as repositorySettings,
} from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectBoardSettingsPageProps>();
@@ -39,6 +40,40 @@ const breadcrumbs: BreadcrumbItem[] = [
href: boardSettings(props.project.id),
},
];
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
const transitionTargetOptions = (
sourceStatus: TicketStatusModel,
): TicketStatusModel[] => {
return props.ticketStatuses.filter(
(ticketStatus) => ticketStatus.id !== sourceStatus.id,
);
};
const statusAllowsTarget = (
sourceStatus: TicketStatusModel,
targetStatus: TicketStatusModel,
): boolean => {
return (
sourceStatus.allowed_target_statuses?.some(
(allowedTargetStatus) => allowedTargetStatus.id === targetStatus.id,
) ?? false
);
};
</script>
<template>
@@ -115,6 +150,123 @@ const breadcrumbs: BreadcrumbItem[] = [
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Status moves</CardTitle>
<CardDescription>
Choose exactly where each status is allowed to move
next.
</CardDescription>
</CardHeader>
<CardContent class="p-0">
<div class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th class="px-4 py-3 font-medium">
Source status
</th>
<th class="px-4 py-3 font-medium">
Allowed move targets
</th>
<th class="px-4 py-3 font-medium">Save</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="sourceStatus in ticketStatuses"
:key="sourceStatus.id"
class="bg-background align-top"
>
<td class="px-4 py-4">
<Badge variant="outline">
{{
ticketStatusLabel(sourceStatus)
}}
</Badge>
</td>
<td class="px-4 py-4">
<Form
:id="`status-transitions-${sourceStatus.id}`"
v-bind="
ProjectBoardSettingsController.updateStatusTransitions.form(
[
project.id,
sourceStatus.id,
],
)
"
class="grid gap-3"
v-slot="{ errors }"
>
<div
v-if="
transitionTargetOptions(
sourceStatus,
).length === 0
"
class="text-muted-foreground"
>
No other statuses exist yet.
</div>
<div
v-else
class="grid gap-2 md:grid-cols-2 xl:grid-cols-3"
>
<label
v-for="targetStatus in transitionTargetOptions(
sourceStatus,
)"
:key="targetStatus.id"
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<input
type="checkbox"
name="allowed_ticket_status_ids[]"
:value="targetStatus.id"
:default-checked="
statusAllowsTarget(
sourceStatus,
targetStatus,
)
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>{{
ticketStatusLabel(
targetStatus,
)
}}</span>
</label>
</div>
<InputError
:message="
errors.allowed_ticket_status_ids
"
/>
</Form>
</td>
<td class="px-4 py-4">
<Button
type="submit"
variant="outline"
:form="`status-transitions-${sourceStatus.id}`"
>
Save
</Button>
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Backlog statuses</CardTitle>
+1
View File
@@ -63,6 +63,7 @@ export type ProjectBoardSettingsPageProps = {
boardColumns: BoardColumnModel[];
project: ProjectModel;
status?: string | null;
ticketStatuses: TicketStatusModel[];
};
export type ProjectGeneralSettingsPageProps = {
+2
View File
@@ -74,6 +74,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
->name('projects.settings.board.statuses.update');
Route::delete('projects/{project}/settings/board/statuses/{ticketStatus}', [ProjectBoardSettingsController::class, 'destroyStatus'])
->name('projects.settings.board.statuses.destroy');
Route::patch('projects/{project}/settings/board/statuses/{ticketStatus}/transitions', [ProjectBoardSettingsController::class, 'updateStatusTransitions'])
->name('projects.settings.board.statuses.transitions.update');
Route::get('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'show'])
->name('projects.settings.metadata');
Route::get('projects/{project}/settings/repositories', [ProjectRepositorySettingsController::class, 'show'])
+34 -4
View File
@@ -51,9 +51,9 @@ class TicketStatusTest extends TestCase
}
/**
* Ensure new statuses can move to each other through the transition relation.
* Ensure new statuses do not allow moves until transitions are configured.
*/
public function test_ticket_statuses_create_allowed_transition_relations(): void
public function test_ticket_statuses_do_not_create_allowed_transition_relations_by_default(): void
{
$project = Project::factory()->create();
$backlogStatus = TicketStatus::factory()->for($project)->create([
@@ -65,8 +65,38 @@ class TicketStatusTest extends TestCase
'position' => 2,
]);
$this->assertTrue($backlogStatus->allowedTargetStatuses()->whereKey($readyStatus->id)->exists());
$this->assertTrue($readyStatus->allowedTargetStatuses()->whereKey($backlogStatus->id)->exists());
$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());
}
/**
@@ -119,6 +119,7 @@ class ProjectBoardControllerTest extends TestCase
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->allowMove($backlogStatus, $todoStatus);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -180,6 +181,7 @@ class ProjectBoardControllerTest extends TestCase
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->allowMove($todoStatus, $doneStatus);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -237,6 +239,7 @@ class ProjectBoardControllerTest extends TestCase
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->allowMove($doingStatus, $backlogStatus);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -328,7 +331,6 @@ class ProjectBoardControllerTest extends TestCase
]);
$todoStatus = $this->createColumnStatus($todoColumn);
$backlogStatus = $this->backlogStatus($project);
$backlogStatus->allowedTargetStatuses()->detach($todoStatus->id);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
@@ -364,6 +366,126 @@ class ProjectBoardControllerTest extends TestCase
$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()
@@ -381,4 +503,9 @@ class ProjectBoardControllerTest extends TestCase
'position' => 1,
]);
}
private function allowMove(TicketStatus $sourceStatus, TicketStatus $targetStatus): void
{
$sourceStatus->allowedTargetStatuses()->syncWithoutDetaching([$targetStatus->id]);
}
}
@@ -40,7 +40,8 @@ class ProjectBoardSettingsControllerTest extends TestCase
->component('projects/settings/Board')
->where('project.id', $project->id)
->where('backlogTicketStatuses.0.name', 'Backlog')
->where('boardColumns.0.name', 'Todo'),
->where('boardColumns.0.name', 'Todo')
->where('ticketStatuses.0.name', 'Backlog'),
);
}
@@ -205,7 +206,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
$this->assertSame($boardColumn->id, $newStatus->board_column_id);
$this->assertSame('ready-for-qa', $newStatus->slug);
$this->assertTrue($defaultStatus->allowedTargetStatuses()->whereKey($newStatus->id)->exists());
$this->assertFalse($defaultStatus->allowedTargetStatuses()->whereKey($newStatus->id)->exists());
$this->actingAs($user)
->patch(route('projects.settings.board.statuses.update', [$project, $newStatus]), [
@@ -238,6 +239,94 @@ class ProjectBoardSettingsControllerTest extends TestCase
]);
}
/**
* 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.
*/