Skip to content

Commit 8aa687e

Browse files
authored
feat(products): implement bulk editing (CT-75)
* feat: implement product bulk editing * chore: add prices, statuses, tests * chore: fix tests * chore: improve bulk updating UI * chore: layout & UX improvements
1 parent 3d1b490 commit 8aa687e

6 files changed

Lines changed: 696 additions & 54 deletions

File tree

resources/lang/en/common.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
return [
4+
'no_change' => '-- no change --',
5+
'yes' => 'Yes',
6+
'no' => 'No',
7+
];

resources/lang/sl/common.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
return [
4+
'no_change' => '-- brez spremembe --',
5+
'yes' => 'Da',
6+
'no' => 'Ne',
7+
];

src/Filament/Resources/ProductResource.php

Lines changed: 2 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Eclipse\Catalogue\Filament\Filters\CustomPropertyConstraint;
88
use Eclipse\Catalogue\Filament\Forms\Components\ImageManager;
99
use Eclipse\Catalogue\Filament\Resources\ProductResource\Pages;
10+
use Eclipse\Catalogue\Filament\Tables\Actions\BulkUpdateProductsAction;
1011
use Eclipse\Catalogue\Forms\Components\GenericTenantFieldsComponent;
1112
use Eclipse\Catalogue\Forms\Components\InlineTranslatableField;
1213
use Eclipse\Catalogue\Models\Category;
@@ -30,11 +31,9 @@
3031
use Filament\Forms\Components\View as ViewComponent;
3132
use Filament\Forms\Form;
3233
use Filament\Forms\Get;
33-
use Filament\Notifications\Notification;
3434
use Filament\Resources\Concerns\Translatable;
3535
use Filament\Resources\Resource;
3636
use Filament\Tables\Actions\ActionGroup;
37-
use Filament\Tables\Actions\BulkAction;
3837
use Filament\Tables\Actions\BulkActionGroup;
3938
use Filament\Tables\Actions\DeleteAction;
4039
use Filament\Tables\Actions\DeleteBulkAction;
@@ -910,58 +909,7 @@ public static function table(Table $table): Table
910909
])
911910
->bulkActions([
912911
BulkActionGroup::make([
913-
BulkAction::make('add_to_group')
914-
->label('Add to Group')
915-
->icon('heroicon-o-plus')
916-
->form([
917-
Select::make('group_id')
918-
->label('Group')
919-
->options(fn () => Group::query()->active()->forCurrentTenant()->pluck('name', 'id')->toArray())
920-
->required()
921-
->searchable(),
922-
])
923-
->action(function (array $data, $records) {
924-
$group = Group::find($data['group_id']);
925-
$addedCount = 0;
926-
927-
foreach ($records as $product) {
928-
if (! $group->hasProduct($product)) {
929-
$group->addProduct($product);
930-
$addedCount++;
931-
}
932-
}
933-
934-
Notification::make()
935-
->title("Added {$addedCount} products to group \"{$group->name}\"")
936-
->success()
937-
->send();
938-
}),
939-
BulkAction::make('remove_from_group')
940-
->label('Remove from Group')
941-
->icon('heroicon-o-minus')
942-
->form([
943-
Select::make('group_id')
944-
->label('Group')
945-
->options(fn () => Group::query()->active()->forCurrentTenant()->pluck('name', 'id')->toArray())
946-
->required()
947-
->searchable(),
948-
])
949-
->action(function (array $data, $records) {
950-
$group = Group::find($data['group_id']);
951-
$removedCount = 0;
952-
953-
foreach ($records as $product) {
954-
if ($group->hasProduct($product)) {
955-
$group->removeProduct($product);
956-
$removedCount++;
957-
}
958-
}
959-
960-
Notification::make()
961-
->title("Removed {$removedCount} products from group \"{$group->name}\"")
962-
->success()
963-
->send();
964-
}),
912+
BulkUpdateProductsAction::make(),
965913
DeleteBulkAction::make(),
966914
RestoreBulkAction::make(),
967915
ForceDeleteBulkAction::make(),
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
<?php
2+
3+
namespace Eclipse\Catalogue\Filament\Tables\Actions;
4+
5+
use Eclipse\Catalogue\Models\Category;
6+
use Eclipse\Catalogue\Models\Group;
7+
use Eclipse\Catalogue\Models\PriceList;
8+
use Eclipse\Catalogue\Models\ProductStatus;
9+
use Eclipse\Catalogue\Models\ProductType;
10+
use Eclipse\Catalogue\Services\ProductBulkUpdater;
11+
use Filament\Forms\Components\Checkbox;
12+
use Filament\Forms\Components\DatePicker;
13+
use Filament\Forms\Components\FileUpload;
14+
use Filament\Forms\Components\Section;
15+
use Filament\Forms\Components\Select;
16+
use Filament\Forms\Components\TextInput;
17+
use Filament\Notifications\Notification;
18+
use Filament\Tables\Actions\BulkAction;
19+
use Illuminate\Support\Facades\App;
20+
21+
class BulkUpdateProductsAction extends BulkAction
22+
{
23+
/**
24+
* Make the bulk update action.
25+
*
26+
* @param string|null $name The name of the action.
27+
* @return static The bulk update action.
28+
*/
29+
public static function make(?string $name = null): static
30+
{
31+
$action = parent::make($name ?? 'bulk_update')
32+
->label('Edit')
33+
->icon('heroicon-o-wrench')
34+
->modalHeading('Bulk edit')
35+
->deselectRecordsAfterCompletion()
36+
->form([
37+
Select::make('product_status_id')
38+
->label(__('eclipse-catalogue::product-status.singular'))
39+
->options(function () {
40+
$query = ProductStatus::query();
41+
$tenantFK = config('eclipse-catalogue.tenancy.foreign_key');
42+
$currentTenant = \Filament\Facades\Filament::getTenant();
43+
44+
if ($tenantFK && $currentTenant) {
45+
$query->where($tenantFK, $currentTenant->id);
46+
}
47+
48+
$noChange = __('eclipse-catalogue::common.no_change') ?: '-- no change --';
49+
$options = $query->orderBy('priority')->get()->mapWithKeys(function ($status) {
50+
$title = is_array($status->title)
51+
? ($status->title[app()->getLocale()] ?? reset($status->title))
52+
: $status->title;
53+
54+
return [$status->id => $title];
55+
})->toArray();
56+
57+
return ['__no_change__' => $noChange] + $options;
58+
})
59+
->searchable()
60+
->default('__no_change__'),
61+
Select::make('product_type_id')
62+
->label('Product Type')
63+
->options(function () {
64+
$tenantFK = config('eclipse-catalogue.tenancy.foreign_key');
65+
$currentTenant = \Filament\Facades\Filament::getTenant();
66+
67+
$query = ProductType::query();
68+
69+
if ($tenantFK && $currentTenant) {
70+
$query->whereHas('productTypeData', function ($q) use ($tenantFK, $currentTenant) {
71+
$q->where($tenantFK, $currentTenant->id)
72+
->where('is_active', true);
73+
});
74+
} else {
75+
$query->whereHas('productTypeData', function ($q) {
76+
$q->where('is_active', true);
77+
});
78+
}
79+
80+
$noChange = __('eclipse-catalogue::common.no_change') ?: '-- no change --';
81+
$options = $query->pluck('name', 'id')->toArray();
82+
83+
return ['__no_change__' => $noChange] + $options;
84+
})
85+
->searchable()
86+
->default('__no_change__'),
87+
Select::make('free_delivery')
88+
->label(__('eclipse-catalogue::product.fields.has_free_delivery'))
89+
->options(function () {
90+
$noChange = __('eclipse-catalogue::common.no_change') ?: '-- no change --';
91+
$yes = __('eclipse-catalogue::common.yes') ?: 'Yes';
92+
$no = __('eclipse-catalogue::common.no') ?: 'No';
93+
94+
return [
95+
'__no_change__' => $noChange,
96+
'1' => $yes,
97+
'0' => $no,
98+
];
99+
})
100+
->selectablePlaceholder(false)
101+
->default('__no_change__'),
102+
Select::make('category_id')
103+
->label('Category')
104+
->options(function () {
105+
$tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id');
106+
$currentTenant = \Filament\Facades\Filament::getTenant();
107+
$query = Category::query()->withoutGlobalScopes();
108+
if ($tenantFK && $currentTenant) {
109+
$query->where($tenantFK, $currentTenant->id);
110+
}
111+
112+
$noChange = __('eclipse-catalogue::common.no_change') ?: '-- no change --';
113+
$options = $query->orderBy('name')->pluck('name', 'id')->toArray();
114+
115+
return ['__no_change__' => $noChange] + $options;
116+
})
117+
->searchable()
118+
->default('__no_change__'),
119+
Section::make('Groups')
120+
->collapsible()
121+
->collapsed()
122+
->schema([
123+
\Filament\Forms\Components\Grid::make(2)
124+
->schema([
125+
Select::make('groups_add_ids')
126+
->label('Add to groups')
127+
->multiple()
128+
->options(fn () => Group::query()->forCurrentTenant()->pluck('name', 'id')->toArray())
129+
->searchable(),
130+
Select::make('groups_remove_ids')
131+
->label('Remove from groups')
132+
->multiple()
133+
->options(fn () => Group::query()->forCurrentTenant()->pluck('name', 'id')->toArray())
134+
->searchable(),
135+
]),
136+
]),
137+
Section::make('Prices')
138+
->collapsible()
139+
->collapsed()
140+
->schema([
141+
Select::make('price_list_id')
142+
->label(__('eclipse-catalogue::product.price.fields.price_list'))
143+
->options(function () {
144+
$tenantFK = config('eclipse-catalogue.tenancy.foreign_key');
145+
$currentTenant = \Filament\Facades\Filament::getTenant();
146+
147+
$query = PriceList::query();
148+
if ($tenantFK && $currentTenant) {
149+
$query->whereHas('priceListData', function ($q) use ($tenantFK, $currentTenant) {
150+
$q->where($tenantFK, $currentTenant->id)
151+
->where('is_active', true);
152+
});
153+
} else {
154+
$query->whereHas('priceListData', function ($q) {
155+
$q->where('is_active', true);
156+
});
157+
}
158+
159+
return $query->orderBy('name')->pluck('name', 'id')->toArray();
160+
})
161+
->searchable()
162+
->preload()
163+
->live()
164+
->afterStateUpdated(function ($state, \Filament\Forms\Set $set) {
165+
if (! $state) {
166+
return;
167+
}
168+
$pl = PriceList::query()->select('id', 'tax_included')->find($state);
169+
if ($pl) {
170+
$set('tax_included', (bool) $pl->tax_included);
171+
}
172+
})
173+
->columnSpanFull(),
174+
175+
\Filament\Forms\Components\Grid::make(2)
176+
->schema([
177+
\Filament\Forms\Components\Group::make()
178+
->schema([
179+
TextInput::make('price')
180+
->label(__('eclipse-catalogue::product.price.fields.price'))
181+
->numeric()
182+
->rule('decimal:0,5'),
183+
Checkbox::make('tax_included')
184+
->label(__('eclipse-catalogue::product.price.fields.tax_included'))
185+
->inline(false)
186+
->default(false),
187+
]),
188+
\Filament\Forms\Components\Group::make()
189+
->schema([
190+
DatePicker::make('valid_from')
191+
->label(__('eclipse-catalogue::product.price.fields.valid_from'))
192+
->native(false)
193+
->default(fn () => now()),
194+
DatePicker::make('valid_to')
195+
->label(__('eclipse-catalogue::product.price.fields.valid_to'))
196+
->native(false)
197+
->nullable(),
198+
]),
199+
]),
200+
]),
201+
Section::make('Images')
202+
->collapsible()
203+
->collapsed()
204+
->schema([
205+
\Filament\Forms\Components\Grid::make(2)
206+
->schema([
207+
\Filament\Forms\Components\Group::make()
208+
->schema([
209+
FileUpload::make('cover_image')
210+
->label('Cover image')
211+
->image()
212+
->imageEditor(false)
213+
->directory('temp-images')
214+
->visibility('public')
215+
->storeFiles(true)
216+
->preserveFilenames()
217+
->nullable(),
218+
FileUpload::make('image_2')
219+
->label('Image #2')
220+
->image()
221+
->imageEditor(false)
222+
->directory('temp-images')
223+
->visibility('public')
224+
->storeFiles(true)
225+
->preserveFilenames()
226+
->nullable(),
227+
]),
228+
\Filament\Forms\Components\Group::make()
229+
->schema([
230+
FileUpload::make('image_1')
231+
->label('Image #1')
232+
->image()
233+
->imageEditor(false)
234+
->directory('temp-images')
235+
->visibility('public')
236+
->storeFiles(true)
237+
->preserveFilenames()
238+
->nullable(),
239+
FileUpload::make('image_3')
240+
->label('Image #3')
241+
->image()
242+
->imageEditor(false)
243+
->directory('temp-images')
244+
->visibility('public')
245+
->storeFiles(true)
246+
->preserveFilenames()
247+
->nullable(),
248+
]),
249+
]),
250+
]),
251+
])
252+
->action(function (array $data, $records) {
253+
/** @var ProductBulkUpdater $updater */
254+
$updater = App::make(ProductBulkUpdater::class);
255+
$result = $updater->apply($data, $records);
256+
257+
$notification = Notification::make();
258+
if (($result['successCount'] ?? 0) > 0) {
259+
$title = $result['successCount'] === 1
260+
? 'Updated 1 product'
261+
: "Updated {$result['successCount']} products";
262+
$notification->title($title)->success();
263+
} else {
264+
$notification->title('No changes applied')->warning();
265+
}
266+
267+
if (! empty($result['errors'] ?? [])) {
268+
$notification->body('Some updates failed: '.implode(' | ', array_unique($result['errors'])));
269+
}
270+
271+
$notification->send();
272+
});
273+
274+
return $action;
275+
}
276+
}

0 commit comments

Comments
 (0)