diff --git a/CHANGELOG.md b/CHANGELOG.md index d22dff2..76dc870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,4 +4,9 @@ added: laravel 12 -added: AGPLv3 \ No newline at end of file +added: AGPLv3 + +## 2026-01-02 + +added initial model and filament resource + diff --git a/LICENSE b/LICENSE index 8c8c48f..aaddaf1 100644 --- a/LICENSE +++ b/LICENSE @@ -219,7 +219,7 @@ If you develop a new program, and you want it to be of the greatest possible use To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - test + share-lt Copyright (C) 2026 jon This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. diff --git a/app/Filament/Resources/Entries/EntryResource.php b/app/Filament/Resources/Entries/EntryResource.php new file mode 100644 index 0000000..9b15701 --- /dev/null +++ b/app/Filament/Resources/Entries/EntryResource.php @@ -0,0 +1,58 @@ + ListEntries::route('/'), + 'create' => CreateEntry::route('/create'), + 'view' => ViewEntry::route('/{record}'), + 'edit' => EditEntry::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Entries/Pages/CreateEntry.php b/app/Filament/Resources/Entries/Pages/CreateEntry.php new file mode 100644 index 0000000..222b29b --- /dev/null +++ b/app/Filament/Resources/Entries/Pages/CreateEntry.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('title') + ->required() + ->reactive() + ->afterStateUpdated(function ($state, $set): void { + $set('slug', Str::slug((string) $state)); + }), + TextInput::make('slug') + ->required() + ->disabled(), + Textarea::make('description') + ->columnSpanFull(), + Toggle::make('is_published') + ->required(), + Toggle::make('is_featured') + ->required(), + DatePicker::make('published_at'), + RichEditor::make('content') + ->columnSpanFull(), + ]); + } +} diff --git a/app/Filament/Resources/Entries/Schemas/EntryInfolist.php b/app/Filament/Resources/Entries/Schemas/EntryInfolist.php new file mode 100644 index 0000000..6a5d87d --- /dev/null +++ b/app/Filament/Resources/Entries/Schemas/EntryInfolist.php @@ -0,0 +1,38 @@ +components([ + TextEntry::make('title'), + TextEntry::make('slug'), + TextEntry::make('description') + ->placeholder('-') + ->columnSpanFull(), + IconEntry::make('is_published') + ->boolean(), + IconEntry::make('is_featured') + ->boolean(), + TextEntry::make('published_at') + ->date() + ->placeholder('-'), + TextEntry::make('content') + ->placeholder('-') + ->columnSpanFull(), + TextEntry::make('created_at') + ->dateTime() + ->placeholder('-'), + TextEntry::make('updated_at') + ->dateTime() + ->placeholder('-'), + ]); + } +} diff --git a/app/Filament/Resources/Entries/Tables/EntriesTable.php b/app/Filament/Resources/Entries/Tables/EntriesTable.php new file mode 100644 index 0000000..57035c0 --- /dev/null +++ b/app/Filament/Resources/Entries/Tables/EntriesTable.php @@ -0,0 +1,52 @@ +columns([ + TextColumn::make('title') + ->searchable(), + TextColumn::make('slug') + ->searchable(), + IconColumn::make('is_published') + ->boolean(), + IconColumn::make('is_featured') + ->boolean(), + TextColumn::make('published_at') + ->date() + ->sortable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Models/Entry.php b/app/Models/Entry.php new file mode 100644 index 0000000..f9b7c1d --- /dev/null +++ b/app/Models/Entry.php @@ -0,0 +1,18 @@ +path('admin') ->login() ->colors([ - 'primary' => Color::Amber, + 'primary' => Color::Blue, ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') diff --git a/database/migrations/2026_01_02_134204_create_entries_table.php b/database/migrations/2026_01_02_134204_create_entries_table.php new file mode 100644 index 0000000..337002b --- /dev/null +++ b/database/migrations/2026_01_02_134204_create_entries_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->boolean('is_published')->default(false); + $table->boolean('is_featured')->default(false); + $table->date('published_at')->nullable(); + $table->mediumText('content')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entries'); + } +}; diff --git a/docs/decisions/002-AGPLv3-and-filament.md b/docs/decisions/002-AGPLv3-and-filament.md index d19b605..7bed142 100644 --- a/docs/decisions/002-AGPLv3-and-filament.md +++ b/docs/decisions/002-AGPLv3-and-filament.md @@ -12,4 +12,3 @@ It is a way to create apps that have all the features of an advanced app with ma There are so many things to do, we don't have the time to re-write or re-invent what is already achieved by filament and likely, better than we could create ourselves or likely dream of. - diff --git a/docs/decisions/003-initial-model-and-filament-resource.md b/docs/decisions/003-initial-model-and-filament-resource.md new file mode 100644 index 0000000..90e58bd --- /dev/null +++ b/docs/decisions/003-initial-model-and-filament-resource.md @@ -0,0 +1,86 @@ +# 2026-01-02 + +## Entry model + +```bash +php artisan make:model -m Entry +``` + +creates skeleton files for a model and migration + +we edit the migration to hold the fields we desire to be present in each record + +just the minimal are added for now, paying attention to the amount of data that these may need to hold + +* string (varchar): ~255 chars default. +* text: ~65 KB. +* mediumText: ~16 MB. + +```bash +php artisan migrate +``` + +runs the migration to create the table + +## Entry filament resource + +```bash +❯ php artisan filament:resource Entry + + The "title attribute" is used to label each record in the UI. + + You can leave this blank if records do not have a title. + + ┌ What is the title attribute for this model? ─────────────────┐ + │ title │ + └──────────────────────────────────────────────────────────────┘ + + ┌ Would you like to generate a read-only view page for the resource? ┐ + │ Yes │ + └────────────────────────────────────────────────────────────────────┘ + + ┌ Should the configuration be generated from the current database columns? ┐ + │ Yes │ + └──────────────────────────────────────────────────────────────────────────┘ + + INFO Filament resource [App\Filament\Resources\Entries\EntryResource] created successfully. +``` + +There is more work to do on this model but for now, a new resource is added to the admin panel by which records can be added, edited, listed. + +Full CRUD is already possible. This is amazing if you think how long this would have taken to create all this manually. + + + +### slugify + +```php + TextInput::make('title') + ->required() + ->reactive() + ->afterStateUpdated(function ($state, $set): void { + $set('slug', Str::slug((string) $state)); + }), + TextInput::make('slug') + ->required(), +``` + +adding reactive and afterStateUpdted to title automatically creates a safe slug + +I class things like this creature comforts of the framework and it shows how simple it can be to make fields active and updated by previous entries + +For the user, this reduces error and confusion over what a url should be + +### rich editor + +just changing the TextInput for content + +```php + RichEditor::make('content') + ->columnSpanFull(), +``` + +to `RichEditr` gives us a 'tiptap' rich text editor + +already, this is feeling more like a mini CMS, with relatively little effort + diff --git a/tests/Pest.php b/tests/Pest.php index 40d096b..b3a25b7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,7 +13,7 @@ pest()->extend(Tests\TestCase::class) ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) - ->in('Feature'); + ->in('Feature', 'Unit'); /* |-------------------------------------------------------------------------- diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..65c00a0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,5 +6,15 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - // + /** + * Creates the application. + */ + public function createApplication(): \Illuminate\Foundation\Application + { + $app = require __DIR__ . '/../bootstrap/app.php'; + + $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + + return $app; + } } diff --git a/tests/Unit/EntryModelTest.php b/tests/Unit/EntryModelTest.php new file mode 100644 index 0000000..e339278 --- /dev/null +++ b/tests/Unit/EntryModelTest.php @@ -0,0 +1,19 @@ +getFillable())->toEqual($expected); +}); diff --git a/tests/Unit/EntryResourceTest.php b/tests/Unit/EntryResourceTest.php new file mode 100644 index 0000000..3757f62 --- /dev/null +++ b/tests/Unit/EntryResourceTest.php @@ -0,0 +1,53 @@ +getDefaultProperties(); + + expect($defaults['model'] ?? null)->toBe(Entry::class); + expect($defaults['recordTitleAttribute'] ?? null)->toBe('title'); +}); + +it('defines the expected pages', function () { + $pages = EntryResource::getPages(); + + expect(array_keys($pages))->toEqual(['index', 'create', 'view', 'edit']); +}); + +it('accepts new record when entered via the form schema', function () { + + $data = [ + 'title' => 'Test Entry', + 'slug' => 'test-entry', + 'description' => 'This is a test entry.', + 'is_published' => true, + 'is_featured' => false, + 'published_at' => now()->toDateString(), + 'content' => '

This is the content of the test entry.

', + ]; + + $entry = new Entry(); + $entry->fill($data); + + expect($entry->slug)->toBe('test-entry'); +}); + +it('deletes a record correctly', function () { + $entry = Entry::create([ + 'title' => 'Test Entry to Delete', + 'slug' => 'test-entry-to-delete', + 'description' => 'This is a test entry.', + 'is_published' => false, + 'is_featured' => false, + 'published_at' => null, + 'content' => '

This is the content of the test entry.

', + ]); + + $entryId = $entry->id; + $entry->delete(); + + $deletedEntry = Entry::find($entryId); + expect($deletedEntry)->toBeNull(); +}); \ No newline at end of file