From 982cd1b0866cde4a9cc810f61f111d86960b62a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omer=20=C5=A0abi=C4=87?= Date: Mon, 28 Jul 2025 15:20:06 +0200 Subject: [PATCH 1/3] feat: implement menus model with migration --- .../2025_07_28_083630_create_menus_table.php | 34 ++++++++++++++++ ..._28_094927_create_cms_menu_items_table.php | 32 +++++++++++++++ src/CmsPlugin.php | 13 ++++++- src/Enums/MenuItemType.php | 21 ++++++++++ src/Models/Menu.php | 24 ++++++++++++ src/Models/Menu/Item.php | 39 +++++++++++++++++++ 6 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2025_07_28_083630_create_menus_table.php create mode 100644 database/migrations/2025_07_28_094927_create_cms_menu_items_table.php create mode 100644 src/Enums/MenuItemType.php create mode 100644 src/Models/Menu.php create mode 100644 src/Models/Menu/Item.php diff --git a/database/migrations/2025_07_28_083630_create_menus_table.php b/database/migrations/2025_07_28_083630_create_menus_table.php new file mode 100644 index 0000000..5eee5dd --- /dev/null +++ b/database/migrations/2025_07_28_083630_create_menus_table.php @@ -0,0 +1,34 @@ +id(); + + if (config('eclipse-cms.tenancy.enabled')) { + $tenantClass = config('eclipse-cms.tenancy.model'); + /** @var \Illuminate\Database\Eloquent\Model $tenant */ + $tenant = new $tenantClass; + $table->foreignId(config('eclipse-cms.tenancy.foreign_key')) + ->constrained($tenant->getTable(), $tenant->getKeyName()) + ->cascadeOnUpdate() + ->cascadeOnDelete(); + } + + $table->string('title'); + $table->boolean('is_active')->default(true); + $table->string('code')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cms_menus'); + }}; diff --git a/database/migrations/2025_07_28_094927_create_cms_menu_items_table.php b/database/migrations/2025_07_28_094927_create_cms_menu_items_table.php new file mode 100644 index 0000000..1248e9c --- /dev/null +++ b/database/migrations/2025_07_28_094927_create_cms_menu_items_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('label'); + $table->foreignId('menu_id') + ->constrained('cms_menus', 'id') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->string('type'); + $table->string('linkable_class'); + $table->string('linkable_id'); + $table->boolean('new_tab')->default(false); + $table->boolean('is_active')->default(true); + $table->integer('sort')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cms_menu_items'); + } +}; diff --git a/src/CmsPlugin.php b/src/CmsPlugin.php index 2b29071..b0a3e54 100644 --- a/src/CmsPlugin.php +++ b/src/CmsPlugin.php @@ -2,9 +2,18 @@ namespace Eclipse\Cms; +use Eclipse\Cms\Models\Page; +use Eclipse\Cms\Models\Section; +use Eclipse\Common\Foundation\Plugins\HasLinkables; use Eclipse\Common\Foundation\Plugins\Plugin; -class CmsPlugin extends Plugin +class CmsPlugin extends Plugin implements HasLinkables { - // + public function getLinkables(): array + { + return [ + Page::class => 'Page', + Section::class => 'Section', + ]; + } } diff --git a/src/Enums/MenuItemType.php b/src/Enums/MenuItemType.php new file mode 100644 index 0000000..2fc9a0f --- /dev/null +++ b/src/Enums/MenuItemType.php @@ -0,0 +1,21 @@ + 'Data record', + self::CustomUrl => 'Custom URL', + self::Group => 'Group', + }; + } +} diff --git a/src/Models/Menu.php b/src/Models/Menu.php new file mode 100644 index 0000000..7d211b3 --- /dev/null +++ b/src/Models/Menu.php @@ -0,0 +1,24 @@ + 'boolean', + ]; + } +} diff --git a/src/Models/Menu/Item.php b/src/Models/Menu/Item.php new file mode 100644 index 0000000..b5150cf --- /dev/null +++ b/src/Models/Menu/Item.php @@ -0,0 +1,39 @@ +belongsTo(Menu::class); + } + + protected function casts(): array + { + return [ + 'type' => MenuItemType::class, + 'new_tab' => 'boolean', + 'is_active' => 'boolean', + ]; + } +} From 145230bd75717d71be8ff1ef9b6394481a601c73 Mon Sep 17 00:00:00 2001 From: SlimDeluxe <131700+SlimDeluxe@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:20:32 +0000 Subject: [PATCH 2/3] style: fix code style --- .../migrations/2025_07_28_083630_create_menus_table.php | 6 ++++-- .../2025_07_28_094927_create_cms_menu_items_table.php | 3 ++- src/Models/Menu/Item.php | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/database/migrations/2025_07_28_083630_create_menus_table.php b/database/migrations/2025_07_28_083630_create_menus_table.php index 5eee5dd..5197864 100644 --- a/database/migrations/2025_07_28_083630_create_menus_table.php +++ b/database/migrations/2025_07_28_083630_create_menus_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('cms_menus', function (Blueprint $table) { @@ -31,4 +32,5 @@ public function up(): void public function down(): void { Schema::dropIfExists('cms_menus'); - }}; + } +}; diff --git a/database/migrations/2025_07_28_094927_create_cms_menu_items_table.php b/database/migrations/2025_07_28_094927_create_cms_menu_items_table.php index 1248e9c..38903da 100644 --- a/database/migrations/2025_07_28_094927_create_cms_menu_items_table.php +++ b/database/migrations/2025_07_28_094927_create_cms_menu_items_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('cms_menu_items', function (Blueprint $table) { diff --git a/src/Models/Menu/Item.php b/src/Models/Menu/Item.php index b5150cf..b584c0c 100644 --- a/src/Models/Menu/Item.php +++ b/src/Models/Menu/Item.php @@ -2,8 +2,8 @@ namespace Eclipse\Cms\Models\Menu; -use Eclipse\Cms\Models\Menu; use Eclipse\Cms\Enums\MenuItemType; +use Eclipse\Cms\Models\Menu; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; From a69a7812b967d72434bf8b153d5d0ed25490c430 Mon Sep 17 00:00:00 2001 From: "Thapa Godar Ank." <76224530+thapacodes4u@users.noreply.github.com> Date: Wed, 1 Oct 2025 07:14:56 -0700 Subject: [PATCH 3/3] feat: implement dynamic menus (CM-12) * feat: implement dynamic menus * feat: improve menu item resource with hierarchical display and lazy loading fixes * Cleaning things * refactor: consolidate menu item management into menu resource * feat: add cascading deletes, tenant scoping, and sublink creation for menu items * fix: adding missing bulk restore action * feat: implementing all the requested feedback * feat: implement dynamic linkables system with ActiveScope - Implemented HasLinkables interface in CmsPlugin for extensible linkable discovery - Replaced manual scopeActive() with #[ScopedBy([ActiveScope::class])] attribute - Added getUrl() methods to Page and Section models for menu URL generation - Created TernaryFilter for is_active (mirrors TrashedFilter pattern) - Added bulk Activate/Deactivate actions with contextual visibility - Updated tree methods to preserve indentation for inactive/soft-deleted items - Fixed MorphToSelect to display clean titles instead of raw JSON --------- Co-authored-by: thapacodes4u --- composer.json | 2 + ..._28_094927_create_cms_menu_items_table.php | 6 +- database/seeders/CmsSeeder.php | 2 + database/seeders/MenuSeeder.php | 179 ++++++++++ src/Admin/Filament/Resources/MenuResource.php | 174 ++++++++++ .../MenuResource/Pages/CreateMenu.php | 32 ++ .../Resources/MenuResource/Pages/EditMenu.php | 24 ++ .../MenuResource/Pages/ListMenus.php | 22 ++ .../MenuResource/Pages/SortMenuItems.php | 100 ++++++ .../MenuItemsRelationManager.php | 246 ++++++++++++++ src/CmsPlugin.php | 9 +- src/CmsServiceProvider.php | 8 + src/Factories/MenuFactory.php | 37 ++ src/Factories/MenuItemFactory.php | 117 +++++++ src/Models/Menu.php | 85 ++++- src/Models/Menu/Item.php | 224 ++++++++++++- src/Models/Page.php | 5 + src/Models/Section.php | 5 + src/Policies/MenuPolicy.php | 57 ++++ tests/Feature/ExampleTest.php | 5 - .../Feature/MenuItemsRelationManagerTest.php | 220 ++++++++++++ tests/Feature/MenuResourceTest.php | 317 ++++++++++++++++++ tests/TestCase.php | 38 ++- tests/Unit/MenuItemTest.php | 281 ++++++++++++++++ tests/Unit/MenuTest.php | 128 +++++++ tests/Unit/TenancyTest.php | 34 ++ .../app/Providers/AdminPanelProvider.php | 3 + 27 files changed, 2340 insertions(+), 20 deletions(-) create mode 100644 database/seeders/MenuSeeder.php create mode 100644 src/Admin/Filament/Resources/MenuResource.php create mode 100644 src/Admin/Filament/Resources/MenuResource/Pages/CreateMenu.php create mode 100644 src/Admin/Filament/Resources/MenuResource/Pages/EditMenu.php create mode 100644 src/Admin/Filament/Resources/MenuResource/Pages/ListMenus.php create mode 100644 src/Admin/Filament/Resources/MenuResource/Pages/SortMenuItems.php create mode 100644 src/Admin/Filament/Resources/MenuResource/RelationManagers/MenuItemsRelationManager.php create mode 100644 src/Factories/MenuFactory.php create mode 100644 src/Factories/MenuItemFactory.php create mode 100644 src/Policies/MenuPolicy.php delete mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/MenuItemsRelationManagerTest.php create mode 100644 tests/Feature/MenuResourceTest.php create mode 100644 tests/Unit/MenuItemTest.php create mode 100644 tests/Unit/MenuTest.php create mode 100644 tests/Unit/TenancyTest.php diff --git a/composer.json b/composer.json index 2d66580..ff8038a 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,8 @@ "datalinx/php-utils": "^2.5", "eclipsephp/common": "dev-main", "filament/filament": "^3.3", + "filament/spatie-laravel-translatable-plugin": "^3.3", + "solution-forest/filament-tree": "^2.1", "spatie/laravel-package-tools": "^1.19" }, "require-dev": { diff --git a/database/migrations/2025_07_28_094927_create_cms_menu_items_table.php b/database/migrations/2025_07_28_094927_create_cms_menu_items_table.php index 38903da..20f14d3 100644 --- a/database/migrations/2025_07_28_094927_create_cms_menu_items_table.php +++ b/database/migrations/2025_07_28_094927_create_cms_menu_items_table.php @@ -15,9 +15,11 @@ public function up(): void ->constrained('cms_menus', 'id') ->cascadeOnUpdate() ->cascadeOnDelete(); + $table->integer('parent_id')->default(-1); $table->string('type'); - $table->string('linkable_class'); - $table->string('linkable_id'); + $table->string('linkable_class')->nullable(); + $table->string('linkable_id')->nullable(); + $table->text('custom_url')->nullable(); $table->boolean('new_tab')->default(false); $table->boolean('is_active')->default(true); $table->integer('sort')->default(0); diff --git a/database/seeders/CmsSeeder.php b/database/seeders/CmsSeeder.php index cb32240..8d5dc5b 100644 --- a/database/seeders/CmsSeeder.php +++ b/database/seeders/CmsSeeder.php @@ -12,5 +12,7 @@ public function run(): void Section::factory() ->count(3) ->create(); + + $this->call(MenuSeeder::class); } } diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php new file mode 100644 index 0000000..ed93f7d --- /dev/null +++ b/database/seeders/MenuSeeder.php @@ -0,0 +1,179 @@ +createMainMenu(); + $this->createFooterMenu(); + } + + private function getTenantData(): array + { + if (config('eclipse-cms.tenancy.enabled')) { + $tenantModel = config('eclipse-cms.tenancy.model'); + if ($tenantModel && class_exists($tenantModel)) { + $tenantFK = config('eclipse-cms.tenancy.foreign_key', 'site_id'); + + $tenant = $tenantModel::first(); + if (! $tenant) { + $tenant = $tenantModel::create([ + 'name' => 'Default Site', + 'domain' => 'localhost', + ]); + } + + if ($tenant) { + return [$tenantFK => $tenant->id]; + } + } + } + + return []; + } + + private function createMainMenu(): void + { + $menu = Menu::factory()->create(array_merge([ + 'title' => [ + 'en' => 'Main Navigation', + 'sl' => 'Glavna Navigacija', + ], + 'code' => 'main', + 'is_active' => true, + ], $this->getTenantData())); + + $homeSection = Section::first() ?: Section::factory()->create([ + 'name' => [ + 'en' => 'Home', + 'sl' => 'Domov', + ], + ]); + + $aboutSection = Section::first() ?: Section::factory()->create([ + 'name' => [ + 'en' => 'About', + 'sl' => 'O nas', + ], + ]); + + $homeItem = Item::factory()->linkableToSection()->create([ + 'label' => [ + 'en' => 'Home', + 'sl' => 'Domov', + ], + 'menu_id' => $menu->id, + 'linkable_id' => $homeSection->id, + 'is_active' => true, + 'sort' => 1, + ]); + + $aboutItem = Item::factory()->linkableToSection()->create([ + 'label' => [ + 'en' => 'About Us', + 'sl' => 'O nas', + ], + 'menu_id' => $menu->id, + 'linkable_id' => $aboutSection->id, + 'is_active' => true, + 'sort' => 2, + ]); + + $servicesGroup = Item::factory()->group()->create([ + 'label' => [ + 'en' => 'Our Services', + 'sl' => 'Naše Storitve', + ], + 'menu_id' => $menu->id, + 'is_active' => true, + 'sort' => 3, + ]); + + if (Page::count() > 0) { + $servicePage = Page::first(); + Item::factory()->linkableToPage()->childOf($servicesGroup)->create([ + 'label' => [ + 'en' => 'Web Development Services', + 'sl' => 'Storitve Spletnega Razvoja', + ], + 'linkable_id' => $servicePage->id, + 'is_active' => true, + 'sort' => 1, + ]); + } + + Item::factory()->customUrl('https://support.example.com')->childOf($servicesGroup)->create([ + 'label' => [ + 'en' => 'Customer Support', + 'sl' => 'Podpora Strankam', + ], + 'new_tab' => true, + 'is_active' => true, + 'sort' => 2, + ]); + + Item::factory()->customUrl('/contact')->create([ + 'label' => [ + 'en' => 'Contact Us', + 'sl' => 'Kontakt', + ], + 'menu_id' => $menu->id, + 'is_active' => true, + 'sort' => 4, + ]); + } + + private function createFooterMenu(): void + { + $menu = Menu::factory()->create(array_merge([ + 'title' => [ + 'en' => 'Footer Links', + 'sl' => 'Povezave v Nogi', + ], + 'code' => 'footer', + 'is_active' => true, + ], $this->getTenantData())); + + Item::factory()->customUrl('/privacy')->create([ + 'label' => [ + 'en' => 'Privacy Policy', + 'sl' => 'Pravilnik o Zasebnosti', + ], + 'menu_id' => $menu->id, + 'is_active' => true, + 'sort' => 1, + ]); + + Item::factory()->customUrl('/terms')->create([ + 'label' => [ + 'en' => 'Terms of Service', + 'sl' => 'Pogoji Uporabe', + ], + 'menu_id' => $menu->id, + 'is_active' => true, + 'sort' => 2, + ]); + + if (Section::count() > 1) { + $section = Section::skip(1)->first(); + Item::factory()->linkableToSection()->create([ + 'label' => [ + 'en' => 'Latest News', + 'sl' => 'Najnovice', + ], + 'menu_id' => $menu->id, + 'linkable_id' => $section->id, + 'is_active' => true, + 'sort' => 3, + ]); + } + } +} diff --git a/src/Admin/Filament/Resources/MenuResource.php b/src/Admin/Filament/Resources/MenuResource.php new file mode 100644 index 0000000..b43578c --- /dev/null +++ b/src/Admin/Filament/Resources/MenuResource.php @@ -0,0 +1,174 @@ +schema([ + Forms\Components\Section::make() + ->compact() + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('title') + ->required() + ->maxLength(255), + Forms\Components\TextInput::make('code') + ->maxLength(255) + ->helperText('Unique identifier for this menu (optional)'), + Forms\Components\Toggle::make('is_active') + ->columnSpanFull() + ->default(true), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist + ->schema([ + Infolists\Components\Section::make('Information') + ->compact() + ->schema([ + Infolists\Components\TextEntry::make('title') + ->label('Title'), + Infolists\Components\TextEntry::make('code') + ->label('Code') + ->placeholder('—'), + Infolists\Components\IconEntry::make('is_active') + ->label('Status') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger'), + ]) + ->columns(3), + + Infolists\Components\Section::make('Timestamps') + ->compact() + ->schema([ + Infolists\Components\TextEntry::make('created_at') + ->label('Created') + ->dateTime(), + Infolists\Components\TextEntry::make('updated_at') + ->label('Last Updated') + ->dateTime(), + Infolists\Components\TextEntry::make('deleted_at') + ->label('Deleted') + ->dateTime() + ->placeholder('—'), + ]) + ->columns(3) + ->collapsible(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('title') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('code') + ->searchable() + ->sortable() + ->placeholder('—'), + Tables\Columns\IconColumn::make('is_active') + ->boolean() + ->sortable(), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\TrashedFilter::make(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\ForceDeleteBulkAction::make(), + Tables\Actions\RestoreBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + RelationManagers\MenuItemsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListMenus::route('/'), + 'create' => Pages\CreateMenu::route('/create'), + 'edit' => Pages\EditMenu::route('/{record}/edit'), + 'sort-items' => Pages\SortMenuItems::route('/{record}/sort-items'), + ]; + } + + public static function getPermissionPrefixes(): array + { + return [ + 'view_any', + 'create', + 'update', + 'restore', + 'restore_any', + 'replicate', + 'reorder', + 'delete', + 'delete_any', + 'force_delete', + 'force_delete_any', + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } +} diff --git a/src/Admin/Filament/Resources/MenuResource/Pages/CreateMenu.php b/src/Admin/Filament/Resources/MenuResource/Pages/CreateMenu.php new file mode 100644 index 0000000..1ea29b5 --- /dev/null +++ b/src/Admin/Filament/Resources/MenuResource/Pages/CreateMenu.php @@ -0,0 +1,32 @@ +id; + } + + return $data; + } +} diff --git a/src/Admin/Filament/Resources/MenuResource/Pages/EditMenu.php b/src/Admin/Filament/Resources/MenuResource/Pages/EditMenu.php new file mode 100644 index 0000000..bc9f557 --- /dev/null +++ b/src/Admin/Filament/Resources/MenuResource/Pages/EditMenu.php @@ -0,0 +1,24 @@ +record = $this->resolveRecord($record); + } + + public function getTitle(): string + { + return 'Sort Menu Items'; + } + + public function getSubheading(): ?string + { + return "Drag and drop to reorder menu items for: {$this->record->title}"; + } + + protected function getTreeQuery(): Builder + { + return Item::query()->where('menu_id', $this->record->id); + } + + public function getBreadcrumbs(): array + { + $resource = static::getResource(); + + $breadcrumbs = [ + $resource::getUrl() => $resource::getBreadcrumb(), + $resource::getUrl('edit', [ + 'record' => $this->record->id, + ]) => "Edit {$this->record->title}", + ...(filled($breadcrumb = $this->getBreadcrumb()) ? [$breadcrumb] : []), + ]; + + if (filled($cluster = static::getCluster())) { + return $cluster::unshiftClusterBreadcrumbs($breadcrumbs); + } + + return $breadcrumbs; + } + + public function getModel(): string + { + return Item::class; + } + + public function getMaxDepth(): int + { + return 5; + } + + public static function tree(Tree $tree): Tree + { + return $tree; + } + + protected function getTreeRecordIcon(?Model $record = null): ?string + { + if (! $record) { + return 'heroicon-o-bars-3'; + } + + return match ($record->type) { + MenuItemType::Group => 'heroicon-o-folder', + MenuItemType::Linkable => 'heroicon-o-link', + MenuItemType::CustomUrl => 'heroicon-o-globe-alt', + default => 'heroicon-o-bars-3', + }; + } + + protected function getActions(): array + { + return [ + Actions\LocaleSwitcher::make(), + ]; + } +} diff --git a/src/Admin/Filament/Resources/MenuResource/RelationManagers/MenuItemsRelationManager.php b/src/Admin/Filament/Resources/MenuResource/RelationManagers/MenuItemsRelationManager.php new file mode 100644 index 0000000..cbf0752 --- /dev/null +++ b/src/Admin/Filament/Resources/MenuResource/RelationManagers/MenuItemsRelationManager.php @@ -0,0 +1,246 @@ +getPlugins() ?? [] as $plugin) { + if ($plugin instanceof HasLinkables) { + $linkables = array_merge($linkables, $plugin->getLinkables()); + } + } + + return $linkables; + } + + protected function getMenuItemFormSchema(?int $excludeId = null): array + { + return [ + Forms\Components\Select::make('parent_id') + ->columnSpanFull() + ->label('Parent Item') + ->options( + fn (?Model $record = null): array => Item::getHierarchicalOptions( + $this->getOwnerRecord()->id + ) + ) + ->searchable() + ->placeholder('Select parent item (leave empty for root level)') + ->nullable() + ->dehydrateStateUsing(fn (?string $state): int => $state ? (int) $state : -1) + ->formatStateUsing(fn (?int $state): ?string => $state === -1 ? null : (string) $state), + Forms\Components\TextInput::make('label') + ->columnSpanFull() + ->required(), + Forms\Components\Select::make('type') + ->columnSpanFull() + ->options(MenuItemType::class) + ->required() + ->live() + ->afterStateUpdated(function (Set $set): void { + $set('custom_url', null); + }), + Forms\Components\MorphToSelect::make('linkable') + ->columnSpanFull() + ->label('Link Target') + ->types($this->getLinkableTypes()) + ->searchable() + ->preload() + ->required() + ->visible(fn (Get $get) => $get('type') === 'Linkable'), + Forms\Components\TextInput::make('custom_url') + ->columnSpanFull() + ->label('Custom URL') + ->required() + ->visible(fn (Get $get) => $get('type') === 'CustomUrl'), + Forms\Components\Toggle::make('new_tab') + ->columnSpanFull() + ->label('Open in new tab') + ->default(false) + ->visible(fn (Get $get) => in_array($get('type'), ['Linkable', 'CustomUrl'])), + Forms\Components\Toggle::make('is_active') + ->columnSpanFull() + ->default(true), + ]; + } + + public function form(Form $form): Form + { + return $form->schema($this->getMenuItemFormSchema()); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('label') + ->columns([ + Tables\Columns\TextColumn::make('label') + ->searchable() + ->sortable(false) + ->formatStateUsing( + fn (Model $record): HtmlString => new HtmlString( + $record->getTreeFormattedName() + ) + ) + ->tooltip(fn ($record) => $record->getFullPath()), + Tables\Columns\TextColumn::make('type') + ->sortable(false) + ->badge() + ->formatStateUsing(fn ($state) => $state->getLabel()), + Tables\Columns\IconColumn::make('is_active') + ->boolean() + ->sortable(false), + ]) + ->filters([ + TernaryFilter::make('is_active') + ->label('Status') + ->placeholder('Active only') + ->trueLabel('All') + ->falseLabel('Inactive only') + ->queries( + true: fn ($query) => $query, + false: fn ($query) => $query->where('is_active', false), + blank: fn ($query) => $query->where('is_active', true), + ) + ->baseQuery(fn (Builder $query) => $query->withoutGlobalScope(ActiveScope::class)) + ->indicateUsing(function (array $state): array { + if ($state['value'] ?? null) { + return [Tables\Filters\Indicator::make('All')]; + } + + if (($state['value'] ?? null) === false) { + return [Tables\Filters\Indicator::make('Inactive only')]; + } + + return []; + }), + + TrashedFilter::make(), + + SelectFilter::make('type') + ->options(MenuItemType::class) + ->multiple(), + SelectFilter::make('parent_id') + ->label('Parent Item') + ->options(fn () => Item::getParentOptions($this->getOwnerRecord()->id)) + ->searchable(), + TernaryFilter::make('new_tab') + ->label('Opens in New Tab'), + ]) + ->headerActions([ + Tables\Actions\CreateAction::make() + ->label('New Menu Item') + ->icon('heroicon-o-plus-circle'), + Tables\Actions\Action::make('sort') + ->label('Sort Items') + ->icon('heroicon-o-arrows-up-down') + ->url(fn () => MenuResource::getUrl('sort-items', ['record' => $this->getOwnerRecord()])) + ->color('gray'), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + Tables\Actions\RestoreAction::make(), + Tables\Actions\ForceDeleteAction::make(), + Tables\Actions\Action::make('addSubitem') + ->icon('heroicon-o-plus-circle') + ->color('warning') + ->label('Add Sub-item') + ->form(fn () => $this->getMenuItemFormSchema(excludeId: null)) + ->fillForm(fn (Model $record): array => [ + 'parent_id' => $record->id, + 'is_active' => true, + ]) + ->action(function (array $data, Model $record): void { + $data['menu_id'] = $this->getOwnerRecord()->id; + $data['parent_id'] = $record->id; + + Item::create($data); + }), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\BulkAction::make('activate') + ->label('Activate') + ->icon('heroicon-o-check-circle') + ->color('success') + ->action(fn ($records) => $records->each->update(['is_active' => true])) + ->deselectRecordsAfterCompletion() + ->hidden(function (HasTable $livewire): bool { + $filterState = $livewire->getTableFilterState('is_active') ?? []; + + if (! array_key_exists('value', $filterState)) { + return true; + } + + return blank($filterState['value']); + }), + Tables\Actions\BulkAction::make('deactivate') + ->label('Deactivate') + ->icon('heroicon-o-x-circle') + ->color('warning') + ->action(fn ($records) => $records->each->update(['is_active' => false])) + ->deselectRecordsAfterCompletion() + ->hidden(function (HasTable $livewire): bool { + $filterState = $livewire->getTableFilterState('is_active') ?? []; + + if (! array_key_exists('value', $filterState)) { + return false; + } + + if ($filterState['value']) { + return false; + } + + return filled($filterState['value']); + }), + Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\RestoreBulkAction::make(), + Tables\Actions\ForceDeleteBulkAction::make(), + ]), + ]); + } + + public function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } +} diff --git a/src/CmsPlugin.php b/src/CmsPlugin.php index b0a3e54..a468ce7 100644 --- a/src/CmsPlugin.php +++ b/src/CmsPlugin.php @@ -6,14 +6,19 @@ use Eclipse\Cms\Models\Section; use Eclipse\Common\Foundation\Plugins\HasLinkables; use Eclipse\Common\Foundation\Plugins\Plugin; +use Filament\Forms\Components\MorphToSelect; class CmsPlugin extends Plugin implements HasLinkables { public function getLinkables(): array { return [ - Page::class => 'Page', - Section::class => 'Section', + MorphToSelect\Type::make(Page::class) + ->titleAttribute('title') + ->label('Page'), + MorphToSelect\Type::make(Section::class) + ->titleAttribute('name') + ->label('Section'), ]; } } diff --git a/src/CmsServiceProvider.php b/src/CmsServiceProvider.php index 98923a3..ab7a398 100644 --- a/src/CmsServiceProvider.php +++ b/src/CmsServiceProvider.php @@ -2,8 +2,11 @@ namespace Eclipse\Cms; +use Eclipse\Cms\Models\Menu; +use Eclipse\Cms\Policies\MenuPolicy; use Eclipse\Common\Foundation\Providers\PackageServiceProvider; use Eclipse\Common\Package; +use Illuminate\Support\Facades\Gate; use Spatie\LaravelPackageTools\Package as SpatiePackage; class CmsServiceProvider extends PackageServiceProvider @@ -17,4 +20,9 @@ public function configurePackage(SpatiePackage|Package $package): void ->discoversMigrations() ->runsMigrations(); } + + public function bootingPackage(): void + { + Gate::policy(Menu::class, MenuPolicy::class); + } } diff --git a/src/Factories/MenuFactory.php b/src/Factories/MenuFactory.php new file mode 100644 index 0000000..b883ea0 --- /dev/null +++ b/src/Factories/MenuFactory.php @@ -0,0 +1,37 @@ + [ + 'en' => $this->faker->words(3, true), + 'sl' => $this->faker->words(3, true), + ], + 'code' => $this->faker->unique()->slug(2), + 'is_active' => $this->faker->boolean(80), + ]; + } + + public function active(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => true, + ]); + } + + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } +} diff --git a/src/Factories/MenuItemFactory.php b/src/Factories/MenuItemFactory.php new file mode 100644 index 0000000..c7f2f45 --- /dev/null +++ b/src/Factories/MenuItemFactory.php @@ -0,0 +1,117 @@ +faker->randomElement(MenuItemType::cases()); + + return [ + 'label' => [ + 'en' => $this->faker->words(2, true), + 'sl' => $this->faker->words(2, true), + ], + 'menu_id' => Menu::factory(), + 'parent_id' => null, + 'type' => $type, + 'linkable_class' => $this->getLinkableClass($type), + 'linkable_id' => null, + 'custom_url' => $type === MenuItemType::CustomUrl ? $this->faker->url() : null, + 'new_tab' => $this->faker->boolean(30), + 'is_active' => $this->faker->boolean(85), + 'sort' => $this->faker->numberBetween(0, 100), + ]; + } + + public function linkableToPage(): static + { + return $this->state(function (array $attributes) { + $page = Page::factory()->create(); + + return [ + 'type' => MenuItemType::Linkable, + 'linkable_class' => Page::class, + 'linkable_id' => $page->id, + 'custom_url' => null, + ]; + }); + } + + public function linkableToSection(): static + { + return $this->state(function (array $attributes) { + $section = Section::factory()->create(); + + return [ + 'type' => MenuItemType::Linkable, + 'linkable_class' => Section::class, + 'linkable_id' => $section->id, + 'custom_url' => null, + ]; + }); + } + + public function customUrl(?string $url = null): static + { + return $this->state(fn (array $attributes) => [ + 'type' => MenuItemType::CustomUrl, + 'linkable_class' => null, + 'linkable_id' => null, + 'custom_url' => $url ?? $this->faker->url(), + ]); + } + + public function group(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => MenuItemType::Group, + 'linkable_class' => null, + 'linkable_id' => null, + 'custom_url' => null, + ]); + } + + public function childOf(Item $parent): static + { + return $this->state(fn (array $attributes) => [ + 'parent_id' => $parent->id, + 'menu_id' => $parent->menu_id, + ]); + } + + public function active(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => true, + ]); + } + + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } + + private function getLinkableClass(MenuItemType $type): ?string + { + if ($type !== MenuItemType::Linkable) { + return null; + } + + $classes = [Page::class, Section::class]; + + return $this->faker->randomElement($classes); + } +} diff --git a/src/Models/Menu.php b/src/Models/Menu.php index 7d211b3..ec7fbea 100644 --- a/src/Models/Menu.php +++ b/src/Models/Menu.php @@ -2,23 +2,100 @@ namespace Eclipse\Cms\Models; +use Eclipse\Cms\Factories\MenuFactory; +use Eclipse\Cms\Models\Menu\Item; +use Eclipse\Common\Foundation\Models\Scopes\ActiveScope; +use Filament\Facades\Filament; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\Translatable\HasTranslations; class Menu extends Model { - use SoftDeletes; + use HasFactory, HasTranslations, SoftDeletes; - protected $fillable = [ + protected $table = 'cms_menus'; + + protected static function newFactory(): MenuFactory + { + return MenuFactory::new(); + } + + protected static function boot(): void + { + parent::boot(); + + static::deleting(function ($menu) { + $menu->allItems()->get()->each(function ($item) { + $item->delete(); + }); + }); + + static::forceDeleting(function ($menu) { + $menu->allItems()->withTrashed()->get()->each(function ($item) { + $item->forceDelete(); + }); + }); + + if (config('eclipse-cms.tenancy.enabled')) { + static::addGlobalScope('tenant', function (Builder $builder) { + $currentTenant = Filament::getTenant(); + if ($currentTenant) { + $tenantFK = config('eclipse-cms.tenancy.foreign_key', 'site_id'); + $builder->where($tenantFK, $currentTenant->getKey()); + } + }); + } + } + + public function getFillable(): array + { + $attr = [ + 'title', + 'is_active', + 'code', + ]; + + if (config('eclipse-cms.tenancy.enabled')) { + $attr[] = config('eclipse-cms.tenancy.foreign_key'); + } + + return $attr; + } + + public array $translatable = [ 'title', - 'is_active', - 'code', ]; protected function casts(): array { return [ + 'title' => 'array', 'is_active' => 'boolean', ]; } + + public function items(): HasMany + { + return $this->hasMany(Item::class)->where('parent_id', -1)->orderBy('sort'); + } + + public function allItems(): HasMany + { + return $this->hasMany(Item::class) + ->withoutGlobalScope(ActiveScope::class) + ->orderedForTree(); + } + + public function site(): BelongsTo + { + $tenantModel = config('eclipse-cms.tenancy.model'); + $tenantFK = config('eclipse-cms.tenancy.foreign_key', 'site_id'); + + return $this->belongsTo($tenantModel, $tenantFK); + } } diff --git a/src/Models/Menu/Item.php b/src/Models/Menu/Item.php index b584c0c..82ec6ff 100644 --- a/src/Models/Menu/Item.php +++ b/src/Models/Menu/Item.php @@ -3,37 +3,251 @@ namespace Eclipse\Cms\Models\Menu; use Eclipse\Cms\Enums\MenuItemType; +use Eclipse\Cms\Factories\MenuItemFactory; use Eclipse\Cms\Models\Menu; +use Eclipse\Common\Foundation\Models\Scopes\ActiveScope; +use Illuminate\Database\Eloquent\Attributes\ScopedBy; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\SoftDeletes; +use SolutionForest\FilamentTree\Concern\ModelTree; +use Spatie\Translatable\HasTranslations; +#[ScopedBy([ActiveScope::class])] class Item extends Model { - use SoftDeletes; + use HasFactory, HasTranslations, ModelTree, SoftDeletes; protected $table = 'cms_menu_items'; + protected static function boot(): void + { + parent::boot(); + + static::deleting(function ($menuItem) { + $menuItem->children()->get()->each(function ($child) { + $child->delete(); + }); + }); + + static::forceDeleting(function ($menuItem) { + $menuItem->children()->withTrashed()->get()->each(function ($child) { + $child->forceDelete(); + }); + }); + } + + protected static function newFactory(): MenuItemFactory + { + return MenuItemFactory::new(); + } + protected $fillable = [ 'label', 'menu_id', + 'parent_id', 'type', + 'linkable_class', + 'linkable_id', + 'custom_url', 'new_tab', 'is_active', 'sort', ]; - public function menu(): BelongsTo - { - return $this->belongsTo(Menu::class); - } + public array $translatable = [ + 'label', + ]; protected function casts(): array { return [ + 'label' => 'array', 'type' => MenuItemType::class, 'new_tab' => 'boolean', 'is_active' => 'boolean', ]; } + + public function menu(): BelongsTo + { + return $this->belongsTo(Menu::class); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(Item::class, 'parent_id'); + } + + public function linkable(): MorphTo + { + return $this->morphTo('linkable', 'linkable_class', 'linkable_id'); + } + + public function determineStatusUsing(): string + { + return 'is_active'; + } + + public function determineTitleColumnName(): string + { + return 'label'; + } + + public function determineOrderColumnName(): string + { + return 'sort'; + } + + public function getUrl(): ?string + { + return match ($this->type) { + MenuItemType::Linkable => $this->linkable?->getUrl(), + MenuItemType::CustomUrl => $this->custom_url, + MenuItemType::Group => null, + }; + } + + public function hasChildren(): bool + { + return $this->children()->exists(); + } + + public static function allNodes() + { + return static::buildSortQuery()->get(); + } + + public static function buildSortQuery(): Builder + { + return static::withoutGlobalScope(ActiveScope::class) + ->withTrashed() + ->ordered(); + } + + public function scopeRootItems($query) + { + return $query->where('parent_id', -1); + } + + public function scopeOrderedForTree($query) + { + $selectArray = static::selectArray(); + unset($selectArray[static::defaultParentKey()]); + $orderedIds = array_keys($selectArray); + + if (empty($orderedIds)) { + return $query->orderBy('sort'); + } + + return $query->orderByRaw( + 'CASE '. + collect($orderedIds)->map(function ($id, $index) { + return "WHEN id = {$id} THEN {$index}"; + })->implode(' '). + ' ELSE 999999 END' + ); + } + + protected static function formatTreeName(string $value): array + { + if (! str_starts_with($value, '-')) { + return ['name' => $value, 'level' => 0]; + } + + $dashCount = 0; + while ($dashCount < strlen($value) && $value[$dashCount] === '-') { + $dashCount++; + } + + $level = intval($dashCount / 3); + $cleanName = ltrim($value, '-'); + + return ['name' => $cleanName, 'level' => $level]; + } + + protected static function getTreePrefix(int $level): string + { + $indent = str_repeat('. . . . ', $level); + $connector = $level > 0 ? '└─ ' : ''; + + return $indent.$connector; + } + + public function getTreeFormattedName(): string + { + $selectArray = static::selectArray(); + $formattedName = $selectArray[$this->id] ?? $this->label; + + $formatted = self::formatTreeName($formattedName); + + return self::getTreePrefix($formatted['level']).e($formatted['name']); + } + + public function getFullPath(): string + { + $allNodes = static::allNodes()->keyBy('id'); + + $path = []; + $current = $this; + + while ($current) { + $path[] = $current->title ?? $current->label; + $parentId = $current->{$this->determineParentColumnName()}; + + if ($parentId && $parentId !== static::defaultParentKey() && isset($allNodes[$parentId])) { + $current = $allNodes[$parentId]; + } else { + $current = null; + } + } + + return implode(' > ', array_reverse($path)); + } + + public static function getHierarchicalOptions(?int $menuId = null): array + { + $query = static::query(); + + if ($menuId) { + $query->where('menu_id', $menuId); + } + + $options = $query->pluck('label', 'id')->toArray(); + $selectArray = static::selectArray(); + + foreach ($options as $key => $value) { + if (isset($selectArray[$key])) { + $formatted = self::formatTreeName($selectArray[$key]); + $options[$key] = self::getTreePrefix($formatted['level']).$formatted['name']; + } + } + + return $options; + } + + public static function getParentOptions(?int $menuId = null, ?int $excludeId = null): array + { + $query = static::query(); + + if ($menuId) { + $query->where('menu_id', $menuId); + } + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + $items = $query->get(); + $options = []; + + foreach ($items as $item) { + $options[$item->id] = $item->getTreeFormattedName(); + } + + return $options; + } } diff --git a/src/Models/Page.php b/src/Models/Page.php index 020da14..f59a7d2 100644 --- a/src/Models/Page.php +++ b/src/Models/Page.php @@ -30,6 +30,11 @@ public function section(): BelongsTo return $this->belongsTo(Section::class); } + public function getUrl(): ?string + { + return $this->sef_key ? "/{$this->sef_key}" : null; + } + protected static function newFactory(): PageFactory { return PageFactory::new(); diff --git a/src/Models/Section.php b/src/Models/Section.php index b752c95..e88d01d 100644 --- a/src/Models/Section.php +++ b/src/Models/Section.php @@ -32,6 +32,11 @@ public function getFillable() return $attr; } + public function getUrl(): ?string + { + return "/section/{$this->id}"; + } + protected static function newFactory(): SectionFactory { return SectionFactory::new(); diff --git a/src/Policies/MenuPolicy.php b/src/Policies/MenuPolicy.php new file mode 100644 index 0000000..b71c7c6 --- /dev/null +++ b/src/Policies/MenuPolicy.php @@ -0,0 +1,57 @@ +can('view_any_menu'); + } + + public function create(Authorizable $user): bool + { + return $user->can('create_menu'); + } + + public function update(Authorizable $user, Menu $menu): bool + { + return $user->can('update_menu'); + } + + public function delete(Authorizable $user, Menu $menu): bool + { + return $user->can('delete_menu'); + } + + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_menu'); + } + + public function forceDelete(Authorizable $user, Menu $menu): bool + { + return $user->can('force_delete_menu'); + } + + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_menu'); + } + + public function restore(Authorizable $user, Menu $menu): bool + { + return $user->can('restore_menu'); + } + + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_menu'); + } +} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 61cd84c..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/Feature/MenuItemsRelationManagerTest.php b/tests/Feature/MenuItemsRelationManagerTest.php new file mode 100644 index 0000000..feef13d --- /dev/null +++ b/tests/Feature/MenuItemsRelationManagerTest.php @@ -0,0 +1,220 @@ +setUpSuperAdmin(); +}); + +it('can delete menu item from relation manager', function () { + $menu = Menu::factory()->create(); + $item = $menu->allItems()->create([ + 'label' => ['en' => 'Test Item'], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + livewire(MenuItemsRelationManager::class, [ + 'ownerRecord' => $menu, + 'pageClass' => MenuResource\Pages\EditMenu::class, + ]) + ->callTableAction('delete', $item); + + $this->assertSoftDeleted($item); +}); + +it('can bulk delete menu items from relation manager', function () { + $menu = Menu::factory()->create(); + $items = collect(); + + for ($i = 0; $i < 3; $i++) { + $items->push($menu->allItems()->create([ + 'label' => ['en' => "Test Item {$i}"], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => $i + 1, + ])); + } + + livewire(MenuItemsRelationManager::class, [ + 'ownerRecord' => $menu, + 'pageClass' => MenuResource\Pages\EditMenu::class, + ]) + ->callTableBulkAction('delete', $items); + + foreach ($items as $item) { + $this->assertSoftDeleted($item); + } +}); + +it('deletes all nested children when parent menu item is deleted', function () { + $menu = Menu::factory()->create(); + + $rootItem = $menu->allItems()->create([ + 'label' => ['en' => 'Root Item'], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $childItem = $menu->allItems()->create([ + 'label' => ['en' => 'Child Item'], + 'parent_id' => $rootItem->id, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $grandchildItem = $menu->allItems()->create([ + 'label' => ['en' => 'Grandchild Item'], + 'parent_id' => $childItem->id, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $rootItem->delete(); + + $this->assertSoftDeleted($rootItem); + $this->assertSoftDeleted($childItem); + $this->assertSoftDeleted($grandchildItem); +}); + +it('can create menu item with sub-item action', function () { + $menu = Menu::factory()->create(); + $parentItem = $menu->allItems()->create([ + 'label' => ['en' => 'Parent Item'], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + livewire(MenuItemsRelationManager::class, [ + 'ownerRecord' => $menu, + 'pageClass' => MenuResource\Pages\EditMenu::class, + ]) + ->callTableAction('addSubitem', $parentItem, [ + 'label' => ['en' => 'Sub Item'], + 'type' => 'Group', + 'is_active' => true, + ]); + + $subItem = Item::where('parent_id', $parentItem->id)->first(); + + expect($subItem)->not->toBeNull() + ->and(is_array($subItem->label) ? $subItem->label['en'] : $subItem->label)->toBe('Sub Item') + ->and($subItem->parent_id)->toBe($parentItem->id) + ->and($subItem->menu_id)->toBe($menu->id); +}); + +it('shows trashed items in relation manager when using trashed filter', function () { + $menu = Menu::factory()->create(); + + $activeItem = $menu->allItems()->create([ + 'label' => ['en' => 'Active Item'], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $trashedItem = $menu->allItems()->create([ + 'label' => ['en' => 'Trashed Item'], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 2, + ]); + + $trashedItem->delete(); + + // Just verify the basic functionality works + $component = livewire(MenuItemsRelationManager::class, [ + 'ownerRecord' => $menu, + 'pageClass' => MenuResource\Pages\EditMenu::class, + ]); + + // Basic test that the component loads + expect($component)->not->toBeNull(); +}); + +it('can restore menu item', function () { + $menu = Menu::factory()->create(); + $item = $menu->allItems()->create([ + 'label' => ['en' => 'Test Item'], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + // First soft delete the item + $item->delete(); + + // Restore it + $item->restore(); + + expect($item->fresh()->trashed())->toBeFalse(); +}); + +it('can force delete menu item', function () { + $menu = Menu::factory()->create(); + $item = $menu->allItems()->create([ + 'label' => ['en' => 'Test Item'], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + // First soft delete the item + $item->delete(); + + // Force delete it + $item->forceDelete(); + + $this->assertModelMissing($item); +}); + +it('force deletes all nested children when parent menu item is force deleted', function () { + $menu = Menu::factory()->create(); + + $rootItem = $menu->allItems()->create([ + 'label' => ['en' => 'Root Item'], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $childItem = $menu->allItems()->create([ + 'label' => ['en' => 'Child Item'], + 'parent_id' => $rootItem->id, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $grandchildItem = $menu->allItems()->create([ + 'label' => ['en' => 'Grandchild Item'], + 'parent_id' => $childItem->id, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $rootItem->forceDelete(); + + $this->assertModelMissing($rootItem); + $this->assertModelMissing($childItem); + $this->assertModelMissing($grandchildItem); +}); diff --git a/tests/Feature/MenuResourceTest.php b/tests/Feature/MenuResourceTest.php new file mode 100644 index 0000000..586a2bb --- /dev/null +++ b/tests/Feature/MenuResourceTest.php @@ -0,0 +1,317 @@ +setUpSuperAdmin(); +}); + +it('can render menu index page', function () { + $this->get(MenuResource::getUrl('index')) + ->assertSuccessful(); +}); + +it('can list menus', function () { + $menus = Menu::factory()->count(10)->create(); + + livewire(ListMenus::class) + ->assertCanSeeTableRecords($menus); +}); + +it('can render menu create page', function () { + $this->get(MenuResource::getUrl('create')) + ->assertSuccessful(); +}); + +it('can create menu', function () { + $newData = Menu::factory()->make(); + + livewire(CreateMenu::class) + ->fillForm([ + 'title' => $newData->title, + 'code' => $newData->code, + 'is_active' => $newData->is_active, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas(Menu::class, [ + 'code' => $newData->code, + 'is_active' => $newData->is_active, + ]); + + $menu = Menu::where('code', $newData->code)->first(); + expect($menu)->not->toBeNull() + ->and($menu->title)->toBe($newData->title); +}); + +it('can validate menu creation', function () { + livewire(CreateMenu::class) + ->fillForm([ + 'title' => null, + ]) + ->call('create') + ->assertHasFormErrors(['title' => 'required']); +}); + +it('can render menu edit page', function () { + $menu = Menu::factory()->create(); + + $this->get(MenuResource::getUrl('edit', ['record' => $menu])) + ->assertSuccessful(); +}); + +it('can retrieve menu data for editing', function () { + $menu = Menu::factory()->create(); + + livewire(EditMenu::class, [ + 'record' => $menu->getRouteKey(), + ]) + ->assertFormSet([ + 'title' => $menu->title, + 'code' => $menu->code, + 'is_active' => $menu->is_active, + ]); +}); + +it('can save menu', function () { + $menu = Menu::factory()->create(); + $newData = Menu::factory()->make(); + + livewire(EditMenu::class, [ + 'record' => $menu->getRouteKey(), + ]) + ->fillForm([ + 'title' => $newData->title, + 'code' => $newData->code, + 'is_active' => $newData->is_active, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($menu->refresh()) + ->title->toBe($newData->title) + ->code->toBe($newData->code) + ->is_active->toBe($newData->is_active); +}); + +it('can validate menu editing', function () { + $menu = Menu::factory()->create(); + + livewire(EditMenu::class, [ + 'record' => $menu->getRouteKey(), + ]) + ->fillForm([ + 'title' => null, + ]) + ->call('save') + ->assertHasFormErrors(['title' => 'required']); +}); + +it('can delete menu', function () { + $menu = Menu::factory()->create(); + + livewire(ListMenus::class) + ->callTableAction('delete', $menu); + + $this->assertSoftDeleted($menu); +}); + +it('can bulk delete menus', function () { + $menus = Menu::factory()->count(10)->create(); + + livewire(ListMenus::class) + ->callTableBulkAction('delete', $menus); + + foreach ($menus as $menu) { + $this->assertSoftDeleted($menu); + } +}); + +it('can search menus', function () { + $menus = Menu::factory()->count(10)->create(); + + $title = $menus->first()->title['en'] ?? $menus->first()->title; + + livewire(ListMenus::class) + ->searchTable($title) + ->assertCanSeeTableRecords($menus->take(1)) + ->assertCanNotSeeTableRecords($menus->skip(1)); +}); + +it('can sort menus', function () { + $menus = Menu::factory()->count(10)->create(); + + livewire(ListMenus::class) + ->sortTable('title') + ->assertCanSeeTableRecords($menus->sortBy('title'), inOrder: true) + ->sortTable('title', 'desc') + ->assertCanSeeTableRecords($menus->sortByDesc('title'), inOrder: true); +}); + +it('can filter menus by active status', function () { + $activeMenus = Menu::factory()->active()->count(5)->create(); + $inactiveMenus = Menu::factory()->inactive()->count(5)->create(); + + livewire(ListMenus::class) + ->assertCanSeeTableRecords($activeMenus) + ->assertCanSeeTableRecords($inactiveMenus); +}); + +test('unauthorized access can be prevented', function () { + $this->setUpUserWithoutPermissions(); + + livewire(ListMenus::class) + ->assertForbidden(); +}); + +test('user with create permission can create menus', function () { + $this->setUpUserWithPermissions(['view_any_menu', 'create_menu']); + + livewire(CreateMenu::class) + ->assertSuccessful(); +}); + +test('user with update permission can edit menus', function () { + $this->setUpUserWithPermissions(['view_any_menu', 'view_menu', 'update_menu']); + $menu = Menu::factory()->create(); + + livewire(EditMenu::class, [ + 'record' => $menu->getRouteKey(), + ]) + ->assertSuccessful(); +}); + +test('user with delete permission can delete menus', function () { + $this->setUpUserWithPermissions(['view_any_menu', 'view_menu', 'delete_menu']); + $menu = Menu::factory()->create(); + + $menuExists = Menu::where('id', $menu->id)->exists(); + expect($menuExists)->toBeTrue(); + + $menu->delete(); + + expect($menu->fresh()->trashed())->toBeTrue(); +}); + +it('can render menu item sorting page', function () { + $menu = Menu::factory()->create(); + + $this->get(MenuResource::getUrl('sort-items', ['record' => $menu])) + ->assertSuccessful(); +}); + +it('can access menu item sorting page from relation manager', function () { + $menu = Menu::factory()->create(); + + $response = $this->get(MenuResource::getUrl('edit', ['record' => $menu])); + $response->assertSuccessful(); + + $response = $this->get(MenuResource::getUrl('sort-items', ['record' => $menu])); + $response->assertSuccessful(); + + expect($response->getContent())->toContain('Sort Menu Items'); + expect($response->getContent())->toContain("Drag and drop to reorder menu items for: {$menu->title}"); +}); + +it('deletes all menu items when menu is soft deleted', function () { + $menu = Menu::factory()->create(); + $rootItem = $menu->allItems()->create([ + 'label' => ['en' => 'Root Item'], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $childItem = $menu->allItems()->create([ + 'label' => ['en' => 'Child Item'], + 'parent_id' => $rootItem->id, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $grandchildItem = $menu->allItems()->create([ + 'label' => ['en' => 'Grandchild Item'], + 'parent_id' => $childItem->id, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $menu->delete(); + + $this->assertSoftDeleted($menu); + $this->assertSoftDeleted($rootItem); + $this->assertSoftDeleted($childItem); + $this->assertSoftDeleted($grandchildItem); +}); + +it('force deletes all menu items when menu is force deleted', function () { + $menu = Menu::factory()->create(); + $rootItem = $menu->allItems()->create([ + 'label' => ['en' => 'Root Item'], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $childItem = $menu->allItems()->create([ + 'label' => ['en' => 'Child Item'], + 'parent_id' => $rootItem->id, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $grandchildItem = $menu->allItems()->create([ + 'label' => ['en' => 'Grandchild Item'], + 'parent_id' => $childItem->id, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $menu->forceDelete(); + + $this->assertModelMissing($menu); + $this->assertModelMissing($rootItem); + $this->assertModelMissing($childItem); + $this->assertModelMissing($grandchildItem); +}); + +it('can restore menu', function () { + $menu = Menu::factory()->create(); + + $menu->delete(); + + $menu->restore(); + + expect($menu->fresh()->trashed())->toBeFalse(); +}); + +it('can force delete menu and its items', function () { + $menu = Menu::factory()->create(); + $item = $menu->allItems()->create([ + 'label' => ['en' => 'Test Item'], + 'parent_id' => -1, + 'type' => 'Group', + 'is_active' => true, + 'sort' => 1, + ]); + + $menu->delete(); + + $menu->forceDelete(); + + $this->assertModelMissing($menu); + $this->assertModelMissing($item); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 68f5785..b8b6a15 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,13 +2,16 @@ namespace Tests; +use Illuminate\Foundation\Testing\RefreshDatabase; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase as BaseTestCase; +use Spatie\Permission\Models\Permission; +use Spatie\Permission\Models\Role; use Workbench\App\Models\User; abstract class TestCase extends BaseTestCase { - use WithWorkbench; + use RefreshDatabase, WithWorkbench; protected ?User $superAdmin = null; @@ -23,6 +26,12 @@ protected function setUp(): void parent::setUp(); $this->withoutVite(); + + config(['eclipse-cms.tenancy.enabled' => false]); + config(['eclipse-cms.tenancy.model' => 'Workbench\\App\\Models\\Site']); + config(['eclipse-cms.tenancy.foreign_key' => 'site_id']); + + config(['scout.driver' => null]); } /** @@ -40,9 +49,9 @@ protected function migrate(): self */ protected function setUpSuperAdmin(): self { + $this->migrate(); $this->superAdmin = User::factory()->make(); $this->superAdmin->assignRole('super_admin')->save(); - $this->actingAs($this->superAdmin); return $this; @@ -52,9 +61,34 @@ protected function setUpSuperAdmin(): self * Set up a common user with no roles or permissions */ protected function setUpCommonUser(): self + { + $this->migrate(); + $this->user = User::factory()->create(); + $this->actingAs($this->user); + + return $this; + } + + protected function setUpUserWithoutPermissions(): self { $this->user = User::factory()->create(); + $this->actingAs($this->user); + + return $this; + } + + protected function setUpUserWithPermissions(array $permissions): self + { + $this->migrate(); + $this->user = User::factory()->create(); + $role = Role::firstOrCreate(['name' => 'test_role', 'guard_name' => 'web']); + + foreach ($permissions as $permission) { + Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'web']); + } + $role->syncPermissions($permissions); + $this->user->assignRole('test_role'); $this->actingAs($this->user); return $this; diff --git a/tests/Unit/MenuItemTest.php b/tests/Unit/MenuItemTest.php new file mode 100644 index 0000000..4f467da --- /dev/null +++ b/tests/Unit/MenuItemTest.php @@ -0,0 +1,281 @@ +create(); + $item = Item::factory()->create([ + 'menu_id' => $menu->id, + 'label' => ['en' => 'Test Item'], + 'type' => MenuItemType::CustomUrl, + 'custom_url' => 'https://example.com', + 'is_active' => true, + ]); + + expect($item) + ->menu_id->toBe($menu->id) + ->getTranslations('label')->toBe(['en' => 'Test Item']) + ->type->toBe(MenuItemType::CustomUrl) + ->custom_url->toBe('https://example.com') + ->is_active->toBe(true); +}); + +it('belongs to a menu', function () { + $menu = Menu::factory()->create(); + $item = Item::factory()->create(['menu_id' => $menu->id]); + + expect($item->menu)->toBeInstanceOf(Menu::class) + ->and($item->menu->id)->toBe($menu->id); +}); + +it('can have parent and children relationships', function () { + $menu = Menu::factory()->create(); + $parent = Item::factory()->create(['menu_id' => $menu->id]); + $child = Item::factory()->create(['menu_id' => $menu->id, 'parent_id' => $parent->id]); + + expect($child->parent)->toBeInstanceOf(Item::class) + ->and($child->parent->id)->toBe($parent->id) + ->and($parent->children)->toHaveCount(1) + ->and($parent->children->first()->id)->toBe($child->id); +}); + +it('has translatable label', function () { + $item = Item::factory()->create([ + 'label' => ['en' => 'English Label', 'sl' => 'Slovenian Label'], + ]); + + app()->setLocale('en'); + expect($item->label)->toBe('English Label'); + + app()->setLocale('sl'); + expect($item->label)->toBe('Slovenian Label'); +}); + +it('can be linked to a page via polymorphic relationship', function () { + $page = Page::factory()->create(); + $item = Item::factory()->create([ + 'type' => MenuItemType::Linkable, + 'linkable_class' => Page::class, + 'linkable_id' => $page->id, + ]); + + expect($item->linkable)->toBeInstanceOf(Page::class) + ->and($item->linkable->id)->toBe($page->id); +}); + +it('can be linked to a section via polymorphic relationship', function () { + $section = Section::factory()->create(); + $item = Item::factory()->create([ + 'type' => MenuItemType::Linkable, + 'linkable_class' => Section::class, + 'linkable_id' => $section->id, + ]); + + expect($item->linkable)->toBeInstanceOf(Section::class) + ->and($item->linkable->id)->toBe($section->id); +}); + +it('getUrl returns correct URL for custom URL type', function () { + $item = Item::factory()->create([ + 'type' => MenuItemType::CustomUrl, + 'custom_url' => 'https://example.com', + ]); + + expect($item->getUrl())->toBe('https://example.com'); +}); + +it('getUrl returns null for group type', function () { + $item = Item::factory()->create([ + 'type' => MenuItemType::Group, + ]); + + expect($item->getUrl())->toBeNull(); +}); + +it('getUrl returns linkable URL for linkable type', function () { + $page = Page::factory()->create(['sef_key' => 'test-page']); + $item = Item::factory()->create([ + 'type' => MenuItemType::Linkable, + 'linkable_class' => Page::class, + 'linkable_id' => $page->id, + ]); + + expect($item->linkable)->not->toBeNull() + ->and($item->linkable)->toBeInstanceOf(Page::class) + ->and($item->type)->toBe(MenuItemType::Linkable); +}); + +it('can check if item has children', function () { + $menu = Menu::factory()->create(); + $parent = Item::factory()->active()->create(['menu_id' => $menu->id]); + $childless = Item::factory()->active()->create(['menu_id' => $menu->id]); + + Item::factory()->active()->create(['menu_id' => $menu->id, 'parent_id' => $parent->id]); + + expect($parent->hasChildren())->toBeTrue() + ->and($childless->hasChildren())->toBeFalse(); +}); + +it('has proper scopes', function () { + $menu = Menu::factory()->create(); + $activeItem = Item::factory()->active()->create(['menu_id' => $menu->id]); + $inactiveItem = Item::factory()->inactive()->create(['menu_id' => $menu->id]); + $rootItem = Item::factory()->active()->create(['menu_id' => $menu->id, 'parent_id' => -1]); + $childItem = Item::factory()->active()->create(['menu_id' => $menu->id, 'parent_id' => $rootItem->id]); + + expect(Item::count())->toBe(3); + + expect(Item::withoutGlobalScope(ActiveScope::class)->where('is_active', false)->count())->toBe(1); + + expect(Item::rootItems()->count())->toBe(2); + + expect(Item::where('parent_id', '!=', -1)->count())->toBe(1); + + expect(Item::withoutGlobalScope(ActiveScope::class)->rootItems()->count())->toBe(3); +}); + +it('has proper casts', function () { + $item = new Item; + + $casts = $item->getCasts(); + + expect($casts) + ->toHaveKey('type', MenuItemType::class) + ->toHaveKey('new_tab', 'boolean') + ->toHaveKey('is_active', 'boolean'); +}); + +it('has proper fillable attributes', function () { + $item = new Item; + + $expectedFillable = [ + 'label', + 'menu_id', + 'parent_id', + 'type', + 'linkable_class', + 'linkable_id', + 'custom_url', + 'new_tab', + 'is_active', + 'sort', + ]; + + expect($item->getFillable())->toEqual($expectedFillable); +}); + +it('can be soft deleted', function () { + $item = Item::factory()->create(); + + $item->delete(); + + expect($item->trashed())->toBeTrue(); + + $this->assertDatabaseHas('cms_menu_items', [ + 'id' => $item->id, + ]); + + $this->assertNotNull($item->deleted_at); +}); + +it('has proper tree methods for sorting', function () { + $item = new Item; + + expect($item->determineStatusUsing())->toBe('is_active') + ->and($item->determineTitleColumnName())->toBe('label') + ->and($item->determineOrderColumnName())->toBe('sort'); +}); + +it('can get tree formatted name', function () { + $menu = Menu::factory()->create(); + $parent = Item::factory()->create(['menu_id' => $menu->id, 'label' => 'Parent']); + $child = Item::factory()->create(['menu_id' => $menu->id, 'parent_id' => $parent->id, 'label' => 'Child']); + + $parentFormatted = $parent->getTreeFormattedName(); + $childFormatted = $child->getTreeFormattedName(); + + expect($parentFormatted)->toContain('Parent') + ->and($childFormatted)->toContain('Child'); +}); + +it('can get full path', function () { + $menu = Menu::factory()->create(); + $parent = Item::factory()->create([ + 'menu_id' => $menu->id, + 'label' => ['en' => 'Parent Label'], + ]); + $child = Item::factory()->create([ + 'menu_id' => $menu->id, + 'parent_id' => $parent->id, + 'label' => ['en' => 'Child Label'], + ]); + + $fullPath = $child->getFullPath(); + + expect($fullPath)->toContain('Parent Label') + ->and($fullPath)->toContain('Child Label') + ->and($fullPath)->toContain('>'); +}); + +it('can get hierarchical options', function () { + $menu = Menu::factory()->create(); + $items = Item::factory()->active()->count(3)->create(['menu_id' => $menu->id]); + + $options = Item::getHierarchicalOptions($menu->id); + + expect($options)->toBeArray() + ->and(count($options))->toBeGreaterThanOrEqual(3); +}); + +it('deleting parent item cascades to delete children', function () { + $menu = Menu::factory()->create(); + $parent = Item::factory()->create(['menu_id' => $menu->id]); + $child1 = Item::factory()->create(['menu_id' => $menu->id, 'parent_id' => $parent->id]); + $child2 = Item::factory()->create(['menu_id' => $menu->id, 'parent_id' => $parent->id]); + + expect($parent->children)->toHaveCount(2); + expect($child1->trashed())->toBeFalse(); + expect($child2->trashed())->toBeFalse(); + + $parent->delete(); + + expect($parent->trashed())->toBeTrue(); + expect($child1->fresh()->trashed())->toBeTrue(); + expect($child2->fresh()->trashed())->toBeTrue(); +}); + +it('deleting parent item cascades to nested children recursively', function () { + $menu = Menu::factory()->create(); + $grandparent = Item::factory()->active()->create(['menu_id' => $menu->id]); + $parent = Item::factory()->active()->create(['menu_id' => $menu->id, 'parent_id' => $grandparent->id]); + $child = Item::factory()->active()->create(['menu_id' => $menu->id, 'parent_id' => $parent->id]); + + expect($grandparent->children)->toHaveCount(1); + expect($parent->children)->toHaveCount(1); + + $grandparent->delete(); + + expect($grandparent->fresh()->trashed())->toBeTrue(); + expect($parent->fresh()->trashed())->toBeTrue(); + expect($child->fresh()->trashed())->toBeTrue(); +}); + +it('cascading delete only affects children, not siblings', function () { + $menu = Menu::factory()->create(); + $parent1 = Item::factory()->create(['menu_id' => $menu->id]); + $parent2 = Item::factory()->create(['menu_id' => $menu->id]); + $child1 = Item::factory()->create(['menu_id' => $menu->id, 'parent_id' => $parent1->id]); + $child2 = Item::factory()->create(['menu_id' => $menu->id, 'parent_id' => $parent2->id]); + + $parent1->delete(); + + expect($parent1->fresh()->trashed())->toBeTrue(); + expect($child1->fresh()->trashed())->toBeTrue(); + expect($parent2->fresh()->trashed())->toBeFalse(); + expect($child2->fresh()->trashed())->toBeFalse(); +}); diff --git a/tests/Unit/MenuTest.php b/tests/Unit/MenuTest.php new file mode 100644 index 0000000..ef76e45 --- /dev/null +++ b/tests/Unit/MenuTest.php @@ -0,0 +1,128 @@ +create([ + 'title' => ['en' => 'Test Menu', 'sl' => 'Test Meni'], + 'code' => 'test-menu', + 'is_active' => true, + ]); + + expect($menu) + ->getTranslations('title')->toBe(['en' => 'Test Menu', 'sl' => 'Test Meni']) + ->code->toBe('test-menu') + ->is_active->toBe(true); +}); + +it('has translatable title', function () { + $menu = Menu::factory()->create([ + 'title' => ['en' => 'English Title', 'sl' => 'Slovenian Title'], + ]); + + app()->setLocale('en'); + expect($menu->title)->toBe('English Title'); + + app()->setLocale('sl'); + expect($menu->title)->toBe('Slovenian Title'); +}); + +it('can have menu items', function () { + $menu = Menu::factory()->create(); + $items = Item::factory()->count(3)->create(['menu_id' => $menu->id]); + + expect($menu->allItems)->toHaveCount(3); + $menuItemIds = $menu->allItems->pluck('id')->toArray(); + $factoryItemIds = $items->pluck('id')->toArray(); + + foreach ($factoryItemIds as $id) { + expect($menuItemIds)->toContain($id); + } +}); + +it('items relationship returns only root items', function () { + $menu = Menu::factory()->create(); + + $rootItem = Item::factory()->active()->create(['menu_id' => $menu->id, 'parent_id' => -1]); + $childItem = Item::factory()->active()->create(['menu_id' => $menu->id, 'parent_id' => $rootItem->id]); + + expect($menu->items)->toHaveCount(1) + ->and($menu->items->first()->id)->toBe($rootItem->id) + ->and($menu->allItems)->toHaveCount(2); +}); + +it('can be soft deleted', function () { + $menu = Menu::factory()->create(); + + $menu->delete(); + + expect($menu->trashed())->toBeTrue(); + + $this->assertDatabaseHas('cms_menus', [ + 'id' => $menu->id, + ]); + + $this->assertNotNull($menu->deleted_at); +}); + +it('has proper fillable attributes', function () { + $menu = new Menu; + + $expectedFillable = [ + 'title', + 'is_active', + 'code', + ]; + + expect($menu->getFillable())->toEqual($expectedFillable); +}); + +it('has proper fillable attributes with tenancy enabled', function () { + config(['eclipse-cms.tenancy.enabled' => true]); + config(['eclipse-cms.tenancy.foreign_key' => 'site_id']); + + $menu = new Menu; + + $expectedFillable = [ + 'title', + 'is_active', + 'code', + 'site_id', + ]; + + expect($menu->getFillable())->toEqual($expectedFillable); + + config(['eclipse-cms.tenancy.enabled' => false]); +}); + +it('has proper casts', function () { + $menu = new Menu; + + expect($menu->getCasts())->toHaveKey('is_active', 'boolean'); +}); + +it('has translatable fields', function () { + $menu = new Menu; + + expect($menu->translatable)->toContain('title'); +}); + +it('deleting menu cascades to delete all menu items', function () { + $menu = Menu::factory()->create(); + $item1 = Item::factory()->create(['menu_id' => $menu->id]); + $item2 = Item::factory()->create(['menu_id' => $menu->id]); + $childItem = Item::factory()->create(['menu_id' => $menu->id, 'parent_id' => $item1->id]); + + expect($menu->allItems)->toHaveCount(3); + expect($item1->trashed())->toBeFalse(); + expect($item2->trashed())->toBeFalse(); + expect($childItem->trashed())->toBeFalse(); + + $menu->delete(); + + expect($menu->trashed())->toBeTrue(); + expect($item1->fresh()->trashed())->toBeTrue(); + expect($item2->fresh()->trashed())->toBeTrue(); + expect($childItem->fresh()->trashed())->toBeTrue(); +}); diff --git a/tests/Unit/TenancyTest.php b/tests/Unit/TenancyTest.php new file mode 100644 index 0000000..3e4f706 --- /dev/null +++ b/tests/Unit/TenancyTest.php @@ -0,0 +1,34 @@ + true]); + config(['eclipse-cms.tenancy.foreign_key' => 'site_id']); + + $menu = new Menu; + $fillable = $menu->getFillable(); + + expect($fillable)->toContain('site_id'); + + config(['eclipse-cms.tenancy.enabled' => false]); +}); + +it('section model has proper tenancy fillable attributes', function () { + config(['eclipse-cms.tenancy.enabled' => true]); + config(['eclipse-cms.tenancy.foreign_key' => 'site_id']); + + $section = new Section; + $fillable = $section->getFillable(); + + expect($fillable)->toContain('site_id'); + + config(['eclipse-cms.tenancy.enabled' => false]); +}); + +it('tenancy configuration is properly handled', function () { + expect(config('eclipse-cms.tenancy.enabled'))->toBeFalse(); + expect(config('eclipse-cms.tenancy.model'))->toBe('Workbench\\App\\Models\\Site'); + expect(config('eclipse-cms.tenancy.foreign_key'))->toBe('site_id'); +}); diff --git a/workbench/app/Providers/AdminPanelProvider.php b/workbench/app/Providers/AdminPanelProvider.php index 49e38f2..a2b6084 100644 --- a/workbench/app/Providers/AdminPanelProvider.php +++ b/workbench/app/Providers/AdminPanelProvider.php @@ -10,6 +10,7 @@ use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; +use Filament\SpatieLaravelTranslatablePlugin; use Filament\Support\Facades\FilamentView; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; @@ -45,6 +46,8 @@ public function panel(Panel $panel): Panel ]) ->plugins([ FilamentShieldPlugin::make(), + SpatieLaravelTranslatablePlugin::make() + ->defaultLocales(['en', 'sl']), CmsPlugin::make(), ]) ->pages([