Skip to content

Commit 0c91cf6

Browse files
authored
feat: implement property value merging (CT-72)
1 parent 4ae2ef5 commit 0c91cf6

5 files changed

Lines changed: 218 additions & 4 deletions

File tree

resources/lang/en/property-value.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,31 @@
4242
'actions' => [
4343
'edit' => 'Edit',
4444
'delete' => 'Delete',
45+
'merge' => 'Merge…',
4546
],
4647
],
4748

4849
'modal' => [
4950
'create_heading' => 'Create Property Value',
5051
'edit_heading' => 'Edit Property Value',
52+
'merge_heading' => 'Merge Value',
53+
'merge_from_label' => 'Merge value…',
54+
'merge_to_label' => 'With value…*',
55+
'merge_helper' => 'This will replace the value on all products with the selected one above, then delete the current value.',
56+
'merge_submit_label' => 'Merge',
57+
'cancel_label' => 'Cancel',
58+
'merge_confirm_title' => 'Are you sure you want to merge?',
59+
'merge_confirm_body' => 'All products using the current value will be updated to the selected value. This action cannot be undone.',
5160
],
5261

5362
'messages' => [
5463
'created' => 'Property value created successfully.',
5564
'updated' => 'Property value updated successfully.',
5665
'deleted' => 'Property value deleted successfully.',
66+
'merged_title' => 'Values merged',
67+
'merged_body' => ':affected product(s) updated. The selected value was kept and the other was removed.',
68+
'merged_error_title' => 'Merge failed',
69+
'merged_error_body' => 'We couldn’t merge these values. Please try again.',
5770
],
5871

5972
'pages' => [

resources/lang/sl/property-value.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,31 @@
4242
'actions' => [
4343
'edit' => 'Uredi',
4444
'delete' => 'Izbriši',
45+
'merge' => 'Združi…',
4546
],
4647
],
4748

4849
'modal' => [
4950
'create_heading' => 'Ustvari vrednost lastnosti',
5051
'edit_heading' => 'Uredi vrednost lastnosti',
52+
'merge_heading' => 'Združi vrednost',
53+
'merge_from_label' => 'Združi vrednost…',
54+
'merge_to_label' => 'Z vrednostjo…*',
55+
'merge_helper' => 'To dejanje bo vsem izdelkom zamenjalo trenutno lastnost s to, označeno zgoraj. Nato bo trenutna lastnost izbrisana.',
56+
'merge_submit_label' => 'Združi',
57+
'cancel_label' => 'Prekliči',
58+
'merge_confirm_title' => 'Ste prepričani, da želite združiti?',
59+
'merge_confirm_body' => 'Vsi izdelki s trenutno vrednostjo bodo posodobljeni na izbrano vrednost. Dejanja ni mogoče razveljaviti.',
5160
],
5261

5362
'messages' => [
5463
'created' => 'Vrednost lastnosti je bila uspešno ustvarjena.',
5564
'updated' => 'Vrednost lastnosti je bila uspešno posodobljena.',
5665
'deleted' => 'Vrednost lastnosti je bila uspešno izbrisana.',
66+
'merged_title' => 'Vrednosti so združene',
67+
'merged_body' => 'Posodobili smo :affected izdelkov. Izbrana vrednost je ostala, druga je bila odstranjena.',
68+
'merged_error_title' => 'Združevanje ni uspelo',
69+
'merged_error_body' => 'Vrednosti trenutno ni mogoče združiti. Poskusite znova.',
5770
],
5871

