Implement board columns and project workflow settings #7
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Http\Requests\StoreBoardColumnRequest;
|
||||
use App\Http\Requests\UpdateBoardColumnRequest;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProjectBoardSettingsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectPermissionService $projectPermissionService,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Project $project): Response
|
||||
{
|
||||
abort_unless(
|
||||
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||
403,
|
||||
);
|
||||
|
||||
return Inertia::render('projects/settings/Board', [
|
||||
'boardColumns' => $project->boardColumns()->get(),
|
||||
'project' => $project,
|
||||
'status' => session('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreBoardColumnRequest $request,
|
||||
Project $project,
|
||||
): RedirectResponse {
|
||||
abort_unless(
|
||||
|
|
||||
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||
403,
|
||||
);
|
||||
$validated = $request->validated();
|
||||
|
||||
$desiredPosition = Arr::get($validated, 'position') ?? $project->boardColumns()->count() + 1;
|
||||
|
||||
$boardColumn = DB::transaction(function () use ($project, $validated, $desiredPosition): BoardColumn {
|
||||
$boardColumn = $project->boardColumns()->create([
|
||||
'name' => Arr::get($validated, 'name'),
|
||||
'position' => $project->boardColumns()->count() + 1,
|
||||
]);
|
||||
|
||||
$this->reorderColumns($project, $boardColumn, $desiredPosition);
|
||||
|
||||
return $boardColumn;
|
||||
});
|
||||
|
||||
return to_route('projects.settings.board', $project)
|
||||
->with('status', "{$boardColumn->name} was added to the board workflow.");
|
||||
}
|
||||
|
||||
public function update(
|
||||
UpdateBoardColumnRequest $request,
|
||||
Project $project,
|
||||
BoardColumn $boardColumn,
|
||||
): RedirectResponse {
|
||||
abort_unless(
|
||||
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||
403,
|
||||
);
|
||||
$validated = $request->validated();
|
||||
|
||||
DB::transaction(function () use ($boardColumn, $project, $validated): void {
|
||||
$boardColumn->update([
|
||||
'name' => Arr::get($validated, 'name'),
|
||||
]);
|
||||
|
||||
$this->reorderColumns($project, $boardColumn, Arr::get($validated, 'position'));
|
||||
});
|
||||
|
||||
return to_route('projects.settings.board', $project)
|
||||
->with('status', "{$boardColumn->fresh()->name} was updated.");
|
||||
}
|
||||
|
||||
public function destroy(
|
||||
Request $request,
|
||||
Project $project,
|
||||
BoardColumn $boardColumn,
|
||||
): RedirectResponse {
|
||||
abort_unless(
|
||||
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||
403,
|
||||
);
|
||||
|
||||
$deletedName = $boardColumn->name;
|
||||
|
||||
DB::transaction(function () use ($boardColumn, $project): void {
|
||||
$boardColumn->delete();
|
||||
|
||||
$this->normalizeColumnPositions($project);
|
||||
});
|
||||
|
||||
return to_route('projects.settings.board', $project)
|
||||
->with('status', "{$deletedName} was removed from the board workflow.");
|
||||
}
|
||||
|
||||
private function normalizeColumnPositions(Project $project): void
|
||||
{
|
||||
$this->persistOrderedColumns(
|
||||
$project->boardColumns()
|
||||
->get()
|
||||
->values(),
|
||||
);
|
||||
}
|
||||
|
||||
private function reorderColumns(Project $project, BoardColumn $movingColumn, int $desiredPosition): void
|
||||
{
|
||||
/** @var Collection<int, BoardColumn> $columns */
|
||||
$columns = $project->boardColumns()
|
||||
->whereKeyNot($movingColumn->id)
|
||||
->get()
|
||||
->values();
|
||||
|
||||
$insertAt = max(0, min($desiredPosition - 1, $columns->count()));
|
||||
|
||||
$columns->splice($insertAt, 0, [$movingColumn]);
|
||||
|
||||
$this->persistOrderedColumns($columns->values());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BoardColumn> $columns
|
||||
*/
|
||||
private function persistOrderedColumns(Collection $columns): void
|
||||
{
|
||||
$temporaryOffset = ((int) $columns->max('position')) + $columns->count() + 1;
|
||||
|
||||
$columns->each(function (BoardColumn $column, int $index) use ($temporaryOffset): void {
|
||||
$column->forceFill([
|
||||
'position' => $temporaryOffset + $index,
|
||||
])->saveQuietly();
|
||||
});
|
||||
|
||||
$columns->each(function (BoardColumn $column, int $index): void {
|
||||
$column->forceFill([
|
||||
'position' => $index + 1,
|
||||
])->saveQuietly();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreBoardColumnRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
|
Twyco marked this conversation as resolved
Eric
commented
auch besser admin? auch besser admin?
Twyco
commented
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'position' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateBoardColumnRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
|
Twyco marked this conversation as resolved
Eric
commented
hier das gleiche? hier das gleiche?
Twyco
commented
siehe oben siehe oben
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'position' => ['required', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\BoardColumnFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id'])]
|
||||
class BoardColumn extends Model
|
||||
{
|
||||
/** @use HasFactory<BoardColumnFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['name', 'key'])]
|
||||
@@ -21,4 +22,10 @@ class Project extends Model
|
||||
set: fn (string $value): string => Str::upper(trim($value)),
|
||||
);
|
||||
}
|
||||
|
||||
public function boardColumns(): HasMany
|
||||
{
|
||||
return $this->hasMany(BoardColumn::class)
|
||||
->orderBy('position');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<BoardColumn>
|
||||
*/
|
||||
class BoardColumnFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->unique()->words(2, true),
|
||||
'position' => 1,
|
||||
'project_id' => Project::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('board_columns', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->unsignedInteger('position');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['project_id', 'position']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('board_columns');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class BoardColumnSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
Project::query()
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->each(function (Project $project): void {
|
||||
BoardColumn::factory()
|
||||
->count(3)
|
||||
->sequence(
|
||||
['name' => 'Todo', 'position' => 1],
|
||||
['name' => 'In Progress', 'position' => 2],
|
||||
['name' => 'Done', 'position' => 3],
|
||||
)
|
||||
->for($project)
|
||||
->create();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class DatabaseSeeder extends Seeder
|
||||
$this->call([
|
||||
UserSeeder::class,
|
||||
ProjectSeeder::class,
|
||||
BoardColumnSeeder::class,
|
||||
TeamSeeder::class,
|
||||
ProjectAccessSeeder::class,
|
||||
]);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { access, index, show } from '@/routes/projects';
|
||||
import { board as boardSettings } from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectShowPageProps>();
|
||||
@@ -73,8 +74,14 @@ const projectAreas = [
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="canManageAccess">
|
||||
<div v-if="canManageAccess" class="flex flex-wrap gap-3">
|
||||
<Button as-child>
|
||||
<Link :href="boardSettings(project.id)">
|
||||
Board settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="access(project.id)">Manage access</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index, show } from '@/routes/projects';
|
||||
import { board as boardSettings } from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectBoardSettingsPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Board settings',
|
||||
href: boardSettings(props.project.id),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} board settings`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} board settings`"
|
||||
description="Configure the ordered board columns that define valid in-board ticket statuses for this project."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow model</CardTitle>
|
||||
<CardDescription>
|
||||
Backlog is always the intake queue and stays outside the
|
||||
normal board column list.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="flex flex-wrap gap-3">
|
||||
<Badge class="px-3 py-1">Backlog</Badge>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Tickets will later be valid only in backlog or in one of
|
||||
the ordered columns below.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add column</CardTitle>
|
||||
<CardDescription>
|
||||
New columns extend the project-specific workflow and
|
||||
are inserted at the end unless you specify a
|
||||
position later.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.store.form(
|
||||
project.id,
|
||||
)
|
||||
"
|
||||
class="space-y-4"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Column name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
autofocus
|
||||
placeholder="In Review"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
Add column
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Board columns</CardTitle>
|
||||
<CardDescription>
|
||||
Rename columns or change the numeric position to
|
||||
reorder the board from left to right.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4 p-6">
|
||||
<div
|
||||
v-if="boardColumns.length === 0"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
No board columns exist yet.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="column in boardColumns"
|
||||
:key="column.id"
|
||||
class="rounded-xl border border-border p-4"
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.update.form([
|
||||
project.id,
|
||||
column.id,
|
||||
])
|
||||
"
|
||||
class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_8rem_auto_auto]"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label :for="`column-name-${column.id}`">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
:id="`column-name-${column.id}`"
|
||||
name="name"
|
||||
:value="column.name"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`column-position-${column.id}`"
|
||||
>
|
||||
Position
|
||||
</Label>
|
||||
<Input
|
||||
:id="`column-position-${column.id}`"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="boardColumns.length"
|
||||
:value="column.position"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.position" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.destroy.form(
|
||||
[project.id, column.id],
|
||||
)
|
||||
"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="processing"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
export type BoardColumnModel = {
|
||||
id: number;
|
||||
name: string;
|
||||
position: number;
|
||||
project_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
Twyco marked this conversation as resolved
Eric
commented
kann raus kann raus
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||
import type { ProjectModel } from '@/types/models/project-model';
|
||||
import type { TeamModel } from '@/types/models/team-model';
|
||||
import type { UserModel } from '@/types/models/user-model';
|
||||
@@ -36,3 +37,9 @@ export type TeamsIndexPageProps = {
|
||||
teams: TeamModel[];
|
||||
users: UserModel[];
|
||||
};
|
||||
|
||||
export type ProjectBoardSettingsPageProps = {
|
||||
boardColumns: BoardColumnModel[];
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\UserManagementController;
|
||||
use App\Http\Controllers\ProjectBoardSettingsController;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use App\Http\Controllers\TeamController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -22,6 +23,17 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
->name('projects.permissions.users.update');
|
||||
Route::patch('projects/{project}/permissions/teams/{team}', [ProjectController::class, 'updateTeamPermissions'])
|
||||
->name('projects.permissions.teams.update');
|
||||
|
||||
Route::scopeBindings()->group(function (): void {
|
||||
|
Twyco marked this conversation as resolved
Eric
commented
Was ist scopeBindings? Und eventuell Board Settings nur für Admins zugänglich machen? Was ist scopeBindings? Und eventuell Board Settings nur für Admins zugänglich machen?
Twyco
commented
nope selbe erklärung, es gibt ja extra permissions für user, damit nicht nur admins boards erstellen können. Boards erstellen soll ja auch einfach ein normaler user mit entsprechenden rechten nope selbe erklärung, es gibt ja extra permissions für user, damit nicht nur admins boards erstellen können.
Admin sind hier in dem fall komplette site adminisatratoren, die useraccounts bearbeiten können etc.
Boards erstellen soll ja auch einfach ein normaler user mit entsprechenden rechten
Twyco
commented
https://laravel.com/docs/13.x/routing#implicit-model-binding-scoping automatische validierung, dass column related zu dem project ist https://laravel.com/docs/13.x/routing#implicit-model-binding-scoping
automatische validierung, dass column related zu dem project ist
|
||||
Route::get('projects/{project}/settings/board', [ProjectBoardSettingsController::class, 'show'])
|
||||
->name('projects.settings.board');
|
||||
Route::post('projects/{project}/settings/board/columns', [ProjectBoardSettingsController::class, 'store'])
|
||||
->name('projects.settings.board.columns.store');
|
||||
Route::patch('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'update'])
|
||||
->name('projects.settings.board.columns.update');
|
||||
Route::delete('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'destroy'])
|
||||
->name('projects.settings.board.columns.destroy');
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'can:access-admin'])
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Models;
|
||||
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Database\Seeders\BoardColumnSeeder;
|
||||
use Database\Seeders\ProjectSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BoardColumnTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure the board column factory creates a position and related project.
|
||||
*/
|
||||
public function test_board_column_factory_creates_expected_defaults(): void
|
||||
{
|
||||
$boardColumn = BoardColumn::factory()->create();
|
||||
|
||||
$this->assertNotNull($boardColumn->project);
|
||||
$this->assertSame(1, $boardColumn->position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure projects expose board columns ordered by position.
|
||||
*/
|
||||
public function test_project_board_columns_are_returned_in_position_order(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'Done',
|
||||
'position' => 3,
|
||||
]);
|
||||
BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'Todo',
|
||||
'position' => 1,
|
||||
]);
|
||||
BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'In Progress',
|
||||
'position' => 2,
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
['Todo', 'In Progress', 'Done'],
|
||||
$project->fresh()->boardColumns->pluck('name')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the board column seeder creates starter workflow columns for seeded projects.
|
||||
*/
|
||||
public function test_board_column_seeder_creates_default_columns_for_projects(): void
|
||||
{
|
||||
$this->seed([
|
||||
ProjectSeeder::class,
|
||||
BoardColumnSeeder::class,
|
||||
]);
|
||||
|
||||
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
|
||||
|
||||
$this->assertDatabaseHas('board_columns', [
|
||||
'project_id' => $coreProject->id,
|
||||
'name' => 'Todo',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('board_columns', [
|
||||
'project_id' => $coreProject->id,
|
||||
'name' => 'In Progress',
|
||||
'position' => 2,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('board_columns', [
|
||||
'project_id' => $coreProject->id,
|
||||
'name' => 'Done',
|
||||
'position' => 3,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace Tests\Feature\Models;
|
||||
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Database\Seeders\BoardColumnSeeder;
|
||||
use Database\Seeders\ProjectSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
@@ -45,4 +47,49 @@ class ProjectTest extends TestCase
|
||||
'key' => 'OPS',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure projects expose their board columns in the configured order.
|
||||
*/
|
||||
public function test_projects_have_an_ordered_board_columns_relationship(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'Done',
|
||||
'position' => 3,
|
||||
]);
|
||||
BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'Todo',
|
||||
'position' => 1,
|
||||
]);
|
||||
BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'In Progress',
|
||||
'position' => 2,
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
['Todo', 'In Progress', 'Done'],
|
||||
$project->fresh()->boardColumns->pluck('name')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the default database seeding can include project board columns.
|
||||
*/
|
||||
public function test_board_column_seeder_adds_columns_to_seeded_projects(): void
|
||||
{
|
||||
$this->seed([
|
||||
ProjectSeeder::class,
|
||||
BoardColumnSeeder::class,
|
||||
]);
|
||||
|
||||
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
|
||||
|
||||
$this->assertDatabaseHas('board_columns', [
|
||||
'project_id' => $coreProject->id,
|
||||
'name' => 'Todo',
|
||||
'position' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectBoardSettingsControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure users with edit permission can view board settings.
|
||||
*/
|
||||
public function test_users_with_edit_permission_can_view_board_settings(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'Todo',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.settings.board', $project))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/settings/Board')
|
||||
->where('project.id', $project->id)
|
||||
->where('boardColumns.0.name', 'Todo'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure users without edit permission cannot view board settings.
|
||||
*/
|
||||
public function test_users_without_edit_permission_cannot_view_board_settings(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.settings.board', $project))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure authorized users can create board columns.
|
||||
*/
|
||||
public function test_users_with_edit_permission_can_create_board_columns(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.settings.board.columns.store', $project), [
|
||||
'name' => 'In Review',
|
||||
])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertSessionHas('status', 'In Review was added to the board workflow.');
|
||||
|
||||
$this->assertDatabaseHas('board_columns', [
|
||||
'project_id' => $project->id,
|
||||
'name' => 'In Review',
|
||||
'position' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure authorized users can rename and reorder board columns.
|
||||
*/
|
||||
public function test_users_with_edit_permission_can_update_and_reorder_board_columns(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$firstColumn = BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'Todo',
|
||||
'position' => 1,
|
||||
]);
|
||||
$secondColumn = BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'In Progress',
|
||||
'position' => 2,
|
||||
]);
|
||||
$thirdColumn = BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'Done',
|
||||
'position' => 3,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.settings.board.columns.update', [$project, $thirdColumn]), [
|
||||
'name' => 'Ready for QA',
|
||||
'position' => 1,
|
||||
])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertSessionHas('status', 'Ready for QA was updated.');
|
||||
|
||||
$firstColumn->refresh();
|
||||
$secondColumn->refresh();
|
||||
$thirdColumn->refresh();
|
||||
|
||||
$this->assertSame(2, $firstColumn->position);
|
||||
$this->assertSame(3, $secondColumn->position);
|
||||
$this->assertSame(1, $thirdColumn->position);
|
||||
$this->assertSame('Ready for QA', $thirdColumn->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure deleting a column normalizes the remaining order.
|
||||
*/
|
||||
public function test_deleting_a_board_column_normalizes_remaining_positions(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$firstColumn = BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'Todo',
|
||||
'position' => 1,
|
||||
]);
|
||||
$secondColumn = BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'In Progress',
|
||||
'position' => 2,
|
||||
]);
|
||||
$thirdColumn = BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'Done',
|
||||
'position' => 3,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.settings.board.columns.destroy', [$project, $secondColumn]))
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertSessionHas('status', 'In Progress was removed from the board workflow.');
|
||||
|
||||
$firstColumn->refresh();
|
||||
$thirdColumn->refresh();
|
||||
|
||||
$this->assertSame(1, $firstColumn->position);
|
||||
$this->assertSame(2, $thirdColumn->position);
|
||||
$this->assertDatabaseMissing('board_columns', [
|
||||
'id' => $secondColumn->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user
Nochmal checken was in userCanMAnageProjectAccess steht, besser hier mit dem Guard
was meinst du mit (Nochmal checken was in userCanMAnageProjectAccess steht)?
Guard benutzen hab ich schon bei 03 documentiert