From fb10ecd535cedae6ced96378d7df15c69ec7bfc2 Mon Sep 17 00:00:00 2001 From: Twyco Date: Tue, 14 Apr 2026 19:07:15 +0200 Subject: [PATCH] added status restriction --- .../ProjectBoardSettingsController.php | 26 +++ .../UpdateTicketStatusTransitionsRequest.php | 68 ++++++++ app/Models/TicketStatus.php | 16 -- ...ticket_board_column_with_ticket_status.php | 21 --- resources/js/pages/projects/Backlog.vue | 53 +++--- resources/js/pages/projects/Board.vue | 40 ++++- .../js/pages/projects/settings/Board.vue | 152 ++++++++++++++++++ resources/js/types/page-props.ts | 1 + routes/web.php | 2 + tests/Feature/Models/TicketStatusTest.php | 38 ++++- .../Projects/ProjectBoardControllerTest.php | 129 ++++++++++++++- .../ProjectBoardSettingsControllerTest.php | 93 ++++++++++- 12 files changed, 570 insertions(+), 69 deletions(-) create mode 100644 app/Http/Requests/UpdateTicketStatusTransitionsRequest.php diff --git a/app/Http/Controllers/ProjectBoardSettingsController.php b/app/Http/Controllers/ProjectBoardSettingsController.php index 0a5a3c2..129e7a4 100644 --- a/app/Http/Controllers/ProjectBoardSettingsController.php +++ b/app/Http/Controllers/ProjectBoardSettingsController.php @@ -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( diff --git a/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php b/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php new file mode 100644 index 0000000..c6c8681 --- /dev/null +++ b/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php @@ -0,0 +1,68 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|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 + */ + 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.', + ); + } + }, + ]; + } +} diff --git a/app/Models/TicketStatus.php b/app/Models/TicketStatus.php index f630d27..e215340 100644 --- a/app/Models/TicketStatus.php +++ b/app/Models/TicketStatus.php @@ -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 diff --git a/database/migrations/2026_04_14_005330_replace_ticket_board_column_with_ticket_status.php b/database/migrations/2026_04_14_005330_replace_ticket_board_column_with_ticket_status.php index 965a286..6ea3261 100644 --- a/database/migrations/2026_04_14_005330_replace_ticket_board_column_with_ticket_status.php +++ b/database/migrations/2026_04_14_005330_replace_ticket_board_column_with_ticket_status.php @@ -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, - ]); - }); - }); - } }; diff --git a/resources/js/pages/projects/Backlog.vue b/resources/js/pages/projects/Backlog.vue index 5e1159b..40cc5b9 100644 --- a/resources/js/pages/projects/Backlog.vue +++ b/resources/js/pages/projects/Backlog.vue @@ -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, ); }; @@ -397,10 +396,6 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
+
- - - No allowed status moves. - diff --git a/resources/js/pages/projects/Board.vue b/resources/js/pages/projects/Board.vue index b2cc296..91d7d2d 100644 --- a/resources/js/pages/projects/Board.vue +++ b/resources/js/pages/projects/Board.vue @@ -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, + ); }; @@ -255,12 +257,23 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {