From f2543470fd76098dd6f30071905f9ce038698fc2 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Sat, 20 Jun 2026 04:40:53 +0330 Subject: [PATCH] feat: implement Pages CMS entity - Add PageStatusEnum (DELETED/PUBLISHED/DRAFT/SCHEDULED) - Add create_pages_table migration (heading, slug unique, content, SEO fields, status, published_at) - Add Page model with MorphOne image and cascade-delete hook - Add PageFactory with withImage state - Add PageSeeder integrated into TestSeeder - Add PageResource with auto-slug, TinyEditor, conditional published_at (required when Scheduled), image Fieldset, hints - Add PageResourceTest covering CRUD and cascade image deletion - Update CACHE.md with pages.{slug} key and IMPLEMENTATION.md Co-authored-by: Cursor --- admin/CACHE.md | 1 + admin/IMPLEMENTATION.md | 3 +- admin/app/Enums/PageStatusEnum.php | 37 ++++ admin/app/Filament/Resources/PageResource.php | 177 ++++++++++++++++++ .../PageResource/Pages/CreatePage.php | 18 ++ .../Resources/PageResource/Pages/EditPage.php | 26 +++ .../PageResource/Pages/ListPages.php | 23 +++ admin/app/Models/Page.php | 61 ++++++ admin/database/factories/PageFactory.php | 46 +++++ .../2026_06_20_000005_create_pages_table.php | 33 ++++ admin/database/seeders/PageSeeder.php | 18 ++ admin/database/seeders/TestSeeder.php | 1 + .../Filament/Resource/PageResourceTest.php | 103 ++++++++++ 13 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 admin/app/Enums/PageStatusEnum.php create mode 100644 admin/app/Filament/Resources/PageResource.php create mode 100644 admin/app/Filament/Resources/PageResource/Pages/CreatePage.php create mode 100644 admin/app/Filament/Resources/PageResource/Pages/EditPage.php create mode 100644 admin/app/Filament/Resources/PageResource/Pages/ListPages.php create mode 100644 admin/app/Models/Page.php create mode 100644 admin/database/factories/PageFactory.php create mode 100644 admin/database/migrations/2026_06_20_000005_create_pages_table.php create mode 100644 admin/database/seeders/PageSeeder.php create mode 100644 admin/tests/Feature/Filament/Resource/PageResourceTest.php diff --git a/admin/CACHE.md b/admin/CACHE.md index 3763ca43..dacaf4eb 100644 --- a/admin/CACHE.md +++ b/admin/CACHE.md @@ -21,6 +21,7 @@ Legend: `[ ]` not started, `[x]` implemented. | 5 | `products.slug.{slug}` | Single product record (with images, attributes, brand, category) | 30 min | Product saved / deleted | | 6 | `products.category.{category_id}` | Published products list for a category page | 15 min | Product saved / deleted | | 7 | `varieties.product.{product_id}` | All varieties for a product (price, inventory, status) | 15 min | Variety saved / deleted | +| 9 | `pages.{slug}` | Single published page record | 1 hour | Page saved / deleted | --- diff --git a/admin/IMPLEMENTATION.md b/admin/IMPLEMENTATION.md index aee5fa98..2ee0f540 100644 --- a/admin/IMPLEMENTATION.md +++ b/admin/IMPLEMENTATION.md @@ -27,6 +27,7 @@ Catalog layer and platform basics. - [x] Banners (polymorphic images via `images` table) - [x] Sliders + Slides (each slide has one polymorphic image via `images` table) - [x] Menus + Menu Items (nested via `parent_id`; optional polymorphic image per item) +- [x] Pages (CMS static pages; polymorphic image; SCHEDULED status with `published_at`) - [~] Addresses (model only, no resource yet) Sample data for manual admin testing lives in `TestSeeder` (`php artisan db:seed --class=TestSeeder`); `DatabaseSeeder` holds only necessary data. @@ -62,7 +63,7 @@ Depend mostly on Images only. - [x] Banners - [x] Sliders + Slides - [x] Menus + Menu Items -- [ ] Pages +- [x] Pages - [ ] FAQs - [ ] Reviews - [ ] Wishlists diff --git a/admin/app/Enums/PageStatusEnum.php b/admin/app/Enums/PageStatusEnum.php new file mode 100644 index 00000000..eda2f0e6 --- /dev/null +++ b/admin/app/Enums/PageStatusEnum.php @@ -0,0 +1,37 @@ + 'Deleted', + self::PUBLISHED => 'Published', + self::DRAFT => 'Draft', + self::SCHEDULED => 'Scheduled', + }; + } + + public function color(): string + { + return match ($this) { + self::DELETED => 'danger', + self::PUBLISHED => 'success', + self::DRAFT => 'warning', + self::SCHEDULED => 'info', + }; + } +} diff --git a/admin/app/Filament/Resources/PageResource.php b/admin/app/Filament/Resources/PageResource.php new file mode 100644 index 00000000..2eb214f6 --- /dev/null +++ b/admin/app/Filament/Resources/PageResource.php @@ -0,0 +1,177 @@ +components([ + TextInput::make('heading') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, ?string $state, Set $set): void { + if ($operation === 'create') { + $set('slug', Str::slug((string) $state)); + } + }) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The page title shown to visitors, e.g. "About Us".'), + TextInput::make('slug') + ->disabled() + ->dehydrated() + ->required() + ->maxLength(255) + ->unique(PageModel::class, 'slug', ignoreRecord: true) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Auto-generated from the heading. Used as the URL path, e.g. /about-us.'), + TinyEditor::make('content') + ->nullable() + ->columnSpanFull() + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The main body content of the page, edited with the rich text editor.'), + TextInput::make('title') + ->maxLength(255) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('SEO page title shown in the browser tab and search results. Leave blank to use the heading.'), + TextInput::make('description') + ->maxLength(255) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Meta description shown in search engine results. Aim for 150-160 characters.'), + Toggle::make('no_index') + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('When enabled, search engines are instructed not to index this page.'), + TextInput::make('canonical') + ->maxLength(255) + ->url() + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Canonical URL to prevent duplicate content issues. Leave blank unless this page should point to another URL.'), + Select::make('status') + ->required() + ->live() + ->options(PageStatusEnum::options()) + ->default(PageStatusEnum::PUBLISHED->value) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Published: visible on the frontend. Draft: hidden. Scheduled: published on the date set below. Deleted: removed from listings.'), + DateTimePicker::make('published_at') + ->nullable() + ->required(fn (Get $get): bool => (int) $get('status') === PageStatusEnum::SCHEDULED->value) + ->hidden(fn (Get $get): bool => (int) $get('status') !== PageStatusEnum::SCHEDULED->value) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The date and time this page will be made public. Only applies when status is Scheduled.'), + Fieldset::make('Image') + ->relationship('image') + ->schema([ + FileUpload::make('path') + ->image() + ->nullable() + ->columnSpanFull(), + TextInput::make('alt_text') + ->nullable() + ->maxLength(255), + ]) + ->mutateRelationshipDataBeforeSaveUsing(function (array $data, PageModel $record): array { + if (empty($data['path'])) { + $record->image?->delete(); + } + + return $data; + }) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('heading') + ->limit(30) + ->wrap() + ->searchable(), + TextColumn::make('slug') + ->limit(30) + ->wrap() + ->searchable(), + IconColumn::make('no_index') + ->boolean(), + TextColumn::make('status') + ->getStateUsing(fn (PageModel $record): string => $record->status->label()) + ->color(fn (PageModel $record): string => $record->status->color()) + ->sortable(), + TextColumn::make('published_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListPages::route('/'), + 'create' => CreatePage::route('/create'), + 'edit' => EditPage::route('/{record}/edit'), + ]; + } +} diff --git a/admin/app/Filament/Resources/PageResource/Pages/CreatePage.php b/admin/app/Filament/Resources/PageResource/Pages/CreatePage.php new file mode 100644 index 00000000..cdbff447 --- /dev/null +++ b/admin/app/Filament/Resources/PageResource/Pages/CreatePage.php @@ -0,0 +1,18 @@ +getResource()::getUrl('index'); + } +} diff --git a/admin/app/Filament/Resources/PageResource/Pages/EditPage.php b/admin/app/Filament/Resources/PageResource/Pages/EditPage.php new file mode 100644 index 00000000..defc2284 --- /dev/null +++ b/admin/app/Filament/Resources/PageResource/Pages/EditPage.php @@ -0,0 +1,26 @@ +getResource()::getUrl('index'); + } + + protected function getHeaderActions(): array + { + return [ + DeleteAction::make(), + ]; + } +} diff --git a/admin/app/Filament/Resources/PageResource/Pages/ListPages.php b/admin/app/Filament/Resources/PageResource/Pages/ListPages.php new file mode 100644 index 00000000..6eb4e61d --- /dev/null +++ b/admin/app/Filament/Resources/PageResource/Pages/ListPages.php @@ -0,0 +1,23 @@ + */ + use HasFactory; + + protected $fillable = [ + 'heading', + 'slug', + 'content', + 'title', + 'description', + 'no_index', + 'canonical', + 'status', + 'published_at', + ]; + + protected $casts = [ + 'no_index' => 'boolean', + 'status' => PageStatusEnum::class, + 'published_at' => 'datetime', + ]; + + protected static function booted(): void + { + static::deleting(function (Page $page): void { + $page->image?->delete(); + }); + } + + public function image(): MorphOne + { + return $this->morphOne(Image::class, 'imageable'); + } +} diff --git a/admin/database/factories/PageFactory.php b/admin/database/factories/PageFactory.php new file mode 100644 index 00000000..052c0806 --- /dev/null +++ b/admin/database/factories/PageFactory.php @@ -0,0 +1,46 @@ + + */ +class PageFactory extends Factory +{ + protected $model = Page::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'heading' => fake()->words(4, true), + 'slug' => fn (array $attributes): string => Str::slug($attributes['heading']) . '-' . fake()->unique()->numberBetween(1, 9999), + 'content' => fake()->paragraphs(3, true), + 'title' => fake()->words(5, true), + 'description' => fake()->sentence(), + 'no_index' => fake()->boolean(20), + 'canonical' => null, + 'status' => fake()->randomElement(PageStatusEnum::cases()), + 'published_at' => null, + ]; + } + + public function withImage(): static + { + return $this->afterCreating(function (Page $page): void { + $page->image()->create([ + 'path' => fake()->imageUrl(), + 'alt_text' => fake()->words(2, true), + ]); + }); + } +} diff --git a/admin/database/migrations/2026_06_20_000005_create_pages_table.php b/admin/database/migrations/2026_06_20_000005_create_pages_table.php new file mode 100644 index 00000000..5e715a7e --- /dev/null +++ b/admin/database/migrations/2026_06_20_000005_create_pages_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('heading'); + $table->string('slug')->unique(); + $table->longText('content')->nullable(); + $table->string('title')->nullable(); + $table->string('description')->nullable(); + $table->boolean('no_index')->default(false); + $table->string('canonical')->nullable(); + $table->unsignedTinyInteger('status')->default(PageStatusEnum::PUBLISHED->value); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/admin/database/seeders/PageSeeder.php b/admin/database/seeders/PageSeeder.php new file mode 100644 index 00000000..0c31f8e7 --- /dev/null +++ b/admin/database/seeders/PageSeeder.php @@ -0,0 +1,18 @@ +each->delete(); + + Page::factory()->count(10)->withImage()->create(); + } +} diff --git a/admin/database/seeders/TestSeeder.php b/admin/database/seeders/TestSeeder.php index dc276534..92f7dcce 100644 --- a/admin/database/seeders/TestSeeder.php +++ b/admin/database/seeders/TestSeeder.php @@ -20,6 +20,7 @@ public function run(): void BannerSeeder::class, SliderSeeder::class, MenuSeeder::class, + PageSeeder::class, ]); } } diff --git a/admin/tests/Feature/Filament/Resource/PageResourceTest.php b/admin/tests/Feature/Filament/Resource/PageResourceTest.php new file mode 100644 index 00000000..75c44b02 --- /dev/null +++ b/admin/tests/Feature/Filament/Resource/PageResourceTest.php @@ -0,0 +1,103 @@ +assertOk(); +}); + +it('can list pages in the table.', function () { + $pages = Page::factory()->count(5)->create(); + + livewire(PageResource\Pages\ListPages::class) + ->assertCanSeeTableRecords($pages); +}); + +it('can render edit page.', function () { + $page = Page::factory()->create(); + + get(PageResource::getUrl('edit', [ + 'record' => $page, + ]))->assertOk(); +}); + +it('can update page model.', function () { + $page = Page::factory()->create(); + $newPage = Page::factory()->make(); + + livewire(PageResource\Pages\EditPage::class, [ + 'record' => $page->getRouteKey(), + ]) + ->fillForm([ + 'heading' => $newPage->heading, + 'slug' => $newPage->slug, + 'title' => $newPage->title, + 'description' => $newPage->description, + 'no_index' => $newPage->no_index, + 'status' => $newPage->status->value, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($page->refresh()) + ->heading->toBe($newPage->heading) + ->slug->toBe($newPage->slug) + ->status->toBe($newPage->status); +}); + +it('can create page model.', function () { + $newPage = Page::factory()->make(); + + livewire(PageResource\Pages\CreatePage::class) + ->fillForm([ + 'heading' => $newPage->heading, + 'slug' => $newPage->slug, + 'title' => $newPage->title, + 'description' => $newPage->description, + 'no_index' => $newPage->no_index, + 'status' => $newPage->status->value, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas(Page::class, [ + 'heading' => $newPage->heading, + 'slug' => $newPage->slug, + 'status' => $newPage->status->value, + ]); +}); + +it('can delete page model.', function () { + $page = Page::factory()->create(); + + livewire(PageResource\Pages\EditPage::class, [ + 'record' => $page->getRouteKey(), + ]) + ->callAction(DeleteAction::class); + + $this->assertModelMissing($page); +}); + +it('deletes image when page is deleted.', function () { + $page = Page::factory()->withImage()->create(); + $image = $page->image; + + livewire(PageResource\Pages\EditPage::class, [ + 'record' => $page->getRouteKey(), + ]) + ->callAction(DeleteAction::class); + + $this->assertModelMissing($page); + $this->assertModelMissing($image); +});