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/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 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 diff --git a/composer.json b/composer.json index 2d66580..1404256 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,8 @@ "datalinx/php-utils": "^2.5", "eclipsephp/common": "dev-main", "filament/filament": "^3.3", + "filament/spatie-laravel-translatable-plugin": "^3.3", + "spatie/image": "^3.8", "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..3ade359 --- /dev/null +++ b/database/seeders/BannerSeeder.php @@ -0,0 +1,129 @@ +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; + + 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, + ]); + } 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, + ]); + } + } + + 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::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::exists($directory)) { + Storage::makeDirectory($directory); + } + + return Storage::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/src/Admin/Filament/Resources/BannerPositionResource.php b/src/Admin/Filament/Resources/BannerPositionResource.php new file mode 100644 index 0000000..d9e2d12 --- /dev/null +++ b/src/Admin/Filament/Resources/BannerPositionResource.php @@ -0,0 +1,172 @@ +schema([ + Forms\Components\Section::make('Position') + ->hidden( + fn (string $context): bool => ($context === 'view') + ) + ->collapsible() + ->compact() + ->schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('code') + ->maxLength(20) + ->alphaDash(), + ]), + + Forms\Components\Section::make('Image Types') + ->hidden( + fn (string $context): bool => ($context === 'view') + ) + ->collapsible() + ->compact() + ->schema([ + 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'), + ]) + ->columns(2) + ->defaultItems(1) + ->addActionLabel('Add Image Type') + ->reorderableWithButtons() + ->collapsible(), + ]), + ]); + } + + 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(), + + ]) + ->defaultSort('id') + ->filters([ + Tables\Filters\TrashedFilter::make(), + ]) + ->actions([ + 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([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + ]; + } + + 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..96c4a2c --- /dev/null +++ b/src/Admin/Filament/Resources/BannerPositionResource/Pages/CreateBannerPosition.php @@ -0,0 +1,28 @@ +previousUrl ?? $this->getResource()::getUrl('view'); + } + + protected function getHeaderActions(): array + { + return [ + Actions\LocaleSwitcher::make(), + ]; + } +} diff --git a/src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php b/src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php new file mode 100644 index 0000000..0540f75 --- /dev/null +++ b/src/Admin/Filament/Resources/BannerPositionResource/Pages/EditBannerPosition.php @@ -0,0 +1,30 @@ +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 new file mode 100644 index 0000000..e372677 --- /dev/null +++ b/src/Admin/Filament/Resources/BannerPositionResource/Pages/ListBannerPositions.php @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..68264e0 --- /dev/null +++ b/src/Admin/Filament/Resources/BannerPositionResource/RelationManagers/BannerRelationManager.php @@ -0,0 +1,339 @@ +getOwnerRecord(); + if (! $position) { + return []; + } + + $imageTypes = $position->imageTypes()->get(); + + return $imageTypes->map(function ($imageType) { + return ImageColumn::make("image_type_{$imageType->id}") + ->label($imageType->name) + ->preview() + ->getStateUsing(function (Banner $record) use ($imageType) { + $locale = $this->activeLocale ?? app()->getLocale(); + $image = $record->images->where('type_id', $imageType->id)->first(); + + 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; + }) + ->title(function (Banner $record) use ($imageType) { + $locale = $this->activeLocale ?? app()->getLocale(); + + return $record->getTranslation('name', $locale).' - '.$imageType->name; + }) + ->link(fn (Banner $record) => $record->link ?? '#') + ->sortable(false); + })->toArray(); + } + + public function form(Form $form): Form + { + return $form + ->schema([ + Forms\Components\Section::make() + ->compact() + ->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() + ->hiddenLabel() + ->schema([ + Forms\Components\Hidden::make('type_id'), + Forms\Components\Hidden::make('is_hidpi'), + Forms\Components\Hidden::make('image_width'), + Forms\Components\Hidden::make('image_height'), + FileUpload::make('file') + ->hiddenLabel() + ->image() + ->directory('banners') + ->rules([ + function (Get $get): BannerImageDimensionRule|string { + $typeId = $get('type_id'); + $isHidpi = $get('is_hidpi') ?? false; + + if ($typeId) { + return new BannerImageDimensionRule( + $this->getOwnerRecord(), + $typeId, + $isHidpi + ); + } + + return 'nullable'; + }, + ]) + ->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) { + $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 { + return "{$imageType->name} ({$imageType->image_width}×{$imageType->image_height})"; + } + } + } + + return 'Upload banner image'; + }), + ]) + ->default(function () { + $position = $this->getOwnerRecord(); + if (! $position) { + return []; + } + + $items = []; + $position->imageTypes()->get()->each(function ($imageType) use (&$items) { + if ($imageType->is_hidpi) { + $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, + 'image_width' => $imageType->image_width, + 'image_height' => $imageType->image_height, + ]; + } + }); + + return $items; + }) + ->minItems(0) + ->maxItems(function (): int { + $position = $this->getOwnerRecord(); + if (! $position) { + return 0; + } + + return $position->imageTypes()->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) { + if ($imageType->image_width && $imageType->image_height) { + if ($isHidpi) { + $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 { + return "{$imageType->name} ({$imageType->image_width}×{$imageType->image_height})"; + } + } + + return $imageType->name; + } + } + + 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) => $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() + ), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('name') + ->columns(array_merge([ + Tables\Columns\TextColumn::make('sort') + ->label('Position') + ->sortable(), + + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + ], $this->getDynamicImageColumns(), [ + Tables\Columns\TextColumn::make('link') + ->limit(30) + ->url(fn ($record) => $record->link) + ->openUrlInNewTab(), + + 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') + ->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(), + ]) + ->reorderable('sort') + ->headerActions([ + Tables\Actions\LocaleSwitcher::make(), + Tables\Actions\CreateAction::make() + ->icon('heroicon-o-plus-circle') + ->label('Add banner') + ->mutateFormDataUsing(function (array $data): array { + $position = $this->getOwnerRecord(); + $data['position_id'] = $position->id; + $data['sort'] = ($position->banners()->max('sort') ?? 0) + 1; + + return $data; + }), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->modifyQueryUsing( + fn (Builder $query) => $query + ->with(['images', 'images.type']) + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]) + ); + } +} diff --git a/src/CmsServiceProvider.php b/src/CmsServiceProvider.php index 98923a3..fcd52e5 100644 --- a/src/CmsServiceProvider.php +++ b/src/CmsServiceProvider.php @@ -2,8 +2,11 @@ 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 Illuminate\Support\Facades\Gate; use Spatie\LaravelPackageTools\Package as SpatiePackage; class CmsServiceProvider extends PackageServiceProvider @@ -14,7 +17,13 @@ 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); + } } 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..4b354ad 100644 --- a/src/Models/Banner/Position.php +++ b/src/Models/Banner/Position.php @@ -2,22 +2,87 @@ 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); + } + }); + + static::creating(function (Position $model) { + $tenant = Filament::getTenant(); + if ($tenant) { + $tenantFK = config('eclipse-cms.tenancy.foreign_key', 'site_id'); + $model->setAttribute($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/Observers/BannerObserver.php b/src/Observers/BannerObserver.php new file mode 100644 index 0000000..dd701b7 --- /dev/null +++ b/src/Observers/BannerObserver.php @@ -0,0 +1,81 @@ +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) { + 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, + ]); + } 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/Observers/PositionObserver.php b/src/Observers/PositionObserver.php new file mode 100644 index 0000000..dddcfb3 --- /dev/null +++ b/src/Observers/PositionObserver.php @@ -0,0 +1,16 @@ +banners()->each(function (Model $banner): void { + $banner->delete(); + }); + } +} diff --git a/src/Policies/BannerPositionPolicy.php b/src/Policies/BannerPositionPolicy.php new file mode 100644 index 0000000..ad73940 --- /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('manage_banners_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/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/src/Services/ImageService.php b/src/Services/ImageService.php new file mode 100644 index 0000000..0216b0f --- /dev/null +++ b/src/Services/ImageService.php @@ -0,0 +1,56 @@ +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; + } + + 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/BannerPositionResourceTest.php b/tests/Feature/BannerPositionResourceTest.php new file mode 100644 index 0000000..c98f825 --- /dev/null +++ b/tests/Feature/BannerPositionResourceTest.php @@ -0,0 +1,218 @@ +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'); +}); + +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(); + + 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/BannerRelationManagerTest.php b/tests/Feature/BannerRelationManagerTest.php new file mode 100644 index 0000000..693288e --- /dev/null +++ b/tests/Feature/BannerRelationManagerTest.php @@ -0,0 +1,347 @@ +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'); +}); + +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/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/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/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, diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index 5c21824..e13e824 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,14 +12,12 @@ class WorkbenchServiceProvider extends ServiceProvider */ public function register(): void { + $this->app->register(CommonServiceProvider::class); $this->app->register(AdminPanelProvider::class); } /** * Bootstrap services. */ - public function boot(): void - { - // - } + public function boot(): void {} }