diff --git a/database/migrations/2025_08_23_221543_create_pim_group_table.php b/database/migrations/2025_08_23_221543_create_pim_group_table.php new file mode 100644 index 0000000..b3a092c --- /dev/null +++ b/database/migrations/2025_08_23_221543_create_pim_group_table.php @@ -0,0 +1,44 @@ +id(); + + // Add foreign key for tenant if it's configured in the catalogue config + if (config('eclipse-catalogue.tenancy.model')) { + $tenantClass = config('eclipse-catalogue.tenancy.model'); + /** @var \Illuminate\Database\Eloquent\Model $tenant */ + $tenant = new $tenantClass; + $table->foreignId(config('eclipse-catalogue.tenancy.foreign_key')) + ->constrained($tenant->getTable(), $tenant->getKeyName()) + ->cascadeOnUpdate() + ->cascadeOnDelete(); + } + + $table->string('code', 50)->nullable(); + $table->string('name', 100); + $table->boolean('is_active')->default(true); + $table->boolean('is_browsable')->default(false); + $table->timestamps(); + + // Create unique index on code + if (config('eclipse-catalogue.tenancy.foreign_key')) { + $table->unique([config('eclipse-catalogue.tenancy.foreign_key'), 'code']); + } else { + $table->unique('code'); + } + }); + } + + public function down(): void + { + Schema::dropIfExists('pim_group'); + } +}; diff --git a/database/migrations/2025_08_23_223125_create_pim_group_has_product_table.php b/database/migrations/2025_08_23_223125_create_pim_group_has_product_table.php new file mode 100644 index 0000000..4adf303 --- /dev/null +++ b/database/migrations/2025_08_23_223125_create_pim_group_has_product_table.php @@ -0,0 +1,32 @@ +foreignId('product_id') + ->constrained('catalogue_products', 'id') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + + $table->foreignId('group_id') + ->constrained('pim_group', 'id') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + + $table->integer('sort')->nullable(); + + $table->primary(['product_id', 'group_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pim_group_has_product'); + } +}; diff --git a/database/seeders/CatalogueSeeder.php b/database/seeders/CatalogueSeeder.php index f7a0128..8bd6d3b 100644 --- a/database/seeders/CatalogueSeeder.php +++ b/database/seeders/CatalogueSeeder.php @@ -13,6 +13,7 @@ public function run(): void { $this->call(CategorySeeder::class); $this->call(ProductTypeSeeder::class); + $this->call(GroupSeeder::class); $this->call(PropertySeeder::class); $this->call(ProductSeeder::class); } diff --git a/database/seeders/GroupSeeder.php b/database/seeders/GroupSeeder.php new file mode 100644 index 0000000..3f99ba4 --- /dev/null +++ b/database/seeders/GroupSeeder.php @@ -0,0 +1,49 @@ + 'featured', 'name' => 'Featured Products', 'is_browsable' => true], + ['code' => 'new-arrivals', 'name' => 'New Arrivals', 'is_browsable' => true], + ['code' => 'best-sellers', 'name' => 'Best Sellers', 'is_browsable' => true], + ['code' => 'sale', 'name' => 'Sale Items', 'is_browsable' => false], + ['code' => 'trending', 'name' => 'Trending Now', 'is_browsable' => true], + ['code' => 'staff-picks', 'name' => 'Staff Picks', 'is_browsable' => true], + ['code' => 'seasonal', 'name' => 'Seasonal Collection', 'is_browsable' => true], + ['code' => 'limited-edition', 'name' => 'Limited Edition', 'is_browsable' => true], + ['code' => 'clearance', 'name' => 'Clearance Items', 'is_browsable' => false], + ['code' => 'premium', 'name' => 'Premium Selection', 'is_browsable' => true], + ['code' => 'eco-friendly', 'name' => 'Eco-Friendly', 'is_browsable' => true], + ['code' => 'gift-guide', 'name' => 'Gift Guide', 'is_browsable' => true], + ]; + + foreach ($sites as $site) { + // Randomly select 4-6 groups for each tenant + $numGroups = rand(4, 6); + $selectedGroups = collect($groupOptions)->shuffle()->take($numGroups); + + foreach ($selectedGroups as $groupData) { + Group::create([ + $tenantFK => $site->id, + 'code' => $groupData['code'], + 'name' => $groupData['name'], + 'is_active' => true, + 'is_browsable' => $groupData['is_browsable'], + ]); + } + } + } +} diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php index 0a6c89e..754a8cd 100644 --- a/database/seeders/ProductSeeder.php +++ b/database/seeders/ProductSeeder.php @@ -3,6 +3,7 @@ namespace Eclipse\Catalogue\Seeders; use Eclipse\Catalogue\Models\Category; +use Eclipse\Catalogue\Models\Group; use Eclipse\Catalogue\Models\Product; use Eclipse\Catalogue\Models\ProductData; use Eclipse\Catalogue\Models\ProductType; @@ -17,6 +18,7 @@ public function run(): void { $this->ensureSampleImagesExist(); $this->ensureProductTypesExist(); + $this->ensureGroupsExist(); $productTypes = ProductType::all(); @@ -33,7 +35,7 @@ public function run(): void $products = Product::query()->latest('id')->take(100)->get(); - foreach ($products as $product) { + foreach ($products as $index => $product) { if ($tenantFK && $tenantModel && class_exists($tenantModel)) { $tenants = $tenantModel::all(); foreach ($tenants as $tenant) { @@ -50,6 +52,14 @@ public function run(): void 'has_free_delivery' => false, 'category_id' => $categoryId, ]); + + // Get groups for this specific tenant + $tenantGroups = Group::where($tenantFK, $tenant->id)->get(); + $groupsToAdd = $this->determineGroupsForProduct($index, $tenantGroups); + + foreach ($groupsToAdd as $group) { + $group->addProduct($product); + } } } else { $categoryId = Category::query()->inRandomOrder()->value('id'); @@ -60,10 +70,34 @@ public function run(): void 'has_free_delivery' => false, 'category_id' => $categoryId, ]); + + // For non-tenant scenarios, use all groups + $groups = Group::all(); + $groupsToAdd = $this->determineGroupsForProduct($index, $groups); + foreach ($groupsToAdd as $group) { + $group->addProduct($product); + } } } } + private function determineGroupsForProduct(int $productIndex, $groups): array + { + $groupsToAdd = []; + + // Randomly assign 1-3 groups per product + $numGroupsToAdd = rand(1, min(3, $groups->count())); + + // Get random groups for this product + $randomGroups = $groups->random($numGroupsToAdd); + + foreach ($randomGroups as $group) { + $groupsToAdd[] = $group; + } + + return $groupsToAdd; + } + private function ensureSampleImagesExist(): void { Storage::disk('public')->makeDirectory('sample-products'); @@ -109,4 +143,13 @@ private function ensureProductTypesExist(): void $this->call(ProductTypeSeeder::class); } } + + private function ensureGroupsExist(): void + { + $groups = Group::all(); + + if ($groups->isEmpty()) { + $this->call(GroupSeeder::class); + } + } } diff --git a/resources/lang/en/group.php b/resources/lang/en/group.php new file mode 100644 index 0000000..8ab20ad --- /dev/null +++ b/resources/lang/en/group.php @@ -0,0 +1,19 @@ + 'Groups', + 'fields' => [ + 'code' => 'Code', + 'name' => 'Name', + 'is_active' => 'Active', + 'is_browsable' => 'Browsable', + ], + 'table' => [ + 'columns' => [ + 'products' => 'Products', + ], + 'actions' => [ + 'sort_products' => 'Sort Products', + ], + ], +]; diff --git a/resources/lang/sl/group.php b/resources/lang/sl/group.php new file mode 100644 index 0000000..fb27557 --- /dev/null +++ b/resources/lang/sl/group.php @@ -0,0 +1,19 @@ + 'Skupine', + 'fields' => [ + 'code' => 'Koda', + 'name' => 'Ime', + 'is_active' => 'Aktivna', + 'is_browsable' => 'Brskanje omogočeno', + ], + 'table' => [ + 'columns' => [ + 'products' => 'Izdelki', + ], + 'actions' => [ + 'sort_products' => 'Razvrsti izdelke', + ], + ], +]; diff --git a/src/Factories/GroupFactory.php b/src/Factories/GroupFactory.php new file mode 100644 index 0000000..8f84e3e --- /dev/null +++ b/src/Factories/GroupFactory.php @@ -0,0 +1,45 @@ + null, // Will be set when creating + 'code' => $this->faker->unique()->slug(2), + 'name' => $this->faker->words(2, true), + 'is_active' => true, + 'is_browsable' => false, + ]; + } + + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } + + public function browsable(): static + { + return $this->state(fn (array $attributes) => [ + 'is_browsable' => true, + ]); + } + + public function notBrowsable(): static + { + return $this->state(fn (array $attributes) => [ + 'is_browsable' => false, + ]); + } +} diff --git a/src/Filament/Resources/GroupResource.php b/src/Filament/Resources/GroupResource.php new file mode 100644 index 0000000..4f53585 --- /dev/null +++ b/src/Filament/Resources/GroupResource.php @@ -0,0 +1,194 @@ +schema([ + Section::make('Group Information') + ->schema([ + TextInput::make('name') + ->required() + ->maxLength(100), + + TextInput::make('code') + ->required() + ->maxLength(50) + ->unique(ignoreRecord: true, modifyRuleUsing: function ($rule) { + $currentTenant = \Filament\Facades\Filament::getTenant(); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id'); + if ($currentTenant) { + return $rule->where($tenantFK, $currentTenant->id); + } + + return $rule; + }) + ->helperText('Unique code for this group within the current tenant'), + + Toggle::make('is_active') + ->label('Active') + ->default(true) + ->helperText('Whether this group is active'), + + Toggle::make('is_browsable') + ->label('Browsable') + ->default(false) + ->helperText('Whether this group can be browsed in the frontend'), + ]) + ->columns(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->searchable() + ->sortable(), + + TextColumn::make('code') + ->searchable() + ->sortable(), + + TextColumn::make('products_count') + ->label('Products') + ->getStateUsing(fn (Group $record) => $record->products_count) + ->sortable(false), + + IconColumn::make('is_active') + ->label('Active') + ->boolean() + ->sortable(), + + IconColumn::make('is_browsable') + ->label('Browsable') + ->boolean() + ->sortable(), + + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + TernaryFilter::make('is_active') + ->label('Active'), + TernaryFilter::make('is_browsable') + ->label('Browsable'), + ]) + ->actions([ + ActionGroup::make([ + EditAction::make(), + DeleteAction::make(), + ]) + ->hiddenLabel() + ->icon('heroicon-m-ellipsis-vertical') + ->size('sm') + ->color('gray') + ->button(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListGroups::route('/'), + 'create' => Pages\CreateGroup::route('/create'), + 'edit' => Pages\EditGroup::route('/{record}/edit'), + ]; + } + + public static function getRelations(): array + { + return [ + RelationGroup::make('Products', [ + \Eclipse\Catalogue\Filament\Resources\GroupResource\RelationManagers\ProductsRelationManager::class, + ]), + ]; + } + + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + $currentTenant = \Filament\Facades\Filament::getTenant(); + if ($currentTenant) { + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id'); + $query->where($tenantFK, $currentTenant->id); + } + + return $query; + } + + public static function getGloballySearchableAttributes(): array + { + return [ + 'code', + 'name', + ]; + } + + public static function getGlobalSearchResultDetails(Model $record): array + { + return array_filter([ + 'Code' => $record->code, + ]); + } + + public static function getPermissionPrefixes(): array + { + return [ + 'view_any', + 'view', + 'create', + 'update', + 'delete', + 'delete_any', + ]; + } +} diff --git a/src/Filament/Resources/GroupResource/Pages/CreateGroup.php b/src/Filament/Resources/GroupResource/Pages/CreateGroup.php new file mode 100644 index 0000000..fb8e769 --- /dev/null +++ b/src/Filament/Resources/GroupResource/Pages/CreateGroup.php @@ -0,0 +1,21 @@ +id; + } + + return $data; + } +} diff --git a/src/Filament/Resources/GroupResource/Pages/EditGroup.php b/src/Filament/Resources/GroupResource/Pages/EditGroup.php new file mode 100644 index 0000000..abe7938 --- /dev/null +++ b/src/Filament/Resources/GroupResource/Pages/EditGroup.php @@ -0,0 +1,19 @@ +recordTitleAttribute('name') + ->columns([ + TextColumn::make('name') + ->label('Product') + ->searchable(), + TextColumn::make('code') + ->label('Code') + ->copyable() + ->toggleable(), + ]) + ->defaultSort('pim_group_has_product.sort') + ->reorderable('pim_group_has_product.sort') + ->reorderRecordsTriggerAction( + fn (Tables\Actions\Action $action, bool $isReordering) => $action + ->button() + ->label($isReordering ? 'Disable reordering' : 'Enable reordering') + ->icon($isReordering ? 'heroicon-o-x-mark' : 'heroicon-o-arrows-up-down') + ->color($isReordering ? 'danger' : 'primary') + ) + ->paginated(false) + ->headerActions([ + Tables\Actions\Action::make('add_product') + ->label('Add product') + ->icon('heroicon-o-plus') + ->modalHeading('Add Product to Group') + ->modalSubmitActionLabel('Add Product') + ->modalCancelActionLabel('Cancel') + ->form([ + \Filament\Forms\Components\Select::make('product_id') + ->label('Select product') + ->options(function () { + $group = $this->getOwnerRecord(); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + + $query = \Eclipse\Catalogue\Models\Product::query(); + + if ($currentTenant) { + $query->whereHas('productData', function ($q) use ($tenantFK, $currentTenant) { + $q->where($tenantFK, $currentTenant->id); + }); + } + + // Exclude products already in this group + $existingProductIds = $group->products()->pluck('catalogue_products.id')->toArray(); + $query->whereNotIn('catalogue_products.id', $existingProductIds); + + return $query->pluck('name', 'id')->toArray(); + }) + ->searchable() + ->required(), + ]) + ->action(function (array $data) { + $group = $this->getOwnerRecord(); + $product = \Eclipse\Catalogue\Models\Product::find($data['product_id']); + + if ($product && ! $group->hasProduct($product)) { + $group->addProduct($product); + + \Filament\Notifications\Notification::make() + ->title('Product added to group') + ->success() + ->send(); + } + }), + ]) + ->actions([ + Tables\Actions\Action::make('edit_product') + ->label('Edit') + ->icon('heroicon-o-pencil') + ->url(fn ($record): string => ProductResource::getUrl('edit', ['record' => $record->id])) + ->openUrlInNewTab(), + + Tables\Actions\Action::make('move_product') + ->label('Reorder') + ->icon('heroicon-o-arrows-up-down') + ->modalHeading(fn ($record) => 'Reorder: '.$record->name) + ->modalSubmitActionLabel('Move Product') + ->modalCancelActionLabel('Cancel') + ->form(function ($record, $livewire) { + return [ + Forms\Components\Placeholder::make('moving_info') + ->label('You are moving') + ->content($record->name), + + Forms\Components\Select::make('reference_id') + ->label('Place relative to') + ->options( + $livewire->getOwnerRecord() + ->products() + ->pluck('name', 'id') + ->except($record->id) + ) + ->searchable() + ->reactive() + ->required(), + + Forms\Components\Radio::make('position') + ->label('Position') + ->options([ + 'before' => 'Before selected product', + 'after' => 'After selected product', + ]) + ->default('before') + ->inline() + ->reactive(), + + Forms\Components\Placeholder::make('preview') + ->label('Result') + ->content(function (callable $get, $livewire) use ($record) { + $refId = $get('reference_id'); + $position = $get('position') ?? 'before'; + + if (! $refId) { + return 'Select a product to see the result.'; + } + + $refName = $livewire->getOwnerRecord() + ->products() + ->where('catalogue_products.id', $refId) + ->value('name'); + + if (! $refName) { + return 'Select a valid product.'; + } + + return sprintf( + '%s will be moved %s %s', + $record->name, + $position, + $refName + ); + }), + ]; + }) + ->action(function ($record, array $data) { + $group = $this->getOwnerRecord(); + + $reference = $group->products() + ->where('catalogue_products.id', $data['reference_id']) + ->firstOrFail(); + + if ($data['position'] === 'before') { + $previous = $group->products() + ->wherePivot('sort', '<', $reference->pivot->sort) + ->orderByDesc('pim_group_has_product.sort') + ->first(); + + $newSort = $previous + ? intdiv($previous->pivot->sort + $reference->pivot->sort, 2) + : $reference->pivot->sort - 1000; + } else { + $next = $group->products() + ->wherePivot('sort', '>', $reference->pivot->sort) + ->orderBy('pim_group_has_product.sort') + ->first(); + + $newSort = $next + ? intdiv($reference->pivot->sort + $next->pivot->sort, 2) + : $reference->pivot->sort + 1000; + } + + $group->updateProductSort($record, $newSort); + }), + + Tables\Actions\Action::make('remove_product') + ->label('Remove') + ->icon('heroicon-o-x-mark') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Remove Product from Group') + ->modalDescription('Are you sure you want to remove this product from the group?') + ->modalSubmitActionLabel('Remove Product') + ->action(function ($record) { + $group = $this->getOwnerRecord(); + $group->removeProduct($record); + + \Filament\Notifications\Notification::make() + ->title('Product removed from group') + ->success() + ->send(); + }), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\BulkAction::make('remove_products') + ->label('Remove from Group') + ->icon('heroicon-o-x-mark') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Remove Products from Group') + ->modalDescription('Are you sure you want to remove the selected products from this group?') + ->modalSubmitActionLabel('Remove Products') + ->action(function ($records) { + $group = $this->getOwnerRecord(); + $removedCount = 0; + + foreach ($records as $record) { + if ($group->hasProduct($record)) { + $group->removeProduct($record); + $removedCount++; + } + } + + \Filament\Notifications\Notification::make() + ->title("Removed {$removedCount} products from group") + ->success() + ->send(); + }), + ]), + ]); + } +} diff --git a/src/Filament/Resources/ProductResource.php b/src/Filament/Resources/ProductResource.php index 56b1d9a..4484092 100644 --- a/src/Filament/Resources/ProductResource.php +++ b/src/Filament/Resources/ProductResource.php @@ -7,6 +7,7 @@ use Eclipse\Catalogue\Filament\Resources\ProductResource\Pages; use Eclipse\Catalogue\Forms\Components\GenericTenantFieldsComponent; use Eclipse\Catalogue\Models\Category; +use Eclipse\Catalogue\Models\Group; use Eclipse\Catalogue\Models\Product; use Eclipse\Catalogue\Models\Property; use Eclipse\Catalogue\Traits\HandlesTenantData; @@ -23,9 +24,11 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Forms\Get; +use Filament\Notifications\Notification; use Filament\Resources\Concerns\Translatable; use Filament\Resources\Resource; use Filament\Tables\Actions\ActionGroup; +use Filament\Tables\Actions\BulkAction; use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteBulkAction; @@ -171,6 +174,20 @@ public static function form(Form $form): Form ->searchable() ->preload() ->placeholder(__('eclipse-catalogue::product.placeholders.category_id')), + Select::make("tenant_data.{$tenantId}.groups") + ->label('Groups') + ->multiple() + ->options(function () use ($tenantId) { + return Group::query() + ->where(config('eclipse-catalogue.tenancy.foreign_key', 'site_id'), $tenantId) + ->where('is_active', true) + ->orderBy('name') + ->pluck('name', 'id') + ->toArray(); + }) + ->searchable() + ->preload() + ->helperText('Select groups for this tenant'), TextInput::make("tenant_data.{$tenantId}.sorting_label") ->label(__('eclipse-catalogue::product.fields.sorting_label')) ->maxLength(255), @@ -444,6 +461,26 @@ public static function table(Table $table): Table TextColumn::make('type.name') ->label(__('eclipse-catalogue::product.table.columns.type')), + TextColumn::make('groups.name') + ->label('Groups') + ->badge() + ->separator(',') + ->limit(3) + ->toggleable() + ->getStateUsing(function (Product $record) { + $currentTenant = \Filament\Facades\Filament::getTenant(); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id'); + + if ($currentTenant) { + return $record->groups() + ->where($tenantFK, $currentTenant->id) + ->pluck('name') + ->toArray(); + } + + return $record->groups->pluck('name')->toArray(); + }), + IconColumn::make('is_active') ->label(__('eclipse-catalogue::product.table.columns.is_active')) ->boolean(), @@ -518,6 +555,19 @@ public static function table(Table $table): Table ->label(__('eclipse-catalogue::product.fields.origin_country_id')) ->multiple() ->options(fn () => Country::query()->orderBy('name')->pluck('name', 'id')->toArray()), + SelectFilter::make('groups') + ->label('Groups') + ->multiple() + ->relationship('groups', 'name', function ($query) { + $currentTenant = \Filament\Facades\Filament::getTenant(); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id'); + if ($currentTenant) { + return $query->where($tenantFK, $currentTenant->id) + ->where('is_active', true); + } + + return $query->where('is_active', true); + }), TernaryFilter::make('is_active') ->label(__('eclipse-catalogue::product.table.columns.is_active')) ->queries( @@ -560,6 +610,58 @@ public static function table(Table $table): Table ]) ->bulkActions([ BulkActionGroup::make([ + BulkAction::make('add_to_group') + ->label('Add to Group') + ->icon('heroicon-o-plus') + ->form([ + Select::make('group_id') + ->label('Group') + ->options(fn () => Group::query()->active()->forCurrentTenant()->pluck('name', 'id')->toArray()) + ->required() + ->searchable(), + ]) + ->action(function (array $data, $records) { + $group = Group::find($data['group_id']); + $addedCount = 0; + + foreach ($records as $product) { + if (! $group->hasProduct($product)) { + $group->addProduct($product); + $addedCount++; + } + } + + Notification::make() + ->title("Added {$addedCount} products to group \"{$group->name}\"") + ->success() + ->send(); + }), + BulkAction::make('remove_from_group') + ->label('Remove from Group') + ->icon('heroicon-o-minus') + ->form([ + Select::make('group_id') + ->label('Group') + ->options(fn () => Group::query()->active()->forCurrentTenant()->pluck('name', 'id')->toArray()) + ->required() + ->searchable(), + ]) + ->action(function (array $data, $records) { + $group = Group::find($data['group_id']); + $removedCount = 0; + + foreach ($records as $product) { + if ($group->hasProduct($product)) { + $group->removeProduct($product); + $removedCount++; + } + } + + Notification::make() + ->title("Removed {$removedCount} products from group \"{$group->name}\"") + ->success() + ->send(); + }), DeleteBulkAction::make(), RestoreBulkAction::make(), ForceDeleteBulkAction::make(), diff --git a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php index 0e7ef42..96a1fdb 100644 --- a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php @@ -38,31 +38,6 @@ protected function mutateFormDataBeforeCreate(array $data): array return $data; } - protected function afterCreate(): void - { - if ($this->record) { - $state = $this->form->getRawState(); - $propertyData = []; - foreach ($state as $key => $value) { - if (is_string($key) && str_starts_with($key, 'property_values_')) { - $propertyId = str_replace('property_values_', '', $key); - $propertyData[$propertyId] = $value; - } - } - - foreach ($propertyData as $propertyId => $values) { - if ($values) { - $valuesToAttach = is_array($values) ? $values : [$values]; - $valuesToAttach = array_filter($valuesToAttach); - - if (! empty($valuesToAttach)) { - $this->record->propertyValues()->attach($valuesToAttach); - } - } - } - } - } - protected function getFormTenantFlags(): array { return ['is_active', 'has_free_delivery']; @@ -86,6 +61,73 @@ protected function handleRecordCreation(array $data): Model return Product::createWithTenantData($productData, $tenantData); } + protected function afterCreate(): void + { + /** @var Product $product */ + $product = $this->record; + + if (! $product) { + return; + } + + $state = $this->form->getState(); + $rawState = $this->form->getRawState(); + $tenantData = $state['tenant_data'] ?? []; + + // Handle property values + $propertyData = []; + foreach ($rawState as $key => $value) { + if (is_string($key) && str_starts_with($key, 'property_values_')) { + $propertyId = str_replace('property_values_', '', $key); + $propertyData[$propertyId] = $value; + } + } + + foreach ($propertyData as $propertyId => $values) { + if ($values) { + $valuesToAttach = is_array($values) ? $values : [$values]; + $valuesToAttach = array_filter($valuesToAttach); + + if (! empty($valuesToAttach)) { + $product->propertyValues()->attach($valuesToAttach); + } + } + } + + // Handle tenant/group associations + $isTenancyEnabled = (bool) config('eclipse-catalogue.tenancy.model'); + if ($isTenancyEnabled) { + foreach ($tenantData as $tenantId => $data) { + $groupIds = array_filter(array_map('intval', (array) ($data['groups'] ?? []))); + foreach ($groupIds as $groupId) { + $group = \Eclipse\Catalogue\Models\Group::find($groupId); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id'); + if ($group && (int) $group->getAttribute($tenantFK) === (int) $tenantId) { + $group->addProduct($product); + } + } + } + } else { + $flatGroupIds = []; + if (isset($state['groups'])) { + $flatGroupIds = array_filter(array_map('intval', (array) $state['groups'])); + } else { + foreach ($tenantData as $data) { + foreach ((array) ($data['groups'] ?? []) as $id) { + $flatGroupIds[] = (int) $id; + } + } + $flatGroupIds = array_values(array_unique(array_filter($flatGroupIds))); + } + + foreach ($flatGroupIds as $groupId) { + if ($group = \Eclipse\Catalogue\Models\Group::find($groupId)) { + $group->addProduct($product); + } + } + } + } + protected function getFormActions(): array { return [ diff --git a/src/Filament/Resources/ProductResource/Pages/EditProduct.php b/src/Filament/Resources/ProductResource/Pages/EditProduct.php index d38890c..576aeb0 100644 --- a/src/Filament/Resources/ProductResource/Pages/EditProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/EditProduct.php @@ -75,6 +75,8 @@ protected function mutateFormDataBeforeFill(array $data): array $data['category_id'] = $recordData->category_id ?? null; } + $data['groups'] = $this->record->groups()->pluck('pim_group.id')->toArray(); + return $data; } @@ -89,6 +91,10 @@ protected function mutateFormDataBeforeFill(array $data): array 'available_from_date' => $tenantRecord->available_from_date, 'sorting_label' => $tenantRecord->sorting_label, 'category_id' => $tenantRecord->category_id ?? null, + 'groups' => $this->record->groups() + ->where('pim_group.'.config('eclipse-catalogue.tenancy.foreign_key', 'site_id'), $tenantId) + ->pluck('pim_group.id') + ->toArray(), ]; } @@ -180,6 +186,38 @@ protected function handleRecordUpdate(Model $record, array $data): Model $record->updateWithTenantData($mainData, $tenantData); + // Sync groups via Group model methods (weak pivot handling) using per-tenant selections + $state = $this->form->getState(); + + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + if ($tenantFK) { + $desiredGroupIds = collect($tenantData) + ->flatMap(fn ($td) => array_map('intval', (array) ($td['groups'] ?? []))) + ->unique() + ->values() + ->toArray(); + } else { + $desiredGroupIds = array_values(array_unique(array_map('intval', (array) ($state['groups'] ?? [])))); + } + + $currentGroupIds = $record->groups()->pluck('pim_group.id')->map(fn ($id) => (int) $id)->toArray(); + $toAttach = array_values(array_diff($desiredGroupIds, $currentGroupIds)); + $toDetach = array_values(array_diff($currentGroupIds, $desiredGroupIds)); + + foreach ($toAttach as $groupId) { + $group = \Eclipse\Catalogue\Models\Group::find($groupId); + if ($group) { + $group->addProduct($record); + } + } + + foreach ($toDetach as $groupId) { + $group = \Eclipse\Catalogue\Models\Group::find($groupId); + if ($group) { + $group->removeProduct($record); + } + } + return $record; } diff --git a/src/Livewire/TenantSwitcher.php b/src/Livewire/TenantSwitcher.php index c8f7fb1..c8ae44a 100644 --- a/src/Livewire/TenantSwitcher.php +++ b/src/Livewire/TenantSwitcher.php @@ -70,16 +70,20 @@ public static function make(string $fieldName = 'selected_tenant'): Component ->default($currentTenant?->id) ->selectablePlaceholder(false) ->live() + // Ensure we have a previous-tenant tracker from the start + ->afterStateHydrated(function ($state, callable $set) { + $set('_previous_tenant', $state); + }) ->afterStateUpdated(function ($state, callable $set, callable $get, $livewire) { - // Get previous tenant from a tracking field + // Snapshot the full sub-state of the tenant we're leaving $previousTenant = $get('_previous_tenant'); + $fromTenant = $previousTenant ?: $get('selected_tenant'); - // Store current tenant data before switching - if ($previousTenant && $previousTenant != $state) { - $currentData = $get("tenant_data.{$previousTenant}") ?? []; + if ($fromTenant && $fromTenant != $state) { + $currentData = $get("tenant_data.{$fromTenant}") ?? []; $allTenantData = $get('all_tenant_data') ?? []; - $allTenantData[$previousTenant] = $currentData; + $allTenantData[$fromTenant] = $currentData; $set('all_tenant_data', $allTenantData); } @@ -127,7 +131,21 @@ public static function makeWithOptions( ->default($currentTenant?->id) ->selectablePlaceholder(false) ->live() + ->afterStateHydrated(function ($state, callable $set) { + $set('_previous_tenant', $state); + }) ->afterStateUpdated(function ($state, callable $set, callable $get, $livewire) { + $previousTenant = $get('_previous_tenant'); + $fromTenant = $previousTenant ?: $get('selected_tenant'); + + if ($fromTenant && $fromTenant != $state) { + $currentData = $get("tenant_data.{$fromTenant}") ?? []; + $allTenantData = $get('all_tenant_data') ?? []; + $allTenantData[$fromTenant] = $currentData; + $set('all_tenant_data', $allTenantData); + } + + $set('_previous_tenant', $state); $livewire->dispatch('tenant-changed', $state); }); diff --git a/src/Models/Group.php b/src/Models/Group.php new file mode 100644 index 0000000..bfd1971 --- /dev/null +++ b/src/Models/Group.php @@ -0,0 +1,175 @@ + 'boolean', + 'is_browsable' => 'boolean', + ]; + + /** + * Scope: only active groups. + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * Scope: restrict by current tenant if tenancy is enabled and a tenant is selected. + */ + public function scopeForCurrentTenant(Builder $query): Builder + { + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenant = \Filament\Facades\Filament::getTenant(); + + if ($tenantFK && $tenant) { + $query->where($tenantFK, $tenant->id); + } + + return $query; + } + + /** + * Include the tenant foreign key in fillable when tenancy is on. + */ + public function getFillable(): array + { + $fillable = $this->fillable; + + if (config('eclipse-catalogue.tenancy.foreign_key')) { + $fillable[] = config('eclipse-catalogue.tenancy.foreign_key'); + } + + return $fillable; + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'pim_group_has_product', 'group_id', 'product_id') + ->withPivot('sort') + ->orderBy('pim_group_has_product.sort'); + } + + /** + * Add a product to this group with optional sort order + */ + public function addProduct(Product $product, ?int $sort = null): void + { + if ($this->hasProduct($product)) { + return; + } + + if ($sort === null) { + $sort = $this->getNextSortOrder(); + } + + DB::table('pim_group_has_product')->insert([ + 'group_id' => $this->id, + 'product_id' => $product->id, + 'sort' => $sort, + ]); + } + + /** + * Remove a product from this group + */ + public function removeProduct(Product $product): void + { + DB::table('pim_group_has_product') + ->where('group_id', $this->id) + ->where('product_id', $product->id) + ->delete(); + } + + /** + * Check if this group contains a specific product + */ + public function hasProduct(Product $product): bool + { + return DB::table('pim_group_has_product') + ->where('group_id', $this->id) + ->where('product_id', $product->id) + ->exists(); + } + + /** + * Update the sort order for a product in this group + */ + public function updateProductSort(Product $product, int $sort): void + { + DB::table('pim_group_has_product') + ->where('group_id', $this->id) + ->where('product_id', $product->id) + ->update(['sort' => $sort]); + } + + /** + * Get the next available sort order for this group + */ + public function getNextSortOrder(): int + { + $maxSort = DB::table('pim_group_has_product') + ->where('group_id', $this->id) + ->max('sort'); + + return ($maxSort ?? 0) + 1; + } + + /** + * Reorder products in this group based on an array of product IDs + */ + public function reorderProducts(array $productIds): void + { + foreach ($productIds as $index => $productId) { + DB::table('pim_group_has_product') + ->where('group_id', $this->id) + ->where('product_id', $productId) + ->update(['sort' => $index + 1]); + } + } + + /** + * Get products count for this group + */ + public function getProductsCountAttribute(): int + { + return DB::table('pim_group_has_product') + ->where('group_id', $this->id) + ->count(); + } + + protected static function newFactory(): GroupFactory + { + return GroupFactory::new(); + } + + /** @return BelongsTo */ + public function site(): BelongsTo + { + $tenantModel = config('eclipse-catalogue.tenancy.model'); + + return $this->belongsTo($tenantModel); + } +} diff --git a/src/Models/Product.php b/src/Models/Product.php index 45b9fb1..04bd228 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -108,6 +108,12 @@ public function productData(): HasMany return $this->hasMany(ProductData::class, 'product_id'); } + public function groups(): BelongsToMany + { + return $this->belongsToMany(Group::class, 'pim_group_has_product', 'product_id', 'group_id') + ->withPivot('sort'); + } + public function getIsActiveAttribute(): bool { return $this->getTenantFlagValue('is_active'); diff --git a/src/Policies/GroupPolicy.php b/src/Policies/GroupPolicy.php new file mode 100644 index 0000000..6fce310 --- /dev/null +++ b/src/Policies/GroupPolicy.php @@ -0,0 +1,62 @@ +can('view_any_group'); + } + + public function view(Authorizable $user, Group $group): bool + { + return $user->can('view_group'); + } + + public function create(Authorizable $user): bool + { + return $user->can('create_group'); + } + + public function update(Authorizable $user, Group $group): bool + { + return $user->can('update_group'); + } + + public function delete(Authorizable $user, Group $group): bool + { + return $user->can('delete_group'); + } + + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_group'); + } + + public function forceDelete(Authorizable $user, Group $group): bool + { + return $user->can('force_delete_group'); + } + + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_group'); + } + + public function restore(Authorizable $user, Group $group): bool + { + return $user->can('restore_group'); + } + + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_group'); + } +} diff --git a/src/Traits/HandlesTenantData.php b/src/Traits/HandlesTenantData.php index f7d2b25..df5be49 100644 --- a/src/Traits/HandlesTenantData.php +++ b/src/Traits/HandlesTenantData.php @@ -42,12 +42,7 @@ protected function storeCurrentTenantData(): void $selectedTenant = $formData['selected_tenant'] ?? null; if ($selectedTenant && config('eclipse-catalogue.tenancy.foreign_key')) { - $currentData = []; - - // Build current data from tenant flags - foreach ($this->getFormTenantFlags() as $flag) { - $currentData[$flag] = $formData['tenant_data'][$selectedTenant][$flag] ?? $this->getDefaultValueForFlag($flag); - } + $currentData = $formData['tenant_data'][$selectedTenant] ?? []; $allTenantData = $formData['all_tenant_data'] ?? []; $allTenantData[$selectedTenant] = $currentData; diff --git a/tests/Feature/GroupBulkActionsTest.php b/tests/Feature/GroupBulkActionsTest.php new file mode 100644 index 0000000..d100ff5 --- /dev/null +++ b/tests/Feature/GroupBulkActionsTest.php @@ -0,0 +1,151 @@ +migrate(); +}); + +it('can bulk add products to a group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $products = [ + Product::create(['name' => 'Product 1', 'code' => 'TEST-001']), + Product::create(['name' => 'Product 2', 'code' => 'TEST-002']), + Product::create(['name' => 'Product 3', 'code' => 'TEST-003']), + ]; + + // Bulk add products + foreach ($products as $product) { + $group->addProduct($product); + } + + expect($group->products)->toHaveCount(3); + expect($group->products_count)->toBe(3); + + foreach ($products as $product) { + expect($group->hasProduct($product))->toBeTrue(); + } +}); + +it('can bulk remove products from a group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $products = [ + Product::create(['name' => 'Product 1', 'code' => 'TEST-001']), + Product::create(['name' => 'Product 2', 'code' => 'TEST-002']), + Product::create(['name' => 'Product 3', 'code' => 'TEST-003']), + ]; + + // Add products first + foreach ($products as $product) { + $group->addProduct($product); + } + + expect($group->products)->toHaveCount(3); + + // Bulk remove products + foreach ($products as $product) { + $group->removeProduct($product); + } + + // Refresh the group to get updated products count + $group->refresh(); + expect($group->products)->toHaveCount(0); + expect($group->products_count)->toBe(0); + + foreach ($products as $product) { + expect($group->hasProduct($product))->toBeFalse(); + } +}); + +it('can bulk add products with custom sort order', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $products = [ + Product::create(['name' => 'Product 1', 'code' => 'TEST-001']), + Product::create(['name' => 'Product 2', 'code' => 'TEST-002']), + Product::create(['name' => 'Product 3', 'code' => 'TEST-003']), + ]; + + // Add products with custom sort order + $group->addProduct($products[0], 10); + $group->addProduct($products[1], 20); + $group->addProduct($products[2], 30); + + $groupProducts = $group->products()->get(); + + expect($groupProducts)->toHaveCount(3); + expect($groupProducts[0]->pivot->sort)->toBe(10); + expect($groupProducts[1]->pivot->sort)->toBe(20); + expect($groupProducts[2]->pivot->sort)->toBe(30); +}); + +it('can handle bulk operations on empty group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $product = Product::create(['name' => 'Product 1', 'code' => 'TEST-001']); + + expect($group->products)->toHaveCount(0); + expect($group->products_count)->toBe(0); + + // Add product to empty group + $group->addProduct($product); + + // Refresh to get updated products + $group->refresh(); + expect($group->products)->toHaveCount(1); + expect($group->hasProduct($product))->toBeTrue(); + + // Remove product from group + $group->removeProduct($product); + + // Refresh to get updated products + $group->refresh(); + expect($group->products)->toHaveCount(0); + expect($group->hasProduct($product))->toBeFalse(); +}); + +it('can get products count attribute', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + expect($group->products_count)->toBe(0); + + $product1 = Product::create(['name' => 'Product 1', 'code' => 'TEST-001']); + $product2 = Product::create(['name' => 'Product 2', 'code' => 'TEST-002']); + + $group->addProduct($product1); + expect($group->products_count)->toBe(1); + + $group->addProduct($product2); + expect($group->products_count)->toBe(2); + + $group->removeProduct($product1); + expect($group->products_count)->toBe(1); +}); diff --git a/tests/Feature/GroupCodeUniquenessTest.php b/tests/Feature/GroupCodeUniquenessTest.php new file mode 100644 index 0000000..a6af3b4 --- /dev/null +++ b/tests/Feature/GroupCodeUniquenessTest.php @@ -0,0 +1,85 @@ +migrate(); +}); + +it('allows same code across different sites', function () { + // Create groups with same code on different sites + $group1 = Group::create([ + 'site_id' => 1, + 'code' => 'same-code', + 'name' => 'Group 1', + 'is_active' => true, + ]); + + $group2 = Group::create([ + 'site_id' => 2, + 'code' => 'same-code', + 'name' => 'Group 2', + 'is_active' => true, + ]); + + expect($group1->code)->toBe('same-code'); + expect($group2->code)->toBe('same-code'); + expect($group1->site_id)->toBe(1); + expect($group2->site_id)->toBe(2); + + $this->assertDatabaseHas('pim_group', [ + 'id' => $group1->id, + 'site_id' => 1, + 'code' => 'same-code', + ]); + + $this->assertDatabaseHas('pim_group', [ + 'id' => $group2->id, + 'site_id' => 2, + 'code' => 'same-code', + ]); +}); + +it('prevents duplicate code within the same site', function () { + // Create first group + Group::create([ + 'site_id' => 1, + 'code' => 'unique-code', + 'name' => 'First Group', + 'is_active' => true, + ]); + + // Attempt to create second group with same code on same site + expect(fn () => Group::create([ + 'site_id' => 1, + 'code' => 'unique-code', + 'name' => 'Second Group', + 'is_active' => true, + ]))->toThrow(\Illuminate\Database\QueryException::class); +}); + +it('allows updating group with same code on same site', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'original-code', + 'name' => 'Original Name', + 'is_active' => true, + ]); + + // Update the same group - should work + $group->update([ + 'name' => 'Updated Name', + 'is_active' => false, + ]); + + expect($group->code)->toBe('original-code'); + expect($group->name)->toBe('Updated Name'); + expect($group->is_active)->toBeFalse(); + + $this->assertDatabaseHas('pim_group', [ + 'id' => $group->id, + 'code' => 'original-code', + 'name' => 'Updated Name', + 'is_active' => false, + ]); +}); diff --git a/tests/Feature/GroupCrudTest.php b/tests/Feature/GroupCrudTest.php new file mode 100644 index 0000000..4c19f74 --- /dev/null +++ b/tests/Feature/GroupCrudTest.php @@ -0,0 +1,89 @@ +migrate(); +}); + +it('can create a group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + 'is_browsable' => false, + ]); + + expect($group)->toBeInstanceOf(Group::class); + expect($group->code)->toBe('test-group'); + expect($group->name)->toBe('Test Group'); + expect($group->is_active)->toBeTrue(); + expect($group->is_browsable)->toBeFalse(); + + $this->assertDatabaseHas('pim_group', [ + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + 'is_browsable' => false, + ]); +}); + +it('can update a group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + 'is_browsable' => false, + ]); + + $group->update([ + 'name' => 'Updated Test Group', + 'is_active' => false, + 'is_browsable' => true, + ]); + + expect($group->name)->toBe('Updated Test Group'); + expect($group->is_active)->toBeFalse(); + expect($group->is_browsable)->toBeTrue(); + + $this->assertDatabaseHas('pim_group', [ + 'id' => $group->id, + 'name' => 'Updated Test Group', + 'is_active' => false, + 'is_browsable' => true, + ]); +}); + +it('can delete a group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $group->delete(); + + $this->assertDatabaseMissing('pim_group', [ + 'id' => $group->id, + ]); +}); + +it('can retrieve group with site relationship', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + // Test that the site_id is set correctly + expect($group->site_id)->toBe(1); + + // Test that the group can be retrieved from database + $retrievedGroup = Group::find($group->id); + expect($retrievedGroup)->not->toBeNull(); + expect($retrievedGroup->site_id)->toBe(1); +}); diff --git a/tests/Feature/GroupPermissionTest.php b/tests/Feature/GroupPermissionTest.php new file mode 100644 index 0000000..97a2485 --- /dev/null +++ b/tests/Feature/GroupPermissionTest.php @@ -0,0 +1,79 @@ +migrate(); +}); + +it('policy allows deletion of regular group with proper permissions', function () { + $user = User::factory()->create(); + $user->givePermissionTo('delete_group'); + + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $policy = new GroupPolicy; + $canDelete = $policy->delete($user, $group); + + expect($canDelete)->toBeTrue(); +}); + +it('policy prevents deletion without proper permissions', function () { + $user = User::factory()->create(); + + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $policy = new GroupPolicy; + $canDelete = $policy->delete($user, $group); + + expect($canDelete)->toBeFalse(); +}); + +test('unauthorized access can be prevented', function () { + // Create regular user with no permissions + $this->setUpCommonUser(); + + // Create test group + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + // View table + $this->get(GroupResource::getUrl()) + ->assertForbidden(); + + // Add direct permission to view the table, since otherwise any other action below is not available even for testing + $this->user->givePermissionTo('view_any_group'); + + // Create group + livewire(ListGroups::class) + ->assertActionDisabled('create'); + + // Edit group + livewire(ListGroups::class) + ->assertCanSeeTableRecords([$group]) + ->assertTableActionDisabled('edit', $group); + + // Delete group + livewire(ListGroups::class) + ->assertTableActionDisabled('delete', $group); +}); diff --git a/tests/Feature/GroupProductAttachDetachTest.php b/tests/Feature/GroupProductAttachDetachTest.php new file mode 100644 index 0000000..9923563 --- /dev/null +++ b/tests/Feature/GroupProductAttachDetachTest.php @@ -0,0 +1,146 @@ +migrate(); +}); + +it('can attach a product to a group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $product = Product::create([ + 'name' => 'Test Product', + 'code' => 'TEST-001', + ]); + + $group->addProduct($product); + + expect($group->hasProduct($product))->toBeTrue(); + expect($group->products)->toHaveCount(1); + expect($group->products->first()->id)->toBe($product->id); + + $this->assertDatabaseHas('pim_group_has_product', [ + 'group_id' => $group->id, + 'product_id' => $product->id, + ]); +}); + +it('can detach a product from a group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $product = Product::create([ + 'name' => 'Test Product', + 'code' => 'TEST-001', + ]); + + $group->addProduct($product); + expect($group->hasProduct($product))->toBeTrue(); + + $group->removeProduct($product); + + expect($group->hasProduct($product))->toBeFalse(); + expect($group->products)->toHaveCount(0); + + $this->assertDatabaseMissing('pim_group_has_product', [ + 'group_id' => $group->id, + 'product_id' => $product->id, + ]); +}); + +it('prevents duplicate product attachments', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $product = Product::create([ + 'name' => 'Test Product', + 'code' => 'TEST-001', + ]); + + $group->addProduct($product); + $group->addProduct($product); // Try to add same product again + + expect($group->products)->toHaveCount(1); + expect($group->hasProduct($product))->toBeTrue(); +}); + +it('can attach multiple products to a group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $product1 = Product::create([ + 'name' => 'Product 1', + 'code' => 'TEST-001', + ]); + + $product2 = Product::create([ + 'name' => 'Product 2', + 'code' => 'TEST-002', + ]); + + $product3 = Product::create([ + 'name' => 'Product 3', + 'code' => 'TEST-003', + ]); + + $group->addProduct($product1); + $group->addProduct($product2); + $group->addProduct($product3); + + expect($group->products)->toHaveCount(3); + expect($group->hasProduct($product1))->toBeTrue(); + expect($group->hasProduct($product2))->toBeTrue(); + expect($group->hasProduct($product3))->toBeTrue(); + + $this->assertDatabaseHas('pim_group_has_product', [ + 'group_id' => $group->id, + 'product_id' => $product1->id, + ]); + $this->assertDatabaseHas('pim_group_has_product', [ + 'group_id' => $group->id, + 'product_id' => $product2->id, + ]); + $this->assertDatabaseHas('pim_group_has_product', [ + 'group_id' => $group->id, + 'product_id' => $product3->id, + ]); +}); + +it('can check if group has a specific product', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $product = Product::create([ + 'name' => 'Test Product', + 'code' => 'TEST-001', + ]); + + expect($group->hasProduct($product))->toBeFalse(); + + $group->addProduct($product); + + expect($group->hasProduct($product))->toBeTrue(); +}); diff --git a/tests/Feature/GroupSortingTest.php b/tests/Feature/GroupSortingTest.php new file mode 100644 index 0000000..ed7ad41 --- /dev/null +++ b/tests/Feature/GroupSortingTest.php @@ -0,0 +1,172 @@ +migrate(); +}); + +it('can update product sort order in a group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $product = Product::create([ + 'name' => 'Test Product', + 'code' => 'TEST-001', + ]); + + $group->addProduct($product, 1); + $group->updateProductSort($product, 5); + + $groupProduct = $group->products()->first(); + expect($groupProduct->pivot->sort)->toBe(5); +}); + +it('can get next sort order for a group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + // Empty group should return 1 + expect($group->getNextSortOrder())->toBe(1); + + $product1 = Product::create(['name' => 'Product 1', 'code' => 'TEST-001']); + $product2 = Product::create(['name' => 'Product 2', 'code' => 'TEST-002']); + + $group->addProduct($product1, 10); + $group->addProduct($product2, 20); + + // Next sort order should be 21 + expect($group->getNextSortOrder())->toBe(21); +}); + +it('can reorder products in a group', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $products = [ + Product::create(['name' => 'Product 1', 'code' => 'TEST-001']), + Product::create(['name' => 'Product 2', 'code' => 'TEST-002']), + Product::create(['name' => 'Product 3', 'code' => 'TEST-003']), + ]; + + // Add products with initial sort order + $group->addProduct($products[0], 1); + $group->addProduct($products[1], 2); + $group->addProduct($products[2], 3); + + // Reorder products (reverse order) + $group->reorderProducts([$products[2]->id, $products[1]->id, $products[0]->id]); + + $groupProducts = $group->products()->get(); + + expect($groupProducts)->toHaveCount(3); + expect($groupProducts[0]->id)->toBe($products[2]->id); + expect($groupProducts[0]->pivot->sort)->toBe(1); + expect($groupProducts[1]->id)->toBe($products[1]->id); + expect($groupProducts[1]->pivot->sort)->toBe(2); + expect($groupProducts[2]->id)->toBe($products[0]->id); + expect($groupProducts[2]->pivot->sort)->toBe(3); +}); + +it('maintains sort order when adding new products', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $product1 = Product::create(['name' => 'Product 1', 'code' => 'TEST-001']); + $product2 = Product::create(['name' => 'Product 2', 'code' => 'TEST-002']); + + // Add products with specific sort order + $group->addProduct($product1, 10); + $group->addProduct($product2, 20); + + $groupProducts = $group->products()->get(); + + expect($groupProducts)->toHaveCount(2); + expect($groupProducts[0]->pivot->sort)->toBe(10); + expect($groupProducts[1]->pivot->sort)->toBe(20); + + // Add another product without specifying sort (should use next available) + $product3 = Product::create(['name' => 'Product 3', 'code' => 'TEST-003']); + $group->addProduct($product3); + + $groupProducts = $group->products()->get(); + expect($groupProducts)->toHaveCount(3); + expect($groupProducts[2]->pivot->sort)->toBe(21); // Next sort order +}); + +it('can handle reordering with gaps in sort values', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $products = [ + Product::create(['name' => 'Product 1', 'code' => 'TEST-001']), + Product::create(['name' => 'Product 2', 'code' => 'TEST-002']), + Product::create(['name' => 'Product 3', 'code' => 'TEST-003']), + ]; + + // Add products with gaps in sort order + $group->addProduct($products[0], 10); + $group->addProduct($products[1], 50); + $group->addProduct($products[2], 100); + + // Reorder products + $group->reorderProducts([$products[1]->id, $products[0]->id, $products[2]->id]); + + $groupProducts = $group->products()->get(); + + expect($groupProducts)->toHaveCount(3); + expect($groupProducts[0]->id)->toBe($products[1]->id); + expect($groupProducts[0]->pivot->sort)->toBe(1); + expect($groupProducts[1]->id)->toBe($products[0]->id); + expect($groupProducts[1]->pivot->sort)->toBe(2); + expect($groupProducts[2]->id)->toBe($products[2]->id); + expect($groupProducts[2]->pivot->sort)->toBe(3); +}); + +it('products are ordered by sort value when retrieved', function () { + $group = Group::create([ + 'site_id' => 1, + 'code' => 'test-group', + 'name' => 'Test Group', + 'is_active' => true, + ]); + + $products = [ + Product::create(['name' => 'Product 1', 'code' => 'TEST-001']), + Product::create(['name' => 'Product 2', 'code' => 'TEST-002']), + Product::create(['name' => 'Product 3', 'code' => 'TEST-003']), + ]; + + // Add products in random order + $group->addProduct($products[2], 3); + $group->addProduct($products[0], 1); + $group->addProduct($products[1], 2); + + $groupProducts = $group->products()->get(); + + expect($groupProducts)->toHaveCount(3); + expect($groupProducts[0]->id)->toBe($products[0]->id); // Sort 1 + expect($groupProducts[1]->id)->toBe($products[1]->id); // Sort 2 + expect($groupProducts[2]->id)->toBe($products[2]->id); // Sort 3 +});