5972
'pages' => [

src/Filament/Resources/PropertyValueResource.php

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Eclipse\Catalogue\Models\PropertyValue;
77
use Filament\Forms;
88
use Filament\Forms\Form;
9+
use Filament\Notifications\Notification;
910
use Filament\Resources\Concerns\Translatable;
1011
use Filament\Resources\Resource;
1112
use Filament\Tables;
@@ -109,10 +110,62 @@ public static function table(Table $table): Table
109110
->default(fn () => request('property')),
110111
])
111112
->actions([
112-
Tables\Actions\EditAction::make()
113-
->modalWidth('lg')
114-
->modalHeading(__('eclipse-catalogue::property-value.modal.edit_heading')),
115-
Tables\Actions\DeleteAction::make(),
113+
Tables\Actions\ActionGroup::make([
114+
Tables\Actions\EditAction::make()
115+
->modalWidth('lg')
116+
->modalHeading(__('eclipse-catalogue::property-value.modal.edit_heading')),
117+
Tables\Actions\Action::make('merge')
118+
->label(__('eclipse-catalogue::property-value.table.actions.merge'))
119+
->icon('heroicon-o-arrow-uturn-right')
120+
->modalHeading(__('eclipse-catalogue::property-value.modal.merge_heading'))
121+
->form(function (PropertyValue $record) {
122+
return [
123+
\Filament\Forms\Components\Placeholder::make('current_value')
124+
->label(__('eclipse-catalogue::property-value.modal.merge_from_label'))
125+
->content($record->value),
126+
\Filament\Forms\Components\Select::make('target_id')
127+
->label(__('eclipse-catalogue::property-value.modal.merge_to_label'))
128+
->required()
129+
->options(
130+
PropertyValue::query()
131+
->where('property_id', $record->property_id)
132+
->whereKeyNot($record->id)
133+
->orderBy('value')
134+
->pluck('value', 'id')
135+
),
136+
\Filament\Forms\Components\Placeholder::make('merge_helper')
137+
->label('')
138+
->content(__('eclipse-catalogue::property-value.modal.merge_helper'))
139+
->columnSpanFull(),
140+
];
141+
})
142+
->modalSubmitActionLabel(__('eclipse-catalogue::property-value.modal.merge_submit_label'))
143+
->modalCancelActionLabel(__('eclipse-catalogue::property-value.modal.cancel_label'))
144+
->requiresConfirmation()
145+
->modalIcon('heroicon-o-question-mark-circle')
146+
->modalHeading(__('eclipse-catalogue::property-value.modal.merge_confirm_title'))
147+
->modalDescription(__('eclipse-catalogue::property-value.modal.merge_confirm_body'))
148+
->action(function (PropertyValue $record, array $data) {
149+
try {
150+
$result = $record->mergeInto((int) $data['target_id']);
151+
152+
Notification::make()
153+
->title(__('eclipse-catalogue::property-value.messages.merged_title'))
154+
->body(__('eclipse-catalogue::property-value.messages.merged_body', ['affected' => $result['affected_products']]))
155+
->success()
156+
->send();
157+
} catch (\Throwable $e) {
158+
\Log::error('Merge property values failed', ['exception' => $e]);
159+
Notification::make()
160+
->title(__('eclipse-catalogue::property-value.messages.merged_error_title'))
161+
->body(__('eclipse-catalogue::property-value.messages.merged_error_body'))
162+
->danger()
163+
->send();
164+
throw $e;
165+
}
166+
}),
167+
Tables\Actions\DeleteAction::make(),
168+
]),
116169
])
117170
->bulkActions([
118171
Tables\Actions\BulkActionGroup::make([

src/Models/PropertyValue.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Illuminate\Database\Eloquent\Model;
88
use Illuminate\Database\Eloquent\Relations\BelongsTo;
99
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
10+
use Illuminate\Support\Facades\DB;
1011
use Spatie\MediaLibrary\HasMedia;
1112
use Spatie\MediaLibrary\InteractsWithMedia;
1213
use Spatie\Translatable\HasTranslations;
@@ -83,4 +84,57 @@ public function attributesToArray(): array
8384

8485
return $attributes;
8586
}
87+
88+
/**
89+
* Merge the current value (source) into a target value.
90+
*
91+
* All product references of the source will be reassigned to the target
92+
* value. Any duplicate pivot rows that would violate the unique constraint
93+
* will be removed first. Finally, the source record is deleted.
94+
*
95+
* @return array{relinked:int,removed_duplicates:int,affected_products:int,deleted:int}
96+
*/
97+
public function mergeInto(int $targetId): array
98+
{
99+
return DB::transaction(function () use ($targetId) {
100+
$target = self::query()->lockForUpdate()->findOrFail($targetId);
101+
102+
if ($target->id === $this->id) {
103+
throw new \RuntimeException('Cannot merge a value into itself.');
104+
}
105+
106+
if ($target->property_id !== $this->property_id) {
107+
throw new \RuntimeException('Values must belong to the same property.');
108+
}
109+
110+
$pivotTable = 'catalogue_product_has_property_value';
111+
112+
$productIds = DB::table($pivotTable)
113+
->where('property_value_id', $this->id)
114+
->pluck('product_id');
115+
116+
$affectedProducts = $productIds->unique()->count();
117+
118+
$removedDuplicates = 0;
119+
if ($productIds->isNotEmpty()) {
120+
$removedDuplicates = DB::table($pivotTable)
121+
->where('property_value_id', $target->id)
122+
->whereIn('product_id', $productIds)
123+
->delete();
124+
}
125+
126+
$relinked = DB::table($pivotTable)
127+
->where('property_value_id', $this->id)
128+
->update(['property_value_id' => $target->id]);
129+
130+
$this->delete();
131+
132+
return [
133+
'relinked' => (int) $relinked,
134+
'removed_duplicates' => (int) $removedDuplicates,
135+
'affected_products' => (int) $affectedProducts,
136+
'deleted' => 1,
137+
];
138+
});
139+
}
86140
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
use Eclipse\Catalogue\Models\Product;
4+
use Eclipse\Catalogue\Models\Property;
5+
use Eclipse\Catalogue\Models\PropertyValue;
6+
7+
it('merges values by moving product references and deleting source', function () {
8+
$property = Property::factory()->create();
9+
$source = PropertyValue::factory()->create(['property_id' => $property->id, 'value' => 'Old']);
10+
$target = PropertyValue::factory()->create(['property_id' => $property->id, 'value' => 'New']);
11+
12+
// Create products linked to source (and one already linked to target)
13+
$productA = Product::factory()->create();
14+
$productB = Product::factory()->create();
15+
$productC = Product::factory()->create();
16+
17+
$productA->propertyValues()->attach($source->id);
18+
$productB->propertyValues()->attach($source->id);
19+
$productC->propertyValues()->attach($target->id); // should remain
20+
21+
// Also add duplicate A to target to ensure duplicate cleanup works
22+
$productA->propertyValues()->attach($target->id);
23+
24+
$result = $source->mergeInto($target->id);
25+
26+
expect($result['deleted'])->toBe(1)
27+
->and($result['relinked'])->toBeGreaterThanOrEqual(2)
28+
->and(PropertyValue::query()->whereKey($source->id)->doesntExist())->toBeTrue();
29+
30+
// All products should now reference only the target
31+
expect($productA->propertyValues()->pluck('property_value_id')->all())
32+
->toEqual([$target->id]);
33+
expect($productB->propertyValues()->pluck('property_value_id')->all())
34+
->toEqual([$target->id]);
35+
expect($productC->propertyValues()->pluck('property_value_id')->all())
36+
->toEqual([$target->id]);
37+
});
38+
39+
it('does not leave duplicate pivot rows after merge', function () {
40+
$property = Property::factory()->create();
41+
$source = PropertyValue::factory()->create(['property_id' => $property->id]);
42+
$target = PropertyValue::factory()->create(['property_id' => $property->id]);
43+
$product = Product::factory()->create();
44+
45+
// Link product to both source and target
46+
$product->propertyValues()->attach($source->id);
47+
$product->propertyValues()->attach($target->id);
48+
49+
$source->mergeInto($target->id);
50+
51+
$count = DB::table('catalogue_product_has_property_value')
52+
->where('product_id', $product->id)
53+
->where('property_value_id', $target->id)
54+
->count();
55+
56+
expect($count)->toBe(1);
57+
});
58+
59+
it('rolls back merge when values belong to different properties', function () {
60+
$prop1 = Property::factory()->create();
61+
$prop2 = Property::factory()->create();
62+
$source = PropertyValue::factory()->create(['property_id' => $prop1->id]);
63+
$target = PropertyValue::factory()->create(['property_id' => $prop2->id]);
64+
$product = Product::factory()->create();
65+
$product->propertyValues()->attach($source->id);
66+
67+
try {
68+
$source->mergeInto($target->id);
69+
test()->fail('Expected exception not thrown');
70+
} catch (Throwable $e) {
71+
// ok
72+
}
73+
74+
// Ensure source still exists and link remains
75+
expect(PropertyValue::query()->whereKey($source->id)->exists())->toBeTrue();
76+
$links = DB::table('catalogue_product_has_property_value')
77+
->where('product_id', $product->id)
78+
->where('property_value_id', $source->id)
79+
->count();
80+
expect($links)->toBe(1);
81+
});

0 commit comments

Comments
 (0)