diff --git a/database/migrations/2025_09_17_150136_add_grouping_columns_to_pim_property_value_table.php b/database/migrations/2025_09_17_150136_add_grouping_columns_to_pim_property_value_table.php new file mode 100644 index 0000000..1c746e7 --- /dev/null +++ b/database/migrations/2025_09_17_150136_add_grouping_columns_to_pim_property_value_table.php @@ -0,0 +1,29 @@ +boolean('is_group')->default(false)->after('image'); + + $table->foreignId('group_value_id') + ->nullable() + ->after('is_group') + ->constrained('pim_property_value') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('pim_property_value', function (Blueprint $table) { + $table->dropConstrainedForeignId('group_value_id'); + $table->dropColumn('is_group'); + }); + } +}; diff --git a/resources/lang/en/property-value.php b/resources/lang/en/property-value.php index 13a44d7..a5b7c3d 100644 --- a/resources/lang/en/property-value.php +++ b/resources/lang/en/property-value.php @@ -32,6 +32,8 @@ 'table' => [ 'columns' => [ 'value' => 'Value', + 'group' => 'Group', + 'aliases' => 'Aliases', 'image' => 'Image', 'info_url' => 'Info URL', 'sort' => 'Sort', @@ -45,6 +47,8 @@ 'edit' => 'Edit', 'delete' => 'Delete', 'merge' => 'Merge…', + 'group_aliases' => 'Group (aliases)', + 'remove_from_group' => 'Remove from group', ], ], @@ -76,6 +80,21 @@ 'merged_error_body' => 'We couldn’t merge these values. Please try again.', ], + 'grouping' => [ + 'success_grouped_title' => 'Grouped', + 'success_grouped_body' => ':count value(s) grouped under ":target".', + 'success_ungrouped_title' => 'Updated', + 'success_ungrouped_body' => ':count value(s) removed from group.', + 'error_title' => 'Grouping failed', + 'selected_values' => 'Selected values', + 'helper_target' => 'Choose the target value into which the selected values will be grouped.', + 'errors' => [ + 'target_in_sources' => 'The target cannot be one of the selected values.', + 'target_is_member' => 'The selected target already belongs to another group.', + 'different_property' => 'All selected values and the target must belong to the same property.', + ], + ], + 'pages' => [ 'title' => [ 'with_property' => 'Values for: :property', @@ -87,6 +106,14 @@ ], ], + 'ui' => [ + 'group_badge' => 'Group', + ], + + 'modal_grouping' => [ + 'target_label' => 'Target value', + ], + 'notifications' => [ 'import_queued' => [ 'title' => 'Import Queued', diff --git a/resources/lang/sl/property-value.php b/resources/lang/sl/property-value.php index 8297e80..8ad8f7a 100644 --- a/resources/lang/sl/property-value.php +++ b/resources/lang/sl/property-value.php @@ -32,6 +32,8 @@ 'table' => [ 'columns' => [ 'value' => 'Vrednost', + 'group' => 'Skupina', + 'aliases' => 'Aliasi', 'image' => 'Slika', 'info_url' => 'URL informacij', 'sort' => 'Vrstni red', @@ -45,6 +47,8 @@ 'edit' => 'Uredi', 'delete' => 'Izbriši', 'merge' => 'Združi…', + 'group_aliases' => 'Združi (aliase)', + 'remove_from_group' => 'Odstrani iz skupine', ], ], @@ -76,6 +80,21 @@ 'merged_error_body' => 'Vrednosti trenutno ni mogoče združiti. Poskusite znova.', ], + 'grouping' => [ + 'success_grouped_title' => 'Združeno', + 'success_grouped_body' => ':count vrednost(i) združene pod ":target".', + 'success_ungrouped_title' => 'Posodobljeno', + 'success_ungrouped_body' => ':count vrednost(i) odstranjene iz skupine.', + 'error_title' => 'Združevanje ni uspelo', + 'selected_values' => 'Izbrane vrednosti', + 'helper_target' => 'Izberite ciljno vrednost, v katero bodo združene izbrane vrednosti.', + 'errors' => [ + 'target_in_sources' => 'Cilj ne sme biti med izbranimi vrednostmi.', + 'target_is_member' => 'Izbrani cilj že pripada drugi skupini.', + 'different_property' => 'Vse izbrane vrednosti in cilj morajo pripadati isti lastnosti.', + ], + ], + 'pages' => [ 'title' => [ 'with_property' => 'Vrednosti za: :property', @@ -87,6 +106,14 @@ ], ], + 'ui' => [ + 'group_badge' => 'Skupina', + ], + + 'modal_grouping' => [ + 'target_label' => 'Ciljna vrednost', + ], + 'notifications' => [ 'import_queued' => [ 'title' => 'Uvoz v čakalni vrsti', diff --git a/resources/views/filament/bulk/group-selected-preview.blade.php b/resources/views/filament/bulk/group-selected-preview.blade.php new file mode 100644 index 0000000..6aaa153 --- /dev/null +++ b/resources/views/filament/bulk/group-selected-preview.blade.php @@ -0,0 +1,24 @@ +@php($items = $getState() ?? []) + +
+
+ {{ __('eclipse-catalogue::property-value.grouping.selected_values') }} +
+ +
+ + diff --git a/resources/views/filament/columns/group-and-aliases.blade.php b/resources/views/filament/columns/group-and-aliases.blade.php new file mode 100644 index 0000000..95bfeda --- /dev/null +++ b/resources/views/filament/columns/group-and-aliases.blade.php @@ -0,0 +1,18 @@ +@php($rec = $getRecord()) + +
+ @if($rec->is_group) + {{ __('eclipse-catalogue::property-value.ui.group_badge') }} + @endif + + @if($rec->group) + {{ $rec->group->value }} + @endif + + @php($aliases = $rec->members()->count()) + @if($rec->is_group) + {{ $aliases }} + @endif +
+ + diff --git a/src/Filament/Resources/PropertyValueResource.php b/src/Filament/Resources/PropertyValueResource.php index e6792ac..623992e 100644 --- a/src/Filament/Resources/PropertyValueResource.php +++ b/src/Filament/Resources/PropertyValueResource.php @@ -11,6 +11,7 @@ use Eclipse\Catalogue\Values\Background; use Filament\Actions\Action; use Filament\Actions\ActionGroup; +use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; @@ -28,14 +29,16 @@ use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Group; use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\View as SchemaView; use Filament\Schemas\Schema; use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Columns\ViewColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Log; use LaraZeus\SpatieTranslatable\Resources\Concerns\Translatable; -use Log; use Throwable; class PropertyValueResource extends Resource @@ -119,6 +122,11 @@ public static function table(Table $table): Table ->searchable() ->sortable(), + ViewColumn::make('group_and_aliases') + ->label(__('eclipse-catalogue::property-value.table.columns.group')) + ->view('eclipse-catalogue::filament.columns.group-and-aliases') + ->extraAttributes(['class' => 'space-x-1']), + TextColumn::make('color_swatch') ->label('Color') ->state(fn ($record) => $record->getColor()) @@ -254,6 +262,115 @@ public static function table(Table $table): Table ]) ->toolbarActions([ BulkActionGroup::make([ + BulkAction::make('group_values') + ->label(__('eclipse-catalogue::property-value.table.actions.group_aliases')) + ->icon('heroicon-o-rectangle-group') + ->form(function (\Filament\Actions\BulkAction $action) { + $firstRecord = $action->getSelectedRecords()->first(); + $derivedPropertyId = $firstRecord?->property_id ?? (request()->has('property') ? (int) request('property') : null); + + return [ + SchemaView::make('eclipse-catalogue::filament.bulk.group-selected-preview') + ->statePath('selected_records') + ->dehydrated(false) + ->afterStateHydrated(function ($component) use ($action) { + $records = $action->getSelectedRecordsQuery()->with(['group:id,value'])->get(); + $items = $records->map(function (PropertyValue $record) { + return [ + 'id' => $record->id, + 'value' => $record->value, + 'is_group' => (bool) $record->is_group, + 'group_value' => $record->group?->value, + ]; + })->all(); + $component->state($items); + }) + ->columnSpanFull(), + + Hidden::make('property_id') + ->default($derivedPropertyId), + + Select::make('target_id') + ->label(__('eclipse-catalogue::property-value.modal_grouping.target_label')) + ->helperText(__('eclipse-catalogue::property-value.grouping.helper_target')) + ->required() + ->options(function (Get $get) { + $query = PropertyValue::query(); + $propertyId = $get('property_id'); + if ($propertyId) { + $query->sameProperty((int) $propertyId); + } + + return $query->orderBy('value')->pluck('value', 'id'); + }) + ->searchable(), + ]; + }) + ->action(function (\Illuminate\Support\Collection $records, array $data) { + try { + if ($records->isEmpty()) { + return; + } + + $target = PropertyValue::findOrFail((int) $data['target_id']); + + $sourceIds = $records->pluck('id'); + if ($sourceIds->contains($target->id)) { + Notification::make()->title(__('eclipse-catalogue::property-value.grouping.error_title'))->body(__('eclipse-catalogue::property-value.grouping.errors.target_in_sources'))->danger()->send(); + + return; + } + + if ($target->group_value_id !== null) { + Notification::make()->title(__('eclipse-catalogue::property-value.grouping.error_title'))->body(__('eclipse-catalogue::property-value.grouping.errors.target_is_member'))->danger()->send(); + + return; + } + + $updated = 0; + foreach ($records as $record) { + /** @var PropertyValue $source */ + $source = $record instanceof PropertyValue ? $record : PropertyValue::findOrFail($record); + if ($source->property_id !== $target->property_id) { + Notification::make()->title(__('eclipse-catalogue::property-value.grouping.error_title'))->body(__('eclipse-catalogue::property-value.grouping.errors.different_property'))->danger()->send(); + + return; + } + $source->groupInto($target->id); + $updated++; + } + + Notification::make()->title(__('eclipse-catalogue::property-value.grouping.success_grouped_title')) + ->body(__('eclipse-catalogue::property-value.grouping.success_grouped_body', ['count' => $updated, 'target' => $target->value])) + ->success()->send(); + } catch (\Throwable $e) { + Log::error('Bulk group failed', ['exception' => $e]); + Notification::make()->title(__('eclipse-catalogue::property-value.grouping.error_title'))->body(__('eclipse-catalogue::property-value.messages.merged_error_body'))->danger()->send(); + } + }) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('ungroup_values') + ->label(__('eclipse-catalogue::property-value.table.actions.remove_from_group')) + ->icon('heroicon-o-squares-2x2') + ->action(function (\Illuminate\Support\Collection $records) { + try { + $updated = 0; + foreach ($records as $record) { + /** @var PropertyValue $model */ + $model = $record instanceof PropertyValue ? $record : PropertyValue::findOrFail($record); + $model->removeFromGroup(); + $updated++; + } + Notification::make()->title(__('eclipse-catalogue::property-value.grouping.success_ungrouped_title')) + ->body(__('eclipse-catalogue::property-value.grouping.success_ungrouped_body', ['count' => $updated])) + ->success()->send(); + } catch (\Throwable $e) { + Log::error('Bulk ungroup failed', ['exception' => $e]); + Notification::make()->title(__('eclipse-catalogue::property-value.grouping.error_title'))->body(__('eclipse-catalogue::property-value.messages.merged_error_body'))->danger()->send(); + } + }) + ->deselectRecordsAfterCompletion(), DeleteBulkAction::make(), ]), ]); @@ -284,11 +401,11 @@ public static function buildColorGroupSchema(): array ->default(BackgroundType::NONE->value) ->live(), ColorPicker::make('color') - ->visible(fn (Get $get) => $get('type') === 's') + ->visible(fn (\Filament\Schemas\Components\Utilities\Get $get) => $get('type') === 's') ->live(), Grid::make() ->columns(4) - ->visible(fn (Get $get) => $get('type') === 'g') + ->visible(fn (\Filament\Schemas\Components\Utilities\Get $get) => $get('type') === 'g') ->schema([ ColorPicker::make('color_start')->columnSpan(2)->live(), ColorPicker::make('color_end')->columnSpan(2)->live(), @@ -309,7 +426,7 @@ public static function buildColorGroupSchema(): array ]), ViewField::make('preview') ->view('eclipse-catalogue::components.color-preview') - ->visible(function (Get $get) { + ->visible(function (\Filament\Schemas\Components\Utilities\Get $get) { $bg = Background::fromForm([ 'type' => $get('type'), 'color' => $get('color'), @@ -321,7 +438,7 @@ public static function buildColorGroupSchema(): array return $bg->hasRenderableCss(); }) - ->viewData(function (Get $get) { + ->viewData(function (\Filament\Schemas\Components\Utilities\Get $get) { $bg = Background::fromForm([ 'type' => $get('type'), 'color' => $get('color'), @@ -365,6 +482,6 @@ public static function getEloquentQuery(): Builder $query->where('property_id', request('property')); } - return $query; + return $query->with(['group', 'members'])->groupedOrder(); } } diff --git a/src/Models/PropertyValue.php b/src/Models/PropertyValue.php index b6b1c4e..2ab1548 100644 --- a/src/Models/PropertyValue.php +++ b/src/Models/PropertyValue.php @@ -26,6 +26,8 @@ class PropertyValue extends Model implements HasMedia 'sort', 'info_url', 'image', + 'is_group', + 'group_value_id', 'color', ]; @@ -38,6 +40,8 @@ class PropertyValue extends Model implements HasMedia protected $casts = [ 'sort' => 'integer', 'property_id' => 'integer', + 'is_group' => 'boolean', + 'group_value_id' => 'integer', 'color' => BackgroundCast::class, ]; @@ -52,6 +56,16 @@ public function products(): BelongsToMany ->withTimestamps(); } + public function group(): BelongsTo + { + return $this->belongsTo(self::class, 'group_value_id'); + } + + public function members() + { + return $this->hasMany(self::class, 'group_value_id'); + } + public function registerMediaCollections(): void { $this->addMediaCollection('images') @@ -71,6 +85,23 @@ protected static function booted(): void }); } + public function scopeSameProperty($query, int $propertyId) + { + return $query->where('property_id', $propertyId); + } + + /** + * Order values so that each group parent is followed by its members. + */ + public function scopeGroupedOrder($query) + { + return $query + ->orderByRaw('COALESCE(group_value_id, id)') + ->orderByRaw('CASE WHEN is_group THEN 0 ELSE 1 END') + ->orderBy('sort') + ->orderBy('value'); + } + /** * Ensure Filament receives scalar values for form hydration. * @@ -142,6 +173,24 @@ public function mergeInto(int $targetId): array throw new RuntimeException('Values must belong to the same property.'); } + if ($this->is_group && $target->group_value_id !== null) { + throw new \RuntimeException('Cannot merge a group into a value that is already a member of another group.'); + } + + if ($this->is_group && ! $target->is_group) { + $target->is_group = true; + $target->save(); + } + + if ($this->is_group && $target->group_value_id !== null) { + throw new \RuntimeException('Cannot merge a group into a value that is already a member of another group.'); + } + + if ($this->is_group && ! $target->is_group) { + $target->is_group = true; + $target->save(); + } + $pivotTable = 'pim_product_has_property_value'; $productIds = DB::table($pivotTable) @@ -172,4 +221,39 @@ public function mergeInto(int $targetId): array ]; }); } + + public function canBeGroupedInto(self $target): void + { + if ($this->id === $target->id) { + throw new \RuntimeException('Cannot group a value into itself.'); + } + if ($this->property_id !== $target->property_id) { + throw new \RuntimeException('Values must belong to the same property.'); + } + if ($target->group_value_id !== null) { + throw new \RuntimeException('Cannot group into a value that is already a member of another group.'); + } + } + + public function groupInto(int $targetId): void + { + DB::transaction(function () use ($targetId) { + $target = self::query()->lockForUpdate()->findOrFail($targetId); + $this->canBeGroupedInto($target); + + if (! $target->is_group) { + $target->is_group = true; + $target->save(); + } + + $this->group_value_id = $target->id; + $this->save(); + }); + } + + public function removeFromGroup(): void + { + $this->group_value_id = null; + $this->save(); + } } diff --git a/tests/Unit/PropertyValueGroupingTest.php b/tests/Unit/PropertyValueGroupingTest.php new file mode 100644 index 0000000..9edef70 --- /dev/null +++ b/tests/Unit/PropertyValueGroupingTest.php @@ -0,0 +1,91 @@ +create(); + $parent = PropertyValue::factory()->create(['property_id' => $property->id, 'is_group' => true]); + $member = PropertyValue::factory()->create(['property_id' => $property->id]); + $member->groupInto($parent->id); + + $other = PropertyValue::factory()->create(['property_id' => $property->id]); + + expect(fn () => $other->groupInto($member->id)) + ->toThrow(RuntimeException::class); // member cannot be target +}); + +it('cannot group a value into itself or across properties', function () { + $propA = Property::factory()->create(); + $propB = Property::factory()->create(); + $a = PropertyValue::factory()->create(['property_id' => $propA->id]); + $b = PropertyValue::factory()->create(['property_id' => $propB->id]); + + // self + expect(fn () => $a->groupInto($a->id)) + ->toThrow(RuntimeException::class); +}); + +it('prevents cross-property grouping', function () { + $propA = Property::factory()->create(); + $propB = Property::factory()->create(); + $a = PropertyValue::factory()->create(['property_id' => $propA->id]); + $b = PropertyValue::factory()->create(['property_id' => $propB->id]); + + expect(fn () => $a->groupInto($b->id)) + ->toThrow(RuntimeException::class); +}); + +it('grouping marks target as group and assigns group_value_id', function () { + $property = Property::factory()->create(); + $target = PropertyValue::factory()->create(['property_id' => $property->id]); + $one = PropertyValue::factory()->create(['property_id' => $property->id]); + $two = PropertyValue::factory()->create(['property_id' => $property->id]); + + $one->groupInto($target->id); + $two->groupInto($target->id); + + expect($target->fresh()->is_group)->toBeTrue() + ->and($one->fresh()->group_value_id)->toBe($target->id) + ->and($two->fresh()->group_value_id)->toBe($target->id); +}); + +it('re-grouping moves a member from old group to a new group', function () { + $property = Property::factory()->create(); + $groupA = PropertyValue::factory()->create(['property_id' => $property->id]); + $groupB = PropertyValue::factory()->create(['property_id' => $property->id]); + $member = PropertyValue::factory()->create(['property_id' => $property->id]); + + $member->groupInto($groupA->id); + expect($member->fresh()->group_value_id)->toBe($groupA->id); + + $member->groupInto($groupB->id); + expect($member->fresh()->group_value_id)->toBe($groupB->id) + ->and($groupB->fresh()->is_group)->toBeTrue(); +}); + +it('removeFromGroup clears group_value_id', function () { + $property = Property::factory()->create(); + $parent = PropertyValue::factory()->create(['property_id' => $property->id]); + $member = PropertyValue::factory()->create(['property_id' => $property->id]); + $member->groupInto($parent->id); + $member->removeFromGroup(); + + expect($member->fresh()->group_value_id)->toBeNull(); +}); + +it('groupedOrder scope clusters parent and children without partial updates on error', function () { + $property = Property::factory()->create(); + $a = PropertyValue::factory()->create(['property_id' => $property->id, 'value' => 'A']); + $b1 = PropertyValue::factory()->create(['property_id' => $property->id, 'value' => 'B1']); + $b2 = PropertyValue::factory()->create(['property_id' => $property->id, 'value' => 'B2']); + + // Group B members under A + $b1->groupInto($a->id); + $b2->groupInto($a->id); + + $ordered = PropertyValue::query()->sameProperty($property->id)->groupedOrder()->pluck('id')->all(); + // Expect A first, then its members in any order + expect($ordered[0])->toBe($a->id) + ->and(collect([$b1->id, $b2->id])->diff($ordered))->toBeEmpty(); +});