From 7d4b0733b366faa12e03382d5e96e3091627a924 Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 25 Mar 2026 00:44:37 +0100 Subject: [PATCH] Implement projects and project keys --- app/Http/Controllers/ProjectController.php | 40 +++++ app/Http/Requests/StoreProjectRequest.php | 46 ++++++ app/Models/Project.php | 24 +++ database/factories/ProjectFactory.php | 26 +++ ...026_03_24_234007_create_projects_table.php | 29 ++++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/ProjectSeeder.php | 24 +++ resources/js/components/AppSidebar.vue | 14 +- resources/js/pages/projects/Create.vue | 102 ++++++++++++ resources/js/pages/projects/Index.vue | 93 +++++++++++ resources/js/pages/projects/Show.vue | 100 ++++++++++++ resources/js/types/models/project-model.ts | 8 + resources/js/types/page-props.ts | 9 ++ routes/web.php | 4 + tests/Feature/Models/ProjectTest.php | 48 ++++++ .../Projects/ProjectControllerTest.php | 149 ++++++++++++++++++ 16 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/ProjectController.php create mode 100644 app/Http/Requests/StoreProjectRequest.php create mode 100644 app/Models/Project.php create mode 100644 database/factories/ProjectFactory.php create mode 100644 database/migrations/2026_03_24_234007_create_projects_table.php create mode 100644 database/seeders/ProjectSeeder.php create mode 100644 resources/js/pages/projects/Create.vue create mode 100644 resources/js/pages/projects/Index.vue create mode 100644 resources/js/pages/projects/Show.vue create mode 100644 resources/js/types/models/project-model.ts create mode 100644 tests/Feature/Models/ProjectTest.php create mode 100644 tests/Feature/Projects/ProjectControllerTest.php diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php new file mode 100644 index 0000000..a9998ed --- /dev/null +++ b/app/Http/Controllers/ProjectController.php @@ -0,0 +1,40 @@ + Project::query() + ->orderByDesc('id') + ->get(), + ]); + } + + public function create(): Response + { + return Inertia::render('projects/Create'); + } + + public function store(StoreProjectRequest $request): RedirectResponse + { + $project = Project::query()->create($request->validated()); + + return to_route('projects.show', $project); + } + + public function show(Project $project): Response + { + return Inertia::render('projects/Show', [ + 'project' => $project, + ]); + } +} diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php new file mode 100644 index 0000000..1739dba --- /dev/null +++ b/app/Http/Requests/StoreProjectRequest.php @@ -0,0 +1,46 @@ +user() !== null; + } + + protected function prepareForValidation(): void + { + if ($this->has('key')) { + $this->merge([ + 'key' => strtoupper((string) $this->string('key')->trim()), + ]); + } + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'key' => [ + 'required', + 'string', + 'max:5', + 'regex:/^[A-Z0-9]+$/', + Rule::unique('projects', 'key'), + ], + 'name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php new file mode 100644 index 0000000..aee2442 --- /dev/null +++ b/app/Models/Project.php @@ -0,0 +1,24 @@ + */ + use HasFactory; + + protected function key(): Attribute + { + return Attribute::make( + set: fn (string $value): string => Str::upper(trim($value)), + ); + } +} diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php new file mode 100644 index 0000000..11c4246 --- /dev/null +++ b/database/factories/ProjectFactory.php @@ -0,0 +1,26 @@ + + */ +class ProjectFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => Str::title(fake()->unique()->words(asText: true)), + 'key' => Str::upper(fake()->unique()->lexify('?????')), + ]; + } +} diff --git a/database/migrations/2026_03_24_234007_create_projects_table.php b/database/migrations/2026_03_24_234007_create_projects_table.php new file mode 100644 index 0000000..fe5a66e --- /dev/null +++ b/database/migrations/2026_03_24_234007_create_projects_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name'); + $table->string('key', 5)->unique(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('projects'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c0dcc6a..db62723 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ + ProjectSeeder::class, UserSeeder::class, ]); } diff --git a/database/seeders/ProjectSeeder.php b/database/seeders/ProjectSeeder.php new file mode 100644 index 0000000..e46e9b1 --- /dev/null +++ b/database/seeders/ProjectSeeder.php @@ -0,0 +1,24 @@ +count(3) + ->sequence( + ['name' => 'Core Platform', 'key' => 'CORE'], + ['name' => 'Authentication', 'key' => 'AUTH'], + ['name' => 'Operations', 'key' => 'OPS'], + ) + ->create(); + } +} diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index e2199ab..801ad56 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,12 @@ + + diff --git a/resources/js/pages/projects/Index.vue b/resources/js/pages/projects/Index.vue new file mode 100644 index 0000000..251a46b --- /dev/null +++ b/resources/js/pages/projects/Index.vue @@ -0,0 +1,93 @@ + + + diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue new file mode 100644 index 0000000..4c17fe0 --- /dev/null +++ b/resources/js/pages/projects/Show.vue @@ -0,0 +1,100 @@ + + + diff --git a/resources/js/types/models/project-model.ts b/resources/js/types/models/project-model.ts new file mode 100644 index 0000000..48bb830 --- /dev/null +++ b/resources/js/types/models/project-model.ts @@ -0,0 +1,8 @@ +export type ProjectModel = { + id: number; + name: string; + key: string; + 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 f9504dd..3e62aea 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -1,6 +1,15 @@ +import type { ProjectModel } from '@/types/models/project-model'; import type { UserModel } from '@/types/models/user-model'; export type AdminUsersPageProps = { users: UserModel[]; status?: string | null; }; + +export type ProjectsIndexPageProps = { + projects: ProjectModel[]; +}; + +export type ProjectShowPageProps = { + project: ProjectModel; +}; diff --git a/routes/web.php b/routes/web.php index 2064ef9..c353e77 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { Route::inertia('dashboard', 'Dashboard')->name('dashboard'); + + Route::resource('projects', ProjectController::class) + ->only(['index', 'create', 'store', 'show']); }); Route::middleware(['auth', 'verified', 'can:access-admin']) diff --git a/tests/Feature/Models/ProjectTest.php b/tests/Feature/Models/ProjectTest.php new file mode 100644 index 0000000..13e0142 --- /dev/null +++ b/tests/Feature/Models/ProjectTest.php @@ -0,0 +1,48 @@ +create([ + 'key' => 'core', + ]); + + $this->assertSame('CORE', $project->fresh()->key); + } + + /** + * Ensure the project seeder creates the expected starter projects. + */ + public function test_project_seeder_creates_projects(): void + { + $this->seed(ProjectSeeder::class); + + $this->assertDatabaseHas('projects', [ + 'name' => 'Core Platform', + 'key' => 'CORE', + ]); + + $this->assertDatabaseHas('projects', [ + 'name' => 'Authentication', + 'key' => 'AUTH', + ]); + + $this->assertDatabaseHas('projects', [ + 'name' => 'Operations', + 'key' => 'OPS', + ]); + } +} diff --git a/tests/Feature/Projects/ProjectControllerTest.php b/tests/Feature/Projects/ProjectControllerTest.php new file mode 100644 index 0000000..686156a --- /dev/null +++ b/tests/Feature/Projects/ProjectControllerTest.php @@ -0,0 +1,149 @@ +get(route('projects.index')) + ->assertRedirect(route('login')); + + $this->get(route('projects.create')) + ->assertRedirect(route('login')); + } + + /** + * Ensure authenticated users can view the projects index. + */ + public function test_authenticated_users_can_view_the_projects_index(): void + { + $user = User::factory()->create(); + $firstProject = Project::factory()->create([ + 'name' => 'Core Platform', + 'key' => 'CORE', + ]); + $secondProject = Project::factory()->create([ + 'name' => 'Authentication', + 'key' => 'AUTH', + ]); + + $this->actingAs($user) + ->get(route('projects.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('projects/Index') + ->has('projects', 2) + ->where('projects.0.id', $secondProject->id) + ->where('projects.1.id', $firstProject->id), + ); + } + + /** + * Ensure authenticated users can view the project creation screen. + */ + public function test_authenticated_users_can_view_the_project_creation_screen(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('projects.create')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('projects/Create'), + ); + } + + /** + * Ensure project keys are normalized to uppercase on creation. + */ + public function test_authenticated_users_can_create_projects_with_uppercase_keys(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->post(route('projects.store'), [ + 'name' => 'Kanban Foundation', + 'key' => 'core', + ]); + + $project = Project::query()->firstOrFail(); + + $response->assertRedirect(route('projects.show', $project)); + + $this->assertDatabaseHas('projects', [ + 'name' => 'Kanban Foundation', + 'key' => 'CORE', + ]); + } + + /** + * Ensure project keys remain unique after normalization. + */ + public function test_project_keys_must_be_unique_case_insensitively(): void + { + $user = User::factory()->create(); + + Project::factory()->create([ + 'key' => 'AUTH', + ]); + + $this->actingAs($user) + ->from(route('projects.create')) + ->post(route('projects.store'), [ + 'name' => 'Another Auth Project', + 'key' => 'auth', + ]) + ->assertSessionHasErrors('key') + ->assertRedirect(route('projects.create')); + } + + /** + * Ensure project keys cannot exceed five characters. + */ + public function test_project_keys_may_not_exceed_five_characters(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->from(route('projects.create')) + ->post(route('projects.store'), [ + 'name' => 'Too Long', + 'key' => 'KANBAN', + ]) + ->assertSessionHasErrors('key') + ->assertRedirect(route('projects.create')); + } + + /** + * Ensure authenticated users can view the project hub page. + */ + public function test_authenticated_users_can_view_a_project_hub_page(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'name' => 'Core Platform', + 'key' => 'CORE', + ]); + + $this->actingAs($user) + ->get(route('projects.show', $project)) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('projects/Show') + ->where('project.id', $project->id) + ->where('project.key', 'CORE'), + ); + } +}