From f3c6050b0eaa2d3f9e463d7f827246a67a0fa1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omer=20=C5=A0abi=C4=87?= Date: Wed, 13 Aug 2025 11:25:34 +0200 Subject: [PATCH 1/9] ci: fix workflows for pull requests --- .github/workflows/linter.yml | 5 ++++- .github/workflows/test-runner.yml | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index a548779..817a9bd 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -22,7 +22,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: ${{ github.head_ref }} + # Checkout the actual branch, not a specific commit + ref: ${{ github.head_ref || github.ref_name }} + # Fetch the full history to avoid shallow clone issues + fetch-depth: 0 - name: Run Laravel Pint uses: aglipanci/laravel-pint-action@latest diff --git a/.github/workflows/test-runner.yml b/.github/workflows/test-runner.yml index 4a1f073..6ce2b83 100644 --- a/.github/workflows/test-runner.yml +++ b/.github/workflows/test-runner.yml @@ -28,7 +28,7 @@ jobs: matrix: os: [ ubuntu-latest ] php: [ 8.2, 8.3, 8.4 ] - dependency-version: [ prefer-lowest, prefer-stable ] + dependency-version: [ prefer-stable ] name: ${{ matrix.os }} / PHP ${{ matrix.php }} / ${{ matrix.dependency-version }} @@ -41,7 +41,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Validate composer.json and composer.lock run: composer validate --strict From 305730d16ec56ee53089383cc6461a5ac504695d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omer=20=C5=A0abi=C4=87?= Date: Tue, 19 Aug 2025 11:14:41 +0200 Subject: [PATCH 2/9] ci: remove release-please workflow --- .github/workflows/release-please.yml | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 .github/workflows/release-please.yml diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index 13ee9f5..0000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: release-please - -on: - push: - branches: - - main - -permissions: - contents: write - pull-requests: write - -jobs: - release-please: - runs-on: ubuntu-latest - steps: - - uses: google-github-actions/release-please-action@v3 - with: - release-type: php - package-name: eclipsephp-cms-plugin From f9578151163a7f96b20549e9ab041ba99858d095 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Sat, 30 Aug 2025 12:31:50 +0545 Subject: [PATCH 3/9] feat: implementing complete banner management system with HiDPI support and image modal viewer --- composer.json | 1 + database/factories/BannerFactory.php | 53 ++++ database/factories/BannerImageFactory.php | 83 +++++ database/factories/BannerImageTypeFactory.php | 60 ++++ database/factories/BannerPositionFactory.php | 22 ++ ...0_make_banner_images_file_translatable.php | 22 ++ database/seeders/BannerSeeder.php | 128 ++++++++ database/seeders/CmsSeeder.php | 3 + .../components/image-preview-modal.blade.php | 279 +++++++++++++++++ .../Resources/BannerPositionResource.php | 200 ++++++++++++ .../Pages/CreateBannerPosition.php | 21 ++ .../Pages/EditBannerPosition.php | 23 ++ .../Pages/ListBannerPositions.php | 22 ++ .../Pages/ViewBannerPosition.php | 22 ++ .../BannersRelationManager.php | 288 ++++++++++++++++++ src/CmsServiceProvider.php | 24 ++ src/Models/Banner.php | 37 ++- src/Models/Banner/Image.php | 14 + src/Models/Banner/ImageType.php | 14 + src/Models/Banner/Position.php | 73 ++++- src/Models/Section.php | 8 + src/Observers/BannerObserver.php | 13 + src/Observers/PositionObserver.php | 13 + src/Policies/BannerPositionPolicy.php | 62 ++++ tests/Feature/BannerPositionResourceTest.php | 135 ++++++++ tests/Feature/BannersRelationManagerTest.php | 215 +++++++++++++ tests/TestCase.php | 38 ++- tests/Unit/BannerModelsTest.php | 143 +++++++++ .../app/Providers/AdminPanelProvider.php | 3 + 29 files changed, 2008 insertions(+), 11 deletions(-) create mode 100644 database/factories/BannerFactory.php create mode 100644 database/factories/BannerImageFactory.php create mode 100644 database/factories/BannerImageTypeFactory.php create mode 100644 database/factories/BannerPositionFactory.php create mode 100644 database/migrations/2025_08_30_120000_make_banner_images_file_translatable.php create mode 100644 database/seeders/BannerSeeder.php create mode 100644 resources/views/components/image-preview-modal.blade.php create mode 100644 src/Admin/Filament/Resources/BannerPositionResource.php create mode 100644 src/Admin/Filament/Resources/BannerPositionResource/Pages/CreateBannerPosition.php create mode 100644 src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php create mode 100644 src/Admin/Filament/Resources/BannerPositionResource/Pages/ListBannerPositions.php create mode 100644 src/Admin/Filament/Resources/BannerPositionResource/Pages/ViewBannerPosition.php create mode 100644 src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannersRelationManager.php create mode 100644 src/Observers/BannerObserver.php create mode 100644 src/Observers/PositionObserver.php create mode 100644 src/Policies/BannerPositionPolicy.php create mode 100644 tests/Feature/BannerPositionResourceTest.php create mode 100644 tests/Feature/BannersRelationManagerTest.php create mode 100644 tests/Unit/BannerModelsTest.php diff --git a/composer.json b/composer.json index 2d66580..ef88701 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "datalinx/php-utils": "^2.5", "eclipsephp/common": "dev-main", "filament/filament": "^3.3", + "filament/spatie-laravel-translatable-plugin": "^3.3", "spatie/laravel-package-tools": "^1.19" }, "require-dev": { diff --git a/database/factories/BannerFactory.php b/database/factories/BannerFactory.php new file mode 100644 index 0000000..1fc85e7 --- /dev/null +++ b/database/factories/BannerFactory.php @@ -0,0 +1,53 @@ + ['Summer Sale', 'Winter Sale', 'Special Offer', 'New Arrivals', 'Featured Deal'], + 'sl' => ['Poletna Razprodaja', 'Zimska Razprodaja', 'Posebna Ponudba', 'Nove Stvari', 'Izpostavljena Ponudba'], + ]; + + $nameIndex = $this->faker->numberBetween(0, count($names['en']) - 1); + + return [ + 'name' => [ + 'en' => $names['en'][$nameIndex], + 'sl' => $names['sl'][$nameIndex], + ], + 'link' => 'https://example.com/'.$this->faker->slug(2), + 'is_active' => $this->faker->boolean(80), + 'new_tab' => $this->faker->boolean(30), + 'sort' => $this->faker->numberBetween(1, 10), + ]; + } + + public function active(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_active' => true, + ]); + } + + public function inactive(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_active' => false, + ]); + } + + public function newTab(): static + { + return $this->state(fn (array $attributes): array => [ + 'new_tab' => true, + ]); + } +} diff --git a/database/factories/BannerImageFactory.php b/database/factories/BannerImageFactory.php new file mode 100644 index 0000000..0ae0484 --- /dev/null +++ b/database/factories/BannerImageFactory.php @@ -0,0 +1,83 @@ + $this->faker->randomElement($sampleImages), + 'image_width' => $this->faker->randomElement([400, 800, 1200, 1920]), + 'image_height' => $this->faker->randomElement([200, 400, 600, 800]), + 'is_hidpi' => $this->faker->boolean(30), + ]; + } + + public function desktop(): static + { + return $this->state(fn (array $attributes): array => [ + 'file' => 'banners/desktop-'.$this->faker->slug().'.jpg', + 'image_width' => 1920, + 'image_height' => 600, + 'is_hidpi' => false, + ]); + } + + public function mobile(): static + { + return $this->state(fn (array $attributes): array => [ + 'file' => 'banners/mobile-'.$this->faker->slug().'.jpg', + 'image_width' => 800, + 'image_height' => 400, + 'is_hidpi' => true, + ]); + } + + public function tablet(): static + { + return $this->state(fn (array $attributes): array => [ + 'file' => 'banners/tablet-'.$this->faker->slug().'.jpg', + 'image_width' => 1024, + 'image_height' => 500, + 'is_hidpi' => false, + ]); + } + + public function square(): static + { + return $this->state(fn (array $attributes): array => [ + 'file' => 'banners/square-'.$this->faker->slug().'.jpg', + 'image_width' => 400, + 'image_height' => 400, + 'is_hidpi' => false, + ]); + } + + public function hidpi(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_hidpi' => true, + 'image_width' => $attributes['image_width'] * 2, + 'image_height' => $attributes['image_height'] * 2, + ]); + } +} diff --git a/database/factories/BannerImageTypeFactory.php b/database/factories/BannerImageTypeFactory.php new file mode 100644 index 0000000..c7944a6 --- /dev/null +++ b/database/factories/BannerImageTypeFactory.php @@ -0,0 +1,60 @@ + [ + 'en' => 'Desktop', + 'sl' => 'Namizje', + ], + 'code' => 'desktop', + 'image_width' => 1200, + 'image_height' => 400, + 'is_hidpi' => false, + ]; + } + + public function desktop(): static + { + return $this->state(fn (array $attributes) => [ + 'name' => [ + 'en' => 'Desktop', + 'sl' => 'Namizje', + ], + 'code' => 'desktop', + 'image_width' => 1200, + 'image_height' => 400, + 'is_hidpi' => false, + ]); + } + + public function mobile(): static + { + return $this->state(fn (array $attributes) => [ + 'name' => [ + 'en' => 'Mobile', + 'sl' => 'Mobilni', + ], + 'code' => 'mobile', + 'image_width' => 800, + 'image_height' => 400, + 'is_hidpi' => true, + ]); + } + + public function hidpi(): static + { + return $this->state(fn (array $attributes) => [ + 'is_hidpi' => true, + ]); + } +} diff --git a/database/factories/BannerPositionFactory.php b/database/factories/BannerPositionFactory.php new file mode 100644 index 0000000..3dad78f --- /dev/null +++ b/database/factories/BannerPositionFactory.php @@ -0,0 +1,22 @@ + [ + 'en' => 'Website Banners', + 'sl' => 'Spletni Bannerji', + ], + 'code' => 'website', + ]; + } +} diff --git a/database/migrations/2025_08_30_120000_make_banner_images_file_translatable.php b/database/migrations/2025_08_30_120000_make_banner_images_file_translatable.php new file mode 100644 index 0000000..ae82800 --- /dev/null +++ b/database/migrations/2025_08_30_120000_make_banner_images_file_translatable.php @@ -0,0 +1,22 @@ +json('file')->change(); + }); + } + + public function down(): void + { + Schema::table('cms_banner_images', function (Blueprint $table) { + $table->string('file')->change(); + }); + } +}; diff --git a/database/seeders/BannerSeeder.php b/database/seeders/BannerSeeder.php new file mode 100644 index 0000000..d898fbc --- /dev/null +++ b/database/seeders/BannerSeeder.php @@ -0,0 +1,128 @@ +getTenantData(); + + $position = Position::factory()->create(['code' => 'website', ...$tenantData]); + + $desktop = ImageType::factory()->for($position)->desktop()->create(); + $mobile = ImageType::factory()->for($position)->mobile()->create(); + + $banners = [ + ['name' => ['en' => 'Summer Sale', 'sl' => 'Poletna Razprodaja'], 'active' => true, 'new_tab' => false], + ['name' => ['en' => 'Winter Sale', 'sl' => 'Zimska Razprodaja'], 'active' => true, 'new_tab' => false], + ['name' => ['en' => 'Special Offer', 'sl' => 'Posebna Ponudba'], 'active' => false, 'new_tab' => true], + ]; + + foreach ($banners as $index => $bannerData) { + $banner = Banner::factory()->create([ + 'position_id' => $position->id, + 'name' => $bannerData['name'], + 'is_active' => $bannerData['active'], + 'new_tab' => $bannerData['new_tab'], + 'sort' => $index + 1, + ]); + + $this->addImages($banner, $desktop, 'desktop'); + $this->addImages($banner, $mobile, 'mobile'); + } + } + + private function addImages($banner, $type, $suffix): void + { + $bannerName = is_array($banner->name) ? $banner->name['en'] : $banner->name; + + $regularFilename = "banner-{$banner->id}-{$suffix}.png"; + $this->createBannerImage( + $type->image_width, + $type->image_height, + strtoupper($suffix)." - {$bannerName}", + $regularFilename + ); + + $banner->images()->create([ + 'type_id' => $type->id, + 'file' => $regularFilename, + 'image_width' => $type->image_width, + 'image_height' => $type->image_height, + 'is_hidpi' => false, + ]); + + if ($type->is_hidpi) { + $hidpiFilename = "banner-{$banner->id}-{$suffix}@2x.png"; + $hidpiWidth = $type->image_width * 2; + $hidpiHeight = $type->image_height * 2; + + $this->createBannerImage( + $hidpiWidth, + $hidpiHeight, + strtoupper($suffix)." @2x - {$bannerName}", + $hidpiFilename + ); + + $banner->images()->create([ + 'type_id' => $type->id, + 'file' => $hidpiFilename, + 'image_width' => $hidpiWidth, + 'image_height' => $hidpiHeight, + 'is_hidpi' => true, + ]); + } + } + + 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() ?: $tenantModel::create([ + 'name' => 'Default Site', + 'domain' => 'localhost', + ]); + if ($tenant) { + return [$tenantFK => $tenant->id]; + } + } + } + + return []; + } + + protected function createBannerImage(int $width, int $height, string $text, string $filePath): bool + { + if (Storage::disk('public')->exists($filePath)) { + return true; + } + + try { + $url = "https://dummyimage.com/{$width}x{$height}/ffffff/000000.png?text=".urlencode($text); + $context = stream_context_create(['http' => ['timeout' => 10]]); + $imageData = file_get_contents($url, false, $context); + + if (! $imageData) { + return false; + } + + $directory = dirname($filePath); + if (! Storage::disk('public')->exists($directory)) { + Storage::disk('public')->makeDirectory($directory); + } + + return Storage::disk('public')->put($filePath, $imageData); + } catch (\Exception) { + return false; + } + } +} diff --git a/database/seeders/CmsSeeder.php b/database/seeders/CmsSeeder.php index cb32240..b55b611 100644 --- a/database/seeders/CmsSeeder.php +++ b/database/seeders/CmsSeeder.php @@ -12,5 +12,8 @@ public function run(): void Section::factory() ->count(3) ->create(); + + $this + ->call(BannerSeeder::class); } } diff --git a/resources/views/components/image-preview-modal.blade.php b/resources/views/components/image-preview-modal.blade.php new file mode 100644 index 0000000..3e76b5f --- /dev/null +++ b/resources/views/components/image-preview-modal.blade.php @@ -0,0 +1,279 @@ +
+
+ +
+ + +
+ +
+

