diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php new file mode 100644 index 0000000..ef52efe --- /dev/null +++ b/app/Http/Controllers/CategoryController.php @@ -0,0 +1,80 @@ +latest() + ->paginate(10, ['id', 'name', 'slug', 'description', 'color', 'created_at']); + + return view('category.index', [ + 'categories' => $categories, + ]); + } + + /** + * Show the form for creating a new category. + */ + public function create(): View + { + return view('category.create'); + } + + /** + * Store a newly created category. + */ + public function store(StoreCategoryRequest $request): RedirectResponse + { + Category::query()->create($request->validated()); + + return redirect() + ->route('category.index') + ->with('status', 'category-created'); + } + + /** + * Show the form for editing the specified category. + */ + public function edit(Category $category): View + { + return view('category.edit', [ + 'category' => $category, + ]); + } + + /** + * Update the specified category. + */ + public function update(UpdateCategoryRequest $request, Category $category): RedirectResponse + { + $category->update($request->validated()); + + return redirect() + ->route('category.edit', $category) + ->with('status', 'category-updated'); + } + + /** + * Remove the specified category. + */ + public function destroy(Category $category): RedirectResponse + { + $category->delete(); + + return redirect() + ->route('category.index') + ->with('status', 'category-deleted'); + } +} diff --git a/app/Http/Requests/StoreCategoryRequest.php b/app/Http/Requests/StoreCategoryRequest.php new file mode 100644 index 0000000..d40521f --- /dev/null +++ b/app/Http/Requests/StoreCategoryRequest.php @@ -0,0 +1,43 @@ +merge([ + 'slug' => Str::slug((string) ($this->filled('slug') ? $this->input('slug') : $this->input('name'))), + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255', 'unique:categories,name'], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash:ascii', 'unique:categories,slug'], + 'description' => ['nullable', 'string', 'max:1000'], + 'color' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], + ]; + } +} diff --git a/app/Http/Requests/UpdateCategoryRequest.php b/app/Http/Requests/UpdateCategoryRequest.php new file mode 100644 index 0000000..a14b143 --- /dev/null +++ b/app/Http/Requests/UpdateCategoryRequest.php @@ -0,0 +1,46 @@ +merge([ + 'slug' => Str::slug((string) ($this->filled('slug') ? $this->input('slug') : $this->input('name'))), + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + $categoryId = $this->route('category')?->id; + + return [ + 'name' => ['required', 'string', 'max:255', Rule::unique('categories', 'name')->ignore($categoryId)], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash:ascii', Rule::unique('categories', 'slug')->ignore($categoryId)], + 'description' => ['nullable', 'string', 'max:1000'], + 'color' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], + ]; + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..c2a45a2 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,15 @@ + */ + use HasFactory; +} diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..0dc23f3 --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,30 @@ + + */ +class CategoryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = fake()->unique()->words(2, true); + + return [ + 'name' => Str::title($name), + 'slug' => Str::slug($name), + 'description' => fake()->sentence(), + 'color' => fake()->hexColor(), + ]; + } +} diff --git a/database/migrations/2026_05_12_022545_create_categories_table.php b/database/migrations/2026_05_12_022545_create_categories_table.php new file mode 100644 index 0000000..5055570 --- /dev/null +++ b/database/migrations/2026_05_12_022545_create_categories_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name')->unique(); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('color', 7)->default('#4f46e5'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..3a6017f --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,29 @@ + 'Product Updates', 'slug' => 'product-updates', 'description' => 'Announcements and release notes.', 'color' => '#4f46e5'], + ['name' => 'Operations', 'slug' => 'operations', 'description' => 'Internal workflow and process items.', 'color' => '#10b981'], + ['name' => 'Marketing', 'slug' => 'marketing', 'description' => 'Campaigns, content, and promotions.', 'color' => '#f59e0b'], + ['name' => 'Support', 'slug' => 'support', 'description' => 'Customer support and service categories.', 'color' => '#ec4899'], + ]; + + foreach ($categories as $category) { + Category::query()->updateOrCreate( + ['slug' => $category['slug']], + $category, + ); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..3d6e3ff 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,6 +17,8 @@ class DatabaseSeeder extends Seeder { // User::factory(10)->create(); + $this->call(CategorySeeder::class); + User::factory()->create([ 'name' => 'Test User', 'email' => 'test@example.com', diff --git a/package-lock.json b/package-lock.json index 90d4905..9bdac46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "git-course", + "name": "git-iszuddin", "lockfileVersion": 3, "requires": true, "packages": { @@ -32,29 +32,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -964,6 +941,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2153,6 +2131,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2200,6 +2179,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2629,6 +2609,7 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -2806,6 +2787,7 @@ "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/resources/views/category/create.blade.php b/resources/views/category/create.blade.php new file mode 100644 index 0000000..238d283 --- /dev/null +++ b/resources/views/category/create.blade.php @@ -0,0 +1,63 @@ + + +
+

{{ __('Categories') }}

+

+ {{ __('Create Category') }} +

+
+
+ +
+
+
+
+

{{ __('Category Details') }}

+

{{ __('Choose a clear name and a color that stands out in lists.') }}

+
+ +
+ @csrf + +
+
+ + + +
+ +
+ + + +
+
+ +
+ + + +
+ +
+ +
+ + {{ __('Select a category accent color.') }} +
+ +
+ +
+ + {{ __('Cancel') }} + + +
+
+
+
+
+
diff --git a/resources/views/category/edit.blade.php b/resources/views/category/edit.blade.php new file mode 100644 index 0000000..22ee77c --- /dev/null +++ b/resources/views/category/edit.blade.php @@ -0,0 +1,92 @@ + + +
+
+

{{ __('Categories') }}

+

+ {{ __('Edit Category') }} +

+
+ + + {{ __('Back to Categories') }} + +
+
+ +
+
+ @if (session('status') === 'category-updated') +
+ {{ __('Category updated successfully.') }} +
+ @endif + +
+
+
+ +
+

{{ $category->name }}

+

{{ $category->slug }}

+
+
+
+ +
+ @csrf + @method('DELETE') +
+ +
+ @csrf + @method('PATCH') + +
+
+ + + +
+ +
+ + + +
+
+ +
+ + + +
+ +
+ +
+ + {{ __('Adjust the accent used across category lists.') }} +
+ +
+ +
+ + +
+ + {{ __('Cancel') }} + + +
+
+
+
+
+
+
diff --git a/resources/views/category/index.blade.php b/resources/views/category/index.blade.php new file mode 100644 index 0000000..5657a1b --- /dev/null +++ b/resources/views/category/index.blade.php @@ -0,0 +1,126 @@ + + +
+
+

{{ __('Content Library') }}

+

+ {{ __('Categories') }} +

+
+ + + {{ __('New Category') }} + +
+
+ +
+
+ @if (session('status')) +
+ @if (session('status') === 'category-created') + {{ __('Category created successfully.') }} + @elseif (session('status') === 'category-updated') + {{ __('Category updated successfully.') }} + @elseif (session('status') === 'category-deleted') + {{ __('Category deleted successfully.') }} + @endif +
+ @endif + +
+
+
+
+
+

{{ __('Manage Categories') }}

+

{{ __('Organize records with clear names, slugs, and color labels.') }}

+
+ + {{ trans_choice(':count category|:count categories', $categories->total(), ['count' => $categories->total()]) }} + +
+
+ +
+ + + + + + + + + + + @forelse ($categories as $category) + + + + + + + @empty + + + + @endforelse + +
{{ __('Category') }}{{ __('Slug') }}{{ __('Created') }}{{ __('Actions') }}
+
+ +
+

{{ $category->name }}

+

+ {{ $category->description ?: __('No description added.') }} +

+
+
+
+ {{ $category->slug }} + {{ $category->created_at?->format('Y-m-d H:i') }} +
+ + {{ __('Edit') }} + +
+ @csrf + @method('DELETE') + + +
+
+
+
+
+

{{ __('No categories yet') }}

+

{{ __('Create your first category to start organizing the system.') }}

+ + {{ __('Create Category') }} + +
+
+
+ + @if ($categories->hasPages()) +
+ {{ $categories->links() }} +
+ @endif +
+ + +
+
+
+
diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 9e9e0a1..f571c10 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -21,6 +21,9 @@ {{ __('Roles') }} + + {{ __('Categories') }} + @@ -82,6 +85,9 @@ {{ __('Roles') }} + + {{ __('Categories') }} + diff --git a/routes/web.php b/routes/web.php index 74935d1..81dafe8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,8 +1,9 @@ group(function () { Route::get('/role', [RoleController::class, 'index'])->name('role.index'); Route::get('/role/create', [RoleController::class, 'create'])->name('role.create'); Route::post('/role', [RoleController::class, 'store'])->name('role.store'); + Route::resource('category', CategoryController::class)->except('show'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); diff --git a/tests/Feature/CategoryCrudTest.php b/tests/Feature/CategoryCrudTest.php new file mode 100644 index 0000000..699ba04 --- /dev/null +++ b/tests/Feature/CategoryCrudTest.php @@ -0,0 +1,96 @@ +create(); + + $this->get('/category')->assertRedirect('/login'); + $this->get("/category/{$category->id}/edit")->assertRedirect('/login'); +}); + +test('authenticated users can view categories', function () { + Category::factory()->create([ + 'name' => 'Design', + 'slug' => 'design', + 'description' => 'Visual work', + 'color' => '#ec4899', + ]); + + $response = $this + ->actingAs(User::factory()->create()) + ->get('/category'); + + $response + ->assertSuccessful() + ->assertSee('Categories') + ->assertSee('Design') + ->assertSee('design') + ->assertSee('Visual work'); +}); + +test('authenticated users can create a category', function () { + $response = $this + ->actingAs(User::factory()->create()) + ->post('/category', [ + 'name' => 'Operations', + 'slug' => '', + 'description' => 'Internal workflows', + 'color' => '#10b981', + ]); + + $response->assertRedirect('/category'); + + $this->assertDatabaseHas('categories', [ + 'name' => 'Operations', + 'slug' => 'operations', + 'description' => 'Internal workflows', + 'color' => '#10b981', + ]); +}); + +test('authenticated users can update a category', function () { + $category = Category::factory()->create([ + 'name' => 'Legacy', + 'slug' => 'legacy', + 'color' => '#4f46e5', + ]); + + $response = $this + ->actingAs(User::factory()->create()) + ->patch("/category/{$category->id}", [ + 'name' => 'Marketing', + 'slug' => 'marketing', + 'description' => 'Campaign planning', + 'color' => '#f59e0b', + ]); + + $response->assertRedirect("/category/{$category->id}/edit"); + + $this->assertDatabaseHas('categories', [ + 'id' => $category->id, + 'name' => 'Marketing', + 'slug' => 'marketing', + 'description' => 'Campaign planning', + 'color' => '#f59e0b', + ]); +}); + +test('authenticated users can delete a category', function () { + $category = Category::factory()->create([ + 'name' => 'Archive', + 'slug' => 'archive', + 'color' => '#64748b', + ]); + + $response = $this + ->actingAs(User::factory()->create()) + ->delete("/category/{$category->id}"); + + $response->assertRedirect('/category'); + + $this->assertDatabaseMissing('categories', [ + 'id' => $category->id, + ]); +});