From 9b9e1a8e291ec4f1f873d4136a50857b2fb64d91 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Fri, 9 Jan 2026 13:18:37 +0000 Subject: [PATCH] feat: add category management associate with entries and text widgets --- CHANGELOG.md | 2 + .../Resources/Categroys/CategroyResource.php | 50 ++++++++++++++++ .../Categroys/Pages/CreateCategroy.php | 11 ++++ .../Categroys/Pages/EditCategroy.php | 19 ++++++ .../Categroys/Pages/ListCategroys.php | 19 ++++++ .../Categroys/Schemas/CategroyForm.php | 20 +++++++ .../Categroys/Tables/CategroysTable.php | 34 +++++++++++ .../Resources/Entries/Schemas/EntryForm.php | 59 +++++++++++-------- .../TextWidgets/Schemas/TextWidgetForm.php | 10 ++++ app/Http/Controllers/EntryController.php | 13 ++-- app/Http/Controllers/TextWidgetController.php | 9 +-- app/Http/Resources/EntryResource.php | 32 ++++++++++ app/Http/Resources/TextWidgetResource.php | 27 +++++++++ app/Models/Category.php | 10 ++++ app/Models/Entry.php | 16 +++-- app/Models/TextWidget.php | 7 +++ ...6_01_08_214229_create_categories_table.php | 28 +++++++++ ..._add_category_id_to_text_widgets_table.php | 29 +++++++++ ...15405_add_category_id_to_entries_table.php | 29 +++++++++ tests/Unit/EntryApiTest.php | 10 ++-- tests/Unit/EntryModelTest.php | 3 +- tests/Unit/TextWidgetModelTest.php | 1 + 22 files changed, 392 insertions(+), 46 deletions(-) create mode 100644 app/Filament/Resources/Categroys/CategroyResource.php create mode 100644 app/Filament/Resources/Categroys/Pages/CreateCategroy.php create mode 100644 app/Filament/Resources/Categroys/Pages/EditCategroy.php create mode 100644 app/Filament/Resources/Categroys/Pages/ListCategroys.php create mode 100644 app/Filament/Resources/Categroys/Schemas/CategroyForm.php create mode 100644 app/Filament/Resources/Categroys/Tables/CategroysTable.php create mode 100644 app/Http/Resources/EntryResource.php create mode 100644 app/Http/Resources/TextWidgetResource.php create mode 100644 app/Models/Category.php create mode 100644 database/migrations/2026_01_08_214229_create_categories_table.php create mode 100644 database/migrations/2026_01_08_215025_add_category_id_to_text_widgets_table.php create mode 100644 database/migrations/2026_01_08_215405_add_category_id_to_entries_table.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 363ddff..6cbae33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ added tags to entry model +added text widget and category + ## 2026-01-07 added simple API for entries model diff --git a/app/Filament/Resources/Categroys/CategroyResource.php b/app/Filament/Resources/Categroys/CategroyResource.php new file mode 100644 index 0000000..323c762 --- /dev/null +++ b/app/Filament/Resources/Categroys/CategroyResource.php @@ -0,0 +1,50 @@ + ListCategroys::route('/'), + 'create' => CreateCategroy::route('/create'), + 'edit' => EditCategroy::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Categroys/Pages/CreateCategroy.php b/app/Filament/Resources/Categroys/Pages/CreateCategroy.php new file mode 100644 index 0000000..594d595 --- /dev/null +++ b/app/Filament/Resources/Categroys/Pages/CreateCategroy.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('name') + ->label('Category Name') + ->required() + ->maxLength(255), + ]); + } +} diff --git a/app/Filament/Resources/Categroys/Tables/CategroysTable.php b/app/Filament/Resources/Categroys/Tables/CategroysTable.php new file mode 100644 index 0000000..211a64a --- /dev/null +++ b/app/Filament/Resources/Categroys/Tables/CategroysTable.php @@ -0,0 +1,34 @@ +columns([ + TextColumn::make('name') + ->label('Category Name') + ->sortable() + ->searchable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Entries/Schemas/EntryForm.php b/app/Filament/Resources/Entries/Schemas/EntryForm.php index bd48f4d..573a47a 100644 --- a/app/Filament/Resources/Entries/Schemas/EntryForm.php +++ b/app/Filament/Resources/Entries/Schemas/EntryForm.php @@ -2,19 +2,19 @@ namespace App\Filament\Resources\Entries\Schemas; +use App\Models\Category; use Filament\Actions\Action; use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Select; use Filament\Forms\Components\SpatieMediaLibraryFileUpload; +use Filament\Forms\Components\SpatieTagsInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Schemas\Schema; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Spatie\MediaLibrary\MediaCollections\Models\Media; -use Filament\Forms\Components\SpatieTagsInput; class EntryForm { @@ -66,13 +66,13 @@ class EntryForm $url = $item->getUrl(); $fileName = e($item->file_name); $name = e($item->name ?? ''); - - $html = "
" . - "{$fileName}" . - "
" . - "{$name}" . - "{$fileName}" . - "
"; + + $html = "
". + "{$fileName}". + "
". + "{$name}". + "{$fileName}". + '
'; return [$item->id => $html]; } catch (\Exception $e) { @@ -86,39 +86,42 @@ class EntryForm ]) ->action(function (array $data, SpatieMediaLibraryFileUpload $component): void { $record = $component->getRecord(); - - if (!$record) { + + if (! $record) { \Filament\Notifications\Notification::make() ->warning() ->title('Save the entry first') ->send(); + return; } - if (!$data['image_id']) { + if (! $data['image_id']) { return; } $sourceMedia = Media::find($data['image_id']); - if (!$sourceMedia || !file_exists($sourceMedia->getPath())) { + if (! $sourceMedia || ! file_exists($sourceMedia->getPath())) { \Filament\Notifications\Notification::make() ->danger() ->title('Image file not found') ->send(); + return; } $sourceFile = $sourceMedia->getPath(); - $tempCopy = sys_get_temp_dir() . '/' . uniqid() . '_' . $sourceMedia->file_name; + $tempCopy = sys_get_temp_dir().'/'.uniqid().'_'.$sourceMedia->file_name; copy($sourceFile, $tempCopy); try { // Verify record has ID - if (!$record->id) { + if (! $record->id) { \Filament\Notifications\Notification::make() ->danger() ->title('Entry must be saved first') ->send(); + return; } @@ -138,7 +141,7 @@ class EntryForm } catch (\Exception $e) { \Filament\Notifications\Notification::make() ->danger() - ->title('Error: ' . $e->getMessage()) + ->title('Error: '.$e->getMessage()) ->send(); } finally { if (file_exists($tempCopy)) { @@ -152,6 +155,14 @@ class EntryForm Toggle::make('is_featured') ->required(), DatePicker::make('published_at'), + Select::make('category_id') + ->label('Category') + ->options(function () { + return Category::all() + ->pluck('name', 'id') + ->toArray(); + }) + ->searchable(), RichEditor::make('content') ->columnSpanFull() ->hintAction( @@ -177,17 +188,17 @@ class EntryForm // Skip items that can't generate URLs return []; } - + $fileName = e($item->file_name); $name = e($item->name ?? ''); - + // Smaller image preview for better performance - $html = "
" . - "{$fileName}" . - "
" . - "{$name}" . - "{$fileName}" . - "
"; + $html = "
". + "{$fileName}". + "
". + "{$name}". + "{$fileName}". + '
'; return [$url => $html]; })->toArray(); diff --git a/app/Filament/Resources/TextWidgets/Schemas/TextWidgetForm.php b/app/Filament/Resources/TextWidgets/Schemas/TextWidgetForm.php index 5a0f530..53d229c 100644 --- a/app/Filament/Resources/TextWidgets/Schemas/TextWidgetForm.php +++ b/app/Filament/Resources/TextWidgets/Schemas/TextWidgetForm.php @@ -2,6 +2,8 @@ namespace App\Filament\Resources\TextWidgets\Schemas; +use App\Models\Category; +use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Schemas\Schema; @@ -20,6 +22,14 @@ class TextWidgetForm Textarea::make('content') ->rows(5) ->columnSpanFull(), + Select::make('category_id') + ->label('Category') + ->options(function () { + return Category::all() + ->pluck('name', 'id') + ->toArray(); + }) + ->searchable(), ]); } diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php index 21b0d6a..88f45e4 100644 --- a/app/Http/Controllers/EntryController.php +++ b/app/Http/Controllers/EntryController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Http\Resources\EntryResource; use App\Models\Entry; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -14,7 +15,7 @@ class EntryController extends Controller */ public function index() { - return Entry::all(); + return EntryResource::collection(Entry::with('category')->get()); } /** @@ -24,7 +25,7 @@ class EntryController extends Controller { $user = Auth::user(); - if (!$user || $user->email !== config('app.admin_email')) { + if (! $user || $user->email !== config('app.admin_email')) { return response()->json(['message' => 'Forbidden'], 403); } @@ -35,13 +36,13 @@ class EntryController extends Controller $validated['slug'] = $this->generateUniqueSlug($validated['title']); - return Entry::create($validated); + return new EntryResource(Entry::create($validated)); } private function generateUniqueSlug(string $title): string { do { - $slug = Str::slug($title) . '-' . Str::random(8); + $slug = Str::slug($title).'-'.Str::random(8); } while (Entry::where('slug', $slug)->exists()); return $slug; @@ -54,7 +55,7 @@ class EntryController extends Controller { $this->authorize('view', $entry); - return $entry; + return new EntryResource($entry); } /** @@ -69,7 +70,7 @@ class EntryController extends Controller $entry->update($validated); - return $entry; + return new EntryResource($entry); } /** diff --git a/app/Http/Controllers/TextWidgetController.php b/app/Http/Controllers/TextWidgetController.php index 7318464..113b9d3 100644 --- a/app/Http/Controllers/TextWidgetController.php +++ b/app/Http/Controllers/TextWidgetController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Http\Resources\TextWidgetResource; use App\Models\TextWidget; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -13,7 +14,7 @@ class TextWidgetController extends Controller */ public function index() { - return TextWidget::all(); + return TextWidget::with('category')->get()->map(fn ($tw) => new TextWidgetResource($tw)); } /** @@ -33,7 +34,7 @@ class TextWidgetController extends Controller 'content' => 'required|string', ]); - return TextWidget::create($validated); + return new TextWidgetResource(TextWidget::create($validated)); } /** @@ -41,7 +42,7 @@ class TextWidgetController extends Controller */ public function show(TextWidget $textWidget) { - return $textWidget; + return new TextWidgetResource($textWidget->load('category')); } /** @@ -57,7 +58,7 @@ class TextWidgetController extends Controller $textWidget->update($validated); - return $textWidget; + return new TextWidgetResource($textWidget->load('category')); } /** diff --git a/app/Http/Resources/EntryResource.php b/app/Http/Resources/EntryResource.php new file mode 100644 index 0000000..e674d9a --- /dev/null +++ b/app/Http/Resources/EntryResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'slug' => $this->slug, + 'description' => $this->description, + 'is_published' => $this->is_published, + 'is_featured' => $this->is_featured, + 'published_at' => $this->published_at, + 'content' => $this->content, + 'category' => $this->category->name ?? null, + 'featured_image_url' => $this->getFirstMediaUrl('featured-image') ?: null, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/TextWidgetResource.php b/app/Http/Resources/TextWidgetResource.php new file mode 100644 index 0000000..141452e --- /dev/null +++ b/app/Http/Resources/TextWidgetResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'description' => $this->description, + 'content' => $this->content, + 'category' => $this->category->name ?? null, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..e82a9cc --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,10 @@ +belongsTo(Category::class); + } } diff --git a/app/Models/TextWidget.php b/app/Models/TextWidget.php index a7e6991..73f121e 100644 --- a/app/Models/TextWidget.php +++ b/app/Models/TextWidget.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class TextWidget extends Model { @@ -13,5 +14,11 @@ class TextWidget extends Model 'title', 'description', 'content', + 'category_id', ]; + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } } diff --git a/database/migrations/2026_01_08_214229_create_categories_table.php b/database/migrations/2026_01_08_214229_create_categories_table.php new file mode 100644 index 0000000..b86b7b1 --- /dev/null +++ b/database/migrations/2026_01_08_214229_create_categories_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2026_01_08_215025_add_category_id_to_text_widgets_table.php b/database/migrations/2026_01_08_215025_add_category_id_to_text_widgets_table.php new file mode 100644 index 0000000..72dd1ac --- /dev/null +++ b/database/migrations/2026_01_08_215025_add_category_id_to_text_widgets_table.php @@ -0,0 +1,29 @@ +foreignId('category_id')->nullable()->constrained('categories'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('text_widgets', function (Blueprint $table) { + $table->dropForeign(['category_id']); + $table->dropColumn('category_id'); + }); + } +}; diff --git a/database/migrations/2026_01_08_215405_add_category_id_to_entries_table.php b/database/migrations/2026_01_08_215405_add_category_id_to_entries_table.php new file mode 100644 index 0000000..96c8b89 --- /dev/null +++ b/database/migrations/2026_01_08_215405_add_category_id_to_entries_table.php @@ -0,0 +1,29 @@ +foreignId('category_id')->nullable()->constrained('categories'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entries', function (Blueprint $table) { + $table->dropForeign(['category_id']); + $table->dropColumn('category_id'); + }); + } +}; diff --git a/tests/Unit/EntryApiTest.php b/tests/Unit/EntryApiTest.php index cbd3e33..0b8fea5 100644 --- a/tests/Unit/EntryApiTest.php +++ b/tests/Unit/EntryApiTest.php @@ -1,9 +1,9 @@ actingAs($user)->getJson('/api/entries'); $response->assertOk() - ->assertJsonCount(3); + ->assertJsonCount(3, 'data'); }); test('can create an entry', function () { @@ -30,7 +30,7 @@ test('can create an entry', function () { $response = $this->postJson('/api/entries', $data); $response->assertCreated() - ->assertJsonFragment($data); + ->assertJsonFragment($data); }); test('can show an entry', function () { @@ -40,7 +40,7 @@ test('can show an entry', function () { $response = $this->actingAs($user)->getJson("/api/entries/{$entry->id}"); $response->assertOk() - ->assertJsonFragment(['id' => $entry->id]); + ->assertJsonFragment(['id' => $entry->id]); }); test('can update an entry', function () { @@ -51,7 +51,7 @@ test('can update an entry', function () { $response = $this->actingAs($user)->putJson("/api/entries/{$entry->id}", $data); $response->assertOk() - ->assertJsonFragment($data); + ->assertJsonFragment($data); }); test('can delete an entry', function () { diff --git a/tests/Unit/EntryModelTest.php b/tests/Unit/EntryModelTest.php index e339278..37fa5a6 100644 --- a/tests/Unit/EntryModelTest.php +++ b/tests/Unit/EntryModelTest.php @@ -11,9 +11,10 @@ it('has correct fillable attributes', function () { 'is_featured', 'published_at', 'content', + 'category_id', ]; - $entry = new Entry(); + $entry = new Entry; expect($entry->getFillable())->toEqual($expected); }); diff --git a/tests/Unit/TextWidgetModelTest.php b/tests/Unit/TextWidgetModelTest.php index 5dc13f6..13f405b 100644 --- a/tests/Unit/TextWidgetModelTest.php +++ b/tests/Unit/TextWidgetModelTest.php @@ -7,6 +7,7 @@ it('has correct fillable attributes', function () { 'title', 'description', 'content', + 'category_id', ]; $entry = new TextWidget;