+ +
+
+ +
+
+
+ + + + \ No newline at end of file diff --git a/src/Admin/Filament/Resources/BannerPositionResource.php b/src/Admin/Filament/Resources/BannerPositionResource.php new file mode 100644 index 0000000..fa1b842 --- /dev/null +++ b/src/Admin/Filament/Resources/BannerPositionResource.php @@ -0,0 +1,200 @@ +schema([ + Forms\Components\Section::make() + ->compact() + ->schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('code') + ->maxLength(20) + ->alphaDash(), + ]), + + Forms\Components\Repeater::make('imageTypes') + ->relationship() + ->columnSpanFull() + ->itemLabel(fn (array $state): ?string => $state['name'] ?? 'Image Type') + ->schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('code') + ->alphaDash() + ->maxLength(255), + + Forms\Components\TextInput::make('image_width') + ->numeric() + ->minValue(1) + ->maxValue(9999) + ->label('Width (px)'), + + Forms\Components\TextInput::make('image_height') + ->numeric() + ->minValue(1) + ->maxValue(9999) + ->label('Height (px)'), + + Forms\Components\Toggle::make('is_hidpi') + ->label('Require HiDPI (2x) images'), + ]) + ->columns(2) + ->defaultItems(1) + ->addActionLabel('Add Image Type') + ->reorderableWithButtons() + ->collapsible(), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist + ->schema([ + Infolists\Components\Section::make('Position Details') + ->columns([ + 'default' => 2, + 'md' => 4, + ]) + ->compact() + ->schema([ + Infolists\Components\TextEntry::make('name'), + + Infolists\Components\TextEntry::make('code'), + + Infolists\Components\TextEntry::make('imageTypes_count') + ->badge() + ->label('Image Types') + ->getStateUsing(fn ($record) => $record->imageTypes()->count()), + + Infolists\Components\TextEntry::make('banners_count') + ->badge() + ->label('Total Banners') + ->getStateUsing(fn ($record) => $record->banners()->count()), + ]), + + Infolists\Components\RepeatableEntry::make('imageTypes') + ->columns([ + 'default' => 2, + 'md' => 4, + ]) + ->columnSpanFull() + ->schema([ + Infolists\Components\TextEntry::make('name'), + + Infolists\Components\TextEntry::make('code') + ->badge(), + + Infolists\Components\TextEntry::make('image_size') + ->default( + fn (Model $record) => "{$record->image_width}px * {$record->image_height}px" + ), + + Infolists\Components\IconEntry::make('is_hidpi') + ->label('HiDPI Required') + ->boolean(), + ]) + ->visible(fn ($record) => $record->imageTypes()->exists()), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('ID') + ->sortable(), + + Tables\Columns\TextColumn::make('name') + ->sortable() + ->searchable(), + + Tables\Columns\TextColumn::make('code') + ->sortable() + ->badge() + ->searchable(), + + Tables\Columns\TextColumn::make('banners_count') + ->badge() + ->counts('banners') + ->suffix(fn (?int $state): string => $state > 1 ? ' Items' : ' Item') + ->label('Banners'), + ]) + ->defaultSort('id') + ->filters([ + Tables\Filters\TrashedFilter::make(), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + RelationManagers\BannersRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBannerPositions::route('/'), + 'create' => Pages\CreateBannerPosition::route('/create'), + 'view' => Pages\ViewBannerPosition::route('/{record}'), + 'edit' => Pages\EditBannerPosition::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } +} diff --git a/src/Admin/Filament/Resources/BannerPositionResource/Pages/CreateBannerPosition.php b/src/Admin/Filament/Resources/BannerPositionResource/Pages/CreateBannerPosition.php new file mode 100644 index 0000000..8ec723f --- /dev/null +++ b/src/Admin/Filament/Resources/BannerPositionResource/Pages/CreateBannerPosition.php @@ -0,0 +1,21 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('link') + ->url() + ->maxLength(255), + + Forms\Components\Toggle::make('is_active') + ->default(true), + + Forms\Components\Toggle::make('new_tab') + ->default(false) + ->label('Open in new tab'), + + Forms\Components\Repeater::make('images') + ->relationship() + ->columnSpanFull() + ->schema([ + Forms\Components\Hidden::make('type_id'), + Forms\Components\Hidden::make('is_hidpi'), + FileUpload::make('file') + ->hiddenLabel() + ->image() + ->directory('banners') + ->required() + ->rules([ + function (Get $get) { + return function (string $attribute, $value, Closure $fail) use ($get): void { + if (! $value) { + return; + } + + $typeId = $get('type_id'); + $isHidpi = $get('is_hidpi'); + + if ($typeId) { + $imageType = $this->getOwnerRecord()->imageTypes()->find($typeId); + if ($imageType && $imageType->image_width && $imageType->image_height) { + $expectedWidth = $isHidpi ? $imageType->image_width * 2 : $imageType->image_width; + $expectedHeight = $isHidpi ? $imageType->image_height * 2 : $imageType->image_height; + + $imageSize = getimagesize($value->getPathname()); + $actualWidth = $imageSize[0] ?? 0; + $actualHeight = $imageSize[1] ?? 0; + + if ($actualWidth !== $expectedWidth || $actualHeight !== $expectedHeight) { + $fail("Image must be exactly {$expectedWidth}×{$expectedHeight}px. Got {$actualWidth}×{$actualHeight}px."); + } + } + } + }; + }, + ]) + ->helperText(function (Get $get): string { + $typeId = $get('type_id'); + $isHidpi = $get('is_hidpi'); + + if ($typeId) { + $imageType = $this->getOwnerRecord()->imageTypes()->find($typeId); + if ($imageType && $imageType->image_width && $imageType->image_height) { + if ($isHidpi) { + $regularWidth = $imageType->image_width; + $regularHeight = $imageType->image_height; + $hidpiWidth = $regularWidth * 2; + $hidpiHeight = $regularHeight * 2; + + return "Expected HiDPI size: {$hidpiWidth}px × {$hidpiHeight}px (2x of {$regularWidth}×{$regularHeight})"; + } else { + return "Expected size: {$imageType->image_width}px × {$imageType->image_height}px"; + } + } + } + + return 'Upload banner image'; + }), + ]) + ->default(function () { + $items = []; + $this->getOwnerRecord()->imageTypes()->get()->each(function ($imageType) use (&$items) { + $items[] = ['type_id' => $imageType->id, 'is_hidpi' => false]; + if ($imageType->is_hidpi) { + $items[] = ['type_id' => $imageType->id, 'is_hidpi' => true]; + } + }); + + return $items; + }) + ->minItems(function (): int { + $count = 0; + $this->getOwnerRecord()->imageTypes()->get()->each(function ($imageType) use (&$count) { + $count++; + if ($imageType->is_hidpi) { + $count++; + } + }); + + return $count; + }) + ->maxItems(function (): int { + $count = 0; + $this->getOwnerRecord()->imageTypes()->get()->each(function ($imageType) use (&$count) { + $count++; + if ($imageType->is_hidpi) { + $count++; + } + }); + + return $count; + }) + ->addable(false) + ->deletable(false) + ->reorderable(false) + ->itemLabel(function (array $state): string { + $typeId = $state['type_id'] ?? null; + $isHidpi = $state['is_hidpi'] ?? false; + + if ($typeId) { + $imageType = $this->getOwnerRecord()->imageTypes()->find($typeId); + if ($imageType) { + $dimensions = ''; + if ($imageType->image_width && $imageType->image_height) { + if ($isHidpi) { + $dimensions = " (@2x: {$imageType->image_width}×{$imageType->image_height} → ". + ($imageType->image_width * 2).'×'.($imageType->image_height * 2).')'; + } else { + $dimensions = " ({$imageType->image_width}×{$imageType->image_height})"; + } + } + + return $imageType->name.($isHidpi ? ' @2x' : '').$dimensions; + } + } + + return 'Banner Image'; + }), + ]); + } + + public function infolist(Infolist $infolist): Infolist + { + return $infolist + ->schema([ + Infolists\Components\TextEntry::make('name'), + + Infolists\Components\TextEntry::make('link') + ->url(fn ($record) => $record->link) + ->openUrlInNewTab(fn ($record) => $record->new_tab), + + Infolists\Components\IconEntry::make('is_active') + ->boolean(), + + Infolists\Components\IconEntry::make('new_tab') + ->boolean() + ->label('Open in new tab'), + + Infolists\Components\Grid::make() + ->columnSpanFull() + ->schema( + fn (Banner $record) => $record->images->load('type')->map( + fn ($image) => Infolists\Components\ImageEntry::make("image_{$image->id}") + ->columnSpanFull() + ->label($image->type->name ?? 'Image') + ->width('100%') + ->height('auto') + ->getStateUsing(fn () => $image->getTranslation('file', $this->activeLocale ?? app()->getLocale())) + )->toArray() + ), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('name') + ->columns([ + Tables\Columns\TextColumn::make('sort') + ->label('#') + ->sortable(), + + Tables\Columns\ImageColumn::make('images.file') + ->circular() + ->stacked() + ->getStateUsing(function (Banner $record) { + $locale = $this->activeLocale ?? app()->getLocale(); + + return $record->images->map(function ($image) use ($locale) { + return $image->getTranslation('file', $locale); + })->filter()->values()->toArray(); + }) + ->preview(true), + + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('link') + ->limit(30) + ->url(fn ($record) => $record->link) + ->openUrlInNewTab(fn ($record) => $record->new_tab), + + Tables\Columns\IconColumn::make('is_active') + ->boolean() + ->sortable(), + + Tables\Columns\IconColumn::make('new_tab') + ->boolean() + ->icon(fn ($state): string => match ($state) { + true => 'heroicon-o-arrow-top-right-on-square', + false => 'heroicon-o-minus', + }) + ->label('New Tab'), + ]) + ->defaultSort('sort') + ->reorderable('sort') + ->filters([ + Tables\Filters\TernaryFilter::make('is_active') + ->label('Active Status') + ->boolean() + ->trueLabel('Active only') + ->falseLabel('Inactive only') + ->native(false), + + Tables\Filters\TernaryFilter::make('new_tab') + ->label('Open in New Tab') + ->boolean() + ->trueLabel('New tab only') + ->falseLabel('Same tab only') + ->native(false), + + Tables\Filters\TrashedFilter::make(), + ]) + ->headerActions([ + CreateAction::make() + ->mutateFormDataUsing(function (array $data): array { + $maxSort = $this->getOwnerRecord()->banners()->max('sort') ?? 0; + $data['sort'] = $maxSort + 1; + + return $data; + }), + LocaleSwitcher::make(), + ]) + ->actions([ + ViewAction::make(), + EditAction::make(), + DeleteAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/src/CmsServiceProvider.php b/src/CmsServiceProvider.php index 98923a3..347e91e 100644 --- a/src/CmsServiceProvider.php +++ b/src/CmsServiceProvider.php @@ -2,8 +2,13 @@ namespace Eclipse\Cms; +use Eclipse\Cms\Models\Banner\Position; +use Eclipse\Cms\Policies\BannerPositionPolicy; use Eclipse\Common\Foundation\Providers\PackageServiceProvider; use Eclipse\Common\Package; +use Filament\Support\Facades\FilamentView; +use Filament\Tables\Columns\ImageColumn; +use Illuminate\Support\Facades\Gate; use Spatie\LaravelPackageTools\Package as SpatiePackage; class CmsServiceProvider extends PackageServiceProvider @@ -14,7 +19,26 @@ public function configurePackage(SpatiePackage|Package $package): void { $package->name(static::$name) ->hasConfigFile() + ->hasViews() ->discoversMigrations() ->runsMigrations(); } + + public function bootingPackage(): void + { + Gate::policy(Position::class, BannerPositionPolicy::class); + + ImageColumn::macro( + 'preview', + fn (bool $enabled = true) => $enabled ? $this->extraImgAttributes([ + 'class' => 'cursor-pointer image-preview-trigger', + 'onclick' => 'event.stopPropagation(); return false;', + ]) : $this + ); + + FilamentView::registerRenderHook( + 'panels::body.end', + fn () => view('eclipse-cms::components.image-preview-modal')->render() + ); + } } diff --git a/src/Models/Banner.php b/src/Models/Banner.php index 067d9e0..2db3290 100644 --- a/src/Models/Banner.php +++ b/src/Models/Banner.php @@ -2,23 +2,58 @@ namespace Eclipse\Cms\Models; +use Eclipse\Cms\Factories\BannerFactory; +use Eclipse\Cms\Models\Banner\Image; use Eclipse\Cms\Models\Banner\Position; +use Eclipse\Cms\Observers\BannerObserver; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; +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; +#[ObservedBy([BannerObserver::class])] class Banner extends Model { - use SoftDeletes; + use HasFactory, HasTranslations, SoftDeletes; protected $table = 'cms_banners'; protected $fillable = [ 'position_id', + 'name', + 'link', + 'is_active', + 'new_tab', + 'sort', + ]; + + public array $translatable = [ + 'name', ]; public function position(): BelongsTo { return $this->belongsTo(Position::class, 'position_id'); } + + public function images(): HasMany + { + return $this->hasMany(Image::class, 'banner_id'); + } + + protected function casts(): array + { + return [ + 'is_active' => 'boolean', + 'new_tab' => 'boolean', + ]; + } + + protected static function newFactory(): BannerFactory + { + return BannerFactory::new(); + } } diff --git a/src/Models/Banner/Image.php b/src/Models/Banner/Image.php index 7775e58..03c7108 100644 --- a/src/Models/Banner/Image.php +++ b/src/Models/Banner/Image.php @@ -2,9 +2,12 @@ namespace Eclipse\Cms\Models\Banner; +use Eclipse\Cms\Factories\BannerImageFactory; use Eclipse\Cms\Models\Banner; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Spatie\Translatable\HasTranslations; /** * @property int $id Image ID @@ -17,6 +20,8 @@ */ class Image extends Model { + use HasFactory, HasTranslations; + public $timestamps = false; protected $table = 'cms_banner_images'; @@ -30,6 +35,10 @@ class Image extends Model 'image_height', ]; + public array $translatable = [ + 'file', + ]; + public function banner(): BelongsTo { return $this->belongsTo(Banner::class, 'banner_id'); @@ -46,4 +55,9 @@ protected function casts(): array 'is_hidpi' => 'boolean', ]; } + + protected static function newFactory(): BannerImageFactory + { + return BannerImageFactory::new(); + } } diff --git a/src/Models/Banner/ImageType.php b/src/Models/Banner/ImageType.php index a90d51b..c6fcbe9 100644 --- a/src/Models/Banner/ImageType.php +++ b/src/Models/Banner/ImageType.php @@ -2,8 +2,11 @@ namespace Eclipse\Cms\Models\Banner; +use Eclipse\Cms\Factories\BannerImageTypeFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Spatie\Translatable\HasTranslations; /** * Type of banner image @@ -18,6 +21,8 @@ */ class ImageType extends Model { + use HasFactory, HasTranslations; + public $timestamps = false; protected $table = 'cms_banner_image_types'; @@ -31,6 +36,10 @@ class ImageType extends Model 'is_hidpi', ]; + public array $translatable = [ + 'name', + ]; + public function position(): BelongsTo { return $this->belongsTo(Position::class, 'position_id'); @@ -42,4 +51,9 @@ protected function casts(): array 'is_hidpi' => 'boolean', ]; } + + protected static function newFactory(): BannerImageTypeFactory + { + return BannerImageTypeFactory::new(); + } } diff --git a/src/Models/Banner/Position.php b/src/Models/Banner/Position.php index 6ef8c09..cd3834d 100644 --- a/src/Models/Banner/Position.php +++ b/src/Models/Banner/Position.php @@ -2,22 +2,79 @@ namespace Eclipse\Cms\Models\Banner; +use Eclipse\Cms\Factories\BannerPositionFactory; +use Eclipse\Cms\Models\Banner; +use Eclipse\Cms\Observers\PositionObserver; +use Filament\Facades\Filament; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; +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; -/** - * @property int $id Position ID - * @property string $name Position name - * @property string|null code Optional position code (for programmatic use) - */ +#[ObservedBy([PositionObserver::class])] class Position extends Model { - use SoftDeletes; + use HasFactory, HasTranslations, SoftDeletes; protected $table = 'cms_banner_positions'; - protected $fillable = [ + protected static function boot(): void + { + parent::boot(); + + if (config('eclipse-cms.tenancy.enabled')) { + static::addGlobalScope('tenant', function (Builder $builder) { + $tenant = Filament::getTenant(); + if ($tenant) { + $tenantFK = config('eclipse-cms.tenancy.foreign_key', 'site_id'); + $builder->where($tenantFK, $tenant->id); + } + }); + } + } + + public function getFillable(): array + { + $attr = [ + 'name', + 'code', + ]; + + if (config('eclipse-cms.tenancy.enabled')) { + $attr[] = config('eclipse-cms.tenancy.foreign_key'); + } + + return $attr; + } + + public array $translatable = [ 'name', - 'code', ]; + + public function imageTypes(): HasMany + { + return $this->hasMany(ImageType::class, 'position_id'); + } + + public function banners(): HasMany + { + return $this->hasMany(Banner::class, 'position_id')->orderBy('sort'); + } + + protected static function newFactory() + { + return BannerPositionFactory::new(); + } + + 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/Section.php b/src/Models/Section.php index b752c95..dc4a041 100644 --- a/src/Models/Section.php +++ b/src/Models/Section.php @@ -3,6 +3,7 @@ namespace Eclipse\Cms\Models; use Eclipse\Cms\Enums\SectionType; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Eclipse\Cms\Factories\SectionFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -36,4 +37,11 @@ protected static function newFactory(): SectionFactory { return SectionFactory::new(); } + + /** @return BelongsTo<\Eclipse\Core\Models\Site, self> */ + public function site(): BelongsTo + { + return $this->belongsTo(\Eclipse\Core\Models\Site::class); + } + } diff --git a/src/Observers/BannerObserver.php b/src/Observers/BannerObserver.php new file mode 100644 index 0000000..7f4a72e --- /dev/null +++ b/src/Observers/BannerObserver.php @@ -0,0 +1,13 @@ +images()->delete(); + } +} diff --git a/src/Observers/PositionObserver.php b/src/Observers/PositionObserver.php new file mode 100644 index 0000000..84318b2 --- /dev/null +++ b/src/Observers/PositionObserver.php @@ -0,0 +1,13 @@ +banners()->delete(); + } +} diff --git a/src/Policies/BannerPositionPolicy.php b/src/Policies/BannerPositionPolicy.php new file mode 100644 index 0000000..06043a6 --- /dev/null +++ b/src/Policies/BannerPositionPolicy.php @@ -0,0 +1,62 @@ +can('view_any_banner::position'); + } + + public function view(Authorizable $user, Position $position): bool + { + return $user->can('view_banner::position'); + } + + public function create(Authorizable $user): bool + { + return $user->can('create_banner::position'); + } + + public function update(Authorizable $user, Position $position): bool + { + return $user->can('update_banner::position'); + } + + public function delete(Authorizable $user, Position $position): bool + { + return $user->can('delete_banner::position'); + } + + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_banner::position'); + } + + public function forceDelete(Authorizable $user, Position $position): bool + { + return $user->can('force_delete_banner::position'); + } + + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_banner::position'); + } + + public function restore(Authorizable $user, Position $position): bool + { + return $user->can('restore_banner::position'); + } + + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_banner::position'); + } +} diff --git a/tests/Feature/BannerPositionResourceTest.php b/tests/Feature/BannerPositionResourceTest.php new file mode 100644 index 0000000..cbd1f05 --- /dev/null +++ b/tests/Feature/BannerPositionResourceTest.php @@ -0,0 +1,135 @@ +setUpSuperAdmin(); +}); + +it('can render position index page', function () { + $this->get(BannerPositionResource::getUrl('index')) + ->assertSuccessful(); +}); + +it('can list positions', function () { + $positions = Position::factory()->count(3)->create(); + + livewire(ListBannerPositions::class) + ->assertCanSeeTableRecords($positions); +}); + +it('can render position create page', function () { + $this->get(BannerPositionResource::getUrl('create')) + ->assertSuccessful(); +}); + +it('can create position', function () { + $newData = Position::factory()->make(); + + livewire(CreateBannerPosition::class) + ->fillForm([ + 'name' => $newData->name, + 'code' => $newData->code, + 'imageTypes' => [], + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas(Position::class, [ + 'code' => $newData->code, + ]); + + $position = Position::where('code', $newData->code)->first(); + expect($position)->toBeInstanceOf(Position::class) + ->and($position->name)->toBe($newData->name); +}); + +it('can create position without image types', function () { + $newData = Position::factory()->make([ + 'name' => 'Header Banner', + 'code' => 'header', + ]); + + livewire(CreateBannerPosition::class) + ->fillForm([ + 'name' => $newData->name, + 'code' => $newData->code, + 'imageTypes' => [], + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $position = Position::where('code', 'header')->with('imageTypes')->first(); + expect($position)->toBeInstanceOf(Position::class); + expect($position->imageTypes)->toHaveCount(0); +}); + +it('can render position edit page', function () { + $position = Position::factory()->create(); + + $this->get(BannerPositionResource::getUrl('edit', ['record' => $position])) + ->assertSuccessful(); +}); + +it('can retrieve position data for editing', function () { + $position = Position::factory()->create(); + + livewire(EditBannerPosition::class, [ + 'record' => $position->getRouteKey(), + ]) + ->assertFormSet([ + 'name' => $position->name, + 'code' => $position->code, + ]); +}); + +it('can save position', function () { + $position = Position::factory()->create(); + $newData = Position::factory()->make(); + + livewire(EditBannerPosition::class, [ + 'record' => $position->getRouteKey(), + ]) + ->fillForm([ + 'name' => $newData->name, + 'code' => $newData->code, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($position->refresh()) + ->name->toBe($newData->name) + ->code->toBe($newData->code); +}); + +it('can delete position', function () { + $position = Position::factory()->create(); + + livewire(ListBannerPositions::class) + ->callTableAction('delete', $position); + + $this->assertSoftDeleted($position); +}); + +it('can validate position creation', function () { + livewire(CreateBannerPosition::class) + ->fillForm([ + 'name' => null, + ]) + ->call('create') + ->assertHasFormErrors(['name' => 'required']); +}); + +it('can filter positions', function () { + $positions = Position::factory()->count(5)->create(); + + livewire(ListBannerPositions::class) + ->assertCanSeeTableRecords($positions) + ->assertTableFilterExists('trashed'); +}); diff --git a/tests/Feature/BannersRelationManagerTest.php b/tests/Feature/BannersRelationManagerTest.php new file mode 100644 index 0000000..835e2a1 --- /dev/null +++ b/tests/Feature/BannersRelationManagerTest.php @@ -0,0 +1,215 @@ +setUpSuperAdmin(); + + $this->position = Position::factory()->create([ + 'name' => 'Header Banner', + 'code' => 'header', + ]); + + $this->position->imageTypes()->createMany([ + [ + 'name' => 'Desktop', + 'code' => 'desktop', + 'image_width' => 1200, + 'image_height' => 400, + 'is_hidpi' => false, + ], + [ + 'name' => 'Mobile', + 'code' => 'mobile', + 'image_width' => 600, + 'image_height' => 300, + 'is_hidpi' => true, + ], + ]); +}); + +it('can render banners relation manager', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'name' => 'Test Banner', + 'link' => 'https://example.com', + 'is_active' => true, + 'new_tab' => false, + 'sort' => 1, + ]); + + livewire(BannersRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => EditBannerPosition::class, + ]) + ->assertSuccessful() + ->assertCanSeeTableRecords([$banner]); +}); + +it('generates correct repeater items for hidpi types', function () { + $component = livewire(BannersRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => EditBannerPosition::class, + ]); + + $component->mountTableAction('create'); + + expect($this->position->imageTypes)->toHaveCount(2); + expect($this->position->imageTypes->where('is_hidpi', true))->toHaveCount(1); +}); + +it('validates required images on banner creation', function () { + livewire(BannersRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => EditBannerPosition::class, + ]) + ->callTableAction('create', data: [ + 'name' => 'Test Banner', + 'link' => 'https://example.com', + 'is_active' => true, + 'new_tab' => false, + 'images' => [ + ['type_id' => $this->position->imageTypes->first()->id, 'is_hidpi' => false, 'file' => null], + ['type_id' => $this->position->imageTypes->last()->id, 'is_hidpi' => false, 'file' => null], + ['type_id' => $this->position->imageTypes->last()->id, 'is_hidpi' => true, 'file' => null], + ], + ]) + ->assertHasTableActionErrors(); + + expect(Banner::where('name', 'Test Banner')->first())->toBeNull(); +}); + +it('can view banner', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'name' => 'Test Banner', + 'link' => 'https://example.com', + 'is_active' => true, + 'new_tab' => true, + 'sort' => 1, + ]); + + $banner->images()->create([ + 'type_id' => $this->position->imageTypes->first()->id, + 'file' => ['en' => 'test.png'], + 'image_width' => 1200, + 'image_height' => 400, + 'is_hidpi' => false, + ]); + + livewire(BannersRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => EditBannerPosition::class, + ]) + ->mountTableAction('view', $banner) + ->assertTableActionExists('view'); +}); + +it('can delete banner and cleanup images', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'name' => 'Test Banner', + 'is_active' => true, + 'new_tab' => false, + 'sort' => 1, + ]); + + $image = $banner->images()->create([ + 'type_id' => $this->position->imageTypes->first()->id, + 'file' => ['en' => 'test.jpg'], + 'image_width' => 1200, + 'image_height' => 400, + 'is_hidpi' => false, + ]); + + livewire(BannersRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => EditBannerPosition::class, + ]) + ->callTableAction('delete', $banner); + + $this->assertSoftDeleted($banner); + expect($image->fresh())->toBeNull(); +}); + +it('can sort banners automatically', function () { + Banner::factory()->create([ + 'position_id' => $this->position->id, + 'name' => 'Existing Banner', + 'sort' => 1, + ]); + + $bannersCount = $this->position->banners()->count(); + + expect($bannersCount)->toBe(1); + + $maxSort = $this->position->banners()->max('sort'); + expect($maxSort)->toBe(1); +}); + +it('can search banners', function () { + $banners = Banner::factory()->count(3)->create([ + 'position_id' => $this->position->id, + ]); + + $firstBanner = $banners->first(); + $bannerName = is_array($firstBanner->name) ? $firstBanner->name['en'] ?? $firstBanner->name : $firstBanner->name; + + livewire(BannersRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => EditBannerPosition::class, + ]) + ->searchTable($bannerName) + ->assertCanSeeTableRecords($banners->take(1)); +}); + +it('can filter banners by active status', function () { + $activeBanner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'name' => 'Active Banner', + 'is_active' => true, + ]); + + $inactiveBanner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'name' => 'Inactive Banner', + 'is_active' => false, + ]); + + livewire(BannersRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => EditBannerPosition::class, + ]) + ->assertCanSeeTableRecords([$activeBanner, $inactiveBanner]) + ->filterTable('is_active', true) + ->assertCanSeeTableRecords([$activeBanner]) + ->assertCanNotSeeTableRecords([$inactiveBanner]); +}); + +it('can filter banners by new tab setting', function () { + $newTabBanner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'name' => 'New Tab Banner', + 'new_tab' => true, + ]); + + $sameTabBanner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'name' => 'Same Tab Banner', + 'new_tab' => false, + ]); + + livewire(BannersRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => EditBannerPosition::class, + ]) + ->assertCanSeeTableRecords([$newTabBanner, $sameTabBanner]) + ->filterTable('new_tab', true) + ->assertCanSeeTableRecords([$newTabBanner]) + ->assertCanNotSeeTableRecords([$sameTabBanner]); +}); 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/BannerModelsTest.php b/tests/Unit/BannerModelsTest.php new file mode 100644 index 0000000..f4d543a --- /dev/null +++ b/tests/Unit/BannerModelsTest.php @@ -0,0 +1,143 @@ + 'Header Banner', + 'code' => 'header', + ]); + + expect($position->name)->toBe('Header Banner'); + expect($position->code)->toBe('header'); + expect($position->imageTypes())->toBeInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class); + expect($position->banners())->toBeInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class); +}); + +it('creates image types for position', function () { + $position = Position::create([ + 'name' => 'Header Banner', + 'code' => 'header', + ]); + + $imageType = ImageType::create([ + 'position_id' => $position->id, + 'name' => 'Desktop', + 'code' => 'desktop', + 'image_width' => 1200, + 'image_height' => 400, + 'is_hidpi' => true, + ]); + + expect($imageType->position_id)->toBe($position->id); + expect($imageType->name)->toBe('Desktop'); + expect($imageType->is_hidpi)->toBe(true); + expect($imageType->position)->toBeInstanceOf(Position::class); + + expect($position->imageTypes)->toHaveCount(1); + expect($position->imageTypes->first()->name)->toBe('Desktop'); +}); + +it('creates banner with images', function () { + $position = Position::create([ + 'name' => 'Header Banner', + 'code' => 'header', + ]); + + $imageType = ImageType::create([ + 'position_id' => $position->id, + 'name' => 'Desktop', + 'code' => 'desktop', + 'image_width' => 1200, + 'image_height' => 400, + 'is_hidpi' => false, + ]); + + $banner = Banner::create([ + 'position_id' => $position->id, + 'name' => 'Test Banner', + 'link' => 'https://example.com', + 'is_active' => true, + 'new_tab' => false, + 'sort' => 1, + ]); + + $image = Image::create([ + 'banner_id' => $banner->id, + 'type_id' => $imageType->id, + 'file' => 'banners/test.jpg', + 'image_width' => 1200, + 'image_height' => 400, + 'is_hidpi' => false, + ]); + + expect($banner->name)->toBe('Test Banner'); + expect($banner->is_active)->toBe(true); + expect($banner->new_tab)->toBe(false); + expect($banner->position)->toBeInstanceOf(Position::class); + expect($banner->images)->toHaveCount(1); + + expect($image->banner)->toBeInstanceOf(Banner::class); + expect($image->type)->toBeInstanceOf(ImageType::class); + expect($image->file)->toBe('banners/test.jpg'); +}); + +it('orders banners by sort field', function () { + $position = Position::create([ + 'name' => 'Header Banner', + 'code' => 'header', + ]); + + Banner::create([ + 'position_id' => $position->id, + 'name' => 'Banner 2', + 'sort' => 2, + 'is_active' => true, + 'new_tab' => false, + ]); + + Banner::create([ + 'position_id' => $position->id, + 'name' => 'Banner 1', + 'sort' => 1, + 'is_active' => true, + 'new_tab' => false, + ]); + + Banner::create([ + 'position_id' => $position->id, + 'name' => 'Banner 3', + 'sort' => 3, + 'is_active' => true, + 'new_tab' => false, + ]); + + $orderedBanners = $position->banners; + + expect($orderedBanners)->toHaveCount(3); + expect($orderedBanners->first()->name)->toBe('Banner 1'); + expect($orderedBanners->last()->name)->toBe('Banner 3'); +}); + +it('soft deletes positions and cascades to banners', function () { + $position = Position::create([ + 'name' => 'Header Banner', + 'code' => 'header', + ]); + + $banner = Banner::create([ + 'position_id' => $position->id, + 'name' => 'Test Banner', + 'is_active' => true, + 'new_tab' => false, + 'sort' => 1, + ]); + + $position->delete(); + + expect($position->trashed())->toBe(true); + expect($banner->fresh()->trashed())->toBe(true); +}); diff --git a/workbench/app/Providers/AdminPanelProvider.php b/workbench/app/Providers/AdminPanelProvider.php index 49e38f2..a6c95dd 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; @@ -46,6 +47,8 @@ public function panel(Panel $panel): Panel ->plugins([ FilamentShieldPlugin::make(), CmsPlugin::make(), + SpatieLaravelTranslatablePlugin::make() + ->defaultLocales(['en', 'sl']), ]) ->pages([ Dashboard::class, From ecb2e0faf7cc52bb6eb12b350f69131d7b88f14b Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Tue, 2 Sep 2025 14:27:50 +0545 Subject: [PATCH 4/9] refactor: migrate banner management from relation manager to dedicated resource with HiDPI support --- composer.json | 1 + database/seeders/BannerSeeder.php | 43 ++-- .../Resources/BannerPositionResource.php | 88 ++----- .../Pages/EditBannerPosition.php | 1 - ...RelationManager.php => BannerResource.php} | 214 +++++++++------- .../BannerResource/Pages/CreateBanner.php | 33 +++ .../BannerResource/Pages/EditBanner.php | 23 ++ .../BannerResource/Pages/ListBanners.php | 31 +++ .../Pages/ViewBanner.php} | 8 +- src/CmsServiceProvider.php | 3 + src/Models/Section.php | 8 - src/Observers/BannerObserver.php | 57 +++++ src/Observers/PositionObserver.php | 5 +- src/Policies/BannerPolicy.php | 62 +++++ src/Policies/BannerPositionPolicy.php | 5 - src/Services/ImageService.php | 43 ++++ tests/Feature/BannerHidpiTest.php | 128 ++++++++++ tests/Feature/BannerPermissionsTest.php | 128 ++++++++++ tests/Feature/BannerPositionResourceTest.php | 51 ++++ tests/Feature/BannerResourceTest.php | 233 ++++++++++++++++++ tests/Feature/BannersRelationManagerTest.php | 215 ---------------- 21 files changed, 969 insertions(+), 411 deletions(-) rename src/Admin/Filament/Resources/{BannerPositionResource/RelationManagers/BannersRelationManager.php => BannerResource.php} (55%) create mode 100644 src/Admin/Filament/Resources/BannerResource/Pages/CreateBanner.php create mode 100644 src/Admin/Filament/Resources/BannerResource/Pages/EditBanner.php create mode 100644 src/Admin/Filament/Resources/BannerResource/Pages/ListBanners.php rename src/Admin/Filament/Resources/{BannerPositionResource/Pages/ViewBannerPosition.php => BannerResource/Pages/ViewBanner.php} (53%) create mode 100644 src/Policies/BannerPolicy.php create mode 100644 src/Services/ImageService.php create mode 100644 tests/Feature/BannerHidpiTest.php create mode 100644 tests/Feature/BannerPermissionsTest.php create mode 100644 tests/Feature/BannerResourceTest.php delete mode 100644 tests/Feature/BannersRelationManagerTest.php diff --git a/composer.json b/composer.json index ef88701..1404256 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ "eclipsephp/common": "dev-main", "filament/filament": "^3.3", "filament/spatie-laravel-translatable-plugin": "^3.3", + "spatie/image": "^3.8", "spatie/laravel-package-tools": "^1.19" }, "require-dev": { diff --git a/database/seeders/BannerSeeder.php b/database/seeders/BannerSeeder.php index d898fbc..3ade359 100644 --- a/database/seeders/BannerSeeder.php +++ b/database/seeders/BannerSeeder.php @@ -5,6 +5,7 @@ use Eclipse\Cms\Models\Banner; use Eclipse\Cms\Models\Banner\ImageType; use Eclipse\Cms\Models\Banner\Position; +use Exception; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Storage; @@ -43,22 +44,6 @@ private function addImages($banner, $type, $suffix): void { $bannerName = is_array($banner->name) ? $banner->name['en'] : $banner->name; - $regularFilename = "banner-{$banner->id}-{$suffix}.png"; - $this->createBannerImage( - $type->image_width, - $type->image_height, - strtoupper($suffix)." - {$bannerName}", - $regularFilename - ); - - $banner->images()->create([ - 'type_id' => $type->id, - 'file' => $regularFilename, - 'image_width' => $type->image_width, - 'image_height' => $type->image_height, - 'is_hidpi' => false, - ]); - if ($type->is_hidpi) { $hidpiFilename = "banner-{$banner->id}-{$suffix}@2x.png"; $hidpiWidth = $type->image_width * 2; @@ -78,6 +63,22 @@ private function addImages($banner, $type, $suffix): void 'image_height' => $hidpiHeight, 'is_hidpi' => true, ]); + } else { + $regularFilename = "banner-{$banner->id}-{$suffix}.png"; + $this->createBannerImage( + $type->image_width, + $type->image_height, + strtoupper($suffix)." - {$bannerName}", + $regularFilename + ); + + $banner->images()->create([ + 'type_id' => $type->id, + 'file' => $regularFilename, + 'image_width' => $type->image_width, + 'image_height' => $type->image_height, + 'is_hidpi' => false, + ]); } } @@ -102,7 +103,7 @@ private function getTenantData(): array protected function createBannerImage(int $width, int $height, string $text, string $filePath): bool { - if (Storage::disk('public')->exists($filePath)) { + if (Storage::exists($filePath)) { return true; } @@ -116,12 +117,12 @@ protected function createBannerImage(int $width, int $height, string $text, stri } $directory = dirname($filePath); - if (! Storage::disk('public')->exists($directory)) { - Storage::disk('public')->makeDirectory($directory); + if (! Storage::exists($directory)) { + Storage::makeDirectory($directory); } - return Storage::disk('public')->put($filePath, $imageData); - } catch (\Exception) { + return Storage::put($filePath, $imageData); + } catch (Exception) { return false; } } diff --git a/src/Admin/Filament/Resources/BannerPositionResource.php b/src/Admin/Filament/Resources/BannerPositionResource.php index fa1b842..2d56eb9 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource.php +++ b/src/Admin/Filament/Resources/BannerPositionResource.php @@ -3,18 +3,14 @@ namespace Eclipse\Cms\Admin\Filament\Resources; use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages; -use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\RelationManagers; use Eclipse\Cms\Models\Banner\Position as BannerPosition; use Filament\Forms; use Filament\Forms\Form; -use Filament\Infolists; -use Filament\Infolists\Infolist; use Filament\Resources\Concerns\Translatable; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletingScope; class BannerPositionResource extends Resource @@ -23,14 +19,12 @@ class BannerPositionResource extends Resource protected static ?string $model = BannerPosition::class; + protected static bool $shouldRegisterNavigation = false; + protected static ?string $navigationIcon = 'heroicon-o-photo'; protected static ?string $navigationGroup = 'CMS'; - protected static ?string $modelLabel = 'Banner Position'; - - protected static ?string $pluralModelLabel = 'Banner Positions'; - public static function form(Form $form): Form { return $form @@ -50,6 +44,7 @@ public static function form(Form $form): Form Forms\Components\Repeater::make('imageTypes') ->relationship() ->columnSpanFull() + ->hiddenLabel() ->itemLabel(fn (array $state): ?string => $state['name'] ?? 'Image Type') ->schema([ Forms\Components\TextInput::make('name') @@ -83,57 +78,6 @@ public static function form(Form $form): Form ]); } - public static function infolist(Infolist $infolist): Infolist - { - return $infolist - ->schema([ - Infolists\Components\Section::make('Position Details') - ->columns([ - 'default' => 2, - 'md' => 4, - ]) - ->compact() - ->schema([ - Infolists\Components\TextEntry::make('name'), - - Infolists\Components\TextEntry::make('code'), - - Infolists\Components\TextEntry::make('imageTypes_count') - ->badge() - ->label('Image Types') - ->getStateUsing(fn ($record) => $record->imageTypes()->count()), - - Infolists\Components\TextEntry::make('banners_count') - ->badge() - ->label('Total Banners') - ->getStateUsing(fn ($record) => $record->banners()->count()), - ]), - - Infolists\Components\RepeatableEntry::make('imageTypes') - ->columns([ - 'default' => 2, - 'md' => 4, - ]) - ->columnSpanFull() - ->schema([ - Infolists\Components\TextEntry::make('name'), - - Infolists\Components\TextEntry::make('code') - ->badge(), - - Infolists\Components\TextEntry::make('image_size') - ->default( - fn (Model $record) => "{$record->image_width}px * {$record->image_height}px" - ), - - Infolists\Components\IconEntry::make('is_hidpi') - ->label('HiDPI Required') - ->boolean(), - ]) - ->visible(fn ($record) => $record->imageTypes()->exists()), - ]); - } - public static function table(Table $table): Table { return $table @@ -151,18 +95,12 @@ public static function table(Table $table): Table ->badge() ->searchable(), - Tables\Columns\TextColumn::make('banners_count') - ->badge() - ->counts('banners') - ->suffix(fn (?int $state): string => $state > 1 ? ' Items' : ' Item') - ->label('Banners'), ]) ->defaultSort('id') ->filters([ Tables\Filters\TrashedFilter::make(), ]) ->actions([ - Tables\Actions\ViewAction::make(), Tables\Actions\EditAction::make(), Tables\Actions\DeleteAction::make(), ]) @@ -175,9 +113,7 @@ public static function table(Table $table): Table public static function getRelations(): array { - return [ - RelationManagers\BannersRelationManager::class, - ]; + return []; } public static function getPages(): array @@ -185,11 +121,25 @@ public static function getPages(): array return [ 'index' => Pages\ListBannerPositions::route('/'), 'create' => Pages\CreateBannerPosition::route('/create'), - 'view' => Pages\ViewBannerPosition::route('/{record}'), 'edit' => Pages\EditBannerPosition::route('/{record}/edit'), ]; } + public static function getPermissions(): array + { + return [ + 'view_any', + 'create', + 'update', + 'delete', + 'delete_any', + 'force_delete', + 'force_delete_any', + 'restore', + 'restore_any', + ]; + } + public static function getEloquentQuery(): Builder { return parent::getEloquentQuery() diff --git a/src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php b/src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php index e2a59ab..db34fc3 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php +++ b/src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php @@ -15,7 +15,6 @@ class EditBannerPosition extends EditRecord protected function getHeaderActions(): array { return [ - Actions\ViewAction::make(), Actions\DeleteAction::make(), Actions\LocaleSwitcher::make(), ]; diff --git a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannersRelationManager.php b/src/Admin/Filament/Resources/BannerResource.php similarity index 55% rename from src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannersRelationManager.php rename to src/Admin/Filament/Resources/BannerResource.php index 4dac4f8..8f27144 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannersRelationManager.php +++ b/src/Admin/Filament/Resources/BannerResource.php @@ -1,63 +1,82 @@ schema([ - Forms\Components\TextInput::make('name') - ->required() - ->maxLength(255), + Forms\Components\Section::make() + ->compact() + ->schema([ + Forms\Components\Select::make('position_id') + ->label('Position') + ->relationship('position', 'name') + ->required() + ->reactive() + ->afterStateUpdated(fn ($state, Set $set) => $set('images', [])), - Forms\Components\TextInput::make('link') - ->url() - ->maxLength(255), + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), - Forms\Components\Toggle::make('is_active') - ->default(true), + Forms\Components\TextInput::make('link') + ->url() + ->maxLength(255), - Forms\Components\Toggle::make('new_tab') - ->default(false) - ->label('Open in new tab'), + Forms\Components\Toggle::make('is_active') + ->default(true), + + Forms\Components\Toggle::make('new_tab') + ->default(false) + ->label('Open in new tab'), + ]), Forms\Components\Repeater::make('images') ->relationship() ->columnSpanFull() + ->hiddenLabel() ->schema([ Forms\Components\Hidden::make('type_id'), Forms\Components\Hidden::make('is_hidpi'), + Forms\Components\Hidden::make('position_id'), FileUpload::make('file') ->hiddenLabel() ->image() ->directory('banners') - ->required() ->rules([ function (Get $get) { return function (string $attribute, $value, Closure $fail) use ($get): void { @@ -69,7 +88,7 @@ function (Get $get) { $isHidpi = $get('is_hidpi'); if ($typeId) { - $imageType = $this->getOwnerRecord()->imageTypes()->find($typeId); + $imageType = Position::find($get('position_id'))?->imageTypes()->find($typeId); if ($imageType && $imageType->image_width && $imageType->image_height) { $expectedWidth = $isHidpi ? $imageType->image_width * 2 : $imageType->image_width; $expectedHeight = $isHidpi ? $imageType->image_height * 2 : $imageType->image_height; @@ -89,19 +108,18 @@ function (Get $get) { ->helperText(function (Get $get): string { $typeId = $get('type_id'); $isHidpi = $get('is_hidpi'); + $positionId = $get('position_id'); - if ($typeId) { - $imageType = $this->getOwnerRecord()->imageTypes()->find($typeId); + if ($typeId && $positionId) { + $imageType = Position::find($positionId)?->imageTypes()->find($typeId); if ($imageType && $imageType->image_width && $imageType->image_height) { if ($isHidpi) { - $regularWidth = $imageType->image_width; - $regularHeight = $imageType->image_height; - $hidpiWidth = $regularWidth * 2; - $hidpiHeight = $regularHeight * 2; + $hidpiWidth = $imageType->image_width * 2; + $hidpiHeight = $imageType->image_height * 2; - return "Expected HiDPI size: {$hidpiWidth}px × {$hidpiHeight}px (2x of {$regularWidth}×{$regularHeight})"; + return "{$imageType->name} @2x ({$hidpiWidth}×{$hidpiHeight}, displayed as {$imageType->image_width}×{$imageType->image_height})"; } else { - return "Expected size: {$imageType->image_width}px × {$imageType->image_height}px"; + return "{$imageType->name} ({$imageType->image_width}×{$imageType->image_height})"; } } } @@ -109,60 +127,55 @@ function (Get $get) { return 'Upload banner image'; }), ]) - ->default(function () { + ->default(function (Get $get) { + $positionId = $get('position_id'); + if (! $positionId) { + return []; + } + $items = []; - $this->getOwnerRecord()->imageTypes()->get()->each(function ($imageType) use (&$items) { - $items[] = ['type_id' => $imageType->id, 'is_hidpi' => false]; + Position::find($positionId)?->imageTypes()->get()->each(function ($imageType) use (&$items, $positionId) { if ($imageType->is_hidpi) { - $items[] = ['type_id' => $imageType->id, 'is_hidpi' => true]; + $items[] = ['type_id' => $imageType->id, 'is_hidpi' => true, 'position_id' => $positionId]; + } else { + $items[] = ['type_id' => $imageType->id, 'is_hidpi' => false, 'position_id' => $positionId]; } }); return $items; }) - ->minItems(function (): int { - $count = 0; - $this->getOwnerRecord()->imageTypes()->get()->each(function ($imageType) use (&$count) { - $count++; - if ($imageType->is_hidpi) { - $count++; - } - }); - - return $count; - }) - ->maxItems(function (): int { - $count = 0; - $this->getOwnerRecord()->imageTypes()->get()->each(function ($imageType) use (&$count) { - $count++; - if ($imageType->is_hidpi) { - $count++; - } - }); + ->minItems(0) + ->maxItems(function (Get $get): int { + $positionId = $get('position_id'); + if (! $positionId) { + return 0; + } - return $count; + return Position::find($positionId)?->imageTypes()->count() ?? 0; }) ->addable(false) ->deletable(false) ->reorderable(false) - ->itemLabel(function (array $state): string { + ->itemLabel(function (array $state, Get $get): string { $typeId = $state['type_id'] ?? null; $isHidpi = $state['is_hidpi'] ?? false; + $positionId = $get('position_id'); - if ($typeId) { - $imageType = $this->getOwnerRecord()->imageTypes()->find($typeId); + if ($typeId && $positionId) { + $imageType = Position::find($positionId)?->imageTypes()->find($typeId); if ($imageType) { - $dimensions = ''; if ($imageType->image_width && $imageType->image_height) { if ($isHidpi) { - $dimensions = " (@2x: {$imageType->image_width}×{$imageType->image_height} → ". - ($imageType->image_width * 2).'×'.($imageType->image_height * 2).')'; + $hidpiWidth = $imageType->image_width * 2; + $hidpiHeight = $imageType->image_height * 2; + + return "{$imageType->name} @2x ({$hidpiWidth}×{$hidpiHeight}, displayed as {$imageType->image_width}×{$imageType->image_height})"; } else { - $dimensions = " ({$imageType->image_width}×{$imageType->image_height})"; + return "{$imageType->name} ({$imageType->image_width}×{$imageType->image_height})"; } } - return $imageType->name.($isHidpi ? ' @2x' : '').$dimensions; + return $imageType->name; } } @@ -171,10 +184,13 @@ function (Get $get) { ]); } - public function infolist(Infolist $infolist): Infolist + public static function infolist(Infolist $infolist): Infolist { return $infolist ->schema([ + Infolists\Components\TextEntry::make('position.name') + ->label('Position'), + Infolists\Components\TextEntry::make('name'), Infolists\Components\TextEntry::make('link') @@ -197,16 +213,15 @@ public function infolist(Infolist $infolist): Infolist ->label($image->type->name ?? 'Image') ->width('100%') ->height('auto') - ->getStateUsing(fn () => $image->getTranslation('file', $this->activeLocale ?? app()->getLocale())) + ->getStateUsing(fn () => $image->getTranslation('file', app()->getLocale())) )->toArray() ), ]); } - public function table(Table $table): Table + public static function table(Table $table): Table { return $table - ->recordTitleAttribute('name') ->columns([ Tables\Columns\TextColumn::make('sort') ->label('#') @@ -216,7 +231,7 @@ public function table(Table $table): Table ->circular() ->stacked() ->getStateUsing(function (Banner $record) { - $locale = $this->activeLocale ?? app()->getLocale(); + $locale = app()->getLocale(); return $record->images->map(function ($image) use ($locale) { return $image->getTranslation('file', $locale); @@ -224,6 +239,11 @@ public function table(Table $table): Table }) ->preview(true), + Tables\Columns\TextColumn::make('position.name') + ->label('Position') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), @@ -246,8 +266,11 @@ public function table(Table $table): Table ->label('New Tab'), ]) ->defaultSort('sort') - ->reorderable('sort') ->filters([ + Tables\Filters\SelectFilter::make('position_id') + ->relationship('position', 'name') + ->label('Position'), + Tables\Filters\TernaryFilter::make('is_active') ->label('Active Status') ->boolean() @@ -264,25 +287,42 @@ public function table(Table $table): Table Tables\Filters\TrashedFilter::make(), ]) - ->headerActions([ - CreateAction::make() - ->mutateFormDataUsing(function (array $data): array { - $maxSort = $this->getOwnerRecord()->banners()->max('sort') ?? 0; - $data['sort'] = $maxSort + 1; - - return $data; - }), - LocaleSwitcher::make(), - ]) + ->groups(['position.name']) + ->reorderable('sort') ->actions([ - ViewAction::make(), - EditAction::make(), - DeleteAction::make(), + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + Tables\Actions\Action::make('editPosition') + ->icon('heroicon-o-pencil-square') + ->color('warning') + ->label('Position') + ->url(fn (Model $record): string => BannerPositionResource::getUrl('edit', [ + 'record' => $record->position, + ])), + Tables\Actions\DeleteAction::make(), ]) ->bulkActions([ - BulkActionGroup::make([ - DeleteBulkAction::make(), + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), ]), ]); } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBanners::route('/'), + 'create' => Pages\CreateBanner::route('/create'), + 'view' => Pages\ViewBanner::route('/{record}'), + 'edit' => Pages\EditBanner::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } } diff --git a/src/Admin/Filament/Resources/BannerResource/Pages/CreateBanner.php b/src/Admin/Filament/Resources/BannerResource/Pages/CreateBanner.php new file mode 100644 index 0000000..859e9f8 --- /dev/null +++ b/src/Admin/Filament/Resources/BannerResource/Pages/CreateBanner.php @@ -0,0 +1,33 @@ +max('sort') ?? 0; + + $data['sort'] = $maxSort + 1; + } + + return $data; + } +} diff --git a/src/Admin/Filament/Resources/BannerResource/Pages/EditBanner.php b/src/Admin/Filament/Resources/BannerResource/Pages/EditBanner.php new file mode 100644 index 0000000..1628b15 --- /dev/null +++ b/src/Admin/Filament/Resources/BannerResource/Pages/EditBanner.php @@ -0,0 +1,23 @@ +icon('heroicon-o-plus-circle') + ->label('Banner'), + Actions\Action::make('positions') + ->icon('heroicon-o-list-bullet') + ->color('warning') + ->url( + BannerPositionResource::getUrl('index') + ), + Actions\LocaleSwitcher::make(), + ]; + } +} diff --git a/src/Admin/Filament/Resources/BannerPositionResource/Pages/ViewBannerPosition.php b/src/Admin/Filament/Resources/BannerResource/Pages/ViewBanner.php similarity index 53% rename from src/Admin/Filament/Resources/BannerPositionResource/Pages/ViewBannerPosition.php rename to src/Admin/Filament/Resources/BannerResource/Pages/ViewBanner.php index c501e87..4efed20 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/Pages/ViewBannerPosition.php +++ b/src/Admin/Filament/Resources/BannerResource/Pages/ViewBanner.php @@ -1,16 +1,16 @@ */ - public function site(): BelongsTo - { - return $this->belongsTo(\Eclipse\Core\Models\Site::class); - } - } diff --git a/src/Observers/BannerObserver.php b/src/Observers/BannerObserver.php index 7f4a72e..842b527 100644 --- a/src/Observers/BannerObserver.php +++ b/src/Observers/BannerObserver.php @@ -3,11 +3,68 @@ namespace Eclipse\Cms\Observers; use Eclipse\Cms\Models\Banner; +use Eclipse\Cms\Services\ImageService; +use Illuminate\Support\Facades\Storage; class BannerObserver { + public function __construct( + private ImageService $imageService + ) {} + + public function created(Banner $banner): void + { + $this->processHidpiImages($banner); + } + + public function updated(Banner $banner): void + { + $this->processHidpiImages($banner); + } + public function deleting(Banner $banner): void { + $banner->images()->each(function ($image): void { + $filePath = $image->getTranslation('file', app()->getLocale()); + if ($filePath && Storage::exists($filePath)) { + Storage::delete($filePath); + } + }); + $banner->images()->delete(); } + + private function processHidpiImages(Banner $banner): void + { + $banner->images()->where('is_hidpi', true)->each(function ($hidpiImage) use ($banner) { + $imageType = $hidpiImage->type; + if (! $imageType || ! $imageType->image_width || ! $imageType->image_height) { + return; + } + + $regularImage = $banner->images() + ->where('type_id', $hidpiImage->type_id) + ->where('is_hidpi', false) + ->first(); + + if (! $regularImage) { + $hidpiFile = $hidpiImage->getTranslation('file', app()->getLocale()); + if ($hidpiFile) { + $regularPath = $this->imageService->createRegularFromHidpi( + $hidpiFile, + $imageType->image_width, + $imageType->image_height + ); + + $banner->images()->create([ + 'type_id' => $hidpiImage->type_id, + 'file' => [app()->getLocale() => $regularPath], + 'is_hidpi' => false, + 'image_width' => $imageType->image_width, + 'image_height' => $imageType->image_height, + ]); + } + } + }); + } } diff --git a/src/Observers/PositionObserver.php b/src/Observers/PositionObserver.php index 84318b2..dddcfb3 100644 --- a/src/Observers/PositionObserver.php +++ b/src/Observers/PositionObserver.php @@ -3,11 +3,14 @@ namespace Eclipse\Cms\Observers; use Eclipse\Cms\Models\Banner\Position; +use Illuminate\Database\Eloquent\Model; class PositionObserver { public function deleting(Position $position): void { - $position->banners()->delete(); + $position->banners()->each(function (Model $banner): void { + $banner->delete(); + }); } } diff --git a/src/Policies/BannerPolicy.php b/src/Policies/BannerPolicy.php new file mode 100644 index 0000000..66fe86a --- /dev/null +++ b/src/Policies/BannerPolicy.php @@ -0,0 +1,62 @@ +can('view_any_banner'); + } + + public function view(Authorizable $user, Banner $banner): bool + { + return $user->can('view_banner'); + } + + public function create(Authorizable $user): bool + { + return $user->can('create_banner'); + } + + public function update(Authorizable $user, Banner $banner): bool + { + return $user->can('update_banner'); + } + + public function delete(Authorizable $user, Banner $banner): bool + { + return $user->can('delete_banner'); + } + + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_banner'); + } + + public function forceDelete(Authorizable $user, Banner $banner): bool + { + return $user->can('force_delete_banner'); + } + + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_banner'); + } + + public function restore(Authorizable $user, Banner $banner): bool + { + return $user->can('restore_banner'); + } + + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_banner'); + } +} diff --git a/src/Policies/BannerPositionPolicy.php b/src/Policies/BannerPositionPolicy.php index 06043a6..fe5520d 100644 --- a/src/Policies/BannerPositionPolicy.php +++ b/src/Policies/BannerPositionPolicy.php @@ -15,11 +15,6 @@ public function viewAny(Authorizable $user): bool return $user->can('view_any_banner::position'); } - public function view(Authorizable $user, Position $position): bool - { - return $user->can('view_banner::position'); - } - public function create(Authorizable $user): bool { return $user->can('create_banner::position'); diff --git a/src/Services/ImageService.php b/src/Services/ImageService.php new file mode 100644 index 0000000..a02a18b --- /dev/null +++ b/src/Services/ImageService.php @@ -0,0 +1,43 @@ +width($targetWidth) + ->height($targetHeight) + ->save($fullRegularPath); + + return $regularPath; + } + + public function processHidpiUpload(UploadedFile $file, string $directory, int $regularWidth, int $regularHeight): array + { + $hidpiPath = $file->store($directory); + + $regularPath = $this->createRegularFromHidpi($hidpiPath, $regularWidth, $regularHeight); + + return [ + 'hidpi_path' => $hidpiPath, + 'regular_path' => $regularPath, + ]; + } +} diff --git a/tests/Feature/BannerHidpiTest.php b/tests/Feature/BannerHidpiTest.php new file mode 100644 index 0000000..2b8f4ef --- /dev/null +++ b/tests/Feature/BannerHidpiTest.php @@ -0,0 +1,128 @@ +setUpSuperAdmin(); + Storage::fake('public'); + + $this->position = Position::factory()->create([ + 'name' => 'Header Banner', + 'code' => 'header', + ]); + + $this->hidpiImageType = $this->position->imageTypes()->create([ + 'name' => 'Mobile', + 'code' => 'mobile', + 'image_width' => 800, + 'image_height' => 400, + 'is_hidpi' => true, + ]); + + $this->regularImageType = $this->position->imageTypes()->create([ + 'name' => 'Desktop', + 'code' => 'desktop', + 'image_width' => 1200, + 'image_height' => 600, + 'is_hidpi' => false, + ]); +}); + +it('generates correct path for 1x image from 2x', function () { + $hidpiPath = 'banners/test@2x.png'; + $pathInfo = pathinfo($hidpiPath); + $expectedRegularPath = $pathInfo['dirname'].'/'.$pathInfo['filename'].'_1x.'.$pathInfo['extension']; + + expect($expectedRegularPath)->toBe('banners/test@2x_1x.png'); +}); + +it('only requires 2x upload for hidpi image types', function () { + $hidpiImageTypes = $this->position->imageTypes()->where('is_hidpi', true)->get(); + $regularImageTypes = $this->position->imageTypes()->where('is_hidpi', false)->get(); + + expect($hidpiImageTypes)->toHaveCount(1); + expect($regularImageTypes)->toHaveCount(1); + + $hidpiType = $hidpiImageTypes->first(); + expect($hidpiType->is_hidpi)->toBeTrue(); + expect($hidpiType->image_width)->toBe(800); + expect($hidpiType->image_height)->toBe(400); +}); + +it('creates only hidpi image in seeder for hidpi types', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + $banner->images()->create([ + 'type_id' => $this->hidpiImageType->id, + 'file' => ['en' => 'banners/mobile@2x.png'], + 'is_hidpi' => true, + 'image_width' => 1600, + 'image_height' => 800, + ]); + + $hidpiImages = $banner->images()->where('is_hidpi', true)->get(); + $regularImages = $banner->images()->where('is_hidpi', false)->get(); + + expect($hidpiImages)->toHaveCount(1); + expect($regularImages)->toHaveCount(0); +}); + +it('creates correct image structure for hidpi types', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + $banner->images()->create([ + 'type_id' => $this->hidpiImageType->id, + 'file' => ['en' => 'banners/test@2x.png'], + 'is_hidpi' => true, + 'image_width' => 1600, + 'image_height' => 800, + ]); + + $hidpiImages = $banner->images()->where('is_hidpi', true)->get(); + expect($hidpiImages)->toHaveCount(1); + + $hidpiImage = $hidpiImages->first(); + expect($hidpiImage->type_id)->toBe($this->hidpiImageType->id); + expect($hidpiImage->image_width)->toBe(1600); + expect($hidpiImage->image_height)->toBe(800); +}); + +it('automatically generates 1x image when hidpi image is created via observer', function () { + $mockImageService = $this->mock(ImageService::class); + $mockImageService->shouldReceive('createRegularFromHidpi') + ->with('banners/test@2x.png', 800, 400) + ->andReturn('banners/test@2x_1x.png'); + + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + expect($banner->images()->count())->toBe(0); + + $banner->images()->create([ + 'type_id' => $this->hidpiImageType->id, + 'file' => ['en' => 'banners/test@2x.png'], + 'is_hidpi' => true, + 'image_width' => 1600, + 'image_height' => 800, + ]); + + $observer = app(\Eclipse\Cms\Observers\BannerObserver::class); + $observer->updated($banner); + + expect($banner->images()->where('is_hidpi', true)->count())->toBe(1); + expect($banner->images()->where('is_hidpi', false)->count())->toBe(1); + + $regularImage = $banner->images()->where('is_hidpi', false)->first(); + expect($regularImage->type_id)->toBe($this->hidpiImageType->id); + expect($regularImage->image_width)->toBe(800); + expect($regularImage->image_height)->toBe(400); + expect($regularImage->getTranslation('file', 'en'))->toBe('banners/test@2x_1x.png'); +}); diff --git a/tests/Feature/BannerPermissionsTest.php b/tests/Feature/BannerPermissionsTest.php new file mode 100644 index 0000000..966ad21 --- /dev/null +++ b/tests/Feature/BannerPermissionsTest.php @@ -0,0 +1,128 @@ +position = Position::factory()->create(); +}); + +it('denies access to banner index without view_any_banner permission', function () { + $this->setUpUserWithoutPermissions(); + + $this->get(BannerResource::getUrl('index')) + ->assertForbidden(); +}); + +it('allows access to banner index with view_any_banner permission', function () { + $this->setUpUserWithPermissions(['view_any_banner']); + + $this->get(BannerResource::getUrl('index')) + ->assertSuccessful(); +}); + +it('denies banner creation without create_banner permission', function () { + $this->setUpUserWithPermissions(['view_any_banner']); + + $this->get(BannerResource::getUrl('create')) + ->assertForbidden(); +}); + +it('allows banner creation with create_banner permission', function () { + $this->setUpUserWithPermissions(['view_any_banner', 'create_banner']); + + livewire(CreateBanner::class) + ->fillForm([ + 'position_id' => $this->position->id, + 'name' => 'Test Banner', + 'is_active' => true, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $banner = Banner::where('position_id', $this->position->id)->first(); + expect($banner)->not->toBeNull() + ->and($banner->getTranslation('name', 'en'))->toBe('Test Banner'); +}); + +it('denies banner editing without update_banner permission', function () { + $this->setUpUserWithPermissions(['view_any_banner', 'view_banner']); + + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + $this->get(BannerResource::getUrl('edit', ['record' => $banner])) + ->assertForbidden(); +}); + +it('allows banner editing with update_banner permission', function () { + $this->setUpUserWithPermissions(['view_any_banner', 'view_banner', 'update_banner']); + + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + livewire(EditBanner::class, [ + 'record' => $banner->getRouteKey(), + ]) + ->fillForm([ + 'name' => 'Updated Banner', + ]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($banner->refresh()->name)->toBe('Updated Banner'); +}); + +it('denies banner deletion without delete_banner permission', function () { + $this->setUpUserWithPermissions(['view_any_banner']); + + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + livewire(ListBanners::class) + ->assertTableActionHidden('delete', $banner); +}); + +it('allows banner deletion with delete_banner permission', function () { + $this->setUpUserWithPermissions(['view_any_banner', 'delete_banner']); + + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + livewire(ListBanners::class) + ->callTableAction('delete', $banner); + + $this->assertSoftDeleted($banner); +}); + +it('denies banner view without view_banner permission', function () { + $this->setUpUserWithPermissions(['view_any_banner']); + + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + $this->get(BannerResource::getUrl('view', ['record' => $banner])) + ->assertForbidden(); +}); + +it('allows banner view with view_banner permission', function () { + $this->setUpUserWithPermissions(['view_any_banner', 'view_banner']); + + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + $this->get(BannerResource::getUrl('view', ['record' => $banner])) + ->assertSuccessful(); +}); diff --git a/tests/Feature/BannerPositionResourceTest.php b/tests/Feature/BannerPositionResourceTest.php index cbd1f05..35e4ee7 100644 --- a/tests/Feature/BannerPositionResourceTest.php +++ b/tests/Feature/BannerPositionResourceTest.php @@ -4,7 +4,9 @@ use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\CreateBannerPosition; use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition; use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\ListBannerPositions; +use Eclipse\Cms\Models\Banner; use Eclipse\Cms\Models\Banner\Position; +use Illuminate\Support\Facades\Storage; use function Pest\Livewire\livewire; @@ -133,3 +135,52 @@ ->assertCanSeeTableRecords($positions) ->assertTableFilterExists('trashed'); }); + +it('deletes all related banners and image files when position is deleted', function () { + Storage::fake(); + + Storage::put('banners/banner1-desktop.png', 'fake-image-content'); + Storage::put('banners/banner1-mobile@2x.png', 'fake-hidpi-content'); + Storage::put('banners/banner1-mobile@2x_1x.png', 'fake-regular-content'); + + $position = Position::factory()->create(); + $banner = Banner::factory()->create(['position_id' => $position->id]); + + $banner->images()->createMany([ + [ + 'type_id' => 1, + 'file' => ['en' => 'banners/banner1-desktop.png'], + 'is_hidpi' => false, + 'image_width' => 1200, + 'image_height' => 400, + ], + [ + 'type_id' => 2, + 'file' => ['en' => 'banners/banner1-mobile@2x.png'], + 'is_hidpi' => true, + 'image_width' => 1600, + 'image_height' => 800, + ], + [ + 'type_id' => 2, + 'file' => ['en' => 'banners/banner1-mobile@2x_1x.png'], + 'is_hidpi' => false, + 'image_width' => 800, + 'image_height' => 400, + ], + ]); + + expect($banner->images()->get())->toHaveCount(3); + expect(Storage::exists('banners/banner1-desktop.png'))->toBeTrue(); + expect(Storage::exists('banners/banner1-mobile@2x.png'))->toBeTrue(); + expect(Storage::exists('banners/banner1-mobile@2x_1x.png'))->toBeTrue(); + + $position->delete(); + + $this->assertSoftDeleted($position); + $this->assertSoftDeleted($banner); + expect(Banner::withTrashed()->find($banner->id)->images()->get())->toHaveCount(0); + expect(Storage::exists('banners/banner1-desktop.png'))->toBeFalse(); + expect(Storage::exists('banners/banner1-mobile@2x.png'))->toBeFalse(); + expect(Storage::exists('banners/banner1-mobile@2x_1x.png'))->toBeFalse(); +}); diff --git a/tests/Feature/BannerResourceTest.php b/tests/Feature/BannerResourceTest.php new file mode 100644 index 0000000..3098339 --- /dev/null +++ b/tests/Feature/BannerResourceTest.php @@ -0,0 +1,233 @@ +setUpSuperAdmin(); + + $this->position = Position::factory()->create([ + 'name' => 'Header Banner', + 'code' => 'header', + ]); + + $this->position->imageTypes()->createMany([ + [ + 'name' => 'Desktop', + 'code' => 'desktop', + 'image_width' => 1200, + 'image_height' => 400, + 'is_hidpi' => false, + ], + [ + 'name' => 'Mobile', + 'code' => 'mobile', + 'image_width' => 600, + 'image_height' => 300, + 'is_hidpi' => true, + ], + ]); +}); + +it('can render banner index page', function () { + $this->get(BannerResource::getUrl('index')) + ->assertSuccessful(); +}); + +it('can list banners', function () { + $banners = Banner::factory()->count(3)->create([ + 'position_id' => $this->position->id, + ]); + + livewire(ListBanners::class) + ->assertCanSeeTableRecords($banners); +}); + +it('can render banner create page', function () { + $this->get(BannerResource::getUrl('create')) + ->assertSuccessful(); +}); + +it('can create banner', function () { + $newData = Banner::factory()->make([ + 'position_id' => $this->position->id, + ]); + + livewire(CreateBanner::class) + ->fillForm([ + 'position_id' => $newData->position_id, + 'name' => $newData->name, + 'link' => $newData->link, + 'is_active' => $newData->is_active, + 'new_tab' => $newData->new_tab, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $banner = Banner::where('position_id', $newData->position_id)->first(); + expect($banner)->not->toBeNull() + ->and($banner->getTranslation('name', 'en'))->toBe($newData->name); +}); + +it('can render banner edit page', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + $this->get(BannerResource::getUrl('edit', ['record' => $banner])) + ->assertSuccessful(); +}); + +it('can retrieve banner data for editing', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + livewire(EditBanner::class, [ + 'record' => $banner->getRouteKey(), + ]) + ->assertFormSet([ + 'position_id' => $banner->position_id, + 'name' => $banner->name, + 'link' => $banner->link, + 'is_active' => $banner->is_active, + 'new_tab' => $banner->new_tab, + ]); +}); + +it('can save banner', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + $newData = Banner::factory()->make([ + 'position_id' => $this->position->id, + ]); + + livewire(EditBanner::class, [ + 'record' => $banner->getRouteKey(), + ]) + ->fillForm([ + 'position_id' => $newData->position_id, + 'name' => $newData->name, + 'link' => $newData->link, + 'is_active' => $newData->is_active, + 'new_tab' => $newData->new_tab, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($banner->refresh()) + ->name->toBe($newData->name) + ->link->toBe($newData->link) + ->is_active->toBe($newData->is_active) + ->new_tab->toBe($newData->new_tab); +}); + +it('can delete banner', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + livewire(ListBanners::class) + ->callTableAction('delete', $banner); + + $this->assertSoftDeleted($banner); +}); + +it('can validate banner creation', function () { + livewire(CreateBanner::class) + ->fillForm([ + 'name' => null, + 'position_id' => null, + ]) + ->call('create') + ->assertHasFormErrors(['name' => 'required', 'position_id' => 'required']); +}); + +it('can filter banners by position', function () { + $position2 = Position::factory()->create(['name' => 'Sidebar']); + + $headerBanners = Banner::factory()->count(2)->create([ + 'position_id' => $this->position->id, + ]); + + $sidebarBanners = Banner::factory()->count(2)->create([ + 'position_id' => $position2->id, + ]); + + livewire(ListBanners::class) + ->assertCanSeeTableRecords([...$headerBanners, ...$sidebarBanners]) + ->filterTable('position_id', $this->position->id) + ->assertCanSeeTableRecords($headerBanners) + ->assertCanNotSeeTableRecords($sidebarBanners); +}); + +it('can filter banners by active status', function () { + $activeBanner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'is_active' => true, + ]); + + $inactiveBanner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'is_active' => false, + ]); + + livewire(ListBanners::class) + ->assertCanSeeTableRecords([$activeBanner, $inactiveBanner]) + ->filterTable('is_active', true) + ->assertCanSeeTableRecords([$activeBanner]) + ->assertCanNotSeeTableRecords([$inactiveBanner]); +}); + +it('can search banners by name', function () { + $banners = Banner::factory()->count(3)->create([ + 'position_id' => $this->position->id, + ]); + + $firstBanner = $banners->first(); + $bannerName = is_array($firstBanner->name) ? $firstBanner->name['en'] ?? $firstBanner->name : $firstBanner->name; + + livewire(ListBanners::class) + ->searchTable($bannerName) + ->assertCanSeeTableRecords($banners->take(1)); +}); + +it('auto-increments sort order on creation', function () { + Banner::query()->forceDelete(); + + $existingBanner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'sort' => 5, + ]); + + $currentMaxSort = Banner::where('position_id', $this->position->id)->max('sort'); + expect($currentMaxSort)->toBe(5); + + $newData = Banner::factory()->make([ + 'position_id' => $this->position->id, + ]); + + livewire(CreateBanner::class) + ->fillForm([ + 'position_id' => $newData->position_id, + 'name' => $newData->name, + 'link' => $newData->link, + 'is_active' => $newData->is_active, + 'new_tab' => $newData->new_tab, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $newBanner = Banner::where('position_id', $newData->position_id) + ->where('sort', '>', 5) + ->first(); + expect($newBanner)->not->toBeNull() + ->and($newBanner->sort)->toBe(6); +}); diff --git a/tests/Feature/BannersRelationManagerTest.php b/tests/Feature/BannersRelationManagerTest.php deleted file mode 100644 index 835e2a1..0000000 --- a/tests/Feature/BannersRelationManagerTest.php +++ /dev/null @@ -1,215 +0,0 @@ -setUpSuperAdmin(); - - $this->position = Position::factory()->create([ - 'name' => 'Header Banner', - 'code' => 'header', - ]); - - $this->position->imageTypes()->createMany([ - [ - 'name' => 'Desktop', - 'code' => 'desktop', - 'image_width' => 1200, - 'image_height' => 400, - 'is_hidpi' => false, - ], - [ - 'name' => 'Mobile', - 'code' => 'mobile', - 'image_width' => 600, - 'image_height' => 300, - 'is_hidpi' => true, - ], - ]); -}); - -it('can render banners relation manager', function () { - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - 'name' => 'Test Banner', - 'link' => 'https://example.com', - 'is_active' => true, - 'new_tab' => false, - 'sort' => 1, - ]); - - livewire(BannersRelationManager::class, [ - 'ownerRecord' => $this->position, - 'pageClass' => EditBannerPosition::class, - ]) - ->assertSuccessful() - ->assertCanSeeTableRecords([$banner]); -}); - -it('generates correct repeater items for hidpi types', function () { - $component = livewire(BannersRelationManager::class, [ - 'ownerRecord' => $this->position, - 'pageClass' => EditBannerPosition::class, - ]); - - $component->mountTableAction('create'); - - expect($this->position->imageTypes)->toHaveCount(2); - expect($this->position->imageTypes->where('is_hidpi', true))->toHaveCount(1); -}); - -it('validates required images on banner creation', function () { - livewire(BannersRelationManager::class, [ - 'ownerRecord' => $this->position, - 'pageClass' => EditBannerPosition::class, - ]) - ->callTableAction('create', data: [ - 'name' => 'Test Banner', - 'link' => 'https://example.com', - 'is_active' => true, - 'new_tab' => false, - 'images' => [ - ['type_id' => $this->position->imageTypes->first()->id, 'is_hidpi' => false, 'file' => null], - ['type_id' => $this->position->imageTypes->last()->id, 'is_hidpi' => false, 'file' => null], - ['type_id' => $this->position->imageTypes->last()->id, 'is_hidpi' => true, 'file' => null], - ], - ]) - ->assertHasTableActionErrors(); - - expect(Banner::where('name', 'Test Banner')->first())->toBeNull(); -}); - -it('can view banner', function () { - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - 'name' => 'Test Banner', - 'link' => 'https://example.com', - 'is_active' => true, - 'new_tab' => true, - 'sort' => 1, - ]); - - $banner->images()->create([ - 'type_id' => $this->position->imageTypes->first()->id, - 'file' => ['en' => 'test.png'], - 'image_width' => 1200, - 'image_height' => 400, - 'is_hidpi' => false, - ]); - - livewire(BannersRelationManager::class, [ - 'ownerRecord' => $this->position, - 'pageClass' => EditBannerPosition::class, - ]) - ->mountTableAction('view', $banner) - ->assertTableActionExists('view'); -}); - -it('can delete banner and cleanup images', function () { - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - 'name' => 'Test Banner', - 'is_active' => true, - 'new_tab' => false, - 'sort' => 1, - ]); - - $image = $banner->images()->create([ - 'type_id' => $this->position->imageTypes->first()->id, - 'file' => ['en' => 'test.jpg'], - 'image_width' => 1200, - 'image_height' => 400, - 'is_hidpi' => false, - ]); - - livewire(BannersRelationManager::class, [ - 'ownerRecord' => $this->position, - 'pageClass' => EditBannerPosition::class, - ]) - ->callTableAction('delete', $banner); - - $this->assertSoftDeleted($banner); - expect($image->fresh())->toBeNull(); -}); - -it('can sort banners automatically', function () { - Banner::factory()->create([ - 'position_id' => $this->position->id, - 'name' => 'Existing Banner', - 'sort' => 1, - ]); - - $bannersCount = $this->position->banners()->count(); - - expect($bannersCount)->toBe(1); - - $maxSort = $this->position->banners()->max('sort'); - expect($maxSort)->toBe(1); -}); - -it('can search banners', function () { - $banners = Banner::factory()->count(3)->create([ - 'position_id' => $this->position->id, - ]); - - $firstBanner = $banners->first(); - $bannerName = is_array($firstBanner->name) ? $firstBanner->name['en'] ?? $firstBanner->name : $firstBanner->name; - - livewire(BannersRelationManager::class, [ - 'ownerRecord' => $this->position, - 'pageClass' => EditBannerPosition::class, - ]) - ->searchTable($bannerName) - ->assertCanSeeTableRecords($banners->take(1)); -}); - -it('can filter banners by active status', function () { - $activeBanner = Banner::factory()->create([ - 'position_id' => $this->position->id, - 'name' => 'Active Banner', - 'is_active' => true, - ]); - - $inactiveBanner = Banner::factory()->create([ - 'position_id' => $this->position->id, - 'name' => 'Inactive Banner', - 'is_active' => false, - ]); - - livewire(BannersRelationManager::class, [ - 'ownerRecord' => $this->position, - 'pageClass' => EditBannerPosition::class, - ]) - ->assertCanSeeTableRecords([$activeBanner, $inactiveBanner]) - ->filterTable('is_active', true) - ->assertCanSeeTableRecords([$activeBanner]) - ->assertCanNotSeeTableRecords([$inactiveBanner]); -}); - -it('can filter banners by new tab setting', function () { - $newTabBanner = Banner::factory()->create([ - 'position_id' => $this->position->id, - 'name' => 'New Tab Banner', - 'new_tab' => true, - ]); - - $sameTabBanner = Banner::factory()->create([ - 'position_id' => $this->position->id, - 'name' => 'Same Tab Banner', - 'new_tab' => false, - ]); - - livewire(BannersRelationManager::class, [ - 'ownerRecord' => $this->position, - 'pageClass' => EditBannerPosition::class, - ]) - ->assertCanSeeTableRecords([$newTabBanner, $sameTabBanner]) - ->filterTable('new_tab', true) - ->assertCanSeeTableRecords([$newTabBanner]) - ->assertCanNotSeeTableRecords([$sameTabBanner]); -}); From a116381eeafa9a389bc3ba8d4dac311d4b57e100 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Fri, 5 Sep 2025 13:48:02 +0545 Subject: [PATCH 5/9] refactor: improve banner management and enhanced modal and adding few more test cases --- .../components/image-preview-modal.blade.php | 18 +- .../Resources/BannerPositionResource.php | 94 +++--- .../Pages/ListBannerPositions.php | 3 +- .../BannerRelationManager.php} | 228 +++++++------- .../BannerResource/Pages/CreateBanner.php | 33 -- .../BannerResource/Pages/EditBanner.php | 23 -- .../BannerResource/Pages/ListBanners.php | 31 -- .../BannerResource/Pages/ViewBanner.php | 22 -- src/CmsServiceProvider.php | 3 - src/Models/Banner/Position.php | 8 + src/Policies/BannerPolicy.php | 62 ---- src/Rules/BannerImageDimensionRule.php | 45 +++ tests/Feature/BannerPermissionsTest.php | 128 -------- tests/Feature/BannerRelationManagerTest.php | 282 ++++++++++++++++++ tests/Feature/BannerResourceTest.php | 233 --------------- tests/Unit/BannerImageDimensionRuleTest.php | 154 ++++++++++ tests/Unit/BannerModelsTest.php | 143 --------- 17 files changed, 682 insertions(+), 828 deletions(-) rename src/Admin/Filament/Resources/{BannerResource.php => BannerPositionResource/RelationManagers/BannerRelationManager.php} (57%) delete mode 100644 src/Admin/Filament/Resources/BannerResource/Pages/CreateBanner.php delete mode 100644 src/Admin/Filament/Resources/BannerResource/Pages/EditBanner.php delete mode 100644 src/Admin/Filament/Resources/BannerResource/Pages/ListBanners.php delete mode 100644 src/Admin/Filament/Resources/BannerResource/Pages/ViewBanner.php delete mode 100644 src/Policies/BannerPolicy.php create mode 100644 src/Rules/BannerImageDimensionRule.php delete mode 100644 tests/Feature/BannerPermissionsTest.php create mode 100644 tests/Feature/BannerRelationManagerTest.php delete mode 100644 tests/Feature/BannerResourceTest.php create mode 100644 tests/Unit/BannerImageDimensionRuleTest.php delete mode 100644 tests/Unit/BannerModelsTest.php diff --git a/resources/views/components/image-preview-modal.blade.php b/resources/views/components/image-preview-modal.blade.php index 3e76b5f..f304e8c 100644 --- a/resources/views/components/image-preview-modal.blade.php +++ b/resources/views/components/image-preview-modal.blade.php @@ -20,8 +20,8 @@ class="image-preview-lightbox-overlay" -
-

+
+

@@ -189,6 +189,10 @@ function imagePreviewLightbox() { return this.currentImage.name || this.currentImage.filename || ''; }, + getBannerName() { + return this.currentImage.name || ''; + }, + init() { const self = this; document.addEventListener('click', function(e) { @@ -221,11 +225,17 @@ function imagePreviewLightbox() { if (imgContainer) { const imgs = imgContainer.querySelectorAll('.image-preview-trigger'); + const nameCell = row.querySelector('[wire\\:key*="name"]') || row.querySelector('.fi-ta-text'); + const linkCell = row.querySelector('[wire\\:key*="link"]') || row.querySelectorAll('.fi-ta-text')[1]; + + const bannerName = nameCell ? nameCell.textContent.trim() : ''; + const bannerLink = linkCell ? linkCell.textContent.trim() : ''; + imgs.forEach((img) => { const imageData = { url: img.src, - name: img.alt || '', - link: '', + name: bannerName, + link: bannerLink && bannerLink !== '—' ? bannerLink : '', filename: img.alt || '' }; diff --git a/src/Admin/Filament/Resources/BannerPositionResource.php b/src/Admin/Filament/Resources/BannerPositionResource.php index 2d56eb9..3ac5252 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource.php +++ b/src/Admin/Filament/Resources/BannerPositionResource.php @@ -3,6 +3,7 @@ namespace Eclipse\Cms\Admin\Filament\Resources; use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages; +use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\RelationManagers; use Eclipse\Cms\Models\Banner\Position as BannerPosition; use Filament\Forms; use Filament\Forms\Form; @@ -19,17 +20,23 @@ class BannerPositionResource extends Resource protected static ?string $model = BannerPosition::class; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $navigationIcon = 'heroicon-o-photo'; protected static ?string $navigationGroup = 'CMS'; + protected static ?string $modelLabel = 'Banner'; + + protected static ?string $pluralModelLabel = 'Banners'; + public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\Section::make() + Forms\Components\Section::make('Position') + ->collapsible() + ->collapsed( + fn ($context) => ($context === 'edit') + ) ->compact() ->schema([ Forms\Components\TextInput::make('name') @@ -41,40 +48,51 @@ public static function form(Form $form): Form ->alphaDash(), ]), - Forms\Components\Repeater::make('imageTypes') - ->relationship() - ->columnSpanFull() - ->hiddenLabel() - ->itemLabel(fn (array $state): ?string => $state['name'] ?? 'Image Type') + Forms\Components\Section::make('Image Types') + ->collapsible() + ->collapsed( + fn ($context) => ($context === 'edit') + ) + ->compact() ->schema([ - Forms\Components\TextInput::make('name') - ->required() - ->maxLength(255), - - Forms\Components\TextInput::make('code') - ->alphaDash() - ->maxLength(255), - - Forms\Components\TextInput::make('image_width') - ->numeric() - ->minValue(1) - ->maxValue(9999) - ->label('Width (px)'), - - Forms\Components\TextInput::make('image_height') - ->numeric() - ->minValue(1) - ->maxValue(9999) - ->label('Height (px)'), - - Forms\Components\Toggle::make('is_hidpi') - ->label('Require HiDPI (2x) images'), - ]) - ->columns(2) - ->defaultItems(1) - ->addActionLabel('Add Image Type') - ->reorderableWithButtons() - ->collapsible(), + Forms\Components\Repeater::make('imageTypes') + ->relationship() + ->columnSpanFull() + ->hiddenLabel() + ->itemLabel(fn (array $state): ?string => $state['name'] ?? 'Image Type') + ->schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('code') + ->alphaDash() + ->maxLength(255), + + Forms\Components\TextInput::make('image_width') + ->numeric() + ->minValue(1) + ->maxValue(9999) + ->label('Width (px)'), + + Forms\Components\TextInput::make('image_height') + ->numeric() + ->minValue(1) + ->maxValue(9999) + ->label('Height (px)'), + + Forms\Components\Toggle::make('is_hidpi') + ->label('Require HiDPI (2x) images'), + ]) + ->collapsed( + fn ($context) => ($context === 'edit') + ) + ->columns(2) + ->defaultItems(1) + ->addActionLabel('Add Image Type') + ->reorderableWithButtons() + ->collapsible(), + ]), ]); } @@ -113,7 +131,9 @@ public static function table(Table $table): Table public static function getRelations(): array { - return []; + return [ + RelationManagers\BannerRelationManager::class, + ]; } public static function getPages(): array diff --git a/src/Admin/Filament/Resources/BannerPositionResource/Pages/ListBannerPositions.php b/src/Admin/Filament/Resources/BannerPositionResource/Pages/ListBannerPositions.php index 3b6193e..7a805a5 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/Pages/ListBannerPositions.php +++ b/src/Admin/Filament/Resources/BannerPositionResource/Pages/ListBannerPositions.php @@ -15,7 +15,8 @@ class ListBannerPositions extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->label('New Position'), Actions\LocaleSwitcher::make(), ]; } diff --git a/src/Admin/Filament/Resources/BannerResource.php b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php similarity index 57% rename from src/Admin/Filament/Resources/BannerResource.php rename to src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php index 8f27144..3e9db91 100644 --- a/src/Admin/Filament/Resources/BannerResource.php +++ b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php @@ -1,54 +1,37 @@ schema([ Forms\Components\Section::make() ->compact() ->schema([ - Forms\Components\Select::make('position_id') - ->label('Position') - ->relationship('position', 'name') - ->required() - ->reactive() - ->afterStateUpdated(fn ($state, Set $set) => $set('images', [])), - Forms\Components\TextInput::make('name') ->required() ->maxLength(255), @@ -72,46 +55,34 @@ public static function form(Form $form): Form ->schema([ Forms\Components\Hidden::make('type_id'), Forms\Components\Hidden::make('is_hidpi'), - Forms\Components\Hidden::make('position_id'), + Forms\Components\Hidden::make('image_width'), + Forms\Components\Hidden::make('image_height'), FileUpload::make('file') ->hiddenLabel() ->image() ->directory('banners') ->rules([ - function (Get $get) { - return function (string $attribute, $value, Closure $fail) use ($get): void { - if (! $value) { - return; - } - - $typeId = $get('type_id'); - $isHidpi = $get('is_hidpi'); - - if ($typeId) { - $imageType = Position::find($get('position_id'))?->imageTypes()->find($typeId); - if ($imageType && $imageType->image_width && $imageType->image_height) { - $expectedWidth = $isHidpi ? $imageType->image_width * 2 : $imageType->image_width; - $expectedHeight = $isHidpi ? $imageType->image_height * 2 : $imageType->image_height; - - $imageSize = getimagesize($value->getPathname()); - $actualWidth = $imageSize[0] ?? 0; - $actualHeight = $imageSize[1] ?? 0; + function (Get $get): BannerImageDimensionRule|string { + $typeId = $get('type_id'); + $isHidpi = $get('is_hidpi') ?? false; + + if ($typeId) { + return new BannerImageDimensionRule( + $this->getOwnerRecord(), + $typeId, + $isHidpi + ); + } - if ($actualWidth !== $expectedWidth || $actualHeight !== $expectedHeight) { - $fail("Image must be exactly {$expectedWidth}×{$expectedHeight}px. Got {$actualWidth}×{$actualHeight}px."); - } - } - } - }; + return 'nullable'; }, ]) ->helperText(function (Get $get): string { $typeId = $get('type_id'); $isHidpi = $get('is_hidpi'); - $positionId = $get('position_id'); - if ($typeId && $positionId) { - $imageType = Position::find($positionId)?->imageTypes()->find($typeId); + if ($typeId) { + $imageType = $this->getOwnerRecord()->imageTypes()->find($typeId); if ($imageType && $imageType->image_width && $imageType->image_height) { if ($isHidpi) { $hidpiWidth = $imageType->image_width * 2; @@ -127,42 +98,51 @@ function (Get $get) { return 'Upload banner image'; }), ]) - ->default(function (Get $get) { - $positionId = $get('position_id'); - if (! $positionId) { + ->default(function () { + $position = $this->getOwnerRecord(); + if (! $position) { return []; } $items = []; - Position::find($positionId)?->imageTypes()->get()->each(function ($imageType) use (&$items, $positionId) { + $position->imageTypes()->get()->each(function ($imageType) use (&$items) { if ($imageType->is_hidpi) { - $items[] = ['type_id' => $imageType->id, 'is_hidpi' => true, 'position_id' => $positionId]; + $items[] = [ + 'type_id' => $imageType->id, + 'is_hidpi' => true, + 'image_width' => $imageType->image_width, + 'image_height' => $imageType->image_height, + ]; } else { - $items[] = ['type_id' => $imageType->id, 'is_hidpi' => false, 'position_id' => $positionId]; + $items[] = [ + 'type_id' => $imageType->id, + 'is_hidpi' => false, + 'image_width' => $imageType->image_width, + 'image_height' => $imageType->image_height, + ]; } }); return $items; }) ->minItems(0) - ->maxItems(function (Get $get): int { - $positionId = $get('position_id'); - if (! $positionId) { + ->maxItems(function (): int { + $position = $this->getOwnerRecord(); + if (! $position) { return 0; } - return Position::find($positionId)?->imageTypes()->count() ?? 0; + return $position->imageTypes()->count(); }) ->addable(false) ->deletable(false) ->reorderable(false) - ->itemLabel(function (array $state, Get $get): string { + ->itemLabel(function (array $state): string { $typeId = $state['type_id'] ?? null; $isHidpi = $state['is_hidpi'] ?? false; - $positionId = $get('position_id'); - if ($typeId && $positionId) { - $imageType = Position::find($positionId)?->imageTypes()->find($typeId); + if ($typeId) { + $imageType = $this->getOwnerRecord()->imageTypes()->find($typeId); if ($imageType) { if ($imageType->image_width && $imageType->image_height) { if ($isHidpi) { @@ -184,13 +164,10 @@ function (Get $get) { ]); } - public static function infolist(Infolist $infolist): Infolist + public function infolist(Infolist $infolist): Infolist { return $infolist ->schema([ - Infolists\Components\TextEntry::make('position.name') - ->label('Position'), - Infolists\Components\TextEntry::make('name'), Infolists\Components\TextEntry::make('link') @@ -219,9 +196,10 @@ public static function infolist(Infolist $infolist): Infolist ]); } - public static function table(Table $table): Table + public function table(Table $table): Table { return $table + ->recordTitleAttribute('name') ->columns([ Tables\Columns\TextColumn::make('sort') ->label('#') @@ -239,11 +217,6 @@ public static function table(Table $table): Table }) ->preview(true), - Tables\Columns\TextColumn::make('position.name') - ->label('Position') - ->sortable() - ->searchable(), - Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), @@ -267,10 +240,6 @@ public static function table(Table $table): Table ]) ->defaultSort('sort') ->filters([ - Tables\Filters\SelectFilter::make('position_id') - ->relationship('position', 'name') - ->label('Position'), - Tables\Filters\TernaryFilter::make('is_active') ->label('Active Status') ->boolean() @@ -287,42 +256,85 @@ public static function table(Table $table): Table Tables\Filters\TrashedFilter::make(), ]) - ->groups(['position.name']) ->reorderable('sort') + ->headerActions([ + Tables\Actions\LocaleSwitcher::make(), + Tables\Actions\CreateAction::make() + ->using(function (array $data, string $model): Banner { + $position = $this->getOwnerRecord(); + + $data['position_id'] = $position->id; + $maxSort = $position->banners()->max('sort') ?? 0; + $data['sort'] = $maxSort + 1; + + $imagesData = $data['images'] ?? []; + unset($data['images']); + + $banner = $position->banners()->create($data); + + foreach ($imagesData as $imageData) { + if (isset($imageData['type_id']) && ! empty($imageData['file'])) { + $imageType = $position->imageTypes()->find($imageData['type_id']); + if ($imageType) { + $banner->images()->create([ + 'type_id' => $imageData['type_id'], + 'is_hidpi' => $imageData['is_hidpi'] ?? false, + 'file' => $imageData['file'], + 'image_width' => $imageType->image_width, + 'image_height' => $imageType->image_height, + ]); + } + } + } + + $banner->refresh(); + $banner->touch(); + + return $banner; + }), + ]) ->actions([ Tables\Actions\ViewAction::make(), - Tables\Actions\EditAction::make(), - Tables\Actions\Action::make('editPosition') - ->icon('heroicon-o-pencil-square') - ->color('warning') - ->label('Position') - ->url(fn (Model $record): string => BannerPositionResource::getUrl('edit', [ - 'record' => $record->position, - ])), + Tables\Actions\EditAction::make() + ->using(function (Banner $record, array $data): Banner { + $position = $this->getOwnerRecord(); + + $imagesData = $data['images'] ?? []; + unset($data['images']); + + $record->update($data); + + $record->images()->delete(); + + foreach ($imagesData as $imageData) { + if (isset($imageData['type_id']) && ! empty($imageData['file'])) { + $imageType = $position->imageTypes()->find($imageData['type_id']); + if ($imageType) { + $record->images()->create([ + 'type_id' => $imageData['type_id'], + 'is_hidpi' => $imageData['is_hidpi'] ?? false, + 'file' => $imageData['file'], + 'image_width' => $imageType->image_width, + 'image_height' => $imageType->image_height, + ]); + } + } + } + + $record->refresh(); + $record->touch(); + + return $record; + }), Tables\Actions\DeleteAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), - ]); - } - - public static function getPages(): array - { - return [ - 'index' => Pages\ListBanners::route('/'), - 'create' => Pages\CreateBanner::route('/create'), - 'view' => Pages\ViewBanner::route('/{record}'), - 'edit' => Pages\EditBanner::route('/{record}/edit'), - ]; - } - - public static function getEloquentQuery(): Builder - { - return parent::getEloquentQuery() - ->withoutGlobalScopes([ + ]) + ->modifyQueryUsing(fn (Builder $query) => $query->withoutGlobalScopes([ SoftDeletingScope::class, - ]); + ])); } } diff --git a/src/Admin/Filament/Resources/BannerResource/Pages/CreateBanner.php b/src/Admin/Filament/Resources/BannerResource/Pages/CreateBanner.php deleted file mode 100644 index 859e9f8..0000000 --- a/src/Admin/Filament/Resources/BannerResource/Pages/CreateBanner.php +++ /dev/null @@ -1,33 +0,0 @@ -max('sort') ?? 0; - - $data['sort'] = $maxSort + 1; - } - - return $data; - } -} diff --git a/src/Admin/Filament/Resources/BannerResource/Pages/EditBanner.php b/src/Admin/Filament/Resources/BannerResource/Pages/EditBanner.php deleted file mode 100644 index 1628b15..0000000 --- a/src/Admin/Filament/Resources/BannerResource/Pages/EditBanner.php +++ /dev/null @@ -1,23 +0,0 @@ -icon('heroicon-o-plus-circle') - ->label('Banner'), - Actions\Action::make('positions') - ->icon('heroicon-o-list-bullet') - ->color('warning') - ->url( - BannerPositionResource::getUrl('index') - ), - Actions\LocaleSwitcher::make(), - ]; - } -} diff --git a/src/Admin/Filament/Resources/BannerResource/Pages/ViewBanner.php b/src/Admin/Filament/Resources/BannerResource/Pages/ViewBanner.php deleted file mode 100644 index 4efed20..0000000 --- a/src/Admin/Filament/Resources/BannerResource/Pages/ViewBanner.php +++ /dev/null @@ -1,22 +0,0 @@ -where($tenantFK, $tenant->id); } }); + + static::creating(function (Position $model) { + $tenant = Filament::getTenant(); + if ($tenant) { + $tenantFK = config('eclipse-cms.tenancy.foreign_key', 'site_id'); + $model->setAttribute($tenantFK, $tenant->id); + } + }); } } diff --git a/src/Policies/BannerPolicy.php b/src/Policies/BannerPolicy.php deleted file mode 100644 index 66fe86a..0000000 --- a/src/Policies/BannerPolicy.php +++ /dev/null @@ -1,62 +0,0 @@ -can('view_any_banner'); - } - - public function view(Authorizable $user, Banner $banner): bool - { - return $user->can('view_banner'); - } - - public function create(Authorizable $user): bool - { - return $user->can('create_banner'); - } - - public function update(Authorizable $user, Banner $banner): bool - { - return $user->can('update_banner'); - } - - public function delete(Authorizable $user, Banner $banner): bool - { - return $user->can('delete_banner'); - } - - public function deleteAny(Authorizable $user): bool - { - return $user->can('delete_any_banner'); - } - - public function forceDelete(Authorizable $user, Banner $banner): bool - { - return $user->can('force_delete_banner'); - } - - public function forceDeleteAny(Authorizable $user): bool - { - return $user->can('force_delete_any_banner'); - } - - public function restore(Authorizable $user, Banner $banner): bool - { - return $user->can('restore_banner'); - } - - public function restoreAny(Authorizable $user): bool - { - return $user->can('restore_any_banner'); - } -} diff --git a/src/Rules/BannerImageDimensionRule.php b/src/Rules/BannerImageDimensionRule.php new file mode 100644 index 0000000..c41c5d7 --- /dev/null +++ b/src/Rules/BannerImageDimensionRule.php @@ -0,0 +1,45 @@ +position->imageTypes()->find($this->typeId); + + if (! $imageType || ! $imageType->image_width || ! $imageType->image_height) { + return; + } + + $expectedWidth = $this->isHidpi + ? $imageType->image_width * 2 + : $imageType->image_width; + + $expectedHeight = $this->isHidpi + ? $imageType->image_height * 2 + : $imageType->image_height; + + $imageSize = getimagesize($value->getPathname()); + $actualWidth = $imageSize[0] ?? 0; + $actualHeight = $imageSize[1] ?? 0; + + if ($actualWidth !== $expectedWidth || $actualHeight !== $expectedHeight) { + $fail("Image must be exactly {$expectedWidth}×{$expectedHeight}px. Got {$actualWidth}×{$actualHeight}px."); + } + } +} diff --git a/tests/Feature/BannerPermissionsTest.php b/tests/Feature/BannerPermissionsTest.php deleted file mode 100644 index 966ad21..0000000 --- a/tests/Feature/BannerPermissionsTest.php +++ /dev/null @@ -1,128 +0,0 @@ -position = Position::factory()->create(); -}); - -it('denies access to banner index without view_any_banner permission', function () { - $this->setUpUserWithoutPermissions(); - - $this->get(BannerResource::getUrl('index')) - ->assertForbidden(); -}); - -it('allows access to banner index with view_any_banner permission', function () { - $this->setUpUserWithPermissions(['view_any_banner']); - - $this->get(BannerResource::getUrl('index')) - ->assertSuccessful(); -}); - -it('denies banner creation without create_banner permission', function () { - $this->setUpUserWithPermissions(['view_any_banner']); - - $this->get(BannerResource::getUrl('create')) - ->assertForbidden(); -}); - -it('allows banner creation with create_banner permission', function () { - $this->setUpUserWithPermissions(['view_any_banner', 'create_banner']); - - livewire(CreateBanner::class) - ->fillForm([ - 'position_id' => $this->position->id, - 'name' => 'Test Banner', - 'is_active' => true, - ]) - ->call('create') - ->assertHasNoFormErrors(); - - $banner = Banner::where('position_id', $this->position->id)->first(); - expect($banner)->not->toBeNull() - ->and($banner->getTranslation('name', 'en'))->toBe('Test Banner'); -}); - -it('denies banner editing without update_banner permission', function () { - $this->setUpUserWithPermissions(['view_any_banner', 'view_banner']); - - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - ]); - - $this->get(BannerResource::getUrl('edit', ['record' => $banner])) - ->assertForbidden(); -}); - -it('allows banner editing with update_banner permission', function () { - $this->setUpUserWithPermissions(['view_any_banner', 'view_banner', 'update_banner']); - - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - ]); - - livewire(EditBanner::class, [ - 'record' => $banner->getRouteKey(), - ]) - ->fillForm([ - 'name' => 'Updated Banner', - ]) - ->call('save') - ->assertHasNoFormErrors(); - - expect($banner->refresh()->name)->toBe('Updated Banner'); -}); - -it('denies banner deletion without delete_banner permission', function () { - $this->setUpUserWithPermissions(['view_any_banner']); - - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - ]); - - livewire(ListBanners::class) - ->assertTableActionHidden('delete', $banner); -}); - -it('allows banner deletion with delete_banner permission', function () { - $this->setUpUserWithPermissions(['view_any_banner', 'delete_banner']); - - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - ]); - - livewire(ListBanners::class) - ->callTableAction('delete', $banner); - - $this->assertSoftDeleted($banner); -}); - -it('denies banner view without view_banner permission', function () { - $this->setUpUserWithPermissions(['view_any_banner']); - - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - ]); - - $this->get(BannerResource::getUrl('view', ['record' => $banner])) - ->assertForbidden(); -}); - -it('allows banner view with view_banner permission', function () { - $this->setUpUserWithPermissions(['view_any_banner', 'view_banner']); - - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - ]); - - $this->get(BannerResource::getUrl('view', ['record' => $banner])) - ->assertSuccessful(); -}); diff --git a/tests/Feature/BannerRelationManagerTest.php b/tests/Feature/BannerRelationManagerTest.php new file mode 100644 index 0000000..b5123e1 --- /dev/null +++ b/tests/Feature/BannerRelationManagerTest.php @@ -0,0 +1,282 @@ +setUpSuperAdmin(); + + $this->position = Position::factory()->create([ + 'name' => 'Header Banner', + 'code' => 'header', + ]); + + $this->position->imageTypes()->createMany([ + [ + 'name' => 'Desktop', + 'code' => 'desktop', + 'image_width' => 1200, + 'image_height' => 400, + 'is_hidpi' => false, + ], + [ + 'name' => 'Mobile', + 'code' => 'mobile', + 'image_width' => 600, + 'image_height' => 300, + 'is_hidpi' => true, + ], + ]); +}); + +it('can list banners in relation manager', function () { + $banners = Banner::factory()->count(3)->create([ + 'position_id' => $this->position->id, + ]); + + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->assertCanSeeTableRecords($banners); +}); + +it('can create banner through relation manager', function () { + $newData = Banner::factory()->make([ + 'position_id' => $this->position->id, + ]); + + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->callTableAction('create', data: [ + 'name' => $newData->name, + 'link' => $newData->link, + 'is_active' => $newData->is_active, + 'new_tab' => $newData->new_tab, + 'images' => [], // Don't create images in tests + ]) + ->assertHasNoTableActionErrors(); + + $banner = Banner::where('position_id', $this->position->id)->first(); + expect($banner)->not->toBeNull() + ->and($banner->getTranslation('name', 'en'))->toBe($newData->name); +}); + +it('can edit banner through relation manager', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + $newData = Banner::factory()->make(); + + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->callTableAction('edit', $banner, data: [ + 'name' => $newData->name, + 'link' => $newData->link, + 'is_active' => $newData->is_active, + 'new_tab' => $newData->new_tab, + ]) + ->assertHasNoTableActionErrors(); + + expect($banner->refresh()) + ->name->toBe($newData->name) + ->link->toBe($newData->link) + ->is_active->toBe($newData->is_active) + ->new_tab->toBe($newData->new_tab); +}); + +it('can delete banner through relation manager', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->callTableAction('delete', $banner); + + $this->assertSoftDeleted($banner); +}); + +it('can view banner through relation manager', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->callTableAction('view', $banner) + ->assertHasNoTableActionErrors(); +}); + +it('can filter banners by active status', function () { + $activeBanner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'is_active' => true, + ]); + + $inactiveBanner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'is_active' => false, + ]); + + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->assertCanSeeTableRecords([$activeBanner, $inactiveBanner]) + ->filterTable('is_active', true) + ->assertCanSeeTableRecords([$activeBanner]) + ->assertCanNotSeeTableRecords([$inactiveBanner]); +}); + +it('can search banners by name', function () { + $banners = Banner::factory()->count(3)->create([ + 'position_id' => $this->position->id, + ]); + + $firstBanner = $banners->first(); + $bannerName = is_array($firstBanner->name) ? $firstBanner->name['en'] ?? $firstBanner->name : $firstBanner->name; + + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->searchTable($bannerName) + ->assertCanSeeTableRecords($banners->take(1)); +}); + +it('auto-increments sort order on creation', function () { + Banner::query()->forceDelete(); + + $existingBanner = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'sort' => 5, + ]); + + $currentMaxSort = Banner::where('position_id', $this->position->id)->max('sort'); + expect($currentMaxSort)->toBe(5); + + $newData = Banner::factory()->make(); + + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->callTableAction('create', data: [ + 'name' => $newData->name, + 'link' => $newData->link, + 'is_active' => $newData->is_active, + 'new_tab' => $newData->new_tab, + 'images' => [], // Don't create images in tests + ]) + ->assertHasNoTableActionErrors(); + + $newBanner = Banner::where('position_id', $this->position->id) + ->where('sort', '>', 5) + ->first(); + expect($newBanner)->not->toBeNull() + ->and($newBanner->sort)->toBe(6); +}); + +it('validates banner creation', function () { + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->callTableAction('create', data: [ + 'name' => null, + ]) + ->assertHasTableActionErrors(['name' => 'required']); +}); + +it('can reorder banners', function () { + $banner1 = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'sort' => 1, + ]); + + $banner2 = Banner::factory()->create([ + 'position_id' => $this->position->id, + 'sort' => 2, + ]); + + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->assertCanSeeTableRecords([$banner1, $banner2]); + // Note: Testing actual reordering would require table action testing +}); + +it('has trashed filter available', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->assertTableFilterExists('trashed') + ->assertCanSeeTableRecords([$banner]); +}); + +it('triggers observer for hidpi image processing', function () { + // Create HiDPI image type + $hidpiImageType = $this->position->imageTypes()->create([ + 'name' => 'Mobile HiDPI', + 'code' => 'mobile_hidpi', + 'image_width' => 600, + 'image_height' => 300, + 'is_hidpi' => true, + ]); + + // Create a banner manually first + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + // Add a HiDPI image manually + $banner->images()->create([ + 'type_id' => $hidpiImageType->id, + 'file' => ['en' => 'banners/test-hidpi.png'], + 'is_hidpi' => true, + 'image_width' => 1200, // 2x the base size + 'image_height' => 600, + ]); + + expect($banner->images()->where('is_hidpi', true)->count())->toBe(1); + expect($banner->images()->where('is_hidpi', false)->count())->toBe(0); + + // Mock the ImageService + $mockImageService = $this->mock(\Eclipse\Cms\Services\ImageService::class); + $mockImageService->shouldReceive('createRegularFromHidpi') + ->with('banners/test-hidpi.png', 600, 300) + ->andReturn('banners/test-hidpi_1x.png'); + + // Manually trigger the observer (simulating what happens in the relation manager) + $observer = app(\Eclipse\Cms\Observers\BannerObserver::class); + $observer->updated($banner); + + // Should now have both HiDPI and regular images + expect($banner->images()->where('is_hidpi', true)->count())->toBe(1); + expect($banner->images()->where('is_hidpi', false)->count())->toBe(1); + + $regularImage = $banner->images()->where('is_hidpi', false)->first(); + expect($regularImage->type_id)->toBe($hidpiImageType->id); + expect($regularImage->image_width)->toBe(600); + expect($regularImage->image_height)->toBe(300); + expect($regularImage->getTranslation('file', 'en'))->toBe('banners/test-hidpi_1x.png'); +}); diff --git a/tests/Feature/BannerResourceTest.php b/tests/Feature/BannerResourceTest.php deleted file mode 100644 index 3098339..0000000 --- a/tests/Feature/BannerResourceTest.php +++ /dev/null @@ -1,233 +0,0 @@ -setUpSuperAdmin(); - - $this->position = Position::factory()->create([ - 'name' => 'Header Banner', - 'code' => 'header', - ]); - - $this->position->imageTypes()->createMany([ - [ - 'name' => 'Desktop', - 'code' => 'desktop', - 'image_width' => 1200, - 'image_height' => 400, - 'is_hidpi' => false, - ], - [ - 'name' => 'Mobile', - 'code' => 'mobile', - 'image_width' => 600, - 'image_height' => 300, - 'is_hidpi' => true, - ], - ]); -}); - -it('can render banner index page', function () { - $this->get(BannerResource::getUrl('index')) - ->assertSuccessful(); -}); - -it('can list banners', function () { - $banners = Banner::factory()->count(3)->create([ - 'position_id' => $this->position->id, - ]); - - livewire(ListBanners::class) - ->assertCanSeeTableRecords($banners); -}); - -it('can render banner create page', function () { - $this->get(BannerResource::getUrl('create')) - ->assertSuccessful(); -}); - -it('can create banner', function () { - $newData = Banner::factory()->make([ - 'position_id' => $this->position->id, - ]); - - livewire(CreateBanner::class) - ->fillForm([ - 'position_id' => $newData->position_id, - 'name' => $newData->name, - 'link' => $newData->link, - 'is_active' => $newData->is_active, - 'new_tab' => $newData->new_tab, - ]) - ->call('create') - ->assertHasNoFormErrors(); - - $banner = Banner::where('position_id', $newData->position_id)->first(); - expect($banner)->not->toBeNull() - ->and($banner->getTranslation('name', 'en'))->toBe($newData->name); -}); - -it('can render banner edit page', function () { - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - ]); - - $this->get(BannerResource::getUrl('edit', ['record' => $banner])) - ->assertSuccessful(); -}); - -it('can retrieve banner data for editing', function () { - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - ]); - - livewire(EditBanner::class, [ - 'record' => $banner->getRouteKey(), - ]) - ->assertFormSet([ - 'position_id' => $banner->position_id, - 'name' => $banner->name, - 'link' => $banner->link, - 'is_active' => $banner->is_active, - 'new_tab' => $banner->new_tab, - ]); -}); - -it('can save banner', function () { - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - ]); - $newData = Banner::factory()->make([ - 'position_id' => $this->position->id, - ]); - - livewire(EditBanner::class, [ - 'record' => $banner->getRouteKey(), - ]) - ->fillForm([ - 'position_id' => $newData->position_id, - 'name' => $newData->name, - 'link' => $newData->link, - 'is_active' => $newData->is_active, - 'new_tab' => $newData->new_tab, - ]) - ->call('save') - ->assertHasNoFormErrors(); - - expect($banner->refresh()) - ->name->toBe($newData->name) - ->link->toBe($newData->link) - ->is_active->toBe($newData->is_active) - ->new_tab->toBe($newData->new_tab); -}); - -it('can delete banner', function () { - $banner = Banner::factory()->create([ - 'position_id' => $this->position->id, - ]); - - livewire(ListBanners::class) - ->callTableAction('delete', $banner); - - $this->assertSoftDeleted($banner); -}); - -it('can validate banner creation', function () { - livewire(CreateBanner::class) - ->fillForm([ - 'name' => null, - 'position_id' => null, - ]) - ->call('create') - ->assertHasFormErrors(['name' => 'required', 'position_id' => 'required']); -}); - -it('can filter banners by position', function () { - $position2 = Position::factory()->create(['name' => 'Sidebar']); - - $headerBanners = Banner::factory()->count(2)->create([ - 'position_id' => $this->position->id, - ]); - - $sidebarBanners = Banner::factory()->count(2)->create([ - 'position_id' => $position2->id, - ]); - - livewire(ListBanners::class) - ->assertCanSeeTableRecords([...$headerBanners, ...$sidebarBanners]) - ->filterTable('position_id', $this->position->id) - ->assertCanSeeTableRecords($headerBanners) - ->assertCanNotSeeTableRecords($sidebarBanners); -}); - -it('can filter banners by active status', function () { - $activeBanner = Banner::factory()->create([ - 'position_id' => $this->position->id, - 'is_active' => true, - ]); - - $inactiveBanner = Banner::factory()->create([ - 'position_id' => $this->position->id, - 'is_active' => false, - ]); - - livewire(ListBanners::class) - ->assertCanSeeTableRecords([$activeBanner, $inactiveBanner]) - ->filterTable('is_active', true) - ->assertCanSeeTableRecords([$activeBanner]) - ->assertCanNotSeeTableRecords([$inactiveBanner]); -}); - -it('can search banners by name', function () { - $banners = Banner::factory()->count(3)->create([ - 'position_id' => $this->position->id, - ]); - - $firstBanner = $banners->first(); - $bannerName = is_array($firstBanner->name) ? $firstBanner->name['en'] ?? $firstBanner->name : $firstBanner->name; - - livewire(ListBanners::class) - ->searchTable($bannerName) - ->assertCanSeeTableRecords($banners->take(1)); -}); - -it('auto-increments sort order on creation', function () { - Banner::query()->forceDelete(); - - $existingBanner = Banner::factory()->create([ - 'position_id' => $this->position->id, - 'sort' => 5, - ]); - - $currentMaxSort = Banner::where('position_id', $this->position->id)->max('sort'); - expect($currentMaxSort)->toBe(5); - - $newData = Banner::factory()->make([ - 'position_id' => $this->position->id, - ]); - - livewire(CreateBanner::class) - ->fillForm([ - 'position_id' => $newData->position_id, - 'name' => $newData->name, - 'link' => $newData->link, - 'is_active' => $newData->is_active, - 'new_tab' => $newData->new_tab, - ]) - ->call('create') - ->assertHasNoFormErrors(); - - $newBanner = Banner::where('position_id', $newData->position_id) - ->where('sort', '>', 5) - ->first(); - expect($newBanner)->not->toBeNull() - ->and($newBanner->sort)->toBe(6); -}); diff --git a/tests/Unit/BannerImageDimensionRuleTest.php b/tests/Unit/BannerImageDimensionRuleTest.php new file mode 100644 index 0000000..a3fc987 --- /dev/null +++ b/tests/Unit/BannerImageDimensionRuleTest.php @@ -0,0 +1,154 @@ +position = Position::factory()->create(); + + $this->imageType = $this->position->imageTypes()->create([ + 'name' => 'Desktop', + 'code' => 'desktop', + 'image_width' => 1200, + 'image_height' => 400, + 'is_hidpi' => false, + ]); +}); + +it('validates correct image dimensions', function () { + $image = imagecreatetruecolor(1200, 400); + $white = imagecolorallocate($image, 255, 255, 255); + imagefill($image, 0, 0, $white); + $tmpFile = tempnam(sys_get_temp_dir(), 'test_image').'.png'; + imagepng($image, $tmpFile); + imagedestroy($image); + + $uploadedFile = new UploadedFile($tmpFile, 'test.png', 'image/png', null, true); + + $rule = new BannerImageDimensionRule($this->position, $this->imageType->id, false); + + $failed = false; + $rule->validate('file', $uploadedFile, function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeFalse(); + + unlink($tmpFile); +}); + +it('fails validation for incorrect image dimensions', function () { + $image = imagecreatetruecolor(800, 300); + $white = imagecolorallocate($image, 255, 255, 255); + imagefill($image, 0, 0, $white); + $tmpFile = tempnam(sys_get_temp_dir(), 'test_image').'.png'; + imagepng($image, $tmpFile); + imagedestroy($image); + + $uploadedFile = new UploadedFile($tmpFile, 'test.png', 'image/png', null, true); + + $rule = new BannerImageDimensionRule($this->position, $this->imageType->id, false); + + $failed = false; + $errorMessage = ''; + + $rule->validate('file', $uploadedFile, function ($message) use (&$failed, &$errorMessage) { + $failed = true; + $errorMessage = $message; + }); + + expect($failed)->toBeTrue(); + expect($errorMessage)->toContain('Image must be exactly 1200×400px. Got 800×300px.'); + + unlink($tmpFile); +}); + +it('validates correct hidpi image dimensions', function () { + $image = imagecreatetruecolor(2400, 800); + $white = imagecolorallocate($image, 255, 255, 255); + imagefill($image, 0, 0, $white); + $tmpFile = tempnam(sys_get_temp_dir(), 'test_image').'.png'; + imagepng($image, $tmpFile); + imagedestroy($image); + + $uploadedFile = new UploadedFile($tmpFile, 'test.png', 'image/png', null, true); + + $rule = new BannerImageDimensionRule($this->position, $this->imageType->id, true); + + $failed = false; + $rule->validate('file', $uploadedFile, function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeFalse(); + + unlink($tmpFile); +}); + +it('fails validation for incorrect hidpi image dimensions', function () { + $image = imagecreatetruecolor(1200, 400); + $white = imagecolorallocate($image, 255, 255, 255); + imagefill($image, 0, 0, $white); + $tmpFile = tempnam(sys_get_temp_dir(), 'test_image').'.png'; + imagepng($image, $tmpFile); + imagedestroy($image); + + $uploadedFile = new UploadedFile($tmpFile, 'test.png', 'image/png', null, true); + + $rule = new BannerImageDimensionRule($this->position, $this->imageType->id, true); + + $failed = false; + $errorMessage = ''; + + $rule->validate('file', $uploadedFile, function ($message) use (&$failed, &$errorMessage) { + $failed = true; + $errorMessage = $message; + }); + + expect($failed)->toBeTrue(); + expect($errorMessage)->toContain('Image must be exactly 2400×800px. Got 1200×400px.'); + + unlink($tmpFile); +}); + +it('passes validation when no file is provided', function () { + $rule = new BannerImageDimensionRule($this->position, $this->imageType->id, false); + + $failed = false; + $rule->validate('file', null, function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeFalse(); +}); + +it('passes validation when image type has no dimensions', function () { + $imageTypeNoDimensions = $this->position->imageTypes()->create([ + 'name' => 'Flexible', + 'code' => 'flexible', + 'image_width' => null, + 'image_height' => null, + 'is_hidpi' => false, + ]); + + $image = imagecreatetruecolor(500, 300); + $white = imagecolorallocate($image, 255, 255, 255); + imagefill($image, 0, 0, $white); + $tmpFile = tempnam(sys_get_temp_dir(), 'test_image').'.png'; + imagepng($image, $tmpFile); + imagedestroy($image); + + $uploadedFile = new UploadedFile($tmpFile, 'test.png', 'image/png', null, true); + + $rule = new BannerImageDimensionRule($this->position, $imageTypeNoDimensions->id, false); + + $failed = false; + $rule->validate('file', $uploadedFile, function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeFalse(); + + unlink($tmpFile); +}); diff --git a/tests/Unit/BannerModelsTest.php b/tests/Unit/BannerModelsTest.php deleted file mode 100644 index f4d543a..0000000 --- a/tests/Unit/BannerModelsTest.php +++ /dev/null @@ -1,143 +0,0 @@ - 'Header Banner', - 'code' => 'header', - ]); - - expect($position->name)->toBe('Header Banner'); - expect($position->code)->toBe('header'); - expect($position->imageTypes())->toBeInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class); - expect($position->banners())->toBeInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class); -}); - -it('creates image types for position', function () { - $position = Position::create([ - 'name' => 'Header Banner', - 'code' => 'header', - ]); - - $imageType = ImageType::create([ - 'position_id' => $position->id, - 'name' => 'Desktop', - 'code' => 'desktop', - 'image_width' => 1200, - 'image_height' => 400, - 'is_hidpi' => true, - ]); - - expect($imageType->position_id)->toBe($position->id); - expect($imageType->name)->toBe('Desktop'); - expect($imageType->is_hidpi)->toBe(true); - expect($imageType->position)->toBeInstanceOf(Position::class); - - expect($position->imageTypes)->toHaveCount(1); - expect($position->imageTypes->first()->name)->toBe('Desktop'); -}); - -it('creates banner with images', function () { - $position = Position::create([ - 'name' => 'Header Banner', - 'code' => 'header', - ]); - - $imageType = ImageType::create([ - 'position_id' => $position->id, - 'name' => 'Desktop', - 'code' => 'desktop', - 'image_width' => 1200, - 'image_height' => 400, - 'is_hidpi' => false, - ]); - - $banner = Banner::create([ - 'position_id' => $position->id, - 'name' => 'Test Banner', - 'link' => 'https://example.com', - 'is_active' => true, - 'new_tab' => false, - 'sort' => 1, - ]); - - $image = Image::create([ - 'banner_id' => $banner->id, - 'type_id' => $imageType->id, - 'file' => 'banners/test.jpg', - 'image_width' => 1200, - 'image_height' => 400, - 'is_hidpi' => false, - ]); - - expect($banner->name)->toBe('Test Banner'); - expect($banner->is_active)->toBe(true); - expect($banner->new_tab)->toBe(false); - expect($banner->position)->toBeInstanceOf(Position::class); - expect($banner->images)->toHaveCount(1); - - expect($image->banner)->toBeInstanceOf(Banner::class); - expect($image->type)->toBeInstanceOf(ImageType::class); - expect($image->file)->toBe('banners/test.jpg'); -}); - -it('orders banners by sort field', function () { - $position = Position::create([ - 'name' => 'Header Banner', - 'code' => 'header', - ]); - - Banner::create([ - 'position_id' => $position->id, - 'name' => 'Banner 2', - 'sort' => 2, - 'is_active' => true, - 'new_tab' => false, - ]); - - Banner::create([ - 'position_id' => $position->id, - 'name' => 'Banner 1', - 'sort' => 1, - 'is_active' => true, - 'new_tab' => false, - ]); - - Banner::create([ - 'position_id' => $position->id, - 'name' => 'Banner 3', - 'sort' => 3, - 'is_active' => true, - 'new_tab' => false, - ]); - - $orderedBanners = $position->banners; - - expect($orderedBanners)->toHaveCount(3); - expect($orderedBanners->first()->name)->toBe('Banner 1'); - expect($orderedBanners->last()->name)->toBe('Banner 3'); -}); - -it('soft deletes positions and cascades to banners', function () { - $position = Position::create([ - 'name' => 'Header Banner', - 'code' => 'header', - ]); - - $banner = Banner::create([ - 'position_id' => $position->id, - 'name' => 'Test Banner', - 'is_active' => true, - 'new_tab' => false, - 'sort' => 1, - ]); - - $position->delete(); - - expect($position->trashed())->toBe(true); - expect($banner->fresh()->trashed())->toBe(true); -}); From 8edcb780a1e809a45876832341956f9d2bb3a5af Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Sat, 13 Sep 2025 07:39:48 +0545 Subject: [PATCH 6/9] refactor: fixing failing test & image lightbox --- .../components/image-preview-modal.blade.php | 289 ------------------ .../BannerRelationManager.php | 12 +- src/CmsServiceProvider.php | 15 - .../Providers/WorkbenchServiceProvider.php | 2 + 4 files changed, 12 insertions(+), 306 deletions(-) delete mode 100644 resources/views/components/image-preview-modal.blade.php diff --git a/resources/views/components/image-preview-modal.blade.php b/resources/views/components/image-preview-modal.blade.php deleted file mode 100644 index f304e8c..0000000 --- a/resources/views/components/image-preview-modal.blade.php +++ /dev/null @@ -1,289 +0,0 @@ -
-
- -
- - -
- -
-

- -
-
- -
-
-
- - - - \ No newline at end of file diff --git a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php index 3e9db91..b9d31ae 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php +++ b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php @@ -15,6 +15,7 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletingScope; class BannerRelationManager extends RelationManager @@ -209,13 +210,20 @@ public function table(Table $table): Table ->circular() ->stacked() ->getStateUsing(function (Banner $record) { - $locale = app()->getLocale(); + $locale = $this->activeLocale ?? app()->getLocale(); return $record->images->map(function ($image) use ($locale) { return $image->getTranslation('file', $locale); })->filter()->values()->toArray(); }) - ->preview(true), + ->preview(function (Model $record) { + $locale = $this->activeLocale ?? app()->getLocale(); + + return [ + 'title' => $record->getTranslation('name', $locale).' Banner', + 'link' => $record->link ?? '#', + ]; + }), Tables\Columns\TextColumn::make('name') ->searchable() diff --git a/src/CmsServiceProvider.php b/src/CmsServiceProvider.php index 347e91e..fcd52e5 100644 --- a/src/CmsServiceProvider.php +++ b/src/CmsServiceProvider.php @@ -6,8 +6,6 @@ use Eclipse\Cms\Policies\BannerPositionPolicy; use Eclipse\Common\Foundation\Providers\PackageServiceProvider; use Eclipse\Common\Package; -use Filament\Support\Facades\FilamentView; -use Filament\Tables\Columns\ImageColumn; use Illuminate\Support\Facades\Gate; use Spatie\LaravelPackageTools\Package as SpatiePackage; @@ -27,18 +25,5 @@ public function configurePackage(SpatiePackage|Package $package): void public function bootingPackage(): void { Gate::policy(Position::class, BannerPositionPolicy::class); - - ImageColumn::macro( - 'preview', - fn (bool $enabled = true) => $enabled ? $this->extraImgAttributes([ - 'class' => 'cursor-pointer image-preview-trigger', - 'onclick' => 'event.stopPropagation(); return false;', - ]) : $this - ); - - FilamentView::registerRenderHook( - 'panels::body.end', - fn () => view('eclipse-cms::components.image-preview-modal')->render() - ); } } diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index 5c21824..851a57c 100644 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -2,6 +2,7 @@ namespace Workbench\App\Providers; +use Eclipse\Common\CommonServiceProvider; use Illuminate\Support\ServiceProvider; class WorkbenchServiceProvider extends ServiceProvider @@ -11,6 +12,7 @@ class WorkbenchServiceProvider extends ServiceProvider */ public function register(): void { + $this->app->register(CommonServiceProvider::class); $this->app->register(AdminPanelProvider::class); } From 298d0c575f1fb7f7f38c3624be1ccf637b45ed45 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Thu, 18 Sep 2025 08:26:26 +0545 Subject: [PATCH 7/9] feat: implemented all the changes requested in PR --- .../Resources/BannerPositionResource.php | 60 +++--- .../Pages/CreateBannerPosition.php | 7 + .../Pages/EditBannerPosition.php | 10 +- .../Pages/ListBannerPositions.php | 1 + .../Pages/ViewBannerPosition.php | 40 ++++ .../BannerRelationManager.php | 181 +++++++++--------- src/Observers/BannerObserver.php | 35 ++-- src/Policies/BannerPositionPolicy.php | 5 + src/Services/ImageService.php | 21 +- tests/Feature/BannerPositionResourceTest.php | 32 ++++ tests/Feature/BannerRelationManagerTest.php | 65 +++++++ .../Providers/WorkbenchServiceProvider.php | 6 +- 12 files changed, 321 insertions(+), 142 deletions(-) create mode 100644 src/Admin/Filament/Resources/BannerPositionResource/Pages/ViewBannerPosition.php diff --git a/src/Admin/Filament/Resources/BannerPositionResource.php b/src/Admin/Filament/Resources/BannerPositionResource.php index 3ac5252..d9e2d12 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource.php +++ b/src/Admin/Filament/Resources/BannerPositionResource.php @@ -2,8 +2,8 @@ namespace Eclipse\Cms\Admin\Filament\Resources; +use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions; use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages; -use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\RelationManagers; use Eclipse\Cms\Models\Banner\Position as BannerPosition; use Filament\Forms; use Filament\Forms\Form; @@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\SoftDeletingScope; -class BannerPositionResource extends Resource +class BannerPositionResource extends Resource implements HasShieldPermissions { use Translatable; @@ -28,15 +28,31 @@ class BannerPositionResource extends Resource protected static ?string $pluralModelLabel = 'Banners'; + public static function getPermissionPrefixes(): array + { + return [ + 'view_any', + 'create', + 'update', + 'delete', + 'delete_any', + 'force_delete', + 'force_delete_any', + 'restore', + 'restore_any', + 'manage_banners', + ]; + } + public static function form(Form $form): Form { return $form ->schema([ Forms\Components\Section::make('Position') - ->collapsible() - ->collapsed( - fn ($context) => ($context === 'edit') + ->hidden( + fn (string $context): bool => ($context === 'view') ) + ->collapsible() ->compact() ->schema([ Forms\Components\TextInput::make('name') @@ -49,10 +65,10 @@ public static function form(Form $form): Form ]), Forms\Components\Section::make('Image Types') - ->collapsible() - ->collapsed( - fn ($context) => ($context === 'edit') + ->hidden( + fn (string $context): bool => ($context === 'view') ) + ->collapsible() ->compact() ->schema([ Forms\Components\Repeater::make('imageTypes') @@ -84,9 +100,6 @@ public static function form(Form $form): Form Forms\Components\Toggle::make('is_hidpi') ->label('Require HiDPI (2x) images'), ]) - ->collapsed( - fn ($context) => ($context === 'edit') - ) ->columns(2) ->defaultItems(1) ->addActionLabel('Add Image Type') @@ -119,8 +132,12 @@ public static function table(Table $table): Table Tables\Filters\TrashedFilter::make(), ]) ->actions([ - Tables\Actions\EditAction::make(), - Tables\Actions\DeleteAction::make(), + Tables\Actions\ViewAction::make() + ->label('Manage banners'), + Tables\Actions\EditAction::make() + ->label('Edit position'), + Tables\Actions\DeleteAction::make() + ->label('Delete position'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ @@ -132,7 +149,6 @@ public static function table(Table $table): Table public static function getRelations(): array { return [ - RelationManagers\BannerRelationManager::class, ]; } @@ -141,25 +157,11 @@ public static function getPages(): array return [ 'index' => Pages\ListBannerPositions::route('/'), 'create' => Pages\CreateBannerPosition::route('/create'), + 'view' => Pages\ViewBannerPosition::route('/{record}'), 'edit' => Pages\EditBannerPosition::route('/{record}/edit'), ]; } - public static function getPermissions(): array - { - return [ - 'view_any', - 'create', - 'update', - 'delete', - 'delete_any', - 'force_delete', - 'force_delete_any', - 'restore', - 'restore_any', - ]; - } - public static function getEloquentQuery(): Builder { return parent::getEloquentQuery() diff --git a/src/Admin/Filament/Resources/BannerPositionResource/Pages/CreateBannerPosition.php b/src/Admin/Filament/Resources/BannerPositionResource/Pages/CreateBannerPosition.php index 8ec723f..96c4a2c 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/Pages/CreateBannerPosition.php +++ b/src/Admin/Filament/Resources/BannerPositionResource/Pages/CreateBannerPosition.php @@ -12,6 +12,13 @@ class CreateBannerPosition extends CreateRecord protected static string $resource = BannerPositionResource::class; + protected static ?string $breadcrumb = 'Create Position'; + + protected function getRedirectUrl(): string + { + return $this->previousUrl ?? $this->getResource()::getUrl('view'); + } + protected function getHeaderActions(): array { return [ diff --git a/src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php b/src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php index db34fc3..0540f75 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php +++ b/src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php @@ -10,12 +10,20 @@ class EditBannerPosition extends EditRecord { use EditRecord\Concerns\Translatable; + protected static ?string $breadcrumb = 'Edit Position'; + protected static string $resource = BannerPositionResource::class; protected function getHeaderActions(): array { return [ - Actions\DeleteAction::make(), + Actions\ViewAction::make() + ->color('primary') + ->icon('heroicon-o-photo') + ->label('Manage Banners'), + Actions\DeleteAction::make() + ->icon('heroicon-o-trash') + ->label('Delete Position'), Actions\LocaleSwitcher::make(), ]; } diff --git a/src/Admin/Filament/Resources/BannerPositionResource/Pages/ListBannerPositions.php b/src/Admin/Filament/Resources/BannerPositionResource/Pages/ListBannerPositions.php index 7a805a5..e372677 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/Pages/ListBannerPositions.php +++ b/src/Admin/Filament/Resources/BannerPositionResource/Pages/ListBannerPositions.php @@ -16,6 +16,7 @@ protected function getHeaderActions(): array { return [ Actions\CreateAction::make() + ->icon('heroicon-o-plus-circle') ->label('New Position'), Actions\LocaleSwitcher::make(), ]; diff --git a/src/Admin/Filament/Resources/BannerPositionResource/Pages/ViewBannerPosition.php b/src/Admin/Filament/Resources/BannerPositionResource/Pages/ViewBannerPosition.php new file mode 100644 index 0000000..5753d5e --- /dev/null +++ b/src/Admin/Filament/Resources/BannerPositionResource/Pages/ViewBannerPosition.php @@ -0,0 +1,40 @@ +getRecord()->name; + } + + protected function getHeaderActions(): array + { + return [ + Actions\EditAction::make() + ->icon('heroicon-o-pencil-square') + ->label('Edit Position'), + Actions\DeleteAction::make() + ->icon('heroicon-o-trash') + ->label('Delete position'), + ]; + } + + public function getRelationManagers(): array + { + return [ + BannerPositionResource\RelationManagers\BannerRelationManager::class, + ]; + } +} diff --git a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php index b9d31ae..4dc477b 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php +++ b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php @@ -4,6 +4,7 @@ use Eclipse\Cms\Models\Banner; use Eclipse\Cms\Rules\BannerImageDimensionRule; +use Eclipse\Common\Foundation\Helpers\MediaHelper; use Filament\Forms; use Filament\Forms\Components\FileUpload; use Filament\Forms\Form; @@ -15,7 +16,6 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletingScope; class BannerRelationManager extends RelationManager @@ -26,6 +26,54 @@ class BannerRelationManager extends RelationManager protected static ?string $recordTitleAttribute = 'name'; + public function isReadOnly(): bool + { + return false; + } + + protected function getDynamicImageColumns(): array + { + $position = $this->getOwnerRecord(); + if (! $position) { + return []; + } + + $imageTypes = $position->imageTypes()->get(); + + return $imageTypes->map(function ($imageType) { + return Tables\Columns\ImageColumn::make("image_type_{$imageType->id}") + ->label($imageType->name) + ->getStateUsing(function (Banner $record) use ($imageType) { + $locale = $this->activeLocale ?? app()->getLocale(); + $image = $record->images->where('type_id', $imageType->id)->first(); + + if ($image && $image->getTranslation('file', $locale)) { + return $image->getTranslation('file', $locale); + } + + // Fallback for test environment when MediaHelper is not autoloaded + if (class_exists(MediaHelper::class)) { + return MediaHelper::getPlaceholderImageUrl( + 'Not Found', + $imageType->image_width ?? 120, + $imageType->image_height ?? 120 + ); + } + + return null; + }) + ->preview(function (Banner $record) use ($imageType) { + $locale = $this->activeLocale ?? app()->getLocale(); + + return [ + 'title' => $record->getTranslation('name', $locale).' - '.$imageType->name, + 'link' => $record->link ?? '#', + ]; + }) + ->sortable(false); + })->toArray(); + } + public function form(Form $form): Form { return $form @@ -185,14 +233,25 @@ public function infolist(Infolist $infolist): Infolist Infolists\Components\Grid::make() ->columnSpanFull() ->schema( - fn (Banner $record) => $record->images->load('type')->map( - fn ($image) => Infolists\Components\ImageEntry::make("image_{$image->id}") - ->columnSpanFull() - ->label($image->type->name ?? 'Image') - ->width('100%') - ->height('auto') - ->getStateUsing(fn () => $image->getTranslation('file', app()->getLocale())) - )->toArray() + fn (Banner $record) => $this->getOwnerRecord()->imageTypes()->get() + ->map(function ($imageType) use ($record) { + $locale = app()->getLocale(); + $image = $record->images->where('type_id', $imageType->id)->first(); + + // Only show image entry if there's an actual image + if (! $image || ! $image->getTranslation('file', $locale)) { + return null; + } + + return Infolists\Components\ImageEntry::make("image_type_{$imageType->id}") + ->columnSpanFull() + ->label($imageType->name ?? 'Image') + ->width('100%') + ->height('auto') + ->getStateUsing(fn () => $image->getTranslation('file', $locale)); + }) + ->filter() // Remove null entries + ->toArray() ), ]); } @@ -201,38 +260,19 @@ public function table(Table $table): Table { return $table ->recordTitleAttribute('name') - ->columns([ + ->columns(array_merge([ Tables\Columns\TextColumn::make('sort') - ->label('#') + ->label('Position') ->sortable(), - Tables\Columns\ImageColumn::make('images.file') - ->circular() - ->stacked() - ->getStateUsing(function (Banner $record) { - $locale = $this->activeLocale ?? app()->getLocale(); - - return $record->images->map(function ($image) use ($locale) { - return $image->getTranslation('file', $locale); - })->filter()->values()->toArray(); - }) - ->preview(function (Model $record) { - $locale = $this->activeLocale ?? app()->getLocale(); - - return [ - 'title' => $record->getTranslation('name', $locale).' Banner', - 'link' => $record->link ?? '#', - ]; - }), - Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), - + ], $this->getDynamicImageColumns(), [ Tables\Columns\TextColumn::make('link') ->limit(30) ->url(fn ($record) => $record->link) - ->openUrlInNewTab(fn ($record) => $record->new_tab), + ->openUrlInNewTab(), Tables\Columns\IconColumn::make('is_active') ->boolean() @@ -245,7 +285,7 @@ public function table(Table $table): Table false => 'heroicon-o-minus', }) ->label('New Tab'), - ]) + ])) ->defaultSort('sort') ->filters([ Tables\Filters\TernaryFilter::make('is_active') @@ -268,72 +308,19 @@ public function table(Table $table): Table ->headerActions([ Tables\Actions\LocaleSwitcher::make(), Tables\Actions\CreateAction::make() - ->using(function (array $data, string $model): Banner { + ->icon('heroicon-o-plus-circle') + ->label('Add banner') + ->mutateFormDataUsing(function (array $data): array { $position = $this->getOwnerRecord(); - $data['position_id'] = $position->id; - $maxSort = $position->banners()->max('sort') ?? 0; - $data['sort'] = $maxSort + 1; - - $imagesData = $data['images'] ?? []; - unset($data['images']); - - $banner = $position->banners()->create($data); - - foreach ($imagesData as $imageData) { - if (isset($imageData['type_id']) && ! empty($imageData['file'])) { - $imageType = $position->imageTypes()->find($imageData['type_id']); - if ($imageType) { - $banner->images()->create([ - 'type_id' => $imageData['type_id'], - 'is_hidpi' => $imageData['is_hidpi'] ?? false, - 'file' => $imageData['file'], - 'image_width' => $imageType->image_width, - 'image_height' => $imageType->image_height, - ]); - } - } - } - - $banner->refresh(); - $banner->touch(); + $data['sort'] = ($position->banners()->max('sort') ?? 0) + 1; - return $banner; + return $data; }), ]) ->actions([ Tables\Actions\ViewAction::make(), - Tables\Actions\EditAction::make() - ->using(function (Banner $record, array $data): Banner { - $position = $this->getOwnerRecord(); - - $imagesData = $data['images'] ?? []; - unset($data['images']); - - $record->update($data); - - $record->images()->delete(); - - foreach ($imagesData as $imageData) { - if (isset($imageData['type_id']) && ! empty($imageData['file'])) { - $imageType = $position->imageTypes()->find($imageData['type_id']); - if ($imageType) { - $record->images()->create([ - 'type_id' => $imageData['type_id'], - 'is_hidpi' => $imageData['is_hidpi'] ?? false, - 'file' => $imageData['file'], - 'image_width' => $imageType->image_width, - 'image_height' => $imageType->image_height, - ]); - } - } - } - - $record->refresh(); - $record->touch(); - - return $record; - }), + Tables\Actions\EditAction::make(), Tables\Actions\DeleteAction::make(), ]) ->bulkActions([ @@ -341,8 +328,12 @@ public function table(Table $table): Table Tables\Actions\DeleteBulkAction::make(), ]), ]) - ->modifyQueryUsing(fn (Builder $query) => $query->withoutGlobalScopes([ - SoftDeletingScope::class, - ])); + ->modifyQueryUsing( + fn (Builder $query) => $query + ->with(['images', 'images.type']) + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]) + ); } } diff --git a/src/Observers/BannerObserver.php b/src/Observers/BannerObserver.php index 842b527..dd701b7 100644 --- a/src/Observers/BannerObserver.php +++ b/src/Observers/BannerObserver.php @@ -4,6 +4,8 @@ use Eclipse\Cms\Models\Banner; use Eclipse\Cms\Services\ImageService; +use Exception; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; class BannerObserver @@ -50,19 +52,28 @@ private function processHidpiImages(Banner $banner): void if (! $regularImage) { $hidpiFile = $hidpiImage->getTranslation('file', app()->getLocale()); if ($hidpiFile) { - $regularPath = $this->imageService->createRegularFromHidpi( - $hidpiFile, - $imageType->image_width, - $imageType->image_height - ); + try { + $regularPath = $this->imageService->createRegularFromHidpi( + $hidpiFile, + $imageType->image_width, + $imageType->image_height + ); - $banner->images()->create([ - 'type_id' => $hidpiImage->type_id, - 'file' => [app()->getLocale() => $regularPath], - 'is_hidpi' => false, - 'image_width' => $imageType->image_width, - 'image_height' => $imageType->image_height, - ]); + $banner->images()->create([ + 'type_id' => $hidpiImage->type_id, + 'file' => [app()->getLocale() => $regularPath], + 'is_hidpi' => false, + 'image_width' => $imageType->image_width, + 'image_height' => $imageType->image_height, + ]); + } catch (Exception $e) { + Log::error('Failed to create regular image from HiDPI', [ + 'hidpi_file' => $hidpiFile, + 'banner_id' => $banner->id, + 'image_type_id' => $imageType->id, + 'error' => $e->getMessage(), + ]); + } } } }); diff --git a/src/Policies/BannerPositionPolicy.php b/src/Policies/BannerPositionPolicy.php index fe5520d..ad73940 100644 --- a/src/Policies/BannerPositionPolicy.php +++ b/src/Policies/BannerPositionPolicy.php @@ -15,6 +15,11 @@ public function viewAny(Authorizable $user): bool return $user->can('view_any_banner::position'); } + public function view(Authorizable $user, Position $position): bool + { + return $user->can('manage_banners_banner::position'); + } + public function create(Authorizable $user): bool { return $user->can('create_banner::position'); diff --git a/src/Services/ImageService.php b/src/Services/ImageService.php index a02a18b..0216b0f 100644 --- a/src/Services/ImageService.php +++ b/src/Services/ImageService.php @@ -2,6 +2,7 @@ namespace Eclipse\Cms\Services; +use Exception; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Spatie\Image\Image; @@ -10,6 +11,10 @@ class ImageService { public function createRegularFromHidpi(string $hidpiPath, int $targetWidth, int $targetHeight): string { + if (! Storage::exists($hidpiPath)) { + throw new Exception("HiDPI image not found: {$hidpiPath}"); + } + $fullHidpiPath = Storage::path($hidpiPath); $pathInfo = pathinfo($hidpiPath); @@ -21,10 +26,18 @@ public function createRegularFromHidpi(string $hidpiPath, int $targetWidth, int mkdir($directory, 0755, true); } - Image::load($fullHidpiPath) - ->width($targetWidth) - ->height($targetHeight) - ->save($fullRegularPath); + try { + Image::load($fullHidpiPath) + ->width($targetWidth) + ->height($targetHeight) + ->save($fullRegularPath); + } catch (Exception $e) { + throw new Exception("Failed to process HiDPI image: {$e->getMessage()}"); + } + + if (! file_exists($fullRegularPath)) { + throw new Exception("Failed to create regular image: {$fullRegularPath}"); + } return $regularPath; } diff --git a/tests/Feature/BannerPositionResourceTest.php b/tests/Feature/BannerPositionResourceTest.php index 35e4ee7..c98f825 100644 --- a/tests/Feature/BannerPositionResourceTest.php +++ b/tests/Feature/BannerPositionResourceTest.php @@ -4,6 +4,7 @@ use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\CreateBannerPosition; use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition; use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\ListBannerPositions; +use Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\ViewBannerPosition; use Eclipse\Cms\Models\Banner; use Eclipse\Cms\Models\Banner\Position; use Illuminate\Support\Facades\Storage; @@ -136,6 +137,37 @@ ->assertTableFilterExists('trashed'); }); +it('can render position view page', function () { + $position = Position::factory()->create(); + + $this->get(BannerPositionResource::getUrl('view', ['record' => $position])) + ->assertSuccessful(); +}); + +it('can view position and manage banners', function () { + $position = Position::factory()->create(); + + livewire(ViewBannerPosition::class, [ + 'record' => $position->getRouteKey(), + ]) + ->assertSuccessful(); +}); + +it('can access edit from view page', function () { + $this->setUpSuperAdmin(); + + $position = Position::factory()->create(); + + $component = livewire(ViewBannerPosition::class, [ + 'record' => $position->getRouteKey(), + ]) + ->assertSuccessful(); + + $editUrl = BannerPositionResource::getUrl('edit', ['record' => $position]); + expect($editUrl)->toContain('edit'); + expect($editUrl)->toContain((string) $position->getRouteKey()); +}); + it('deletes all related banners and image files when position is deleted', function () { Storage::fake(); diff --git a/tests/Feature/BannerRelationManagerTest.php b/tests/Feature/BannerRelationManagerTest.php index b5123e1..693288e 100644 --- a/tests/Feature/BannerRelationManagerTest.php +++ b/tests/Feature/BannerRelationManagerTest.php @@ -280,3 +280,68 @@ expect($regularImage->image_height)->toBe(300); expect($regularImage->getTranslation('file', 'en'))->toBe('banners/test-hidpi_1x.png'); }); + +it('displays separate image columns for each image type', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + $banner->images()->create([ + 'type_id' => $this->position->imageTypes->first()->id, + 'file' => ['en' => 'banners/desktop-image.png'], + 'is_hidpi' => false, + 'image_width' => 1200, + 'image_height' => 400, + ]); + + $banner->images()->create([ + 'type_id' => $this->position->imageTypes->skip(1)->first()->id, + 'file' => ['en' => 'banners/mobile-image.png'], + 'is_hidpi' => true, + 'image_width' => 1200, + 'image_height' => 600, + ]); + + $component = livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]); + + foreach ($this->position->imageTypes as $imageType) { + $component->assertTableColumnExists("image_type_{$imageType->id}"); + } + + $component->assertCanSeeTableRecords([$banner]); +}); + +it('preserves existing images when editing banner without changing images', function () { + $banner = Banner::factory()->create([ + 'position_id' => $this->position->id, + ]); + + $banner->images()->create([ + 'type_id' => $this->position->imageTypes->first()->id, + 'file' => ['en' => 'banners/test-image.png'], + 'is_hidpi' => false, + 'image_width' => 1200, + 'image_height' => 600, + ]); + + expect($banner->images()->count())->toBe(1, 'Should start with 1 image'); + + livewire(BannerRelationManager::class, [ + 'ownerRecord' => $this->position, + 'pageClass' => 'Eclipse\Cms\Admin\Filament\Resources\BannerPositionResource\Pages\EditBannerPosition', + ]) + ->callTableAction('edit', $banner, data: [ + 'name' => 'Updated Banner Name', + 'link' => $banner->link, + 'is_active' => $banner->is_active, + 'new_tab' => $banner->new_tab, + ]) + ->assertHasNoTableActionErrors(); + + $banner->refresh(); + expect($banner->images()->count())->toBe(1, 'Should still have 1 image after edit'); + expect($banner->getTranslation('name', 'en'))->toBe('Updated Banner Name'); +}); diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index 851a57c..82a55ee 100644 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -3,6 +3,7 @@ namespace Workbench\App\Providers; use Eclipse\Common\CommonServiceProvider; +use Filament\Tables\Columns\ImageColumn; use Illuminate\Support\ServiceProvider; class WorkbenchServiceProvider extends ServiceProvider @@ -21,6 +22,9 @@ public function register(): void */ public function boot(): void { - // + // Minimal preview macro for tests only + if (! ImageColumn::hasMacro('preview')) { + ImageColumn::macro('preview', fn () => $this); + } } } From 86ccc45cd1c2ccacadae9b4fcc41e722117c8e80 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Thu, 18 Sep 2025 11:04:58 +0545 Subject: [PATCH 8/9] feat: moving lightbox to SliderColumn --- .../RelationManagers/BannerRelationManager.php | 11 +++++------ workbench/app/Providers/WorkbenchServiceProvider.php | 9 +-------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php index 4dc477b..efa0708 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php +++ b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php @@ -4,6 +4,7 @@ use Eclipse\Cms\Models\Banner; use Eclipse\Cms\Rules\BannerImageDimensionRule; +use Eclipse\Common\Admin\Filament\Tables\Columns\SliderColumn; use Eclipse\Common\Foundation\Helpers\MediaHelper; use Filament\Forms; use Filament\Forms\Components\FileUpload; @@ -41,7 +42,7 @@ protected function getDynamicImageColumns(): array $imageTypes = $position->imageTypes()->get(); return $imageTypes->map(function ($imageType) { - return Tables\Columns\ImageColumn::make("image_type_{$imageType->id}") + return SliderColumn::make("image_type_{$imageType->id}") ->label($imageType->name) ->getStateUsing(function (Banner $record) use ($imageType) { $locale = $this->activeLocale ?? app()->getLocale(); @@ -62,14 +63,12 @@ protected function getDynamicImageColumns(): array return null; }) - ->preview(function (Banner $record) use ($imageType) { + ->title(function (Banner $record) use ($imageType) { $locale = $this->activeLocale ?? app()->getLocale(); - return [ - 'title' => $record->getTranslation('name', $locale).' - '.$imageType->name, - 'link' => $record->link ?? '#', - ]; + return $record->getTranslation('name', $locale).' - '.$imageType->name; }) + ->link(fn (Banner $record) => $record->link ?? '#') ->sortable(false); })->toArray(); } diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index 82a55ee..e13e824 100644 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -3,7 +3,6 @@ namespace Workbench\App\Providers; use Eclipse\Common\CommonServiceProvider; -use Filament\Tables\Columns\ImageColumn; use Illuminate\Support\ServiceProvider; class WorkbenchServiceProvider extends ServiceProvider @@ -20,11 +19,5 @@ public function register(): void /** * Bootstrap services. */ - public function boot(): void - { - // Minimal preview macro for tests only - if (! ImageColumn::hasMacro('preview')) { - ImageColumn::macro('preview', fn () => $this); - } - } + public function boot(): void {} } From e106dca76fec229744a1ced05b9a59b28d9d1127 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Sat, 20 Sep 2025 12:07:32 +0545 Subject: [PATCH 9/9] feat: importing correct image classes --- .../RelationManagers/BannerRelationManager.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php index efa0708..68264e0 100644 --- a/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php +++ b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php @@ -4,8 +4,8 @@ use Eclipse\Cms\Models\Banner; use Eclipse\Cms\Rules\BannerImageDimensionRule; -use Eclipse\Common\Admin\Filament\Tables\Columns\SliderColumn; -use Eclipse\Common\Foundation\Helpers\MediaHelper; +use Eclipse\Common\Filament\Tables\Columns\ImageColumn; +use Eclipse\Common\Helpers\MediaHelper; use Filament\Forms; use Filament\Forms\Components\FileUpload; use Filament\Forms\Form; @@ -42,8 +42,9 @@ protected function getDynamicImageColumns(): array $imageTypes = $position->imageTypes()->get(); return $imageTypes->map(function ($imageType) { - return SliderColumn::make("image_type_{$imageType->id}") + 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();