Implement ticket types priorities and custom fields #8

Merged
Twyco merged 3 commits from codex/0.1-kanban-foundation/05-ticket-types-priorities-and-custom-fields into main 2026-03-30 17:55:58 +00:00
33 changed files with 1848 additions and 1 deletions
@@ -0,0 +1,42 @@
<?php
namespace App\Actions\ProjectTicketMetadata;
use App\Models\Project;
use App\Models\TicketType;
class InitializeProjectTicketMetadata
{
public function handle(Project $project): void
{
$project->ticketTypes()->firstOrCreate(
['system_key' => 'task'],
[
'is_builtin' => true,
'name' => 'Task',
'position' => 1,
],
);
$project->ticketTypes()->firstOrCreate(
['system_key' => 'parent'],
[
'is_builtin' => true,
'name' => 'Parent',
'position' => 2,
],
);
$project->ticketTypes()
->whereIn('system_key', TicketType::BUILTIN_KEYS)
->orderBy('position')
->get()
->values()
->each(function (TicketType $ticketType, int $index): void {
$ticketType->forceFill([
Twyco marked this conversation as resolved
Review

Warum machen wir forcefill auf Built in

Warum machen wir forcefill auf Built in
Review

gut frage warum wir(codex) das macht. adde ich der liste

gut frage warum wir(codex) das macht. adde ich der liste
'is_builtin' => true,
'position' => $index + 1,
])->saveQuietly();
Twyco marked this conversation as resolved
Review

Wenn die Action nur beimErstellen eines neuen Projekts läuft, ist das neuvergeben von posi doppelt oder nicth. Die Built-ins werden ja bereits mit position 1 und 2 angelegt.

Wenn die Action nur beimErstellen eines neuen Projekts läuft, ist das neuvergeben von posi doppelt oder nicth. Die Built-ins werden ja bereits mit position 1 und 2 angelegt.
Review

wird ja aber nicht nur beim erstellen, sondern bei jedem speichern aufgerufen

wird ja aber nicht nur beim erstellen, sondern bei jedem speichern aufgerufen
});
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\SyncProjectPermissionsRequest;
use App\Models\Project;
@@ -16,6 +17,7 @@ use Inertia\Response;
class ProjectController extends Controller
{
public function __construct(
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
private readonly ProjectPermissionService $projectPermissionService,
) {}
@@ -50,6 +52,7 @@ class ProjectController extends Controller
public function store(StoreProjectRequest $request): RedirectResponse
{
$project = Project::query()->create($request->validated());
$this->initializeProjectTicketMetadata->handle($project);
$this->projectPermissionService->grantAllProjectPermissions($project, $request->user());
return to_route('projects.show', $project);
@@ -0,0 +1,291 @@
<?php
namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Http\Requests\StoreProjectNamedSettingRequest;
use App\Http\Requests\StoreTicketFieldDefinitionRequest;
use App\Http\Requests\UpdateProjectNamedSettingRequest;
use App\Http\Requests\UpdateTicketFieldDefinitionRequest;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
use App\Models\TicketPriority;
use App\Models\TicketType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class ProjectTicketMetadataSettingsController extends Controller
{
public function __construct(
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
private readonly ProjectPermissionService $projectPermissionService,
) {}
public function show(Request $request, Project $project): Response
{
$this->abortUnlessCanManage($request, $project);
$this->initializeProjectTicketMetadata->handle($project);
Twyco marked this conversation as resolved
Review

warum machen wir das hier? werden die metadaten nicht beim project erstellen initialisoiert
Weil bei Show View ist initialize auch komisch benannt oder bin ich am trippen

warum machen wir das hier? werden die metadaten nicht beim project erstellen initialisoiert Weil bei Show View ist initialize auch komisch benannt oder bin ich am trippen
Review

ja, hier aus show kanns raus, sollte nur bei store oder update

ja, hier aus show kanns raus, sollte nur bei store oder update
return Inertia::render('projects/settings/Metadata', [
'fieldTypeOptions' => collect(TicketFieldDefinition::FIELD_TYPES)
->map(fn (string $fieldType): array => [
'label' => ucfirst($fieldType),
'value' => $fieldType,
])
->all(),
'project' => $project,
'status' => session('status'),
'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(),
'ticketPriorities' => $project->ticketPriorities()->get(),
'ticketTypes' => $project->ticketTypes()->get(),
]);
}
public function storeType(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse
{
$this->abortUnlessCanManage($request, $project);
$this->initializeProjectTicketMetadata->handle($project);
Twyco marked this conversation as resolved
Review

same here

same here
Review

renaming wär gut ja, der in der method selber wird ja sichergestellt, dass immer (auch nach jedem bearbeiten) parent und task an 1 und 2 stelle sind und existieren

renaming wär gut ja, der in der method selber wird ja sichergestellt, dass immer (auch nach jedem bearbeiten) parent und task an 1 und 2 stelle sind und existieren
Review

built in ticket types überarbeitet ich aber eh nochmal, hab mir da was anders überlegt.

built in ticket types überarbeitet ich aber eh nochmal, hab mir da was anders überlegt.
$ticketType = DB::transaction(function () use ($project, $request): TicketType {
$ticketType = $project->ticketTypes()->create([
'is_builtin' => false,
'name' => $request->validated('name'),
'position' => $project->ticketTypes()->count() + 1,
'system_key' => null,
]);
$this->reorderModels(
$project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(),
$ticketType,
$request->position() ?? $project->ticketTypes()->count(),
);
return $ticketType;
});
return to_route('projects.settings.metadata', $project)
->with('status', "{$ticketType->name} type was added.");
}
public function updateType(
UpdateProjectNamedSettingRequest $request,
Project $project,
TicketType $ticketType,
): RedirectResponse {
$this->abortUnlessCanManage($request, $project);
$this->initializeProjectTicketMetadata->handle($project);
Twyco marked this conversation as resolved
Review

Hier auch wieso machen wir das in jeder Methode? Können wir das nicht einmal initialisieren beim erstellen des Projekts und dann sind die gegeben und wir müssen sie nciht in jeder Methode nochmal callen.

Hier auch wieso machen wir das in jeder Methode? Können wir das nicht einmal initialisieren beim erstellen des Projekts und dann sind die gegeben und wir müssen sie nciht in jeder Methode nochmal callen.
DB::transaction(function () use ($project, $request, $ticketType): void {
$ticketType->update([
'name' => $request->validated('name'),
]);
$this->reorderModels(
$project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(),
$ticketType,
$request->position(),
);
});
return to_route('projects.settings.metadata', $project)
->with('status', "{$ticketType->fresh()->name} type was updated.");
}
public function destroyType(Request $request, Project $project, TicketType $ticketType): RedirectResponse
{
$this->abortUnlessCanManage($request, $project);
abort_if($ticketType->is_builtin, 403);
$deletedName = $ticketType->name;
DB::transaction(function () use ($project, $ticketType): void {
$ticketType->delete();
$this->persistOrderedModels($project->ticketTypes()->get()->values());
});
return to_route('projects.settings.metadata', $project)
->with('status', "{$deletedName} type was removed.");
}
public function storePriority(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse
{
$this->abortUnlessCanManage($request, $project);
$ticketPriority = DB::transaction(function () use ($project, $request): TicketPriority {
$ticketPriority = $project->ticketPriorities()->create([
'name' => $request->validated('name'),
'position' => $project->ticketPriorities()->count() + 1,
]);
$this->reorderModels(
$project->ticketPriorities()->whereKeyNot($ticketPriority->id)->get()->values(),
$ticketPriority,
$request->position() ?? $project->ticketPriorities()->count(),
);
return $ticketPriority;
});
return to_route('projects.settings.metadata', $project)
->with('status', "{$ticketPriority->name} priority was added.");
}
public function updatePriority(
UpdateProjectNamedSettingRequest $request,
Project $project,
TicketPriority $ticketPriority,
): RedirectResponse {
$this->abortUnlessCanManage($request, $project);
DB::transaction(function () use ($project, $request, $ticketPriority): void {
$ticketPriority->update([
'name' => $request->validated('name'),
]);
$this->reorderModels(
$project->ticketPriorities()->whereKeyNot($ticketPriority->id)->get()->values(),
$ticketPriority,
$request->position(),
);
});
return to_route('projects.settings.metadata', $project)
->with('status', "{$ticketPriority->fresh()->name} priority was updated.");
}
public function destroyPriority(
Request $request,
Project $project,
TicketPriority $ticketPriority,
): RedirectResponse {
$this->abortUnlessCanManage($request, $project);
$deletedName = $ticketPriority->name;
DB::transaction(function () use ($project, $ticketPriority): void {
$ticketPriority->delete();
$this->persistOrderedModels($project->ticketPriorities()->get()->values());
});
return to_route('projects.settings.metadata', $project)
->with('status', "{$deletedName} priority was removed.");
}
public function storeFieldDefinition(
StoreTicketFieldDefinitionRequest $request,
Project $project,
): RedirectResponse {
$this->abortUnlessCanManage($request, $project);
$ticketFieldDefinition = DB::transaction(function () use ($project, $request): TicketFieldDefinition {
$ticketFieldDefinition = $project->ticketFieldDefinitions()->create([
'field_type' => $request->validated('field_type'),
'label' => $request->validated('label'),
'position' => $project->ticketFieldDefinitions()->count() + 1,
Review

find posi immernoch strange, aber das wegen Position wie es im Ticket zu sehen ist wahrscheinlich oder?

find posi immernoch strange, aber das wegen Position wie es im Ticket zu sehen ist wahrscheinlich oder?
]);
$this->reorderModels(
$project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(),
$ticketFieldDefinition,
$request->position() ?? $project->ticketFieldDefinitions()->count(),
);
return $ticketFieldDefinition;
});
return to_route('projects.settings.metadata', $project)
->with('status', "{$ticketFieldDefinition->label} field was added.");
}
public function updateFieldDefinition(
UpdateTicketFieldDefinitionRequest $request,
Project $project,
TicketFieldDefinition $ticketFieldDefinition,
): RedirectResponse {
$this->abortUnlessCanManage($request, $project);
DB::transaction(function () use ($project, $request, $ticketFieldDefinition): void {
$ticketFieldDefinition->update([
'field_type' => $request->validated('field_type'),
'label' => $request->validated('label'),
]);
$this->reorderModels(
$project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(),
$ticketFieldDefinition,
$request->position(),
);
});
return to_route('projects.settings.metadata', $project)
->with('status', "{$ticketFieldDefinition->fresh()->label} field was updated.");
}
public function destroyFieldDefinition(
Request $request,
Project $project,
TicketFieldDefinition $ticketFieldDefinition,
): RedirectResponse {
$this->abortUnlessCanManage($request, $project);
$deletedLabel = $ticketFieldDefinition->label;
DB::transaction(function () use ($project, $ticketFieldDefinition): void {
$ticketFieldDefinition->delete();
$this->persistOrderedModels($project->ticketFieldDefinitions()->get()->values());
});
return to_route('projects.settings.metadata', $project)
->with('status', "{$deletedLabel} field was removed.");
}
private function abortUnlessCanManage(Request $request, Project $project): void
{
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
403,
);
}
/**
* @param Collection<int, Model> $models
*/
private function persistOrderedModels(Collection $models): void
{
$temporaryOffset = ((int) $models->max('position')) + $models->count() + 1;
$models->each(function (Model $model, int $index) use ($temporaryOffset): void {
$model->forceFill([
'position' => $temporaryOffset + $index,
])->saveQuietly();
});
$models->each(function (Model $model, int $index): void {
$model->forceFill([
'position' => $index + 1,
])->saveQuietly();
});
}
/**
* @param Collection<int, Model> $existingModels
*/
private function reorderModels(Collection $existingModels, Model $movingModel, int $desiredPosition): void
{
$insertAt = max(0, min($desiredPosition - 1, $existingModels->count()));
$existingModels->splice($insertAt, 0, [$movingModel]);
$this->persistOrderedModels($existingModels->values());
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreProjectNamedSettingRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
Twyco marked this conversation as resolved
Review

hier maybe auch admin und nicht jeder authorisierte user

hier maybe auch admin und nicht jeder authorisierte user
Review

es gibt ja extra user permissions

es gibt ja extra user permissions
}
/**
* 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'],
];
}
public function position(): ?int
{
$position = $this->validated('position');
if ($position === null) {
return null;
}
return (int) $position;
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests;
use App\Models\TicketFieldDefinition;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTicketFieldDefinitionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
Twyco marked this conversation as resolved
Review

hier auch

hier auch
Review

selbe

selbe
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'field_type' => ['required', 'string', Rule::in(TicketFieldDefinition::FIELD_TYPES)],
'label' => ['required', 'string', 'max:255'],
'position' => ['nullable', 'integer', 'min:1'],
];
}
public function position(): ?int
{
$position = $this->validated('position');
if ($position === null) {
return null;
}
return (int) $position;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProjectNamedSettingRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
Twyco marked this conversation as resolved
Review

hier auch

hier auch
Review

selbe

selbe
}
/**
* 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'],
];
}
public function position(): int
{
return (int) $this->validated('position');
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests;
use App\Models\TicketFieldDefinition;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTicketFieldDefinitionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
Twyco marked this conversation as resolved
Review

🤨

🤨
{
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 [
'field_type' => ['required', 'string', Rule::in(TicketFieldDefinition::FIELD_TYPES)],
'label' => ['required', 'string', 'max:255'],
'position' => ['required', 'integer', 'min:1'],
];
}
public function position(): int
{
return (int) $this->validated('position');
}
}
+18
View File
@@ -28,4 +28,22 @@ class Project extends Model
return $this->hasMany(BoardColumn::class)
->orderBy('position');
}
public function ticketFieldDefinitions(): HasMany
{
return $this->hasMany(TicketFieldDefinition::class)
->orderBy('position');
}
public function ticketPriorities(): HasMany
{
return $this->hasMany(TicketPriority::class)
->orderBy('position');
}
public function ticketTypes(): HasMany
{
return $this->hasMany(TicketType::class)
->orderBy('position');
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Database\Factories\TicketFieldDefinitionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['field_type', 'label', 'position', 'project_id'])]
class TicketFieldDefinition extends Model
{
/**
* @var array<int, string>
*/
public const FIELD_TYPES = [
'user',
'number',
];
/** @use HasFactory<TicketFieldDefinitionFactory> */
use HasFactory;
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Database\Factories\TicketPriorityFactory;
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 TicketPriority extends Model
{
/** @use HasFactory<TicketPriorityFactory> */
use HasFactory;
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Database\Factories\TicketTypeFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['is_builtin', 'name', 'position', 'project_id', 'system_key'])]
class TicketType extends Model
{
/**
* @var array<int, string>
*/
public const BUILTIN_KEYS = [
'task',
'parent',
];
/** @use HasFactory<TicketTypeFactory> */
use HasFactory;
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<TicketFieldDefinition>
*/
class TicketFieldDefinitionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
'label' => fake()->unique()->words(2, true),
'position' => 1,
'project_id' => Project::factory(),
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketPriority;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<TicketPriority>
*/
class TicketPriorityFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']),
'position' => 1,
'project_id' => Project::factory(),
];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketType;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<TicketType>
*/
class TicketTypeFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'is_builtin' => false,
'name' => fake()->unique()->jobTitle(),
Twyco marked this conversation as resolved
Review

hö wieso JobTitle

hö wieso JobTitle
'position' => 1,
'project_id' => Project::factory(),
'system_key' => null,
];
}
}
@@ -0,0 +1,35 @@
<?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('ticket_types', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('system_key')->nullable();
$table->boolean('is_builtin')->default(false);
$table->unsignedInteger('position');
$table->timestamps();
$table->unique(['project_id', 'position']);
$table->unique(['project_id', 'system_key']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ticket_types');
}
};
@@ -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('ticket_priorities', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->unsignedInteger('position');
$table->timestamps();
$table->unique(['project_id', 'position']);
Twyco marked this conversation as resolved
Review

sollten wir priority nicht nh value oderso noch geben das man wenn man mehrere tickets in eineer column hat die nach prio easy sortieren kann ?

sollten wir priority nicht nh value oderso noch geben das man wenn man mehrere tickets in eineer column hat die nach prio easy sortieren kann ?
Review

hast du was geraucht? was denkst du was denkst du was position ist?

hast du was geraucht? was denkst du was denkst du was position ist?
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ticket_priorities');
}
};
@@ -0,0 +1,33 @@
<?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('ticket_field_definitions', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->string('label');
$table->string('field_type');
$table->unsignedInteger('position');
$table->timestamps();
$table->unique(['project_id', 'position']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ticket_field_definitions');
}
};
+1
View File
@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
UserSeeder::class,
ProjectSeeder::class,
BoardColumnSeeder::class,
ProjectTicketMetadataSeeder::class,
TeamSeeder::class,
ProjectAccessSeeder::class,
]);
@@ -0,0 +1,55 @@
<?php
namespace Database\Seeders;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
use App\Models\TicketPriority;
use App\Models\TicketType;
use Illuminate\Database\Seeder;
class ProjectTicketMetadataSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$initializeProjectTicketMetadata = app(InitializeProjectTicketMetadata::class);
Twyco marked this conversation as resolved
Review

