diff --git a/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php b/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php new file mode 100644 index 0000000..fc3470b --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/InitializeProjectTicketMetadata.php @@ -0,0 +1,42 @@ +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([ + 'is_builtin' => true, + 'position' => $index + 1, + ])->saveQuietly(); + }); + } +} diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 79d9157..dc2e63e 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -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); diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php new file mode 100644 index 0000000..e740838 --- /dev/null +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -0,0 +1,291 @@ +abortUnlessCanManage($request, $project); + $this->initializeProjectTicketMetadata->handle($project); + + 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); + + $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); + + 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, + ]); + + $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 $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 $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()); + } +} diff --git a/app/Http/Requests/StoreProjectNamedSettingRequest.php b/app/Http/Requests/StoreProjectNamedSettingRequest.php new file mode 100644 index 0000000..e15870a --- /dev/null +++ b/app/Http/Requests/StoreProjectNamedSettingRequest.php @@ -0,0 +1,41 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|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; + } +} diff --git a/app/Http/Requests/StoreTicketFieldDefinitionRequest.php b/app/Http/Requests/StoreTicketFieldDefinitionRequest.php new file mode 100644 index 0000000..1932178 --- /dev/null +++ b/app/Http/Requests/StoreTicketFieldDefinitionRequest.php @@ -0,0 +1,44 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|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; + } +} diff --git a/app/Http/Requests/UpdateProjectNamedSettingRequest.php b/app/Http/Requests/UpdateProjectNamedSettingRequest.php new file mode 100644 index 0000000..84b8fe0 --- /dev/null +++ b/app/Http/Requests/UpdateProjectNamedSettingRequest.php @@ -0,0 +1,35 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|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'); + } +} diff --git a/app/Http/Requests/UpdateTicketFieldDefinitionRequest.php b/app/Http/Requests/UpdateTicketFieldDefinitionRequest.php new file mode 100644 index 0000000..29fff74 --- /dev/null +++ b/app/Http/Requests/UpdateTicketFieldDefinitionRequest.php @@ -0,0 +1,38 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|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'); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 5bca7f8..e71e301 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -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'); + } } diff --git a/app/Models/TicketFieldDefinition.php b/app/Models/TicketFieldDefinition.php new file mode 100644 index 0000000..c7c4e2a --- /dev/null +++ b/app/Models/TicketFieldDefinition.php @@ -0,0 +1,29 @@ + + */ + public const FIELD_TYPES = [ + 'user', + 'number', + ]; + + /** @use HasFactory */ + use HasFactory; + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } +} diff --git a/app/Models/TicketPriority.php b/app/Models/TicketPriority.php new file mode 100644 index 0000000..92b1f9c --- /dev/null +++ b/app/Models/TicketPriority.php @@ -0,0 +1,21 @@ + */ + use HasFactory; + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } +} diff --git a/app/Models/TicketType.php b/app/Models/TicketType.php new file mode 100644 index 0000000..6021023 --- /dev/null +++ b/app/Models/TicketType.php @@ -0,0 +1,29 @@ + + */ + public const BUILTIN_KEYS = [ + 'task', + 'parent', + ]; + + /** @use HasFactory */ + use HasFactory; + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } +} diff --git a/database/factories/TicketFieldDefinitionFactory.php b/database/factories/TicketFieldDefinitionFactory.php new file mode 100644 index 0000000..1b4c088 --- /dev/null +++ b/database/factories/TicketFieldDefinitionFactory.php @@ -0,0 +1,28 @@ + + */ +class TicketFieldDefinitionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES), + 'label' => fake()->unique()->words(2, true), + 'position' => 1, + 'project_id' => Project::factory(), + ]; + } +} diff --git a/database/factories/TicketPriorityFactory.php b/database/factories/TicketPriorityFactory.php new file mode 100644 index 0000000..c1baec5 --- /dev/null +++ b/database/factories/TicketPriorityFactory.php @@ -0,0 +1,27 @@ + + */ +class TicketPriorityFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']), + 'position' => 1, + 'project_id' => Project::factory(), + ]; + } +} diff --git a/database/factories/TicketTypeFactory.php b/database/factories/TicketTypeFactory.php new file mode 100644 index 0000000..9c96b98 --- /dev/null +++ b/database/factories/TicketTypeFactory.php @@ -0,0 +1,29 @@ + + */ +class TicketTypeFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'is_builtin' => false, + 'name' => fake()->unique()->jobTitle(), + 'position' => 1, + 'project_id' => Project::factory(), + 'system_key' => null, + ]; + } +} diff --git a/database/migrations/2026_03_25_003100_create_ticket_types_table.php b/database/migrations/2026_03_25_003100_create_ticket_types_table.php new file mode 100644 index 0000000..938050b --- /dev/null +++ b/database/migrations/2026_03_25_003100_create_ticket_types_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/database/migrations/2026_03_25_003101_create_ticket_priorities_table.php b/database/migrations/2026_03_25_003101_create_ticket_priorities_table.php new file mode 100644 index 0000000..c2f9070 --- /dev/null +++ b/database/migrations/2026_03_25_003101_create_ticket_priorities_table.php @@ -0,0 +1,32 @@ +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('ticket_priorities'); + } +}; diff --git a/database/migrations/2026_03_25_003102_create_ticket_field_definitions_table.php b/database/migrations/2026_03_25_003102_create_ticket_field_definitions_table.php new file mode 100644 index 0000000..9c81fc0 --- /dev/null +++ b/database/migrations/2026_03_25_003102_create_ticket_field_definitions_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 448c323..725e6bf 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder UserSeeder::class, ProjectSeeder::class, BoardColumnSeeder::class, + ProjectTicketMetadataSeeder::class, TeamSeeder::class, ProjectAccessSeeder::class, ]); diff --git a/database/seeders/ProjectTicketMetadataSeeder.php b/database/seeders/ProjectTicketMetadataSeeder.php new file mode 100644 index 0000000..45c6c26 --- /dev/null +++ b/database/seeders/ProjectTicketMetadataSeeder.php @@ -0,0 +1,55 @@ +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(); + }); + } +} diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue index e8114b2..74ce5d9 100644 --- a/resources/js/pages/projects/Show.vue +++ b/resources/js/pages/projects/Show.vue @@ -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(); @@ -81,6 +84,12 @@ const projectAreas = [ + + diff --git a/resources/js/pages/projects/settings/Metadata.vue b/resources/js/pages/projects/settings/Metadata.vue new file mode 100644 index 0000000..3dc2933 --- /dev/null +++ b/resources/js/pages/projects/settings/Metadata.vue @@ -0,0 +1,503 @@ + + + diff --git a/resources/js/types/models/ticket-field-definition-model.ts b/resources/js/types/models/ticket-field-definition-model.ts new file mode 100644 index 0000000..f5d42ce --- /dev/null +++ b/resources/js/types/models/ticket-field-definition-model.ts @@ -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; +}; diff --git a/resources/js/types/models/ticket-priority-model.ts b/resources/js/types/models/ticket-priority-model.ts new file mode 100644 index 0000000..daadc87 --- /dev/null +++ b/resources/js/types/models/ticket-priority-model.ts @@ -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; +}; diff --git a/resources/js/types/models/ticket-type-model.ts b/resources/js/types/models/ticket-type-model.ts new file mode 100644 index 0000000..6c60edc --- /dev/null +++ b/resources/js/types/models/ticket-type-model.ts @@ -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; +}; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index 3b283a0..f1c6494 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -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[]; +}; diff --git a/routes/web.php b/routes/web.php index f044262..29fa174 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); }); }); diff --git a/tests/Feature/Models/ProjectTest.php b/tests/Feature/Models/ProjectTest.php index 8576079..1b64cf5 100644 --- a/tests/Feature/Models/ProjectTest.php +++ b/tests/Feature/Models/ProjectTest.php @@ -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(), + ); + } } diff --git a/tests/Feature/Models/TicketFieldDefinitionTest.php b/tests/Feature/Models/TicketFieldDefinitionTest.php new file mode 100644 index 0000000..ffb623b --- /dev/null +++ b/tests/Feature/Models/TicketFieldDefinitionTest.php @@ -0,0 +1,55 @@ +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, + ]); + } +} diff --git a/tests/Feature/Models/TicketPriorityTest.php b/tests/Feature/Models/TicketPriorityTest.php new file mode 100644 index 0000000..44ae259 --- /dev/null +++ b/tests/Feature/Models/TicketPriorityTest.php @@ -0,0 +1,56 @@ +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, + ]); + } +} diff --git a/tests/Feature/Models/TicketTypeTest.php b/tests/Feature/Models/TicketTypeTest.php new file mode 100644 index 0000000..bd87212 --- /dev/null +++ b/tests/Feature/Models/TicketTypeTest.php @@ -0,0 +1,55 @@ +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, + ]); + } +} diff --git a/tests/Feature/Projects/ProjectControllerTest.php b/tests/Feature/Projects/ProjectControllerTest.php index 97e55d3..338827a 100644 --- a/tests/Feature/Projects/ProjectControllerTest.php +++ b/tests/Feature/Projects/ProjectControllerTest.php @@ -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, + ]); } /** diff --git a/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php new file mode 100644 index 0000000..3ca1819 --- /dev/null +++ b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php @@ -0,0 +1,211 @@ +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)); + } +}