added status restriction
This commit is contained in:
@@ -8,6 +8,7 @@ use App\Http\Requests\StoreBoardColumnRequest;
|
|||||||
use App\Http\Requests\StoreTicketStatusRequest;
|
use App\Http\Requests\StoreTicketStatusRequest;
|
||||||
use App\Http\Requests\UpdateBoardColumnRequest;
|
use App\Http\Requests\UpdateBoardColumnRequest;
|
||||||
use App\Http\Requests\UpdateTicketStatusRequest;
|
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 App\Models\TicketStatus;
|
||||||
@@ -42,6 +43,9 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
->get(),
|
->get(),
|
||||||
'project' => $project,
|
'project' => $project,
|
||||||
'status' => session('status'),
|
'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}.");
|
->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(
|
||||||
|
|||||||
@@ -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 {
|
static::saving(function (self $ticketStatus): void {
|
||||||
$ticketStatus->slug = Str::slug($ticketStatus->name);
|
$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
|
public function allowedSourceStatuses(): BelongsToMany
|
||||||
|
|||||||
-21
@@ -88,7 +88,6 @@ return new class extends Migration
|
|||||||
->update(['ticket_status_id' => $ticketStatusId]);
|
->update(['ticket_status_id' => $ticketStatusId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->createFullTransitionMatrix((int) $project->id);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,24 +109,4 @@ return new class extends Migration
|
|||||||
'updated_at' => $now,
|
'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[] => {
|
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||||
return (
|
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||||
ticket.ticket_status?.allowed_target_statuses ??
|
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
||||||
props.ticketStatusOptions
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -397,10 +396,6 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
</td>
|
</td>
|
||||||
<td v-if="canEditTickets" class="px-4 py-4">
|
<td v-if="canEditTickets" class="px-4 py-4">
|
||||||
<Form
|
<Form
|
||||||
v-if="
|
|
||||||
allowedTargetStatuses(ticket)
|
|
||||||
.length > 0
|
|
||||||
"
|
|
||||||
v-bind="
|
v-bind="
|
||||||
ProjectBoardController.move.form(
|
ProjectBoardController.move.form(
|
||||||
{
|
{
|
||||||
@@ -419,12 +414,23 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
name="ticket_status_id"
|
name="ticket_status_id"
|
||||||
:value="
|
:disabled="
|
||||||
ticket.ticket_status_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
|
||||||
|
v-if="
|
||||||
|
allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
No allowed moves
|
||||||
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="ticketStatus in allowedTargetStatuses(
|
v-for="ticketStatus in allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
@@ -442,18 +448,27 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="processing"
|
:disabled="
|
||||||
|
processing ||
|
||||||
|
allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
>
|
>
|
||||||
Move
|
Move
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
<p
|
||||||
|
v-if="
|
||||||
<span
|
allowedTargetStatuses(
|
||||||
v-else
|
ticket,
|
||||||
class="text-muted-foreground text-sm"
|
).length === 0
|
||||||
|
"
|
||||||
|
class="text-muted-foreground text-xs sm:basis-full"
|
||||||
>
|
>
|
||||||
No allowed status moves.
|
This status does not allow
|
||||||
</span>
|
moving anywhere else.
|
||||||
|
</p>
|
||||||
|
</Form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -255,12 +257,23 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
<select
|
<select
|
||||||
:id="`ticket-status-${ticket.id}`"
|
:id="`ticket-status-${ticket.id}`"
|
||||||
name="ticket_status_id"
|
name="ticket_status_id"
|
||||||
:value="
|
:disabled="
|
||||||
ticket.ticket_status_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
|
||||||
|
v-if="
|
||||||
|
allowedTargetStatuses(
|
||||||
|
ticket,
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
No allowed moves
|
||||||
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="ticketStatus in allowedTargetStatuses(
|
v-for="ticketStatus in allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
@@ -278,11 +291,26 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -115,6 +150,123 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Backlog statuses</CardTitle>
|
<CardTitle>Backlog statuses</CardTitle>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export type ProjectBoardSettingsPageProps = {
|
|||||||
boardColumns: BoardColumnModel[];
|
boardColumns: BoardColumnModel[];
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
ticketStatuses: TicketStatusModel[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectGeneralSettingsPageProps = {
|
export type ProjectGeneralSettingsPageProps = {
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
->name('projects.settings.board.statuses.update');
|
->name('projects.settings.board.statuses.update');
|
||||||
Route::delete('projects/{project}/settings/board/statuses/{ticketStatus}', [ProjectBoardSettingsController::class, 'destroyStatus'])
|
Route::delete('projects/{project}/settings/board/statuses/{ticketStatus}', [ProjectBoardSettingsController::class, 'destroyStatus'])
|
||||||
->name('projects.settings.board.statuses.destroy');
|
->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'])
|
||||||
|
|||||||
@@ -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();
|
$project = Project::factory()->create();
|
||||||
$backlogStatus = TicketStatus::factory()->for($project)->create([
|
$backlogStatus = TicketStatus::factory()->for($project)->create([
|
||||||
@@ -65,8 +65,38 @@ class TicketStatusTest extends TestCase
|
|||||||
'position' => 2,
|
'position' => 2,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertTrue($backlogStatus->allowedTargetStatuses()->whereKey($readyStatus->id)->exists());
|
$this->assertFalse($backlogStatus->allowedTargetStatuses()->whereKey($readyStatus->id)->exists());
|
||||||
$this->assertTrue($readyStatus->allowedTargetStatuses()->whereKey($backlogStatus->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->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]), [
|
||||||
@@ -180,6 +181,7 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$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]), [
|
||||||
@@ -237,6 +239,7 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$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]), [
|
||||||
@@ -328,7 +331,6 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
$todoStatus = $this->createColumnStatus($todoColumn);
|
$todoStatus = $this->createColumnStatus($todoColumn);
|
||||||
$backlogStatus = $this->backlogStatus($project);
|
$backlogStatus = $this->backlogStatus($project);
|
||||||
$backlogStatus->allowedTargetStatuses()->detach($todoStatus->id);
|
|
||||||
|
|
||||||
$priority = TicketPriority::factory()->for($project)->create([
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
'name' => 'High',
|
'name' => 'High',
|
||||||
@@ -364,6 +366,126 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
$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
|
private function backlogStatus(Project $project): TicketStatus
|
||||||
{
|
{
|
||||||
return $project->ticketStatuses()
|
return $project->ticketStatuses()
|
||||||
@@ -381,4 +503,9 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
'position' => 1,
|
'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')
|
->component('projects/settings/Board')
|
||||||
->where('project.id', $project->id)
|
->where('project.id', $project->id)
|
||||||
->where('backlogTicketStatuses.0.name', 'Backlog')
|
->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($boardColumn->id, $newStatus->board_column_id);
|
||||||
$this->assertSame('ready-for-qa', $newStatus->slug);
|
$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)
|
$this->actingAs($user)
|
||||||
->patch(route('projects.settings.board.statuses.update', [$project, $newStatus]), [
|
->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.
|
* Ensure the last status in a column or backlog cannot be deleted.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user