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();
+});