Skip to content

Commit a69a781

Browse files
thapacodes4uthapacodes4u
andauthored
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 <ankitcodes4u@gmail.com>
1 parent 145230b commit a69a781

27 files changed

Lines changed: 2340 additions & 20 deletions

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
"datalinx/php-utils": "^2.5",
4747
"eclipsephp/common": "dev-main",
4848
"filament/filament": "^3.3",
49+
"filament/spatie-laravel-translatable-plugin": "^3.3",
50+
"solution-forest/filament-tree": "^2.1",
4951
"spatie/laravel-package-tools": "^1.19"
5052
},
5153
"require-dev": {

database/migrations/2025_07_28_094927_create_cms_menu_items_table.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ public function up(): void
1515
->constrained('cms_menus', 'id')
1616
->cascadeOnUpdate()
1717
->cascadeOnDelete();
18+
$table->integer('parent_id')->default(-1);
1819
$table->string('type');
19-
$table->string('linkable_class');
20-
$table->string('linkable_id');
20+
$table->string('linkable_class')->nullable();
21+
$table->string('linkable_id')->nullable();
22+
$table->text('custom_url')->nullable();
2123
$table->boolean('new_tab')->default(false);
2224
$table->boolean('is_active')->default(true);
2325
$table->integer('sort')->default(0);

database/seeders/CmsSeeder.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,7 @@ public function run(): void
1212
Section::factory()
1313
->count(3)
1414
->create();
15+
16+
$this->call(MenuSeeder::class);
1517
}
1618
}

