ticket-rework-status-restriction #30
@@ -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.',
|
||||
);
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
-21
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
<p
|
||||
v-if="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
class="text-muted-foreground text-xs sm:basis-full"
|
||||
>
|
||||
This status does not allow
|
||||
moving anywhere else.
|
||||
</p>
|
||||
</Form>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
No allowed status moves.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -63,6 +63,7 @@ export type ProjectBoardSettingsPageProps = {
|
||||
boardColumns: BoardColumnModel[];
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
ticketStatuses: TicketStatusModel[];
|
||||
};
|
||||
|
||||
export type ProjectGeneralSettingsPageProps = {
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user