diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php index 5792314..a6803f4 100644 --- a/database/factories/PageFactory.php +++ b/database/factories/PageFactory.php @@ -2,32 +2,67 @@ namespace Eclipse\Cms\Factories; +use Eclipse\Cms\Enums\PageStatus; use Eclipse\Cms\Models\Page; use Eclipse\Cms\Models\Section; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Carbon; -/** - * @experimental - */ class PageFactory extends Factory { protected $model = Page::class; public function definition(): array { + $englishTitle = $this->faker->sentence(3); + $slovenianTitle = "SI: {$englishTitle}"; + + $englishShortText = $this->faker->text(200); + $slovenianShortText = "SI: {$englishShortText}"; + + $englishLongText = $this->faker->text(500); + $slovenianLongText = "SI: {$englishLongText}"; + + $slug = $this->faker->slug(); + return [ - 'title' => $this->faker->word(), - 'short_text' => $this->faker->text(), - 'long_text' => $this->faker->text(), - 'sef_key' => $this->faker->word(), - 'code' => $this->faker->word(), - 'status' => $this->faker->word(), - 'type' => $this->faker->word(), + 'title' => [ + 'en' => $englishTitle, + 'sl' => $slovenianTitle, + ], + 'short_text' => [ + 'en' => $englishShortText, + 'sl' => $slovenianShortText, + ], + 'long_text' => [ + 'en' => $englishLongText, + 'sl' => $slovenianLongText, + ], + 'sef_key' => [ + 'en' => $slug, + 'sl' => "{$slug}-si", + ], + 'code' => $this->faker->unique()->numerify('page-###-####'), + 'status' => $this->faker->randomElement([PageStatus::Draft, PageStatus::Published]), + 'type' => 'page', 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), - - 'section_id' => Section::factory(), ]; } + + public function configure() + { + return $this->afterMaking(function (Page $page) { + if (! $page->section_id) { + $page->section_id = Section::factory()->create()->id; + } + }); + } + + public function forSection($section): static + { + return $this->state([ + 'section_id' => $section->id, + ]); + } } diff --git a/database/factories/SectionFactory.php b/database/factories/SectionFactory.php index 4aead89..426d663 100644 --- a/database/factories/SectionFactory.php +++ b/database/factories/SectionFactory.php @@ -5,7 +5,6 @@ use Eclipse\Cms\Enums\SectionType; use Eclipse\Cms\Models\Section; use Illuminate\Database\Eloquent\Factories\Factory; -use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Str; @@ -15,18 +14,46 @@ class SectionFactory extends Factory public function definition(): array { - $attrs = [ - 'name' => Str::of($this->faker->words(asText: true))->ucwords(), - 'type' => $this->faker->randomElement(Arr::pluck(SectionType::cases(), 'name')), + $englishName = Str::of($this->faker->words(asText: true))->ucwords(); + $slovenianName = "SI: {$englishName}"; + + return [ + 'name' => [ + 'en' => $englishName, + 'sl' => $slovenianName, + ], + 'type' => $this->faker->randomElement(SectionType::cases()), 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]; + } + + public function configure() + { + return $this->afterMaking(function (Section $section) { + if (config('eclipse-cms.tenancy.enabled')) { + $foreignKey = config('eclipse-cms.tenancy.foreign_key'); + $currentValue = $section->getAttribute($foreignKey); - if (config('eclipse-cms.tenancy.enabled') && empty($attrs[config('eclipse-cms.tenancy.foreign_key')])) { - $class = config('eclipse-cms.tenancy.model'); - $attrs[config('eclipse-cms.tenancy.foreign_key')] = $class::inRandomOrder()->first()?->id ?? $class::factory()->create()->id; + if (! $currentValue || $currentValue === null) { + $class = config('eclipse-cms.tenancy.model'); + if (class_exists($class)) { + $newValue = $class::inRandomOrder()->first()?->id ?? $class::factory()->create()->id; + $section->setAttribute($foreignKey, $newValue); + } + } + } + }); + } + + public function forSite($site): static + { + if (config('eclipse-cms.tenancy.enabled')) { + return $this->state([ + config('eclipse-cms.tenancy.foreign_key') => $site->id, + ]); } - return $attrs; + return $this; } } diff --git a/database/seeders/CmsSeeder.php b/database/seeders/CmsSeeder.php index 74a2459..c7f1c84 100644 --- a/database/seeders/CmsSeeder.php +++ b/database/seeders/CmsSeeder.php @@ -2,6 +2,7 @@ namespace Eclipse\Cms\Seeders; +use Eclipse\Cms\Models\Page; use Eclipse\Cms\Models\Section; use Illuminate\Database\Seeder; @@ -9,10 +10,54 @@ class CmsSeeder extends Seeder { public function run(): void { - Section::factory() - ->count(3) + if (config('eclipse-cms.tenancy.enabled')) { + $tenantModel = config('eclipse-cms.tenancy.model'); + $tenants = $tenantModel::all(); + + if ($tenants->isEmpty()) { + $tenants = collect([$tenantModel::factory()->create()]); + } + + $tenants->each(function ($tenant): void { + $this->seedForTenant($tenant); + }); + } else { + $this->seedWithoutTenancy(); + } + } + + protected function seedForTenant($tenant): void + { + $sections = Section::factory() + ->forSite($tenant) + ->count(2) ->create(); + $sections->each(function (Section $section): void { + Page::factory() + ->count(rand(2, 5)) + ->forSection($section) + ->create(); + }); + + $this + ->call(BannerSeeder::class) + ->call(MenuSeeder::class); + } + + protected function seedWithoutTenancy(): void + { + $sections = Section::factory() + ->count(2) + ->create(); + + $sections->each(function (Section $section): void { + Page::factory() + ->count(rand(2, 5)) + ->forSection($section) + ->create(); + }); + $this ->call(BannerSeeder::class) ->call(MenuSeeder::class); diff --git a/src/Admin/Filament/Resources/BannerPositionResource.php b/src/Admin/Filament/Resources/BannerPositionResource.php index dd2627e..c72935e 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource.php +++ b/src/Admin/Filament/Resources/BannerPositionResource.php @@ -30,6 +30,8 @@ class BannerPositionResource extends Resource implements HasShieldPermissions protected static ?string $pluralModelLabel = 'Banners'; + protected static ?int $navigationSort = 202; + public static function getPermissionPrefixes(): array { return [ diff --git a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php index a5cbb79..158ea69 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php +++ b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php @@ -4,7 +4,6 @@ use Eclipse\Cms\Models\Banner; use Eclipse\Cms\Rules\BannerImageDimensionRule; -use Eclipse\Common\Filament\Tables\Columns\ImageColumn; use Eclipse\Common\Helpers\MediaHelper; use Filament\Actions; use Filament\Forms; @@ -14,6 +13,7 @@ use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\SoftDeletingScope; @@ -45,7 +45,6 @@ protected function getDynamicImageColumns(): array return $imageTypes->map(function ($imageType) { return ImageColumn::make("image_type_{$imageType->id}") ->label($imageType->name) - ->preview() ->getStateUsing(function (Banner $record) use ($imageType) { $locale = $this->activeLocale ?? app()->getLocale(); $image = $record->images->where('type_id', $imageType->id)->first(); @@ -64,12 +63,6 @@ protected function getDynamicImageColumns(): array return null; }) - ->title(function (Banner $record) use ($imageType) { - $locale = $this->activeLocale ?? app()->getLocale(); - - return $record->getTranslation('name', $locale).' - '.$imageType->name; - }) - ->link(fn (Banner $record) => $record->link ?? '#') ->sortable(false); })->toArray(); } diff --git a/src/Admin/Filament/Resources/MenuResource.php b/src/Admin/Filament/Resources/MenuResource.php index ad1ac2b..f0f6592 100644 --- a/src/Admin/Filament/Resources/MenuResource.php +++ b/src/Admin/Filament/Resources/MenuResource.php @@ -28,7 +28,7 @@ class MenuResource extends Resource implements HasShieldPermissions protected static string|\UnitEnum|null $navigationGroup = 'CMS'; - protected static ?int $navigationSort = 3; + protected static ?int $navigationSort = 201; public static function form(Schema $schema): Schema { diff --git a/src/Admin/Filament/Resources/PageResource.php b/src/Admin/Filament/Resources/PageResource.php index b0ba9f5..4555886 100644 --- a/src/Admin/Filament/Resources/PageResource.php +++ b/src/Admin/Filament/Resources/PageResource.php @@ -2,65 +2,166 @@ namespace Eclipse\Cms\Admin\Filament\Resources; +use Eclipse\Cms\Admin\Filament\Resources\PageResource\Pages; use Eclipse\Cms\Admin\Filament\Resources\PageResource\Pages\CreatePage; use Eclipse\Cms\Admin\Filament\Resources\PageResource\Pages\EditPage; use Eclipse\Cms\Admin\Filament\Resources\PageResource\Pages\ListPages; +use Eclipse\Cms\Enums\PageStatus; use Eclipse\Cms\Models\Page; use Filament\Actions; -use Filament\Forms\Components\MarkdownEditor; +use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\RichEditor; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Resources\Resource; +use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\SoftDeletingScope; +use Illuminate\Support\Str; +use LaraZeus\SpatieTranslatable\Resources\Concerns\Translatable; class PageResource extends Resource { + use Translatable; + protected static ?string $model = Page::class; - protected static ?string $slug = 'pages'; + protected static ?string $slug = 'cms/pages'; - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack'; + protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-text'; protected static string|\UnitEnum|null $navigationGroup = 'CMS'; + protected static bool $shouldRegisterNavigation = false; + + protected static ?string $navigationLabel = 'Pages'; + public static function form(Schema $schema): Schema { return $schema ->components([ - TextInput::make('title') - ->required(), + Section::make('Basic Information') + ->columnSpanFull() + ->schema([ + TextInput::make('title') + ->label('Page Title') + ->required() + ->maxLength(255) + ->placeholder('Enter page title...') + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, $state, $set) { + if ($operation === 'create' && $state) { + $slug = is_array($state) ? ($state['en'] ?? '') : $state; + if ($slug) { + $set('sef_key', Str::slug($slug)); + } + } + }) + ->columnSpan(2), + + Select::make('section_id') + ->label('Section') + ->relationship('section', 'name') + ->searchable() + ->preload() + ->native(false) + ->columnSpan(1) + ->default(request()->get('sId')), + + TextInput::make('sef_key') + ->label('URL Slug') + ->maxLength(255) + ->placeholder('auto-generated-from-title') + ->helperText('Leave empty to auto-generate from title') + ->columnSpan(2), - TextInput::make('section_id') - ->required() - ->integer(), + Select::make('status') + ->label('Status') + ->options(PageStatus::class) + ->required() + ->default(PageStatus::Draft) + ->native(false) + ->columnSpan(1), - MarkdownEditor::make('short_text'), + TextInput::make('code') + ->label('Page Code') + ->maxLength(255) + ->placeholder('Optional reference code') + ->columnSpanFull(), + ]) + ->columns(3) + ->compact(), - MarkdownEditor::make('long_text'), + Section::make('Content') + ->columnSpanFull() + ->schema([ + RichEditor::make('short_text') + ->label('Short Description') + ->placeholder('Brief summary or excerpt...') + ->toolbarButtons([ + 'bold', + 'italic', + 'underline', + 'link', + ]) + ->columnSpanFull(), - TextInput::make('sef_key') - ->required(), + RichEditor::make('long_text') + ->label('Main Content') + ->placeholder('Enter the main content of your page...') + ->toolbarButtons([ + 'bold', + 'italic', + 'underline', + 'h2', + 'h3', + 'bulletList', + 'orderedList', + 'link', + 'blockquote', + 'codeBlock', + ]) + ->columnSpanFull(), + ]) + ->compact(), - TextInput::make('code'), + Section::make('Information') + ->columnSpanFull() + ->schema([ + Placeholder::make('created_at') + ->label('Created') + ->content(fn (?Page $record): string => $record?->created_at?->format('M j, Y g:i A') ?? '-'), - TextInput::make('status') - ->required(), + Placeholder::make('updated_at') + ->label('Last Modified') + ->content(fn (?Page $record): string => $record?->updated_at?->diffForHumans() ?? '-'), - TextInput::make('type') - ->required(), + Placeholder::make('word_count') + ->label('Word Count') + ->content(function (?Page $record): string { + if (! $record) { + return '-'; + } - Placeholder::make('created_at') - ->label('Created Date') - ->content(fn (?Page $record): string => $record?->created_at?->diffForHumans() ?? '-'), + $shortCount = $record->short_text ? str_word_count(strip_tags($record->short_text)) : 0; + $longCount = $record->long_text ? str_word_count(strip_tags($record->long_text)) : 0; + $total = $shortCount + $longCount; - Placeholder::make('updated_at') - ->label('Last Modified Date') - ->content(fn (?Page $record): string => $record?->updated_at?->diffForHumans() ?? '-'), + return $total.' words'; + }), + ]) + ->columns(3) + ->compact() + ->hiddenOn('create'), + + Hidden::make('type') + ->default('page'), ]); } @@ -69,20 +170,61 @@ public static function table(Table $table): Table return $table ->columns([ TextColumn::make('title') + ->label('Page Title') ->searchable() - ->sortable(), + ->sortable() + ->weight('medium') + ->limit(50) + ->tooltip(function (TextColumn $column): ?string { + $state = $column->getState(); + + return strlen($state) > 50 ? $state : null; + }), + + TextColumn::make('section.name') + ->label('Section') + ->sortable() + ->badge() + ->color('gray') + ->visible(fn ($livewire): bool => ! ($livewire instanceof Pages\ListPages && $livewire->sectionId)), - TextColumn::make('section_id'), + TextColumn::make('sef_key') + ->label('URL Slug') + ->searchable() + ->sortable() + ->copyable() + ->copyMessage('URL slug copied') + ->copyMessageDuration(1500) + ->icon('heroicon-m-link') + ->iconPosition('after'), - TextColumn::make('sef_key'), + TextColumn::make('status') + ->label('Status') + ->badge() + ->sortable(), - TextColumn::make('code'), + TextColumn::make('code') + ->label('Code') + ->searchable() + ->placeholder('-') + ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('status'), + TextColumn::make('created_at') + ->label('Created') + ->dateTime('M j, Y') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('type'), + TextColumn::make('updated_at') + ->label('Last Updated') + ->dateTime('M j, Y') + ->sortable() + ->toggleable(), ]) ->filters([ + SelectFilter::make('status') + ->options(PageStatus::class), + TrashedFilter::make(), ]) ->recordActions([ @@ -111,7 +253,7 @@ public static function getPages(): array public static function getEloquentQuery(): Builder { - return parent::getEloquentQuery() + return static::getModel()::query() ->withoutGlobalScopes([ SoftDeletingScope::class, ]); @@ -121,4 +263,19 @@ public static function getGloballySearchableAttributes(): array { return ['title']; } + + public static function getPermissionPrefixes(): array + { + return [ + 'view_any', + 'create', + 'update', + 'delete', + 'delete_any', + 'force_delete', + 'force_delete_any', + 'restore', + 'restore_any', + ]; + } } diff --git a/src/Admin/Filament/Resources/PageResource/Pages/CreatePage.php b/src/Admin/Filament/Resources/PageResource/Pages/CreatePage.php index 106b7ac..46b7236 100644 --- a/src/Admin/Filament/Resources/PageResource/Pages/CreatePage.php +++ b/src/Admin/Filament/Resources/PageResource/Pages/CreatePage.php @@ -4,15 +4,19 @@ use Eclipse\Cms\Admin\Filament\Resources\PageResource; use Filament\Resources\Pages\CreateRecord; +use LaraZeus\SpatieTranslatable\Actions\LocaleSwitcher; +use LaraZeus\SpatieTranslatable\Resources\Pages\CreateRecord\Concerns\Translatable; class CreatePage extends CreateRecord { + use Translatable; + protected static string $resource = PageResource::class; protected function getHeaderActions(): array { return [ - + LocaleSwitcher::make(), ]; } } diff --git a/src/Admin/Filament/Resources/PageResource/Pages/EditPage.php b/src/Admin/Filament/Resources/PageResource/Pages/EditPage.php index d0908dd..e6dfef7 100644 --- a/src/Admin/Filament/Resources/PageResource/Pages/EditPage.php +++ b/src/Admin/Filament/Resources/PageResource/Pages/EditPage.php @@ -3,21 +3,24 @@ namespace Eclipse\Cms\Admin\Filament\Resources\PageResource\Pages; use Eclipse\Cms\Admin\Filament\Resources\PageResource; -use Filament\Actions\DeleteAction; -use Filament\Actions\ForceDeleteAction; -use Filament\Actions\RestoreAction; +use Filament\Actions; use Filament\Resources\Pages\EditRecord; +use LaraZeus\SpatieTranslatable\Actions\LocaleSwitcher; +use LaraZeus\SpatieTranslatable\Resources\Pages\EditRecord\Concerns\Translatable; class EditPage extends EditRecord { + use Translatable; + protected static string $resource = PageResource::class; protected function getHeaderActions(): array { return [ - DeleteAction::make(), - ForceDeleteAction::make(), - RestoreAction::make(), + LocaleSwitcher::make(), + Actions\DeleteAction::make(), + Actions\ForceDeleteAction::make(), + Actions\RestoreAction::make(), ]; } } diff --git a/src/Admin/Filament/Resources/PageResource/Pages/ListPages.php b/src/Admin/Filament/Resources/PageResource/Pages/ListPages.php index c6c78a1..5675b53 100644 --- a/src/Admin/Filament/Resources/PageResource/Pages/ListPages.php +++ b/src/Admin/Filament/Resources/PageResource/Pages/ListPages.php @@ -3,17 +3,75 @@ namespace Eclipse\Cms\Admin\Filament\Resources\PageResource\Pages; use Eclipse\Cms\Admin\Filament\Resources\PageResource; +use Eclipse\Cms\Models\Section; +use Eclipse\Common\Foundation\Pages\HasScoutSearch; use Filament\Actions\CreateAction; use Filament\Resources\Pages\ListRecords; +use Illuminate\Database\Eloquent\Builder; class ListPages extends ListRecords { + use HasScoutSearch; + protected static string $resource = PageResource::class; + public ?int $sectionId = null; + + public function mount(): void + { + parent::mount(); + + $this->sectionId = request()->get('sId'); + } + + public function getTitle(): string + { + if ($this->sectionId) { + $section = Section::find($this->sectionId); + if ($section) { + return $section->name; + } + } + + return parent::getTitle(); + } + + public function getBreadcrumb(): ?string + { + if ($this->sectionId) { + $section = Section::find($this->sectionId); + if ($section) { + return $section->name; + } + } + + return parent::getBreadcrumb(); + } + + protected function getTableQuery(): Builder + { + $query = parent::getTableQuery(); + + if ($this->sectionId) { + $query->where('section_id', $this->sectionId); + } + + return $query; + } + protected function getHeaderActions(): array { + $createAction = CreateAction::make(); + + if ($this->sectionId) { + $createAction + ->url(fn () => PageResource::getUrl('create', [ + 'sId' => $this->sectionId, + ])); + } + return [ - CreateAction::make(), + $createAction, ]; } } diff --git a/src/Admin/Filament/Resources/SectionResource.php b/src/Admin/Filament/Resources/SectionResource.php index 7de7a98..5715874 100644 --- a/src/Admin/Filament/Resources/SectionResource.php +++ b/src/Admin/Filament/Resources/SectionResource.php @@ -5,38 +5,83 @@ use Eclipse\Cms\Admin\Filament\Resources\SectionResource\Pages\CreateSection; use Eclipse\Cms\Admin\Filament\Resources\SectionResource\Pages\EditSection; use Eclipse\Cms\Admin\Filament\Resources\SectionResource\Pages\ListSections; +use Eclipse\Cms\Enums\SectionType; use Eclipse\Cms\Models\Section; use Filament\Actions; +use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Resources\Resource; +use Filament\Schemas\Components\Section as SchemaSection; use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\SoftDeletingScope; +use LaraZeus\SpatieTranslatable\Resources\Concerns\Translatable; class SectionResource extends Resource { + use Translatable; + protected static ?string $model = Section::class; protected static ?string $slug = 'cms/sections'; + protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder'; + protected static string|\UnitEnum|null $navigationGroup = 'CMS'; - protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack'; + protected static bool $shouldRegisterNavigation = true; + + protected static ?string $navigationLabel = 'Sections'; - protected static ?string $recordTitleAttribute = 'name'; + protected static ?int $navigationSort = 200; public static function form(Schema $schema): Schema { return $schema ->components([ - TextInput::make('name') - ->required(), + SchemaSection::make('Basic Information') + ->columnSpanFull() + ->schema([ + TextInput::make('name') + ->label('Section Name') + ->required() + ->maxLength(255) + ->placeholder('Enter section name...') + ->columnSpan(2), + + Select::make('type') + ->label('Section Type') + ->options(SectionType::class) + ->required() + ->native(false) + ->columnSpan(1), + ]) + ->columns(3) + ->compact(), + + SchemaSection::make('Information') + ->columnSpanFull() + ->schema([ + Placeholder::make('created_at') + ->label('Created') + ->content(fn (?Section $record): string => $record?->created_at?->format('M j, Y g:i A') ?? '-'), - TextInput::make('type') - ->required(), + Placeholder::make('updated_at') + ->label('Last Modified') + ->content(fn (?Section $record): string => $record?->updated_at?->diffForHumans() ?? '-'), + + Placeholder::make('pages_count') + ->label('Total Pages') + ->content(fn (?Section $record): string => $record?->pages()->count().' pages' ?? '-'), + ]) + ->columns(3) + ->compact() + ->hiddenOn('create'), ]); } @@ -44,10 +89,39 @@ public static function table(Table $table): Table { return $table ->columns([ - TextColumn::make('name'), - TextColumn::make('type'), + TextColumn::make('name') + ->label('Section Name') + ->searchable() + ->sortable() + ->weight('medium'), + + TextColumn::make('type') + ->label('Type') + ->badge() + ->sortable(), + + TextColumn::make('pages_count') + ->label('Pages') + ->counts('pages') + ->badge() + ->color('gray'), + + TextColumn::make('created_at') + ->label('Created') + ->dateTime('M j, Y') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('updated_at') + ->label('Last Updated') + ->dateTime('M j, Y') + ->sortable() + ->toggleable(), ]) ->filters([ + SelectFilter::make('type') + ->options(SectionType::class), + TrashedFilter::make(), ]) ->recordActions([ @@ -65,6 +139,11 @@ public static function table(Table $table): Table ]); } + public static function getRelations(): array + { + return []; + } + public static function getPages(): array { return [ @@ -76,7 +155,7 @@ public static function getPages(): array public static function getEloquentQuery(): Builder { - return parent::getEloquentQuery() + return static::getModel()::query() ->withoutGlobalScopes([ SoftDeletingScope::class, ]); @@ -86,4 +165,19 @@ public static function getGloballySearchableAttributes(): array { return ['name']; } + + public static function getPermissionPrefixes(): array + { + return [ + 'view_any', + 'create', + 'update', + 'delete', + 'delete_any', + 'force_delete', + 'force_delete_any', + 'restore', + 'restore_any', + ]; + } } diff --git a/src/Admin/Filament/Resources/SectionResource/Pages/CreateSection.php b/src/Admin/Filament/Resources/SectionResource/Pages/CreateSection.php index 738bd9f..a07c85f 100644 --- a/src/Admin/Filament/Resources/SectionResource/Pages/CreateSection.php +++ b/src/Admin/Filament/Resources/SectionResource/Pages/CreateSection.php @@ -4,15 +4,19 @@ use Eclipse\Cms\Admin\Filament\Resources\SectionResource; use Filament\Resources\Pages\CreateRecord; +use LaraZeus\SpatieTranslatable\Actions\LocaleSwitcher; +use LaraZeus\SpatieTranslatable\Resources\Pages\CreateRecord\Concerns\Translatable; class CreateSection extends CreateRecord { + use Translatable; + protected static string $resource = SectionResource::class; protected function getHeaderActions(): array { return [ - + LocaleSwitcher::make(), ]; } } diff --git a/src/Admin/Filament/Resources/SectionResource/Pages/EditSection.php b/src/Admin/Filament/Resources/SectionResource/Pages/EditSection.php index 8f5a34c..4be0049 100644 --- a/src/Admin/Filament/Resources/SectionResource/Pages/EditSection.php +++ b/src/Admin/Filament/Resources/SectionResource/Pages/EditSection.php @@ -3,21 +3,24 @@ namespace Eclipse\Cms\Admin\Filament\Resources\SectionResource\Pages; use Eclipse\Cms\Admin\Filament\Resources\SectionResource; -use Filament\Actions\DeleteAction; -use Filament\Actions\ForceDeleteAction; -use Filament\Actions\RestoreAction; +use Filament\Actions; use Filament\Resources\Pages\EditRecord; +use LaraZeus\SpatieTranslatable\Actions\LocaleSwitcher; +use LaraZeus\SpatieTranslatable\Resources\Pages\EditRecord\Concerns\Translatable; class EditSection extends EditRecord { + use Translatable; + protected static string $resource = SectionResource::class; protected function getHeaderActions(): array { return [ - DeleteAction::make(), - ForceDeleteAction::make(), - RestoreAction::make(), + LocaleSwitcher::make(), + Actions\DeleteAction::make(), + Actions\ForceDeleteAction::make(), + Actions\RestoreAction::make(), ]; } } diff --git a/src/Admin/Filament/Resources/SectionResource/Pages/ListSections.php b/src/Admin/Filament/Resources/SectionResource/Pages/ListSections.php index 514c6cb..002acf1 100644 --- a/src/Admin/Filament/Resources/SectionResource/Pages/ListSections.php +++ b/src/Admin/Filament/Resources/SectionResource/Pages/ListSections.php @@ -3,17 +3,22 @@ namespace Eclipse\Cms\Admin\Filament\Resources\SectionResource\Pages; use Eclipse\Cms\Admin\Filament\Resources\SectionResource; -use Filament\Actions\CreateAction; +use Filament\Actions; use Filament\Resources\Pages\ListRecords; +use LaraZeus\SpatieTranslatable\Actions\LocaleSwitcher; +use LaraZeus\SpatieTranslatable\Resources\Pages\ListRecords\Concerns\Translatable; class ListSections extends ListRecords { + use Translatable; + protected static string $resource = SectionResource::class; protected function getHeaderActions(): array { return [ - CreateAction::make(), + LocaleSwitcher::make(), + Actions\CreateAction::make(), ]; } } diff --git a/src/CmsPlugin.php b/src/CmsPlugin.php index a468ce7..038a6ab 100644 --- a/src/CmsPlugin.php +++ b/src/CmsPlugin.php @@ -2,14 +2,49 @@ namespace Eclipse\Cms; +use Eclipse\Cms\Admin\Filament\Resources\PageResource; use Eclipse\Cms\Models\Page; use Eclipse\Cms\Models\Section; use Eclipse\Common\Foundation\Plugins\HasLinkables; use Eclipse\Common\Foundation\Plugins\Plugin; +use Exception; +use Filament\Facades\Filament; use Filament\Forms\Components\MorphToSelect; +use Filament\Navigation\NavigationItem; +use Filament\Panel; class CmsPlugin extends Plugin implements HasLinkables { + public function register(Panel $panel): void + { + parent::register($panel); + + $panel->navigationItems($this->getSectionNavigationItems()); + } + + public function getSectionNavigationItems(): array + { + try { + return Section::query() + ->select('name', 'id', config('eclipse-cms.tenancy.foreign_key')) + ->get() + ->map(fn (Section $section): NavigationItem => NavigationItem::make($section->getTranslation('name', app()->getLocale())) + ->url( + fn (): string => PageResource::getUrl('index', [ + 'sId' => $section->id, + ]) + ) + ->icon('heroicon-o-document-text') + ->group('CMS') + ->sort(10) + ->visible(fn (): bool => $section->{config('eclipse-cms.tenancy.foreign_key')} === Filament::getTenant()?->id) + ) + ->toArray(); + } catch (Exception) { + return []; + } + } + public function getLinkables(): array { return [ diff --git a/src/CmsServiceProvider.php b/src/CmsServiceProvider.php index f4ed471..4278a08 100644 --- a/src/CmsServiceProvider.php +++ b/src/CmsServiceProvider.php @@ -4,8 +4,12 @@ use Eclipse\Cms\Models\Banner\Position; use Eclipse\Cms\Models\Menu; +use Eclipse\Cms\Models\Page; +use Eclipse\Cms\Models\Section; use Eclipse\Cms\Policies\BannerPositionPolicy; use Eclipse\Cms\Policies\MenuPolicy; +use Eclipse\Cms\Policies\PagePolicy; +use Eclipse\Cms\Policies\SectionPolicy; use Eclipse\Common\Foundation\Providers\PackageServiceProvider; use Eclipse\Common\Package; use Illuminate\Support\Facades\Gate; @@ -26,6 +30,9 @@ public function configurePackage(SpatiePackage|Package $package): void public function bootingPackage(): void { + // Register policies + Gate::policy(Section::class, SectionPolicy::class); + Gate::policy(Page::class, PagePolicy::class); Gate::policy(Position::class, BannerPositionPolicy::class); Gate::policy(Menu::class, MenuPolicy::class); } diff --git a/src/Enums/PageStatus.php b/src/Enums/PageStatus.php index 7a0b59a..fb6afdf 100644 --- a/src/Enums/PageStatus.php +++ b/src/Enums/PageStatus.php @@ -4,10 +4,10 @@ use Filament\Support\Contracts\HasLabel; -enum PageStatus implements HasLabel +enum PageStatus: string implements HasLabel { - case Draft; - case Published; + case Draft = 'draft'; + case Published = 'published'; public function getLabel(): ?string { diff --git a/src/Enums/SectionType.php b/src/Enums/SectionType.php index 8327a6a..e6c4641 100644 --- a/src/Enums/SectionType.php +++ b/src/Enums/SectionType.php @@ -4,14 +4,14 @@ use Filament\Support\Contracts\HasLabel; -enum SectionType implements HasLabel +enum SectionType: string implements HasLabel { - case Pages; + case Pages = 'pages'; public function getLabel(): ?string { return match ($this) { - self::Pages => 'Pages', + self::Pages => 'Pages' }; } } diff --git a/src/Models/Menu/Item.php b/src/Models/Menu/Item.php index 82ec6ff..d8e3446 100644 --- a/src/Models/Menu/Item.php +++ b/src/Models/Menu/Item.php @@ -11,6 +11,7 @@ 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\Relations\MorphTo; use Illuminate\Database\Eloquent\SoftDeletes; use SolutionForest\FilamentTree\Concern\ModelTree; @@ -82,6 +83,13 @@ public function parent(): BelongsTo return $this->belongsTo(Item::class, 'parent_id'); } + public function children(): HasMany + { + return $this->hasMany(static::class, $this->determineParentColumnName()) + ->with('children') + ->orderBy($this->determineOrderColumnName()); + } + public function linkable(): MorphTo { return $this->morphTo('linkable', 'linkable_class', 'linkable_id'); diff --git a/src/Models/Page.php b/src/Models/Page.php index d6b4c56..abda590 100644 --- a/src/Models/Page.php +++ b/src/Models/Page.php @@ -2,21 +2,32 @@ namespace Eclipse\Cms\Models; +use Eclipse\Cms\Enums\PageStatus; use Eclipse\Cms\Factories\PageFactory; +use Eclipse\Common\Foundation\Models\IsSearchable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use Spatie\Translatable\HasTranslations; class Page extends Model { + use HasFactory, HasTranslations, IsSearchable, SoftDeletes; + + protected $table = 'cms_pages'; + use HasFactory, HasTranslations, SoftDeletes; protected $table = 'cms_pages'; public array $translatable = [ 'title', + 'short_text', + 'long_text', + 'sef_key' ]; protected $fillable = [ @@ -30,11 +41,59 @@ class Page extends Model 'type', ]; + protected $casts = [ + 'status' => PageStatus::class, + 'title' => 'array', + 'short_text' => 'array', + 'long_text' => 'array', + 'sef_key' => 'array', + ]; + public function section(): BelongsTo { return $this->belongsTo(Section::class); } + protected static function booted() + { + static::creating(function (Page $page) { + if (! $page->sef_key && $page->title) { + $page->sef_key = Str::slug($page->title); + } + + static::validateUniqueSefKey($page); + }); + + static::updating(function (Page $page) { + if (! $page->sef_key && $page->title) { + $page->sef_key = Str::slug($page->title); + } + + static::validateUniqueSefKey($page); + }); + } + + protected static function validateUniqueSefKey(Page $page): void + { + $sefKeyForComparison = is_string($page->sef_key) + ? json_encode([app()->getLocale() => $page->sef_key]) + : json_encode($page->sef_key); + + $query = static::query() + ->where('sef_key', $sefKeyForComparison) + ->where('section_id', $page->section_id); + + if ($page->exists) { + $query->whereNot('id', $page->id); + } + + if ($query->exists()) { + throw ValidationException::withMessages([ + 'sef_key' => 'The SEF key must be unique within the section.', + ]); + } + } + public function getUrl(): ?string { return $this->sef_key ? "/{$this->sef_key}" : null; @@ -44,4 +103,17 @@ protected static function newFactory(): PageFactory { return PageFactory::new(); } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->getTranslations('title'), + 'short_text' => $this->getTranslations('short_text'), + 'long_text' => $this->getTranslations('long_text'), + 'sef_key' => $this->getTranslations('sef_key'), + 'status' => $this->status->value, + 'type' => $this->type, + ]; + } } diff --git a/src/Models/Section.php b/src/Models/Section.php index 8f534ab..2fd5cce 100644 --- a/src/Models/Section.php +++ b/src/Models/Section.php @@ -4,8 +4,11 @@ use Eclipse\Cms\Enums\SectionType; use Eclipse\Cms\Factories\SectionFactory; +use Filament\Facades\Filament; 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; @@ -15,6 +18,8 @@ class Section extends Model protected $table = 'cms_sections'; + public $translatable = ['name']; + public array $translatable = [ 'name', ]; @@ -23,6 +28,7 @@ protected function casts(): array { return [ 'type' => SectionType::class, + 'name' => 'array', ]; } @@ -40,6 +46,11 @@ public function getFillable() return $attr; } + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + public function getUrl(): ?string { return "/section/{$this->id}"; @@ -49,4 +60,26 @@ protected static function newFactory(): SectionFactory { return SectionFactory::new(); } + + public function site(): BelongsTo + { + return $this->belongsTo(config('eclipse-cms.tenancy.model')); + } + + protected static function booted(): void + { + if (config('eclipse-cms.tenancy.enabled')) { + static::addGlobalScope('tenant', function ($query): void { + if ($tenant = Filament::getTenant()) { + $query->where(config('eclipse-cms.tenancy.foreign_key'), $tenant->id); + } + }); + + static::creating(function ($model): void { + if ($tenant = Filament::getTenant()) { + $model->{config('eclipse-cms.tenancy.foreign_key')} = $tenant->id; + } + }); + } + } } diff --git a/src/Policies/PagePolicy.php b/src/Policies/PagePolicy.php new file mode 100644 index 0000000..0e49ad3 --- /dev/null +++ b/src/Policies/PagePolicy.php @@ -0,0 +1,84 @@ +can('view_any_page'); + } + + /** + * Determine whether the user can create models. + */ + public function create(Authorizable $user): bool + { + return $user->can('create_page'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(Authorizable $user, Page $page): bool + { + return $user->can('update_page'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Authorizable $user, Page $page): bool + { + return $user->can('delete_page'); + } + + /** + * Determine whether the user can bulk delete. + */ + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_page'); + } + + /** + * Determine whether the user can permanently delete. + */ + public function forceDelete(Authorizable $user, Page $page): bool + { + return $user->can('force_delete_page'); + } + + /** + * Determine whether the user can permanently bulk delete. + */ + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_page'); + } + + /** + * Determine whether the user can restore. + */ + public function restore(Authorizable $user, Page $page): bool + { + return $user->can('restore_page'); + } + + /** + * Determine whether the user can bulk restore. + */ + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_page'); + } +} diff --git a/src/Policies/SectionPolicy.php b/src/Policies/SectionPolicy.php new file mode 100644 index 0000000..dceb3e3 --- /dev/null +++ b/src/Policies/SectionPolicy.php @@ -0,0 +1,100 @@ +can('view_any_section'); + } + + /** + * Determine whether the user can create models. + */ + public function create(Authorizable $user): bool + { + return $user->can('create_section'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(Authorizable $user, Section $section): bool + { + return $user->can('update_section'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Authorizable $user, Section $section): bool + { + return $user->can('delete_section'); + } + + /** + * Determine whether the user can bulk delete. + */ + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_section'); + } + + /** + * Determine whether the user can permanently delete. + */ + public function forceDelete(Authorizable $user, Section $section): bool + { + return $user->can('force_delete_section'); + } + + /** + * Determine whether the user can permanently bulk delete. + */ + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_section'); + } + + /** + * Determine whether the user can restore. + */ + public function restore(Authorizable $user, Section $section): bool + { + return $user->can('restore_section'); + } + + /** + * Determine whether the user can bulk restore. + */ + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_section'); + } + + /** + * Determine whether the user can replicate. + */ + public function replicate(Authorizable $user, Section $section): bool + { + return $user->can('replicate_section'); + } + + /** + * Determine whether the user can reorder. + */ + public function reorder(Authorizable $user): bool + { + return $user->can('reorder_section'); + } +} diff --git a/tests/Feature/Filament/Resources/PageResourceTest.php b/tests/Feature/Filament/Resources/PageResourceTest.php new file mode 100644 index 0000000..8dc6768 --- /dev/null +++ b/tests/Feature/Filament/Resources/PageResourceTest.php @@ -0,0 +1,183 @@ +setUpSuperAdmin(); +}); + +test('authorized access can view pages list', function () { + Page::factory()->count(3)->create(); + + Livewire::test(PageResource\Pages\ListPages::class) + ->assertSuccessful() + ->assertCanSeeTableRecords(Page::all()); +}); + +test('create page screen can be rendered', function () { + Livewire::test(PageResource\Pages\CreatePage::class) + ->assertSuccessful(); +}); + +test('page form validation works', function () { + Livewire::test(PageResource\Pages\CreatePage::class) + ->fillForm([ + 'title' => '', + ]) + ->call('create') + ->assertHasFormErrors(['title' => 'required']); +}); + +test('page can be created through form', function () { + $section = Section::factory()->create(); + $initialCount = Page::count(); + + Livewire::test(PageResource\Pages\CreatePage::class) + ->fillForm([ + 'title' => ['en' => 'Test Page'], + 'short_text' => ['en' => 'Short description'], + 'long_text' => ['en' => 'Long content'], + 'status' => PageStatus::Published, + 'type' => 'page', + 'section_id' => $section->id, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + expect(Page::count())->toBe($initialCount + 1); + + $page = Page::latest()->first(); + $title = $page->getTranslation('title', 'en'); + + $expectedTitle = is_array($title) ? ($title['en'] ?? $title) : $title; + expect($expectedTitle)->toBe('Test Page'); +}); + +test('sef_key is auto-generated from title when empty', function () { + $section = Section::factory()->create(); + + Livewire::test(PageResource\Pages\CreatePage::class) + ->fillForm([ + 'title' => ['en' => 'Auto SEF Key'], + 'status' => PageStatus::Published, + 'type' => 'page', + 'section_id' => $section->id, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $page = Page::latest()->first(); + $sefKey = $page->sef_key; + $expectedSefKey = is_array($sefKey) ? ($sefKey['en'] ?? $sefKey) : $sefKey; + expect($expectedSefKey)->toBe('auto-sef-key'); +}); + +test('pages can be filtered by status', function () { + Page::factory()->create(['status' => PageStatus::Published]); + Page::factory()->create(['status' => PageStatus::Draft]); + + Livewire::test(PageResource\Pages\ListPages::class) + ->filterTable('status', PageStatus::Published->value) + ->assertCanSeeTableRecords(Page::where('status', PageStatus::Published)->get()) + ->assertCanNotSeeTableRecords(Page::where('status', PageStatus::Draft)->get()); +}); + +test('page can be updated', function () { + $page = Page::factory()->create(); + + Livewire::test(PageResource\Pages\EditPage::class, [ + 'record' => $page->getRouteKey(), + ]) + ->fillForm([ + 'title' => ['en' => 'Updated Title'], + 'status' => PageStatus::Draft, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $updatedPage = $page->fresh(); + $titleValue = $updatedPage->getTranslation('title', 'en'); + $expectedTitle = is_array($titleValue) ? ($titleValue['en'] ?? $titleValue) : $titleValue; + expect($expectedTitle)->toBe('Updated Title'); + expect($page->fresh()->status)->toBe(PageStatus::Draft); +}); + +test('page can be deleted', function () { + $page = Page::factory()->create(); + + Livewire::test(PageResource\Pages\EditPage::class, [ + 'record' => $page->getRouteKey(), + ]) + ->callAction('delete'); + + expect($page->fresh()->trashed())->toBeTrue(); +}); + +test('unauthorized access can be prevented', function () { + $this->setUpUserWithoutPermissions(); + + Livewire::test(PageResource\Pages\ListPages::class) + ->assertForbidden(); +}); + +test('user with create permission can create pages', function () { + $this->setUpUserWithPermissions(['view_any_page', 'create_page']); + + Livewire::test(PageResource\Pages\CreatePage::class) + ->assertSuccessful(); +}); + +test('user with update permission can edit pages', function () { + $this->setUpUserWithPermissions(['view_any_page', 'view_page', 'update_page']); + $page = Page::factory()->create(); + + Livewire::test(PageResource\Pages\EditPage::class, [ + 'record' => $page->getRouteKey(), + ]) + ->assertSuccessful(); +}); + +test('user with delete permission can delete pages', function () { + $this->setUpUserWithPermissions(['view_any_page', 'view_page', 'delete_page']); + $page = Page::factory()->create(); + + $pageExists = Page::where('id', $page->id)->exists(); + expect($pageExists)->toBeTrue(); + + $page->delete(); + + expect($page->fresh()->trashed())->toBeTrue(); +}); + +test('pages can be filtered by section via URL parameter', function () { + $section1 = Section::factory()->create(['name' => ['en' => 'Section 1']]); + $section2 = Section::factory()->create(['name' => ['en' => 'Section 2']]); + + $page1 = Page::factory()->forSection($section1)->create(); + $page2 = Page::factory()->forSection($section2)->create(); + + $response = $this->get(PageResource::getUrl('index').'?sId='.$section1->id); + + $response->assertSuccessful(); + $response->assertSee($page1->title); + $response->assertDontSee($page2->title); +}); + +test('section navigation items generate correct URLs', function () { + $section = Section::factory()->create(['name' => ['en' => 'Test Section']]); + + $plugin = new CmsPlugin; + $navigationItems = $plugin->getSectionNavigationItems(); + + expect($navigationItems)->toHaveCount(1); + + $item = $navigationItems[0]; + expect($item->getLabel())->toBe('Test Section'); + expect($item->getUrl())->toContain('sId='.$section->id); + expect($item->getGroup())->toBe('CMS'); +}); diff --git a/tests/Feature/Filament/Resources/SectionResourceTest.php b/tests/Feature/Filament/Resources/SectionResourceTest.php new file mode 100644 index 0000000..295379d --- /dev/null +++ b/tests/Feature/Filament/Resources/SectionResourceTest.php @@ -0,0 +1,118 @@ +setUpSuperAdmin(); +}); + +test('authorized access can view sections list', function () { + $response = $this->get(SectionResource::getUrl('index')); + + $response->assertSuccessful(); +}); + +test('create section screen can be rendered', function () { + $response = $this->get(SectionResource::getUrl('create')); + + $response->assertSuccessful(); +}); + +test('section form validation works', function () { + Livewire::test(SectionResource\Pages\CreateSection::class) + ->fillForm([ + 'name' => '', + 'type' => 'pages', + ]) + ->call('create') + ->assertHasFormErrors(['name']); +}); + +test('section can be created through form', function () { + $newData = [ + 'name.en' => 'Test Section', + 'name.sl' => 'Test Sekcija', + 'type' => 'pages', + ]; + + Livewire::test(SectionResource\Pages\CreateSection::class) + ->fillForm($newData) + ->call('create') + ->assertHasNoFormErrors(); + + expect(Section::where('type', 'pages')->exists())->toBeTrue(); +}); + +test('section can be updated', function () { + $section = Section::factory()->create(); + + $newData = [ + 'name.en' => 'Updated Section', + 'name.sl' => 'Posodobljena Sekcija', + 'type' => 'pages', + ]; + + Livewire::test(SectionResource\Pages\EditSection::class, [ + 'record' => $section->getRouteKey(), + ]) + ->fillForm($newData) + ->call('save') + ->assertHasNoFormErrors(); + + expect(true)->toBeTrue(); +}); + +test('section can be deleted', function () { + $section = Section::factory()->create(); + + Livewire::test(SectionResource\Pages\EditSection::class, [ + 'record' => $section->getRouteKey(), + ]) + ->callAction(DeleteAction::class); + + expect($section->fresh()->trashed())->toBeTrue(); +}); + +test('unauthorized access can be prevented', function () { + $this->setUpUserWithoutPermissions(); + + $response = $this->get(SectionResource::getUrl('index')); + + $response->assertForbidden(); +}); + +test('user with create permission can create sections', function () { + $this->setUpUserWithPermissions(['view_any_section', 'create_section']); + + $response = $this->get(SectionResource::getUrl('create')); + + $response->assertSuccessful(); +}); + +test('user with update permission can edit sections', function () { + $this->setUpUserWithPermissions(['view_any_section', 'view_section', 'update_section']); + + $section = Section::factory()->create(); + + $response = $this->get(SectionResource::getUrl('edit', [ + 'record' => $section, + ])); + + $response->assertSuccessful(); +}); + +test('user with delete permission can delete sections', function () { + $this->setUpUserWithPermissions(['view_any_section', 'view_section', 'update_section', 'delete_section']); + + $section = Section::factory()->create(); + + Livewire::test(SectionResource\Pages\EditSection::class, [ + 'record' => $section->getRouteKey(), + ]) + ->callAction('delete'); + + expect($section->fresh()->trashed())->toBeTrue(); +}); diff --git a/tests/Feature/Models/PageTest.php b/tests/Feature/Models/PageTest.php new file mode 100644 index 0000000..99f8710 --- /dev/null +++ b/tests/Feature/Models/PageTest.php @@ -0,0 +1,150 @@ +setUpSuperAdmin(); +}); + +test('page can be created with valid data', function () { + $section = Section::factory()->create(); + + $page = Page::create([ + 'title' => ['en' => 'Test Page', 'sl' => 'Testna Stran'], + 'short_text' => ['en' => 'Short description', 'sl' => 'Kratek opis'], + 'long_text' => ['en' => 'Long content', 'sl' => 'Dolga vsebina'], + 'sef_key' => ['en' => 'test-page', 'sl' => 'testna-stran'], + 'status' => PageStatus::Published, + 'type' => 'page', + 'section_id' => $section->id, + ]); + + expect($page)->toBeInstanceOf(Page::class); + expect($page->title)->toBe('Test Page'); + expect($page->status)->toBe(PageStatus::Published); +}); + +test('page translatable fields work correctly', function () { + $section = Section::factory()->create(); + + $page = Page::create([ + 'title' => ['en' => 'English Title', 'sl' => 'Slovenski Naslov'], + 'short_text' => ['en' => 'English short', 'sl' => 'Slovenski kratek'], + 'long_text' => ['en' => 'English long', 'sl' => 'Slovenski dolg'], + 'sef_key' => ['en' => 'english-title', 'sl' => 'slovenski-naslov'], + 'status' => PageStatus::Published, + 'type' => 'page', + 'section_id' => $section->id, + ]); + + expect($page->getTranslation('title', 'en'))->toBe('English Title'); + expect($page->getTranslation('title', 'sl'))->toBe('Slovenski Naslov'); + expect($page->getTranslation('sef_key', 'en'))->toBe('english-title'); + expect($page->getTranslation('sef_key', 'sl'))->toBe('slovenski-naslov'); +}); + +test('page auto-generates sef_key from title when empty', function () { + $section = Section::factory()->create(); + + $page = Page::create([ + 'title' => ['en' => 'Auto Generated SEF Key'], + 'short_text' => ['en' => 'Short description'], + 'long_text' => ['en' => 'Long content'], + 'status' => PageStatus::Published, + 'type' => 'page', + 'section_id' => $section->id, + ]); + + expect($page->sef_key)->toBe('auto-generated-sef-key'); +}); + +test('page validates unique sef_key', function () { + $section = Section::factory()->create(); + + Page::create([ + 'title' => ['en' => 'First Page'], + 'sef_key' => ['en' => 'unique-key'], + 'status' => PageStatus::Published, + 'type' => 'page', + 'section_id' => $section->id, + ]); + + expect(function () use ($section) { + Page::create([ + 'title' => ['en' => 'Second Page'], + 'sef_key' => ['en' => 'unique-key'], + 'status' => PageStatus::Published, + 'type' => 'page', + 'section_id' => $section->id, + ]); + })->toThrow(ValidationException::class); +}); + +test('page can be updated', function () { + $page = Page::factory()->create(); + + $page->update([ + 'title' => ['en' => 'Updated Title'], + 'status' => PageStatus::Draft, + ]); + + expect($page->fresh()->title)->toBe('Updated Title'); + expect($page->fresh()->status)->toBe(PageStatus::Draft); +}); + +test('page can be soft deleted', function () { + $page = Page::factory()->create(); + + $page->delete(); + + expect($page->trashed())->toBeTrue(); + expect(Page::count())->toBe(0); + expect(Page::withTrashed()->count())->toBe(1); +}); + +test('page can be restored after soft delete', function () { + $page = Page::factory()->create(); + $page->delete(); + + $page->restore(); + + expect($page->trashed())->toBeFalse(); + expect(Page::count())->toBe(1); +}); + +test('page can be force deleted', function () { + $page = Page::factory()->create(); + + $page->forceDelete(); + + expect(Page::withTrashed()->count())->toBe(0); +}); + +test('page search functionality works correctly', function () { + $section = Section::factory()->create(); + + $page = Page::factory()->forSection($section)->create([ + 'title' => ['en' => 'Searchable Title'], + 'short_text' => ['en' => 'Searchable content'], + ]); + + $searchData = $page->toSearchableArray(); + + expect($searchData)->toHaveKeys([ + 'id', 'title', 'short_text', 'long_text', + 'sef_key', 'status', 'type', + ]); + expect($searchData['title'])->toBe(['en' => 'Searchable Title']); +}); + +test('page validation prevents creation with invalid data', function () { + expect(function () { + Page::create([ + 'title' => '', + 'status' => 'invalid-status', + ]); + })->toThrow(ValueError::class); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 07e3145..51e3146 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -21,9 +21,6 @@ abstract class TestCase extends BaseTestCase protected function setUp(): void { - ini_set('display_errors', 1); - error_reporting(E_ALL); - parent::setUp(); $this->withoutVite(); @@ -35,14 +32,6 @@ protected function setUp(): void config(['scout.driver' => null]); } - protected function defineEnvironment($app): void - { - $app['config']->set('app.key', 'base64:0qAvnB4fU0hiVsd01U1b/GljkPRLBS50IQ7I4DS7fxI='); - } - - /** - * Run database migrations - */ protected function migrate(): self { $this->artisan('migrate'); @@ -50,9 +39,6 @@ protected function migrate(): self return $this; } - /** - * Set up default "super admin" user - */ protected function setUpSuperAdmin(): self { $this->migrate(); @@ -75,37 +61,30 @@ protected function setUpSuperAdmin(): self protected function createAllPermissions(): void { - $permissions = [ - 'view_any_menu', - 'view_menu', - 'create_menu', - 'update_menu', - 'delete_menu', - 'delete_any_menu', - 'force_delete_menu', - 'force_delete_any_menu', - 'restore_menu', - 'restore_any_menu', - 'view_any_position', - 'view_position', - 'create_position', - 'update_position', - 'delete_position', - 'delete_any_position', - 'force_delete_position', - 'force_delete_any_position', - 'restore_position', - 'restore_any_position', + $resources = ['section', 'page', 'menu', 'position']; + $prefixes = [ + 'view_any', + 'view', + 'create', + 'update', + 'delete', + 'delete_any', + 'force_delete', + 'force_delete_any', + 'restore', + 'restore_any', ]; - foreach ($permissions as $permission) { - Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'web']); + foreach ($resources as $resource) { + foreach ($prefixes as $prefix) { + Permission::firstOrCreate([ + 'name' => "{$prefix}_{$resource}", + 'guard_name' => 'web', + ]); + } } } - /** - * Set up a common user with no roles or permissions - */ protected function setUpCommonUser(): self { $this->migrate(); diff --git a/tests/Unit/MenuItemTest.php b/tests/Unit/MenuItemTest.php index 81c84a4..b8ef79b 100644 --- a/tests/Unit/MenuItemTest.php +++ b/tests/Unit/MenuItemTest.php @@ -269,10 +269,10 @@ 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 = Item::factory()->active()->create(['menu_id' => $menu->id]); + $parent2 = Item::factory()->active()->create(['menu_id' => $menu->id]); + $child1 = Item::factory()->active()->create(['menu_id' => $menu->id, 'parent_id' => $parent1->id]); + $child2 = Item::factory()->active()->create(['menu_id' => $menu->id, 'parent_id' => $parent2->id]); $parent1->delete(); diff --git a/workbench/app/Models/Site.php b/workbench/app/Models/Site.php new file mode 100644 index 0000000..2761790 --- /dev/null +++ b/workbench/app/Models/Site.php @@ -0,0 +1,44 @@ + 'boolean', + ]; + + protected static function newFactory() + { + return SiteFactory::new(); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class); + } + + public function sections(): HasMany + { + return $this->hasMany(\Eclipse\Cms\Models\Section::class, 'site_id'); + } + + public function pages(): HasMany + { + return $this->hasMany(\Eclipse\Cms\Models\Page::class, 'site_id'); + } +} diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php index fc12e68..767deb6 100644 --- a/workbench/app/Models/User.php +++ b/workbench/app/Models/User.php @@ -3,14 +3,18 @@ namespace Workbench\App\Models; use Filament\Models\Contracts\FilamentUser; +use Filament\Models\Contracts\HasTenants; use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Collection; use Spatie\Permission\Traits\HasRoles; use Workbench\Database\Factories\UserFactory; -class User extends Authenticatable implements FilamentUser +class User extends Authenticatable implements FilamentUser, HasTenants { use HasFactory, HasRoles, Notifiable; @@ -50,8 +54,23 @@ protected static function newFactory(): UserFactory return UserFactory::new(); } + public function sites(): BelongsToMany + { + return $this->belongsToMany(Site::class); + } + public function canAccessPanel(Panel $panel): bool { return true; } + + public function getTenants(Panel $panel): array|Collection + { + return $this->sites; + } + + public function canAccessTenant(Model $tenant): bool + { + return $this->sites->contains($tenant); + } } diff --git a/workbench/database/database.sqlite b/workbench/database/database.sqlite new file mode 100644 index 0000000..e69de29 diff --git a/workbench/database/factories/SiteFactory.php b/workbench/database/factories/SiteFactory.php new file mode 100644 index 0000000..1e77c12 --- /dev/null +++ b/workbench/database/factories/SiteFactory.php @@ -0,0 +1,20 @@ + $this->faker->company(), + 'slug' => $this->faker->slug(), + 'is_default' => false, + ]; + } +} diff --git a/workbench/database/migrations/2024_01_01_000001_create_sites_table.php b/workbench/database/migrations/2024_01_01_000001_create_sites_table.php new file mode 100644 index 0000000..f54d27a --- /dev/null +++ b/workbench/database/migrations/2024_01_01_000001_create_sites_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->boolean('is_default')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sites'); + } +}; diff --git a/workbench/database/migrations/2024_01_01_000002_create_site_user_table.php b/workbench/database/migrations/2024_01_01_000002_create_site_user_table.php new file mode 100644 index 0000000..a43a5f0 --- /dev/null +++ b/workbench/database/migrations/2024_01_01_000002_create_site_user_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('site_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['site_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('site_user'); + } +}; diff --git a/workbench/database/seeders/DatabaseSeeder.php b/workbench/database/seeders/DatabaseSeeder.php index f10adbb..7df2363 100644 --- a/workbench/database/seeders/DatabaseSeeder.php +++ b/workbench/database/seeders/DatabaseSeeder.php @@ -3,7 +3,6 @@ namespace Workbench\Database\Seeders; use Illuminate\Database\Seeder; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Workbench\Database\Factories\UserFactory; class DatabaseSeeder extends Seeder @@ -13,8 +12,6 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // UserFactory::new()->times(10)->create(); - UserFactory::new()->create([ 'name' => 'Test User', 'email' => 'test@example.com',