database/seeders/MenuSeeder.php

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php
2+
3+
namespace Eclipse\Cms\Seeders;
4+
5+
use Eclipse\Cms\Models\Menu;
6+
use Eclipse\Cms\Models\Menu\Item;
7+
use Eclipse\Cms\Models\Page;
8+
use Eclipse\Cms\Models\Section;
9+
use Illuminate\Database\Seeder;
10+
11+
class MenuSeeder extends Seeder
12+
{
13+
public function run(): void
14+
{
15+
$this->createMainMenu();
16+
$this->createFooterMenu();
17+
}
18+
19+
private function getTenantData(): array
20+
{
21+
if (config('eclipse-cms.tenancy.enabled')) {
22+
$tenantModel = config('eclipse-cms.tenancy.model');
23+
if ($tenantModel && class_exists($tenantModel)) {
24+
$tenantFK = config('eclipse-cms.tenancy.foreign_key', 'site_id');
25+
26+
$tenant = $tenantModel::first();
27+
if (! $tenant) {
28+
$tenant = $tenantModel::create([
29+
'name' => 'Default Site',
30+
'domain' => 'localhost',
31+
]);
32+
}
33+
34+
if ($tenant) {
35+
return [$tenantFK => $tenant->id];
36+
}
37+
}
38+
}
39+
40+
return [];
41+
}
42+
43+
private function createMainMenu(): void
44+
{
45+
$menu = Menu::factory()->create(array_merge([
46+
'title' => [
47+
'en' => 'Main Navigation',
48+
'sl' => 'Glavna Navigacija',
49+
],
50+
'code' => 'main',
51+
'is_active' => true,
52+
], $this->getTenantData()));
53+
54+
$homeSection = Section::first() ?: Section::factory()->create([
55+
'name' => [
56+
'en' => 'Home',
57+
'sl' => 'Domov',
58+
],
59+
]);
60+
61+
$aboutSection = Section::first() ?: Section::factory()->create([
62+
'name' => [
63+
'en' => 'About',
64+
'sl' => 'O nas',
65+
],
66+
]);
67+
68+
$homeItem = Item::factory()->linkableToSection()->create([
69+
'label' => [
70+
'en' => 'Home',
71+
'sl' => 'Domov',
72+
],
73+
'menu_id' => $menu->id,
74+
'linkable_id' => $homeSection->id,
75+
'is_active' => true,
76+
'sort' => 1,
77+
]);
78+
79+
$aboutItem = Item::factory()->linkableToSection()->create([
80+
'label' => [
81+
'en' => 'About Us',
82+
'sl' => 'O nas',
83+
],
84+
'menu_id' => $menu->id,
85+
'linkable_id' => $aboutSection->id,
86+
'is_active' => true,
87+
'sort' => 2,
88+
]);
89+
90+
$servicesGroup = Item::factory()->group()->create([
91+
'label' => [
92+
'en' => 'Our Services',
93+
'sl' => 'Naše Storitve',
94+
],
95+
'menu_id' => $menu->id,
96+
'is_active' => true,
97+
'sort' => 3,
98+
]);
99+
100+
if (Page::count() > 0) {
101+
$servicePage = Page::first();
102+
Item::factory()->linkableToPage()->childOf($servicesGroup)->create([
103+
'label' => [
104+
'en' => 'Web Development Services',
105+
'sl' => 'Storitve Spletnega Razvoja',
106+
],
107+
'linkable_id' => $servicePage->id,
108+
'is_active' => true,
109+
'sort' => 1,
110+
]);
111+
}
112+
113+
Item::factory()->customUrl('https://support.example.com')->childOf($servicesGroup)->create([
114+
'label' => [
115+
'en' => 'Customer Support',
116+
'sl' => 'Podpora Strankam',
117+
],
118+
'new_tab' => true,
119+
'is_active' => true,
120+
'sort' => 2,
121+
]);
122+
123+
Item::factory()->customUrl('/contact')->create([
124+
'label' => [
125+
'en' => 'Contact Us',
126+
'sl' => 'Kontakt',
127+
],
128+
'menu_id' => $menu->id,
129+
'is_active' => true,
130+
'sort' => 4,
131+
]);
132+
}
133+
134+
private function createFooterMenu(): void
135+
{
136+
$menu = Menu::factory()->create(array_merge([
137+
'title' => [
138+
'en' => 'Footer Links',
139+
'sl' => 'Povezave v Nogi',
140+
],
141+
'code' => 'footer',
142+
'is_active' => true,
143+
], $this->getTenantData()));
144+
145+
Item::factory()->customUrl('/privacy')->create([
146+
'label' => [
147+
'en' => 'Privacy Policy',
148+
'sl' => 'Pravilnik o Zasebnosti',
149+
],
150+
'menu_id' => $menu->id,
151+
'is_active' => true,
152+
'sort' => 1,
153+
]);
154+
155+
Item::factory()->customUrl('/terms')->create([
156+
'label' => [
157+
'en' => 'Terms of Service',
158+
'sl' => 'Pogoji Uporabe',
159+
],
160+
'menu_id' => $menu->id,
161+
'is_active' => true,
162+
'sort' => 2,
163+
]);
164+
165+
if (Section::count() > 1) {
166+
$section = Section::skip(1)->first();
167+
Item::factory()->linkableToSection()->create([
168+
'label' => [
169+
'en' => 'Latest News',
170+
'sl' => 'Najnovice',
171+
],
172+
'menu_id' => $menu->id,
173+
'linkable_id' => $section->id,
174+
'is_active' => true,
175+
'sort' => 3,
176+
]);
177+
}
178+
}
179+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
<?php
2+
3+
namespace Eclipse\Cms\Admin\Filament\Resources;
4+
5+
use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions;
6+
use Eclipse\Cms\Admin\Filament\Resources\MenuResource\Pages;
7+
use Eclipse\Cms\Admin\Filament\Resources\MenuResource\RelationManagers;
8+
use Eclipse\Cms\Models\Menu;
9+
use Filament\Forms;
10+
use Filament\Forms\Form;
11+
use Filament\Infolists;
12+
use Filament\Infolists\Infolist;
13+
use Filament\Resources\Concerns\Translatable;
14+
use Filament\Resources\Resource;
15+
use Filament\Tables;
16+
use Filament\Tables\Table;
17+
use Illuminate\Database\Eloquent\Builder;
18+
use Illuminate\Database\Eloquent\SoftDeletingScope;
19+
20+
class MenuResource extends Resource implements HasShieldPermissions
21+
{
22+
use Translatable;
23+
24+
protected static ?string $model = Menu::class;
25+
26+
protected static ?string $navigationIcon = 'heroicon-o-bars-3';
27+
28+
protected static ?string $navigationGroup = 'CMS';
29+
30+
protected static ?int $navigationSort = 3;
31+
32+
public static function form(Form $form): Form
33+
{
34+
return $form
35+
->schema([
36+
Forms\Components\Section::make()
37+
->compact()
38+
->columns(2)
39+
->schema([
40+
Forms\Components\TextInput::make('title')
41+
->required()
42+
->maxLength(255),
43+
Forms\Components\TextInput::make('code')
44+
->maxLength(255)
45+
->helperText('Unique identifier for this menu (optional)'),
46+
Forms\Components\Toggle::make('is_active')
47+
->columnSpanFull()
48+
->default(true),
49+
]),
50+
]);
51+
}
52+
53+
public static function infolist(Infolist $infolist): Infolist
54+
{
55+
return $infolist
56+
->schema([
57+
Infolists\Components\Section::make('Information')
58+
->compact()
59+
->schema([
60+
Infolists\Components\TextEntry::make('title')
61+
->label('Title'),
62+
Infolists\Components\TextEntry::make('code')
63+
->label('Code')
64+
->placeholder(''),
65+
Infolists\Components\IconEntry::make('is_active')
66+
->label('Status')
67+
->boolean()
68+
->trueIcon('heroicon-o-check-circle')
69+
->falseIcon('heroicon-o-x-circle')
70+
->trueColor('success')
71+
->falseColor('danger'),
72+
])
73+
->columns(3),
74+
75+
Infolists\Components\Section::make('Timestamps')
76+
->compact()
77+
->schema([
78+
Infolists\Components\TextEntry::make('created_at')
79+
->label('Created')
80+
->dateTime(),
81+
Infolists\Components\TextEntry::make('updated_at')
82+
->label('Last Updated')
83+
->dateTime(),
84+
Infolists\Components\TextEntry::make('deleted_at')
85+
->label('Deleted')
86+
->dateTime()
87+
->placeholder(''),
88+
])
89+
->columns(3)
90+
->collapsible(),
91+
]);
92+
}
93+
94+
public static function table(Table $table): Table
95+
{
96+
return $table
97+
->columns([
98+
Tables\Columns\TextColumn::make('title')
99+
->searchable()
100+
->sortable(),
101+
Tables\Columns\TextColumn::make('code')
102+
->searchable()
103+
->sortable()
104+
->placeholder(''),
105+
Tables\Columns\IconColumn::make('is_active')
106+
->boolean()
107+
->sortable(),
108+
Tables\Columns\TextColumn::make('created_at')
109+
->dateTime()
110+
->sortable()
111+
->toggleable(isToggledHiddenByDefault: true),
112+
Tables\Columns\TextColumn::make('updated_at')
113+
->dateTime()
114+
->sortable()
115+
->toggleable(isToggledHiddenByDefault: true),
116+
])
117+
->filters([
118+
Tables\Filters\TrashedFilter::make(),
119+
])
120+
->actions([
121+
Tables\Actions\EditAction::make(),
122+
Tables\Actions\DeleteAction::make(),
123+
])
124+
->bulkActions([
125+
Tables\Actions\BulkActionGroup::make([
126+
Tables\Actions\DeleteBulkAction::make(),
127+
Tables\Actions\ForceDeleteBulkAction::make(),
128+
Tables\Actions\RestoreBulkAction::make(),
129+
]),
130+
]);
131+
}
132+
133+
public static function getRelations(): array
134+
{
135+
return [
136+
RelationManagers\MenuItemsRelationManager::class,
137+
];
138+
}
139+
140+
public static function getPages(): array
141+
{
142+
return [
143+
'index' => Pages\ListMenus::route('/'),
144+
'create' => Pages\CreateMenu::route('/create'),
145+
'edit' => Pages\EditMenu::route('/{record}/edit'),
146+
'sort-items' => Pages\SortMenuItems::route('/{record}/sort-items'),
147+
];
148+
}
149+
150+
public static function getPermissionPrefixes(): array
151+
{
152+
return [
153+
'view_any',
154+
'create',
155+
'update',
156+
'restore',
157+
'restore_any',
158+
'replicate',
159+
'reorder',
160+
'delete',
161+
'delete_any',
162+
'force_delete',
163+
'force_delete_any',
164+
];
165+
}
166+
167+
public static function getEloquentQuery(): Builder
168+
{
169+
return parent::getEloquentQuery()
170+
->withoutGlobalScopes([
171+
SoftDeletingScope::class,
172+
]);
173+
}
174+
}

0 commit comments

Comments
 (0)