warum so kann man kein constreuct hier machen und das dann so injecten?

warum so kann man kein constreuct hier machen und das dann so injecten?
Review

weil dass hier ein seeder ist und der nicht zur runtime läuft

weil dass hier ein seeder ist und der nicht zur runtime läuft
Project::query()
->orderBy('id')
->get()
->each(function (Project $project) use ($initializeProjectTicketMetadata): void {
$initializeProjectTicketMetadata->handle($project);
TicketType::factory()
->for($project)
->create([
'name' => 'Bug',
'position' => 3,
'system_key' => null,
]);
TicketPriority::factory()
->count(3)
->sequence(
['name' => 'Low', 'position' => 1],
['name' => 'Medium', 'position' => 2],
['name' => 'High', 'position' => 3],
)
->for($project)
->create();
TicketFieldDefinition::factory()
->count(2)
->sequence(
['label' => 'Assignee', 'field_type' => 'user', 'position' => 1],
['label' => 'Story Points', 'field_type' => 'number', 'position' => 2],
)
->for($project)
->create();
});
}
}
+16
View File
@@ -15,3 +15,19 @@
- gate statt has permission methods
- permission for creating teams without beeing admind -> teamowner
- team roles
- 05
- ticket type remove build in
- → add is parentBool to ticketType -> remove system_key (maybe add slug)
- auto generate one parent one task on new project as now
- disable delete last child ticket type
- every form request removed unnecessary methods: like position in StoreProjectNamedSettingRequest
- use Arr:get(\$validated, 'value'). instead of these messages or \$request->value
- Made ticket priority more static(change to has one) in ticket, but only show if there are more than one configured.
- made story points more static(change to table field in table), but only show if checked in project settings → new value on Project: hasStoryPoints
- more ticket field types (string, string array, boolean)
- later:
- if deleting ticket type select a new ticket type which all existing ticket should get or select delete all tickets of that type
+10 -1
View File
@@ -13,7 +13,10 @@ 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 {
board as boardSettings,
metadata as metadataSettings,
} from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
const props = defineProps<ProjectShowPageProps>();
@@ -81,6 +84,12 @@ const projectAreas = [
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Ticket settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="access(project.id)">Manage access</Link>
</Button>
@@ -0,0 +1,503 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
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,
metadata as metadataSettings,
} from '@/routes/projects/settings';
import type {
BreadcrumbItem,
ProjectTicketMetadataSettingsPageProps,
} from '@/types';
const props = defineProps<ProjectTicketMetadataSettingsPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Ticket settings',
href: metadataSettings(props.project.id),
},
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} ticket 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} ticket settings`"
description="Configure project-specific ticket types, priorities, and custom field definitions."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="boardSettings(project.id)">
Board settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</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>
<div class="grid gap-6 xl:grid-cols-3">
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Ticket types</CardTitle>
<CardDescription>
Built-in Task and Parent always exist. Custom types
can be added and reordered per project.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storeType.form(
project.id,
)
"
class="space-y-4 rounded-xl border border-dashed border-border p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="type-name">New type name</Label>
<Input
id="type-name"
name="name"
required
placeholder="Bug"
/>
<InputError :message="errors.name" />
</div>
<Button type="submit" :disabled="processing">
Add type
</Button>
</Form>
<div
v-for="ticketType in ticketTypes"
:key="ticketType.id"
class="rounded-xl border border-border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updateType.form(
[project.id, ticketType.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div
class="flex items-center justify-between gap-3"
>
<Badge
:variant="
ticketType.is_builtin
? 'default'
: 'secondary'
"
>
{{
ticketType.is_builtin
? 'Built-in'
: 'Custom'
}}
</Badge>
<p
class="text-xs tracking-wide text-muted-foreground uppercase"
>
Position {{ ticketType.position }}
</p>
</div>
<div class="grid gap-2">
<Label :for="`type-name-${ticketType.id}`">
Name
</Label>
<Input
:id="`type-name-${ticketType.id}`"
name="name"
:value="ticketType.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`type-position-${ticketType.id}`"
>
Position
</Label>
<Input
:id="`type-position-${ticketType.id}`"
name="position"
type="number"
min="1"
:max="ticketTypes.length"
:value="ticketType.position"
required
/>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-if="!ticketType.is_builtin"
v-bind="
ProjectTicketMetadataSettingsController.destroyType.form(
[project.id, ticketType.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Priorities</CardTitle>
<CardDescription>
Priorities are fully project-specific and can be
reordered or removed.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storePriority.form(
project.id,
)
"
class="space-y-4 rounded-xl border border-dashed border-border p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="priority-name"
>New priority name</Label
>
<Input
id="priority-name"
name="name"
required
placeholder="Urgent"
/>
<InputError :message="errors.name" />
</div>
<Button type="submit" :disabled="processing">
Add priority
</Button>
</Form>
<div
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
class="rounded-xl border border-border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updatePriority.form(
[project.id, ticketPriority.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`priority-name-${ticketPriority.id}`"
>
Name
</Label>
<Input
:id="`priority-name-${ticketPriority.id}`"
name="name"
:value="ticketPriority.name"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`priority-position-${ticketPriority.id}`"
>
Position
</Label>
<Input
:id="`priority-position-${ticketPriority.id}`"
name="position"
type="number"
min="1"
:max="ticketPriorities.length"
:value="ticketPriority.position"
required
/>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyPriority.form(
[project.id, ticketPriority.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Custom fields</CardTitle>
<CardDescription>
Define project-specific user or number fields for
upcoming ticket data.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storeFieldDefinition.form(
project.id,
)
"
class="space-y-4 rounded-xl border border-dashed border-border p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="field-label">New field label</Label>
<Input
id="field-label"
name="label"
required
placeholder="Reviewer"
/>
<InputError :message="errors.label" />
</div>
<div class="grid gap-2">
<Label for="field-type">Field type</Label>
<select
id="field-type"
name="field_type"
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<InputError :message="errors.field_type" />
</div>
<Button type="submit" :disabled="processing">
Add field
</Button>
</Form>
<div
v-for="fieldDefinition in ticketFieldDefinitions"
:key="fieldDefinition.id"
class="rounded-xl border border-border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updateFieldDefinition.form(
[project.id, fieldDefinition.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`field-label-${fieldDefinition.id}`"
>
Label
</Label>
<Input
:id="`field-label-${fieldDefinition.id}`"
name="label"
:value="fieldDefinition.label"
required
/>
<InputError :message="errors.label" />
</div>
<div class="grid gap-2">
<Label
:for="`field-type-${fieldDefinition.id}`"
>
Field type
</Label>
<select
:id="`field-type-${fieldDefinition.id}`"
name="field_type"
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
:selected="
option.value ===
fieldDefinition.field_type
"
>
{{ option.label }}
</option>
</select>
<InputError :message="errors.field_type" />
</div>
<div class="grid gap-2">
<Label
:for="`field-position-${fieldDefinition.id}`"
>
Position
</Label>
<Input
:id="`field-position-${fieldDefinition.id}`"
name="position"
type="number"
min="1"
:max="ticketFieldDefinitions.length"
:value="fieldDefinition.position"
required
/>
<InputError :message="errors.position" />
</div>
<div
class="flex flex-wrap justify-between gap-3"
>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyFieldDefinition.form(
[project.id, fieldDefinition.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,10 @@
export type TicketFieldDefinitionModel = {
id: number;
field_type: string;
label: string;
position: number;
project_id: number;
created_at: string;
updated_at: string;
[key: string]: unknown;
Review

weg

weg
};
@@ -0,0 +1,9 @@
export type TicketPriorityModel = {
id: number;
name: string;
position: number;
project_id: number;
created_at: string;
updated_at: string;
[key: string]: unknown;
};
Review

weg

weg
@@ -0,0 +1,11 @@
export type TicketTypeModel = {
id: number;
is_builtin: boolean;
name: string;
position: number;
project_id: number;
system_key: string | null;
created_at: string;
updated_at: string;
[key: string]: unknown;
};
+17
View File
@@ -1,6 +1,9 @@
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 { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { UserModel } from '@/types/models/user-model';
export type AdminUsersPageProps = {
@@ -43,3 +46,17 @@ export type ProjectBoardSettingsPageProps = {
project: ProjectModel;
status?: string | null;
};
export type TicketFieldTypeOption = {
label: string;
value: string;
};
export type ProjectTicketMetadataSettingsPageProps = {
fieldTypeOptions: TicketFieldTypeOption[];
project: ProjectModel;
status?: string | null;
ticketFieldDefinitions: TicketFieldDefinitionModel[];
ticketPriorities: TicketPriorityModel[];
ticketTypes: TicketTypeModel[];
};
+21
View File
@@ -3,6 +3,7 @@
use App\Http\Controllers\Admin\UserManagementController;
use App\Http\Controllers\ProjectBoardSettingsController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectTicketMetadataSettingsController;
use App\Http\Controllers\TeamController;
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Features;
@@ -33,6 +34,26 @@ Route::middleware(['auth', 'verified'])->group(function () {
->name('projects.settings.board.columns.update');
Route::delete('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'destroy'])
->name('projects.settings.board.columns.destroy');
Route::get('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'show'])
->name('projects.settings.metadata');
Route::post('projects/{project}/settings/metadata/types', [ProjectTicketMetadataSettingsController::class, 'storeType'])
->name('projects.settings.metadata.types.store');
Route::patch('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'updateType'])
->name('projects.settings.metadata.types.update');
Route::delete('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'destroyType'])
->name('projects.settings.metadata.types.destroy');
Route::post('projects/{project}/settings/metadata/priorities', [ProjectTicketMetadataSettingsController::class, 'storePriority'])
->name('projects.settings.metadata.priorities.store');
Route::patch('projects/{project}/settings/metadata/priorities/{ticketPriority}', [ProjectTicketMetadataSettingsController::class, 'updatePriority'])
->name('projects.settings.metadata.priorities.update');
Route::delete('projects/{project}/settings/metadata/priorities/{ticketPriority}', [ProjectTicketMetadataSettingsController::class, 'destroyPriority'])
->name('projects.settings.metadata.priorities.destroy');
Route::post('projects/{project}/settings/metadata/fields', [ProjectTicketMetadataSettingsController::class, 'storeFieldDefinition'])
->name('projects.settings.metadata.fields.store');
Route::patch('projects/{project}/settings/metadata/fields/{ticketFieldDefinition}', [ProjectTicketMetadataSettingsController::class, 'updateFieldDefinition'])
->name('projects.settings.metadata.fields.update');
Route::delete('projects/{project}/settings/metadata/fields/{ticketFieldDefinition}', [ProjectTicketMetadataSettingsController::class, 'destroyFieldDefinition'])
->name('projects.settings.metadata.fields.destroy');
});
});
+29
View File
@@ -6,6 +6,7 @@ use App\Models\BoardColumn;
use App\Models\Project;
use Database\Seeders\BoardColumnSeeder;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -92,4 +93,32 @@ class ProjectTest extends TestCase
'position' => 1,
]);
}
/**
* Ensure projects expose ticket metadata relationships after seeding.
*/
public function test_projects_expose_ticket_metadata_relationships(): void
{
$this->seed([
ProjectSeeder::class,
ProjectTicketMetadataSeeder::class,
]);
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$this->assertSame(
['Task', 'Parent', 'Bug'],
$coreProject->ticketTypes->pluck('name')->all(),
);
$this->assertSame(
['Low', 'Medium', 'High'],
$coreProject->ticketPriorities->pluck('name')->all(),
);
$this->assertSame(
['Assignee', 'Story Points'],
$coreProject->ticketFieldDefinitions->pluck('label')->all(),
);
}
}
@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature\Models;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TicketFieldDefinitionTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure the field definition factory uses a supported field type.
*/
public function test_ticket_field_definition_factory_uses_supported_types(): void
{
$ticketFieldDefinition = TicketFieldDefinition::factory()->create();
$this->assertContains(
$ticketFieldDefinition->field_type,
TicketFieldDefinition::FIELD_TYPES,
);
}
/**
* Ensure the metadata seeder creates project field definitions.
*/
public function test_metadata_seeder_creates_ticket_field_definitions(): void
{
$this->seed([
ProjectSeeder::class,
ProjectTicketMetadataSeeder::class,
]);
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$this->assertDatabaseHas('ticket_field_definitions', [
'project_id' => $coreProject->id,
'label' => 'Assignee',
'field_type' => 'user',
'position' => 1,
]);
$this->assertDatabaseHas('ticket_field_definitions', [
'project_id' => $coreProject->id,
'label' => 'Story Points',
'field_type' => 'number',
'position' => 2,
]);
}
}
@@ -0,0 +1,56 @@
<?php
namespace Tests\Feature\Models;
use App\Models\Project;
use App\Models\TicketPriority;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TicketPriorityTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure the ticket priority factory creates a default position.
*/
public function test_ticket_priority_factory_creates_a_position(): void
{
$ticketPriority = TicketPriority::factory()->create();
$this->assertSame(1, $ticketPriority->position);
}
/**
* Ensure the metadata seeder creates project-specific priorities.
*/
public function test_metadata_seeder_creates_project_priorities(): void
{
$this->seed([
ProjectSeeder::class,
ProjectTicketMetadataSeeder::class,
]);
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$this->assertDatabaseHas('ticket_priorities', [
'project_id' => $coreProject->id,
'name' => 'Low',
'position' => 1,
]);
$this->assertDatabaseHas('ticket_priorities', [
'project_id' => $coreProject->id,
'name' => 'Medium',
'position' => 2,
]);
$this->assertDatabaseHas('ticket_priorities', [
'project_id' => $coreProject->id,
'name' => 'High',
'position' => 3,
]);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature\Models;
use App\Models\Project;
use App\Models\TicketType;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TicketTypeTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure the ticket type factory creates custom types by default.
*/
public function test_ticket_type_factory_creates_custom_types(): void
{
$ticketType = TicketType::factory()->create();
$this->assertFalse($ticketType->is_builtin);
$this->assertNull($ticketType->system_key);
}
/**
* Ensure the metadata seeder creates the required built-in types for projects.
*/
public function test_metadata_seeder_creates_builtin_task_and_parent_types(): void
{
$this->seed([
ProjectSeeder::class,
ProjectTicketMetadataSeeder::class,
]);
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$this->assertDatabaseHas('ticket_types', [
'project_id' => $coreProject->id,
'name' => 'Task',
'system_key' => 'task',
'is_builtin' => true,
'position' => 1,
]);
$this->assertDatabaseHas('ticket_types', [
'project_id' => $coreProject->id,
'name' => 'Parent',
'system_key' => 'parent',
'is_builtin' => true,
'position' => 2,
]);
}
}
@@ -135,6 +135,20 @@ class ProjectControllerTest extends TestCase
'permission' => $permission,
]);
}
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'name' => 'Task',
'system_key' => 'task',
'is_builtin' => true,
]);
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'name' => 'Parent',
'system_key' => 'parent',
'is_builtin' => true,
]);
}
/**
@@ -0,0 +1,211 @@
<?php
namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
use App\Models\TicketPriority;
use App\Models\TicketType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ProjectTicketMetadataSettingsControllerTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure users with edit permission can view ticket metadata settings.
*/
public function test_users_with_edit_permission_can_view_ticket_metadata_settings(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
TicketType::factory()->for($project)->create([
'name' => 'Bug',
'position' => 3,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.metadata', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/Metadata')
->where('ticketTypes.0.name', 'Task')
->where('ticketTypes.1.name', 'Parent')
->where('ticketTypes.2.name', 'Bug'),
);
}
/**
* Ensure users can create custom ticket types.
*/
public function test_users_with_edit_permission_can_create_custom_ticket_types(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.metadata.types.store', $project), [
'name' => 'Bug',
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Bug type was added.');
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'name' => 'Bug',
'is_builtin' => false,
'system_key' => null,
]);
}
/**
* Ensure built-in ticket types can be renamed but not deleted.
*/
public function test_builtin_ticket_types_can_be_renamed_but_not_deleted(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$taskType = TicketType::query()
->where('project_id', $project->id)
->where('system_key', 'task')
->first();
if ($taskType === null) {
$this->actingAs($user)
->get(route('projects.settings.metadata', $project))
->assertOk();
$taskType = TicketType::query()
->where('project_id', $project->id)
->where('system_key', 'task')
->firstOrFail();
}
$this->actingAs($user)
->patch(route('projects.settings.metadata.types.update', [$project, $taskType]), [
'name' => 'Standard Task',
'position' => 1,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Standard Task type was updated.');
$this->assertDatabaseHas('ticket_types', [
'id' => $taskType->id,
'name' => 'Standard Task',
'system_key' => 'task',
'is_builtin' => true,
]);
$this->actingAs($user)
->delete(route('projects.settings.metadata.types.destroy', [$project, $taskType]))
->assertForbidden();
}
/**
* Ensure priorities can be created, updated, and deleted.
*/
public function test_users_with_edit_permission_can_manage_ticket_priorities(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
'position' => 1,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.metadata.priorities.store', $project), [
'name' => 'Low',
])
->assertRedirect(route('projects.settings.metadata', $project));
$this->actingAs($user)
->patch(route('projects.settings.metadata.priorities.update', [$project, $priority]), [
'name' => 'Urgent',
'position' => 2,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Urgent priority was updated.');
$this->actingAs($user)
->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority]))
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Urgent priority was removed.');
$this->assertDatabaseMissing('ticket_priorities', [
'id' => $priority->id,
]);
}
/**
* Ensure custom field definitions can be created and updated with supported types.
*/
public function test_users_with_edit_permission_can_manage_custom_field_definitions(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([
'label' => 'Story Points',
'field_type' => 'number',
'position' => 1,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.metadata.fields.store', $project), [
'label' => 'Reviewer',
'field_type' => 'user',
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Reviewer field was added.');
$this->actingAs($user)
->patch(route('projects.settings.metadata.fields.update', [$project, $fieldDefinition]), [
'label' => 'Estimated Points',
'field_type' => 'number',
'position' => 2,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Estimated Points field was updated.');
$this->assertDatabaseHas('ticket_field_definitions', [
'id' => $fieldDefinition->id,
'label' => 'Estimated Points',
'field_type' => 'number',
]);
}
/**
* Ensure unsupported field types are rejected.
*/
public function test_unsupported_field_types_are_rejected(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->actingAs($user)
->from(route('projects.settings.metadata', $project))
->post(route('projects.settings.metadata.fields.store', $project), [
'label' => 'Reviewer',
'field_type' => 'text',
])
->assertSessionHasErrors('field_type')
->assertRedirect(route('projects.settings.metadata', $project));
}
}